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

1733 lines
43 KiB
TypeScript

import '../../src/plugins/payment';
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';
import { EventEmitter } from '../../src/fundamentals';
import { Config, ConfigModule, Runtime } from '../../src/fundamentals/config';
import { SubscriptionService } from '../../src/plugins/payment/service';
import {
CouponType,
encodeLookupKey,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
SubscriptionVariant,
} from '../../src/plugins/payment/types';
import { createTestingApp, initTestingDB } from '../utils';
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`;
const PRO_LIFETIME = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`;
const PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`;
const AI_YEARLY = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`;
const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`;
const TEAM_MONTHLY = `${SubscriptionPlan.Team}_${SubscriptionRecurring.Monthly}`;
const TEAM_YEARLY = `${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`;
// prices for code redeeming
const PRO_MONTHLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`;
const PRO_YEARLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`;
const AI_YEARLY_CODE = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`;
const PRICES = {
[PRO_MONTHLY]: {
recurring: {
interval: 'month',
},
unit_amount: 799,
currency: 'usd',
id: PRO_MONTHLY,
lookup_key: PRO_MONTHLY,
},
[PRO_YEARLY]: {
recurring: {
interval: 'year',
},
unit_amount: 8100,
currency: 'usd',
id: PRO_YEARLY,
lookup_key: PRO_YEARLY,
},
[PRO_LIFETIME]: {
unit_amount: 49900,
currency: 'usd',
id: PRO_LIFETIME,
lookup_key: PRO_LIFETIME,
},
[PRO_EA_YEARLY]: {
recurring: {
interval: 'year',
},
unit_amount: 5000,
currency: 'usd',
id: PRO_EA_YEARLY,
lookup_key: PRO_EA_YEARLY,
},
[AI_YEARLY]: {
recurring: {
interval: 'year',
},
unit_amount: 10680,
currency: 'usd',
id: AI_YEARLY,
lookup_key: AI_YEARLY,
},
[AI_YEARLY_EA]: {
recurring: {
interval: 'year',
},
unit_amount: 9999,
currency: 'usd',
id: AI_YEARLY_EA,
lookup_key: AI_YEARLY_EA,
},
[PRO_MONTHLY_CODE]: {
unit_amount: 799,
currency: 'usd',
id: PRO_MONTHLY_CODE,
lookup_key: PRO_MONTHLY_CODE,
},
[PRO_YEARLY_CODE]: {
unit_amount: 8100,
currency: 'usd',
id: PRO_YEARLY_CODE,
lookup_key: PRO_YEARLY_CODE,
},
[AI_YEARLY_CODE]: {
unit_amount: 10680,
currency: 'usd',
id: AI_YEARLY_CODE,
lookup_key: AI_YEARLY_CODE,
},
[TEAM_MONTHLY]: {
unit_amount: 1500,
currency: 'usd',
id: TEAM_MONTHLY,
lookup_key: TEAM_MONTHLY,
},
[TEAM_YEARLY]: {
unit_amount: 14400,
currency: 'usd',
id: TEAM_YEARLY,
lookup_key: TEAM_YEARLY,
},
} as any as Record<string, Stripe.Price>;
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,
// @ts-expect-error stub
customer: {
id: 'cus_1',
email: 'u1@affine.pro',
},
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,
metadata: {},
};
const test = ava as TestFn<{
u1: CurrentUser;
db: PrismaClient;
app: INestApplication;
service: SubscriptionService;
event: Sinon.SinonStubbedInstance<EventEmitter>;
feature: Sinon.SinonStubbedInstance<FeatureManagementService>;
runtime: Sinon.SinonStubbedInstance<Runtime>;
stripe: {
customers: Sinon.SinonStubbedInstance<Stripe.CustomersResource>;
prices: Sinon.SinonStubbedInstance<Stripe.PricesResource>;
subscriptions: Sinon.SinonStubbedInstance<Stripe.SubscriptionsResource>;
subscriptionSchedules: Sinon.SinonStubbedInstance<Stripe.SubscriptionSchedulesResource>;
checkout: {
sessions: Sinon.SinonStubbedInstance<Stripe.Checkout.SessionsResource>;
};
promotionCodes: Sinon.SinonStubbedInstance<Stripe.PromotionCodesResource>;
};
}>;
function getLastCheckoutPrice(checkoutStub: Sinon.SinonStub) {
const call = checkoutStub.getCall(checkoutStub.callCount - 1);
const arg = call.args[0] as Stripe.Checkout.SessionCreateParams;
return {
price: arg.line_items?.[0]?.price,
coupon: arg.discounts?.[0]?.coupon,
};
}
test.before(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)
);
m.overrideProvider(EventEmitter).useValue(
Sinon.createStubInstance(EventEmitter)
);
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
},
});
t.context.event = app.get(EventEmitter);
t.context.service = app.get(SubscriptionService);
t.context.feature = app.get(FeatureManagementService);
t.context.runtime = app.get(Runtime);
t.context.db = app.get(PrismaClient);
t.context.app = app;
const stripe = app.get(Stripe);
const stripeStubs = {
customers: Sinon.stub(stripe.customers),
prices: Sinon.stub(stripe.prices),
subscriptions: Sinon.stub(stripe.subscriptions),
subscriptionSchedules: Sinon.stub(stripe.subscriptionSchedules),
checkout: {
sessions: Sinon.stub(stripe.checkout.sessions),
},
promotionCodes: Sinon.stub(stripe.promotionCodes),
};
t.context.stripe = stripeStubs;
});
test.beforeEach(async t => {
const { db, app, stripe } = t.context;
Sinon.reset();
await initTestingDB(db);
t.context.runtime.fetch
.withArgs('plugins.payment/showLifetimePrice')
.resolves(true);
t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1');
await db.workspace.create({
data: {
id: 'ws_1',
public: false,
},
});
await db.userStripeCustomer.create({
data: {
userId: t.context.u1.id,
stripeCustomerId: 'cus_1',
},
});
// default stubs
// @ts-expect-error stub
stripe.prices.list.callsFake((params: Stripe.PriceListParams) => {
if (params.lookup_keys) {
return Promise.resolve({
data: params.lookup_keys.map(lk => PRICES[lk]),
});
}
return Promise.resolve({ data: Object.values(PRICES) });
});
// @ts-expect-error stub
stripe.subscriptions.list.resolves({ data: [] });
});
test.after.always(async t => {
await t.context.app.close();
});
// ============== prices ==============
test('should list normal price for unauthenticated user', async t => {
const { service } = t.context;
const prices = await service.listPrices();
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
test('should list normal prices for authenticated user', async t => {
const { feature, service, u1 } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
const prices = await service.listPrices(u1);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
test('should not show lifetime price if not enabled', async t => {
const { service, runtime } = t.context;
runtime.fetch.withArgs('plugins.payment/showLifetimePrice').resolves(false);
const prices = await service.listPrices(t.context.u1);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
test('should list early access prices for pro ea user', async t => {
const { feature, service, u1 } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
const prices = await service.listPrices(u1);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
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);
stripe.subscriptions.list.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: PRO_YEARLY,
},
},
],
},
},
],
});
const prices = await service.listPrices(u1);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
test('should list early access prices for ai ea user', async t => {
const { feature, service, u1 } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(true);
const prices = await service.listPrices(u1);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
test('should list early access prices for pro and ai ea user', async t => {
const { feature, service, u1 } = t.context;
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
const prices = await service.listPrices(u1);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
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);
stripe.subscriptions.list.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: AI_YEARLY,
},
},
],
},
},
],
});
const prices = await service.listPrices(u1);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
// ============= end prices ================
// ============= checkout ==================
test('should throw if user has subscription already', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
successCallbackLink: '',
},
{ user: u1 }
),
{ message: 'You have already subscribed to the pro plan.' }
);
});
test('should get correct pro plan price for checking out', async t => {
const { app, service, u1, stripe, feature } = t.context;
// non-ea user
{
feature.isEarlyAccessUser.resolves(false);
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_MONTHLY,
coupon: undefined,
});
}
// ea user, but monthly
{
feature.isEarlyAccessUser.resolves(true);
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_MONTHLY,
coupon: undefined,
});
}
// ea user, yearly
{
feature.isEarlyAccessUser.resolves(true);
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_EA_YEARLY,
coupon: CouponType.ProEarlyAccessOneYearFree,
});
}
// ea user, yearly recurring, but has old subscription
{
feature.isEarlyAccessUser.resolves(true);
stripe.subscriptions.list.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: PRO_YEARLY,
},
},
],
},
},
],
});
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_YEARLY,
coupon: undefined,
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
variant: SubscriptionVariant.EA,
successCallbackLink: '',
},
{ user: u1 }
),
{
message: 'You are trying to access a unknown subscription plan.',
}
);
}
// any user, lifetime recurring
{
feature.isEarlyAccessUser.resolves(false);
const config = app.get(Config);
await config.runtime.set('plugins.payment/showLifetimePrice', true);
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_LIFETIME,
coupon: undefined,
});
}
});
test('should get correct ai plan price for checking out', async t => {
const { service, u1, stripe, feature } = t.context;
// non-ea user
{
feature.isEarlyAccessUser.resolves(false);
await service.checkout(
{
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: AI_YEARLY,
coupon: undefined,
});
}
// ea user
{
feature.isEarlyAccessUser.resolves(true);
await service.checkout(
{
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: AI_YEARLY_EA,
coupon: CouponType.AIEarlyAccessOneYearFree,
});
}
// ea user, but has old subscription
{
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
feature.isEarlyAccessUser
.withArgs(u1.id, EarlyAccessType.AI)
.resolves(true);
stripe.subscriptions.list.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: AI_YEARLY,
},
},
],
},
},
],
});
await service.checkout(
{
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: AI_YEARLY,
coupon: undefined,
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
variant: SubscriptionVariant.EA,
successCallbackLink: '',
},
{ user: u1 }
),
{
message: 'You are trying to access a unknown subscription plan.',
}
);
}
// pro ea user
{
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
feature.isEarlyAccessUser
.withArgs(u1.id, EarlyAccessType.AI)
.resolves(false);
// @ts-expect-error stub
stripe.subscriptions.list.resolves({ data: [] });
await service.checkout(
{
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: AI_YEARLY,
coupon: CouponType.ProEarlyAccessAIOneYearFree,
});
}
// pro ea user, but has old subscription
{
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
feature.isEarlyAccessUser
.withArgs(u1.id, EarlyAccessType.AI)
.resolves(false);
stripe.subscriptions.list.resolves({
data: [
{
id: 'sub_1',
status: 'canceled',
items: {
data: [
{
// @ts-expect-error stub
price: {
lookup_key: AI_YEARLY,
},
},
],
},
},
],
});
await service.checkout(
{
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: AI_YEARLY,
coupon: undefined,
});
}
});
test('should apply user coupon for checking out', async t => {
const { service, u1, stripe } = t.context;
stripe.promotionCodes.list.resolves({
data: [
{
// @ts-expect-error mock
coupon: {
id: 'coupon_1',
},
},
],
});
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
successCallbackLink: '',
coupon: 'test',
},
{ user: u1 }
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_MONTHLY,
coupon: 'coupon_1',
});
});
// =============== subscriptions ===============
test('should be able to create subscription', async t => {
const { event, service, db, u1 } = t.context;
await service.saveStripeSubscription(sub);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.true(
event.emit.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
})
);
t.is(subInDB?.stripeSubscriptionId, sub.id);
});
test('should be able to update subscription', async t => {
const { event, service, db, u1 } = t.context;
await service.saveStripeSubscription(sub);
await service.saveStripeSubscription({
...sub,
cancel_at_period_end: true,
canceled_at: 1714118236,
});
t.true(
event.emit.calledWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
})
);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.canceledAt?.getTime(), 1714118236000);
});
test('should be able to delete subscription', async t => {
const { event, service, db, u1 } = t.context;
await service.saveStripeSubscription(sub);
await service.saveStripeSubscription({
...sub,
status: 'canceled',
});
t.true(
event.emit.calledWith('user.subscription.canceled', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
})
);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.is(subInDB, null);
});
test('should be able to cancel subscription', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
stripe.subscriptions.update.resolves({
...sub,
cancel_at_period_end: true,
canceled_at: 1714118236,
} as any);
const subInDB = await service.cancelSubscription({
userId: u1.id,
plan: SubscriptionPlan.Pro,
});
t.true(
stripe.subscriptions.update.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 => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
data: {
targetId: 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(),
},
});
stripe.subscriptions.update.resolves(sub as any);
const subInDB = await service.resumeSubscription({
userId: u1.id,
plan: SubscriptionPlan.Pro,
});
t.true(
stripe.subscriptions.update.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,
quantity: 1,
},
],
start_date: Math.floor(Date.now() / 1000),
end_date: Math.floor((Date.now() + 30 * 24 * 60 * 60 * 1000) / 1000),
},
{
items: [
// @ts-expect-error mock
{
price: PRO_YEARLY,
quantity: 1,
},
],
start_date: Math.floor((Date.now() + 30 * 24 * 60 * 60 * 1000) / 1000),
},
],
};
test('should be able to update recurring', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
data: {
targetId: 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 current phase with an end date
stripe.subscriptions.retrieve.resolves(sub as any);
stripe.subscriptionSchedules.create.resolves(subscriptionSchedule as any);
stripe.subscriptionSchedules.update.resolves(subscriptionSchedule as any);
await service.updateSubscriptionRecurring(
{
userId: u1.id,
plan: SubscriptionPlan.Pro,
},
SubscriptionRecurring.Yearly
);
t.true(
stripe.subscriptionSchedules.update.calledOnceWith(
subscriptionSchedule.id,
{
phases: [
{
items: [
{
price: PRO_MONTHLY,
quantity: 1,
},
],
start_date: subscriptionSchedule.phases[0].start_date,
end_date: subscriptionSchedule.phases[0].end_date,
},
{
items: [
{
price: PRO_YEARLY,
quantity: 1,
},
],
},
],
}
)
);
});
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.subscription.create({
data: {
targetId: 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),
},
});
stripe.subscriptions.retrieve.resolves({
...sub,
schedule: subscriptionSchedule.id,
} as any);
stripe.subscriptionSchedules.retrieve.resolves(subscriptionSchedule as any);
const subInDB = await service.updateSubscriptionRecurring(
{
userId: u1.id,
plan: SubscriptionPlan.Pro,
},
SubscriptionRecurring.Monthly
);
t.true(
stripe.subscriptionSchedules.release.calledOnceWith(subscriptionSchedule.id)
);
t.is(subInDB.recurring, SubscriptionRecurring.Monthly);
});
test('should be able to cancel subscription with schedule', async t => {
const { service, u1, stripe } = t.context;
await service.saveStripeSubscription({
...sub,
schedule: 'sub_sched_1',
});
stripe.subscriptionSchedules.retrieve.resolves(subscriptionSchedule as any);
const subInDB = await service.cancelSubscription({
userId: u1.id,
plan: SubscriptionPlan.Pro,
});
t.true(
stripe.subscriptionSchedules.update.calledOnceWith(
subscriptionSchedule.id,
{
phases: [
{
items: [
{
price: PRO_MONTHLY,
quantity: 1,
},
],
coupon: undefined,
start_date: subscriptionSchedule.phases[0].start_date,
end_date: subscriptionSchedule.phases[0].end_date,
metadata: {
next_coupon: null,
next_price: PRO_YEARLY,
},
},
],
end_behavior: 'cancel',
}
)
);
t.is(subInDB.status, SubscriptionStatus.Active);
t.truthy(subInDB.canceledAt);
t.falsy(subInDB.nextBillAt);
});
test('should be able to resume subscription with schedule', async t => {
const { service, u1, stripe } = t.context;
await service.saveStripeSubscription({
...sub,
canceled_at: 1714118236,
schedule: 'sub_sched_1',
});
stripe.subscriptionSchedules.retrieve.resolves({
...subscriptionSchedule,
phases: [
{
items: [
// @ts-expect-error mock
{
price: PRO_MONTHLY,
quantity: 1,
},
],
start_date: subscriptionSchedule.phases[0].start_date,
end_date: subscriptionSchedule.phases[0].end_date,
metadata: {
next_price: PRO_YEARLY,
},
},
],
end_behavior: 'cancel',
});
const subInDB = await service.resumeSubscription({
userId: u1.id,
plan: SubscriptionPlan.Pro,
});
t.true(
stripe.subscriptionSchedules.update.calledOnceWith(
subscriptionSchedule.id,
{
phases: [
{
items: [{ price: PRO_MONTHLY, quantity: 1 }],
start_date: subscriptionSchedule.phases[0].start_date,
end_date: subscriptionSchedule.phases[0].end_date,
metadata: {
next_price: null,
next_coupon: null,
},
},
{
items: [{ price: PRO_YEARLY, quantity: 1 }],
coupon: undefined,
},
],
end_behavior: 'release',
}
)
);
t.is(subInDB.status, SubscriptionStatus.Active);
t.falsy(subInDB.canceledAt);
t.truthy(subInDB.nextBillAt);
});
// ============== Lifetime Subscription ===============
const lifetimeInvoice: Stripe.Invoice = {
id: 'in_1',
object: 'invoice',
amount_paid: 49900,
total: 49900,
customer: 'cus_1',
customer_email: 'u1@affine.pro',
currency: 'usd',
status: 'paid',
lines: {
data: [
// @ts-expect-error stub
{
price: PRICES[PRO_LIFETIME],
},
],
},
};
const onetimeMonthlyInvoice: Stripe.Invoice = {
id: 'in_2',
object: 'invoice',
amount_paid: 799,
total: 799,
customer: 'cus_1',
customer_email: 'u1@affine.pro',
currency: 'usd',
status: 'paid',
lines: {
data: [
// @ts-expect-error stub
{
price: PRICES[PRO_MONTHLY_CODE],
},
],
},
};
const onetimeYearlyInvoice: Stripe.Invoice = {
id: 'in_3',
object: 'invoice',
amount_paid: 8100,
total: 8100,
customer: 'cus_1',
customer_email: 'u1@affine.pro',
currency: 'usd',
status: 'paid',
lines: {
data: [
// @ts-expect-error stub
{
price: PRICES[PRO_YEARLY_CODE],
},
],
},
};
test('should not be able to checkout for lifetime recurring if not enabled', async t => {
const { service, u1, runtime } = t.context;
runtime.fetch.withArgs('plugins.payment/showLifetimePrice').resolves(false);
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
variant: null,
successCallbackLink: '',
},
{
user: u1,
}
),
{ message: 'You are trying to access a unknown subscription plan.' }
);
});
test('should be able to checkout for lifetime recurring', async t => {
const { service, u1, stripe } = t.context;
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
variant: null,
successCallbackLink: '',
},
{
user: u1,
}
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_LIFETIME,
coupon: undefined,
});
});
test('should not be able to checkout for lifetime recurring if already subscribed', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
stripeSubscriptionId: null,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
},
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
variant: null,
successCallbackLink: '',
},
{
user: u1,
}
),
{ message: 'You have already subscribed to the pro plan.' }
);
await db.subscription.updateMany({
where: { targetId: u1.id },
data: {
stripeSubscriptionId: null,
recurring: SubscriptionRecurring.Monthly,
variant: SubscriptionVariant.Onetime,
end: new Date(Date.now() + 100000),
},
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
variant: null,
successCallbackLink: '',
},
{
user: u1,
}
),
{ message: 'You have already subscribed to the pro plan.' }
);
});
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, db, u1, event } = t.context;
await service.saveStripeInvoice(lifetimeInvoice);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.true(
event.emit.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.subscription.create({
data: {
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
stripe.subscriptions.cancel.resolves(sub as any);
await service.saveStripeInvoice(lifetimeInvoice);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.true(
event.emit.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 cancel lifetime subscription', async t => {
const { service, db, u1 } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
await t.throwsAsync(
() =>
service.cancelSubscription({
plan: SubscriptionPlan.Pro,
userId: u1.id,
}),
{ message: 'Onetime payment subscription cannot be canceled.' }
);
});
test('should not be able to update lifetime recurring', async t => {
const { service, db, u1 } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
await t.throwsAsync(
() =>
service.updateSubscriptionRecurring(
{
plan: SubscriptionPlan.Pro,
userId: u1.id,
},
SubscriptionRecurring.Monthly
),
{ message: 'You cannot update an onetime payment subscription.' }
);
});
// ============== Onetime Subscription ===============
test('should be able to checkout for onetime payment', async t => {
const { service, u1, stripe } = t.context;
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
variant: SubscriptionVariant.Onetime,
successCallbackLink: '',
},
{
user: u1,
}
);
t.true(stripe.checkout.sessions.create.calledOnce);
const arg = stripe.checkout.sessions.create.firstCall
.args[0] as Stripe.Checkout.SessionCreateParams;
t.is(arg.mode, 'payment');
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_MONTHLY_CODE,
coupon: undefined,
});
});
test('should be able to checkout onetime payment if previous subscription is onetime', async t => {
const { service, u1, stripe, db } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
variant: SubscriptionVariant.Onetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
await service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
variant: SubscriptionVariant.Onetime,
successCallbackLink: '',
},
{
user: u1,
}
);
t.true(stripe.checkout.sessions.create.calledOnce);
const arg = stripe.checkout.sessions.create.firstCall
.args[0] as Stripe.Checkout.SessionCreateParams;
t.is(arg.mode, 'payment');
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: PRO_MONTHLY_CODE,
coupon: undefined,
});
});
test('should not be able to checkout out onetime payment if previous subscription is not onetime', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
},
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
variant: SubscriptionVariant.Onetime,
successCallbackLink: '',
},
{
user: u1,
}
),
{ message: 'You have already subscribed to the pro plan.' }
);
await db.subscription.updateMany({
where: { targetId: u1.id },
data: {
stripeSubscriptionId: null,
recurring: SubscriptionRecurring.Lifetime,
},
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
variant: SubscriptionVariant.Onetime,
successCallbackLink: '',
},
{
user: u1,
}
),
{ message: 'You have already subscribed to the pro plan.' }
);
});
test('should be able to subscribe onetime payment subscription', async t => {
const { service, db, u1, event } = t.context;
await service.saveStripeInvoice(onetimeMonthlyInvoice);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.true(
event.emit.calledOnceWith('user.subscription.activated', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
})
);
t.is(subInDB?.plan, SubscriptionPlan.Pro);
t.is(subInDB?.recurring, SubscriptionRecurring.Monthly);
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null);
t.is(
subInDB?.end?.toDateString(),
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toDateString()
);
});
test('should be able to recalculate onetime payment subscription period', async t => {
const { service, db, u1 } = t.context;
await service.saveStripeInvoice(onetimeMonthlyInvoice);
let subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.truthy(subInDB);
let end = subInDB!.end!;
await service.saveStripeInvoice(onetimeMonthlyInvoice);
subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
// add 30 days
t.is(subInDB!.end!.getTime(), end.getTime() + 30 * 24 * 60 * 60 * 1000);
end = subInDB!.end!;
await service.saveStripeInvoice(onetimeYearlyInvoice);
subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
// add 365 days
t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000);
// make subscription expired
await db.subscription.update({
where: { id: subInDB!.id },
data: {
end: new Date(Date.now() - 1000),
},
});
await service.saveStripeInvoice(onetimeYearlyInvoice);
subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
// add 365 days from now
t.is(
subInDB?.end?.toDateString(),
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
);
});
// TEAM
test('should be able to list prices for team', async t => {
const { service } = t.context;
const prices = await service.listPrices(undefined);
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
});
test('should be able to checkout for team', async t => {
const { service, u1, stripe } = t.context;
await service.checkout(
{
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
variant: null,
successCallbackLink: '',
},
{
user: u1,
workspaceId: 'ws_1',
}
);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: TEAM_MONTHLY,
coupon: undefined,
});
});
test('should not be able to checkout for workspace if subscribed', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
data: {
targetId: 'ws_1',
stripeSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(),
quantity: 1,
},
});
await t.throwsAsync(
() =>
service.checkout(
{
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
variant: null,
successCallbackLink: '',
},
{
user: u1,
workspaceId: 'ws_1',
}
),
{ message: 'You have already subscribed to the team plan.' }
);
});
const teamSub: Stripe.Subscription = {
...sub,
items: {
object: 'list',
data: [
{
id: 'si_1',
// @ts-expect-error stub
price: {
id: TEAM_MONTHLY,
lookup_key: 'team_monthly',
},
subscription: 'sub_1',
quantity: 1,
},
],
},
metadata: {
workspaceId: 'ws_1',
},
};
test('should be able to create team subscription', async t => {
const { event, service, db } = t.context;
await service.saveStripeSubscription(teamSub);
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
});
t.true(
event.emit.calledOnceWith('workspace.subscription.activated', {
workspaceId: 'ws_1',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
quantity: 1,
})
);
t.is(subInDB?.stripeSubscriptionId, sub.id);
});
test('should be able to update team subscription', async t => {
const { service, db, event } = t.context;
await service.saveStripeSubscription(teamSub);
await service.saveStripeSubscription({
...teamSub,
items: {
...teamSub.items,
data: [
{
...teamSub.items.data[0],
quantity: 2,
},
],
},
});
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
});
t.is(subInDB?.quantity, 2);
t.true(
event.emit.calledWith('workspace.subscription.activated', {
workspaceId: 'ws_1',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
quantity: 2,
})
);
});
// NOTE(@forehalo): cancel and resume a team subscription share the same logic with user subscription
test.skip('should be able to cancel team subscription', async () => {});
test.skip('should be able to resume team subscription', async () => {});