diff --git a/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts b/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts index 22d88e30b..900ad7d3c 100644 --- a/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts +++ b/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts @@ -34,6 +34,18 @@ export function getSubscriptionItemPeriodEnd(item: Stripe.SubscriptionItem | nul return periodEnd == null ? null : new Date(periodEnd * 1000); } +export function getSubscriptionEntitlementPeriodEndUnix( + subscription: Stripe.Subscription, + item: Stripe.SubscriptionItem | null, +): number | null { + const periodEnd = getSubscriptionItemPeriodEndUnix(item); + const trialEnd = subscription.trial_end ?? null; + if (trialEnd != null && periodEnd != null) { + return Math.max(trialEnd, periodEnd); + } + return trialEnd ?? periodEnd; +} + function getSubscriptionCurrentPeriodEnd(subscription: Stripe.Subscription): Date | null { const items = subscription.items?.data ?? []; if (items.length === 0) { diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts index 5389b3ab3..c2f4f02ba 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts @@ -28,6 +28,7 @@ import type {Currency} from '../../utils/CurrencyUtils'; import type {RecurringBillingCycle} from '../ProductRegistry'; import { getPrimarySubscriptionItem, + getSubscriptionEntitlementPeriodEndUnix, getSubscriptionItemPeriodEndUnix, getSubscriptionPremiumPeriodEnd, } from '../StripeSubscriptionPeriod'; @@ -448,7 +449,7 @@ export class StripeSubscriptionService { if (!this.stripe) { throw new StripePaymentNotAvailableError(); } - const periodEnd = getSubscriptionItemPeriodEndUnix(item); + const periodEnd = getSubscriptionEntitlementPeriodEndUnix(subscription, item); if (!periodEnd || periodEnd <= Math.floor(Date.now() / 1000)) { throw new StripeError('Subscription is missing a future period end for scheduled billing cycle change'); } @@ -478,6 +479,7 @@ export class StripeSubscriptionService { }); const currentPhase = this.buildCurrentSchedulePhase(schedule, currentSubscription, item, periodEnd); const firstInvoiceCredit = await this.buildPeriodEndCycleSwapCredit({ + subscription: currentSubscription, item, currentBillingCycle, targetBillingCycle, @@ -549,7 +551,7 @@ export class StripeSubscriptionService { throw new StripePaymentNotAvailableError(); } const item = getPrimarySubscriptionItem(subscription); - const periodEnd = getSubscriptionItemPeriodEndUnix(item); + const periodEnd = getSubscriptionEntitlementPeriodEndUnix(subscription, item); if (!item || !periodEnd || periodEnd <= Math.floor(Date.now() / 1000)) { throw new StripeError('Subscription is missing a future period end for scheduled cancellation'); } @@ -599,11 +601,13 @@ export class StripeSubscriptionService { } private async buildPeriodEndCycleSwapCredit({ + subscription, item, currentBillingCycle, targetBillingCycle, targetPriceId, }: { + subscription: Stripe.Subscription; item: Stripe.SubscriptionItem; currentBillingCycle: RecurringBillingCycle; targetBillingCycle: RecurringBillingCycle; @@ -612,6 +616,9 @@ export class StripeSubscriptionService { if (!this.stripe || currentBillingCycle !== 'monthly' || targetBillingCycle !== 'yearly') { return null; } + if (subscription.trial_end != null && subscription.trial_end > Math.floor(Date.now() / 1000)) { + return null; + } const currentAmountMinor = item.price.unit_amount; if (currentAmountMinor == null || currentAmountMinor <= 0) { return null; @@ -739,7 +746,7 @@ export class StripeSubscriptionService { }; }) .filter((phaseItem): phaseItem is {price: string; quantity: number} => phaseItem !== null) ?? []; - return { + const currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = { start_date: phase?.start_date ?? subscription.start_date ?? subscription.created, end_date: periodEnd, items: @@ -753,6 +760,15 @@ export class StripeSubscriptionService { ], proration_behavior: 'none', }; + const trialEnd = subscription.trial_end; + if (trialEnd != null && trialEnd > now) { + if (trialEnd >= periodEnd) { + currentPhase.trial = true; + } else { + currentPhase.trial_end = trialEnd; + } + } + return currentPhase; } async getCurrentSubscriptionPrice(userId: UserID): Promise { diff --git a/fluxer_api/src/api/stripe/tests/StripeSubscriptionService.test.ts b/fluxer_api/src/api/stripe/tests/StripeSubscriptionService.test.ts index 91b416319..466723856 100644 --- a/fluxer_api/src/api/stripe/tests/StripeSubscriptionService.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeSubscriptionService.test.ts @@ -473,6 +473,75 @@ describe('StripeSubscriptionService', () => { expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0); expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(0); }); + test('preserves gifted trial time when scheduling a monthly-to-yearly upgrade at period end', async () => { + const account = await createTestAccount(harness); + const currentPeriodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60; + const trialEnd = Math.floor(Date.now() / 1000) + 60 * 24 * 60 * 60; + stripeHandlers = createStripeApiHandlers({ + subscriptions: { + sub_test_gift_trial: { + price_id: MOCK_PRICES.monthlyUsd, + interval: 'month', + item_id: 'si_gift_trial_monthly', + current_period_start: currentPeriodStart, + current_period_end: trialEnd, + status: 'trialing', + trial_end: trialEnd, + }, + }, + }); + server.use(...stripeHandlers.handlers); + await createBuilder(harness, account.token) + .post(`/test/users/${account.userId}/premium`) + .body({ + stripe_subscription_id: 'sub_test_gift_trial', + premium_type: 1, + premium_billing_cycle: 'monthly', + premium_will_cancel: false, + }) + .execute(); + await createBuilder(harness, account.token) + .post('/premium/change-subscription') + .body({billing_cycle: 'yearly', effective_at: 'period_end'}) + .expect(204) + .execute(); + expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0); + expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(1); + expect(stripeHandlers.spies.createdSubscriptionSchedules[0]?.from_subscription).toBe('sub_test_gift_trial'); + expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1); + const scheduleUpdate = stripeHandlers.spies.updatedSubscriptionSchedules[0]; + expect(scheduleUpdate?.params.phases?.[0]?.end_date).toBe(String(trialEnd)); + expect(scheduleUpdate?.params.phases?.[0]?.trial).toBe('true'); + expect(scheduleUpdate?.params.phases?.[0]?.trial_end).toBeUndefined(); + expect(scheduleUpdate?.params.phases?.[0]?.items?.[0]?.price).toBe(MOCK_PRICES.monthlyUsd); + expect(scheduleUpdate?.params.phases?.[1]?.start_date).toBe(String(trialEnd)); + expect(scheduleUpdate?.params.phases?.[1]?.billing_cycle_anchor).toBe('phase_start'); + expect(scheduleUpdate?.params.phases?.[1]?.items?.[0]?.price).toBe(MOCK_PRICES.yearlyUsd); + expect(scheduleUpdate?.params.phases?.[1]?.add_invoice_items).toBeUndefined(); + const premiumState = await createBuilder<{ + billing: { + pending_subscription_change: { + target_billing_cycle: string; + effective_at: string; + initial_amount_minor: number | null; + recurring_amount_minor: number | null; + credit_amount_minor: number | null; + } | null; + }; + }>(harness, account.token) + .get('/premium/state') + .expect(200) + .execute(); + expect(premiumState.billing.pending_subscription_change).toEqual( + expect.objectContaining({ + target_billing_cycle: 'yearly', + effective_at: new Date(trialEnd * 1000).toISOString(), + initial_amount_minor: 4999, + recurring_amount_minor: 4999, + credit_amount_minor: null, + }), + ); + }); test('cancels a pending yearly upgrade without canceling the active subscription', async () => { const account = await createTestAccount(harness); const currentPeriodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60; diff --git a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts index 14c27b883..209c3836f 100644 --- a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts +++ b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts @@ -74,6 +74,8 @@ interface SubscriptionScheduleParams { start_date?: string; end_date?: string; billing_cycle_anchor?: string; + trial?: string; + trial_end?: string; metadata?: Record; add_invoice_items?: Array<{ discountable?: string;