diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index 22e5867f9..7c6325177 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -25,6 +25,7 @@ import type {StripeSubscriptionService} from './StripeSubscriptionService'; const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; export const SELF_SERVE_REFUND_WINDOW_DAYS = 3; export const SELF_SERVE_REFUND_COOLDOWN_DAYS = 30; +export const PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS = 1; interface RefundTarget { invoice: Stripe.Invoice; @@ -206,6 +207,30 @@ export class StripeRefundService { }; } + private async resolveChargePaymentMethodType(stripe: Stripe, target: RefundTarget): Promise { + try { + if (target.chargeId) { + const charge = await stripe.charges.retrieve(target.chargeId); + return charge.payment_method_details?.type ?? null; + } + if (target.paymentIntentId) { + const paymentIntent = await stripe.paymentIntents.retrieve(target.paymentIntentId, { + expand: ['latest_charge'], + }); + const latestCharge = paymentIntent.latest_charge; + if (latestCharge && typeof latestCharge !== 'string') { + return latestCharge.payment_method_details?.type ?? null; + } + } + } catch (error) { + Logger.warn( + {error, chargeId: target.chargeId, paymentIntentId: target.paymentIntentId}, + 'Failed to resolve payment method type for self-serve refund', + ); + } + return null; + } + private async countPriorTerminalFailures(invoiceId: string): Promise { const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId); return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length; @@ -273,12 +298,18 @@ export class StripeRefundService { target.paymentIntentId ?? target.chargeId, ...(priorFailures > 0 ? [`retry-${priorFailures}`] : []), ].join(':'); + const paymentMethodType = await this.resolveChargePaymentMethodType(stripe, target); + const isPix = paymentMethodType === 'pix'; + const requestedAmountCents = + isPix && target.amountPaidCents > PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS + ? target.amountPaidCents - PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS + : target.amountPaidCents; let refund: Stripe.Response; try { refund = await stripe.refunds.create( { ...(target.paymentIntentId ? {payment_intent: target.paymentIntentId} : {charge: target.chargeId!}), - amount: target.amountPaidCents, + amount: requestedAmountCents, reason: 'requested_by_customer', metadata: { user_id: user.id.toString(), diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts index 2a22d9b45..1085eb2d1 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts @@ -589,8 +589,15 @@ export class StripeSubscriptionWebhookHandler { premium_billing_cycle: null, }; if (targetUser.premiumType !== UserPremiumTypes.LIFETIME) { - const anchorMs = Math.max(Date.now(), targetUser.premiumUntil?.getTime() ?? 0); - updates.premium_grace_ends_at = new Date(anchorMs + PREMIUM_GRACE_PERIOD_MS); + const subscriptionEndedAt = subscription.ended_at ? new Date(subscription.ended_at * 1000) : new Date(); + const cancelledBeforePeriodEnd = + targetUser.premiumUntil != null && subscriptionEndedAt.getTime() < targetUser.premiumUntil.getTime(); + if (cancelledBeforePeriodEnd) { + updates.premium_until = subscriptionEndedAt; + updates.premium_grace_ends_at = null; + } else { + updates.premium_grace_ends_at = new Date(subscriptionEndedAt.getTime() + PREMIUM_GRACE_PERIOD_MS); + } } const updatedUser = await this.userRepository.patchUpsert(targetUser.id, updates, targetUser.toRow()); await this.dispatchUser(updatedUser); diff --git a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts index 1520fe621..c26a51733 100644 --- a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts @@ -238,6 +238,22 @@ describe('StripeRefundService self-serve refund', () => { expect(response.refunded_amount_cents).toBe(2500); expect(response.subscription_id).toBeNull(); }); + test('requests one cent under the captured amount when the underlying charge was paid via PIX', async () => { + server.use( + ...createStripeApiHandlers({charges: {ch_in_recent: {payment_method_details: {type: 'pix'}}}}).handlers, + ); + server.use( + invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY, subscriptionId: null})]), + refundCreateHandler(), + ); + const account = await createTestAccount(harness); + await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID}); + const response = await createBuilder(harness, account.token) + .post('/premium/refund-latest') + .execute(); + expect(response.invoice_amount_paid_cents).toBe(2500); + expect(response.refunded_amount_cents).toBe(2499); + }); test('refunds latest invoice and cancels subscription when present', async () => { server.use(...createStripeApiHandlers().handlers); server.use( diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts index 4dc4b11cc..043957ff9 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts @@ -474,6 +474,56 @@ describe('Stripe Webhook Subscription Lifecycle', () => { expect(afterUser?.premiumType).toBe(UserPremiumTypes.LIFETIME); expect(afterUser?.stripeSubscriptionId).toBeNull(); }); + test('cuts premium off at the cancellation moment when the subscription is cancelled before its paid period ends', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const subscriptionId = 'sub_test_cancel_early'; + const premiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + await userRepository.patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_since: new Date(), + premium_until: premiumUntil, + }, + (await userRepository.findUnique(userId))!.toRow(), + ); + const endedAt = Math.floor(Date.now() / 1000); + const eventData = createSubscriptionDeletedEvent({subscriptionId, endedAt}); + const result = await sendWebhook(eventData); + expect(result.received).toBe(true); + const afterUser = await userRepository.findUnique(userId); + expect(afterUser?.premiumUntil?.getTime()).toBe(endedAt * 1000); + expect(afterUser?.premiumGraceEndsAt).toBeNull(); + expect(afterUser?.stripeSubscriptionId).toBeNull(); + }); + test('grants standard grace when the subscription is cancelled at the end of its paid period', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const subscriptionId = 'sub_test_cancel_natural'; + const premiumUntil = new Date(Date.now() - 60 * 1000); + await userRepository.patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + premium_since: new Date(), + premium_until: premiumUntil, + }, + (await userRepository.findUnique(userId))!.toRow(), + ); + const endedAt = Math.floor(Date.now() / 1000); + const eventData = createSubscriptionDeletedEvent({subscriptionId, endedAt}); + const result = await sendWebhook(eventData); + expect(result.received).toBe(true); + const afterUser = await userRepository.findUnique(userId); + expect(afterUser?.premiumUntil?.getTime()).toBe(premiumUntil.getTime()); + expect(afterUser?.premiumGraceEndsAt).not.toBeNull(); + expect(afterUser!.premiumGraceEndsAt!.getTime()).toBe(endedAt * 1000 + 3 * 24 * 60 * 60 * 1000); + }); test('processes donation subscription deletion', async () => { const subscriptionId = 'sub_donor_delete_test'; const donorEmail = 'donor-delete@example.com'; diff --git a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts index d67e14630..e05e6a54c 100644 --- a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts +++ b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts @@ -1855,7 +1855,9 @@ export function createSubscriptionUpdatedEvent(options: { export function createSubscriptionDeletedEvent(options: { subscriptionId?: string; customerId?: string; + endedAt?: number; }): StripeWebhookEventData { + const nowSeconds = Math.floor(Date.now() / 1000); return { type: 'customer.subscription.deleted', data: { @@ -1864,7 +1866,8 @@ export function createSubscriptionDeletedEvent(options: { object: 'subscription', customer: options.customerId ?? 'cus_test_1', status: 'canceled', - canceled_at: Math.floor(Date.now() / 1000), + canceled_at: nowSeconds, + ended_at: options.endedAt ?? nowSeconds, }, }, };