From df58020f4c3c3634011b4b6c5643ffc6af71e226 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 20 Sep 2026 23:50:49 +0200 Subject: [PATCH] fix(api): keep premium paid for after a subscription cancels (#2875) --- .../ProcessPremiumStateReconciliationQueue.ts | 53 ++++++- ...essPremiumStateReconciliationQueue.test.ts | 148 ++++++++++++++++++ 2 files changed, 197 insertions(+), 4 deletions(-) diff --git a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts index f75059f69..34bdcb57e 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts @@ -8,6 +8,7 @@ import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import type {User} from '@app/api/models/User'; import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeSubscriptionAccessPolicy'; import { + getInvoiceLatestLinePeriodEnd, getPrimarySubscriptionItem, getSubscriptionPremiumPeriodEnd, getSubscriptionStartDate, @@ -142,6 +143,39 @@ function trackMostRecentTerminalSubscription( return current; } +const PAID_INVOICE_LOOKUP_LIMIT = 10; + +async function getPaidThroughFromSubscriptionInvoices( + stripe: Stripe, + subscription: Stripe.Subscription | null, +): Promise { + if (!subscription) { + return null; + } + let invoices: Stripe.ApiList; + 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( stripe: Stripe, user: User, @@ -240,17 +274,28 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri if (hasStalePremium && !hasNonStripePremium) { const patch: Partial = {}; let effectivePremiumUntil = getEffectivePremiumUntil(user); + const paidThrough = await getPaidThroughFromSubscriptionInvoices(stripe, mostRecentTerminalSubscription); if (mostRecentTerminalSubscription?.ended_at && user.premiumUntil) { const subscriptionEndedAt = new Date(mostRecentTerminalSubscription.ended_at * 1000); - if (subscriptionEndedAt.getTime() < user.premiumUntil.getTime()) { - patch.premium_until = subscriptionEndedAt; - patch.premium_grace_ends_at = subscriptionEndedAt; + const entitlementEnd = + paidThrough && paidThrough.getTime() > subscriptionEndedAt.getTime() ? paidThrough : subscriptionEndedAt; + if (entitlementEnd.getTime() < user.premiumUntil.getTime()) { + patch.premium_until = entitlementEnd; + patch.premium_grace_ends_at = entitlementEnd; effectivePremiumUntil = getEffectivePremiumUntil({ - premiumUntil: subscriptionEndedAt, + premiumUntil: entitlementEnd, 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(); if (hasFutureLocalEntitlement) { if (user.premiumWillCancel !== true) { diff --git a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts index 4b2dd4837..49a5e4201 100644 --- a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts +++ b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later 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 {User} from '@app/api/models/User'; import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider'; import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; 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 ONE_HOUR_MS = 60 * 60 * 1000; +const ONE_DAY_MS = 24 * ONE_HOUR_MS; + +function createPremiumUser(overrides: Partial = {}): 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 { + 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>; + extras: Record; +} { + const patches: Array> = []; + const userRepository = { + findUnique: async () => user, + patchUpsert: async (_id: unknown, patch: Partial) => { + 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 { return { @@ -151,4 +225,78 @@ describe('processPremiumStateReconciliationQueue', () => { expect(findUniqueCalls).toBe(1); 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(); + }); });