fix(api): keep premium paid for after a subscription cancels (#2875)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-20 23:50:49 +02:00
1 parent f052ce05aa
commit df58020f4c
2 files changed
+197 -4

No files matched your search

@@ -8,6 +8,7 @@ import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User'; import type {User} from '@app/api/models/User';
import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeSubscriptionAccessPolicy'; import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
import { import {
getInvoiceLatestLinePeriodEnd,
getPrimarySubscriptionItem, getPrimarySubscriptionItem,
getSubscriptionPremiumPeriodEnd, getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate, getSubscriptionStartDate,
@@ -142,6 +143,39 @@ function trackMostRecentTerminalSubscription(
return current; return current;
} }
const PAID_INVOICE_LOOKUP_LIMIT = 10;
async function getPaidThroughFromSubscriptionInvoices(
stripe: Stripe,
subscription: Stripe.Subscription | null,
): Promise<Date | null> {
if (!subscription) {
return null;
}
let invoices: Stripe.ApiList<Stripe.Invoice>;
try {
invoices = await stripe.invoices.list({
subscription: subscription.id,
status: 'paid',
limit: PAID_INVOICE_LOOKUP_LIMIT,
});
} catch (error) {
Logger.warn(
{subscriptionId: subscription.id, error},
'Failed to list paid invoices while reconciling premium state; not stripping on incomplete data',
);
throw error;
}
let paidThrough: Date | null = null;
for (const invoice of invoices.data) {
const periodEnd = getInvoiceLatestLinePeriodEnd(invoice);
if (periodEnd && (paidThrough === null || periodEnd.getTime() > paidThrough.getTime())) {
paidThrough = periodEnd;
}
}
return paidThrough;
}
async function getEffectiveActiveStripeSubscription( async function getEffectiveActiveStripeSubscription(
stripe: Stripe, stripe: Stripe,
user: User, user: User,
@@ -240,17 +274,28 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
if (hasStalePremium && !hasNonStripePremium) { if (hasStalePremium && !hasNonStripePremium) {
const patch: Partial<UserRow> = {}; const patch: Partial<UserRow> = {};
let effectivePremiumUntil = getEffectivePremiumUntil(user); let effectivePremiumUntil = getEffectivePremiumUntil(user);
const paidThrough = await getPaidThroughFromSubscriptionInvoices(stripe, mostRecentTerminalSubscription);
if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) { if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) {
const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000); const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000);
if (subscriptionEndedAt.getTime() < user.premiumUntil.getTime()) { const entitlementEnd =
patch.premium_until = subscriptionEndedAt; paidThrough && paidThrough.getTime() > subscriptionEndedAt.getTime() ? paidThrough : subscriptionEndedAt;
patch.premium_grace_ends_at = subscriptionEndedAt; if (entitlementEnd.getTime() < user.premiumUntil.getTime()) {
patch.premium_until = entitlementEnd;
patch.premium_grace_ends_at = entitlementEnd;
effectivePremiumUntil = getEffectivePremiumUntil({ effectivePremiumUntil = getEffectivePremiumUntil({
premiumUntil: subscriptionEndedAt, premiumUntil: entitlementEnd,
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt, premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
}); });
} }
} }
if (paidThrough && (effectivePremiumUntil == null || paidThrough.getTime() > effectivePremiumUntil.getTime())) {
patch.premium_until = paidThrough;
patch.premium_grace_ends_at = paidThrough;
effectivePremiumUntil = getEffectivePremiumUntil({
premiumUntil: paidThrough,
premiumGiftExtensionEndsAt: user.premiumGiftExtensionEndsAt,
});
}
const hasFutureLocalEntitlement = effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime(); const hasFutureLocalEntitlement = effectivePremiumUntil != null && Date.now() <= effectivePremiumUntil.getTime();
if (hasFutureLocalEntitlement) { if (hasFutureLocalEntitlement) {
if (user.premiumWillCancel !== true) { if (user.premiumWillCancel !== true) {
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {createUserID} from '@app/api/BrandedTypes'; import {createUserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
import {User} from '@app/api/models/User';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider'; import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import type {UserRepository} from '@app/api/user/repositories/UserRepository'; import type {UserRepository} from '@app/api/user/repositories/UserRepository';
@@ -13,6 +16,77 @@ import {afterEach, describe, expect, test} from 'vitest';
const USER_ID = createUserID(834271905123471361n); const USER_ID = createUserID(834271905123471361n);
const ONE_HOUR_MS = 60 * 60 * 1000; const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
function createPremiumUser(overrides: Partial<UserRow> = {}): User {
return new User({
...EMPTY_USER_ROW,
user_id: USER_ID,
username: 'paidlate',
discriminator: 1,
premium_type: 1,
premium_since: new Date(Date.now() - 200 * ONE_DAY_MS),
premium_until: new Date(Date.now() + 20 * ONE_DAY_MS),
stripe_customer_id: 'cus_test',
stripe_subscription_id: 'sub_test',
...overrides,
});
}
function createCancelledSubscription(endedAtMs: number): Stripe.Subscription {
return {
id: 'sub_test',
status: 'canceled',
customer: 'cus_test',
ended_at: Math.floor(endedAtMs / 1000),
canceled_at: Math.floor(endedAtMs / 1000),
cancel_at: null,
cancel_at_period_end: false,
trial_end: null,
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
items: {data: []},
} as unknown as Stripe.Subscription;
}
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
return {
id: 'in_test',
status: 'paid',
lines: {data: [{period: {start: 0, end: Math.floor(periodEndMs / 1000)}}]},
} as unknown as Stripe.Invoice;
}
function createStripeStub(subscription: Stripe.Subscription, invoices: Array<Stripe.Invoice>): Stripe {
return {
subscriptions: {
retrieve: async () => subscription,
list: async () => ({data: [subscription]}),
},
invoices: {
list: async () => ({data: invoices}),
},
} as unknown as Stripe;
}
function createCapturingDeps(user: User): {
userRepository: UserRepository;
patches: Array<Partial<UserRow>>;
extras: Record<string, unknown>;
} {
const patches: Array<Partial<UserRow>> = [];
const userRepository = {
findUnique: async () => user,
patchUpsert: async (_id: unknown, patch: Partial<UserRow>) => {
patches.push(patch);
return new User({...user.toRow(), ...patch});
},
} as unknown as UserRepository;
const extras = {
userCacheService: {setUserPartialResponseFromUserInBackground: () => {}},
gatewayService: {dispatchPresence: async () => {}},
};
return {userRepository, patches, extras};
}
function createHelpers(): WorkerTaskHelpers { function createHelpers(): WorkerTaskHelpers {
return { return {
@@ -151,4 +225,78 @@ describe('processPremiumStateReconciliationQueue', () => {
expect(findUniqueCalls).toBe(1); expect(findUniqueCalls).toBe(1);
expect(await queueService.getQueueSize()).toBe(0); expect(await queueService.getQueueSize()).toBe(0);
}); });
test('keeps premium when a paid invoice covers a period beyond the cancellation', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
const paidThroughMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000;
const user = createPremiumUser({premium_until: new Date(paidThroughMs)});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.premium_type).toBeUndefined();
expect(patch.premium_since).toBeUndefined();
expect(patch.premium_until).toBeUndefined();
expect(patch.premium_will_cancel).toBe(true);
});
test('extends premium to the paid period instead of the cancellation date', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
const paidThroughMs = Date.now() + 20 * ONE_DAY_MS;
const user = createPremiumUser({premium_until: new Date(Date.now() + 5 * ONE_DAY_MS)});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(1);
expect(patches[0].premium_until).toEqual(new Date(Math.floor(paidThroughMs / 1000) * 1000));
expect(patches[0].premium_will_cancel).toBe(true);
expect(patches[0].premium_type).toBeUndefined();
});
test('still strips premium when no paid invoice covers a future period', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const endedAtMs = Date.now() - 3 * ONE_DAY_MS;
const paidThroughMs = Date.now() - 3 * ONE_DAY_MS;
const user = createPremiumUser();
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(paidThroughMs)]),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(1);
expect(patches[0].premium_type).toBeNull();
expect(patches[0].premium_until).toBeNull();
expect(patches[0].premium_since).toBeNull();
});
}); });