mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(api): only finalize self-serve refunds once the provider confirms (#1731)
This commit is contained in:
1 parent
d148b4e5b7
commit
591e9fe2ba
7 files changed
+248
-23
No files matched your search
@@ -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>;
|
||||||
Reference in new issue
Block a user