mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +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 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user