diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 7c0d95223..fdaa3fa5e 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -22729,7 +22729,7 @@ "description": "The sequence number for lifetime premium subscribers" }, "premium_grace_ends_at": { - "description": "ISO8601 timestamp at which the post-cancel grace period ends. Set when the subscription is fully canceled in Stripe; perks remain active and the original premium_since is restored on resubscribe until this timestamp passes. Null when not in grace.", + "description": "ISO8601 timestamp at which grace access ends after premium_until passes: after a failed renewal payment (7 days from the renewal for monthly plans, 14 for yearly), after a subscription ends (3 days), or during an App Store or Google Play grace period. Perks stay active and the original premium_since is kept on resubscribe until this timestamp passes. Null when no grace is recorded, in which case access lasts 3 days after premium_until.", "type": ["string", "null"] }, "premium_discriminator": { diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts index 222c4186b..3896ef362 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts @@ -30,7 +30,7 @@ import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumS import type {StripeSubscriptionReconciler} from '@app/api/stripe/services/StripeSubscriptionReconciler'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository'; -import {PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers'; +import {getPremiumPaymentRecoveryGraceMs, PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError'; @@ -369,7 +369,7 @@ export class StripeSubscriptionWebhookHandler { ); return; } - const updatedUser = await this.markSubscriptionAsGraceDisabled(targetUser, { + const updatedUser = await this.markSubscriptionPaymentIssue(targetUser, { subscriptionId, customerId: this.reconciler.getCustomerIdFromInvoice(invoice), failedInvoiceServicePeriod: this.getInvoiceServicePeriod(invoice), @@ -507,10 +507,10 @@ export class StripeSubscriptionWebhookHandler { } const willCancel = getPremiumWillCancelFromSubscription(canonicalSubscription); if (!canProvisionPremiumFromSubscriptionStatus(canonicalSubscription.status)) { - const updatedUser = await this.markSubscriptionAsGraceDisabled(targetUser, { + const updatedUser = await this.markSubscriptionPaymentIssue(targetUser, { subscriptionId: canonicalSubscription.id, customerId: extractId(canonicalSubscription.customer), - failedInvoiceServicePeriod: null, + failedInvoiceServicePeriod: this.getUnpaidRenewalPeriod(targetUser, canonicalSubscription), }); await this.enqueuePremiumStateReconciliation(updatedUser.id, { reason: 'subscription_updated_non_provisionable', @@ -523,7 +523,7 @@ export class StripeSubscriptionWebhookHandler { status: canonicalSubscription.status, willCancel, }, - 'Subscription updated in non-provisionable state; preserved local expiry and disabled grace period', + 'Subscription updated in non-provisionable state; preserved local expiry', ); return; } @@ -663,7 +663,18 @@ export class StripeSubscriptionWebhookHandler { if (!alreadyAppliedEarlyCancellation) { const cancelledBeforePeriodEnd = targetUser.premiumUntil != null && subscriptionEndedAt.getTime() < targetUser.premiumUntil.getTime(); - if (cancelledBeforePeriodEnd) { + if (subscription.cancellation_details?.reason === 'payment_failed') { + const lapseStart = + targetUser.premiumUntil && targetUser.premiumUntil.getTime() < subscriptionEndedAt.getTime() + ? targetUser.premiumUntil + : subscriptionEndedAt; + const billingCycle = + targetUser.premiumBillingCycle ?? this.reconciler.getBillingCycleFromSubscription(subscription); + updates.premium_until = lapseStart; + updates.premium_grace_ends_at = new Date( + lapseStart.getTime() + getPremiumPaymentRecoveryGraceMs(billingCycle), + ); + } else if (cancelledBeforePeriodEnd) { updates.premium_until = subscriptionEndedAt; updates.premium_grace_ends_at = subscriptionEndedAt; } else { @@ -740,7 +751,7 @@ export class StripeSubscriptionWebhookHandler { ); } - private async markSubscriptionAsGraceDisabled( + private async markSubscriptionPaymentIssue( user: User, context: { subscriptionId: string | null; @@ -770,6 +781,17 @@ export class StripeSubscriptionWebhookHandler { ) { patch.premium_until = context.failedInvoiceServicePeriod.start; } + const lapseStart = patch.premium_until ?? user.premiumUntil; + if ( + user.premiumType === UserPremiumTypes.SUBSCRIPTION && + context.failedInvoiceServicePeriod && + lapseStart?.getTime() === context.failedInvoiceServicePeriod.start.getTime() + ) { + const graceEndsAt = new Date(lapseStart.getTime() + getPremiumPaymentRecoveryGraceMs(user.premiumBillingCycle)); + if (user.premiumGraceEndsAt?.getTime() !== graceEndsAt.getTime()) { + patch.premium_grace_ends_at = graceEndsAt; + } + } if (Object.keys(patch).length === 0) { return user; } @@ -778,6 +800,27 @@ export class StripeSubscriptionWebhookHandler { return (await this.restoreStoreEntitlement(updatedUser.id)) ?? updatedUser; } + private getUnpaidRenewalPeriod( + user: User, + subscription: Stripe.Subscription, + ): { + start: Date; + end: Date; + } | null { + if (subscription.status !== 'past_due' && subscription.status !== 'unpaid') { + return null; + } + const item = getPrimarySubscriptionItem(subscription); + if (!item?.current_period_start || !item.current_period_end || !user.premiumUntil) { + return null; + } + const start = new Date(item.current_period_start * 1000); + if (user.premiumUntil.getTime() !== start.getTime()) { + return null; + } + return {start, end: new Date(item.current_period_end * 1000)}; + } + private getInvoiceServicePeriod(invoice: Stripe.Invoice): { start: Date; end: Date; diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts index 8b5c5a358..e56a6562c 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts @@ -43,6 +43,7 @@ const MOCK_PRICES = { gift1YearEur: 'price_gift_1_year_eur', }; +const DAY_MS = 24 * 60 * 60 * 1000; const LEGACY_MONTHLY_BRL_PRICE = 'price_legacy_monthly_brl'; const LEGACY_YEARLY_BRL_PRICE = 'price_legacy_yearly_brl'; const UNMAPPED_PRICE = 'price_retired_unmapped_brl'; @@ -147,6 +148,7 @@ describe('Stripe Webhook - Invoice Events', () => { customerId: string; premiumUntil: Date; premiumWillCancel?: boolean; + billingCycle?: 'monthly' | 'yearly'; }): Promise { const {UserRepository} = await import('@app/api/user/repositories/UserRepository'); const userRepository = new UserRepository(); @@ -157,12 +159,44 @@ describe('Stripe Webhook - Invoice Events', () => { premium_type: UserPremiumTypes.SUBSCRIPTION, premium_until: params.premiumUntil, premium_will_cancel: params.premiumWillCancel ?? false, + ...(params.billingCycle ? {premium_billing_cycle: params.billingCycle} : {}), stripe_subscription_id: params.subscriptionId, stripe_customer_id: params.customerId, }, (await userRepository.findUnique(userId))!.toRow(), ); } + function createRenewalFailureEvent(params: { + invoiceId: string; + customerId: string; + subscriptionId: string; + periodStart: Date; + periodEnd: Date; + }): StripeWebhookEventData { + const eventData = createInvoicePaymentFailedEvent({ + invoiceId: params.invoiceId, + customerId: params.customerId, + subscriptionId: params.subscriptionId, + amountDue: 2500, + }); + eventData.data.object.billing_reason = 'subscription_cycle'; + eventData.data.object.lines = { + data: [ + { + period: { + start: Math.floor(params.periodStart.getTime() / 1000), + end: Math.floor(params.periodEnd.getTime() / 1000), + }, + parent: { + subscription_item_details: { + subscription: params.subscriptionId, + }, + }, + }, + ], + }; + return eventData; + } describe('invoice.payment_succeeded', () => { test('processes recurring subscription payment successfully', async () => { const account = await createTestAccount(harness); @@ -559,6 +593,7 @@ describe('Stripe Webhook - Invoice Events', () => { premium_type: UserPremiumTypes.SUBSCRIPTION, premium_until: existingPremiumUntil, premium_will_cancel: false, + premium_billing_cycle: 'monthly', stripe_subscription_id: subscriptionId, stripe_customer_id: 'cus_test_failed_invoice', }, @@ -592,12 +627,96 @@ describe('Stripe Webhook - Invoice Events', () => { premium_type: number | null; premium_until: string | null; premium_will_cancel: boolean; + premium_grace_ends_at: string | null; + }>(harness, account.token) + .get('/users/@me') + .execute(); + const failedPeriodStartMs = Math.floor(failedPeriodStart.getTime() / 1000) * 1000; + expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION); + expect(me.premium_until).toBe(new Date(failedPeriodStartMs).toISOString()); + expect(me.premium_will_cancel).toBe(true); + expect(me.premium_grace_ends_at).toBe(new Date(failedPeriodStartMs + 7 * DAY_MS).toISOString()); + }); + test('records a 14-day recovery deadline for a yearly subscription', async () => { + const account = await createTestAccount(harness); + const subscriptionId = `sub_failed_yearly_${Date.now()}`; + const failedPeriodStart = new Date(Math.floor((Date.now() - 60 * 60 * 1000) / 1000) * 1000); + const failedPeriodEnd = new Date(failedPeriodStart.getTime() + 365 * DAY_MS); + await createPaymentRecord({ + userId: account.userId, + subscriptionId, + priceId: MOCK_PRICES.yearlyUsd, + productType: ProductType.YEARLY_SUBSCRIPTION, + }); + await setSubscriptionUserState({ + accountUserId: account.userId, + subscriptionId, + customerId: 'cus_test_failed_yearly', + premiumUntil: failedPeriodStart, + billingCycle: 'yearly', + }); + const result = await sendWebhook( + createRenewalFailureEvent({ + invoiceId: `in_failed_yearly_${Date.now()}`, + customerId: 'cus_test_failed_yearly', + subscriptionId, + periodStart: failedPeriodStart, + periodEnd: failedPeriodEnd, + }), + ); + expect(result.received).toBe(true); + const me = await createBuilder<{ + premium_type: number | null; + premium_until: string | null; + premium_grace_ends_at: string | null; }>(harness, account.token) .get('/users/@me') .execute(); expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION); - expect(me.premium_until).toBe(new Date(Math.floor(failedPeriodStart.getTime() / 1000) * 1000).toISOString()); - expect(me.premium_will_cancel).toBe(true); + expect(me.premium_until).toBe(failedPeriodStart.toISOString()); + expect(me.premium_grace_ends_at).toBe(new Date(failedPeriodStart.getTime() + 14 * DAY_MS).toISOString()); + }); + test('does not move the recovery deadline on a repeated failure for the same period', async () => { + const account = await createTestAccount(harness); + const subscriptionId = `sub_failed_repeat_${Date.now()}`; + const failedPeriodStart = new Date(Math.floor((Date.now() - 2 * DAY_MS) / 1000) * 1000); + const failedPeriodEnd = new Date(failedPeriodStart.getTime() + 30 * DAY_MS); + await createPaymentRecord({ + userId: account.userId, + subscriptionId, + priceId: MOCK_PRICES.monthlyUsd, + productType: ProductType.MONTHLY_SUBSCRIPTION, + }); + await setSubscriptionUserState({ + accountUserId: account.userId, + subscriptionId, + customerId: 'cus_test_failed_repeat', + premiumUntil: failedPeriodStart, + billingCycle: 'monthly', + }); + const invoiceId = `in_failed_repeat_${Date.now()}`; + const expectedDeadline = new Date(failedPeriodStart.getTime() + 7 * DAY_MS).toISOString(); + for (const attempt of [1, 2]) { + const eventData = createRenewalFailureEvent({ + invoiceId, + customerId: 'cus_test_failed_repeat', + subscriptionId, + periodStart: failedPeriodStart, + periodEnd: failedPeriodEnd, + }); + eventData.id = `evt_failed_repeat_${attempt}_${Date.now()}`; + eventData.data.object.attempt_count = attempt; + const result = await sendWebhook(eventData); + expect(result.received).toBe(true); + const me = await createBuilder<{ + premium_until: string | null; + premium_grace_ends_at: string | null; + }>(harness, account.token) + .get('/users/@me') + .execute(); + expect(me.premium_until).toBe(failedPeriodStart.toISOString()); + expect(me.premium_grace_ends_at).toBe(expectedDeadline); + } }); test('ignores non-renewal invoice payment failures', async () => { const account = await createTestAccount(harness); @@ -688,11 +807,14 @@ describe('Stripe Webhook - Invoice Events', () => { const me = await createBuilder<{ premium_until: string | null; premium_will_cancel: boolean; + premium_grace_ends_at: string | null; }>(harness, account.token) .get('/users/@me') .execute(); - expect(me.premium_until).toBe(new Date(Math.floor(failedPeriodStart.getTime() / 1000) * 1000).toISOString()); + const failedPeriodStartMs = Math.floor(failedPeriodStart.getTime() / 1000) * 1000; + expect(me.premium_until).toBe(new Date(failedPeriodStartMs).toISOString()); expect(me.premium_will_cancel).toBe(true); + expect(me.premium_grace_ends_at).toBe(new Date(failedPeriodStartMs + 7 * DAY_MS).toISOString()); }); test('handles invoice.finalization_failed like a recurring access issue', async () => { const account = await createTestAccount(harness); diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts index b36f1c0ae..b64bade27 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookSubscription.test.ts @@ -19,6 +19,7 @@ import {server} from '@app/api/test/msw/server'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository'; import {UserRepository} from '@app/api/user/repositories/UserRepository'; +import {getPremiumPaymentRecoveryGraceMs, PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; import {HttpResponse, http} from 'msw'; @@ -26,6 +27,7 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from const MOCK_PRICES = { monthlyUsd: 'price_monthly_usd', + yearlyUsd: 'price_yearly_usd', }; describe('Stripe Webhook Subscription Lifecycle', () => { @@ -259,6 +261,80 @@ describe('Stripe Webhook Subscription Lifecycle', () => { expect(updatedUser?.stripeSubscriptionId).toBe(subscriptionId); expect(updatedUser?.stripeCustomerId).toBe('cus_test_past_due'); }); + async function seedPastDueUser(subscriptionId: string, premiumUntil: Date) { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const sessionId = `cs_${subscriptionId}_${Date.now()}`; + await paymentRepository.createPayment({ + checkout_session_id: sessionId, + user_id: userId, + price_id: MOCK_PRICES.monthlyUsd, + product_type: ProductType.MONTHLY_SUBSCRIPTION, + status: 'completed', + is_gift: false, + created_at: new Date(), + }); + await paymentRepository.updatePayment({ + checkout_session_id: sessionId, + subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_past_due', + status: 'completed', + }); + await userRepository.patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: premiumUntil, + premium_will_cancel: false, + premium_billing_cycle: 'monthly', + stripe_subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_past_due', + premium_since: new Date(), + }, + (await userRepository.findUnique(userId))!.toRow(), + ); + return userId; + } + function pastDueEvent(subscriptionId: string, periodStartSeconds: number, periodEndSeconds: number) { + const eventData = createSubscriptionUpdatedEvent({ + subscriptionId, + customerId: 'cus_test_past_due', + status: 'past_due', + cancelAtPeriodEnd: false, + }); + eventData.data.object.cancel_at = null; + eventData.data.object.items = { + data: [{current_period_start: periodStartSeconds, current_period_end: periodEndSeconds}], + }; + return eventData; + } + test('records the payment recovery deadline when past_due arrives before the failed invoice', async () => { + const subscriptionId = 'sub_test_past_due_first'; + const renewalSeconds = Math.floor(Date.now() / 1000) - 60 * 60; + const renewalAt = new Date(renewalSeconds * 1000); + const userId = await seedPastDueUser(subscriptionId, renewalAt); + const result = await sendWebhook( + pastDueEvent(subscriptionId, renewalSeconds, renewalSeconds + 30 * 24 * 60 * 60), + ); + expect(result.received).toBe(true); + const updatedUser = await userRepository.findUnique(userId); + expect(updatedUser?.premiumUntil?.getTime()).toBe(renewalAt.getTime()); + expect(updatedUser?.premiumGraceEndsAt?.getTime()).toBe( + renewalAt.getTime() + getPremiumPaymentRecoveryGraceMs('monthly'), + ); + }); + test('does not move premium back to the period start for a mid-period past_due', async () => { + const subscriptionId = 'sub_test_past_due_mid_period'; + const periodStartSeconds = Math.floor(Date.now() / 1000) - 20 * 24 * 60 * 60; + const periodEndSeconds = periodStartSeconds + 30 * 24 * 60 * 60; + const paidThrough = new Date(periodEndSeconds * 1000); + const userId = await seedPastDueUser(subscriptionId, paidThrough); + const result = await sendWebhook(pastDueEvent(subscriptionId, periodStartSeconds, periodEndSeconds)); + expect(result.received).toBe(true); + const updatedUser = await userRepository.findUnique(userId); + expect(updatedUser?.premiumUntil?.getTime()).toBe(paidThrough.getTime()); + expect(updatedUser?.premiumGraceEndsAt).toBeNull(); + }); test('does not clear premium when period end is missing', async () => { const account = await createTestAccount(harness); const userId = createUserID(BigInt(account.userId)); @@ -524,7 +600,113 @@ describe('Stripe Webhook Subscription Lifecycle', () => { 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); + expect(afterUser!.premiumGraceEndsAt!.getTime()).toBe(endedAt * 1000 + PREMIUM_GRACE_PERIOD_MS); + const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers'); + expect(checkHasActivePaidPremium(afterUser!)).toBe(true); + }); + test('keeps the payment recovery deadline when Stripe cancels for non-payment', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const subscriptionId = 'sub_test_cancel_non_payment'; + const sessionId = `cs_test_non_payment_${Date.now()}`; + await paymentRepository.createPayment({ + checkout_session_id: sessionId, + user_id: userId, + price_id: MOCK_PRICES.monthlyUsd, + product_type: ProductType.MONTHLY_SUBSCRIPTION, + status: 'completed', + is_gift: false, + created_at: new Date(), + }); + await paymentRepository.updatePayment({ + checkout_session_id: sessionId, + subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + status: 'completed', + }); + const lapseStart = new Date(Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000) * 1000); + const recoveryDeadline = new Date(lapseStart.getTime() + 7 * 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: lapseStart, + premium_billing_cycle: 'monthly', + premium_will_cancel: true, + premium_grace_ends_at: recoveryDeadline, + }, + (await userRepository.findUnique(userId))!.toRow(), + ); + const endedAt = Math.floor(Date.now() / 1000); + const eventData = createSubscriptionDeletedEvent({ + subscriptionId, + endedAt, + cancellationReason: 'payment_failed', + interval: 'month', + }); + const result = await sendWebhook(eventData); + expect(result.received).toBe(true); + const afterUser = await userRepository.findUnique(userId); + expect(afterUser?.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION); + expect(afterUser?.premiumUntil?.getTime()).toBe(lapseStart.getTime()); + expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(recoveryDeadline.getTime()); + expect(afterUser?.premiumGraceEndsAt?.getTime()).not.toBe(endedAt * 1000 + PREMIUM_GRACE_PERIOD_MS); + expect(afterUser?.stripeSubscriptionId).toBeNull(); + const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers'); + expect(checkHasActivePaidPremium(afterUser!)).toBe(true); + }); + test('records the recovery deadline from the payload cycle when none was recorded', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const subscriptionId = 'sub_test_cancel_non_payment_yearly'; + const sessionId = `cs_test_non_payment_yearly_${Date.now()}`; + await paymentRepository.createPayment({ + checkout_session_id: sessionId, + user_id: userId, + price_id: MOCK_PRICES.yearlyUsd, + product_type: ProductType.YEARLY_SUBSCRIPTION, + status: 'completed', + is_gift: false, + created_at: new Date(), + }); + await paymentRepository.updatePayment({ + checkout_session_id: sessionId, + subscription_id: subscriptionId, + stripe_customer_id: 'cus_test_1', + status: 'completed', + }); + const lapseStart = new Date(Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000) * 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: lapseStart, + premium_billing_cycle: null, + premium_grace_ends_at: null, + }, + (await userRepository.findUnique(userId))!.toRow(), + ); + const endedAt = Math.floor(Date.now() / 1000); + const eventData = createSubscriptionDeletedEvent({ + subscriptionId, + endedAt, + cancellationReason: 'payment_failed', + interval: 'year', + }); + const result = await sendWebhook(eventData); + expect(result.received).toBe(true); + const afterUser = await userRepository.findUnique(userId); + expect(afterUser?.premiumUntil?.getTime()).toBe(lapseStart.getTime()); + expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe( + lapseStart.getTime() + getPremiumPaymentRecoveryGraceMs('yearly'), + ); + expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(lapseStart.getTime() + 14 * 24 * 60 * 60 * 1000); const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers'); expect(checkHasActivePaidPremium(afterUser!)).toBe(true); }); diff --git a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts index 279f6cc86..12728763b 100644 --- a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts +++ b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts @@ -1857,6 +1857,8 @@ export function createSubscriptionDeletedEvent(options: { subscriptionId?: string; customerId?: string; endedAt?: number; + cancellationReason?: 'cancellation_requested' | 'payment_disputed' | 'payment_failed'; + interval?: 'month' | 'year'; }): StripeWebhookEventData { const nowSeconds = Math.floor(Date.now() / 1000); return { @@ -1869,6 +1871,15 @@ export function createSubscriptionDeletedEvent(options: { status: 'canceled', canceled_at: nowSeconds, ended_at: options.endedAt ?? nowSeconds, + ...(options.cancellationReason ? {cancellation_details: {reason: options.cancellationReason}} : {}), + ...(options.interval + ? { + items: { + object: 'list', + data: [{object: 'subscription_item', price: {recurring: {interval: options.interval}}}], + }, + } + : {}), }, }, }; diff --git a/fluxer_api/src/api/user/UserHelpers.test.ts b/fluxer_api/src/api/user/UserHelpers.test.ts index a41f686f9..02291c2ba 100644 --- a/fluxer_api/src/api/user/UserHelpers.test.ts +++ b/fluxer_api/src/api/user/UserHelpers.test.ts @@ -7,9 +7,11 @@ import { checkIsPremium, getEffectivePremiumUntil, getEffectiveSuspiciousFlags, + getPremiumPaymentRecoveryGraceMs, getRequiredActions, isSignInRefused, isTemporarilyBanned, + PREMIUM_GRACE_PERIOD_MS, } from '@app/api/user/UserHelpers'; import { DEFERRED_PHONE_ON_COMMUNITY_JOIN, @@ -289,3 +291,17 @@ describe('account standing', () => { expect(canOwnerRunBots(standing(0n))).toBe(true); }); }); + +describe('premium grace lengths', () => { + it('maps billing cycles to payment recovery grace', () => { + const day = 24 * 60 * 60 * 1000; + expect(getPremiumPaymentRecoveryGraceMs('monthly')).toBe(7 * day); + expect(getPremiumPaymentRecoveryGraceMs('yearly')).toBe(14 * day); + expect(getPremiumPaymentRecoveryGraceMs(null)).toBe(7 * day); + expect(getPremiumPaymentRecoveryGraceMs(undefined)).toBe(7 * day); + }); + + it('keeps the fallback grace at 3 days', () => { + expect(PREMIUM_GRACE_PERIOD_MS).toBe(3 * 24 * 60 * 60 * 1000); + }); +}); diff --git a/fluxer_api/src/api/user/UserHelpers.ts b/fluxer_api/src/api/user/UserHelpers.ts index d05dd443a..3b89bf3c1 100644 --- a/fluxer_api/src/api/user/UserHelpers.ts +++ b/fluxer_api/src/api/user/UserHelpers.ts @@ -9,12 +9,14 @@ import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils'; import { DEFERRABLE_PHONE_FLAGS, DEFERRED_PHONE_ON_COMMUNITY_JOIN, + PREMIUM_GRACE_PERIOD_DAYS, + PREMIUM_PAYMENT_RECOVERY_GRACE_DAYS, PremiumFlags, SuspiciousActivityFlags, UserFlags, } from '@fluxer/constants/src/UserConstants'; +import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants'; import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; -import {ms} from 'itty-time'; type ClauseAction = Exclude; type VerificationChannel = 'email' | 'phone'; @@ -214,7 +216,15 @@ interface PremiumCheckable { premiumFlags: number; } -export const PREMIUM_GRACE_PERIOD_MS = ms('3 days'); +export const PREMIUM_GRACE_PERIOD_MS = PREMIUM_GRACE_PERIOD_DAYS * MS_PER_DAY; + +export function getPremiumPaymentRecoveryGraceMs(billingCycle: string | null | undefined): number { + const days = + billingCycle === 'yearly' + ? PREMIUM_PAYMENT_RECOVERY_GRACE_DAYS.yearly + : PREMIUM_PAYMENT_RECOVERY_GRACE_DAYS.monthly; + return days * MS_PER_DAY; +} export function getEffectivePremiumUntil( user: Pick, diff --git a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts index 9a725d495..7541b4bed 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts @@ -75,6 +75,9 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip if (user.premiumWillCancel !== premiumWillCancel) { patch.premium_will_cancel = premiumWillCancel; } + if (user.premiumGraceEndsAt != null) { + patch.premium_grace_ends_at = null; + } if (premiumBillingCycle && user.premiumBillingCycle !== premiumBillingCycle) { patch.premium_billing_cycle = premiumBillingCycle; } @@ -293,7 +296,9 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri const patch: Partial = {}; let effectivePremiumUntil = getEffectivePremiumUntil(user); const paidThrough = await getPaidThroughFromSubscriptionInvoices(stripe, mostRecentTerminalSubscription); - if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) { + const terminalSubscriptionIsCurrent = + user.stripeSubscriptionId == null || mostRecentTerminalSubscription?.id === user.stripeSubscriptionId; + if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil && terminalSubscriptionIsCurrent) { const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000); const entitlementEnd = paidThrough && paidThrough.getTime() > subscriptionEndedAt.getTime() ? paidThrough : subscriptionEndedAt; @@ -314,7 +319,10 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt, }); } - const hasFutureLocalEntitlement = effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime(); + const graceEndsAt = patch.premium_grace_ends_at ?? user.premiumGraceEndsAt; + const hasFutureLocalEntitlement = + (effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime()) || + (graceEndsAt != null && Date.now() <= graceEndsAt.getTime()); if (hasFutureLocalEntitlement) { if (user.premiumWillCancel !== true) { patch.premium_will_cancel = true; diff --git a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts index 8d1471d44..e5e4d5799 100644 --- a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts +++ b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts @@ -71,6 +71,28 @@ function createActiveSubscription(periodEndMs: number): Stripe.Subscription { } as unknown as Stripe.Subscription; } +function createPastDueSubscription(): Stripe.Subscription { + return { + id: 'sub_test', + status: 'past_due', + customer: 'cus_test', + ended_at: null, + canceled_at: null, + cancel_at: null, + cancel_at_period_end: false, + trial_end: null, + start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000), + items: { + data: [ + { + current_period_end: Math.floor((Date.now() + 29 * ONE_DAY_MS) / 1000), + price: {recurring: {interval: 'month'}}, + }, + ], + }, + } as unknown as Stripe.Subscription; +} + function createPaidInvoice(periodEndMs: number): Stripe.Invoice { return { id: 'in_test', @@ -344,6 +366,153 @@ describe('processPremiumStateReconciliationQueue', () => { expect(patches[0].premium_until).toBeNull(); expect(patches[0].premium_since).toBeNull(); }); + test('keeps premium for a past_due subscription until the recorded payment recovery deadline', async () => { + const queueService = createQueueService(); + await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); + + const lapseStartMs = Math.floor((Date.now() - ONE_HOUR_MS) / 1000) * 1000; + const user = createPremiumUser({ + premium_until: new Date(lapseStartMs), + premium_will_cancel: true, + premium_billing_cycle: 'monthly', + premium_grace_ends_at: new Date(lapseStartMs + 7 * ONE_DAY_MS), + }); + const {userRepository, patches, extras} = createCapturingDeps(user); + + setWorkerDependenciesForTest({ + premiumStateReconciliationQueueService: queueService, + stripe: createStripeStub(createPastDueSubscription(), [createPaidInvoice(lapseStartMs)]), + userRepository, + ...extras, + }); + + await processPremiumStateReconciliationQueue({}, createHelpers()); + + expect(patches).toHaveLength(0); + expect(await queueService.getQueueSize()).toBe(0); + }); + + test('keeps the recovery deadline when an older cancelled subscription shares the customer', async () => { + const queueService = createQueueService(); + await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); + + const lapseStartMs = Math.floor((Date.now() - ONE_HOUR_MS) / 1000) * 1000; + const recoveryDeadline = new Date(lapseStartMs + 7 * ONE_DAY_MS); + const user = createPremiumUser({ + premium_until: new Date(lapseStartMs), + premium_will_cancel: true, + premium_billing_cycle: 'monthly', + premium_grace_ends_at: recoveryDeadline, + stripe_subscription_id: 'sub_current', + }); + const {userRepository, patches, extras} = createCapturingDeps(user); + const currentSubscription = {...createPastDueSubscription(), id: 'sub_current'} as Stripe.Subscription; + const olderSubscription = { + ...createCancelledSubscription(Date.now() - 90 * ONE_DAY_MS), + id: 'sub_older', + } as Stripe.Subscription; + + setWorkerDependenciesForTest({ + premiumStateReconciliationQueueService: queueService, + stripe: { + subscriptions: { + retrieve: async () => currentSubscription, + list: async () => ({data: [olderSubscription, currentSubscription]}), + }, + invoices: { + list: async () => ({data: [createPaidInvoice(Date.now() - 90 * ONE_DAY_MS)]}), + }, + } as unknown as Stripe, + userRepository, + ...extras, + }); + + await processPremiumStateReconciliationQueue({}, createHelpers()); + + expect(patches).toHaveLength(0); + }); + + test('clears a leftover recovery deadline once the subscription is active again', async () => { + const queueService = createQueueService(); + await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); + + const periodEndMs = Math.floor((Date.now() + 29 * ONE_DAY_MS) / 1000) * 1000; + const user = createPremiumUser({ + premium_until: new Date(periodEndMs), + premium_billing_cycle: 'monthly', + premium_grace_ends_at: new Date(Date.now() + 6 * ONE_DAY_MS), + }); + const {userRepository, patches, extras} = createCapturingDeps(user); + + setWorkerDependenciesForTest({ + premiumStateReconciliationQueueService: queueService, + stripe: createStripeStub(createActiveSubscription(periodEndMs), []), + userRepository, + ...extras, + }); + + await processPremiumStateReconciliationQueue({}, createHelpers()); + + expect(patches).toHaveLength(1); + expect(patches[0].premium_grace_ends_at).toBeNull(); + expect(patches[0].premium_until).toBeUndefined(); + }); + + test('strips a past_due subscription once the recovery deadline has passed', async () => { + const queueService = createQueueService(); + await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); + + const lapseStartMs = Math.floor((Date.now() - 7 * ONE_DAY_MS - 60_000) / 1000) * 1000; + const user = createPremiumUser({ + premium_until: new Date(lapseStartMs), + premium_will_cancel: true, + premium_billing_cycle: 'monthly', + premium_grace_ends_at: new Date(lapseStartMs + 7 * ONE_DAY_MS), + }); + const {userRepository, patches, extras} = createCapturingDeps(user); + + setWorkerDependenciesForTest({ + premiumStateReconciliationQueueService: queueService, + stripe: createStripeStub(createPastDueSubscription(), [createPaidInvoice(lapseStartMs)]), + userRepository, + ...extras, + }); + + await processPremiumStateReconciliationQueue({}, createHelpers()); + + expect(patches).toHaveLength(1); + expect(patches[0].premium_type).toBeNull(); + expect(patches[0].premium_grace_ends_at).toBeNull(); + }); + + test('honours the voluntary-cancel grace written by the delete webhook', async () => { + const queueService = createQueueService(); + await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); + + const endedAtMs = Math.floor((Date.now() - 60_000) / 1000) * 1000; + const user = createPremiumUser({ + premium_until: new Date(endedAtMs), + premium_will_cancel: false, + premium_billing_cycle: null, + stripe_subscription_id: null, + premium_grace_ends_at: new Date(endedAtMs + 3 * ONE_DAY_MS), + }); + const {userRepository, patches, extras} = createCapturingDeps(user); + + setWorkerDependenciesForTest({ + premiumStateReconciliationQueueService: queueService, + stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(endedAtMs)]), + userRepository, + ...extras, + }); + + await processPremiumStateReconciliationQueue({}, createHelpers()); + + expect(patches.every((patch) => patch.premium_type === undefined)).toBe(true); + expect(patches.every((patch) => patch.premium_until === undefined)).toBe(true); + expect(patches.every((patch) => patch.premium_grace_ends_at === undefined)).toBe(true); + }); + test('hands a user with an active App Store subscription and a stale Stripe customer to the store', async () => { const queueService = createQueueService(); await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); diff --git a/fluxer_app/src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx b/fluxer_app/src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx index 314bb480a..6ea165e1e 100644 --- a/fluxer_app/src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx +++ b/fluxer_app/src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx @@ -463,6 +463,22 @@ export const SubscriptionCard: React.FC = observer( ); })() + ) : gracePeriodInfo.isPaymentRecovery ? ( + (() => { + const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined; + return ( + + Your renewal payment failed but{' '} + {' '} + stay active until{' '} + {graceDate}. Update your + payment method before then to keep your subscription. + + ); + })() ) : isInGracePeriod ? ( (() => { const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined; @@ -844,7 +860,7 @@ export const SubscriptionCard: React.FC = observer( Cancel subscription )} - {isInGracePeriod && ( + {isInGracePeriod && !gracePeriodInfo.isPaymentRecovery && (