import { randomUUID } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; import { OnEvent as RawOnEvent } from '@nestjs/event-emitter'; import type { User, UserInvoice, UserStripeCustomer, UserSubscription, } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import Stripe from 'stripe'; import { CurrentUser } from '../../core/auth'; import { EarlyAccessType, FeatureManagementService } from '../../core/features'; import { ActionForbidden, CantUpdateLifetimeSubscription, Config, CustomerPortalCreateFailed, EventEmitter, OnEvent, SameSubscriptionRecurring, SubscriptionAlreadyExists, SubscriptionExpired, SubscriptionHasBeenCanceled, SubscriptionNotExists, SubscriptionPlanNotFound, UserNotFound, } from '../../fundamentals'; import { ScheduleManager } from './schedule'; import { InvoiceStatus, SubscriptionPlan, SubscriptionPriceVariant, SubscriptionRecurring, SubscriptionStatus, } from './types'; const OnStripeEvent = ( event: Stripe.Event.Type, opts?: Parameters[1] ) => RawOnEvent(event, opts); // Plan x Recurring make a stripe price lookup key export function encodeLookupKey( plan: SubscriptionPlan, recurring: SubscriptionRecurring, variant?: SubscriptionPriceVariant ): string { return `${plan}_${recurring}` + (variant ? `_${variant}` : ''); } export function decodeLookupKey( key: string ): [SubscriptionPlan, SubscriptionRecurring, SubscriptionPriceVariant?] { const [plan, recurring, variant] = key.split('_'); return [ plan as SubscriptionPlan, recurring as SubscriptionRecurring, variant as SubscriptionPriceVariant | undefined, ]; } const SubscriptionActivated: Stripe.Subscription.Status[] = [ SubscriptionStatus.Active, SubscriptionStatus.Trialing, ]; export enum CouponType { ProEarlyAccessOneYearFree = 'pro_ea_one_year_free', AIEarlyAccessOneYearFree = 'ai_ea_one_year_free', ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free', } @Injectable() export class SubscriptionService { private readonly logger = new Logger(SubscriptionService.name); constructor( private readonly config: Config, private readonly stripe: Stripe, private readonly db: PrismaClient, private readonly scheduleManager: ScheduleManager, private readonly event: EventEmitter, private readonly feature: FeatureManagementService ) {} async listPrices(user?: CurrentUser) { let canHaveEarlyAccessDiscount = false; let canHaveAIEarlyAccessDiscount = false; if (user) { canHaveEarlyAccessDiscount = await this.feature.isEarlyAccessUser( user.id ); canHaveAIEarlyAccessDiscount = await this.feature.isEarlyAccessUser( user.id, EarlyAccessType.AI ); const customer = await this.getOrCreateCustomer( 'list-price:' + randomUUID(), user ); const oldSubscriptions = await this.stripe.subscriptions.list({ customer: customer.stripeCustomerId, status: 'all', }); oldSubscriptions.data.forEach(sub => { if (sub.status === 'past_due' || sub.status === 'canceled') { const [oldPlan] = this.decodePlanFromSubscription(sub); if (oldPlan === SubscriptionPlan.Pro) { canHaveEarlyAccessDiscount = false; } if (oldPlan === SubscriptionPlan.AI) { canHaveAIEarlyAccessDiscount = false; } } }); } const lifetimePriceEnabled = await this.config.runtime.fetch( 'plugins.payment/showLifetimePrice' ); const list = await this.stripe.prices.list({ active: true, // only list recurring prices if lifetime price is not enabled ...(lifetimePriceEnabled ? {} : { type: 'recurring' }), }); return list.data.filter(price => { if (!price.lookup_key) { return false; } const [plan, recurring, variant] = decodeLookupKey(price.lookup_key); // no variant price should be used for monthly or lifetime subscription if ( recurring === SubscriptionRecurring.Monthly || recurring === SubscriptionRecurring.Lifetime ) { return !variant; } if (plan === SubscriptionPlan.Pro) { return ( (canHaveEarlyAccessDiscount && variant) || (!canHaveEarlyAccessDiscount && !variant) ); } if (plan === SubscriptionPlan.AI) { return ( (canHaveAIEarlyAccessDiscount && variant) || (!canHaveAIEarlyAccessDiscount && !variant) ); } return false; }); } async createCheckoutSession({ user, recurring, plan, promotionCode, redirectUrl, idempotencyKey, }: { user: CurrentUser; recurring: SubscriptionRecurring; plan: SubscriptionPlan; promotionCode?: string | null; redirectUrl: string; idempotencyKey: string; }) { if ( this.config.deploy && this.config.affine.canary && !this.feature.isStaff(user.email) ) { throw new ActionForbidden(); } const currentSubscription = await this.db.userSubscription.findFirst({ where: { userId: user.id, plan, status: SubscriptionStatus.Active, }, }); if ( currentSubscription && // do not allow to re-subscribe unless the new recurring is `Lifetime` (currentSubscription.recurring === recurring || recurring !== SubscriptionRecurring.Lifetime) ) { throw new SubscriptionAlreadyExists({ plan }); } const customer = await this.getOrCreateCustomer( `${idempotencyKey}-getOrCreateCustomer`, user ); const { price, coupon } = await this.getAvailablePrice( customer, plan, recurring ); let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = []; if (coupon) { discounts = [{ coupon }]; } else if (promotionCode) { const code = await this.getAvailablePromotionCode( promotionCode, customer.stripeCustomerId ); if (code) { discounts = [{ promotion_code: code }]; } } return await this.stripe.checkout.sessions.create( { line_items: [ { price, quantity: 1, }, ], tax_id_collection: { enabled: true, }, // discount ...(discounts.length ? { discounts } : { allow_promotion_codes: true }), // mode: 'subscription' or 'payment' for lifetime ...(recurring === SubscriptionRecurring.Lifetime ? { mode: 'payment', invoice_creation: { enabled: true, }, } : { mode: 'subscription', }), success_url: redirectUrl, customer: customer.stripeCustomerId, customer_update: { address: 'auto', name: 'auto', }, }, { idempotencyKey: `${idempotencyKey}-checkoutSession` } ); } async cancelSubscription( idempotencyKey: string, userId: string, plan: SubscriptionPlan ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, }, include: { subscriptions: { where: { plan, }, }, }, }); if (!user) { throw new UserNotFound(); } const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); if (!subscriptionInDB) { throw new SubscriptionNotExists({ plan }); } if (!subscriptionInDB.stripeSubscriptionId) { throw new CantUpdateLifetimeSubscription( 'Lifetime subscription cannot be canceled.' ); } if (subscriptionInDB.canceledAt) { throw new SubscriptionHasBeenCanceled(); } // should release the schedule first if (subscriptionInDB.stripeScheduleId) { const manager = await this.scheduleManager.fromSchedule( subscriptionInDB.stripeScheduleId ); await manager.cancel(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( subscriptionInDB.stripeSubscriptionId ) ); } 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( subscriptionInDB.stripeSubscriptionId, { cancel_at_period_end: true }, { idempotencyKey } ); return await this.saveSubscription(user, subscription); } } async resumeCanceledSubscription( idempotencyKey: string, userId: string, plan: SubscriptionPlan ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, }, include: { subscriptions: true, }, }); if (!user) { throw new UserNotFound(); } const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); if (!subscriptionInDB) { throw new SubscriptionNotExists({ plan }); } if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) { throw new CantUpdateLifetimeSubscription( 'Lifetime subscription cannot be resumed.' ); } if (!subscriptionInDB.canceledAt) { throw new SubscriptionHasBeenCanceled(); } if (subscriptionInDB.end < new Date()) { throw new SubscriptionExpired(); } if (subscriptionInDB.stripeScheduleId) { const manager = await this.scheduleManager.fromSchedule( subscriptionInDB.stripeScheduleId ); await manager.resume(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( subscriptionInDB.stripeSubscriptionId ) ); } else { const subscription = await this.stripe.subscriptions.update( subscriptionInDB.stripeSubscriptionId, { cancel_at_period_end: false }, { idempotencyKey } ); return await this.saveSubscription(user, subscription); } } async updateSubscriptionRecurring( idempotencyKey: string, userId: string, plan: SubscriptionPlan, recurring: SubscriptionRecurring ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, }, include: { subscriptions: true, }, }); if (!user) { throw new UserNotFound(); } const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); if (!subscriptionInDB) { throw new SubscriptionNotExists({ plan }); } if (!subscriptionInDB.stripeSubscriptionId) { throw new CantUpdateLifetimeSubscription( 'Can not update lifetime subscription.' ); } if (subscriptionInDB.canceledAt) { throw new SubscriptionHasBeenCanceled(); } if (subscriptionInDB.recurring === recurring) { throw new SameSubscriptionRecurring({ recurring }); } const price = await this.getPrice( subscriptionInDB.plan as SubscriptionPlan, recurring ); const manager = await this.scheduleManager.fromSubscription( `${idempotencyKey}-fromSubscription`, subscriptionInDB.stripeSubscriptionId ); await manager.update(`${idempotencyKey}-update`, price); return await this.db.userSubscription.update({ where: { id: subscriptionInDB.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 UserNotFound(); } 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 CustomerPortalCreateFailed(); } } @OnStripeEvent('invoice.created') @OnStripeEvent('invoice.updated') @OnStripeEvent('invoice.finalization_failed') @OnStripeEvent('invoice.payment_failed') @OnStripeEvent('invoice.payment_succeeded') async saveInvoice(stripeInvoice: Stripe.Invoice, event: string) { stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id); 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 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'; } } // create invoice const price = stripeInvoice.lines.data[0].price; if (!price) { throw new Error('Unexpected invoice with no price'); } if (!price.lookup_key) { throw new Error('Unexpected subscription with no key'); } const [plan, recurring] = decodeLookupKey(price.lookup_key); const invoice = await this.db.userInvoice.upsert({ where: { stripeInvoiceId: stripeInvoice.id, }, update: data, create: { userId: user.id, stripeInvoiceId: stripeInvoice.id, plan, recurring, reason: stripeInvoice.billing_reason ?? 'contact support', ...(data as any), }, }); // handle one time payment, no subscription created by stripe if ( event === 'invoice.payment_succeeded' && recurring === SubscriptionRecurring.Lifetime && stripeInvoice.status === 'paid' ) { await this.saveLifetimeSubscription(user, invoice); } } async saveLifetimeSubscription(user: User, invoice: UserInvoice) { // cancel previous non-lifetime subscription const savedSubscription = await this.db.userSubscription.findUnique({ where: { userId_plan: { userId: user.id, plan: SubscriptionPlan.Pro, }, }, }); if (savedSubscription && savedSubscription.stripeSubscriptionId) { await this.db.userSubscription.update({ where: { id: savedSubscription.id, }, data: { stripeScheduleId: null, stripeSubscriptionId: null, status: SubscriptionStatus.Active, recurring: SubscriptionRecurring.Lifetime, start: new Date(), end: null, nextBillAt: null, }, }); await this.stripe.subscriptions.cancel( savedSubscription.stripeSubscriptionId, { prorate: true, } ); } else { await this.db.userSubscription.create({ data: { userId: user.id, stripeSubscriptionId: null, plan: invoice.plan, recurring: invoice.recurring, start: new Date(), end: null, status: SubscriptionStatus.Active, nextBillAt: null, }, }); } this.event.emit('user.subscription.activated', { userId: user.id, plan: invoice.plan as SubscriptionPlan, recurring: SubscriptionRecurring.Lifetime, }); } @OnStripeEvent('customer.subscription.created') @OnStripeEvent('customer.subscription.updated') async onSubscriptionChanges(subscription: Stripe.Subscription) { subscription = await this.stripe.subscriptions.retrieve(subscription.id); if (subscription.status === 'active') { const user = await this.retrieveUserFromCustomer( typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id ); await this.saveSubscription(user, subscription); } else { await this.onSubscriptionDeleted(subscription); } } @OnStripeEvent('customer.subscription.deleted') async onSubscriptionDeleted(subscription: Stripe.Subscription) { const user = await this.retrieveUserFromCustomer( typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id ); const [plan, recurring] = this.decodePlanFromSubscription(subscription); this.event.emit('user.subscription.canceled', { userId: user.id, plan, recurring, }); await this.db.userSubscription.deleteMany({ where: { stripeSubscriptionId: subscription.id, }, }); } private async saveSubscription( user: User, subscription: Stripe.Subscription ): Promise { const price = subscription.items.data[0].price; if (!price.lookup_key) { throw new Error('Unexpected subscription with no key'); } const [plan, recurring] = this.decodePlanFromSubscription(subscription); const planActivated = SubscriptionActivated.includes(subscription.status); // update features first, features modify are idempotent // so there is no need to skip if a subscription already exists. this.event.emit('user.subscription.activated', { userId: user.id, plan, recurring, }); let nextBillAt: Date | null = null; if (planActivated && !subscription.canceled_at) { // get next bill date from upcoming invoice // see https://stripe.com/docs/api/invoices/upcoming nextBillAt = new Date(subscription.current_period_end * 1000); } 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, status: subscription.status, stripeScheduleId: subscription.schedule as string | null, }; return await this.db.userSubscription.upsert({ where: { stripeSubscriptionId: subscription.id, }, update: commonData, create: { userId: user.id, recurring, ...commonData, }, }); } private async getOrCreateCustomer( idempotencyKey: string, user: CurrentUser ): Promise { let customer = await this.db.userStripeCustomer.findUnique({ where: { userId: user.id, }, }); if (!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 } ); } customer = await this.db.userStripeCustomer.create({ data: { userId: user.id, stripeCustomerId: stripeCustomer.id, }, }); } return customer; } @OnEvent('user.updated') async onUserUpdated(user: User) { const customer = await this.db.userStripeCustomer.findUnique({ where: { userId: user.id, }, }); if (customer) { const stripeCustomer = await this.stripe.customers.retrieve( customer.stripeCustomerId ); if (!stripeCustomer.deleted && stripeCustomer.email !== user.email) { await this.stripe.customers.update(customer.stripeCustomerId, { email: user.email, }); } } } 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, variant?: SubscriptionPriceVariant ): Promise { if (recurring === SubscriptionRecurring.Lifetime) { const lifetimePriceEnabled = await this.config.runtime.fetch( 'plugins.payment/showLifetimePrice' ); if (!lifetimePriceEnabled) { throw new ActionForbidden(); } } const prices = await this.stripe.prices.list({ lookup_keys: [encodeLookupKey(plan, recurring, variant)], }); if (!prices.data.length) { throw new SubscriptionPlanNotFound({ plan, recurring, }); } return prices.data[0].id; } /** * Get available for different plans with special early-access price and coupon */ private async getAvailablePrice( customer: UserStripeCustomer, plan: SubscriptionPlan, recurring: SubscriptionRecurring ): Promise<{ price: string; coupon?: string }> { const isEaUser = await this.feature.isEarlyAccessUser(customer.userId); const oldSubscriptions = await this.stripe.subscriptions.list({ customer: customer.stripeCustomerId, status: 'all', }); const subscribed = oldSubscriptions.data.some(sub => { const [oldPlan] = this.decodePlanFromSubscription(sub); return ( oldPlan === plan && (sub.status === 'past_due' || sub.status === 'canceled') ); }); if (plan === SubscriptionPlan.Pro) { const canHaveEADiscount = isEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly; const price = await this.getPrice( plan, recurring, canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined ); return { price, coupon: canHaveEADiscount ? CouponType.ProEarlyAccessOneYearFree : undefined, }; } else { const isAIEaUser = await this.feature.isEarlyAccessUser( customer.userId, EarlyAccessType.AI ); const canHaveEADiscount = isAIEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly; const price = await this.getPrice( plan, recurring, canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined ); return { price, coupon: !subscribed ? isAIEaUser ? CouponType.AIEarlyAccessOneYearFree : isEaUser ? CouponType.ProEarlyAccessAIOneYearFree : undefined : undefined, }; } } private async getAvailablePromotionCode( userFacingPromotionCode: string, customer?: string ) { const list = await this.stripe.promotionCodes.list({ code: userFacingPromotionCode, active: true, limit: 1, }); const code = list.data[0]; if (!code) { return null; } let available = false; if (code.customer) { available = typeof code.customer === 'string' ? code.customer === customer : code.customer.id === customer; } else { available = true; } return available ? code.id : null; } private decodePlanFromSubscription(sub: Stripe.Subscription) { const price = sub.items.data[0].price; if (!price.lookup_key) { throw new Error('Unexpected subscription with no key'); } return decodeLookupKey(price.lookup_key); } }