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 {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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user