mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
871 lines
31 KiB
TypeScript
871 lines
31 KiB
TypeScript
// 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<Record<string, unknown>> = []): 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<typeof createUserID>,
|
|
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<Record<string, unknown>>) {
|
|
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: '[email protected]',
|
|
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();
|
|
});
|
|
});
|
|
});
|