mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): stop counting one refund twice against the allowance (#3226)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user