fix(api): only finalize self-serve refunds once the provider confirms (#1731)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-19 02:57:58 +02:00
1 parent d148b4e5b7
commit 591e9fe2ba
7 files changed
+248 -23

No files matched your search

+11 -2
View File
@@ -33253,12 +33253,20 @@
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"refund_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"}, "invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
"currency": {"type": "string"}, "currency": {"type": "string"},
"subscription_id": { "subscription_id": {
"anyOf": [{"type": "string"}, {"type": "null"}], "anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Subscription that was cancelled along with the refund, when applicable" "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": [ "required": [
@@ -33269,7 +33277,8 @@
"refunded_amount_cents", "refunded_amount_cents",
"invoice_amount_paid_cents", "invoice_amount_paid_cents",
"currency", "currency",
"subscription_id" "subscription_id",
"status"
] ]
}, },
"ReadStateAckResponse": { "ReadStateAckResponse": {
@@ -13,7 +13,7 @@ import type {
SelfServeRefundResponse, SelfServeRefundResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas'; } from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import type {UserID} from '../../BrandedTypes'; import {createUserID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config'; import {Config} from '../../Config';
import {Logger} from '../../Logger'; import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry'; import {getBillingRepository} from '../../middleware/ServiceRegistry';
@@ -206,6 +206,51 @@ export class StripeRefundService {
}; };
} }
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
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<void> {
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<void> {
await this.finalizeIfSucceeded(refund);
}
async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> { async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> {
const stripe = this.ensureStripe(); const stripe = this.ensureStripe();
const user = await this.getRequiredUser(userId); const user = await this.getRequiredUser(userId);
@@ -220,6 +265,14 @@ export class StripeRefundService {
if (this.cooldownExpiresAt(user)) { if (this.cooldownExpiresAt(user)) {
throw new StripeRefundCooldownActiveError(); 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<Stripe.Refund>; let refund: Stripe.Response<Stripe.Refund>;
try { try {
refund = await stripe.refunds.create( refund = await stripe.refunds.create(
@@ -232,11 +285,10 @@ export class StripeRefundService {
invoice_id: target.invoiceId, invoice_id: target.invoiceId,
refund_kind: 'self_serve', refund_kind: 'self_serve',
refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS), refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS),
...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}),
}, },
}, },
{ {idempotencyKey},
idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`,
},
); );
} catch (error) { } catch (error) {
Logger.warn( Logger.warn(
@@ -254,36 +306,29 @@ export class StripeRefundService {
} catch (mirrorErr) { } catch (mirrorErr) {
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal'); Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
} }
if (target.subscriptionId) { await this.finalizeIfSucceeded(refund);
try { const succeeded = refund.status === 'succeeded';
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());
Logger.info( Logger.info(
{ {
userId: user.id.toString(), userId: user.id.toString(),
invoiceId: target.invoiceId, invoiceId: target.invoiceId,
refundId: refund.id, refundId: refund.id,
status: refund.status,
amountCents: refund.amount, amountCents: refund.amount,
subscriptionId: target.subscriptionId, subscriptionId: target.subscriptionId,
}, },
'Self-serve refund issued', succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider',
); );
return { return {
invoice_id: target.invoiceId, invoice_id: target.invoiceId,
payment_intent_id: target.paymentIntentId, payment_intent_id: target.paymentIntentId,
charge_id: target.chargeId, charge_id: target.chargeId,
refund_id: refund.id, refund_id: refund.id,
refunded_amount_cents: refund.amount, refunded_amount_cents: succeeded ? refund.amount : 0,
invoice_amount_paid_cents: target.amountPaidCents, invoice_amount_paid_cents: target.amountPaidCents,
currency: target.currency, currency: target.currency,
subscription_id: target.subscriptionId, subscription_id: succeeded ? target.subscriptionId : null,
status: refund.status ?? 'pending',
}; };
} }
} }
@@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
import type {StripeGiftService} from './StripeGiftService'; import type {StripeGiftService} from './StripeGiftService';
import {StripePaymentFraudService} from './StripePaymentFraudService'; import {StripePaymentFraudService} from './StripePaymentFraudService';
import type {StripePremiumService} from './StripePremiumService'; import type {StripePremiumService} from './StripePremiumService';
import type {StripeRefundService} from './StripeRefundService';
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler'; import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler'; import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
@@ -61,6 +62,7 @@ export class StripeWebhookService {
adminRepository: AdminRepository, adminRepository: AdminRepository,
snowflakeService: ISnowflakeService, snowflakeService: ISnowflakeService,
private billingRepository: BillingRepository, private billingRepository: BillingRepository,
private refundService: StripeRefundService,
) { ) {
this.checkoutHandler = new StripeCheckoutWebhookHandler( this.checkoutHandler = new StripeCheckoutWebhookHandler(
stripe, stripe,
@@ -336,6 +338,7 @@ export class StripeWebhookService {
livemode: event.livemode, livemode: event.livemode,
}), }),
); );
await this.refundService.handleRefundWebhookEvent(r);
break; break;
} }
case 'invoice.created': case 'invoice.created':
@@ -8,6 +8,7 @@ import type {
import {HttpResponse, http} from 'msw'; import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest'; import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils'; import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers'; import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server'; import {server} from '../../test/msw/server';
@@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
}); });
} }
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}) => { return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => {
opts?.onRequest?.(request.headers.get('idempotency-key'));
const formData = await request.formData(); const formData = await request.formData();
const params = Object.fromEntries(formData.entries()); const params = Object.fromEntries(formData.entries());
const metadata: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
const match = key.match(/^metadata\[(.+)\]$/);
if (match) metadata[match[1]] = value as string;
}
return HttpResponse.json({ return HttpResponse.json({
id: 're_test_self_serve', id: 're_test_self_serve',
object: 'refund', object: 'refund',
amount: Number.parseInt((params.amount as string) ?? '0', 10), amount: Number.parseInt((params.amount as string) ?? '0', 10),
currency: 'usd', currency: 'usd',
status: 'succeeded', status: opts?.status ?? 'succeeded',
failure_reason: opts?.failureReason ?? null,
payment_intent: params.payment_intent ?? null, payment_intent: params.payment_intent ?? null,
charge: params.charge ?? null, charge: params.charge ?? null,
metadata,
}); });
}); });
} }
@@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => {
.expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY) .expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY)
.execute(); .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<SelfServeRefundResponse>(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<SelfServeRefundResponse>(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<string | null> = [];
server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)}));
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
await createBuilder<SelfServeRefundResponse>(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');
});
}); });
}); });
@@ -2,12 +2,14 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {HttpResponse, http} from 'msw';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils'; import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes'; import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config'; import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers'; import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {createBuilder} from '../../test/TestRequestBuilder'; import {createBuilder} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository'; import {UserRepository} from '../../user/repositories/UserRepository';
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils'; import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
@@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => {
expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME); 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();
});
});
}); });
@@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi
import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService'; import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService';
import {StripeGiftService} from '../../stripe/services/StripeGiftService'; import {StripeGiftService} from '../../stripe/services/StripeGiftService';
import {StripePremiumService} from '../../stripe/services/StripePremiumService'; import {StripePremiumService} from '../../stripe/services/StripePremiumService';
import {StripeRefundService} from '../../stripe/services/StripeRefundService';
import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService'; import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService';
import {StripeWebhookService} from '../../stripe/services/StripeWebhookService'; import {StripeWebhookService} from '../../stripe/services/StripeWebhookService';
import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate'; import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate';
@@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
const ageVerificationService = deps.stripe const ageVerificationService = deps.stripe
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService) ? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
: null; : null;
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
const webhookService = new StripeWebhookService( const webhookService = new StripeWebhookService(
deps.stripe, deps.stripe,
checkoutService, checkoutService,
@@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
deps.adminRepository, deps.adminRepository,
deps.snowflakeService, deps.snowflakeService,
deps.billingRepository, deps.billingRepository,
refundService,
); );
await webhookService.handleWebhook({body, signature}); await webhookService.handleWebhook({body, signature});
}; };
@@ -301,13 +301,20 @@ export const SelfServeRefundResponse = z.object({
payment_intent_id: z.string().nullable(), payment_intent_id: z.string().nullable(),
charge_id: z.string().nullable(), charge_id: z.string().nullable(),
refund_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(), invoice_amount_paid_cents: z.number().int(),
currency: z.string(), currency: z.string(),
subscription_id: z subscription_id: z
.string() .string()
.nullable() .nullable()
.describe('Subscription that was cancelled along with the refund, when applicable'), .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<typeof SelfServeRefundResponse>; export type SelfServeRefundResponse = z.infer<typeof SelfServeRefundResponse>;