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 {User} from '@app/api/models/User';
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 {StripePaymentFraudService} from '@app/api/stripe/services/StripePaymentFraudService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -236,9 +237,25 @@ export class StripeDisputeWebhookHandler {
return;
}
const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund
? {first_refund_at: new Date()}
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
let patch: Partial<UserRow>;
let countedRefundIds: Array<string> = [];
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;
try {
updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
@@ -253,7 +270,7 @@ export class StripeDisputeWebhookHandler {
}
await this.dispatchUser(updatedUser);
Logger.debug(
{userId: user.id, chargeId: charge.id, paymentIntentId},
{userId: user.id, chargeId: charge.id, paymentIntentId, countedRefundIds},
isFirstRefund
? 'First refund recorded - 30 day self-serve refund cooldown 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 type {User} from '@app/api/models/User';
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 type {StripeSubscriptionService} from '@app/api/stripe/services/StripeSubscriptionService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -271,17 +272,31 @@ export class StripeRefundService {
}
const claimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${refund.id}`;
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
if (claim !== 'claimed') {
if (claim === 'in_flight') {
Logger.debug(
{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;
}
const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund
? {first_refund_at: new Date()}
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
let patch: Partial<UserRow>;
let countedRefundIds: Array<string> = [];
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 {
await this.userRepository.patchUpsert(user.id, patch, user.toRow());
} catch (error) {
@@ -290,7 +305,13 @@ export class StripeRefundService {
}
await getBillingRepository().webhookEvents.markProcessed(claimKey);
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',
);
}
@@ -105,23 +105,14 @@ describe('Stripe Webhook Refund', () => {
expect(updatedPayment).not.toBeNull();
expect(updatedPayment!.status).toBe('refunded');
});
test('applies permanent purchase block on second refund', 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(),
);
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_second_456';
const checkoutSessionId = 'cs_test_refund_second_456';
const paymentIntentId = `pi_test_refund_${suffix}`;
const checkoutSessionId = `cs_test_refund_${suffix}`;
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
@@ -136,14 +127,41 @@ describe('Stripe Webhook Refund', () => {
payment_intent_id: paymentIntentId,
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({
type: 'charge.refunded',
data: {
object: {
id: 'ch_test_refund_456',
id: chargeId,
payment_intent: paymentIntentId,
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!.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));
@@ -74,6 +74,7 @@ export interface IUserContentRepository {
checkout_session_id: string;
},
): Promise<void>;
findPaymentsByUserId(userId: UserID): Promise<Array<Payment>>;
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
@@ -116,6 +116,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
}
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
return this.paymentRepository.findPaymentsByUserId(userId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
}
@@ -740,6 +740,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
}
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
return this.contentRepo.findPaymentsByUserId(userId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
}