diff --git a/packages/backend/server/src/modules/payment/index.ts b/packages/backend/server/src/modules/payment/index.ts index 1a51678848..30393504b5 100644 --- a/packages/backend/server/src/modules/payment/index.ts +++ b/packages/backend/server/src/modules/payment/index.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { UsersModule } from '../users'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; +import { ScheduleManager } from './schedule'; import { SubscriptionService } from './service'; import { StripeProvider } from './stripe'; import { StripeWebhook } from './webhook'; @Module({ + imports: [UsersModule], providers: [ + ScheduleManager, StripeProvider, SubscriptionService, SubscriptionResolver, diff --git a/packages/backend/server/src/modules/payment/schedule.ts b/packages/backend/server/src/modules/payment/schedule.ts new file mode 100644 index 0000000000..423bdf6916 --- /dev/null +++ b/packages/backend/server/src/modules/payment/schedule.ts @@ -0,0 +1,214 @@ +import { Injectable } from '@nestjs/common'; +import Stripe from 'stripe'; + +@Injectable() +export class ScheduleManager { + private _schedule: Stripe.SubscriptionSchedule | null = null; + + constructor(private readonly stripe: Stripe) {} + + static create(stripe: Stripe, schedule?: Stripe.SubscriptionSchedule) { + const manager = new ScheduleManager(stripe); + if (schedule) { + manager._schedule = schedule; + } + + return manager; + } + + get schedule() { + return this._schedule; + } + + get currentPhase() { + if (!this._schedule) { + return null; + } + + return this._schedule.phases.find( + phase => + phase.start_date * 1000 < Date.now() && + phase.end_date * 1000 > Date.now() + ); + } + + get nextPhase() { + if (!this._schedule) { + return null; + } + + return this._schedule.phases.find( + phase => phase.start_date * 1000 > Date.now() + ); + } + + get isActive() { + return this._schedule?.status === 'active'; + } + + async fromSchedule(schedule: string | Stripe.SubscriptionSchedule) { + if (typeof schedule === 'string') { + const s = await this.stripe.subscriptionSchedules + .retrieve(schedule) + .catch(() => undefined); + + return ScheduleManager.create(this.stripe, s); + } else { + return ScheduleManager.create(this.stripe, schedule); + } + } + + async fromSubscription(subscription: string | Stripe.Subscription) { + if (typeof subscription === 'string') { + subscription = await this.stripe.subscriptions.retrieve(subscription, { + expand: ['schedule'], + }); + } + + if (subscription.schedule) { + return await this.fromSchedule(subscription.schedule); + } else { + const schedule = await this.stripe.subscriptionSchedules.create({ + from_subscription: subscription.id, + }); + + return await this.fromSchedule(schedule); + } + } + + /** + * Cancel a subscription by marking schedule's end behavior to `cancel`. + * At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription. + */ + async cancel() { + if (!this._schedule) { + throw new Error('No schedule'); + } + + if (!this.isActive || !this.currentPhase) { + throw new Error('Unexpected subscription schedule status'); + } + + const phases: Stripe.SubscriptionScheduleUpdateParams.Phase = { + items: [ + { + price: this.currentPhase.items[0].price as string, + quantity: 1, + }, + ], + coupon: (this.currentPhase.coupon as string | null) ?? undefined, + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + }; + + if (this.nextPhase) { + // cancel a subscription with a schedule exiting will delete the upcoming phase, + // it's hard to recover the subscription to the original state if user wan't to resume before due. + // so we manually save the next phase's key information to metadata for later easy resuming. + phases.metadata = { + next_coupon: (this.nextPhase.coupon as string | null) || null, // avoid empty string + next_price: this.nextPhase.items[0].price as string, + }; + } + + await this.stripe.subscriptionSchedules.update(this._schedule.id, { + phases: [phases], + end_behavior: 'cancel', + }); + } + + async resume() { + if (!this._schedule) { + throw new Error('No schedule'); + } + + if (!this.isActive || !this.currentPhase) { + throw new Error('Unexpected subscription schedule status'); + } + + const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [ + { + items: [ + { + price: this.currentPhase.items[0].price as string, + quantity: 1, + }, + ], + coupon: (this.currentPhase.coupon as string | null) ?? undefined, + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + metadata: { + next_coupon: null, + next_price: null, + }, + }, + ]; + + if (this.currentPhase.metadata && this.currentPhase.metadata.next_price) { + phases.push({ + items: [ + { + price: this.currentPhase.metadata.next_price, + quantity: 1, + }, + ], + coupon: this.currentPhase.metadata.next_coupon || undefined, + }); + } + + await this.stripe.subscriptionSchedules.update(this._schedule.id, { + phases: phases, + end_behavior: 'release', + }); + } + + async release() { + if (!this._schedule) { + throw new Error('No schedule'); + } + + await this.stripe.subscriptionSchedules.release(this._schedule.id); + } + + async update(price: string, coupon?: string) { + if (!this._schedule) { + throw new Error('No schedule'); + } + + if (!this.isActive || !this.currentPhase) { + throw new Error('Unexpected subscription schedule status'); + } + + // if current phase's plan matches target, and no coupon change, just release the schedule + if ( + this.currentPhase.items[0].price === price && + (!coupon || this.currentPhase.coupon === coupon) + ) { + await this.stripe.subscriptionSchedules.release(this._schedule.id); + this._schedule = null; + } else { + await this.stripe.subscriptionSchedules.update(this._schedule.id, { + phases: [ + { + items: [ + { + price: this.currentPhase.items[0].price as string, + }, + ], + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + }, + { + items: [ + { + price: price, + quantity: 1, + }, + ], + coupon, + }, + ], + }); + } + } +} diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts index e9e4374b0b..91d98d2788 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/modules/payment/service.ts @@ -11,6 +11,8 @@ import Stripe from 'stripe'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; +import { UsersService } from '../users'; +import { ScheduleManager } from './schedule'; const OnEvent = ( event: Stripe.Event.Type, @@ -65,7 +67,7 @@ export enum InvoiceStatus { Uncollectible = 'uncollectible', } -export enum Coupon { +export enum CouponType { EarlyAccess = 'earlyaccess', EarlyAccessRenew = 'earlyaccessrenew', } @@ -78,7 +80,9 @@ export class SubscriptionService { constructor( config: Config, private readonly stripe: Stripe, - private readonly db: PrismaService + private readonly db: PrismaService, + private readonly user: UsersService, + private readonly scheduleManager: ScheduleManager ) { this.paymentConfig = config.payment; @@ -117,8 +121,9 @@ export class SubscriptionService { } const price = await this.getPrice(plan, recurring); - const customer = await this.getOrCreateCustomer(user); + const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess); + return await this.stripe.checkout.sessions.create({ line_items: [ { @@ -126,10 +131,16 @@ export class SubscriptionService { quantity: 1, }, ], - allow_promotion_codes: true, tax_id_collection: { enabled: true, }, + ...(coupon + ? { + discounts: [{ coupon }], + } + : { + allow_promotion_codes: true, + }), mode: 'subscription', success_url: redirectUrl, customer: customer.stripeCustomerId, @@ -160,12 +171,16 @@ export class SubscriptionService { // should release the schedule first if (user.subscription.stripeScheduleId) { - await this.cancelSubscriptionSchedule(user.subscription.stripeScheduleId); + const manager = await this.scheduleManager.fromSchedule( + user.subscription.stripeScheduleId + ); + await manager.cancel(); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( user.subscription.stripeSubscriptionId - ) + ), + false ); } else { // let customer contact support if they want to cancel immediately @@ -203,12 +218,16 @@ export class SubscriptionService { } if (user.subscription.stripeScheduleId) { - await this.resumeSubscriptionSchedule(user.subscription.stripeScheduleId); + const manager = await this.scheduleManager.fromSchedule( + user.subscription.stripeScheduleId + ); + await manager.resume(); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( user.subscription.stripeSubscriptionId - ) + ), + false ); } else { const subscription = await this.stripe.subscriptions.update( @@ -252,27 +271,26 @@ export class SubscriptionService { recurring ); - let scheduleId: string | null; - // a schedule existing - if (user.subscription.stripeScheduleId) { - scheduleId = await this.scheduleNewPrice( - user.subscription.stripeScheduleId, - price - ); - } else { - const schedule = await this.stripe.subscriptionSchedules.create({ - from_subscription: user.subscription.stripeSubscriptionId, - }); - await this.scheduleNewPrice(schedule.id, price); - scheduleId = schedule.id; - } + const manager = await this.scheduleManager.fromSubscription( + user.subscription.stripeSubscriptionId + ); + + await manager.update( + price, + // if user is early access user, use early access coupon + manager.currentPhase?.coupon === CouponType.EarlyAccess || + manager.currentPhase?.coupon === CouponType.EarlyAccessRenew || + manager.nextPhase?.coupon === CouponType.EarlyAccessRenew + ? CouponType.EarlyAccessRenew + : undefined + ); return await this.db.userSubscription.update({ where: { id: user.subscription.id, }, data: { - stripeScheduleId: scheduleId, + stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched) recurring, }, }); @@ -325,8 +343,26 @@ export class SubscriptionService { }); } - @OnEvent('invoice.created') @OnEvent('invoice.paid') + async onInvoicePaid(stripeInvoice: Stripe.Invoice) { + await this.saveInvoice(stripeInvoice); + + const line = stripeInvoice.lines.data[0]; + + if (!line.price || line.price.type !== 'recurring') { + throw new Error('Unknown invoice with no recurring price'); + } + + // deal with early access user + if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) { + const manager = await this.scheduleManager.fromSubscription( + line.subscription as string + ); + await manager.update(line.price.id, CouponType.EarlyAccessRenew); + } + } + + @OnEvent('invoice.created') @OnEvent('invoice.finalization_failed') @OnEvent('invoice.payment_failed') async saveInvoice(stripeInvoice: Stripe.Invoice) { @@ -591,165 +627,21 @@ export class SubscriptionService { return prices.data[0].id; } - /** - * If a subscription is managed by a schedule, it has a different way to cancel. - */ - private async cancelSubscriptionSchedule(scheduleId: string) { - const schedule = - await this.stripe.subscriptionSchedules.retrieve(scheduleId); - - const currentPhase = schedule.phases.find( - phase => - phase.start_date * 1000 < Date.now() && - phase.end_date * 1000 > Date.now() - ); - - if ( - schedule.status !== 'active' || - schedule.phases.length > 2 || - !currentPhase - ) { - throw new Error('Unexpected subscription schedule status'); - } - - if (schedule.status !== 'active') { - throw new Error('unexpected subscription schedule status'); - } - - const nextPhase = schedule.phases.find( - phase => phase.start_date * 1000 > Date.now() - ); - - if (!currentPhase) { - throw new Error('Unexpected subscription schedule status'); - } - - const update: Stripe.SubscriptionScheduleUpdateParams.Phase = { - items: [ - { - price: currentPhase.items[0].price as string, - quantity: 1, - }, - ], - coupon: (currentPhase.coupon as string | null) ?? undefined, - start_date: currentPhase.start_date, - end_date: currentPhase.end_date, - }; - - if (nextPhase) { - // cancel a subscription with a schedule exiting will delete the upcoming phase, - // it's hard to recover the subscription to the original state if user wan't to resume before due. - // so we manually save the next phase's key information to metadata for later easy resuming. - update.metadata = { - next_coupon: (nextPhase.coupon as string | null) || null, // avoid empty string - next_price: nextPhase.items[0].price as string, - }; - } - - await this.stripe.subscriptionSchedules.update(schedule.id, { - phases: [update], - end_behavior: 'cancel', - }); - } - - private async resumeSubscriptionSchedule(scheduleId: string) { - const schedule = - await this.stripe.subscriptionSchedules.retrieve(scheduleId); - - const currentPhase = schedule.phases.find( - phase => - phase.start_date * 1000 < Date.now() && - phase.end_date * 1000 > Date.now() - ); - - if (schedule.status !== 'active' || !currentPhase) { - throw new Error('Unexpected subscription schedule status'); - } - - const update: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [ - { - items: [ - { - price: currentPhase.items[0].price as string, - quantity: 1, - }, - ], - coupon: (currentPhase.coupon as string | null) ?? undefined, - start_date: currentPhase.start_date, - end_date: currentPhase.end_date, - metadata: { - next_coupon: null, - next_price: null, - }, - }, - ]; - - if (currentPhase.metadata && currentPhase.metadata.next_price) { - update.push({ - items: [ - { - price: currentPhase.metadata.next_price, - quantity: 1, - }, - ], - coupon: currentPhase.metadata.next_coupon || undefined, - }); - } - - await this.stripe.subscriptionSchedules.update(schedule.id, { - phases: update, - end_behavior: 'release', - }); - } - - /** - * we only schedule a new price when user change the recurring plan and there is now upcoming phases. - */ - private async scheduleNewPrice( - scheduleId: string, - priceId: string + private async getAvailableCoupon( + user: User, + couponType: CouponType ): Promise { - const schedule = - await this.stripe.subscriptionSchedules.retrieve(scheduleId); - - const currentPhase = schedule.phases.find( - phase => - phase.start_date * 1000 < Date.now() && - phase.end_date * 1000 > Date.now() - ); - - if (schedule.status !== 'active' || !currentPhase) { - throw new Error('Unexpected subscription schedule status'); + const earlyAccess = await this.user.isEarlyAccessUser(user.email); + if (earlyAccess) { + try { + const coupon = await this.stripe.coupons.retrieve(couponType); + return coupon.valid ? coupon.id : null; + } catch (e) { + this.logger.error('Failed to get early access coupon', e); + return null; + } } - // if current phase's plan matches target, just release the schedule - if (currentPhase.items[0].price === priceId) { - await this.stripe.subscriptionSchedules.release(scheduleId); - return null; - } else { - await this.stripe.subscriptionSchedules.update(schedule.id, { - phases: [ - { - items: [ - { - price: currentPhase.items[0].price as string, - }, - ], - start_date: schedule.phases[0].start_date, - end_date: schedule.phases[0].end_date, - }, - { - items: [ - { - price: priceId, - quantity: 1, - }, - ], - }, - ], - }); - - return scheduleId; - } + return null; } } diff --git a/packages/backend/server/src/modules/users/index.ts b/packages/backend/server/src/modules/users/index.ts index 40dc84ae46..308a1a9a13 100644 --- a/packages/backend/server/src/modules/users/index.ts +++ b/packages/backend/server/src/modules/users/index.ts @@ -7,6 +7,7 @@ import { UsersService } from './users'; @Module({ imports: [StorageModule], providers: [UserResolver, UsersService], + exports: [UsersService], }) export class UsersModule {} diff --git a/packages/backend/server/src/modules/users/users.ts b/packages/backend/server/src/modules/users/users.ts index 477adc1fd3..1d56c936b2 100644 --- a/packages/backend/server/src/modules/users/users.ts +++ b/packages/backend/server/src/modules/users/users.ts @@ -15,16 +15,21 @@ export class UsersService { async canEarlyAccess(email: string) { if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) { - return this.prisma.newFeaturesWaitingList - .findUnique({ - where: { email, type: NewFeaturesKind.EarlyAccess }, - }) - .catch(() => false); + return this.isEarlyAccessUser(email); } else { return true; } } + async isEarlyAccessUser(email: string) { + return this.prisma.newFeaturesWaitingList + .count({ + where: { email, type: NewFeaturesKind.EarlyAccess }, + }) + .then(count => count > 0) + .catch(() => false); + } + async getStorageQuotaById(id: string) { const features = await this.prisma.user .findUnique({