feat(premium): give failed renewals a billing-cycle grace period (#3066)

This commit is contained in:
Hampus
2026-09-30 18:48:01 +02:00
committed by GitHub
parent f5e32aed31
commit dfdfffe5de
53 changed files with 1974 additions and 1206 deletions
+1 -1
View File
@@ -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": {
@@ -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;
@@ -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<void> {
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);
@@ -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);
});
@@ -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}}}],
},
}
: {}),
},
},
};
@@ -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);
});
});
+12 -2
View File
@@ -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<RequiredAction, 'REQUIRE_INBOUND_PHONE_VERIFICATION'>;
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<PremiumCheckable, 'premiumUntil' | 'premiumGiftExtensionEndsAt'>,
@@ -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<UserRow> = {};
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;
@@ -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));