var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var SubscriptionService_1; import { randomUUID } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; import { OnEvent as RawOnEvent } from '@nestjs/event-emitter'; import { PrismaClient } from '@prisma/client'; import Stripe from 'stripe'; 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, opts) => RawOnEvent(event, opts); // Plan x Recurring make a stripe price lookup key export function encodeLookupKey(plan, recurring, variant) { return `${plan}_${recurring}` + (variant ? `_${variant}` : ''); } export function decodeLookupKey(key) { const [plan, recurring, variant] = key.split('_'); return [ plan, recurring, variant, ]; } const SubscriptionActivated = [ SubscriptionStatus.Active, SubscriptionStatus.Trialing, ]; export var CouponType; (function (CouponType) { CouponType["ProEarlyAccessOneYearFree"] = "pro_ea_one_year_free"; CouponType["AIEarlyAccessOneYearFree"] = "ai_ea_one_year_free"; CouponType["ProEarlyAccessAIOneYearFree"] = "ai_pro_ea_one_year_free"; })(CouponType || (CouponType = {})); let SubscriptionService = SubscriptionService_1 = class SubscriptionService { constructor(config, stripe, db, scheduleManager, event, feature) { this.config = config; this.stripe = stripe; this.db = db; this.scheduleManager = scheduleManager; this.event = event; this.feature = feature; this.logger = new Logger(SubscriptionService_1.name); } async listPrices(user) { 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, }) { 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 = []; 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, userId, plan) { 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, userId, plan) { 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, userId, plan, recurring) { 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, 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) { 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(); } } async saveInvoice(stripeInvoice, event) { 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 = { 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); 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, }, }); // 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, invoice) { // 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, recurring: SubscriptionRecurring.Lifetime, }); } async onSubscriptionChanges(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); } } async onSubscriptionDeleted(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, }, }); } async saveSubscription(user, subscription) { 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 = 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, }; return await this.db.userSubscription.upsert({ where: { stripeSubscriptionId: subscription.id, }, update: commonData, create: { userId: user.id, recurring, ...commonData, }, }); } async getOrCreateCustomer(idempotencyKey, user) { 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; 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; } async onUserUpdated(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, }); } } } async retrieveUserFromCustomer(customerId) { 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; } async getPrice(plan, recurring, variant) { 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 */ async getAvailablePrice(customer, plan, recurring) { 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, }; } } async getAvailablePromotionCode(userFacingPromotionCode, customer) { 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; } decodePlanFromSubscription(sub) { const price = sub.items.data[0].price; if (!price.lookup_key) { throw new Error('Unexpected subscription with no key'); } return decodeLookupKey(price.lookup_key); } }; __decorate([ OnStripeEvent('invoice.created'), OnStripeEvent('invoice.updated'), OnStripeEvent('invoice.finalization_failed'), OnStripeEvent('invoice.payment_failed'), OnStripeEvent('invoice.payment_succeeded'), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, String]), __metadata("design:returntype", Promise) ], SubscriptionService.prototype, "saveInvoice", null); __decorate([ OnStripeEvent('customer.subscription.created'), OnStripeEvent('customer.subscription.updated'), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], SubscriptionService.prototype, "onSubscriptionChanges", null); __decorate([ OnStripeEvent('customer.subscription.deleted'), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], SubscriptionService.prototype, "onSubscriptionDeleted", null); __decorate([ OnEvent('user.updated'), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], SubscriptionService.prototype, "onUserUpdated", null); SubscriptionService = SubscriptionService_1 = __decorate([ Injectable(), __metadata("design:paramtypes", [Config, Stripe, PrismaClient, ScheduleManager, EventEmitter, FeatureManagementService]) ], SubscriptionService); export { SubscriptionService }; //# sourceMappingURL=service.js.map