import { Injectable, Logger } from '@nestjs/common'; import { OnEvent as RawOnEvent } from '@nestjs/event-emitter'; import type { Prisma, User, UserInvoice, UserStripeCustomer, UserSubscription, } from '@prisma/client'; import Stripe from 'stripe'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; import { FeatureManagementService } from '../features'; import { QuotaService, QuotaType } from '../quota'; import { ScheduleManager } from './schedule'; const OnEvent = ( event: Stripe.Event.Type, opts?: Parameters[1] ) => RawOnEvent(event, opts); // Plan x Recurring make a stripe price lookup key export enum SubscriptionRecurring { Monthly = 'monthly', Yearly = 'yearly', } export enum SubscriptionPlan { Free = 'free', Pro = 'pro', Team = 'team', Enterprise = 'enterprise', SelfHosted = 'selfhosted', } export function encodeLookupKey( plan: SubscriptionPlan, recurring: SubscriptionRecurring ): string { return plan + '_' + recurring; } export function decodeLookupKey( key: string ): [SubscriptionPlan, SubscriptionRecurring] { const [plan, recurring] = key.split('_'); return [plan as SubscriptionPlan, recurring as SubscriptionRecurring]; } // see https://stripe.com/docs/api/subscriptions/object#subscription_object-status export enum SubscriptionStatus { Active = 'active', PastDue = 'past_due', Unpaid = 'unpaid', Canceled = 'canceled', Incomplete = 'incomplete', Paused = 'paused', IncompleteExpired = 'incomplete_expired', Trialing = 'trialing', } const SubscriptionActivated: Stripe.Subscription.Status[] = [ SubscriptionStatus.Active, SubscriptionStatus.Trialing, ]; export enum InvoiceStatus { Draft = 'draft', Open = 'open', Void = 'void', Paid = 'paid', Uncollectible = 'uncollectible', } export enum CouponType { EarlyAccess = 'earlyaccess', EarlyAccessRenew = 'earlyaccessrenew', } @Injectable() export class SubscriptionService { private readonly paymentConfig: Config['payment']; private readonly logger = new Logger(SubscriptionService.name); constructor( config: Config, private readonly stripe: Stripe, private readonly db: PrismaService, private readonly scheduleManager: ScheduleManager, private readonly features: FeatureManagementService, private readonly quota: QuotaService ) { this.paymentConfig = config.payment; if ( !this.paymentConfig.stripe.keys.APIKey || !this.paymentConfig.stripe.keys.webhookKey /* default empty string */ ) { this.logger.warn('Stripe API key not set, Stripe will be disabled'); this.logger.warn('Set STRIPE_API_KEY to enable Stripe'); } } async listPrices() { return this.stripe.prices.list(); } async createCheckoutSession({ user, recurring, redirectUrl, idempotencyKey, plan = SubscriptionPlan.Pro, }: { user: User; plan?: SubscriptionPlan; recurring: SubscriptionRecurring; redirectUrl: string; idempotencyKey: string; }) { const currentSubscription = await this.db.userSubscription.findFirst({ where: { userId: user.id, status: SubscriptionStatus.Active, }, }); if (currentSubscription) { throw new Error('You already have a subscription'); } const price = await this.getPrice(plan, recurring); const customer = await this.getOrCreateCustomer( `${idempotencyKey}-getOrCreateCustomer`, user ); const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess); return await this.stripe.checkout.sessions.create( { line_items: [ { price, quantity: 1, }, ], tax_id_collection: { enabled: true, }, ...(coupon ? { discounts: [{ coupon }], } : { allow_promotion_codes: true, }), mode: 'subscription', success_url: redirectUrl, customer: customer.stripeCustomerId, customer_update: { address: 'auto', name: 'auto', }, }, { idempotencyKey: `${idempotencyKey}-checkoutSession` } ); } async cancelSubscription( idempotencyKey: string, userId: string ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, }, include: { subscription: true, }, }); if (!user?.subscription) { throw new Error('You do not have any subscription'); } if (user.subscription.canceledAt) { throw new Error('Your subscription has already been canceled'); } // should release the schedule first if (user.subscription.stripeScheduleId) { const manager = await this.scheduleManager.fromSchedule( user.subscription.stripeScheduleId ); await manager.cancel(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( user.subscription.stripeSubscriptionId ), false ); } else { // let customer contact support if they want to cancel immediately // see https://stripe.com/docs/billing/subscriptions/cancel const subscription = await this.stripe.subscriptions.update( user.subscription.stripeSubscriptionId, { cancel_at_period_end: true }, { idempotencyKey } ); return await this.saveSubscription(user, subscription); } } async resumeCanceledSubscription( idempotencyKey: string, userId: string ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, }, include: { subscription: true, }, }); if (!user?.subscription) { throw new Error('You do not have any subscription'); } if (!user.subscription.canceledAt) { throw new Error('Your subscription has not been canceled'); } if (user.subscription.end < new Date()) { throw new Error('Your subscription is expired, please checkout again.'); } if (user.subscription.stripeScheduleId) { const manager = await this.scheduleManager.fromSchedule( user.subscription.stripeScheduleId ); await manager.resume(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( user.subscription.stripeSubscriptionId ), false ); } else { const subscription = await this.stripe.subscriptions.update( user.subscription.stripeSubscriptionId, { cancel_at_period_end: false }, { idempotencyKey } ); return await this.saveSubscription(user, subscription); } } async updateSubscriptionRecurring( idempotencyKey: string, userId: string, recurring: SubscriptionRecurring ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, }, include: { subscription: true, }, }); if (!user?.subscription) { throw new Error('You do not have any subscription'); } if (user.subscription.canceledAt) { throw new Error('Your subscription has already been canceled '); } if (user.subscription.recurring === recurring) { throw new Error('You have already subscribed to this plan'); } const price = await this.getPrice( user.subscription.plan as SubscriptionPlan, recurring ); const manager = await this.scheduleManager.fromSubscription( `${idempotencyKey}-fromSubscription`, user.subscription.stripeSubscriptionId ); await manager.update( `${idempotencyKey}-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: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched) recurring, }, }); } async createCustomerPortal(id: string) { const user = await this.db.userStripeCustomer.findUnique({ where: { userId: id, }, }); if (!user) { throw new Error('Unknown user'); } try { const portal = await this.stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, }); return portal.url; } catch (e) { this.logger.error('Failed to create customer portal.', e); throw new Error('Failed to create customer portal'); } } @OnEvent('customer.subscription.created') @OnEvent('customer.subscription.updated') async onSubscriptionChanges(subscription: Stripe.Subscription) { const user = await this.retrieveUserFromCustomer( subscription.customer as string ); await this.saveSubscription(user, subscription); } @OnEvent('customer.subscription.deleted') async onSubscriptionDeleted(subscription: Stripe.Subscription) { const user = await this.retrieveUserFromCustomer( subscription.customer as string ); await this.db.userSubscription.deleteMany({ where: { stripeSubscriptionId: subscription.id, userId: user.id, }, }); } @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 idempotencyKey = stripeInvoice.id + '_earlyaccess'; const manager = await this.scheduleManager.fromSubscription( `${idempotencyKey}-fromSubscription`, line.subscription as string ); await manager.update( `${idempotencyKey}-update`, line.price.id, CouponType.EarlyAccessRenew ); } } @OnEvent('invoice.created') @OnEvent('invoice.finalization_failed') @OnEvent('invoice.payment_failed') async saveInvoice(stripeInvoice: Stripe.Invoice) { if (!stripeInvoice.customer) { throw new Error('Unexpected invoice with no customer'); } const user = await this.retrieveUserFromCustomer( typeof stripeInvoice.customer === 'string' ? stripeInvoice.customer : stripeInvoice.customer.id ); const invoice = await this.db.userInvoice.findUnique({ where: { stripeInvoiceId: stripeInvoice.id, }, }); const data: Partial = { currency: stripeInvoice.currency, amount: stripeInvoice.total, status: stripeInvoice.status ?? InvoiceStatus.Void, link: stripeInvoice.hosted_invoice_url, }; // handle payment error if (stripeInvoice.attempt_count > 1) { const paymentIntent = await this.stripe.paymentIntents.retrieve( stripeInvoice.payment_intent as string ); if (paymentIntent.last_payment_error) { if (paymentIntent.last_payment_error.type === 'card_error') { data.lastPaymentError = paymentIntent.last_payment_error.message ?? 'Failed to pay'; } else { data.lastPaymentError = 'Internal Payment error'; } } } else if (stripeInvoice.last_finalization_error) { if (stripeInvoice.last_finalization_error.type === 'card_error') { data.lastPaymentError = stripeInvoice.last_finalization_error.message ?? 'Failed to finalize invoice'; } else { data.lastPaymentError = 'Internal Payment error'; } } // update invoice if (invoice) { await this.db.userInvoice.update({ where: { stripeInvoiceId: stripeInvoice.id, }, data, }); } else { // create invoice const price = stripeInvoice.lines.data[0].price; if (!price || price.type !== 'recurring') { throw new Error('Unexpected invoice with no recurring price'); } if (!price.lookup_key) { throw new Error('Unexpected subscription with no key'); } const [plan, recurring] = decodeLookupKey(price.lookup_key); await this.db.userInvoice.create({ data: { userId: user.id, stripeInvoiceId: stripeInvoice.id, plan, recurring, reason: stripeInvoice.billing_reason ?? 'contact support', ...(data as any), }, }); } } private getPlanQuota(plan: SubscriptionPlan) { if (plan === SubscriptionPlan.Free) { return QuotaType.FreePlanV1; } else if (plan === SubscriptionPlan.Pro) { return QuotaType.ProPlanV1; } else { throw new Error(`Unknown plan: ${plan}`); } } private async saveSubscription( user: User, subscription: Stripe.Subscription, fromWebhook = true ): 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'); } const [plan, recurring] = decodeLookupKey(price.lookup_key); const planActivated = SubscriptionActivated.includes(subscription.status); let nextBillAt: Date | null = null; if (planActivated) { // update user's quota if plan activated await this.quota.switchUserQuota(user.id, this.getPlanQuota(plan)); // get next bill date from upcoming invoice // see https://stripe.com/docs/api/invoices/upcoming if (!subscription.canceled_at) { nextBillAt = new Date(subscription.current_period_end * 1000); } } else { // switch to free plan if subscription is canceled await this.quota.switchUserQuota(user.id, QuotaType.FreePlanV1); } const commonData = { start: new Date(subscription.current_period_start * 1000), end: new Date(subscription.current_period_end * 1000), trialStart: subscription.trial_start ? new Date(subscription.trial_start * 1000) : null, trialEnd: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null, nextBillAt, canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null, stripeSubscriptionId: subscription.id, plan, recurring, status: subscription.status, stripeScheduleId: subscription.schedule as string | null, }; const currentSubscription = await this.db.userSubscription.findUnique({ where: { userId: user.id, }, }); if (currentSubscription) { const update: Prisma.UserSubscriptionUpdateInput = { ...commonData, }; // a schedule exists, update the recurring to scheduled one if (update.stripeScheduleId) { delete update.recurring; } return await this.db.userSubscription.update({ where: { id: currentSubscription.id, }, data: update, }); } else { return await this.db.userSubscription.create({ data: { userId: user.id, ...commonData, }, }); } } private async getOrCreateCustomer( idempotencyKey: string, user: User ): Promise { const customer = await this.db.userStripeCustomer.findUnique({ where: { userId: user.id, }, }); if (customer) { return customer; } const stripeCustomersList = await this.stripe.customers.list({ email: user.email, limit: 1, }); let stripeCustomer: Stripe.Customer | undefined; if (stripeCustomersList.data.length) { stripeCustomer = stripeCustomersList.data[0]; } else { stripeCustomer = await this.stripe.customers.create( { email: user.email }, { idempotencyKey } ); } return await this.db.userStripeCustomer.create({ data: { userId: user.id, stripeCustomerId: stripeCustomer.id, }, }); } private async retrieveUserFromCustomer(customerId: string) { const customer = await this.db.userStripeCustomer.findUnique({ where: { stripeCustomerId: customerId, }, include: { user: true, }, }); if (customer?.user) { return customer.user; } // customer may not saved is db, check it with stripe const stripeCustomer = await this.stripe.customers.retrieve(customerId); if (stripeCustomer.deleted) { throw new Error('Unexpected subscription created with deleted customer'); } if (!stripeCustomer.email) { throw new Error('Unexpected subscription created with no email customer'); } const user = await this.db.user.findUnique({ where: { email: stripeCustomer.email, }, }); if (!user) { throw new Error( `Unexpected subscription created with unknown customer ${stripeCustomer.email}` ); } await this.db.userStripeCustomer.create({ data: { userId: user.id, stripeCustomerId: stripeCustomer.id, }, }); return user; } private async getPrice( plan: SubscriptionPlan, recurring: SubscriptionRecurring ): Promise { const prices = await this.stripe.prices.list({ lookup_keys: [encodeLookupKey(plan, recurring)], }); if (!prices.data.length) { throw new Error( `Unknown subscription plan ${plan} with recurring ${recurring}` ); } return prices.data[0].id; } private async getAvailableCoupon( user: User, couponType: CouponType ): Promise { const earlyAccess = await this.features.canEarlyAccess(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; } } return null; } }