AFFiNE/packages/backend/server/tests/payment/service.spec.ts

1108 lines
29 KiB
TypeScript
Raw Normal View History

feat(server): runtime setting support (#5602) --- <details open="true"><summary>Generated summary (powered by <a href="https://app.graphite.dev">Graphite</a>)</summary> > ## TL;DR > This pull request adds a new migration file, a new model, and new modules related to runtime settings. It also introduces a new `Runtime` service that allows getting, setting, and updating runtime configurations. > > ## What changed > - Added a new migration file `migration.sql` that creates a table called `application_settings` with columns `key` and `value`. > - Added a new model `ApplicationSetting` with properties `key` and `value`. > - Added a new module `RuntimeSettingModule` that exports the `Runtime` service. > - Added a new service `Runtime` that provides methods for getting, setting, and updating runtime configurations. > - Modified the `app.module.ts` file to import the `RuntimeSettingModule`. > - Modified the `index.ts` file in the `fundamentals` directory to export the `Runtime` service. > - Added a new file `def.ts` in the `runtime` directory that defines the runtime configurations and provides a default implementation. > - Added a new file `service.ts` in the `runtime` directory that implements the `Runtime` service. > > ## How to test > 1. Run the migration script to create the `application_settings` table. > 2. Use the `Runtime` service to get, set, and update runtime configurations. > 3. Verify that the runtime configurations are stored correctly in the database and can be retrieved and modified using the `Runtime` service. > > ## Why make this change > This change introduces a new feature related to runtime settings. The `Runtime` service allows the application to dynamically manage and modify runtime configurations without requiring a restart. This provides flexibility and allows for easier customization and configuration of the application. </details>
2024-05-28 09:43:53 +03:00
import '../../src/plugins/payment';
2024-04-30 09:59:54 +03:00
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import Stripe from 'stripe';
import { AppModule } from '../../src/app.module';
import { CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import {
EarlyAccessType,
FeatureManagementService,
} from '../../src/core/features';
2024-05-07 12:44:37 +03:00
import { EventEmitter } from '../../src/fundamentals';
import { Config, ConfigModule } from '../../src/fundamentals/config';
2024-04-30 09:59:54 +03:00
import {
CouponType,
encodeLookupKey,
SubscriptionService,
} from '../../src/plugins/payment/service';
import {
SubscriptionPlan,
SubscriptionPriceVariant,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../src/plugins/payment/types';
import { createTestingApp } from '../utils';
const test = ava as TestFn<{
u1: CurrentUser;
db: PrismaClient;
app: INestApplication;
service: SubscriptionService;
stripe: Stripe;
2024-05-07 12:44:37 +03:00
event: EventEmitter;
2024-04-30 09:59:54 +03:00
feature: Sinon.SinonStubbedInstance<FeatureManagementService>;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [
ConfigModule.forRoot({
plugins: {
payment: {
stripe: {
keys: {
APIKey: '1',
webhookKey: '1',
},
},
},
},
}),
AppModule,
],
tapModule: m => {
m.overrideProvider(FeatureManagementService).useValue(
Sinon.createStubInstance(FeatureManagementService)
);
},
});
2024-05-07 12:44:37 +03:00
t.context.event = app.get(EventEmitter);
2024-04-30 09:59:54 +03:00
t.context.stripe = app.get(Stripe);
t.context.service = app.get(SubscriptionService);
t.context.feature = app.get(FeatureManagementService);
t.context.db = app.get(PrismaClient);
t.context.app = app;
2024-09-03 12:03:39 +03:00
t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1');
2024-04-30 09:59:54 +03:00
await t.context.db.userStripeCustomer.create({
data: {
userId: t.context.u1.id,
stripeCustomerId: 'cus_1',
},
});
});
test.afterEach.always(async t => {
await t.context.app.close();
});
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`;
const PRO_LIFETIME = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`;
2024-04-30 09:59:54 +03:00
const PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`;
const AI_YEARLY = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`;
const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`;
const PRICES = {
[PRO_MONTHLY]: {
recurring: {
interval: 'month',
},
unit_amount: 799,
currency: 'usd',
lookup_key: PRO_MONTHLY,
},
[PRO_YEARLY]: {
recurring: {
interval: 'year',
},
unit_amount: 8100,
currency: 'usd',
lookup_key: PRO_YEARLY,
},
[PRO_LIFETIME]: {
unit_amount: 49900,
currency: 'usd',
lookup_key: PRO_LIFETIME,
},
2024-04-30 09:59:54 +03:00
[PRO_EA_YEARLY]: {
recurring: {
interval: 'year',
},
unit_amount: 5000,
currency: 'usd',
lookup_key: PRO_EA_YEARLY,
},
[AI_YEARLY]: {
recurring: {
interval: 'year',
},
unit_amount: 10680,
currency: 'usd',
lookup_key: AI_YEARLY,
},
[AI_YEARLY_EA]: {
recurring: {
interval: 'year',
},
unit_amount: 9999,
currency: 'usd',
lookup_key: AI_YEARLY_EA,
},
};
const sub: Stripe.Subscription = {
id: 'sub_1',
object: 'subscription',
cancel_at_period_end: false,
canceled_at: null,
current_period_end: 1745654236,
current_period_start: 1714118236,
customer: 'cus_1',
items: {
object: 'list',
data: [
{
id: 'si_1',
// @ts-expect-error stub
price: {
id: 'price_1',
lookup_key: 'pro_monthly',
},
subscription: 'sub_1',
},
],
},
status: 'active',
trial_end: null,
trial_start: null,
schedule: null,
};
// ============== prices ==============
test('should list normal price for unauthenticated user', async t => {
const { service, stripe } = t.context;
// @ts-expect-error stub
Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] });
// @ts-expect-error stub
Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) });
const prices = await service.listPrices();
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
2024-04-30 09:59:54 +03:00
);
});
test('should list normal prices for authenticated user', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
2024-04-30 09:59:54 +03:00
// @ts-expect-error stub
Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] });
// @ts-expect-error stub
Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) });
const prices = await service.listPrices(u1);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
2024-04-30 09:59:54 +03:00
);
});
test('should list early access prices for pro ea user', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
2024-04-30 09:59:54 +03:00
// @ts-expect-error stub
Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] });
// @ts-expect-error stub
Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) });
const prices = await service.listPrices(u1);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY])
2024-04-30 09:59:54 +03:00
);
});
test('should list normal prices for pro ea user with old subscriptions', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
2024-04-30 09:59:54 +03:00
Sinon.stub(stripe.subscriptions, 'list').resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: PRO_YEARLY,
},
},
],
},
},
],
});
// @ts-expect-error stub
Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) });
const prices = await service.listPrices(u1);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
2024-04-30 09:59:54 +03:00
);
});
test('should list early access prices for ai ea user', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(true);
2024-04-30 09:59:54 +03:00
// @ts-expect-error stub
Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] });
// @ts-expect-error stub
Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) });
const prices = await service.listPrices(u1);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY_EA])
2024-04-30 09:59:54 +03:00
);
});
test('should list early access prices for pro and ai ea user', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(true);
2024-04-30 09:59:54 +03:00
// @ts-expect-error stub
Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] });
// @ts-expect-error stub
Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) });
const prices = await service.listPrices(u1);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY_EA])
2024-04-30 09:59:54 +03:00
);
});
test('should list normal prices for ai ea user with old subscriptions', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(true);
2024-04-30 09:59:54 +03:00
Sinon.stub(stripe.subscriptions, 'list').resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: AI_YEARLY,
},
},
],
},
},
],
});
// @ts-expect-error stub
Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) });
const prices = await service.listPrices(u1);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY])
2024-04-30 09:59:54 +03:00
);
});
// ============= end prices ================
// ============= checkout ==================
test('should throw if user has subscription already', async t => {
const { service, u1, db } = t.context;
await db.userSubscription.create({
data: {
userId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
await t.throwsAsync(
() =>
service.createCheckoutSession({
user: u1,
recurring: SubscriptionRecurring.Monthly,
plan: SubscriptionPlan.Pro,
redirectUrl: '',
idempotencyKey: '',
}),
{ message: 'You have already subscribed to the pro plan.' }
2024-04-30 09:59:54 +03:00
);
});
test('should get correct pro plan price for checking out', async t => {
const { service, u1, stripe, feature } = t.context;
const customer = {
userId: u1.id,
email: u1.email,
stripeCustomerId: 'cus_1',
createdAt: new Date(),
};
const subListStub = Sinon.stub(stripe.subscriptions, 'list');
// @ts-expect-error allow
Sinon.stub(service, 'getPrice').callsFake((plan, recurring, variant) => {
return encodeLookupKey(plan, recurring, variant);
});
// @ts-expect-error private member
const getAvailablePrice = service.getAvailablePrice.bind(service);
// non-ea user
{
feature.isEarlyAccessUser.resolves(false);
// @ts-expect-error stub
subListStub.resolves({ data: [] });
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.Pro,
SubscriptionRecurring.Monthly
);
t.deepEqual(ret, {
price: PRO_MONTHLY,
coupon: undefined,
});
}
// ea user, but monthly
{
feature.isEarlyAccessUser.resolves(true);
// @ts-expect-error stub
subListStub.resolves({ data: [] });
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.Pro,
SubscriptionRecurring.Monthly
);
t.deepEqual(ret, {
price: PRO_MONTHLY,
coupon: undefined,
});
}
// ea user, yearly
{
feature.isEarlyAccessUser.resolves(true);
// @ts-expect-error stub
subListStub.resolves({ data: [] });
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.Pro,
SubscriptionRecurring.Yearly
);
t.deepEqual(ret, {
price: PRO_EA_YEARLY,
coupon: CouponType.ProEarlyAccessOneYearFree,
});
}
// ea user, yearly recurring, but has old subscription
{
feature.isEarlyAccessUser.resolves(true);
subListStub.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: PRO_YEARLY,
},
},
],
},
},
],
});
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.Pro,
SubscriptionRecurring.Yearly
);
t.deepEqual(ret, {
price: PRO_YEARLY,
coupon: undefined,
});
}
// any user, lifetime recurring
{
feature.isEarlyAccessUser.resolves(false);
// @ts-expect-error stub
subListStub.resolves({ data: [] });
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.Pro,
SubscriptionRecurring.Lifetime
);
t.deepEqual(ret, {
price: PRO_LIFETIME,
coupon: undefined,
});
}
2024-04-30 09:59:54 +03:00
});
test('should get correct ai plan price for checking out', async t => {
const { service, u1, stripe, feature } = t.context;
const customer = {
userId: u1.id,
email: u1.email,
stripeCustomerId: 'cus_1',
createdAt: new Date(),
};
const subListStub = Sinon.stub(stripe.subscriptions, 'list');
// @ts-expect-error allow
Sinon.stub(service, 'getPrice').callsFake((plan, recurring, variant) => {
return encodeLookupKey(plan, recurring, variant);
});
// @ts-expect-error private member
const getAvailablePrice = service.getAvailablePrice.bind(service);
// non-ea user
{
feature.isEarlyAccessUser.resolves(false);
// @ts-expect-error stub
subListStub.resolves({ data: [] });
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
);
t.deepEqual(ret, {
price: AI_YEARLY,
coupon: undefined,
});
}
// ea user
{
feature.isEarlyAccessUser.resolves(true);
// @ts-expect-error stub
subListStub.resolves({ data: [] });
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
);
t.deepEqual(ret, {
price: AI_YEARLY_EA,
coupon: CouponType.AIEarlyAccessOneYearFree,
});
}
// ea user, but has old subscription
{
feature.isEarlyAccessUser.resolves(true);
subListStub.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: AI_YEARLY,
},
},
],
},
},
],
});
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
);
t.deepEqual(ret, {
price: AI_YEARLY,
coupon: undefined,
});
}
// pro ea user
{
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
2024-04-30 09:59:54 +03:00
feature.isEarlyAccessUser
.withArgs(u1.id, EarlyAccessType.AI)
2024-04-30 09:59:54 +03:00
.resolves(false);
// @ts-expect-error stub
subListStub.resolves({ data: [] });
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
);
t.deepEqual(ret, {
price: AI_YEARLY,
coupon: CouponType.ProEarlyAccessAIOneYearFree,
});
}
// pro ea user, but has old subscription
{
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
2024-04-30 09:59:54 +03:00
feature.isEarlyAccessUser
.withArgs(u1.id, EarlyAccessType.AI)
2024-04-30 09:59:54 +03:00
.resolves(false);
subListStub.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: AI_YEARLY,
},
},
],
},
},
],
});
const ret = await getAvailablePrice(
customer,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
);
t.deepEqual(ret, {
price: AI_YEARLY,
coupon: undefined,
});
}
});
test('should apply user coupon for checking out', async t => {
const { service, u1, stripe } = t.context;
const checkoutStub = Sinon.stub(stripe.checkout.sessions, 'create');
// @ts-expect-error private member
Sinon.stub(service, 'getAvailablePrice').resolves({
// @ts-expect-error type inference error
price: PRO_MONTHLY,
coupon: undefined,
});
// @ts-expect-error private member
Sinon.stub(service, 'getAvailablePromotionCode').resolves('promo_1');
await service.createCheckoutSession({
user: u1,
recurring: SubscriptionRecurring.Monthly,
plan: SubscriptionPlan.Pro,
redirectUrl: '',
idempotencyKey: '',
promotionCode: 'test',
});
t.true(checkoutStub.calledOnce);
const arg = checkoutStub.firstCall
.args[0] as Stripe.Checkout.SessionCreateParams;
t.deepEqual(arg.discounts, [{ promotion_code: 'promo_1' }]);
});
// =============== subscriptions ===============
test('should be able to create subscription', async t => {
2024-05-07 12:44:37 +03:00
const { event, service, stripe, db, u1 } = t.context;
2024-04-30 09:59:54 +03:00
2024-05-07 12:44:37 +03:00
const emitStub = Sinon.stub(event, 'emit').returns(true);
2024-04-30 09:59:54 +03:00
Sinon.stub(stripe.subscriptions, 'retrieve').resolves(sub as any);
await service.onSubscriptionChanges(sub);
2024-05-07 12:44:37 +03:00
t.true(
emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
2024-05-07 12:44:37 +03:00
})
);
2024-04-30 09:59:54 +03:00
const subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.is(subInDB?.stripeSubscriptionId, sub.id);
});
test('should be able to update subscription', async t => {
2024-05-07 12:44:37 +03:00
const { event, service, stripe, db, u1 } = t.context;
2024-04-30 09:59:54 +03:00
const stub = Sinon.stub(stripe.subscriptions, 'retrieve').resolves(
sub as any
);
await service.onSubscriptionChanges(sub);
let subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.is(subInDB?.stripeSubscriptionId, sub.id);
2024-05-07 12:44:37 +03:00
const emitStub = Sinon.stub(event, 'emit').returns(true);
2024-04-30 09:59:54 +03:00
stub.resolves({
...sub,
cancel_at_period_end: true,
canceled_at: 1714118236,
} as any);
await service.onSubscriptionChanges(sub);
2024-05-07 12:44:37 +03:00
t.true(
emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
2024-05-07 12:44:37 +03:00
})
);
2024-04-30 09:59:54 +03:00
subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.canceledAt?.getTime(), 1714118236000);
});
test('should be able to delete subscription', async t => {
2024-05-07 12:44:37 +03:00
const { event, service, stripe, db, u1 } = t.context;
2024-04-30 09:59:54 +03:00
const stub = Sinon.stub(stripe.subscriptions, 'retrieve').resolves(
sub as any
);
await service.onSubscriptionChanges(sub);
let subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.is(subInDB?.stripeSubscriptionId, sub.id);
2024-05-07 12:44:37 +03:00
const emitStub = Sinon.stub(event, 'emit').returns(true);
2024-04-30 09:59:54 +03:00
stub.resolves({ ...sub, status: 'canceled' } as any);
await service.onSubscriptionChanges(sub);
2024-05-07 12:44:37 +03:00
t.true(
emitStub.calledOnceWith('user.subscription.canceled', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
2024-05-07 12:44:37 +03:00
})
);
2024-04-30 09:59:54 +03:00
subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.is(subInDB, null);
});
test('should be able to cancel subscription', async t => {
2024-05-07 12:44:37 +03:00
const { event, service, db, u1, stripe } = t.context;
2024-04-30 09:59:54 +03:00
await db.userSubscription.create({
data: {
userId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
const stub = Sinon.stub(stripe.subscriptions, 'update').resolves({
...sub,
cancel_at_period_end: true,
canceled_at: 1714118236,
} as any);
2024-05-07 12:44:37 +03:00
const emitStub = Sinon.stub(event, 'emit').returns(true);
2024-04-30 09:59:54 +03:00
const subInDB = await service.cancelSubscription(
'',
u1.id,
SubscriptionPlan.Pro
);
2024-05-07 12:44:37 +03:00
// we will cancel the subscription at the end of the period
// so in cancel event, we still emit the activated event
t.true(
emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
2024-05-07 12:44:37 +03:00
})
);
2024-04-30 09:59:54 +03:00
t.true(stub.calledOnceWith('sub_1', { cancel_at_period_end: true }));
t.is(subInDB.status, SubscriptionStatus.Active);
t.truthy(subInDB.canceledAt);
});
test('should be able to resume subscription', async t => {
2024-05-07 12:44:37 +03:00
const { event, service, db, u1, stripe } = t.context;
2024-04-30 09:59:54 +03:00
await db.userSubscription.create({
data: {
userId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
canceledAt: new Date(),
},
});
const stub = Sinon.stub(stripe.subscriptions, 'update').resolves(sub as any);
2024-05-07 12:44:37 +03:00
const emitStub = Sinon.stub(event, 'emit').returns(true);
2024-04-30 09:59:54 +03:00
const subInDB = await service.resumeCanceledSubscription(
'',
u1.id,
SubscriptionPlan.Pro
);
2024-05-07 12:44:37 +03:00
t.true(
emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
2024-05-07 12:44:37 +03:00
})
);
2024-04-30 09:59:54 +03:00
t.true(stub.calledOnceWith('sub_1', { cancel_at_period_end: false }));
t.is(subInDB.status, SubscriptionStatus.Active);
t.falsy(subInDB.canceledAt);
});
const subscriptionSchedule: Stripe.SubscriptionSchedule = {
id: 'sub_sched_1',
customer: 'cus_1',
subscription: 'sub_1',
status: 'active',
phases: [
{
items: [
// @ts-expect-error mock
{
price: PRO_MONTHLY,
},
],
start_date: 1714118236,
end_date: 1745654236,
},
],
};
test('should be able to update recurring', async t => {
const { service, db, u1, stripe } = t.context;
await db.userSubscription.create({
data: {
userId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
},
});
// 1. turn a subscription into a subscription schedule
// 2. update the schedule
// 2.1 update the current phase with an end date
// 2.2 add a new phase with a start date
// @ts-expect-error private member
Sinon.stub(service, 'getPrice').resolves(PRO_YEARLY);
Sinon.stub(stripe.subscriptions, 'retrieve').resolves(sub as any);
Sinon.stub(stripe.subscriptionSchedules, 'create').resolves(
subscriptionSchedule as any
);
const stub = Sinon.stub(stripe.subscriptionSchedules, 'update');
await service.updateSubscriptionRecurring(
'',
u1.id,
SubscriptionPlan.Pro,
SubscriptionRecurring.Yearly
);
t.true(stub.calledOnce);
const arg = stub.firstCall.args;
t.is(arg[0], subscriptionSchedule.id);
t.deepEqual(arg[1], {
phases: [
{
items: [
{
price: PRO_MONTHLY,
},
],
start_date: 1714118236,
end_date: 1745654236,
},
{
items: [
{
price: PRO_YEARLY,
},
],
},
],
});
});
test('should release the schedule if the new recurring is the same as the current phase', async t => {
const { service, db, u1, stripe } = t.context;
await db.userSubscription.create({
data: {
userId: u1.id,
stripeSubscriptionId: 'sub_1',
stripeScheduleId: 'sub_sched_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
},
});
// @ts-expect-error private member
Sinon.stub(service, 'getPrice').resolves(PRO_MONTHLY);
Sinon.stub(stripe.subscriptions, 'retrieve').resolves({
...sub,
schedule: subscriptionSchedule,
} as any);
Sinon.stub(stripe.subscriptionSchedules, 'retrieve').resolves(
subscriptionSchedule as any
);
const stub = Sinon.stub(stripe.subscriptionSchedules, 'release');
await service.updateSubscriptionRecurring(
'',
u1.id,
SubscriptionPlan.Pro,
SubscriptionRecurring.Monthly
);
t.true(stub.calledOnce);
t.is(stub.firstCall.args[0], subscriptionSchedule.id);
});
test('should operate with latest subscription status', async t => {
const { service, stripe } = t.context;
Sinon.stub(stripe.subscriptions, 'retrieve').resolves(sub as any);
// @ts-expect-error private member
const stub = Sinon.stub(service, 'saveSubscription');
// latest state come first
await service.onSubscriptionChanges(sub);
// old state come later
await service.onSubscriptionChanges({
...sub,
status: 'canceled',
});
t.is(stub.callCount, 2);
t.deepEqual(stub.firstCall.args[1], sub);
t.deepEqual(stub.secondCall.args[1], sub);
});
// ============== Lifetime Subscription ===============
const invoice: Stripe.Invoice = {
id: 'in_xxx',
object: 'invoice',
amount_paid: 49900,
total: 49900,
customer: 'cus_1',
currency: 'usd',
status: 'paid',
lines: {
data: [
{
// @ts-expect-error stub
price: PRICES[PRO_LIFETIME],
},
],
},
};
test('should not be able to checkout for lifetime recurring if not enabled', async t => {
const { service, stripe, u1 } = t.context;
Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] } as any);
await t.throwsAsync(
() =>
service.createCheckoutSession({
user: u1,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
redirectUrl: '',
idempotencyKey: '',
}),
{ message: 'You are not allowed to perform this action.' }
);
});
test('should be able to checkout for lifetime recurring', async t => {
const { service, stripe, u1, app } = t.context;
const config = app.get(Config);
await config.runtime.set('plugins.payment/showLifetimePrice', true);
Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] } as any);
Sinon.stub(stripe.prices, 'list').resolves({
data: [PRICES[PRO_LIFETIME]],
} as any);
const sessionStub = Sinon.stub(stripe.checkout.sessions, 'create');
await service.createCheckoutSession({
user: u1,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
redirectUrl: '',
idempotencyKey: '',
});
t.true(sessionStub.calledOnce);
});
test('should be able to subscribe to lifetime recurring', async t => {
// lifetime payment isn't a subscription, so we need to trigger the creation by invoice payment event
const { service, stripe, db, u1, event } = t.context;
const emitStub = Sinon.stub(event, 'emit');
Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any);
await service.saveInvoice(invoice, 'invoice.payment_succeeded');
const subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.true(
emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
})
);
t.is(subInDB?.plan, SubscriptionPlan.Pro);
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null);
});
test('should be able to subscribe to lifetime recurring with old subscription', async t => {
const { service, stripe, db, u1, event } = t.context;
await db.userSubscription.create({
data: {
userId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
const emitStub = Sinon.stub(event, 'emit');
Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any);
Sinon.stub(stripe.subscriptions, 'cancel').resolves(sub as any);
await service.saveInvoice(invoice, 'invoice.payment_succeeded');
const subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.true(
emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
})
);
t.is(subInDB?.plan, SubscriptionPlan.Pro);
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null);
});
test('should not be able to update lifetime recurring', async t => {
const { service, db, u1 } = t.context;
await db.userSubscription.create({
data: {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
await t.throwsAsync(
() => service.cancelSubscription('', u1.id, SubscriptionPlan.Pro),
{ message: 'Lifetime subscription cannot be canceled.' }
);
await t.throwsAsync(
() =>
service.updateSubscriptionRecurring(
'',
u1.id,
SubscriptionPlan.Pro,
SubscriptionRecurring.Monthly
),
{ message: 'Can not update lifetime subscription.' }
);
await t.throwsAsync(
() => service.resumeCanceledSubscription('', u1.id, SubscriptionPlan.Pro),
{ message: 'Lifetime subscription cannot be resumed.' }
);
});