From 36a8ed59ed79928305a8507ef19056c4a7fba7c0 Mon Sep 17 00:00:00 2001 From: forehalo Date: Mon, 25 Nov 2024 11:38:39 +0800 Subject: [PATCH] refactor(server): payment service --- .../migration.sql | 7 + .../server/migrations/migration_lock.toml | 2 +- packages/backend/server/schema.prisma | 12 +- .../server/src/plugins/payment/controller.ts | 52 + .../server/src/plugins/payment/cron.ts | 54 + .../server/src/plugins/payment/index.ts | 13 +- .../src/plugins/payment/manager/common.ts | 56 ++ .../src/plugins/payment/manager/index.ts | 2 + .../src/plugins/payment/manager/user.ts | 534 ++++++++++ .../server/src/plugins/payment/resolver.ts | 242 ++--- .../server/src/plugins/payment/schedule.ts | 10 +- .../server/src/plugins/payment/service.ts | 936 +++++------------- .../server/src/plugins/payment/types.ts | 129 +++ .../server/src/plugins/payment/webhook.ts | 104 +- packages/backend/server/src/schema.gql | 83 +- .../server/tests/payment/service.spec.ts | 928 +++++++++-------- .../payment/snapshots/service.spec.ts.md | 75 ++ .../payment/snapshots/service.spec.ts.snap | Bin 0 -> 361 bytes .../setting/general-setting/billing/index.tsx | 11 +- .../src/modules/cloud/stores/subscription.ts | 14 +- .../src/graphql/cancel-subscription.gql | 7 +- .../frontend/graphql/src/graphql/index.ts | 18 +- .../frontend/graphql/src/graphql/invoices.gql | 2 - .../src/graphql/resume-subscription.gql | 7 +- .../graphql/update-subscription-billing.gql | 7 +- packages/frontend/graphql/src/schema.ts | 118 ++- 26 files changed, 1910 insertions(+), 1513 deletions(-) create mode 100644 packages/backend/server/migrations/20241125035804_opt_user_invoices/migration.sql create mode 100644 packages/backend/server/src/plugins/payment/controller.ts create mode 100644 packages/backend/server/src/plugins/payment/cron.ts create mode 100644 packages/backend/server/src/plugins/payment/manager/common.ts create mode 100644 packages/backend/server/src/plugins/payment/manager/index.ts create mode 100644 packages/backend/server/src/plugins/payment/manager/user.ts create mode 100644 packages/backend/server/tests/payment/snapshots/service.spec.ts.md create mode 100644 packages/backend/server/tests/payment/snapshots/service.spec.ts.snap diff --git a/packages/backend/server/migrations/20241125035804_opt_user_invoices/migration.sql b/packages/backend/server/migrations/20241125035804_opt_user_invoices/migration.sql new file mode 100644 index 0000000000000..60c48475db8c9 --- /dev/null +++ b/packages/backend/server/migrations/20241125035804_opt_user_invoices/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "user_invoices" ALTER COLUMN "plan" DROP NOT NULL, +ALTER COLUMN "recurring" DROP NOT NULL, +ALTER COLUMN "reason" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "user_invoices_user_id_idx" ON "user_invoices"("user_id"); diff --git a/packages/backend/server/migrations/migration_lock.toml b/packages/backend/server/migrations/migration_lock.toml index 99e4f20090794..fbffa92c2bb7c 100644 --- a/packages/backend/server/migrations/migration_lock.toml +++ b/packages/backend/server/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) -provider = "postgresql" +provider = "postgresql" \ No newline at end of file diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index acf2df7fa647d..865a22a3b133f 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -335,7 +335,7 @@ model UserSubscription { // yearly/monthly/lifetime recurring String @db.VarChar(20) // onetime subscription or anything else - variant String? @db.VarChar(20) + variant String? @db.VarChar(20) // subscription.id, null for linefetime payment or one time payment subscription stripeSubscriptionId String? @unique @map("stripe_subscription_id") // subscription.status, active/past_due/canceled/unpaid... @@ -370,18 +370,22 @@ model UserInvoice { // CNY 12.50 stored as 1250 amount Int @db.Integer status String @db.VarChar(20) - plan String @db.VarChar(20) - recurring String @db.VarChar(20) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) // billing reason - reason String @db.VarChar + reason String? @db.VarChar lastPaymentError String? @map("last_payment_error") @db.Text // stripe hosted invoice link link String? @db.Text + // @deprecated + plan String? @db.VarChar(20) + // @deprecated + recurring String? @db.VarChar(20) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId]) @@map("user_invoices") } diff --git a/packages/backend/server/src/plugins/payment/controller.ts b/packages/backend/server/src/plugins/payment/controller.ts new file mode 100644 index 0000000000000..edbbeaaa721d6 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/controller.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert'; + +import type { RawBodyRequest } from '@nestjs/common'; +import { Controller, Logger, Post, Req } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import type { Request } from 'express'; +import Stripe from 'stripe'; + +import { Public } from '../../core/auth'; +import { Config, InternalServerError } from '../../fundamentals'; + +@Controller('/api/stripe') +export class StripeWebhookController { + private readonly webhookKey: string; + private readonly logger = new Logger(StripeWebhookController.name); + + constructor( + config: Config, + private readonly stripe: Stripe, + private readonly event: EventEmitter2 + ) { + assert(config.plugins.payment.stripe); + this.webhookKey = config.plugins.payment.stripe.keys.webhookKey; + } + + @Public() + @Post('/webhook') + async handleWebhook(@Req() req: RawBodyRequest) { + // Retrieve the event by verifying the signature using the raw body and secret. + const signature = req.headers['stripe-signature']; + try { + const event = this.stripe.webhooks.constructEvent( + req.rawBody ?? '', + signature ?? '', + this.webhookKey + ); + + this.logger.debug( + `[${event.id}] Stripe Webhook {${event.type}} received.` + ); + + // Stripe requires responseing webhook immediately and handle event asynchronously. + setImmediate(() => { + this.event.emitAsync(`stripe:${event.type}`, event).catch(e => { + this.logger.error('Failed to handle Stripe Webhook event.', e); + }); + }); + } catch (err: any) { + throw new InternalServerError(err.message); + } + } +} diff --git a/packages/backend/server/src/plugins/payment/cron.ts b/packages/backend/server/src/plugins/payment/cron.ts new file mode 100644 index 0000000000000..dacacaf824959 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/cron.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaClient } from '@prisma/client'; + +import { EventEmitter, type EventPayload } from '../../fundamentals'; +import { + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionVariant, +} from './types'; + +@Injectable() +export class SubscriptionCronJobs { + constructor( + private readonly db: PrismaClient, + private readonly event: EventEmitter + ) {} + + @Cron(CronExpression.EVERY_HOUR) + async cleanExpiredOnetimeSubscriptions() { + const subscriptions = await this.db.userSubscription.findMany({ + where: { + variant: SubscriptionVariant.Onetime, + end: { + lte: new Date(), + }, + }, + }); + + for (const subscription of subscriptions) { + this.event.emit('user.subscription.canceled', { + userId: subscription.userId, + plan: subscription.plan as SubscriptionPlan, + recurring: subscription.variant as SubscriptionRecurring, + }); + } + } + + @OnEvent('user.subscription.canceled') + async handleUserSubscriptionCanceled({ + userId, + plan, + }: EventPayload<'user.subscription.canceled'>) { + await this.db.userSubscription.delete({ + where: { + userId_plan: { + userId, + plan, + }, + }, + }); + } +} diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index 1cc0f5477ae84..c7f85354e6da4 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -2,24 +2,29 @@ import './config'; import { ServerFeature } from '../../core/config'; import { FeatureModule } from '../../core/features'; +import { UserModule } from '../../core/user'; import { Plugin } from '../registry'; +import { StripeWebhookController } from './controller'; +import { SubscriptionCronJobs } from './cron'; +import { UserSubscriptionManager } from './manager'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; -import { ScheduleManager } from './schedule'; import { SubscriptionService } from './service'; import { StripeProvider } from './stripe'; import { StripeWebhook } from './webhook'; @Plugin({ name: 'payment', - imports: [FeatureModule], + imports: [FeatureModule, UserModule], providers: [ - ScheduleManager, StripeProvider, SubscriptionService, SubscriptionResolver, UserSubscriptionResolver, + StripeWebhook, + UserSubscriptionManager, + SubscriptionCronJobs, ], - controllers: [StripeWebhook], + controllers: [StripeWebhookController], requires: [ 'plugins.payment.stripe.keys.APIKey', 'plugins.payment.stripe.keys.webhookKey', diff --git a/packages/backend/server/src/plugins/payment/manager/common.ts b/packages/backend/server/src/plugins/payment/manager/common.ts new file mode 100644 index 0000000000000..29336df980082 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/manager/common.ts @@ -0,0 +1,56 @@ +import { UserStripeCustomer } from '@prisma/client'; + +import { + KnownStripePrice, + KnownStripeSubscription, + SubscriptionPlan, + SubscriptionRecurring, +} from '../types'; + +export interface Subscription { + status: string; + plan: string; + recurring: string; + variant: string | null; + start: Date; + end: Date | null; + trialStart: Date | null; + trialEnd: Date | null; + nextBillAt: Date | null; + canceledAt: Date | null; +} + +export interface Invoice { + currency: string; + amount: number; + status: string; + createdAt: Date; + lastPaymentError: string | null; + link: string | null; +} + +export interface SubscriptionManager { + filterPrices( + prices: KnownStripePrice[], + customer?: UserStripeCustomer + ): Promise; + + saveSubscription( + subscription: KnownStripeSubscription + ): Promise; + deleteSubscription(subscription: KnownStripeSubscription): Promise; + + getSubscription( + id: string, + plan: SubscriptionPlan + ): Promise; + + cancelSubscription(subscription: Subscription): Promise; + + resumeSubscription(subscription: Subscription): Promise; + + updateSubscriptionRecurring( + subscription: Subscription, + recurring: SubscriptionRecurring + ): Promise; +} diff --git a/packages/backend/server/src/plugins/payment/manager/index.ts b/packages/backend/server/src/plugins/payment/manager/index.ts new file mode 100644 index 0000000000000..13c53c6d662ba --- /dev/null +++ b/packages/backend/server/src/plugins/payment/manager/index.ts @@ -0,0 +1,2 @@ +export * from './common'; +export * from './user'; diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts new file mode 100644 index 0000000000000..be2103dea667f --- /dev/null +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -0,0 +1,534 @@ +import { Injectable } from '@nestjs/common'; +import { + PrismaClient, + UserStripeCustomer, + UserSubscription, +} from '@prisma/client'; +import Stripe from 'stripe'; + +import { + EarlyAccessType, + FeatureManagementService, +} from '../../../core/features'; +import { + Config, + EventEmitter, + InternalServerError, +} from '../../../fundamentals'; +import { + CouponType, + KnownStripeInvoice, + KnownStripePrice, + KnownStripeSubscription, + retriveLookupKeyFromStripeSubscription, + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionStatus, + SubscriptionVariant, +} from '../types'; +import { SubscriptionManager } from './common'; + +interface PriceStrategyStatus { + proEarlyAccess: boolean; + aiEarlyAccess: boolean; + proSubscribed: boolean; + aiSubscribed: boolean; + onetime: boolean; +} + +@Injectable() +export class UserSubscriptionManager implements SubscriptionManager { + constructor( + private readonly db: PrismaClient, + private readonly config: Config, + private readonly stripe: Stripe, + private readonly feature: FeatureManagementService, + private readonly event: EventEmitter + ) {} + + async filterPrices( + prices: KnownStripePrice[], + customer?: UserStripeCustomer + ) { + const strategyStatus = customer + ? await this.strategyStatus(customer) + : { + proEarlyAccess: false, + aiEarlyAccess: false, + proSubscribed: false, + aiSubscribed: false, + onetime: false, + }; + + const availablePrices: KnownStripePrice[] = []; + + for (const price of prices) { + if (await this.isPriceAvailable(price, strategyStatus)) { + availablePrices.push(price); + } + } + + return availablePrices; + } + + async getSubscription(userId: string, plan: SubscriptionPlan) { + return this.db.userSubscription.findFirst({ + where: { + userId, + plan, + status: { + in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing], + }, + }, + }); + } + + async saveSubscription({ + userId, + lookupKey, + stripeSubscription: subscription, + }: KnownStripeSubscription) { + // update features first, features modify are idempotent + // so there is no need to skip if a subscription already exists. + // TODO(@forehalo): + // we should move the subscription feature updating logic back to payment module, + // because quota or feature module themself should not be aware of what payment or subscription is. + this.event.emit('user.subscription.activated', { + userId, + plan: lookupKey.plan, + recurring: lookupKey.recurring, + }); + + const commonData = { + status: subscription.status, + stripeScheduleId: subscription.schedule as string | null, + nextBillAt: !subscription.canceled_at + ? new Date(subscription.current_period_end * 1000) + : null, + canceledAt: subscription.canceled_at + ? new Date(subscription.canceled_at * 1000) + : null, + }; + + return await this.db.userSubscription.upsert({ + where: { + stripeSubscriptionId: subscription.id, + }, + update: commonData, + create: { + userId, + ...lookupKey, + stripeSubscriptionId: subscription.id, + 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, + ...commonData, + }, + }); + } + + async cancelSubscription(subscription: UserSubscription) { + return this.db.userSubscription.update({ + where: { + id: subscription.id, + }, + data: { + canceledAt: new Date(), + nextBillAt: null, + }, + }); + } + + async resumeSubscription(subscription: UserSubscription) { + return this.db.userSubscription.update({ + where: { id: subscription.id }, + data: { + canceledAt: null, + nextBillAt: subscription.end, + }, + }); + } + + async updateSubscriptionRecurring( + subscription: UserSubscription, + recurring: SubscriptionRecurring + ) { + return this.db.userSubscription.update({ + where: { id: subscription.id }, + data: { recurring }, + }); + } + + async deleteSubscription({ + userId, + lookupKey, + stripeSubscription, + }: KnownStripeSubscription) { + await this.db.userSubscription.delete({ + where: { + stripeSubscriptionId: stripeSubscription.id, + }, + }); + + this.event.emit('user.subscription.canceled', { + userId, + plan: lookupKey.plan, + recurring: lookupKey.recurring, + }); + } + + async validatePrice(price: KnownStripePrice, customer: UserStripeCustomer) { + const strategyStatus = await this.strategyStatus(customer); + + // onetime price is allowed for checkout + strategyStatus.onetime = true; + + if (!(await this.isPriceAvailable(price, strategyStatus))) { + return null; + } + + let coupon: CouponType | null = null; + + if (price.lookupKey.variant === SubscriptionVariant.EA) { + if (price.lookupKey.plan === SubscriptionPlan.Pro) { + coupon = CouponType.ProEarlyAccessOneYearFree; + } else if (price.lookupKey.plan === SubscriptionPlan.AI) { + coupon = CouponType.AIEarlyAccessOneYearFree; + } + } else if (price.lookupKey.plan === SubscriptionPlan.AI) { + const { proEarlyAccess, aiSubscribed } = strategyStatus; + if (proEarlyAccess && !aiSubscribed) { + coupon = CouponType.ProEarlyAccessAIOneYearFree; + } + } + + return { + price, + coupon, + }; + } + + async saveInvoice(knownInvoice: KnownStripeInvoice) { + const { userId, lookupKey, stripeInvoice } = knownInvoice; + + const status = stripeInvoice.status ?? 'void'; + let error: string | boolean | null = null; + + if (status !== 'paid') { + if (stripeInvoice.last_finalization_error) { + error = stripeInvoice.last_finalization_error.message ?? true; + } else if ( + stripeInvoice.attempt_count > 1 && + stripeInvoice.payment_intent + ) { + const paymentIntent = + typeof stripeInvoice.payment_intent === 'string' + ? await this.stripe.paymentIntents.retrieve( + stripeInvoice.payment_intent + ) + : stripeInvoice.payment_intent; + + if (paymentIntent.last_payment_error) { + error = paymentIntent.last_payment_error.message ?? true; + } + } + } + + // fallback to generic error message + if (error === true) { + error = 'Payment Error. Please contact support.'; + } + + const invoice = this.db.userInvoice.upsert({ + where: { + stripeInvoiceId: stripeInvoice.id, + }, + update: { + status, + link: stripeInvoice.hosted_invoice_url, + amount: stripeInvoice.total, + currency: stripeInvoice.currency, + lastPaymentError: error, + }, + create: { + userId, + stripeInvoiceId: stripeInvoice.id, + status, + link: stripeInvoice.hosted_invoice_url, + reason: stripeInvoice.billing_reason, + amount: stripeInvoice.total, + currency: stripeInvoice.currency, + lastPaymentError: error, + }, + }); + + // onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system + // we track the deals by invoice only. + if (status === 'paid') { + if (lookupKey.recurring === SubscriptionRecurring.Lifetime) { + await this.saveLifetimeSubscription(knownInvoice); + } else if (lookupKey.variant === SubscriptionVariant.Onetime) { + await this.saveOnetimePaymentSubscription(knownInvoice); + } + } + + return invoice; + } + + async saveLifetimeSubscription( + knownInvoice: KnownStripeInvoice + ): Promise { + // cancel previous non-lifetime subscription + const prevSubscription = await this.db.userSubscription.findUnique({ + where: { + userId_plan: { + userId: knownInvoice.userId, + plan: SubscriptionPlan.Pro, + }, + }, + }); + + let subscription: UserSubscription; + if (prevSubscription && prevSubscription.stripeSubscriptionId) { + subscription = await this.db.userSubscription.update({ + where: { + id: prevSubscription.id, + }, + data: { + stripeScheduleId: null, + stripeSubscriptionId: null, + plan: knownInvoice.lookupKey.plan, + recurring: SubscriptionRecurring.Lifetime, + start: new Date(), + end: null, + status: SubscriptionStatus.Active, + nextBillAt: null, + }, + }); + + await this.stripe.subscriptions.cancel( + prevSubscription.stripeSubscriptionId, + { + prorate: true, + } + ); + } else { + subscription = await this.db.userSubscription.create({ + data: { + userId: knownInvoice.userId, + stripeSubscriptionId: null, + plan: knownInvoice.lookupKey.plan, + recurring: SubscriptionRecurring.Lifetime, + start: new Date(), + end: null, + status: SubscriptionStatus.Active, + nextBillAt: null, + }, + }); + } + + this.event.emit('user.subscription.activated', { + userId: knownInvoice.userId, + plan: knownInvoice.lookupKey.plan, + recurring: SubscriptionRecurring.Lifetime, + }); + + return subscription; + } + + async saveOnetimePaymentSubscription( + knownInvoice: KnownStripeInvoice + ): Promise { + const { userId, lookupKey } = knownInvoice; + const existingSubscription = await this.db.userSubscription.findUnique({ + where: { + userId_plan: { + userId, + plan: lookupKey.plan, + }, + }, + }); + + // TODO(@forehalo): time helper + const subscriptionTime = + (lookupKey.recurring === SubscriptionRecurring.Monthly ? 30 : 365) * + 24 * + 60 * + 60 * + 1000; + + let subscription: UserSubscription; + + // extends the subscription time if exists + if (existingSubscription) { + if (!existingSubscription.end) { + throw new InternalServerError( + 'Unexpected onetime subscription with no end date' + ); + } + + const period = + // expired, reset the period + existingSubscription.end <= new Date() + ? { + start: new Date(), + end: new Date(Date.now() + subscriptionTime), + } + : { + end: new Date( + existingSubscription.end.getTime() + subscriptionTime + ), + }; + + subscription = await this.db.userSubscription.update({ + where: { + id: existingSubscription.id, + }, + data: period, + }); + } else { + subscription = await this.db.userSubscription.create({ + data: { + userId, + stripeSubscriptionId: null, + ...lookupKey, + start: new Date(), + end: new Date(Date.now() + subscriptionTime), + status: SubscriptionStatus.Active, + nextBillAt: null, + }, + }); + } + + this.event.emit('user.subscription.activated', { + userId, + plan: lookupKey.plan, + recurring: lookupKey.recurring, + }); + + return subscription; + } + + private async isPriceAvailable( + price: KnownStripePrice, + strategy: PriceStrategyStatus + ) { + if (price.lookupKey.plan === SubscriptionPlan.Pro) { + return this.isProPriceAvailable(price, strategy); + } + + if (price.lookupKey.plan === SubscriptionPlan.AI) { + return this.isAIPriceAvailable(price, strategy); + } + + return false; + } + + private async isProPriceAvailable( + { lookupKey }: KnownStripePrice, + { proEarlyAccess, proSubscribed, onetime }: PriceStrategyStatus + ) { + if (lookupKey.recurring === SubscriptionRecurring.Lifetime) { + return this.config.runtime.fetch('plugins.payment/showLifetimePrice'); + } + + if (lookupKey.variant === SubscriptionVariant.Onetime) { + return onetime; + } + + // no special price for monthly plan + if (lookupKey.recurring === SubscriptionRecurring.Monthly) { + return true; + } + + // show EA price instead of normal price if early access is available + return proEarlyAccess && !proSubscribed + ? lookupKey.variant === SubscriptionVariant.EA + : lookupKey.variant !== SubscriptionVariant.EA; + } + + private async isAIPriceAvailable( + { lookupKey }: KnownStripePrice, + { aiEarlyAccess, aiSubscribed, onetime }: PriceStrategyStatus + ) { + // no lifetime price for AI + if (lookupKey.recurring === SubscriptionRecurring.Lifetime) { + return false; + } + + // never show onetime prices + if (lookupKey.variant === SubscriptionVariant.Onetime) { + return onetime; + } + + // show EA price instead of normal price if early access is available + return aiEarlyAccess && !aiSubscribed + ? lookupKey.variant === SubscriptionVariant.EA + : lookupKey.variant !== SubscriptionVariant.EA; + } + + private async strategyStatus( + customer: UserStripeCustomer + ): Promise { + const proEarlyAccess = await this.feature.isEarlyAccessUser( + customer.userId, + EarlyAccessType.App + ); + + const aiEarlyAccess = await this.feature.isEarlyAccessUser( + customer.userId, + EarlyAccessType.AI + ); + + // fast pass if the user is not early access for any plan + if (!proEarlyAccess && !aiEarlyAccess) { + return { + proEarlyAccess, + aiEarlyAccess, + proSubscribed: false, + aiSubscribed: false, + onetime: false, + }; + } + + let proSubscribed = false; + let aiSubscribed = false; + + const subscriptions = await this.stripe.subscriptions.list({ + customer: customer.stripeCustomerId, + status: 'all', + }); + + // if the early access user had early access subscription in the past, but it got canceled or past due, + // the user will lose the early access privilege + for (const sub of subscriptions.data) { + const lookupKey = retriveLookupKeyFromStripeSubscription(sub); + if (!lookupKey) { + continue; + } + + if (sub.status === 'past_due' || sub.status === 'canceled') { + if (lookupKey.plan === SubscriptionPlan.Pro) { + proSubscribed = true; + } + + if (lookupKey.plan === SubscriptionPlan.AI) { + aiSubscribed = true; + } + } + } + + return { + proEarlyAccess, + aiEarlyAccess, + proSubscribed, + aiSubscribed, + onetime: false, + }; + } +} diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index ac24e58c62e68..6a2cabecef7d0 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -1,6 +1,6 @@ +import { Headers } from '@nestjs/common'; import { Args, - Context, Field, InputType, Int, @@ -12,19 +12,15 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import type { User, UserInvoice, UserSubscription } from '@prisma/client'; +import type { User, UserSubscription } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import { groupBy } from 'lodash-es'; import { CurrentUser, Public } from '../../core/auth'; import { UserType } from '../../core/user'; -import { - AccessDenied, - Config, - FailedToCheckout, - URLHelper, -} from '../../fundamentals'; -import { decodeLookupKey, SubscriptionService } from './service'; +import { AccessDenied, FailedToCheckout, URLHelper } from '../../fundamentals'; +import { Invoice, Subscription } from './manager'; +import { SubscriptionService } from './service'; import { InvoiceStatus, SubscriptionPlan, @@ -60,11 +56,8 @@ class SubscriptionPrice { lifetimeAmount?: number | null; } -@ObjectType('UserSubscription') -export class UserSubscriptionType implements Partial { - @Field(() => String, { name: 'id', nullable: true }) - stripeSubscriptionId!: string | null; - +@ObjectType() +export class SubscriptionType implements Subscription { @Field(() => SubscriptionPlan, { description: "The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.\nThere won't actually be a subscription with plan 'Free'", @@ -75,7 +68,7 @@ export class UserSubscriptionType implements Partial { recurring!: SubscriptionRecurring; @Field(() => SubscriptionVariant, { nullable: true }) - variant?: SubscriptionVariant | null; + variant!: SubscriptionVariant | null; @Field(() => SubscriptionStatus) status!: SubscriptionStatus; @@ -87,35 +80,34 @@ export class UserSubscriptionType implements Partial { end!: Date | null; @Field(() => Date, { nullable: true }) - trialStart?: Date | null; + trialStart!: Date | null; @Field(() => Date, { nullable: true }) - trialEnd?: Date | null; + trialEnd!: Date | null; @Field(() => Date, { nullable: true }) - nextBillAt?: Date | null; + nextBillAt!: Date | null; @Field(() => Date, { nullable: true }) - canceledAt?: Date | null; + canceledAt!: Date | null; @Field(() => Date) createdAt!: Date; @Field(() => Date) updatedAt!: Date; -} - -@ObjectType('UserInvoice') -class UserInvoiceType implements Partial { - @Field({ name: 'id' }) - stripeInvoiceId!: string; - @Field(() => SubscriptionPlan) - plan!: SubscriptionPlan; - - @Field(() => SubscriptionRecurring) - recurring!: SubscriptionRecurring; + // deprecated fields + @Field(() => String, { + name: 'id', + nullable: true, + deprecationReason: 'removed', + }) + stripeSubscriptionId!: string; +} +@ObjectType() +export class InvoiceType implements Invoice { @Field() currency!: string; @@ -129,16 +121,36 @@ class UserInvoiceType implements Partial { reason!: string; @Field(() => String, { nullable: true }) - lastPaymentError?: string | null; + lastPaymentError!: string | null; @Field(() => String, { nullable: true }) - link?: string | null; + link!: string | null; @Field(() => Date) createdAt!: Date; @Field(() => Date) updatedAt!: Date; + + // deprecated fields + @Field(() => String, { + name: 'id', + nullable: true, + deprecationReason: 'removed', + }) + stripeInvoiceId!: string | null; + + @Field(() => SubscriptionPlan, { + nullable: true, + deprecationReason: 'removed', + }) + plan!: SubscriptionPlan | null; + + @Field(() => SubscriptionRecurring, { + nullable: true, + deprecationReason: 'removed', + }) + recurring!: SubscriptionRecurring | null; } @InputType() @@ -166,12 +178,14 @@ class CreateCheckoutSessionInput { @Field(() => String) successCallbackLink!: string; - // @FIXME(forehalo): we should put this field in the header instead of as a explicity args - @Field(() => String) - idempotencyKey!: string; + @Field(() => String, { + nullable: true, + deprecationReason: 'use header `Idempotency-Key`', + }) + idempotencyKey?: string; } -@Resolver(() => UserSubscriptionType) +@Resolver(() => SubscriptionType) export class SubscriptionResolver { constructor( private readonly service: SubscriptionService, @@ -186,9 +200,7 @@ export class SubscriptionResolver { const prices = await this.service.listPrices(user); const group = groupBy(prices, price => { - // @ts-expect-error empty lookup key is filtered out - const [plan] = decodeLookupKey(price.lookup_key); - return plan; + return price.lookupKey.plan; }); function findPrice(plan: SubscriptionPlan) { @@ -198,21 +210,24 @@ export class SubscriptionResolver { return null; } - const monthlyPrice = prices.find(p => p.recurring?.interval === 'month'); - const yearlyPrice = prices.find(p => p.recurring?.interval === 'year'); + const monthlyPrice = prices.find( + p => p.lookupKey.recurring === SubscriptionRecurring.Monthly + ); + const yearlyPrice = prices.find( + p => p.lookupKey.recurring === SubscriptionRecurring.Yearly + ); const lifetimePrice = prices.find( - p => - // asserted before - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - decodeLookupKey(p.lookup_key!)[1] === SubscriptionRecurring.Lifetime + p => p.lookupKey.recurring === SubscriptionRecurring.Lifetime ); - const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd'; + + const currency = + monthlyPrice?.price.currency ?? yearlyPrice?.price.currency ?? 'usd'; return { currency, - amount: monthlyPrice?.unit_amount, - yearlyAmount: yearlyPrice?.unit_amount, - lifetimeAmount: lifetimePrice?.unit_amount, + amount: monthlyPrice?.price.unit_amount, + yearlyAmount: yearlyPrice?.price.unit_amount, + lifetimeAmount: lifetimePrice?.price.unit_amount, }; } @@ -240,16 +255,19 @@ export class SubscriptionResolver { async createCheckoutSession( @CurrentUser() user: CurrentUser, @Args({ name: 'input', type: () => CreateCheckoutSessionInput }) - input: CreateCheckoutSessionInput + input: CreateCheckoutSessionInput, + @Headers('idempotency-key') idempotencyKey?: string ) { - const session = await this.service.createCheckoutSession({ + const session = await this.service.checkout({ user, - plan: input.plan, - recurring: input.recurring, - variant: input.variant, + lookupKey: { + plan: input.plan, + recurring: input.recurring, + variant: input.variant, + }, promotionCode: input.coupon, redirectUrl: this.url.link(input.successCallbackLink), - idempotencyKey: input.idempotencyKey, + idempotencyKey, }); if (!session.url) { @@ -266,7 +284,7 @@ export class SubscriptionResolver { return this.service.createCustomerPortal(user.id); } - @Mutation(() => UserSubscriptionType) + @Mutation(() => SubscriptionType) async cancelSubscription( @CurrentUser() user: CurrentUser, @Args({ @@ -276,12 +294,18 @@ export class SubscriptionResolver { defaultValue: SubscriptionPlan.Pro, }) plan: SubscriptionPlan, - @Args('idempotencyKey') idempotencyKey: string + @Headers('idempotency-key') idempotencyKey?: string, + @Args('idempotencyKey', { + type: () => String, + nullable: true, + deprecationReason: 'use header `Idempotency-Key`', + }) + _?: string ) { - return this.service.cancelSubscription(idempotencyKey, user.id, plan); + return this.service.cancelSubscription(user.id, plan, idempotencyKey); } - @Mutation(() => UserSubscriptionType) + @Mutation(() => SubscriptionType) async resumeSubscription( @CurrentUser() user: CurrentUser, @Args({ @@ -291,16 +315,18 @@ export class SubscriptionResolver { defaultValue: SubscriptionPlan.Pro, }) plan: SubscriptionPlan, - @Args('idempotencyKey') idempotencyKey: string + @Headers('idempotency-key') idempotencyKey?: string, + @Args('idempotencyKey', { + type: () => String, + nullable: true, + deprecationReason: 'use header `Idempotency-Key`', + }) + _?: string ) { - return this.service.resumeCanceledSubscription( - idempotencyKey, - user.id, - plan - ); + return this.service.resumeSubscription(user.id, plan, idempotencyKey); } - @Mutation(() => UserSubscriptionType) + @Mutation(() => SubscriptionType) async updateSubscriptionRecurring( @CurrentUser() user: CurrentUser, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) @@ -312,88 +338,28 @@ export class SubscriptionResolver { defaultValue: SubscriptionPlan.Pro, }) plan: SubscriptionPlan, - @Args('idempotencyKey') idempotencyKey: string + @Headers('idempotency-key') idempotencyKey?: string, + @Args('idempotencyKey', { + type: () => String, + nullable: true, + deprecationReason: 'use header `Idempotency-Key`', + }) + _?: string ) { return this.service.updateSubscriptionRecurring( - idempotencyKey, user.id, plan, - recurring + recurring, + idempotencyKey ); } } @Resolver(() => UserType) export class UserSubscriptionResolver { - constructor( - private readonly config: Config, - private readonly db: PrismaClient - ) {} - - @ResolveField(() => UserSubscriptionType, { - nullable: true, - deprecationReason: 'use `UserType.subscriptions`', - }) - async subscription( - @Context() ctx: { isAdminQuery: boolean }, - @CurrentUser() me: User, - @Parent() user: User, - @Args({ - name: 'plan', - type: () => SubscriptionPlan, - nullable: true, - defaultValue: SubscriptionPlan.Pro, - }) - plan: SubscriptionPlan - ) { - // allow admin to query other user's subscription - if (!ctx.isAdminQuery && me.id !== user.id) { - throw new AccessDenied(); - } - - // @FIXME(@forehalo): should not mock any api for selfhosted server - // the frontend should avoid calling such api if feature is not enabled - if (this.config.isSelfhosted) { - const start = new Date(); - const end = new Date(); - end.setFullYear(start.getFullYear() + 1); - - return { - stripeSubscriptionId: 'dummy', - plan: SubscriptionPlan.SelfHosted, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start, - end, - createdAt: start, - updatedAt: start, - }; - } - - const subscription = await this.db.userSubscription.findUnique({ - where: { - userId_plan: { - userId: user.id, - plan, - }, - status: SubscriptionStatus.Active, - }, - }); - - if ( - subscription && - subscription.variant && - ![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes( - subscription.variant as SubscriptionVariant - ) - ) { - subscription.variant = null; - } - - return subscription; - } + constructor(private readonly db: PrismaClient) {} - @ResolveField(() => [UserSubscriptionType]) + @ResolveField(() => [SubscriptionType]) async subscriptions( @CurrentUser() me: User, @Parent() user: User @@ -423,7 +389,7 @@ export class UserSubscriptionResolver { return subscriptions; } - @ResolveField(() => [UserInvoiceType]) + @ResolveField(() => [InvoiceType]) async invoices( @CurrentUser() me: User, @Parent() user: User, diff --git a/packages/backend/server/src/plugins/payment/schedule.ts b/packages/backend/server/src/plugins/payment/schedule.ts index 0f9c744fde066..f0714cf1c5e10 100644 --- a/packages/backend/server/src/plugins/payment/schedule.ts +++ b/packages/backend/server/src/plugins/payment/schedule.ts @@ -63,8 +63,8 @@ export class ScheduleManager { } async fromSubscription( - idempotencyKey: string, - subscription: string | Stripe.Subscription + subscription: string | Stripe.Subscription, + idempotencyKey?: string ) { if (typeof subscription === 'string') { subscription = await this.stripe.subscriptions.retrieve(subscription, { @@ -88,7 +88,7 @@ export class ScheduleManager { * 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(idempotencyKey: string) { + async cancel(idempotencyKey?: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -129,7 +129,7 @@ export class ScheduleManager { ); } - async resume(idempotencyKey: string) { + async resume(idempotencyKey?: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -188,7 +188,7 @@ export class ScheduleManager { }); } - async update(idempotencyKey: string, price: string) { + async update(price: string, idempotencyKey?: string) { if (!this._schedule) { throw new Error('No schedule'); } diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index cc9fa6fcc4c9f..2de5f031d8e07 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -1,7 +1,4 @@ -import { randomUUID } from 'node:crypto'; - import { Injectable, Logger } from '@nestjs/common'; -import { OnEvent as RawOnEvent } from '@nestjs/event-emitter'; import type { User, UserInvoice, @@ -12,13 +9,13 @@ import { PrismaClient } from '@prisma/client'; import Stripe from 'stripe'; import { CurrentUser } from '../../core/auth'; -import { EarlyAccessType, FeatureManagementService } from '../../core/features'; +import { FeatureManagementService } from '../../core/features'; +import { UserService } from '../../core/user'; import { ActionForbidden, CantUpdateOnetimePaymentSubscription, Config, CustomerPortalCreateFailed, - EventEmitter, InternalServerError, OnEvent, SameSubscriptionRecurring, @@ -29,163 +26,64 @@ import { SubscriptionPlanNotFound, UserNotFound, } from '../../fundamentals'; +import { UserSubscriptionManager } from './manager'; import { ScheduleManager } from './schedule'; import { - InvoiceStatus, + encodeLookupKey, + KnownStripeInvoice, + KnownStripePrice, + LookupKey, + retriveLookupKeyFromStripePrice, + retriveLookupKeyFromStripeSubscription, SubscriptionPlan, SubscriptionRecurring, SubscriptionStatus, SubscriptionVariant, } 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?: SubscriptionVariant -): string { - return `${plan}_${recurring}` + (variant ? `_${variant}` : ''); -} - -export function decodeLookupKey( - key: string -): [SubscriptionPlan, SubscriptionRecurring, SubscriptionVariant?] { - const [plan, recurring, variant] = key.split('_'); - - return [ - plan as SubscriptionPlan, - recurring as SubscriptionRecurring, - variant as SubscriptionVariant | undefined, - ]; -} - -const SubscriptionActivated: Set = new Set([ - 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); + private readonly scheduleManager = new ScheduleManager(this.stripe); 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 + private readonly feature: FeatureManagementService, + private readonly user: UserService, + private readonly userManager: UserSubscriptionManager ) {} - 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' - ); + async listPrices(user?: CurrentUser): Promise { + const customer = user ? await this.getOrCreateCustomer(user) : undefined; - const list = await this.stripe.prices.list({ + // TODO(@forehalo): cache + const prices = await this.stripe.prices.list({ active: true, - // only list recurring prices if lifetime price is not enabled - ...(lifetimePriceEnabled ? {} : { type: 'recurring' }), + limit: 100, }); - return list.data.filter(price => { - if (!price.lookup_key) { - return false; - } - - const [plan, recurring, variant] = decodeLookupKey(price.lookup_key); - - // never return onetime payment price - if (variant === SubscriptionVariant.Onetime) { - return false; - } - - // 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; - }); + return this.userManager.filterPrices( + prices.data + .map(price => this.parseStripePrice(price)) + .filter(Boolean) as KnownStripePrice[], + customer + ); } - async createCheckoutSession({ + async checkout({ user, - recurring, - plan, - variant, + lookupKey, promotionCode, redirectUrl, idempotencyKey, }: { user: CurrentUser; - recurring: SubscriptionRecurring; - plan: SubscriptionPlan; - variant?: SubscriptionVariant; + lookupKey: LookupKey; promotionCode?: string | null; redirectUrl: string; - idempotencyKey: string; + idempotencyKey?: string; }) { if ( this.config.deploy && @@ -195,18 +93,10 @@ export class SubscriptionService { throw new ActionForbidden(); } - // variant is not allowed for lifetime subscription - if (recurring === SubscriptionRecurring.Lifetime) { - variant = undefined; - } - - const currentSubscription = await this.db.userSubscription.findFirst({ - where: { - userId: user.id, - plan, - status: SubscriptionStatus.Active, - }, - }); + const currentSubscription = await this.userManager.getSubscription( + user.id, + lookupKey.plan + ); if ( currentSubscription && @@ -215,40 +105,42 @@ export class SubscriptionService { /* current subscription is a onetime subscription and so as the one that's checking out */ ( (currentSubscription.variant === SubscriptionVariant.Onetime && - variant === SubscriptionVariant.Onetime) || + lookupKey.variant === SubscriptionVariant.Onetime) || /* current subscription is normal subscription and is checking-out a lifetime subscription */ (currentSubscription.recurring !== SubscriptionRecurring.Lifetime && currentSubscription.variant !== SubscriptionVariant.Onetime && - recurring === SubscriptionRecurring.Lifetime) + lookupKey.recurring === SubscriptionRecurring.Lifetime) ) ) ) { - throw new SubscriptionAlreadyExists({ plan }); + throw new SubscriptionAlreadyExists({ plan: lookupKey.plan }); } - const customer = await this.getOrCreateCustomer( - `${idempotencyKey}-getOrCreateCustomer`, - user - ); + const price = await this.getPrice(lookupKey); + const customer = await this.getOrCreateCustomer(user); - const { price, coupon } = await this.getAvailablePrice( - customer, - plan, - recurring, - variant - ); + const priceAndAutoCoupon = price + ? await this.userManager.validatePrice(price, customer) + : null; + + if (!priceAndAutoCoupon) { + throw new SubscriptionPlanNotFound({ + plan: lookupKey.plan, + recurring: lookupKey.recurring, + }); + } let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = []; - if (coupon) { - discounts = [{ coupon }]; + if (priceAndAutoCoupon.coupon) { + discounts = [{ coupon: priceAndAutoCoupon.coupon }]; } else if (promotionCode) { - const code = await this.getAvailablePromotionCode( + const coupon = await this.getCouponFromPromotionCode( promotionCode, - customer.stripeCustomerId + customer ); - if (code) { - discounts = [{ promotion_code: code }]; + if (coupon) { + discounts = [{ coupon }]; } } @@ -256,7 +148,7 @@ export class SubscriptionService { { line_items: [ { - price, + price: priceAndAutoCoupon.price.price.id, quantity: 1, }, ], @@ -266,8 +158,8 @@ export class SubscriptionService { // discount ...(discounts.length ? { discounts } : { allow_promotion_codes: true }), // mode: 'subscription' or 'payment' for lifetime and onetime payment - ...(recurring === SubscriptionRecurring.Lifetime || - variant === SubscriptionVariant.Onetime + ...(lookupKey.recurring === SubscriptionRecurring.Lifetime || + lookupKey.variant === SubscriptionVariant.Onetime ? { mode: 'payment', invoice_creation: { @@ -284,186 +176,144 @@ export class SubscriptionService { name: 'auto', }, }, - { idempotencyKey: `${idempotencyKey}-checkoutSession` } + { idempotencyKey } ); } async cancelSubscription( - idempotencyKey: string, userId: string, - plan: SubscriptionPlan + plan: SubscriptionPlan, + idempotencyKey?: string ): Promise { - const user = await this.db.user.findUnique({ - where: { - id: userId, - }, - include: { - subscriptions: { - where: { - plan, - }, - }, - }, - }); + const subscription = await this.userManager.getSubscription(userId, plan); - if (!user) { - throw new UserNotFound(); - } - - const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); - if (!subscriptionInDB) { + if (!subscription) { throw new SubscriptionNotExists({ plan }); } - if (!subscriptionInDB.stripeSubscriptionId) { + if (!subscription.stripeSubscriptionId) { throw new CantUpdateOnetimePaymentSubscription( 'Onetime payment subscription cannot be canceled.' ); } - if (subscriptionInDB.canceledAt) { + if (subscription.canceledAt) { throw new SubscriptionHasBeenCanceled(); } + // update the subscription in db optimistically + const newSubscription = this.userManager.cancelSubscription(subscription); + // should release the schedule first - if (subscriptionInDB.stripeScheduleId) { + if (subscription.stripeScheduleId) { const manager = await this.scheduleManager.fromSchedule( - subscriptionInDB.stripeScheduleId + subscription.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, + await this.stripe.subscriptions.update( + subscription.stripeSubscriptionId, { cancel_at_period_end: true }, { idempotencyKey } ); - return await this.saveSubscription(user, subscription); } + + return newSubscription; } - async resumeCanceledSubscription( - idempotencyKey: string, + async resumeSubscription( userId: string, - plan: SubscriptionPlan + plan: SubscriptionPlan, + idempotencyKey?: string ): Promise { - const user = await this.db.user.findUnique({ - where: { - id: userId, - }, - include: { - subscriptions: true, - }, - }); + const subscription = await this.userManager.getSubscription(userId, plan); - if (!user) { - throw new UserNotFound(); + if (!subscription) { + throw new SubscriptionNotExists({ plan }); } - const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); - if (!subscriptionInDB) { - throw new SubscriptionNotExists({ plan }); + if (!subscription.canceledAt) { + throw new SubscriptionHasBeenCanceled(); } - if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) { + if (!subscription.stripeSubscriptionId || !subscription.end) { throw new CantUpdateOnetimePaymentSubscription( 'Onetime payment subscription cannot be resumed.' ); } - if (!subscriptionInDB.canceledAt) { - throw new SubscriptionHasBeenCanceled(); - } - - if (subscriptionInDB.end < new Date()) { + if (subscription.end < new Date()) { throw new SubscriptionExpired(); } - if (subscriptionInDB.stripeScheduleId) { + // update the subscription in db optimistically + const newSubscription = + await this.userManager.resumeSubscription(subscription); + + if (subscription.stripeScheduleId) { const manager = await this.scheduleManager.fromSchedule( - subscriptionInDB.stripeScheduleId + subscription.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, + await this.stripe.subscriptions.update( + subscription.stripeSubscriptionId, { cancel_at_period_end: false }, { idempotencyKey } ); - - return await this.saveSubscription(user, subscription); } + + return newSubscription; } async updateSubscriptionRecurring( - idempotencyKey: string, userId: string, plan: SubscriptionPlan, - recurring: SubscriptionRecurring + recurring: SubscriptionRecurring, + idempotencyKey?: string ): Promise { - const user = await this.db.user.findUnique({ - where: { - id: userId, - }, - include: { - subscriptions: true, - }, - }); + const subscription = await this.userManager.getSubscription(userId, plan); - if (!user) { - throw new UserNotFound(); - } - const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); - if (!subscriptionInDB) { + if (!subscription) { throw new SubscriptionNotExists({ plan }); } - if (!subscriptionInDB.stripeSubscriptionId) { + if (!subscription.stripeSubscriptionId) { throw new CantUpdateOnetimePaymentSubscription(); } - if (subscriptionInDB.canceledAt) { + if (subscription.canceledAt) { throw new SubscriptionHasBeenCanceled(); } - if (subscriptionInDB.recurring === recurring) { + if (subscription.recurring === recurring) { throw new SameSubscriptionRecurring({ recurring }); } - const price = await this.getPrice( - subscriptionInDB.plan as SubscriptionPlan, + const price = await this.getPrice({ + plan, + recurring, + }); + + if (!price) { + throw new SubscriptionPlanNotFound({ plan, recurring }); + } + + // update the subscription in db optimistically + const newSubscription = this.userManager.updateSubscriptionRecurring( + subscription, recurring ); const manager = await this.scheduleManager.fromSubscription( - `${idempotencyKey}-fromSubscription`, - subscriptionInDB.stripeSubscriptionId + subscription.stripeSubscriptionId ); - await manager.update(`${idempotencyKey}-update`, price); + await manager.update(price.price.id, idempotencyKey); - 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, - }, - }); + return newSubscription; } async createCustomerPortal(id: string) { @@ -489,321 +339,45 @@ export class SubscriptionService { } } - @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; + async saveStripeInvoice(stripeInvoice: Stripe.Invoice): Promise { + const knownInvoice = await this.parseStripeInvoice(stripeInvoice); - if (!price) { - throw new Error('Unexpected invoice with no price'); + if (!knownInvoice) { + throw new InternalServerError('Failed to parse stripe invoice.'); } - if (!price.lookup_key) { - throw new Error('Unexpected subscription with no key'); - } - - const [plan, recurring, variant] = 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 ?? 'subscription_update', - ...(data as any), - }, - }); - - // handle one time payment, no subscription created by stripe - if ( - event === 'invoice.payment_succeeded' && - stripeInvoice.status === 'paid' - ) { - if (recurring === SubscriptionRecurring.Lifetime) { - await this.saveLifetimeSubscription(user, invoice); - } else if (variant === SubscriptionVariant.Onetime) { - await this.saveOnetimePaymentSubscription(user, invoice); - } - } + return this.userManager.saveInvoice(knownInvoice); } - 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, - }, - }, - }); + async saveStripeSubscription(subscription: Stripe.Subscription) { + const knownSubscription = await this.parseStripeSubscription(subscription); - 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, - }, - }); + if (!knownSubscription) { + throw new InternalServerError('Failed to parse stripe subscription.'); } - this.event.emit('user.subscription.activated', { - userId: user.id, - plan: invoice.plan as SubscriptionPlan, - recurring: SubscriptionRecurring.Lifetime, - }); - } - - async saveOnetimePaymentSubscription(user: User, invoice: UserInvoice) { - const savedSubscription = await this.db.userSubscription.findUnique({ - where: { - userId_plan: { - userId: user.id, - plan: invoice.plan, - }, - }, - }); - - // TODO(@forehalo): time helper - const subscriptionTime = - (invoice.recurring === SubscriptionRecurring.Monthly ? 30 : 365) * - 24 * - 60 * - 60 * - 1000; - - // extends the subscription time if exists - if (savedSubscription) { - if (!savedSubscription.end) { - throw new InternalServerError( - 'Unexpected onetime subscription with no end date' - ); - } - - const period = - // expired, reset the period - savedSubscription.end <= new Date() - ? { - start: new Date(), - end: new Date(Date.now() + subscriptionTime), - } - : { - end: new Date(savedSubscription.end.getTime() + subscriptionTime), - }; - - await this.db.userSubscription.update({ - where: { - id: savedSubscription.id, - }, - data: period, - }); - } else { - await this.db.userSubscription.create({ - data: { - userId: user.id, - stripeSubscriptionId: null, - plan: invoice.plan, - recurring: invoice.recurring, - variant: SubscriptionVariant.Onetime, - start: new Date(), - end: new Date(Date.now() + subscriptionTime), - status: SubscriptionStatus.Active, - nextBillAt: null, - }, - }); - } - - this.event.emit('user.subscription.activated', { - userId: user.id, - plan: invoice.plan as SubscriptionPlan, - recurring: invoice.recurring as SubscriptionRecurring, - }); - } - - @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 - ); + const isPlanActive = + subscription.status === SubscriptionStatus.Active || + subscription.status === SubscriptionStatus.Trialing; - await this.saveSubscription(user, subscription); + if (!isPlanActive) { + await this.userManager.deleteSubscription(knownSubscription); } else { - await this.onSubscriptionDeleted(subscription); + await this.userManager.saveSubscription(knownSubscription); } } - @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, - }, - }); - } + async deleteStripeSubscription(subscription: Stripe.Subscription) { + const knownSubscription = await this.parseStripeSubscription(subscription); - 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'); + if (!knownSubscription) { + throw new InternalServerError('Failed to parse stripe subscription.'); } - const [plan, recurring, variant] = - this.decodePlanFromSubscription(subscription); - const planActivated = SubscriptionActivated.has(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, - recurring, - variant, - 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, - ...commonData, - }, - }); + await this.userManager.deleteSubscription(knownSubscription); } - private async getOrCreateCustomer( - idempotencyKey: string, - user: CurrentUser - ): Promise { + async getOrCreateCustomer(user: CurrentUser): Promise { let customer = await this.db.userStripeCustomer.findUnique({ where: { userId: user.id, @@ -820,10 +394,9 @@ export class SubscriptionService { if (stripeCustomersList.data.length) { stripeCustomer = stripeCustomersList.data[0]; } else { - stripeCustomer = await this.stripe.customers.create( - { email: user.email }, - { idempotencyKey } - ); + stripeCustomer = await this.stripe.customers.create({ + email: user.email, + }); } customer = await this.db.userStripeCustomer.create({ @@ -857,154 +430,64 @@ export class SubscriptionService { } } - private async retrieveUserFromCustomer(customerId: string) { - const customer = await this.db.userStripeCustomer.findUnique({ + private async retrieveUserFromCustomer( + customer: string | Stripe.Customer | Stripe.DeletedCustomer + ) { + const userStripeCustomer = await this.db.userStripeCustomer.findUnique({ where: { - stripeCustomerId: customerId, - }, - include: { - user: true, + stripeCustomerId: typeof customer === 'string' ? customer : customer.id, }, }); - if (customer?.user) { - return customer.user; + if (userStripeCustomer) { + return userStripeCustomer.userId; } - // 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 (typeof customer === 'string') { + customer = await this.stripe.customers.retrieve(customer); } - if (!stripeCustomer.email) { - throw new Error('Unexpected subscription created with no email customer'); + if (customer.deleted || !customer.email || !customer.id) { + return null; } - const user = await this.db.user.findUnique({ - where: { - email: stripeCustomer.email, - }, - }); + const user = await this.user.findUserByEmail(customer.email); if (!user) { - throw new Error( - `Unexpected subscription created with unknown customer ${stripeCustomer.email}` - ); + return null; } await this.db.userStripeCustomer.create({ data: { userId: user.id, - stripeCustomerId: stripeCustomer.id, + stripeCustomerId: customer.id, }, }); - return user; + return user.id; } private async getPrice( - plan: SubscriptionPlan, - recurring: SubscriptionRecurring, - variant?: SubscriptionVariant - ): Promise { - if (recurring === SubscriptionRecurring.Lifetime) { - const lifetimePriceEnabled = await this.config.runtime.fetch( - 'plugins.payment/showLifetimePrice' - ); - - if (!lifetimePriceEnabled) { - throw new ActionForbidden(); - } - } - + lookupKey: LookupKey + ): Promise { 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, - variant?: SubscriptionVariant - ): Promise<{ price: string; coupon?: string }> { - if (variant) { - const price = await this.getPrice(plan, recurring, variant); - return { price }; - } - - 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') - ); + lookup_keys: [encodeLookupKey(lookupKey)], + limit: 1, }); - if (plan === SubscriptionPlan.Pro) { - const canHaveEADiscount = - isEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly; - const price = await this.getPrice( - plan, - recurring, - canHaveEADiscount ? SubscriptionVariant.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 ? SubscriptionVariant.EA : undefined - ); + const price = prices.data[0]; - return { - price, - coupon: !subscribed - ? isAIEaUser - ? CouponType.AIEarlyAccessOneYearFree - : isEaUser - ? CouponType.ProEarlyAccessAIOneYearFree - : undefined - : undefined, - }; - } + return price + ? { + lookupKey, + price, + } + : null; } - private async getAvailablePromotionCode( + private async getCouponFromPromotionCode( userFacingPromotionCode: string, - customer?: string + customer: UserStripeCustomer ) { const list = await this.stripe.promotionCodes.list({ code: userFacingPromotionCode, @@ -1017,27 +500,86 @@ export class SubscriptionService { return null; } - let available = false; + // the coupons are always bound to products, we need to check it first + // but the logic would be too complicated, and stripe will complain if the code is not applicable when checking out + // It's safe to skip the check here + // code.coupon.applies_to.products.forEach() - if (code.customer) { - available = - typeof code.customer === 'string' - ? code.customer === customer - : code.customer.id === customer; - } else { - available = true; + // check if the code is bound to a specific customer + return !code.customer || + (typeof code.customer === 'string' + ? code.customer === customer.stripeCustomerId + : code.customer.id === customer.stripeCustomerId) + ? code.coupon.id + : null; + } + + private async parseStripeInvoice( + invoice: Stripe.Invoice + ): Promise { + // we can't do anything if we can't recognize the customer + if (!invoice.customer_email) { + return null; + } + + const price = invoice.lines.data[0]?.price; + + // there should be at least one line item in the invoice + if (!price) { + return null; + } + + const lookupKey = retriveLookupKeyFromStripePrice(price); + + // The whole subscription system depends on the lookup_keys bound with prices. + // if the price comes with no lookup_key, we should just ignore it. + if (!lookupKey) { + return null; } - return available ? code.id : null; + const user = await this.user.findUserByEmail(invoice.customer_email); + + // TODO(@forehalo): the email may actually not appear to be AFFiNE user + // There is coming feature that allow anonymous user with only email provided to buy selfhost licenses + if (!user) { + return null; + } + + return { + userId: user.id, + stripeInvoice: invoice, + lookupKey, + }; } - private decodePlanFromSubscription(sub: Stripe.Subscription) { - const price = sub.items.data[0].price; + private async parseStripeSubscription(subscription: Stripe.Subscription) { + const lookupKey = retriveLookupKeyFromStripeSubscription(subscription); - if (!price.lookup_key) { - throw new Error('Unexpected subscription with no key'); + if (!lookupKey) { + return null; } - return decodeLookupKey(price.lookup_key); + const userId = await this.retrieveUserFromCustomer(subscription.customer); + + if (!userId) { + return null; + } + + return { + userId, + lookupKey, + stripeSubscription: subscription, + }; + } + + private parseStripePrice(price: Stripe.Price): KnownStripePrice | null { + const lookupKey = retriveLookupKeyFromStripePrice(price); + + return lookupKey + ? { + lookupKey, + price, + } + : null; } } diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index aee3470ae9c59..196c57d98e5c0 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -1,4 +1,5 @@ import type { User } from '@prisma/client'; +import Stripe from 'stripe'; import type { Payload } from '../../fundamentals/event/def'; @@ -42,6 +43,12 @@ export enum InvoiceStatus { Uncollectible = 'uncollectible', } +export enum CouponType { + ProEarlyAccessOneYearFree = 'pro_ea_one_year_free', + AIEarlyAccessOneYearFree = 'ai_ea_one_year_free', + ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free', +} + declare module '../../fundamentals/event/def' { interface UserEvents { subscription: { @@ -58,3 +65,125 @@ declare module '../../fundamentals/event/def' { }; } } + +export interface LookupKey { + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + variant?: SubscriptionVariant; +} + +export interface KnownStripeInvoice { + /** + * User in AFFiNE system. + */ + userId: string; + + /** + * The lookup key of the price that the invoice is for. + */ + lookupKey: LookupKey; + + /** + * The invoice object from Stripe. + */ + stripeInvoice: Stripe.Invoice; +} + +export interface KnownStripeSubscription { + /** + * User in AFFiNE system. + */ + userId: string; + + /** + * The lookup key of the price that the invoice is for. + */ + lookupKey: LookupKey; + + /** + * The subscription object from Stripe. + */ + stripeSubscription: Stripe.Subscription; +} + +export interface KnownStripePrice { + /** + * The lookup key of the price. + */ + lookupKey: LookupKey; + + /** + * The price object from Stripe. + */ + price: Stripe.Price; +} + +const VALID_LOOKUP_KEYS = new Set([ + // pro + `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`, + `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`, + // only EA for yearly pro + `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`, + `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`, + `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`, + `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`, + + // ai + `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`, + // only EA for yearly AI + `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`, + `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`, + + // team + `${SubscriptionPlan.Team}_${SubscriptionRecurring.Monthly}`, + `${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`, +]); + +// [Plan x Recurring x Variant] make a stripe price lookup key +export function encodeLookupKey({ + plan, + recurring, + variant, +}: LookupKey): string { + const key = `${plan}_${recurring}` + (variant ? `_${variant}` : ''); + + if (!VALID_LOOKUP_KEYS.has(key)) { + throw new Error(`Invalid price: ${key}`); + } + + return key; +} + +export function decodeLookupKey(key: string): LookupKey | null { + // NOTE(@forehalo): + // we have some legacy prices in stripe still in used, + // so we give it `pro_monthly_xxx` variant to make it invisible but valid, + // and those variant won't be listed in [SubscriptionVariant] + // if (!VALID_LOOKUP_KEYS.has(key)) { + // return null; + // } + const [plan, recurring, variant] = key.split('_'); + + return { + plan: plan as SubscriptionPlan, + recurring: recurring as SubscriptionRecurring, + variant: variant as SubscriptionVariant | undefined, + }; +} + +export function retriveLookupKeyFromStripePrice(price: Stripe.Price) { + return price.lookup_key ? decodeLookupKey(price.lookup_key) : null; +} + +export function retriveLookupKeyFromStripeSubscription( + subscription: Stripe.Subscription +) { + const price = subscription.items.data[0]?.price; + + // there should be and only one item in the subscription + if (!price) { + return null; + } + + return retriveLookupKeyFromStripePrice(price); +} diff --git a/packages/backend/server/src/plugins/payment/webhook.ts b/packages/backend/server/src/plugins/payment/webhook.ts index cf757609e28dc..5cfbfe35a5d6f 100644 --- a/packages/backend/server/src/plugins/payment/webhook.ts +++ b/packages/backend/server/src/plugins/payment/webhook.ts @@ -1,63 +1,63 @@ -import assert from 'node:assert'; - -import type { RawBodyRequest } from '@nestjs/common'; -import { Controller, Logger, Post, Req } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import type { Request } from 'express'; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import Stripe from 'stripe'; -import { Public } from '../../core/auth'; -import { Config, InternalServerError } from '../../fundamentals'; +import { SubscriptionService } from './service'; -@Controller('/api/stripe') -export class StripeWebhook { - private readonly webhookKey: string; - private readonly logger = new Logger(StripeWebhook.name); +const OnStripeEvent = ( + event: Stripe.Event.Type, + opts?: Parameters[1] +) => OnEvent(`stripe:${event}`, opts); +/** + * Stripe webhook events sent in random order, and may be even sent more than once. + * + * A good way to avoid events sequence issue is fetch the latest object data regarding that event, + * and all following operations only depend on the latest state instead of the one in event data. + */ +@Injectable() +export class StripeWebhook { constructor( - config: Config, - private readonly stripe: Stripe, - private readonly event: EventEmitter2 + private readonly service: SubscriptionService, + private readonly stripe: Stripe + ) {} + + @OnStripeEvent('invoice.created') + @OnStripeEvent('invoice.updated') + @OnStripeEvent('invoice.finalization_failed') + @OnStripeEvent('invoice.payment_failed') + @OnStripeEvent('invoice.payment_succeeded') + async onInvoiceUpdated( + event: + | Stripe.InvoiceCreatedEvent + | Stripe.InvoiceUpdatedEvent + | Stripe.InvoiceFinalizationFailedEvent + | Stripe.InvoicePaymentFailedEvent + | Stripe.InvoicePaymentSucceededEvent ) { - assert(config.plugins.payment.stripe); - this.webhookKey = config.plugins.payment.stripe.keys.webhookKey; + const invoice = await this.stripe.invoices.retrieve(event.data.object.id); + await this.service.saveStripeInvoice(invoice); } - @Public() - @Post('/webhook') - async handleWebhook(@Req() req: RawBodyRequest) { - // Check if webhook signing is configured. - - // Retrieve the event by verifying the signature using the raw body and secret. - const signature = req.headers['stripe-signature']; - try { - const event = this.stripe.webhooks.constructEvent( - req.rawBody ?? '', - signature ?? '', - this.webhookKey - ); - - this.logger.debug( - `[${event.id}] Stripe Webhook {${event.type}} received.` - ); + @OnStripeEvent('customer.subscription.created') + @OnStripeEvent('customer.subscription.updated') + async onSubscriptionChanges( + event: + | Stripe.CustomerSubscriptionUpdatedEvent + | Stripe.CustomerSubscriptionCreatedEvent + ) { + const subscription = await this.stripe.subscriptions.retrieve( + event.data.object.id, + { + expand: ['customer'], + } + ); + + await this.service.saveStripeSubscription(subscription); + } - // Stripe requires responseing webhook immediately and handle event asynchronously. - setImmediate(() => { - // handle duplicated events? - // see https://stripe.com/docs/webhooks#handle-duplicate-events - this.event - .emitAsync( - event.type, - event.data.object, - // here to let event listeners know what exactly the event is if a handler can handle multiple events - event.type - ) - .catch(e => { - this.logger.error('Failed to handle Stripe Webhook event.', e); - }); - }); - } catch (err: any) { - throw new InternalServerError(err.message); - } + @OnStripeEvent('customer.subscription.deleted') + async onSubscriptionDeleted(event: Stripe.CustomerSubscriptionDeletedEvent) { + await this.service.deleteStripeSubscription(event.data.object); } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 5d129eb89e1b4..5f0ce5b5f8cea 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -141,7 +141,7 @@ input CreateChatSessionInput { input CreateCheckoutSessionInput { coupon: String - idempotencyKey: String! + idempotencyKey: String plan: SubscriptionPlan = Pro recurring: SubscriptionRecurring = Yearly successCallbackLink: String! @@ -388,6 +388,20 @@ enum InvoiceStatus { Void } +type InvoiceType { + amount: Int! + createdAt: DateTime! + currency: String! + id: String @deprecated(reason: "removed") + lastPaymentError: String + link: String + plan: SubscriptionPlan @deprecated(reason: "removed") + reason: String! + recurring: SubscriptionRecurring @deprecated(reason: "removed") + status: InvoiceStatus! + updatedAt: DateTime! +} + """ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). """ @@ -426,7 +440,7 @@ type MissingOauthQueryParameterDataType { type Mutation { acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! - cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription! + cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro): SubscriptionType! changeEmail(email: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!, userId: String): Boolean! @@ -473,7 +487,7 @@ type Mutation { """Remove user avatar""" removeAvatar: RemoveAvatar! removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! - resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription! + resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro): SubscriptionType! revoke(userId: String!, workspaceId: String!): Boolean! revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! @@ -495,7 +509,7 @@ type Mutation { """update multiple server runtime configurable settings""" updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]! - updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription! + updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): SubscriptionType! """Update a user""" updateUser(id: String!, input: ManageUserInput!): UserType! @@ -766,6 +780,27 @@ enum SubscriptionStatus { Unpaid } +type SubscriptionType { + canceledAt: DateTime + createdAt: DateTime! + end: DateTime + id: String @deprecated(reason: "removed") + nextBillAt: DateTime + + """ + The 'Free' plan just exists to be a placeholder and for the type convenience of frontend. + There won't actually be a subscription with plan 'Free' + """ + plan: SubscriptionPlan! + recurring: SubscriptionRecurring! + start: DateTime! + status: SubscriptionStatus! + trialEnd: DateTime + trialStart: DateTime + updatedAt: DateTime! + variant: SubscriptionVariant +} + enum SubscriptionVariant { EA Onetime @@ -792,20 +827,6 @@ input UpdateWorkspaceInput { """The `Upload` scalar type represents a file upload.""" scalar Upload -type UserInvoice { - amount: Int! - createdAt: DateTime! - currency: String! - id: String! - lastPaymentError: String - link: String - plan: SubscriptionPlan! - reason: String! - recurring: SubscriptionRecurring! - status: InvoiceStatus! - updatedAt: DateTime! -} - union UserOrLimitedUser = LimitedUserType | UserType type UserQuota { @@ -825,27 +846,6 @@ type UserQuotaHumanReadable { storageQuota: String! } -type UserSubscription { - canceledAt: DateTime - createdAt: DateTime! - end: DateTime - id: String - nextBillAt: DateTime - - """ - The 'Free' plan just exists to be a placeholder and for the type convenience of frontend. - There won't actually be a subscription with plan 'Free' - """ - plan: SubscriptionPlan! - recurring: SubscriptionRecurring! - start: DateTime! - status: SubscriptionStatus! - trialEnd: DateTime - trialStart: DateTime - updatedAt: DateTime! - variant: SubscriptionVariant -} - type UserType { """User avatar url""" avatarUrl: String @@ -869,13 +869,12 @@ type UserType { """Get user invoice count""" invoiceCount: Int! - invoices(skip: Int, take: Int = 8): [UserInvoice!]! + invoices(skip: Int, take: Int = 8): [InvoiceType!]! """User name""" name: String! quota: UserQuota - subscription(plan: SubscriptionPlan = Pro): UserSubscription @deprecated(reason: "use `UserType.subscriptions`") - subscriptions: [UserSubscription!]! + subscriptions: [SubscriptionType!]! token: tokenType! @deprecated(reason: "use [/api/auth/sign-in?native=true] instead") } diff --git a/packages/backend/server/tests/payment/service.spec.ts b/packages/backend/server/tests/payment/service.spec.ts index 1b0532bd0e18e..2692ccc8a563a 100644 --- a/packages/backend/server/tests/payment/service.spec.ts +++ b/packages/backend/server/tests/payment/service.spec.ts @@ -15,72 +15,16 @@ import { } from '../../src/core/features'; import { EventEmitter } from '../../src/fundamentals'; import { Config, ConfigModule } from '../../src/fundamentals/config'; +import { SubscriptionService } from '../../src/plugins/payment/service'; import { CouponType, encodeLookupKey, - SubscriptionService, -} from '../../src/plugins/payment/service'; -import { SubscriptionPlan, SubscriptionRecurring, SubscriptionStatus, SubscriptionVariant, } from '../../src/plugins/payment/types'; -import { createTestingApp } from '../utils'; - -const test = ava as TestFn<{ - u1: CurrentUser; - db: PrismaClient; - app: INestApplication; - service: SubscriptionService; - stripe: Stripe; - event: EventEmitter; - 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.event = app.get(EventEmitter); - 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@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(); -}); +import { createTestingApp, initTestingDB } from '../utils'; const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`; const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`; @@ -100,6 +44,7 @@ const PRICES = { }, unit_amount: 799, currency: 'usd', + id: PRO_MONTHLY, lookup_key: PRO_MONTHLY, }, [PRO_YEARLY]: { @@ -108,11 +53,13 @@ const PRICES = { }, unit_amount: 8100, currency: 'usd', + id: PRO_YEARLY, lookup_key: PRO_YEARLY, }, [PRO_LIFETIME]: { unit_amount: 49900, currency: 'usd', + id: PRO_LIFETIME, lookup_key: PRO_LIFETIME, }, [PRO_EA_YEARLY]: { @@ -121,6 +68,7 @@ const PRICES = { }, unit_amount: 5000, currency: 'usd', + id: PRO_EA_YEARLY, lookup_key: PRO_EA_YEARLY, }, [AI_YEARLY]: { @@ -129,6 +77,7 @@ const PRICES = { }, unit_amount: 10680, currency: 'usd', + id: AI_YEARLY, lookup_key: AI_YEARLY, }, [AI_YEARLY_EA]: { @@ -137,24 +86,28 @@ const PRICES = { }, unit_amount: 9999, currency: 'usd', + id: AI_YEARLY_EA, lookup_key: AI_YEARLY_EA, }, [PRO_MONTHLY_CODE]: { unit_amount: 799, currency: 'usd', + id: PRO_MONTHLY_CODE, lookup_key: PRO_MONTHLY_CODE, }, [PRO_YEARLY_CODE]: { unit_amount: 8100, currency: 'usd', + id: PRO_YEARLY_CODE, lookup_key: PRO_YEARLY_CODE, }, [AI_YEARLY_CODE]: { unit_amount: 10680, currency: 'usd', + id: AI_YEARLY_CODE, lookup_key: AI_YEARLY_CODE, }, -}; +} as any as Record; const sub: Stripe.Subscription = { id: 'sub_1', @@ -163,7 +116,11 @@ const sub: Stripe.Subscription = { canceled_at: null, current_period_end: 1745654236, current_period_start: 1714118236, - customer: 'cus_1', + // @ts-expect-error stub + customer: { + id: 'cus_1', + email: 'u1@affine.pro', + }, items: { object: 'list', data: [ @@ -184,59 +141,144 @@ const sub: Stripe.Subscription = { schedule: null, }; -// ============== prices ============== -test('should list normal price for unauthenticated user', async t => { - const { service, stripe } = t.context; +const test = ava as TestFn<{ + u1: CurrentUser; + db: PrismaClient; + app: INestApplication; + service: SubscriptionService; + event: Sinon.SinonStubbedInstance; + feature: Sinon.SinonStubbedInstance; + stripe: { + customers: Sinon.SinonStubbedInstance; + prices: Sinon.SinonStubbedInstance; + subscriptions: Sinon.SinonStubbedInstance; + subscriptionSchedules: Sinon.SinonStubbedInstance; + checkout: { + sessions: Sinon.SinonStubbedInstance; + }; + }; +}>; + +function getLastCheckoutPrice(checkoutStub: Sinon.SinonStub) { + const call = checkoutStub.getCall(checkoutStub.callCount - 1); + const arg = call.args[0] as Stripe.Checkout.SessionCreateParams; + return { + price: arg.line_items?.[0]?.price, + coupon: arg.discounts?.[0]?.coupon, + }; +} +test.before(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) + ); + m.overrideProvider(EventEmitter).useValue( + Sinon.createStubInstance(EventEmitter) + ); + }, + }); + + t.context.event = app.get(EventEmitter); + t.context.service = app.get(SubscriptionService); + t.context.feature = app.get(FeatureManagementService); + t.context.db = app.get(PrismaClient); + t.context.app = app; + + const stripe = app.get(Stripe); + const stripeStubs = { + customers: Sinon.stub(stripe.customers), + prices: Sinon.stub(stripe.prices), + subscriptions: Sinon.stub(stripe.subscriptions), + subscriptionSchedules: Sinon.stub(stripe.subscriptionSchedules), + checkout: { + sessions: Sinon.stub(stripe.checkout.sessions), + }, + }; + + t.context.stripe = stripeStubs; +}); + +test.beforeEach(async t => { + const { db, app, stripe } = t.context; + Sinon.reset(); + await initTestingDB(db); + // TODO(@forehalo): workaround for runtime module, need to init all runtime configs in [initTestingDB] + await app.get(Config).runtime.onModuleInit(); + t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1'); + + await db.userStripeCustomer.create({ + data: { + userId: t.context.u1.id, + stripeCustomerId: 'cus_1', + }, + }); + + // default stubs // @ts-expect-error stub - Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] }); + stripe.prices.list.callsFake((params: Stripe.PriceListParams) => { + if (params.lookup_keys) { + return Promise.resolve({ + data: params.lookup_keys.map(lk => PRICES[lk]), + }); + } + + return Promise.resolve({ data: Object.values(PRICES) }); + }); + // @ts-expect-error stub - Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) }); + stripe.subscriptions.list.resolves({ data: [] }); +}); + +test.after.always(async t => { + await t.context.app.close(); +}); + +// ============== prices ============== +test('should list normal price for unauthenticated user', async t => { + const { service } = t.context; const prices = await service.listPrices(); - t.deepEqual( - new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) - ); + t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey))); }); test('should list normal prices for authenticated user', async t => { - const { feature, service, u1, stripe } = t.context; + const { feature, service, u1 } = t.context; feature.isEarlyAccessUser.withArgs(u1.id).resolves(false); feature.isEarlyAccessUser.withArgs(u1.id, 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.deepEqual( - new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) - ); + t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey))); }); test('should list early access prices for pro ea user', async t => { - const { feature, service, u1, stripe } = t.context; + const { feature, service, u1 } = t.context; feature.isEarlyAccessUser.withArgs(u1.id).resolves(true); feature.isEarlyAccessUser.withArgs(u1.id, 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.deepEqual( - new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY]) - ); + t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey))); }); test('should list normal prices for pro ea user with old subscriptions', async t => { @@ -245,7 +287,7 @@ test('should list normal prices for pro ea user with old subscriptions', async t feature.isEarlyAccessUser.withArgs(u1.id).resolves(true); feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false); - Sinon.stub(stripe.subscriptions, 'list').resolves({ + stripe.subscriptions.list.resolves({ data: [ { id: 'sub_1', @@ -263,53 +305,31 @@ test('should list normal prices for pro ea user with old subscriptions', async t }, ], }); - // @ts-expect-error stub - Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) }); const prices = await service.listPrices(u1); - t.deepEqual( - new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) - ); + t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey))); }); test('should list early access prices for ai ea user', async t => { - const { feature, service, u1, stripe } = t.context; + const { feature, service, u1 } = t.context; feature.isEarlyAccessUser.withArgs(u1.id).resolves(false); feature.isEarlyAccessUser.withArgs(u1.id, 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.deepEqual( - new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY_EA]) - ); + t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey))); }); test('should list early access prices for pro and ai ea user', async t => { - const { feature, service, u1, stripe } = t.context; + const { feature, service, u1 } = t.context; feature.isEarlyAccessUser.withArgs(u1.id).resolves(true); - feature.isEarlyAccessUser.withArgs(u1.id, 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.deepEqual( - new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY_EA]) - ); + t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey))); }); test('should list normal prices for ai ea user with old subscriptions', async t => { @@ -318,7 +338,7 @@ test('should list normal prices for ai ea user with old subscriptions', async t feature.isEarlyAccessUser.withArgs(u1.id).resolves(false); feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(true); - Sinon.stub(stripe.subscriptions, 'list').resolves({ + stripe.subscriptions.list.resolves({ data: [ { id: 'sub_1', @@ -336,15 +356,10 @@ test('should list normal prices for ai ea user with old subscriptions', async t }, ], }); - // @ts-expect-error stub - Sinon.stub(stripe.prices, 'list').resolves({ data: Object.values(PRICES) }); const prices = await service.listPrices(u1); - t.deepEqual( - new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) - ); + t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey))); }); // ============= end prices ================ @@ -367,10 +382,12 @@ test('should throw if user has subscription already', async t => { await t.throwsAsync( () => - service.createCheckoutSession({ + service.checkout({ user: u1, - recurring: SubscriptionRecurring.Monthly, - plan: SubscriptionPlan.Pro, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + }, redirectUrl: '', idempotencyKey: '', }), @@ -379,34 +396,22 @@ test('should throw if user has subscription already', async t => { }); 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); - + const { app, service, u1, stripe, feature } = t.context; // 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, { + + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: PRO_MONTHLY, coupon: undefined, }); @@ -415,14 +420,17 @@ test('should get correct pro plan price for checking out', async t => { // 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, { + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: PRO_MONTHLY, coupon: undefined, }); @@ -431,14 +439,18 @@ test('should get correct pro plan price for checking out', async t => { // 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, { + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Yearly, + variant: SubscriptionVariant.EA, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: PRO_EA_YEARLY, coupon: CouponType.ProEarlyAccessOneYearFree, }); @@ -447,7 +459,7 @@ test('should get correct pro plan price for checking out', async t => { // ea user, yearly recurring, but has old subscription { feature.isEarlyAccessUser.resolves(true); - subListStub.resolves({ + stripe.subscriptions.list.resolves({ data: [ { id: 'sub_1', @@ -466,28 +478,56 @@ test('should get correct pro plan price for checking out', async t => { ], }); - const ret = await getAvailablePrice( - customer, - SubscriptionPlan.Pro, - SubscriptionRecurring.Yearly - ); - t.deepEqual(ret, { + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Yearly, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: PRO_YEARLY, coupon: undefined, }); + + await t.throwsAsync( + () => + service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Yearly, + variant: SubscriptionVariant.EA, + }, + redirectUrl: '', + idempotencyKey: '', + }), + { + message: 'You are trying to access a unknown subscription plan.', + } + ); } // any user, lifetime recurring { feature.isEarlyAccessUser.resolves(false); - // @ts-expect-error stub - subListStub.resolves({ data: [] }); - const ret = await getAvailablePrice( - customer, - SubscriptionPlan.Pro, - SubscriptionRecurring.Lifetime - ); - t.deepEqual(ret, { + const config = app.get(Config); + await config.runtime.set('plugins.payment/showLifetimePrice', true); + + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: PRO_LIFETIME, coupon: undefined, }); @@ -497,32 +537,21 @@ test('should get correct pro plan price for checking out', async t => { 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, { + + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: AI_YEARLY, coupon: undefined, }); @@ -531,14 +560,19 @@ test('should get correct ai plan price for checking out', async t => { // 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, { + + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + variant: SubscriptionVariant.EA, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: AI_YEARLY_EA, coupon: CouponType.AIEarlyAccessOneYearFree, }); @@ -546,8 +580,11 @@ test('should get correct ai plan price for checking out', async t => { // ea user, but has old subscription { - feature.isEarlyAccessUser.resolves(true); - subListStub.resolves({ + feature.isEarlyAccessUser.withArgs(u1.id).resolves(false); + feature.isEarlyAccessUser + .withArgs(u1.id, EarlyAccessType.AI) + .resolves(true); + stripe.subscriptions.list.resolves({ data: [ { id: 'sub_1', @@ -566,15 +603,37 @@ test('should get correct ai plan price for checking out', async t => { ], }); - const ret = await getAvailablePrice( - customer, - SubscriptionPlan.AI, - SubscriptionRecurring.Yearly - ); - t.deepEqual(ret, { + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: AI_YEARLY, coupon: undefined, }); + + await t.throwsAsync( + () => + service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + variant: SubscriptionVariant.EA, + }, + redirectUrl: '', + idempotencyKey: '', + }), + { + message: 'You are trying to access a unknown subscription plan.', + } + ); } // pro ea user @@ -584,13 +643,19 @@ test('should get correct ai plan price for checking out', async t => { .withArgs(u1.id, EarlyAccessType.AI) .resolves(false); // @ts-expect-error stub - subListStub.resolves({ data: [] }); - const ret = await getAvailablePrice( - customer, - SubscriptionPlan.AI, - SubscriptionRecurring.Yearly - ); - t.deepEqual(ret, { + stripe.subscriptions.list.resolves({ data: [] }); + + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: AI_YEARLY, coupon: CouponType.ProEarlyAccessAIOneYearFree, }); @@ -602,7 +667,7 @@ test('should get correct ai plan price for checking out', async t => { feature.isEarlyAccessUser .withArgs(u1.id, EarlyAccessType.AI) .resolves(false); - subListStub.resolves({ + stripe.subscriptions.list.resolves({ data: [ { id: 'sub_1', @@ -621,12 +686,17 @@ test('should get correct ai plan price for checking out', async t => { ], }); - const ret = await getAvailablePrice( - customer, - SubscriptionPlan.AI, - SubscriptionRecurring.Yearly - ); - t.deepEqual(ret, { + await service.checkout({ + user: u1, + lookupKey: { + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + }, + redirectUrl: '', + idempotencyKey: '', + }); + + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { price: AI_YEARLY, coupon: undefined, }); @@ -636,84 +706,65 @@ test('should get correct ai plan price for checking out', async t => { 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'); + Sinon.stub(service, 'getCouponFromPromotionCode').resolves('coupon_1'); - await service.createCheckoutSession({ + await service.checkout({ user: u1, - recurring: SubscriptionRecurring.Monthly, - plan: SubscriptionPlan.Pro, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + }, 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' }]); + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { + price: PRO_MONTHLY, + coupon: 'coupon_1', + }); }); // =============== subscriptions =============== - test('should be able to create subscription', async t => { - const { event, service, stripe, db, u1 } = t.context; + const { event, service, db, u1 } = t.context; + + await service.saveStripeSubscription(sub); + + const subInDB = await db.userSubscription.findFirst({ + where: { userId: u1.id }, + }); - const emitStub = Sinon.stub(event, 'emit').returns(true); - Sinon.stub(stripe.subscriptions, 'retrieve').resolves(sub as any); - await service.onSubscriptionChanges(sub); t.true( - emitStub.calledOnceWith('user.subscription.activated', { + event.emit.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Monthly, }) ); - - 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 { event, 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); + const { event, service, db, u1 } = t.context; + await service.saveStripeSubscription(sub); - const emitStub = Sinon.stub(event, 'emit').returns(true); - stub.resolves({ + await service.saveStripeSubscription({ ...sub, cancel_at_period_end: true, canceled_at: 1714118236, - } as any); - await service.onSubscriptionChanges(sub); + }); + t.true( - emitStub.calledOnceWith('user.subscription.activated', { + event.emit.calledWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Monthly, }) ); - subInDB = await db.userSubscription.findFirst({ + const subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); @@ -722,31 +773,23 @@ test('should be able to update subscription', async t => { }); test('should be able to delete subscription', async t => { - const { event, service, stripe, db, u1 } = t.context; + const { event, service, db, u1 } = t.context; + await service.saveStripeSubscription(sub); - 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 }, + await service.saveStripeSubscription({ + ...sub, + status: 'canceled', }); - t.is(subInDB?.stripeSubscriptionId, sub.id); - - const emitStub = Sinon.stub(event, 'emit').returns(true); - stub.resolves({ ...sub, status: 'canceled' } as any); - await service.onSubscriptionChanges(sub); t.true( - emitStub.calledOnceWith('user.subscription.canceled', { + event.emit.calledWith('user.subscription.canceled', { userId: u1.id, plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Monthly, }) ); - subInDB = await db.userSubscription.findFirst({ + const subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); @@ -754,7 +797,7 @@ test('should be able to delete subscription', async t => { }); test('should be able to cancel subscription', async t => { - const { event, service, db, u1, stripe } = t.context; + const { service, db, u1, stripe } = t.context; await db.userSubscription.create({ data: { @@ -768,35 +811,25 @@ test('should be able to cancel subscription', async t => { }, }); - const stub = Sinon.stub(stripe.subscriptions, 'update').resolves({ + stripe.subscriptions.update.resolves({ ...sub, cancel_at_period_end: true, canceled_at: 1714118236, } as any); - const emitStub = Sinon.stub(event, 'emit').returns(true); - const subInDB = await service.cancelSubscription( - '', - u1.id, - SubscriptionPlan.Pro - ); - // we will cancel the subscription at the end of the period - // so in cancel event, we still emit the activated event + const subInDB = await service.cancelSubscription(u1.id, SubscriptionPlan.Pro); + t.true( - emitStub.calledOnceWith('user.subscription.activated', { - userId: u1.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Monthly, + stripe.subscriptions.update.calledOnceWith('sub_1', { + cancel_at_period_end: true, }) ); - - 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 { event, service, db, u1, stripe } = t.context; + const { service, db, u1, stripe } = t.context; await db.userSubscription.create({ data: { @@ -811,23 +844,15 @@ test('should be able to resume subscription', async t => { }, }); - const stub = Sinon.stub(stripe.subscriptions, 'update').resolves(sub as any); + stripe.subscriptions.update.resolves(sub as any); + + const subInDB = await service.resumeSubscription(u1.id, SubscriptionPlan.Pro); - const emitStub = Sinon.stub(event, 'emit').returns(true); - const subInDB = await service.resumeCanceledSubscription( - '', - u1.id, - SubscriptionPlan.Pro - ); t.true( - emitStub.calledOnceWith('user.subscription.activated', { - userId: u1.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Monthly, + stripe.subscriptions.update.calledOnceWith('sub_1', { + cancel_at_period_end: false, }) ); - - t.true(stub.calledOnceWith('sub_1', { cancel_at_period_end: false })); t.is(subInDB.status, SubscriptionStatus.Active); t.falsy(subInDB.canceledAt); }); @@ -868,26 +893,21 @@ test('should be able to update recurring', async t => { // 1. turn a subscription into a subscription schedule // 2. update the schedule - // 2.1 update the current phase with an end date + // 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'); + stripe.subscriptions.retrieve.resolves(sub as any); + stripe.subscriptionSchedules.create.resolves(subscriptionSchedule as any); + stripe.subscriptionSchedules.update.resolves(subscriptionSchedule as any); await service.updateSubscriptionRecurring( - '', u1.id, SubscriptionPlan.Pro, SubscriptionRecurring.Yearly ); - t.true(stub.calledOnce); - const arg = stub.firstCall.args; + t.true(stripe.subscriptionSchedules.update.calledOnce); + const arg = stripe.subscriptionSchedules.update.firstCall.args; t.is(arg[0], subscriptionSchedule.id); t.deepEqual(arg[1], { phases: [ @@ -927,46 +947,21 @@ test('should release the schedule if the new recurring is the same as the curren }, }); - // @ts-expect-error private member - Sinon.stub(service, 'getPrice').resolves(PRO_MONTHLY); - Sinon.stub(stripe.subscriptions, 'retrieve').resolves({ + stripe.subscriptions.retrieve.resolves({ ...sub, - schedule: subscriptionSchedule, + schedule: subscriptionSchedule.id, } as any); - Sinon.stub(stripe.subscriptionSchedules, 'retrieve').resolves( - subscriptionSchedule as any - ); - const stub = Sinon.stub(stripe.subscriptionSchedules, 'release'); + stripe.subscriptionSchedules.retrieve.resolves(subscriptionSchedule as any); 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); + t.true( + stripe.subscriptionSchedules.release.calledOnceWith(subscriptionSchedule.id) + ); }); // ============== Lifetime Subscription =============== @@ -976,12 +971,13 @@ const lifetimeInvoice: Stripe.Invoice = { amount_paid: 49900, total: 49900, customer: 'cus_1', + customer_email: 'u1@affine.pro', currency: 'usd', status: 'paid', lines: { data: [ + // @ts-expect-error stub { - // @ts-expect-error stub price: PRICES[PRO_LIFETIME], }, ], @@ -994,12 +990,13 @@ const onetimeMonthlyInvoice: Stripe.Invoice = { amount_paid: 799, total: 799, customer: 'cus_1', + customer_email: 'u1@affine.pro', currency: 'usd', status: 'paid', lines: { data: [ + // @ts-expect-error stub { - // @ts-expect-error stub price: PRICES[PRO_MONTHLY_CODE], }, ], @@ -1012,12 +1009,13 @@ const onetimeYearlyInvoice: Stripe.Invoice = { amount_paid: 8100, total: 8100, customer: 'cus_1', + customer_email: 'u1@affine.pro', currency: 'usd', status: 'paid', lines: { data: [ + // @ts-expect-error stub { - // @ts-expect-error stub price: PRICES[PRO_YEARLY_CODE], }, ], @@ -1025,42 +1023,43 @@ const onetimeYearlyInvoice: Stripe.Invoice = { }; test('should not be able to checkout for lifetime recurring if not enabled', async t => { - const { service, stripe, u1 } = t.context; + const { service, u1, app } = t.context; + await app.get(Config).runtime.set('plugins.payment/showLifetimePrice', false); - Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] } as any); await t.throwsAsync( () => - service.createCheckoutSession({ + service.checkout({ user: u1, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Lifetime, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }, redirectUrl: '', idempotencyKey: '', }), - { message: 'You are not allowed to perform this action.' } + { message: 'You are trying to access a unknown subscription plan.' } ); }); test('should be able to checkout for lifetime recurring', async t => { - const { service, stripe, u1, app } = t.context; + const { service, u1, app, stripe } = t.context; const config = app.get(Config); await config.runtime.set('plugins.payment/showLifetimePrice', true); - Sinon.stub(stripe.subscriptions, 'list').resolves({ data: [] } as any); - Sinon.stub(stripe.prices, 'list').resolves({ - data: [PRICES[PRO_LIFETIME]], - } as any); - const sessionStub = Sinon.stub(stripe.checkout.sessions, 'create'); - - await service.createCheckoutSession({ + await service.checkout({ user: u1, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Lifetime, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }, redirectUrl: '', idempotencyKey: '', }); - t.true(sessionStub.calledOnce); + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { + price: PRO_LIFETIME, + coupon: undefined, + }); }); test('should not be able to checkout for lifetime recurring if already subscribed', async t => { @@ -1079,10 +1078,12 @@ test('should not be able to checkout for lifetime recurring if already subscribe await t.throwsAsync( () => - service.createCheckoutSession({ + service.checkout({ user: u1, - recurring: SubscriptionRecurring.Lifetime, - plan: SubscriptionPlan.Pro, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }, redirectUrl: '', idempotencyKey: '', }), @@ -1101,10 +1102,12 @@ test('should not be able to checkout for lifetime recurring if already subscribe await t.throwsAsync( () => - service.createCheckoutSession({ + service.checkout({ user: u1, - recurring: SubscriptionRecurring.Lifetime, - plan: SubscriptionPlan.Pro, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }, redirectUrl: '', idempotencyKey: '', }), @@ -1114,18 +1117,16 @@ test('should not be able to checkout for lifetime recurring if already subscribe test('should be able to subscribe to lifetime recurring', async t => { // lifetime payment isn't a subscription, so we need to trigger the creation by invoice payment event - const { service, stripe, db, u1, event } = t.context; + const { service, db, u1, event } = t.context; - const emitStub = Sinon.stub(event, 'emit'); - Sinon.stub(stripe.invoices, 'retrieve').resolves(lifetimeInvoice as any); - await service.saveInvoice(lifetimeInvoice, 'invoice.payment_succeeded'); + await service.saveStripeInvoice(lifetimeInvoice); const subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); t.true( - emitStub.calledOnceWith('user.subscription.activated', { + event.emit.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Lifetime, @@ -1152,17 +1153,15 @@ test('should be able to subscribe to lifetime recurring with old subscription', }, }); - const emitStub = Sinon.stub(event, 'emit'); - Sinon.stub(stripe.invoices, 'retrieve').resolves(lifetimeInvoice as any); - Sinon.stub(stripe.subscriptions, 'cancel').resolves(sub as any); - await service.saveInvoice(lifetimeInvoice, 'invoice.payment_succeeded'); + stripe.subscriptions.cancel.resolves(sub as any); + await service.saveStripeInvoice(lifetimeInvoice); const subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); t.true( - emitStub.calledOnceWith('user.subscription.activated', { + event.emit.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Lifetime, @@ -1174,7 +1173,7 @@ test('should be able to subscribe to lifetime recurring with old subscription', t.is(subInDB?.stripeSubscriptionId, null); }); -test('should not be able to update lifetime recurring', async t => { +test('should not be able to cancel lifetime subscription', async t => { const { service, db, u1 } = t.context; await db.userSubscription.create({ @@ -1189,53 +1188,59 @@ test('should not be able to update lifetime recurring', async t => { }); await t.throwsAsync( - () => service.cancelSubscription('', u1.id, SubscriptionPlan.Pro), + () => service.cancelSubscription(u1.id, SubscriptionPlan.Pro), { message: 'Onetime payment subscription cannot be canceled.' } ); +}); + +test('should not be able to update lifetime recurring', async t => { + const { service, db, u1 } = t.context; + + await db.userSubscription.create({ + data: { + userId: u1.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + status: SubscriptionStatus.Active, + start: new Date(), + end: new Date(), + }, + }); await t.throwsAsync( () => service.updateSubscriptionRecurring( - '', u1.id, SubscriptionPlan.Pro, SubscriptionRecurring.Monthly ), { message: 'You cannot update an onetime payment subscription.' } ); - - await t.throwsAsync( - () => service.resumeCanceledSubscription('', u1.id, SubscriptionPlan.Pro), - { message: 'Onetime payment subscription cannot be resumed.' } - ); }); // ============== Onetime Subscription =============== test('should be able to checkout for onetime payment', 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_CODE, - coupon: undefined, - }); - - await service.createCheckoutSession({ + await service.checkout({ user: u1, - recurring: SubscriptionRecurring.Monthly, - plan: SubscriptionPlan.Pro, - variant: SubscriptionVariant.Onetime, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + variant: SubscriptionVariant.Onetime, + }, redirectUrl: '', idempotencyKey: '', }); - t.true(checkoutStub.calledOnce); - const arg = checkoutStub.firstCall + t.true(stripe.checkout.sessions.create.calledOnce); + const arg = stripe.checkout.sessions.create.firstCall .args[0] as Stripe.Checkout.SessionCreateParams; t.is(arg.mode, 'payment'); - t.is(arg.line_items?.[0].price, PRO_MONTHLY_CODE); + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { + price: PRO_MONTHLY_CODE, + coupon: undefined, + }); }); test('should be able to checkout onetime payment if previous subscription is onetime', async t => { @@ -1254,28 +1259,25 @@ test('should be able to checkout onetime payment if previous subscription is one }, }); - 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_CODE, - coupon: undefined, - }); - - await service.createCheckoutSession({ + await service.checkout({ user: u1, - recurring: SubscriptionRecurring.Monthly, - plan: SubscriptionPlan.Pro, - variant: SubscriptionVariant.Onetime, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + variant: SubscriptionVariant.Onetime, + }, redirectUrl: '', idempotencyKey: '', }); - t.true(checkoutStub.calledOnce); - const arg = checkoutStub.firstCall + t.true(stripe.checkout.sessions.create.calledOnce); + const arg = stripe.checkout.sessions.create.firstCall .args[0] as Stripe.Checkout.SessionCreateParams; t.is(arg.mode, 'payment'); - t.is(arg.line_items?.[0].price, PRO_MONTHLY_CODE); + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { + price: PRO_MONTHLY_CODE, + coupon: undefined, + }); }); test('should not be able to checkout out onetime payment if previous subscription is not onetime', async t => { @@ -1295,11 +1297,13 @@ test('should not be able to checkout out onetime payment if previous subscriptio await t.throwsAsync( () => - service.createCheckoutSession({ + service.checkout({ user: u1, - recurring: SubscriptionRecurring.Monthly, - plan: SubscriptionPlan.Pro, - variant: SubscriptionVariant.Onetime, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + variant: SubscriptionVariant.Onetime, + }, redirectUrl: '', idempotencyKey: '', }), @@ -1316,11 +1320,13 @@ test('should not be able to checkout out onetime payment if previous subscriptio await t.throwsAsync( () => - service.createCheckoutSession({ + service.checkout({ user: u1, - recurring: SubscriptionRecurring.Monthly, - plan: SubscriptionPlan.Pro, - variant: SubscriptionVariant.Onetime, + lookupKey: { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + variant: SubscriptionVariant.Onetime, + }, redirectUrl: '', idempotencyKey: '', }), @@ -1329,20 +1335,16 @@ test('should not be able to checkout out onetime payment if previous subscriptio }); test('should be able to subscribe onetime payment subscription', async t => { - const { service, stripe, db, u1, event } = t.context; + const { service, db, u1, event } = t.context; - const emitStub = Sinon.stub(event, 'emit'); - Sinon.stub(stripe.invoices, 'retrieve').resolves( - onetimeMonthlyInvoice as any - ); - await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded'); + await service.saveStripeInvoice(onetimeMonthlyInvoice); const subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); t.true( - emitStub.calledOnceWith('user.subscription.activated', { + event.emit.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Monthly, @@ -1359,12 +1361,9 @@ test('should be able to subscribe onetime payment subscription', async t => { }); test('should be able to recalculate onetime payment subscription period', async t => { - const { service, stripe, db, u1 } = t.context; + const { service, db, u1 } = t.context; - const stub = Sinon.stub(stripe.invoices, 'retrieve').resolves( - onetimeMonthlyInvoice as any - ); - await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded'); + await service.saveStripeInvoice(onetimeMonthlyInvoice); let subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, @@ -1373,7 +1372,7 @@ test('should be able to recalculate onetime payment subscription period', async t.truthy(subInDB); let end = subInDB!.end!; - await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded'); + await service.saveStripeInvoice(onetimeMonthlyInvoice); subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); @@ -1382,8 +1381,7 @@ test('should be able to recalculate onetime payment subscription period', async t.is(subInDB!.end!.getTime(), end.getTime() + 30 * 24 * 60 * 60 * 1000); end = subInDB!.end!; - stub.resolves(onetimeYearlyInvoice as any); - await service.saveInvoice(onetimeYearlyInvoice, 'invoice.payment_succeeded'); + await service.saveStripeInvoice(onetimeYearlyInvoice); subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); @@ -1398,7 +1396,7 @@ test('should be able to recalculate onetime payment subscription period', async end: new Date(Date.now() - 1000), }, }); - await service.saveInvoice(onetimeYearlyInvoice, 'invoice.payment_succeeded'); + await service.saveStripeInvoice(onetimeYearlyInvoice); subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, }); diff --git a/packages/backend/server/tests/payment/snapshots/service.spec.ts.md b/packages/backend/server/tests/payment/snapshots/service.spec.ts.md new file mode 100644 index 0000000000000..b10c48de7847f --- /dev/null +++ b/packages/backend/server/tests/payment/snapshots/service.spec.ts.md @@ -0,0 +1,75 @@ +# Snapshot report for `tests/payment/service.spec.ts` + +The actual snapshot is saved in `service.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should list normal price for unauthenticated user + +> Snapshot 1 + + [ + 'pro_monthly', + 'pro_yearly', + 'ai_yearly', + ] + +## should list normal prices for authenticated user + +> Snapshot 1 + + [ + 'pro_monthly', + 'pro_yearly', + 'ai_yearly', + ] + +## should list early access prices for pro ea user + +> Snapshot 1 + + [ + 'pro_monthly', + 'pro_yearly_earlyaccess', + 'ai_yearly', + ] + +## should list normal prices for pro ea user with old subscriptions + +> Snapshot 1 + + [ + 'pro_monthly', + 'pro_yearly', + 'ai_yearly', + ] + +## should list early access prices for ai ea user + +> Snapshot 1 + + [ + 'pro_monthly', + 'pro_yearly', + 'ai_yearly_earlyaccess', + ] + +## should list early access prices for pro and ai ea user + +> Snapshot 1 + + [ + 'pro_monthly', + 'pro_yearly_earlyaccess', + 'ai_yearly_earlyaccess', + ] + +## should list normal prices for ai ea user with old subscriptions + +> Snapshot 1 + + [ + 'pro_monthly', + 'pro_yearly', + 'ai_yearly', + ] diff --git a/packages/backend/server/tests/payment/snapshots/service.spec.ts.snap b/packages/backend/server/tests/payment/snapshots/service.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..e442c5ca70755a9d26668bd6e316a5340b704151 GIT binary patch literal 361 zcmV-v0hazjRzVdC+0;0B$Iovn=wQ^tDJL#7u`rs0u+GoP zh}3JT#LRh-S;|79Y|xT|ibclCYqpef;sQS{s*h&>pEP!CdK{v&KAb2=JF~!aj!!noQVZz zQ