mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(api): keep premium paid for after a subscription cancels (#2875)
This commit is contained in:
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 {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<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(
|
||||
stripe: Stripe,
|
||||
user: User,
|
||||
@@ -240,17 +274,28 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
|
||||
if (hasStalePremium && !hasNonStripePremium) {
|
||||
const patch: Partial<UserRow> = {};
|
||||
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) {
|
||||
|
||||
@@ -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<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 {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user