mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-01 22:32:26 +03:00
test(server): payment tests (#6737)
This commit is contained in:
parent
1f97437320
commit
f566457dcf
@ -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,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -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 {
|
||||||
|
901
packages/backend/server/tests/payment/service.spec.ts
Normal file
901
packages/backend/server/tests/payment/service.spec.ts
Normal 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);
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user