From 591e9fe2bac3f92bd956815038f9dac3143759df Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 19 Aug 2026 02:57:58 +0200 Subject: [PATCH] fix(api): only finalize self-serve refunds once the provider confirms (#1731) --- fluxer_api/src/api/openapi/openapi.json | 13 ++- .../stripe/services/StripeRefundService.ts | 81 +++++++++++++---- .../stripe/services/StripeWebhookService.ts | 3 + .../stripe/tests/StripeRefundService.test.ts | 74 +++++++++++++++- .../stripe/tests/StripeWebhookRefund.test.ts | 88 +++++++++++++++++++ .../api/worker/tasks/ProcessStripeWebhook.ts | 3 + .../src/domains/premium/PremiumSchemas.ts | 9 +- 7 files changed, 248 insertions(+), 23 deletions(-) diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index eb7876bbd..f3df90c93 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -33253,12 +33253,20 @@ "payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, - "refunded_amount_cents": {"type": "integer", "format": "int53"}, + "refunded_amount_cents": { + "type": "integer", + "format": "int53", + "description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success" + }, "invoice_amount_paid_cents": {"type": "integer", "format": "int53"}, "currency": {"type": "string"}, "subscription_id": { "anyOf": [{"type": "string"}, {"type": "null"}], "description": "Subscription that was cancelled along with the refund, when applicable" + }, + "status": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded" } }, "required": [ @@ -33269,7 +33277,8 @@ "refunded_amount_cents", "invoice_amount_paid_cents", "currency", - "subscription_id" + "subscription_id", + "status" ] }, "ReadStateAckResponse": { diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index 54d0870e5..22e5867f9 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -13,7 +13,7 @@ import type { SelfServeRefundResponse, } from '@fluxer/schema/src/domains/premium/PremiumSchemas'; import type Stripe from 'stripe'; -import type {UserID} from '../../BrandedTypes'; +import {createUserID, type UserID} from '../../BrandedTypes'; import {Config} from '../../Config'; import {Logger} from '../../Logger'; import {getBillingRepository} from '../../middleware/ServiceRegistry'; @@ -206,6 +206,51 @@ export class StripeRefundService { }; } + private async countPriorTerminalFailures(invoiceId: string): Promise { + const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId); + return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length; + } + + private async finalizeIfSucceeded(refund: Stripe.Refund): Promise { + if (refund.status !== 'succeeded' || refund.metadata?.refund_kind !== 'self_serve') { + return; + } + const userIdRaw = refund.metadata.user_id; + if (!userIdRaw) { + return; + } + let userId: UserID; + try { + userId = createUserID(BigInt(userIdRaw)); + } catch { + return; + } + const user = await this.userRepository.findUnique(userId); + if (!user || user.firstRefundAt) { + return; + } + const subscriptionId = refund.metadata.subscription_id; + if (subscriptionId) { + try { + await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund'); + } catch (error) { + Logger.warn( + {error, userId: user.id.toString(), subscriptionId}, + 'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook', + ); + } + } + await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow()); + Logger.info( + {userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null}, + 'Self-serve refund confirmed succeeded; cooldown and cancellation finalized', + ); + } + + async handleRefundWebhookEvent(refund: Stripe.Refund): Promise { + await this.finalizeIfSucceeded(refund); + } + async refundLatestPurchase(userId: UserID): Promise { const stripe = this.ensureStripe(); const user = await this.getRequiredUser(userId); @@ -220,6 +265,14 @@ export class StripeRefundService { if (this.cooldownExpiresAt(user)) { throw new StripeRefundCooldownActiveError(); } + const priorFailures = await this.countPriorTerminalFailures(target.invoiceId); + const idempotencyKey = [ + 'self-serve-refund', + user.id.toString(), + target.invoiceId, + target.paymentIntentId ?? target.chargeId, + ...(priorFailures > 0 ? [`retry-${priorFailures}`] : []), + ].join(':'); let refund: Stripe.Response; try { refund = await stripe.refunds.create( @@ -232,11 +285,10 @@ export class StripeRefundService { invoice_id: target.invoiceId, refund_kind: 'self_serve', refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS), + ...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}), }, }, - { - idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`, - }, + {idempotencyKey}, ); } catch (error) { Logger.warn( @@ -254,36 +306,29 @@ export class StripeRefundService { } catch (mirrorErr) { Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal'); } - if (target.subscriptionId) { - try { - await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund'); - } catch (error) { - Logger.warn( - {error, userId: user.id.toString(), subscriptionId: target.subscriptionId}, - 'Self-serve refund issued but subscription cancellation failed; will reconcile via webhook', - ); - } - } - await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow()); + await this.finalizeIfSucceeded(refund); + const succeeded = refund.status === 'succeeded'; Logger.info( { userId: user.id.toString(), invoiceId: target.invoiceId, refundId: refund.id, + status: refund.status, amountCents: refund.amount, subscriptionId: target.subscriptionId, }, - 'Self-serve refund issued', + succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider', ); return { invoice_id: target.invoiceId, payment_intent_id: target.paymentIntentId, charge_id: target.chargeId, refund_id: refund.id, - refunded_amount_cents: refund.amount, + refunded_amount_cents: succeeded ? refund.amount : 0, invoice_amount_paid_cents: target.amountPaidCents, currency: target.currency, - subscription_id: target.subscriptionId, + subscription_id: succeeded ? target.subscriptionId : null, + status: refund.status ?? 'pending', }; } } diff --git a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts index e80b050d0..f496e3aba 100644 --- a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts +++ b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts @@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler'; import type {StripeGiftService} from './StripeGiftService'; import {StripePaymentFraudService} from './StripePaymentFraudService'; import type {StripePremiumService} from './StripePremiumService'; +import type {StripeRefundService} from './StripeRefundService'; import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler'; import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler'; @@ -61,6 +62,7 @@ export class StripeWebhookService { adminRepository: AdminRepository, snowflakeService: ISnowflakeService, private billingRepository: BillingRepository, + private refundService: StripeRefundService, ) { this.checkoutHandler = new StripeCheckoutWebhookHandler( stripe, @@ -336,6 +338,7 @@ export class StripeWebhookService { livemode: event.livemode, }), ); + await this.refundService.handleRefundWebhookEvent(r); break; } case 'invoice.created': diff --git a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts index 5e1fdd9b2..1520fe621 100644 --- a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts @@ -8,6 +8,7 @@ import type { import {HttpResponse, http} from 'msw'; import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest'; import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils'; +import {createUserID} from '../../BrandedTypes'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers'; import {server} from '../../test/msw/server'; @@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray) { }); } -function refundCreateHandler() { +function refundCreateHandler(opts?: { + status?: 'succeeded' | 'pending' | 'failed'; + failureReason?: string; + onRequest?: (idempotencyKey: string | null) => void; +}) { return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => { + opts?.onRequest?.(request.headers.get('idempotency-key')); const formData = await request.formData(); const params = Object.fromEntries(formData.entries()); + const metadata: Record = {}; + for (const [key, value] of Object.entries(params)) { + const match = key.match(/^metadata\[(.+)\]$/); + if (match) metadata[match[1]] = value as string; + } return HttpResponse.json({ id: 're_test_self_serve', object: 'refund', amount: Number.parseInt((params.amount as string) ?? '0', 10), currency: 'usd', - status: 'succeeded', + status: opts?.status ?? 'succeeded', + failure_reason: opts?.failureReason ?? null, payment_intent: params.payment_intent ?? null, charge: params.charge ?? null, + metadata, }); }); } @@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => { .expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY) .execute(); }); + test('does not finalize cooldown or cancel the subscription while the refund is still pending at the provider', async () => { + server.use(...createStripeApiHandlers().handlers); + server.use( + invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]), + refundCreateHandler({status: 'pending'}), + ); + const account = await createTestAccount(harness); + await setStripeIds(harness, account, { + stripe_customer_id: MOCK_CUSTOMER_ID, + stripe_subscription_id: MOCK_SUBSCRIPTION_ID, + }); + const response = await createBuilder(harness, account.token) + .post('/premium/refund-latest') + .execute(); + expect(response.status).toBe('pending'); + expect(response.refunded_amount_cents).toBe(0); + expect(response.subscription_id).toBeNull(); + const {UserRepository} = await import('../../user/repositories/UserRepository'); + const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId))); + expect(updatedUser!.firstRefundAt).toBeNull(); + }); + test('does not finalize cooldown or cancel the subscription when the refund fails at the provider', async () => { + server.use(...createStripeApiHandlers().handlers); + server.use( + invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]), + refundCreateHandler({status: 'failed', failureReason: 'unknown'}), + ); + const account = await createTestAccount(harness); + await setStripeIds(harness, account, { + stripe_customer_id: MOCK_CUSTOMER_ID, + stripe_subscription_id: MOCK_SUBSCRIPTION_ID, + }); + const response = await createBuilder(harness, account.token) + .post('/premium/refund-latest') + .execute(); + expect(response.status).toBe('failed'); + expect(response.refunded_amount_cents).toBe(0); + expect(response.subscription_id).toBeNull(); + const {UserRepository} = await import('../../user/repositories/UserRepository'); + const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId))); + expect(updatedUser!.firstRefundAt).toBeNull(); + }); + test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => { + server.use(...createStripeApiHandlers().handlers); + server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})])); + const account = await createTestAccount(harness); + await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID}); + const idempotencyKeys: Array = []; + server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)})); + await createBuilder(harness, account.token).post('/premium/refund-latest').execute(); + await createBuilder(harness, account.token).post('/premium/refund-latest').execute(); + expect(idempotencyKeys).toHaveLength(2); + expect(idempotencyKeys[0]).not.toBeNull(); + expect(idempotencyKeys[1]).not.toBeNull(); + expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]); + expect(idempotencyKeys[1]).toContain('retry-1'); + }); }); }); diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts index ae9dfafd4..bd9edb405 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts @@ -2,12 +2,14 @@ import crypto from 'node:crypto'; import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {HttpResponse, http} from 'msw'; import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; import {createTestAccount} from '../../auth/tests/AuthTestUtils'; import {createUserID} from '../../BrandedTypes'; import {Config} from '../../Config'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers'; +import {server} from '../../test/msw/server'; import {createBuilder} from '../../test/TestRequestBuilder'; import {UserRepository} from '../../user/repositories/UserRepository'; import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils'; @@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => { expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME); }); }); + describe('refund.updated', () => { + test('finalizes self-serve cooldown and cancels the subscription once the refund is confirmed succeeded', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const userRepository = new UserRepository(); + const subscriptionId = 'sub_test_webhook_finalize'; + await userRepository.patchUpsert( + userId, + {stripe_subscription_id: subscriptionId}, + (await userRepository.findUnique(userId))!.toRow(), + ); + server.use( + http.delete('https://api.stripe.com/v1/subscriptions/:id', ({params}) => + HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}), + ), + ); + await sendWebhook({ + type: 'refund.updated', + data: { + object: { + id: 'pyr_test_webhook_finalize', + status: 'succeeded', + amount: 2499, + currency: 'brl', + metadata: { + refund_kind: 'self_serve', + user_id: account.userId.toString(), + invoice_id: 'in_test_webhook_finalize', + subscription_id: subscriptionId, + }, + }, + }, + }); + const updatedUser = await userRepository.findUnique(userId); + expect(updatedUser!.firstRefundAt).not.toBeNull(); + }); + test('does not finalize cooldown while the refund is still pending', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const userRepository = new UserRepository(); + await sendWebhook({ + type: 'refund.updated', + data: { + object: { + id: 'pyr_test_webhook_pending', + status: 'pending', + amount: 2499, + currency: 'brl', + metadata: { + refund_kind: 'self_serve', + user_id: account.userId.toString(), + invoice_id: 'in_test_webhook_pending', + }, + }, + }, + }); + const updatedUser = await userRepository.findUnique(userId); + expect(updatedUser!.firstRefundAt).toBeNull(); + }); + }); + describe('refund.failed', () => { + test('does not finalize cooldown when the refund ultimately fails', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const userRepository = new UserRepository(); + await sendWebhook({ + type: 'refund.failed', + data: { + object: { + id: 'pyr_test_webhook_failed', + status: 'failed', + failure_reason: 'unknown', + amount: 2499, + currency: 'brl', + metadata: { + refund_kind: 'self_serve', + user_id: account.userId.toString(), + invoice_id: 'in_test_webhook_failed', + }, + }, + }, + }); + const updatedUser = await userRepository.findUnique(userId); + expect(updatedUser!.firstRefundAt).toBeNull(); + }); + }); }); diff --git a/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts b/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts index 59f208f5c..fba080f65 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts @@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService'; import {StripeGiftService} from '../../stripe/services/StripeGiftService'; import {StripePremiumService} from '../../stripe/services/StripePremiumService'; +import {StripeRefundService} from '../../stripe/services/StripeRefundService'; import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService'; import {StripeWebhookService} from '../../stripe/services/StripeWebhookService'; import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate'; @@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => { const ageVerificationService = deps.stripe ? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService) : null; + const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService); const webhookService = new StripeWebhookService( deps.stripe, checkoutService, @@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => { deps.adminRepository, deps.snowflakeService, deps.billingRepository, + refundService, ); await webhookService.handleWebhook({body, signature}); }; diff --git a/packages/schema/src/domains/premium/PremiumSchemas.ts b/packages/schema/src/domains/premium/PremiumSchemas.ts index 3e6b2b7d8..c32a04ffb 100644 --- a/packages/schema/src/domains/premium/PremiumSchemas.ts +++ b/packages/schema/src/domains/premium/PremiumSchemas.ts @@ -301,13 +301,20 @@ export const SelfServeRefundResponse = z.object({ payment_intent_id: z.string().nullable(), charge_id: z.string().nullable(), refund_id: z.string().nullable(), - refunded_amount_cents: z.number().int(), + refunded_amount_cents: z + .number() + .int() + .describe('Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success'), invoice_amount_paid_cents: z.number().int(), currency: z.string(), subscription_id: z .string() .nullable() .describe('Subscription that was cancelled along with the refund, when applicable'), + status: z + .string() + .nullable() + .describe('Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded'), }); export type SelfServeRefundResponse = z.infer;