diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index c0a19d8a6..cbf720aac 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -261,7 +261,7 @@ export class StripeRefundService { const subscriptionId = refund.metadata.subscription_id; if (subscriptionId) { try { - await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund'); + await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund', subscriptionId); } catch (error) { Logger.error( {error, userId: user.id.toString(), subscriptionId}, diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts index d3d886357..e01c45b0e 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts @@ -174,7 +174,7 @@ export class StripeSubscriptionService { } } - async cancelSubscriptionImmediately(userId: UserID, reason?: string): Promise { + async cancelSubscriptionImmediately(userId: UserID, reason?: string, expectedSubscriptionId?: string): Promise { if (!this.stripe) { throw new StripePaymentNotAvailableError(); } @@ -185,6 +185,18 @@ export class StripeSubscriptionService { if (!user.stripeSubscriptionId) { throw new StripeNoActiveSubscriptionError(); } + if (expectedSubscriptionId && user.stripeSubscriptionId !== expectedSubscriptionId) { + Logger.info( + { + userId: user.id.toString(), + expectedSubscriptionId, + currentSubscriptionId: user.stripeSubscriptionId, + reason: reason ?? null, + }, + 'Skipping immediate cancellation because the target subscription is no longer the current one', + ); + return; + } try { const canceledSubscription = await this.stripe.subscriptions.cancel( user.stripeSubscriptionId, diff --git a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts index 3d2374f8e..f600dc7f4 100644 --- a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts @@ -487,4 +487,90 @@ describe('StripeRefundService self-serve refund', () => { expect(idempotencyKeys[1]).toContain('retry-1'); }); }); + describe('self-serve refund teardown targeting', () => { + function trackingSubscriptionDeleteHandler(deleted: Array) { + return http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => { + deleted.push(String(params.id)); + return HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}); + }); + } + + function buildRefundUpdatedEvent(opts: { + eventId: string; + refundId: string; + userId: string; + invoiceId: string; + subscriptionId: string; + }): StripeWebhookEventData { + return { + id: opts.eventId, + type: 'refund.updated', + data: { + object: { + id: opts.refundId, + object: 'refund', + status: 'succeeded', + amount: 2500, + currency: 'usd', + metadata: { + refund_kind: 'self_serve', + user_id: opts.userId, + invoice_id: opts.invoiceId, + subscription_id: opts.subscriptionId, + }, + }, + }, + }; + } + + test('leaves a newer subscription alone when the refunded one is no longer current', async () => { + server.use(...createStripeApiHandlers().handlers); + const deleted: Array = []; + server.use(trackingSubscriptionDeleteHandler(deleted)); + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + await setStripeIds(harness, account, { + stripe_customer_id: MOCK_CUSTOMER_ID, + stripe_subscription_id: 'sub_bought_after_the_refund', + }); + await sendWebhook( + buildRefundUpdatedEvent({ + eventId: 'evt_stale_teardown', + refundId: 're_stale_teardown', + userId: account.userId, + invoiceId: 'in_stale_teardown', + subscriptionId: 'sub_refunded_and_already_gone', + }), + ); + expect(deleted).toEqual([]); + const userRepository = new UserRepository(); + const user = await userRepository.findUnique(userId); + expect(user!.stripeSubscriptionId).toBe('sub_bought_after_the_refund'); + }); + + test('cancels the subscription when the refunded one is still current', async () => { + server.use(...createStripeApiHandlers().handlers); + const deleted: Array = []; + server.use(trackingSubscriptionDeleteHandler(deleted)); + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + await setStripeIds(harness, account, { + stripe_customer_id: MOCK_CUSTOMER_ID, + stripe_subscription_id: MOCK_SUBSCRIPTION_ID, + }); + await sendWebhook( + buildRefundUpdatedEvent({ + eventId: 'evt_current_teardown', + refundId: 're_current_teardown', + userId: account.userId, + invoiceId: 'in_current_teardown', + subscriptionId: MOCK_SUBSCRIPTION_ID, + }), + ); + expect(deleted).toEqual([MOCK_SUBSCRIPTION_ID]); + const userRepository = new UserRepository(); + const user = await userRepository.findUnique(userId); + expect(user!.stripeSubscriptionId).toBeNull(); + }); + }); });