fix(api): preserve gifted trial time on upgrade (#1413)

This commit is contained in:
Hampus authored and GitHub committed 2026-07-15 23:58:30 +02:00
1 parent 2896b1871d
commit 483b90fb08
4 files changed
+102 -3

No files matched your search

@@ -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) {
@@ -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<CurrentSubscriptionPriceResponse> {
@@ -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;
@@ -74,6 +74,8 @@ interface SubscriptionScheduleParams {
start_date?: string;
end_date?: string;
billing_cycle_anchor?: string;
trial?: string;
trial_end?: string;
metadata?: Record<string, string>;
add_invoice_items?: Array<{
discountable?: string;