fix(api): stop counting one refund twice against the allowance (#3226)

This commit is contained in:
Hampus
2026-10-05 17:41:28 +02:00
committed by GitHub
parent 283d179b05
commit fd35b4da24
7 changed files with 170 additions and 27 deletions
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {BillingRefundRow} from '@app/api/database/types/BillingTypes';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
export const REFUND_ALLOWANCE_BLOCK_THRESHOLD = 2;
function isCountedAgainstAllowance(refund: BillingRefundRow): boolean {
if (refund.status !== 'succeeded') {
return false;
}
return (refund.metadata?.get('rejection_reason') ?? null) === null;
}
export async function listCountedRefundIds(user: User, userRepository: IUserRepository): Promise<Array<string>> {
const payments = await userRepository.findPaymentsByUserId(user.id);
const paymentIntentIds = [
...new Set(payments.map((payment) => payment.paymentIntentId).filter((id): id is string => id !== null)),
];
const counted = new Set<string>();
for (const paymentIntentId of paymentIntentIds) {
for (const refund of await getBillingRepository().refunds.listByPaymentIntent(paymentIntentId)) {
if (isCountedAgainstAllowance(refund)) {
counted.add(refund.provider_id);
}
}
}
return [...counted].sort();
}
export async function shouldBlockFurtherPurchases(
user: User,
userRepository: IUserRepository,
): Promise<{blocked: boolean; countedRefundIds: Array<string>}> {
const countedRefundIds = await listCountedRefundIds(user, userRepository);
return {blocked: countedRefundIds.length >= REFUND_ALLOWANCE_BLOCK_THRESHOLD, countedRefundIds};
}
@@ -11,6 +11,7 @@ import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {GiftCode} from '@app/api/models/GiftCode'; import type {GiftCode} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User'; import type {User} from '@app/api/models/User';
import {extractId} from '@app/api/stripe/StripeUtils'; import {extractId} from '@app/api/stripe/StripeUtils';
import {shouldBlockFurtherPurchases} from '@app/api/stripe/services/RefundAllowance';
import type {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler'; import type {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
import type {StripePaymentFraudService} from '@app/api/stripe/services/StripePaymentFraudService'; import type {StripePaymentFraudService} from '@app/api/stripe/services/StripePaymentFraudService';
import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -236,9 +237,25 @@ export class StripeDisputeWebhookHandler {
return; return;
} }
const isFirstRefund = !user.firstRefundAt; const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund let patch: Partial<UserRow>;
? {first_refund_at: new Date()} let countedRefundIds: Array<string> = [];
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED}; if (isFirstRefund) {
patch = {first_refund_at: new Date()};
} else {
const outcome = await shouldBlockFurtherPurchases(user, this.userRepository);
countedRefundIds = outcome.countedRefundIds;
if (!outcome.blocked) {
for (const claimKey of claimedKeys) {
await getBillingRepository().webhookEvents.markProcessed(claimKey);
}
Logger.info(
{userId: user.id, chargeId: charge.id, paymentIntentId, countedRefundIds},
'Refund redelivered after the allowance claim expired; not counting it twice',
);
return;
}
patch = {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
}
let updatedUser: User; let updatedUser: User;
try { try {
updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow()); updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
@@ -253,7 +270,7 @@ export class StripeDisputeWebhookHandler {
} }
await this.dispatchUser(updatedUser); await this.dispatchUser(updatedUser);
Logger.debug( Logger.debug(
{userId: user.id, chargeId: charge.id, paymentIntentId}, {userId: user.id, chargeId: charge.id, paymentIntentId, countedRefundIds},
isFirstRefund isFirstRefund
? 'First refund recorded - 30 day self-serve refund cooldown applied' ? 'First refund recorded - 30 day self-serve refund cooldown applied'
: 'Second refund recorded - permanent purchase block applied', : 'Second refund recorded - permanent purchase block applied',
@@ -7,6 +7,7 @@ import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User'; import type {User} from '@app/api/models/User';
import {extractId} from '@app/api/stripe/StripeUtils'; import {extractId} from '@app/api/stripe/StripeUtils';
import {shouldBlockFurtherPurchases} from '@app/api/stripe/services/RefundAllowance';
import {REFUND_ALLOWANCE_CLAIM_PREFIX} from '@app/api/stripe/services/StripeDisputeWebhookHandler'; import {REFUND_ALLOWANCE_CLAIM_PREFIX} from '@app/api/stripe/services/StripeDisputeWebhookHandler';
import type {StripeSubscriptionService} from '@app/api/stripe/services/StripeSubscriptionService'; import type {StripeSubscriptionService} from '@app/api/stripe/services/StripeSubscriptionService';
import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -271,17 +272,31 @@ export class StripeRefundService {
} }
const claimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${refund.id}`; const claimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${refund.id}`;
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey); const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
if (claim !== 'claimed') { if (claim === 'in_flight') {
Logger.debug( Logger.debug(
{userId: user.id.toString(), refundId: refund.id, claim}, {userId: user.id.toString(), refundId: refund.id, claim},
'Self-serve refund already counted against the user refund allowance', 'Self-serve refund allowance is being counted by another delivery',
); );
return; return;
} }
const isFirstRefund = !user.firstRefundAt; const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund let patch: Partial<UserRow>;
? {first_refund_at: new Date()} let countedRefundIds: Array<string> = [];
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED}; if (isFirstRefund) {
patch = {first_refund_at: new Date()};
} else {
const outcome = await shouldBlockFurtherPurchases(user, this.userRepository);
countedRefundIds = outcome.countedRefundIds;
if (!outcome.blocked) {
await getBillingRepository().webhookEvents.markProcessed(claimKey);
Logger.info(
{userId: user.id.toString(), refundId: refund.id, countedRefundIds},
'Self-serve refund redelivered after the allowance claim expired; not counting it twice',
);
return;
}
patch = {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
}
try { try {
await this.userRepository.patchUpsert(user.id, patch, user.toRow()); await this.userRepository.patchUpsert(user.id, patch, user.toRow());
} catch (error) { } catch (error) {
@@ -290,7 +305,13 @@ export class StripeRefundService {
} }
await getBillingRepository().webhookEvents.markProcessed(claimKey); await getBillingRepository().webhookEvents.markProcessed(claimKey);
Logger.info( Logger.info(
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null, isFirstRefund}, {
userId: user.id.toString(),
refundId: refund.id,
subscriptionId: subscriptionId || null,
isFirstRefund,
countedRefundIds,
},
'Self-serve refund confirmed succeeded; refund allowance and cancellation finalized', 'Self-serve refund confirmed succeeded; refund allowance and cancellation finalized',
); );
} }
@@ -105,23 +105,14 @@ describe('Stripe Webhook Refund', () => {
expect(updatedPayment).not.toBeNull(); expect(updatedPayment).not.toBeNull();
expect(updatedPayment!.status).toBe('refunded'); expect(updatedPayment!.status).toBe('refunded');
}); });
test('applies permanent purchase block on second refund', async () => { async function seedRefundedPayment(
const account = await createTestAccount(harness); userId: ReturnType<typeof createUserID>,
const userId = createUserID(BigInt(account.userId)); suffix: string,
const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); ): Promise<{paymentIntentId: string; chargeId: string}> {
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 {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository'); const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
const paymentRepository = new PaymentRepository(); const paymentRepository = new PaymentRepository();
const paymentIntentId = 'pi_test_refund_second_456'; const paymentIntentId = `pi_test_refund_${suffix}`;
const checkoutSessionId = 'cs_test_refund_second_456'; const checkoutSessionId = `cs_test_refund_${suffix}`;
await paymentRepository.createPayment({ await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId, checkout_session_id: checkoutSessionId,
user_id: userId, user_id: userId,
@@ -136,14 +127,41 @@ describe('Stripe Webhook Refund', () => {
payment_intent_id: paymentIntentId, payment_intent_id: paymentIntentId,
completed_at: new Date(), completed_at: new Date(),
}); });
useRefundListHandler('ch_test_refund_456'); 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({ await sendWebhook({
type: 'charge.refunded', type: 'charge.refunded',
data: { data: {
object: { object: {
id: 'ch_test_refund_456', id: chargeId,
payment_intent: paymentIntentId, payment_intent: paymentIntentId,
amount_refunded: 2500, amount_refunded: 2500,
refunds: {object: 'list', has_more: false, url: `/v1/charges/${chargeId}/refunds`, data: [earlier, latest]},
}, },
}, },
}); });
@@ -155,6 +173,45 @@ describe('Stripe Webhook Refund', () => {
expect(updatedPayment).not.toBeNull(); expect(updatedPayment).not.toBeNull();
expect(updatedPayment!.status).toBe('refunded'); 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 () => { test('counts a refund once when the same charge.refunded event is redelivered', async () => {
const account = await createTestAccount(harness); const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId)); const userId = createUserID(BigInt(account.userId));
@@ -74,6 +74,7 @@ export interface IUserContentRepository {
checkout_session_id: string; checkout_session_id: string;
}, },
): Promise<void>; ): Promise<void>;
findPaymentsByUserId(userId: UserID): Promise<Array<Payment>>;
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>; getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>; getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>; getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
@@ -116,6 +116,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId); return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
} }
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
return this.paymentRepository.findPaymentsByUserId(userId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> { async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId); return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
} }
@@ -740,6 +740,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId); return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
} }
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
return this.contentRepo.findPaymentsByUserId(userId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> { async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId); return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
} }