// SPDX-License-Identifier: AGPL-3.0-or-later import crypto from 'node:crypto'; import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; import {createUserID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import {setupSyncStripeWebhookWorker} from '@app/api/stripe/tests/StripeWebhookTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; import {createMockWebhookPayload, type StripeWebhookEventData} from '@app/api/test/msw/handlers/StripeApiHandlers'; import {server} from '@app/api/test/msw/server'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; import {UserRepository} from '@app/api/user/repositories/UserRepository'; import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; import {HttpResponse, http} from 'msw'; import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; describe('Stripe Webhook Refund', () => { let harness: ApiTestHarness; let originalWebhookSecret: string | undefined; beforeAll(async () => { harness = await createApiTestHarness(); setupSyncStripeWebhookWorker(); originalWebhookSecret = Config.stripe.webhookSecret; Config.stripe.webhookSecret = 'whsec_test_secret'; }); afterAll(async () => { await harness.shutdown(); Config.stripe.webhookSecret = originalWebhookSecret; }); beforeEach(async () => { await harness.resetData(); }); function createWebhookSignature(payload: string, timestamp: number, secret: string): string { const signedPayload = `${timestamp}.${payload}`; const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); return `t=${timestamp},v1=${signature}`; } async function sendWebhook(eventData: StripeWebhookEventData): Promise<{ received: boolean; }> { const {payload, timestamp} = createMockWebhookPayload(eventData); const signature = createWebhookSignature(payload, timestamp, Config.stripe.webhookSecret!); return createBuilder<{ received: boolean; }>(harness, '') .post('/stripe/webhook') .header('stripe-signature', signature) .header('content-type', 'application/json') .body(payload) .execute(); } function useRefundListHandler(chargeId: string, refunds: Array> = []): void { server.use( http.get( ({request}) => request.url === `https://api.stripe.com/v1/refunds?charge=${chargeId}&limit=100`, () => HttpResponse.json({ object: 'list', data: refunds, has_more: false, url: '/v1/refunds', }), ), ); } describe('charge.refunded', () => { test('revokes premium and records first refund', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const paymentRepository = new PaymentRepository(); const userRepository = new UserRepository(); const paymentIntentId = 'pi_test_refund_first_123'; const checkoutSessionId = 'cs_test_refund_first_123'; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); useRefundListHandler('ch_test_refund_123'); await sendWebhook({ type: 'charge.refunded', data: { object: { id: 'ch_test_refund_123', payment_intent: paymentIntentId, amount_refunded: 2500, }, }, }); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser).not.toBeNull(); expect(updatedUser!.firstRefundAt).not.toBeNull(); const updatedPayment = await userRepository.getPaymentByPaymentIntent(paymentIntentId); expect(updatedPayment).not.toBeNull(); expect(updatedPayment!.status).toBe('refunded'); }); async function seedRefundedPayment( userId: ReturnType, suffix: string, ): Promise<{paymentIntentId: string; chargeId: string}> { const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const paymentRepository = new PaymentRepository(); const paymentIntentId = `pi_test_refund_${suffix}`; const checkoutSessionId = `cs_test_refund_${suffix}`; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); return {paymentIntentId, chargeId: `ch_test_refund_${suffix}`}; } test('applies permanent purchase block when a second distinct refund exists', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const userRepository = new UserRepository(); const firstRefundDate = new Date('2024-01-01'); await userRepository.patchUpsert( userId, {first_refund_at: firstRefundDate}, (await userRepository.findUnique(userId))!.toRow(), ); const {paymentIntentId, chargeId} = await seedRefundedPayment(userId, 'second_456'); const nowSeconds = Math.floor(Date.now() / 1000); const earlier = { id: 're_test_refund_second_456_1', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 1200, currency: 'usd', status: 'succeeded', created: nowSeconds - 600, metadata: {}, }; const latest = {...earlier, id: 're_test_refund_second_456_2', amount: 1300, created: nowSeconds}; await sendWebhook({ type: 'charge.refunded', data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, refunds: {object: 'list', has_more: false, url: `/v1/charges/${chargeId}/refunds`, data: [earlier, latest]}, }, }, }); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser).not.toBeNull(); expect(updatedUser!.firstRefundAt).toEqual(firstRefundDate); expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(PremiumFlags.PURCHASE_DISABLED); const updatedPayment = await userRepository.getPaymentByPaymentIntent(paymentIntentId); expect(updatedPayment).not.toBeNull(); expect(updatedPayment!.status).toBe('refunded'); }); test('does not block purchases when one refund is redelivered after the allowance claim expired', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const userRepository = new UserRepository(); const firstRefundDate = new Date('2024-01-01'); await userRepository.patchUpsert( userId, {first_refund_at: firstRefundDate}, (await userRepository.findUnique(userId))!.toRow(), ); const {paymentIntentId, chargeId} = await seedRefundedPayment(userId, 'replay_789'); const onlyRefund = { id: 're_test_refund_replay_789', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 2500, currency: 'usd', status: 'succeeded', created: Math.floor(Date.now() / 1000), metadata: {}, }; await sendWebhook({ type: 'charge.refunded', data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, refunds: {object: 'list', has_more: false, url: `/v1/charges/${chargeId}/refunds`, data: [onlyRefund]}, }, }, }); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser).not.toBeNull(); expect(updatedUser!.firstRefundAt).toEqual(firstRefundDate); expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); }); test('counts a refund once when the same charge.refunded event is redelivered', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const paymentRepository = new PaymentRepository(); const userRepository = new UserRepository(); const paymentIntentId = 'pi_test_refund_retry_123'; const checkoutSessionId = 'cs_test_refund_retry_123'; const chargeId = 'ch_test_refund_retry_123'; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); useRefundListHandler(chargeId, [ { id: 're_test_refund_retry_123', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 2500, currency: 'brl', status: 'succeeded', created: Math.floor(Date.now() / 1000), metadata: {}, }, ]); const chargeEvent = { type: 'charge.refunded' as const, data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, }, }, }; await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_1'}); await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_2'}); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser!.firstRefundAt).not.toBeNull(); expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); }); test('counts a refund once when charge.refunded lands before the refund record exists', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const paymentRepository = new PaymentRepository(); const userRepository = new UserRepository(); const paymentIntentId = 'pi_test_refund_late_record'; const checkoutSessionId = 'cs_test_refund_late_record'; const chargeId = 'ch_test_refund_late_record'; const refundId = 're_test_refund_late_record'; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); const refund = { id: refundId, object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 2500, currency: 'brl', status: 'succeeded', created: Math.floor(Date.now() / 1000), metadata: {}, }; const chargeEvent = { type: 'charge.refunded' as const, data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, }, }, }; useRefundListHandler(chargeId); await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_1'}); const afterFallback = await userRepository.findUnique(userId); expect(afterFallback!.firstRefundAt).not.toBeNull(); expect(afterFallback!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); await sendWebhook({ id: 'evt_test_refund_late_record_2', type: 'refund.created', data: {object: refund}, }); useRefundListHandler(chargeId, [refund]); await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_3'}); const afterRecord = await userRepository.findUnique(userId); expect(afterRecord!.firstRefundAt!.getTime()).toBe(afterFallback!.firstRefundAt!.getTime()); expect(afterRecord!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); }); test('does not count a refund Fluxer issued itself against the refund allowance', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const paymentRepository = new PaymentRepository(); const userRepository = new UserRepository(); const paymentIntentId = 'pi_test_refund_system_123'; const checkoutSessionId = 'cs_test_refund_system_123'; const chargeId = 'ch_test_refund_system_123'; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); useRefundListHandler(chargeId, [ { id: 're_test_refund_system_123', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 2500, currency: 'brl', status: 'succeeded', created: Math.floor(Date.now() / 1000), metadata: {rejection_reason: 'duplicate_active_subscription'}, }, ]); await sendWebhook({ type: 'charge.refunded', data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, }, }, }); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser!.firstRefundAt).toBeNull(); expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); }); test('does not count a refund Fluxer issued for a localized card country mismatch', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const paymentRepository = new PaymentRepository(); const userRepository = new UserRepository(); const paymentIntentId = 'pi_test_refund_card_mismatch'; const checkoutSessionId = 'cs_test_refund_card_mismatch'; const chargeId = 'ch_test_refund_card_mismatch'; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); useRefundListHandler(chargeId, [ { id: 're_test_refund_card_mismatch', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 2500, currency: 'brl', status: 'succeeded', created: Math.floor(Date.now() / 1000), metadata: {rejection_reason: 'localized_card_country_mismatch'}, }, ]); await sendWebhook({ type: 'charge.refunded', data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, }, }, }); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser!.firstRefundAt).toBeNull(); expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); const updatedPayment = await userRepository.getPaymentByPaymentIntent(paymentIntentId); expect(updatedPayment!.status).toBe('refunded'); }); test('counts only the customer refund when Fluxer issued a later system refund on the same charge', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const paymentRepository = new PaymentRepository(); const userRepository = new UserRepository(); const paymentIntentId = 'pi_test_refund_mixed'; const checkoutSessionId = 'cs_test_refund_mixed'; const chargeId = 'ch_test_refund_mixed'; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); const nowSeconds = Math.floor(Date.now() / 1000); const chargeEvent = { type: 'charge.refunded' as const, data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, refunds: { object: 'list', has_more: false, url: `/v1/charges/${chargeId}/refunds`, data: [ { id: 're_test_refund_mixed_customer', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 1500, currency: 'brl', status: 'succeeded', created: nowSeconds - 600, metadata: {}, }, { id: 're_test_refund_mixed_system', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 1000, currency: 'brl', status: 'succeeded', created: nowSeconds, metadata: {rejection_reason: 'duplicate_active_subscription'}, }, ], }, }, }, }; await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_1'}); const afterFirst = await userRepository.findUnique(userId); expect(afterFirst!.firstRefundAt).not.toBeNull(); expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_2'}); const afterRetry = await userRepository.findUnique(userId); expect(afterRetry!.firstRefundAt!.getTime()).toBe(afterFirst!.firstRefundAt!.getTime()); expect(afterRetry!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); }); test('counts a second, distinct refund against the allowance after the first one was already counted', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const paymentRepository = new PaymentRepository(); const userRepository = new UserRepository(); const paymentIntentId = 'pi_test_refund_ladder'; const checkoutSessionId = 'cs_test_refund_ladder'; const chargeId = 'ch_test_refund_ladder'; await paymentRepository.createPayment({ checkout_session_id: checkoutSessionId, user_id: userId, price_id: 'price_test_monthly', product_type: 'monthly_subscription', status: 'completed', is_gift: false, created_at: new Date(), }); await paymentRepository.updatePayment({ checkout_session_id: checkoutSessionId, payment_intent_id: paymentIntentId, completed_at: new Date(), }); const nowSeconds = Math.floor(Date.now() / 1000); function chargeEventWithRefunds(refunds: Array>) { return { type: 'charge.refunded' as const, data: { object: { id: chargeId, payment_intent: paymentIntentId, amount_refunded: 2500, refunds: { object: 'list', has_more: false, url: `/v1/charges/${chargeId}/refunds`, data: refunds, }, }, }, }; } const firstRefund = { id: 're_test_refund_ladder_1', object: 'refund', charge: chargeId, payment_intent: paymentIntentId, amount: 1200, currency: 'brl', status: 'succeeded', created: nowSeconds - 600, metadata: {}, }; const secondRefund = { ...firstRefund, id: 're_test_refund_ladder_2', amount: 1300, created: nowSeconds, }; await sendWebhook({...chargeEventWithRefunds([firstRefund]), id: 'evt_test_refund_ladder_1'}); const afterFirst = await userRepository.findUnique(userId); expect(afterFirst!.firstRefundAt).not.toBeNull(); expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0); await sendWebhook({...chargeEventWithRefunds([firstRefund, secondRefund]), id: 'evt_test_refund_ladder_2'}); const afterSecond = await userRepository.findUnique(userId); expect(afterSecond!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(PremiumFlags.PURCHASE_DISABLED); }); test('falls back to customer ID when payment intent is not indexed (subscription mode)', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const userRepository = new UserRepository(); const stripeCustomerId = 'cus_test_subscription_fallback'; await userRepository.patchUpsert( userId, {stripe_customer_id: stripeCustomerId}, (await userRepository.findUnique(userId))!.toRow(), ); useRefundListHandler('ch_test_refund_sub_789'); await sendWebhook({ type: 'charge.refunded', data: { object: { id: 'ch_test_refund_sub_789', payment_intent: 'pi_test_not_indexed_789', customer: stripeCustomerId, amount_refunded: 4999, }, }, }); const updatedUser = await userRepository.findUnique(userId); expect(updatedUser).not.toBeNull(); expect(updatedUser!.firstRefundAt).not.toBeNull(); }); test('skips premium action for donation customer refund', async () => { const donationCustomerId = 'cus_test_donation_refund'; const {DonationRepository} = await import('@app/api/donation/DonationRepository'); const donationRepository = new DonationRepository(); await donationRepository.createDonor({ email: 'donor-refund-test@example.com', stripeCustomerId: donationCustomerId, businessName: null, taxId: null, taxIdType: null, stripeSubscriptionId: null, subscriptionAmountCents: null, subscriptionCurrency: null, subscriptionInterval: null, subscriptionCurrentPeriodEnd: null, subscriptionCancelAt: null, }); useRefundListHandler('ch_test_refund_donation_101'); const result = await sendWebhook({ type: 'charge.refunded', data: { object: { id: 'ch_test_refund_donation_101', payment_intent: 'pi_test_donation_not_indexed_101', customer: donationCustomerId, amount_refunded: 2500, }, }, }); expect(result.received).toBe(true); }); test('preserves remaining premium when another redeemed gift is still active', async () => { const purchaser = await createTestAccount(harness); const redeemer = await createTestAccount(harness); const userRepository = new UserRepository(); await userRepository.createGiftCode({ code: 'GIFT_REFUND_FIRST', duration_months: null, duration_type: 'months', duration_quantity: 1, created_at: new Date(), created_by_user_id: createUserID(BigInt(purchaser.userId)), redeemed_at: new Date(), redeemed_by_user_id: createUserID(BigInt(redeemer.userId)), stripe_payment_intent_id: 'pi_test_gift_refund_first', visionary_sequence_number: null, checkout_session_id: null, version: 1, }); await userRepository.createGiftCode({ code: 'GIFT_REFUND_SECOND', duration_months: null, duration_type: 'months', duration_quantity: 1, created_at: new Date(), created_by_user_id: createUserID(BigInt(purchaser.userId)), redeemed_at: new Date(), redeemed_by_user_id: createUserID(BigInt(redeemer.userId)), stripe_payment_intent_id: 'pi_test_gift_refund_second', visionary_sequence_number: null, checkout_session_id: null, version: 1, }); await createBuilder(harness, redeemer.token) .post(`/test/users/${redeemer.userId}/premium`) .body({ premium_type: UserPremiumTypes.SUBSCRIPTION, premium_until: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), }) .execute(); useRefundListHandler('ch_test_refund_gift_multi_123'); await sendWebhook({ type: 'charge.refunded', data: { object: { id: 'ch_test_refund_gift_multi_123', payment_intent: 'pi_test_gift_refund_first', amount_refunded: 2500, }, }, }); const updatedRedeemer = await createBuilder<{ premium_type: number; premium_until: string | null; }>(harness, redeemer.token) .get('/users/@me') .execute(); expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION); expect(updatedRedeemer.premium_until).not.toBeNull(); }); test('handles gift refund when redeemer has stripe_customer_id but no active subscription', async () => { const purchaser = await createTestAccount(harness); const redeemer = await createTestAccount(harness); const userRepository = new UserRepository(); const redeemerId = createUserID(BigInt(redeemer.userId)); await userRepository.createGiftCode({ code: 'GIFT_REFUND_STRIPE_CUS_ONLY', duration_months: null, duration_type: 'months', duration_quantity: 1, created_at: new Date(), created_by_user_id: createUserID(BigInt(purchaser.userId)), redeemed_at: new Date(), redeemed_by_user_id: redeemerId, stripe_payment_intent_id: 'pi_test_gift_refund_stripe_cus', visionary_sequence_number: null, checkout_session_id: null, version: 1, }); await createBuilder(harness, redeemer.token) .post(`/test/users/${redeemer.userId}/premium`) .body({ premium_type: UserPremiumTypes.SUBSCRIPTION, premium_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), stripe_customer_id: 'cus_test_refund_redeemer', }) .execute(); const redeemerBefore = await createBuilder<{ premium_type: number; premium_until: string | null; }>(harness, redeemer.token) .get('/users/@me') .execute(); expect(redeemerBefore.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION); useRefundListHandler('ch_test_refund_stripe_cus_only'); await sendWebhook({ type: 'charge.refunded', data: { object: { id: 'ch_test_refund_stripe_cus_only', payment_intent: 'pi_test_gift_refund_stripe_cus', amount_refunded: 2500, }, }, }); const updatedRedeemer = await userRepository.findUnique(redeemerId); expect(updatedRedeemer).not.toBeNull(); expect(updatedRedeemer!.stripeCustomerId).toBe('cus_test_refund_redeemer'); }); test('preserves lifetime premium overrides on gift refund', async () => { const purchaser = await createTestAccount(harness); const redeemer = await createTestAccount(harness); const userRepository = new UserRepository(); await userRepository.createGiftCode({ code: 'GIFT_REFUND_OVERRIDE', duration_months: null, duration_type: 'months', duration_quantity: 1, created_at: new Date(), created_by_user_id: createUserID(BigInt(purchaser.userId)), redeemed_at: new Date(), redeemed_by_user_id: createUserID(BigInt(redeemer.userId)), stripe_payment_intent_id: 'pi_test_gift_refund_override', visionary_sequence_number: null, checkout_session_id: null, version: 1, }); await createBuilder(harness, redeemer.token) .post(`/test/users/${redeemer.userId}/premium`) .body({ premium_type: UserPremiumTypes.LIFETIME, }) .execute(); useRefundListHandler('ch_test_refund_gift_override_123'); await sendWebhook({ type: 'charge.refunded', data: { object: { id: 'ch_test_refund_gift_override_123', payment_intent: 'pi_test_gift_refund_override', amount_refunded: 2500, }, }, }); const updatedRedeemer = await createBuilder<{ premium_type: number; }>(harness, redeemer.token) .get('/users/@me') .execute(); 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(); }); }); });