test(server): payment tests (#6737)

This commit is contained in:
forehalo 2024-04-30 06:59:54 +00:00
parent 1f97437320
commit f566457dcf
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
3 changed files with 919 additions and 37 deletions

View File

@ -197,7 +197,7 @@ export class ScheduleManager {
throw new Error('Unexpected subscription schedule status'); throw new Error('Unexpected subscription schedule status');
} }
// if current phase's plan matches target, and no coupon change, just release the schedule // if current phase's plan matches target, just release the schedule
if (this.currentPhase.items[0].price === price) { if (this.currentPhase.items[0].price === price) {
await this.stripe.subscriptionSchedules.release(this._schedule.id, { await this.stripe.subscriptionSchedules.release(this._schedule.id, {
idempotencyKey, idempotencyKey,
@ -221,13 +221,8 @@ export class ScheduleManager {
items: [ items: [
{ {
price: price, price: price,
quantity: 1,
}, },
], ],
coupon:
typeof this.currentPhase.coupon === 'string'
? this.currentPhase.coupon
: this.currentPhase.coupon?.id ?? undefined,
}, },
], ],
}, },

View File

@ -164,7 +164,7 @@ export class SubscriptionService {
if (currentSubscription) { if (currentSubscription) {
throw new BadRequestException( throw new BadRequestException(
`You've already subscripted to the ${plan} plan` `You've already subscribed to the ${plan} plan`
); );
} }
@ -181,7 +181,9 @@ export class SubscriptionService {
let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = []; let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = [];
if (promotionCode) { if (coupon) {
discounts = [{ coupon }];
} else if (promotionCode) {
const code = await this.getAvailablePromotionCode( const code = await this.getAvailablePromotionCode(
promotionCode, promotionCode,
customer.stripeCustomerId customer.stripeCustomerId
@ -189,8 +191,6 @@ export class SubscriptionService {
if (code) { if (code) {
discounts = [{ promotion_code: code }]; discounts = [{ promotion_code: code }];
} }
} else if (coupon) {
discounts = [{ coupon }];
} }
return await this.stripe.checkout.sessions.create( return await this.stripe.checkout.sessions.create(
@ -241,7 +241,7 @@ export class SubscriptionService {
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) { if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`); throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
} }
if (subscriptionInDB.canceledAt) { if (subscriptionInDB.canceledAt) {
@ -260,8 +260,7 @@ export class SubscriptionService {
user, user,
await this.stripe.subscriptions.retrieve( await this.stripe.subscriptions.retrieve(
subscriptionInDB.stripeSubscriptionId subscriptionInDB.stripeSubscriptionId
), )
false
); );
} else { } else {
// let customer contact support if they want to cancel immediately // let customer contact support if they want to cancel immediately
@ -295,7 +294,7 @@ export class SubscriptionService {
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) { if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`); throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
} }
if (!subscriptionInDB.canceledAt) { if (!subscriptionInDB.canceledAt) {
@ -317,8 +316,7 @@ export class SubscriptionService {
user, user,
await this.stripe.subscriptions.retrieve( await this.stripe.subscriptions.retrieve(
subscriptionInDB.stripeSubscriptionId subscriptionInDB.stripeSubscriptionId
), )
false
); );
} else { } else {
const subscription = await this.stripe.subscriptions.update( const subscription = await this.stripe.subscriptions.update(
@ -351,12 +349,12 @@ export class SubscriptionService {
} }
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) { if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`); throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
} }
if (subscriptionInDB.canceledAt) { if (subscriptionInDB.canceledAt) {
throw new BadRequestException( throw new BadRequestException(
'Your subscription has already been canceled ' 'Your subscription has already been canceled'
); );
} }
@ -415,7 +413,6 @@ export class SubscriptionService {
@OnEvent('customer.subscription.created') @OnEvent('customer.subscription.created')
@OnEvent('customer.subscription.updated') @OnEvent('customer.subscription.updated')
async onSubscriptionChanges(subscription: Stripe.Subscription) { async onSubscriptionChanges(subscription: Stripe.Subscription) {
// webhook call may not in sequential order, get the latest status
subscription = await this.stripe.subscriptions.retrieve(subscription.id); subscription = await this.stripe.subscriptions.retrieve(subscription.id);
if (subscription.status === 'active') { if (subscription.status === 'active') {
const user = await this.retrieveUserFromCustomer( const user = await this.retrieveUserFromCustomer(
@ -432,7 +429,6 @@ export class SubscriptionService {
@OnEvent('customer.subscription.deleted') @OnEvent('customer.subscription.deleted')
async onSubscriptionDeleted(subscription: Stripe.Subscription) { async onSubscriptionDeleted(subscription: Stripe.Subscription) {
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
const user = await this.retrieveUserFromCustomer( const user = await this.retrieveUserFromCustomer(
typeof subscription.customer === 'string' typeof subscription.customer === 'string'
? subscription.customer ? subscription.customer
@ -553,16 +549,8 @@ export class SubscriptionService {
private async saveSubscription( private async saveSubscription(
user: User, user: User,
subscription: Stripe.Subscription, subscription: Stripe.Subscription
fromWebhook = true
): Promise<UserSubscription> { ): Promise<UserSubscription> {
// webhook events may not in sequential order
// always fetch the latest subscription and save
// see https://stripe.com/docs/webhooks#behaviors
if (fromWebhook) {
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
}
const price = subscription.items.data[0].price; const price = subscription.items.data[0].price;
if (!price.lookup_key) { if (!price.lookup_key) {
throw new Error('Unexpected subscription with no key'); throw new Error('Unexpected subscription with no key');
@ -768,13 +756,12 @@ export class SubscriptionService {
}); });
if (plan === SubscriptionPlan.Pro) { if (plan === SubscriptionPlan.Pro) {
const canHaveEADiscount = isEaUser && !subscribed; const canHaveEADiscount =
isEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly;
const price = await this.getPrice( const price = await this.getPrice(
plan, plan,
recurring, recurring,
canHaveEADiscount && recurring === SubscriptionRecurring.Yearly canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
? SubscriptionPriceVariant.EA
: undefined
); );
return { return {
price, price,
@ -788,13 +775,12 @@ export class SubscriptionService {
EarlyAccessType.AI EarlyAccessType.AI
); );
const canHaveEADiscount = isAIEaUser && !subscribed; const canHaveEADiscount =
isAIEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly;
const price = await this.getPrice( const price = await this.getPrice(
plan, plan,
recurring, recurring,
canHaveEADiscount && recurring === SubscriptionRecurring.Yearly canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
? SubscriptionPriceVariant.EA
: undefined
); );
return { return {

View File

@ -0,0 +1,901 @@
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 { ConfigModule } from '../../src/fundamentals/config';
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;
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)
);
},
});
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;
t.context.u1 = await app.get(AuthService).signUp('u1', 'u1@affine.pro', '1');
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_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_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.is(prices.length, 3);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
);
});
test('should list normal prices for authenticated user', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.email).resolves(false);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.resolves(false);
// @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.is(prices.length, 3);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
);
});
test('should list early access prices for pro ea user', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.email).resolves(true);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.resolves(false);
// @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.is(prices.length, 3);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_EA_YEARLY, AI_YEARLY])
);
});
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.email).resolves(true);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.resolves(false);
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.is(prices.length, 3);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
);
});
test('should list early access prices for ai ea user', async t => {
const { feature, service, u1, stripe } = t.context;
feature.isEarlyAccessUser.withArgs(u1.email).resolves(false);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.resolves(true);
// @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.is(prices.length, 3);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY_EA])
);
});
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.email).resolves(true);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.resolves(true);
// @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.is(prices.length, 3);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_EA_YEARLY, AI_YEARLY_EA])
);
});
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.email).resolves(false);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.resolves(true);
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.is(prices.length, 3);
t.deepEqual(
new Set(prices.map(p => p.lookup_key)),
new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY])
);
});
// ============= 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've already subscribed to the pro plan" }
);
});
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,
});
}
});
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.email).resolves(true);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.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.email).resolves(true);
feature.isEarlyAccessUser
.withArgs(u1.email, EarlyAccessType.AI)
.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 => {
const { service, stripe, db, u1 } = t.context;
Sinon.stub(stripe.subscriptions, 'retrieve').resolves(sub as any);
await service.onSubscriptionChanges(sub);
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 => {
const { service, stripe, db, u1 } = t.context;
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);
stub.resolves({
...sub,
cancel_at_period_end: true,
canceled_at: 1714118236,
} as any);
await service.onSubscriptionChanges(sub);
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 => {
const { service, stripe, db, u1 } = t.context;
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);
stub.resolves({ ...sub, status: 'canceled' } as any);
await service.onSubscriptionChanges(sub);
subInDB = await db.userSubscription.findFirst({
where: { userId: u1.id },
});
t.is(subInDB, null);
});
test('should be able to cancel subscription', 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.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);
const subInDB = await service.cancelSubscription(
'',
u1.id,
SubscriptionPlan.Pro
);
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 => {
const { service, db, u1, stripe } = t.context;
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);
const subInDB = await service.resumeCanceledSubscription(
'',
u1.id,
SubscriptionPlan.Pro
);
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);
});