fix(billing): stack gifts from paid period end after trial ends (#3406)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 20:08:19 +02:00
1 parent bcb30eea88
commit 74c6fad260
3 files changed
+49 -4

No files matched your search

@@ -9,7 +9,7 @@ export function getGiftTrialMetadataKey(giftCode: string): string {
return `${GIFT_TRIAL_KEY_PREFIX}${giftCode}`; return `${GIFT_TRIAL_KEY_PREFIX}${giftCode}`;
} }
function isGiftTrialRunning(subscription: Stripe.Subscription, nowMs: number): boolean { export function isGiftTrialRunning(subscription: Stripe.Subscription, nowMs: number): boolean {
return subscription.trial_end != null && subscription.trial_end * 1000 > nowMs; return subscription.trial_end != null && subscription.trial_end * 1000 > nowMs;
} }
@@ -10,7 +10,11 @@ import {addGiftCodeDuration} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User'; import type {User} from '@app/api/models/User';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {ProductInfo, RecurringBillingCycle} from '@app/api/stripe/ProductRegistry'; import type {ProductInfo, RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
import {buildGiftTrialMetadata, getGiftTrialPaidUntil} from '@app/api/stripe/StripeGiftTrialMetadata'; import {
buildGiftTrialMetadata,
getGiftTrialPaidUntil,
isGiftTrialRunning,
} from '@app/api/stripe/StripeGiftTrialMetadata';
import { import {
getPrimarySubscriptionItem, getPrimarySubscriptionItem,
getSubscriptionEntitlementPeriodEndUnix, getSubscriptionEntitlementPeriodEndUnix,
@@ -1293,7 +1297,8 @@ export class StripeSubscriptionService {
const currentTrialEnd = subscription.trial_end; const currentTrialEnd = subscription.trial_end;
const item = getPrimarySubscriptionItem(subscription); const item = getPrimarySubscriptionItem(subscription);
const currentPeriodEnd = getSubscriptionItemPeriodEndUnix(item); const currentPeriodEnd = getSubscriptionItemPeriodEndUnix(item);
const baseUnix = currentTrialEnd ?? currentPeriodEnd; const nowMs = Date.now();
const baseUnix = isGiftTrialRunning(subscription, nowMs) ? currentTrialEnd : currentPeriodEnd;
Logger.debug( Logger.debug(
{ {
userId: user.id, userId: user.id,
@@ -1331,7 +1336,7 @@ export class StripeSubscriptionService {
{ {
trial_end: newTrialEndUnix, trial_end: newTrialEndUnix,
proration_behavior: 'none', proration_behavior: 'none',
metadata: buildGiftTrialMetadata(subscription, idempotencyKey, baseUnix, newTrialEndUnix, Date.now()), metadata: buildGiftTrialMetadata(subscription, idempotencyKey, baseUnix, newTrialEndUnix, nowMs),
}, },
{idempotencyKey: stripeIdempotencyKey}, {idempotencyKey: stripeIdempotencyKey},
); );
@@ -749,5 +749,45 @@ describe('gift time around a Stripe subscription', () => {
const afterWebhook = await findUser(account.userId); const afterWebhook = await findUser(account.userId);
expect(afterWebhook.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime()); expect(afterWebhook.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime());
}); });
test('stacks from the paid period end when an earlier gift trial has ended', async () => {
const subscriptionId = 'sub_shift_stale_trial';
const periodEnd = Math.floor((Date.now() + ms('20 days')) / 1000);
const staleTrialEnd = periodEnd - 60 * 24 * 60 * 60;
useStripe({
subscriptions: {
[subscriptionId]: {
current_period_start: periodEnd - 30 * 24 * 60 * 60,
current_period_end: periodEnd,
trial_end: staleTrialEnd,
metadata: giftTrialMetadata('OLDGIFT', 30, staleTrialEnd - 30 * 24 * 60 * 60),
},
},
});
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: new Date(periodEnd * 1000),
});
const gifter = await createTestAccount(harness);
await createBuilder(harness, gifter.token)
.post('/test/gifts/SHIFTSTALEGIFT')
.body({duration_type: 'days', duration_quantity: 30, created_by_user_id: gifter.userId})
.execute();
await createBuilder(harness, account.token).post('/gifts/SHIFTSTALEGIFT/redeem').expect(204).execute();
const update = stripeHandlers.spies.updatedSubscriptions[0]?.params;
expect(Number(update?.trial_end)).toBe(periodEnd + 30 * 24 * 60 * 60);
expect(update?.metadata).toEqual({
gtrial_OLDGIFT: '',
gtrial_paid_until: String(periodEnd),
gtrial_SHIFTSTALEGIFT: String(30 * 24 * 60 * 60),
});
const redeemed = await findUser(account.userId);
expect(redeemed.premiumUntil?.getTime()).toBe((periodEnd + 30 * 24 * 60 * 60) * 1000);
});
}); });
}); });