mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): preserve gifted trial time on upgrade (#1413)
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user