mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(premium): give failed renewals a billing-cycle grace period (#3066)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user