mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(api): end premium at the real cancellation time and unstick pix refunds (#1985)
This commit is contained in:
1 parent
c226eb7211
commit
3445b94af3
5 files changed
+111
-4
No files matched your search
@@ -25,6 +25,7 @@ import type {StripeSubscriptionService} from './StripeSubscriptionService';
|
|||||||
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
export const SELF_SERVE_REFUND_WINDOW_DAYS = 3;
|
export const SELF_SERVE_REFUND_WINDOW_DAYS = 3;
|
||||||
export const SELF_SERVE_REFUND_COOLDOWN_DAYS = 30;
|
export const SELF_SERVE_REFUND_COOLDOWN_DAYS = 30;
|
||||||
|
export const PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS = 1;
|
||||||
|
|
||||||
interface RefundTarget {
|
interface RefundTarget {
|
||||||
invoice: Stripe.Invoice;
|
invoice: Stripe.Invoice;
|
||||||
@@ -206,6 +207,30 @@ export class StripeRefundService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveChargePaymentMethodType(stripe: Stripe, target: RefundTarget): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
if (target.chargeId) {
|
||||||
|
const charge = await stripe.charges.retrieve(target.chargeId);
|
||||||
|
return charge.payment_method_details?.type ?? null;
|
||||||
|
}
|
||||||
|
if (target.paymentIntentId) {
|
||||||
|
const paymentIntent = await stripe.paymentIntents.retrieve(target.paymentIntentId, {
|
||||||
|
expand: ['latest_charge'],
|
||||||
|
});
|
||||||
|
const latestCharge = paymentIntent.latest_charge;
|
||||||
|
if (latestCharge && typeof latestCharge !== 'string') {
|
||||||
|
return latestCharge.payment_method_details?.type ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(
|
||||||
|
{error, chargeId: target.chargeId, paymentIntentId: target.paymentIntentId},
|
||||||
|
'Failed to resolve payment method type for self-serve refund',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
|
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
|
||||||
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
|
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
|
||||||
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
|
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
|
||||||
@@ -273,12 +298,18 @@ export class StripeRefundService {
|
|||||||
target.paymentIntentId ?? target.chargeId,
|
target.paymentIntentId ?? target.chargeId,
|
||||||
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
|
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
|
||||||
].join(':');
|
].join(':');
|
||||||
|
const paymentMethodType = await this.resolveChargePaymentMethodType(stripe, target);
|
||||||
|
const isPix = paymentMethodType === 'pix';
|
||||||
|
const requestedAmountCents =
|
||||||
|
isPix && target.amountPaidCents > PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS
|
||||||
|
? target.amountPaidCents - PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS
|
||||||
|
: target.amountPaidCents;
|
||||||
let refund: Stripe.Response<Stripe.Refund>;
|
let refund: Stripe.Response<Stripe.Refund>;
|
||||||
try {
|
try {
|
||||||
refund = await stripe.refunds.create(
|
refund = await stripe.refunds.create(
|
||||||
{
|
{
|
||||||
...(target.paymentIntentId ? {payment_intent: target.paymentIntentId} : {charge: target.chargeId!}),
|
...(target.paymentIntentId ? {payment_intent: target.paymentIntentId} : {charge: target.chargeId!}),
|
||||||
amount: target.amountPaidCents,
|
amount: requestedAmountCents,
|
||||||
reason: 'requested_by_customer',
|
reason: 'requested_by_customer',
|
||||||
metadata: {
|
metadata: {
|
||||||
user_id: user.id.toString(),
|
user_id: user.id.toString(),
|
||||||
|
|||||||
@@ -589,8 +589,15 @@ export class StripeSubscriptionWebhookHandler {
|
|||||||
premium_billing_cycle: null,
|
premium_billing_cycle: null,
|
||||||
};
|
};
|
||||||
if (targetUser.premiumType !== UserPremiumTypes.LIFETIME) {
|
if (targetUser.premiumType !== UserPremiumTypes.LIFETIME) {
|
||||||
const anchorMs = Math.max(Date.now(), targetUser.premiumUntil?.getTime() ?? 0);
|
const subscriptionEndedAt = subscription.ended_at ? new Date(subscription.ended_at * 1000) : new Date();
|
||||||
updates.premium_grace_ends_at = new Date(anchorMs + PREMIUM_GRACE_PERIOD_MS);
|
const cancelledBeforePeriodEnd =
|
||||||
|
targetUser.premiumUntil != null && subscriptionEndedAt.getTime() < targetUser.premiumUntil.getTime();
|
||||||
|
if (cancelledBeforePeriodEnd) {
|
||||||
|
updates.premium_until = subscriptionEndedAt;
|
||||||
|
updates.premium_grace_ends_at = null;
|
||||||
|
} else {
|
||||||
|
updates.premium_grace_ends_at = new Date(subscriptionEndedAt.getTime() + PREMIUM_GRACE_PERIOD_MS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const updatedUser = await this.userRepository.patchUpsert(targetUser.id, updates, targetUser.toRow());
|
const updatedUser = await this.userRepository.patchUpsert(targetUser.id, updates, targetUser.toRow());
|
||||||
await this.dispatchUser(updatedUser);
|
await this.dispatchUser(updatedUser);
|
||||||
|
|||||||
@@ -238,6 +238,22 @@ describe('StripeRefundService self-serve refund', () => {
|
|||||||
expect(response.refunded_amount_cents).toBe(2500);
|
expect(response.refunded_amount_cents).toBe(2500);
|
||||||
expect(response.subscription_id).toBeNull();
|
expect(response.subscription_id).toBeNull();
|
||||||
});
|
});
|
||||||
|
test('requests one cent under the captured amount when the underlying charge was paid via PIX', async () => {
|
||||||
|
server.use(
|
||||||
|
...createStripeApiHandlers({charges: {ch_in_recent: {payment_method_details: {type: 'pix'}}}}).handlers,
|
||||||
|
);
|
||||||
|
server.use(
|
||||||
|
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY, subscriptionId: null})]),
|
||||||
|
refundCreateHandler(),
|
||||||
|
);
|
||||||
|
const account = await createTestAccount(harness);
|
||||||
|
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||||
|
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||||
|
.post('/premium/refund-latest')
|
||||||
|
.execute();
|
||||||
|
expect(response.invoice_amount_paid_cents).toBe(2500);
|
||||||
|
expect(response.refunded_amount_cents).toBe(2499);
|
||||||
|
});
|
||||||
test('refunds latest invoice and cancels subscription when present', async () => {
|
test('refunds latest invoice and cancels subscription when present', async () => {
|
||||||
server.use(...createStripeApiHandlers().handlers);
|
server.use(...createStripeApiHandlers().handlers);
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -474,6 +474,56 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
|
|||||||
expect(afterUser?.premiumType).toBe(UserPremiumTypes.LIFETIME);
|
expect(afterUser?.premiumType).toBe(UserPremiumTypes.LIFETIME);
|
||||||
expect(afterUser?.stripeSubscriptionId).toBeNull();
|
expect(afterUser?.stripeSubscriptionId).toBeNull();
|
||||||
});
|
});
|
||||||
|
test('cuts premium off at the cancellation moment when the subscription is cancelled before its paid period ends', async () => {
|
||||||
|
const account = await createTestAccount(harness);
|
||||||
|
const userId = createUserID(BigInt(account.userId));
|
||||||
|
const subscriptionId = 'sub_test_cancel_early';
|
||||||
|
const premiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
await userRepository.patchUpsert(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
premium_type: UserPremiumTypes.SUBSCRIPTION,
|
||||||
|
stripe_subscription_id: subscriptionId,
|
||||||
|
stripe_customer_id: 'cus_test_1',
|
||||||
|
premium_since: new Date(),
|
||||||
|
premium_until: premiumUntil,
|
||||||
|
},
|
||||||
|
(await userRepository.findUnique(userId))!.toRow(),
|
||||||
|
);
|
||||||
|
const endedAt = Math.floor(Date.now() / 1000);
|
||||||
|
const eventData = createSubscriptionDeletedEvent({subscriptionId, endedAt});
|
||||||
|
const result = await sendWebhook(eventData);
|
||||||
|
expect(result.received).toBe(true);
|
||||||
|
const afterUser = await userRepository.findUnique(userId);
|
||||||
|
expect(afterUser?.premiumUntil?.getTime()).toBe(endedAt * 1000);
|
||||||
|
expect(afterUser?.premiumGraceEndsAt).toBeNull();
|
||||||
|
expect(afterUser?.stripeSubscriptionId).toBeNull();
|
||||||
|
});
|
||||||
|
test('grants standard grace when the subscription is cancelled at the end of its paid period', async () => {
|
||||||
|
const account = await createTestAccount(harness);
|
||||||
|
const userId = createUserID(BigInt(account.userId));
|
||||||
|
const subscriptionId = 'sub_test_cancel_natural';
|
||||||
|
const premiumUntil = new Date(Date.now() - 60 * 1000);
|
||||||
|
await userRepository.patchUpsert(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
premium_type: UserPremiumTypes.SUBSCRIPTION,
|
||||||
|
stripe_subscription_id: subscriptionId,
|
||||||
|
stripe_customer_id: 'cus_test_1',
|
||||||
|
premium_since: new Date(),
|
||||||
|
premium_until: premiumUntil,
|
||||||
|
},
|
||||||
|
(await userRepository.findUnique(userId))!.toRow(),
|
||||||
|
);
|
||||||
|
const endedAt = Math.floor(Date.now() / 1000);
|
||||||
|
const eventData = createSubscriptionDeletedEvent({subscriptionId, endedAt});
|
||||||
|
const result = await sendWebhook(eventData);
|
||||||
|
expect(result.received).toBe(true);
|
||||||
|
const afterUser = await userRepository.findUnique(userId);
|
||||||
|
expect(afterUser?.premiumUntil?.getTime()).toBe(premiumUntil.getTime());
|
||||||
|
expect(afterUser?.premiumGraceEndsAt).not.toBeNull();
|
||||||
|
expect(afterUser!.premiumGraceEndsAt!.getTime()).toBe(endedAt * 1000 + 3 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
test('processes donation subscription deletion', async () => {
|
test('processes donation subscription deletion', async () => {
|
||||||
const subscriptionId = 'sub_donor_delete_test';
|
const subscriptionId = 'sub_donor_delete_test';
|
||||||
const donorEmail = '[email protected]';
|
const donorEmail = '[email protected]';
|
||||||
|
|||||||
@@ -1855,7 +1855,9 @@ export function createSubscriptionUpdatedEvent(options: {
|
|||||||
export function createSubscriptionDeletedEvent(options: {
|
export function createSubscriptionDeletedEvent(options: {
|
||||||
subscriptionId?: string;
|
subscriptionId?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
endedAt?: number;
|
||||||
}): StripeWebhookEventData {
|
}): StripeWebhookEventData {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
return {
|
return {
|
||||||
type: 'customer.subscription.deleted',
|
type: 'customer.subscription.deleted',
|
||||||
data: {
|
data: {
|
||||||
@@ -1864,7 +1866,8 @@ export function createSubscriptionDeletedEvent(options: {
|
|||||||
object: 'subscription',
|
object: 'subscription',
|
||||||
customer: options.customerId ?? 'cus_test_1',
|
customer: options.customerId ?? 'cus_test_1',
|
||||||
status: 'canceled',
|
status: 'canceled',
|
||||||
canceled_at: Math.floor(Date.now() / 1000),
|
canceled_at: nowSeconds,
|
||||||
|
ended_at: options.endedAt ?? nowSeconds,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user