diff --git a/fluxer_api/src/api/stripe/services/RefundAllowance.ts b/fluxer_api/src/api/stripe/services/RefundAllowance.ts new file mode 100644 index 000000000..c7d5c836b --- /dev/null +++ b/fluxer_api/src/api/stripe/services/RefundAllowance.ts @@ -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> { + 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(); + 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}> { + const countedRefundIds = await listCountedRefundIds(user, userRepository); + return {blocked: countedRefundIds.length >= REFUND_ALLOWANCE_BLOCK_THRESHOLD, countedRefundIds}; +} diff --git a/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts index 3c40b3846..6edb7f76a 100644 --- a/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts @@ -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 = isFirstRefund - ? {first_refund_at: new Date()} - : {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED}; + let patch: Partial; + let countedRefundIds: Array = []; + 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', diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index cbf720aac..2bb39bb90 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -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 = isFirstRefund - ? {first_refund_at: new Date()} - : {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED}; + let patch: Partial; + let countedRefundIds: Array = []; + 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', ); } diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts index e0d0fcd85..19b787740 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookRefund.test.ts @@ -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, + 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)); diff --git a/fluxer_api/src/api/user/repositories/IUserContentRepository.ts b/fluxer_api/src/api/user/repositories/IUserContentRepository.ts index c60ac7f0a..c74002196 100644 --- a/fluxer_api/src/api/user/repositories/IUserContentRepository.ts +++ b/fluxer_api/src/api/user/repositories/IUserContentRepository.ts @@ -74,6 +74,7 @@ export interface IUserContentRepository { checkout_session_id: string; }, ): Promise; + findPaymentsByUserId(userId: UserID): Promise>; getPaymentByCheckoutSession(checkoutSessionId: string): Promise; getPaymentByPaymentIntent(paymentIntentId: string): Promise; getSubscriptionInfo(subscriptionId: string): Promise; diff --git a/fluxer_api/src/api/user/repositories/UserContentRepository.ts b/fluxer_api/src/api/user/repositories/UserContentRepository.ts index 1fc920cd1..c07a6dbfc 100644 --- a/fluxer_api/src/api/user/repositories/UserContentRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserContentRepository.ts @@ -116,6 +116,10 @@ export class UserContentRepository implements IUserContentRepository { return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId); } + async findPaymentsByUserId(userId: UserID): Promise> { + return this.paymentRepository.findPaymentsByUserId(userId); + } + async getPaymentByPaymentIntent(paymentIntentId: string): Promise { return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId); } diff --git a/fluxer_api/src/api/user/repositories/UserRepository.ts b/fluxer_api/src/api/user/repositories/UserRepository.ts index 37ae13a7c..42258cf42 100644 --- a/fluxer_api/src/api/user/repositories/UserRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserRepository.ts @@ -740,6 +740,10 @@ export class UserRepository implements IUserRepositoryAggregate { return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId); } + async findPaymentsByUserId(userId: UserID): Promise> { + return this.contentRepo.findPaymentsByUserId(userId); + } + async getPaymentByPaymentIntent(paymentIntentId: string): Promise { return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId); }