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
@@ -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