From f566457dcf7d83855073f7af2fa64fec19fe5dd2 Mon Sep 17 00:00:00 2001 From: forehalo Date: Tue, 30 Apr 2024 06:59:54 +0000 Subject: [PATCH] test(server): payment tests (#6737) --- .../server/src/plugins/payment/schedule.ts | 7 +- .../server/src/plugins/payment/service.ts | 48 +- .../server/tests/payment/service.spec.ts | 901 ++++++++++++++++++ 3 files changed, 919 insertions(+), 37 deletions(-) create mode 100644 packages/backend/server/tests/payment/service.spec.ts diff --git a/packages/backend/server/src/plugins/payment/schedule.ts b/packages/backend/server/src/plugins/payment/schedule.ts index 7a2439580e..0f9c744fde 100644 --- a/packages/backend/server/src/plugins/payment/schedule.ts +++ b/packages/backend/server/src/plugins/payment/schedule.ts @@ -197,7 +197,7 @@ export class ScheduleManager { 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) { await this.stripe.subscriptionSchedules.release(this._schedule.id, { idempotencyKey, @@ -221,13 +221,8 @@ export class ScheduleManager { items: [ { price: price, - quantity: 1, }, ], - coupon: - typeof this.currentPhase.coupon === 'string' - ? this.currentPhase.coupon - : this.currentPhase.coupon?.id ?? undefined, }, ], }, diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index fcdf6881b5..57fff9695c 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -164,7 +164,7 @@ export class SubscriptionService { if (currentSubscription) { 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'] = []; - if (promotionCode) { + if (coupon) { + discounts = [{ coupon }]; + } else if (promotionCode) { const code = await this.getAvailablePromotionCode( promotionCode, customer.stripeCustomerId @@ -189,8 +191,6 @@ export class SubscriptionService { if (code) { discounts = [{ promotion_code: code }]; } - } else if (coupon) { - discounts = [{ coupon }]; } return await this.stripe.checkout.sessions.create( @@ -241,7 +241,7 @@ export class SubscriptionService { const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); 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) { @@ -260,8 +260,7 @@ export class SubscriptionService { user, await this.stripe.subscriptions.retrieve( subscriptionInDB.stripeSubscriptionId - ), - false + ) ); } else { // 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); 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) { @@ -317,8 +316,7 @@ export class SubscriptionService { user, await this.stripe.subscriptions.retrieve( subscriptionInDB.stripeSubscriptionId - ), - false + ) ); } else { const subscription = await this.stripe.subscriptions.update( @@ -351,12 +349,12 @@ export class SubscriptionService { } const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); 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) { 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.updated') async onSubscriptionChanges(subscription: Stripe.Subscription) { - // webhook call may not in sequential order, get the latest status subscription = await this.stripe.subscriptions.retrieve(subscription.id); if (subscription.status === 'active') { const user = await this.retrieveUserFromCustomer( @@ -432,7 +429,6 @@ export class SubscriptionService { @OnEvent('customer.subscription.deleted') async onSubscriptionDeleted(subscription: Stripe.Subscription) { - subscription = await this.stripe.subscriptions.retrieve(subscription.id); const user = await this.retrieveUserFromCustomer( typeof subscription.customer === 'string' ? subscription.customer @@ -553,16 +549,8 @@ export class SubscriptionService { private async saveSubscription( user: User, - subscription: Stripe.Subscription, - fromWebhook = true + subscription: Stripe.Subscription ): Promise { - // 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; if (!price.lookup_key) { throw new Error('Unexpected subscription with no key'); @@ -768,13 +756,12 @@ export class SubscriptionService { }); if (plan === SubscriptionPlan.Pro) { - const canHaveEADiscount = isEaUser && !subscribed; + const canHaveEADiscount = + isEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly; const price = await this.getPrice( plan, recurring, - canHaveEADiscount && recurring === SubscriptionRecurring.Yearly - ? SubscriptionPriceVariant.EA - : undefined + canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined ); return { price, @@ -788,13 +775,12 @@ export class SubscriptionService { EarlyAccessType.AI ); - const canHaveEADiscount = isAIEaUser && !subscribed; + const canHaveEADiscount = + isAIEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly; const price = await this.getPrice( plan, recurring, - canHaveEADiscount && recurring === SubscriptionRecurring.Yearly - ? SubscriptionPriceVariant.EA - : undefined + canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined ); return { diff --git a/packages/backend/server/tests/payment/service.spec.ts b/packages/backend/server/tests/payment/service.spec.ts new file mode 100644 index 0000000000..fca7618f3c --- /dev/null +++ b/packages/backend/server/tests/payment/service.spec.ts @@ -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; +}>; + +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); +});