From de91027852f870b839109cd110715e19ab09c9bd Mon Sep 17 00:00:00 2001 From: forehalo Date: Mon, 8 Jul 2024 07:41:26 +0000 Subject: [PATCH] feat(server): support lifetime subscription (#7405) closes CLOUD-48 - [x] lifetime subscription quota - [ ] tests --- .../migration.sql | 3 + packages/backend/server/schema.prisma | 8 +- .../backend/server/src/core/quota/schema.ts | 20 ++ .../backend/server/src/core/quota/service.ts | 30 +- .../backend/server/src/core/quota/types.ts | 2 + .../1719917815802-lifetime-pro-quota.ts | 14 + .../server/src/fundamentals/error/def.ts | 4 + .../src/fundamentals/error/errors.gen.ts | 7 + .../server/src/plugins/payment/resolver.ts | 19 +- .../server/src/plugins/payment/service.ts | 292 +++++++++++------- .../server/src/plugins/payment/types.ts | 3 + .../server/src/plugins/payment/webhook.ts | 13 +- packages/backend/server/src/schema.gql | 7 +- .../server/tests/payment/service.spec.ts | 165 +++++++++- .../general-setting/billing/index.tsx | 3 +- packages/frontend/graphql/src/schema.ts | 21 +- packages/frontend/i18n/src/resources/en.json | 1 + 17 files changed, 447 insertions(+), 165 deletions(-) create mode 100644 packages/backend/server/migrations/20240416042935_lifetime_subscription/migration.sql create mode 100644 packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts diff --git a/packages/backend/server/migrations/20240416042935_lifetime_subscription/migration.sql b/packages/backend/server/migrations/20240416042935_lifetime_subscription/migration.sql new file mode 100644 index 0000000000..91b2adf2d2 --- /dev/null +++ b/packages/backend/server/migrations/20240416042935_lifetime_subscription/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "user_subscriptions" ALTER COLUMN "stripe_subscription_id" DROP NOT NULL, +ALTER COLUMN "end" DROP NOT NULL; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 7ffd1f13d3..2d9be52c88 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -377,14 +377,14 @@ model UserSubscription { plan String @db.VarChar(20) // yearly/monthly recurring String @db.VarChar(20) - // subscription.id - stripeSubscriptionId String @unique @map("stripe_subscription_id") + // subscription.id, null for linefetime payment + stripeSubscriptionId String? @unique @map("stripe_subscription_id") // subscription.status, active/past_due/canceled/unpaid... status String @db.VarChar(20) // subscription.current_period_start start DateTime @map("start") @db.Timestamptz(6) - // subscription.current_period_end - end DateTime @map("end") @db.Timestamptz(6) + // subscription.current_period_end, null for lifetime payment + end DateTime? @map("end") @db.Timestamptz(6) // subscription.billing_cycle_anchor nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6) // subscription.canceled_at diff --git a/packages/backend/server/src/core/quota/schema.ts b/packages/backend/server/src/core/quota/schema.ts index 6dc45f0fbd..2a7a8d6e6e 100644 --- a/packages/backend/server/src/core/quota/schema.ts +++ b/packages/backend/server/src/core/quota/schema.ts @@ -155,6 +155,25 @@ export const Quotas: Quota[] = [ copilotActionLimit: 10, }, }, + { + feature: QuotaType.LifetimeProPlanV1, + type: FeatureKind.Quota, + version: 1, + configs: { + // quota name + name: 'Lifetime Pro', + // single blob limit 100MB + blobLimit: 100 * OneMB, + // total blob limit 1TB + storageQuota: 1024 * OneGB, + // history period of validity 30 days + historyPeriod: 30 * OneDay, + // member limit 10 + memberLimit: 10, + // copilot action limit 10 + copilotActionLimit: 10, + }, + }, ]; export function getLatestQuota(type: QuotaType) { @@ -165,6 +184,7 @@ export function getLatestQuota(type: QuotaType) { export const FreePlan = getLatestQuota(QuotaType.FreePlanV1); export const ProPlan = getLatestQuota(QuotaType.ProPlanV1); +export const LifetimeProPlan = getLatestQuota(QuotaType.LifetimeProPlanV1); export const Quota_FreePlanV1_1 = { feature: Quotas[5].feature, diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index 5b6e6094cd..4e2a34c840 100644 --- a/packages/backend/server/src/core/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -3,7 +3,6 @@ import { PrismaClient } from '@prisma/client'; import type { EventPayload } from '../../fundamentals'; import { OnEvent, PrismaTransaction } from '../../fundamentals'; -import { SubscriptionPlan } from '../../plugins/payment/types'; import { FeatureManagementService } from '../features/management'; import { FeatureKind } from '../features/types'; import { QuotaConfig } from './quota'; @@ -152,15 +151,18 @@ export class QuotaService { async onSubscriptionUpdated({ userId, plan, + recurring, }: EventPayload<'user.subscription.activated'>) { switch (plan) { - case SubscriptionPlan.AI: + case 'ai': await this.feature.addCopilot(userId, 'subscription activated'); break; - case SubscriptionPlan.Pro: + case 'pro': await this.switchUserQuota( userId, - QuotaType.ProPlanV1, + recurring === 'lifetime' + ? QuotaType.LifetimeProPlanV1 + : QuotaType.ProPlanV1, 'subscription activated' ); break; @@ -175,16 +177,22 @@ export class QuotaService { plan, }: EventPayload<'user.subscription.canceled'>) { switch (plan) { - case SubscriptionPlan.AI: + case 'ai': await this.feature.removeCopilot(userId); break; - case SubscriptionPlan.Pro: - await this.switchUserQuota( - userId, - QuotaType.FreePlanV1, - 'subscription canceled' - ); + case 'pro': { + // edge case: when user switch from recurring Pro plan to `Lifetime` plan, + // a subscription canceled event will be triggered because `Lifetime` plan is not subscription based + const quota = await this.getUserQuota(userId); + if (quota.feature.name !== QuotaType.LifetimeProPlanV1) { + await this.switchUserQuota( + userId, + QuotaType.FreePlanV1, + 'subscription canceled' + ); + } break; + } default: break; } diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index 800b87f751..ef21f05271 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -17,6 +17,7 @@ import { ByteUnit, OneDay, OneKB } from './constant'; export enum QuotaType { FreePlanV1 = 'free_plan_v1', ProPlanV1 = 'pro_plan_v1', + LifetimeProPlanV1 = 'lifetime_pro_plan_v1', // only for test, smaller quota RestrictedPlanV1 = 'restricted_plan_v1', } @@ -25,6 +26,7 @@ const quotaPlan = z.object({ feature: z.enum([ QuotaType.FreePlanV1, QuotaType.ProPlanV1, + QuotaType.LifetimeProPlanV1, QuotaType.RestrictedPlanV1, ]), configs: z.object({ diff --git a/packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts b/packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts new file mode 100644 index 0000000000..a673b7f466 --- /dev/null +++ b/packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from '@prisma/client'; + +import { QuotaType } from '../../core/quota'; +import { upsertLatestQuotaVersion } from './utils/user-quotas'; + +export class LifetimeProQuota1719917815802 { + // do the migration + static async up(db: PrismaClient) { + await upsertLatestQuotaVersion(db, QuotaType.LifetimeProPlanV1); + } + + // revert the migration + static async down(_db: PrismaClient) {} +} diff --git a/packages/backend/server/src/fundamentals/error/def.ts b/packages/backend/server/src/fundamentals/error/def.ts index 46ff613740..c4c549f91a 100644 --- a/packages/backend/server/src/fundamentals/error/def.ts +++ b/packages/backend/server/src/fundamentals/error/def.ts @@ -408,6 +408,10 @@ export const USER_FRIENDLY_ERRORS = { args: { plan: 'string', recurring: 'string' }, message: 'You are trying to access a unknown subscription plan.', }, + cant_update_lifetime_subscription: { + type: 'action_forbidden', + message: 'You cannot update a lifetime subscription.', + }, // Copilot errors copilot_session_not_found: { diff --git a/packages/backend/server/src/fundamentals/error/errors.gen.ts b/packages/backend/server/src/fundamentals/error/errors.gen.ts index 5d918aac87..bbdcb9694d 100644 --- a/packages/backend/server/src/fundamentals/error/errors.gen.ts +++ b/packages/backend/server/src/fundamentals/error/errors.gen.ts @@ -350,6 +350,12 @@ export class SubscriptionPlanNotFound extends UserFriendlyError { } } +export class CantUpdateLifetimeSubscription extends UserFriendlyError { + constructor(message?: string) { + super('action_forbidden', 'cant_update_lifetime_subscription', message); + } +} + export class CopilotSessionNotFound extends UserFriendlyError { constructor(message?: string) { super('resource_not_found', 'copilot_session_not_found', message); @@ -521,6 +527,7 @@ export enum ErrorNames { SAME_SUBSCRIPTION_RECURRING, CUSTOMER_PORTAL_CREATE_FAILED, SUBSCRIPTION_PLAN_NOT_FOUND, + CANT_UPDATE_LIFETIME_SUBSCRIPTION, COPILOT_SESSION_NOT_FOUND, COPILOT_SESSION_DELETED, NO_COPILOT_PROVIDER_AVAILABLE, diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 3223642eff..46a1a7e5b8 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -53,12 +53,15 @@ class SubscriptionPrice { @Field(() => Int, { nullable: true }) yearlyAmount?: number | null; + + @Field(() => Int, { nullable: true }) + lifetimeAmount?: number | null; } @ObjectType('UserSubscription') export class UserSubscriptionType implements Partial { - @Field({ name: 'id' }) - stripeSubscriptionId!: string; + @Field(() => String, { name: 'id', nullable: true }) + stripeSubscriptionId!: string | null; @Field(() => SubscriptionPlan, { description: @@ -75,8 +78,8 @@ export class UserSubscriptionType implements Partial { @Field(() => Date) start!: Date; - @Field(() => Date) - end!: Date; + @Field(() => Date, { nullable: true }) + end!: Date | null; @Field(() => Date, { nullable: true }) trialStart?: Date | null; @@ -187,11 +190,19 @@ export class SubscriptionResolver { const monthlyPrice = prices.find(p => p.recurring?.interval === 'month'); const yearlyPrice = prices.find(p => p.recurring?.interval === 'year'); + const lifetimePrice = prices.find( + p => + // asserted before + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + decodeLookupKey(p.lookup_key!)[1] === SubscriptionRecurring.Lifetime + ); const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd'; + return { currency, amount: monthlyPrice?.unit_amount, yearlyAmount: yearlyPrice?.unit_amount, + lifetimeAmount: lifetimePrice?.unit_amount, }; } diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 0320f3202c..4c15da5b2d 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; import { OnEvent as RawOnEvent } from '@nestjs/event-emitter'; import type { - Prisma, User, UserInvoice, UserStripeCustomer, @@ -16,6 +15,7 @@ import { CurrentUser } from '../../core/auth'; import { EarlyAccessType, FeatureManagementService } from '../../core/features'; import { ActionForbidden, + CantUpdateLifetimeSubscription, Config, CustomerPortalCreateFailed, EventEmitter, @@ -131,7 +131,11 @@ export class SubscriptionService { } const [plan, recurring, variant] = decodeLookupKey(price.lookup_key); - if (recurring === SubscriptionRecurring.Monthly) { + // no variant price should be used for monthly or lifetime subscription + if ( + recurring === SubscriptionRecurring.Monthly || + recurring === SubscriptionRecurring.Lifetime + ) { return !variant; } @@ -184,7 +188,12 @@ export class SubscriptionService { }, }); - if (currentSubscription) { + if ( + currentSubscription && + // do not allow to re-subscribe unless the new recurring is `Lifetime` + (currentSubscription.recurring === recurring || + recurring !== SubscriptionRecurring.Lifetime) + ) { throw new SubscriptionAlreadyExists({ plan }); } @@ -224,8 +233,19 @@ export class SubscriptionService { tax_id_collection: { enabled: true, }, + // discount ...(discounts.length ? { discounts } : { allow_promotion_codes: true }), - mode: 'subscription', + // mode: 'subscription' or 'payment' for lifetime + ...(recurring === SubscriptionRecurring.Lifetime + ? { + mode: 'payment', + invoice_creation: { + enabled: true, + }, + } + : { + mode: 'subscription', + }), success_url: redirectUrl, customer: customer.stripeCustomerId, customer_update: { @@ -264,6 +284,12 @@ export class SubscriptionService { throw new SubscriptionNotExists({ plan }); } + if (!subscriptionInDB.stripeSubscriptionId) { + throw new CantUpdateLifetimeSubscription( + 'Lifetime subscription cannot be canceled.' + ); + } + if (subscriptionInDB.canceledAt) { throw new SubscriptionHasBeenCanceled(); } @@ -315,6 +341,12 @@ export class SubscriptionService { throw new SubscriptionNotExists({ plan }); } + if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) { + throw new CantUpdateLifetimeSubscription( + 'Lifetime subscription cannot be resumed.' + ); + } + if (!subscriptionInDB.canceledAt) { throw new SubscriptionHasBeenCanceled(); } @@ -368,6 +400,12 @@ export class SubscriptionService { throw new SubscriptionNotExists({ plan }); } + if (!subscriptionInDB.stripeSubscriptionId) { + throw new CantUpdateLifetimeSubscription( + 'Can not update lifetime subscription.' + ); + } + if (subscriptionInDB.canceledAt) { throw new SubscriptionHasBeenCanceled(); } @@ -422,60 +460,12 @@ export class SubscriptionService { } } - @OnStripeEvent('customer.subscription.created') - @OnStripeEvent('customer.subscription.updated') - async onSubscriptionChanges(subscription: Stripe.Subscription) { - subscription = await this.stripe.subscriptions.retrieve(subscription.id); - if (subscription.status === 'active') { - const user = await this.retrieveUserFromCustomer( - typeof subscription.customer === 'string' - ? subscription.customer - : subscription.customer.id - ); - - await this.saveSubscription(user, subscription); - } else { - await this.onSubscriptionDeleted(subscription); - } - } - - @OnStripeEvent('customer.subscription.deleted') - async onSubscriptionDeleted(subscription: Stripe.Subscription) { - const user = await this.retrieveUserFromCustomer( - typeof subscription.customer === 'string' - ? subscription.customer - : subscription.customer.id - ); - - const [plan] = this.decodePlanFromSubscription(subscription); - this.event.emit('user.subscription.canceled', { - userId: user.id, - plan, - }); - - await this.db.userSubscription.deleteMany({ - where: { - stripeSubscriptionId: subscription.id, - }, - }); - } - - @OnStripeEvent('invoice.paid') - async onInvoicePaid(stripeInvoice: Stripe.Invoice) { - stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id); - await this.saveInvoice(stripeInvoice); - - const line = stripeInvoice.lines.data[0]; - - if (!line.price || line.price.type !== 'recurring') { - throw new Error('Unknown invoice with no recurring price'); - } - } - @OnStripeEvent('invoice.created') + @OnStripeEvent('invoice.updated') @OnStripeEvent('invoice.finalization_failed') @OnStripeEvent('invoice.payment_failed') - async saveInvoice(stripeInvoice: Stripe.Invoice) { + @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'); @@ -487,12 +477,6 @@ export class SubscriptionService { : stripeInvoice.customer.id ); - const invoice = await this.db.userInvoice.findUnique({ - where: { - stripeInvoiceId: stripeInvoice.id, - }, - }); - const data: Partial = { currency: stripeInvoice.currency, amount: stripeInvoice.total, @@ -524,39 +508,135 @@ export class SubscriptionService { } } - // update invoice - if (invoice) { - await this.db.userInvoice.update({ - where: { - stripeInvoiceId: stripeInvoice.id, + // create invoice + const price = stripeInvoice.lines.data[0].price; + + if (!price) { + throw new Error('Unexpected invoice with no price'); + } + + if (!price.lookup_key) { + throw new Error('Unexpected subscription with no key'); + } + + const [plan, recurring] = decodeLookupKey(price.lookup_key); + + const invoice = await this.db.userInvoice.upsert({ + where: { + stripeInvoiceId: stripeInvoice.id, + }, + update: data, + create: { + userId: user.id, + stripeInvoiceId: stripeInvoice.id, + plan, + recurring, + reason: stripeInvoice.billing_reason ?? 'contact support', + ...(data as any), + }, + }); + + // handle one time payment, no subscription created by stripe + if ( + event === 'invoice.payment_succeeded' && + recurring === SubscriptionRecurring.Lifetime && + stripeInvoice.status === 'paid' + ) { + await this.saveLifetimeSubscription(user, invoice); + } + } + + async saveLifetimeSubscription(user: User, invoice: UserInvoice) { + // cancel previous non-lifetime subscription + const savedSubscription = await this.db.userSubscription.findUnique({ + where: { + userId_plan: { + userId: user.id, + plan: SubscriptionPlan.Pro, + }, + }, + }); + + if (savedSubscription && savedSubscription.stripeSubscriptionId) { + await this.db.userSubscription.update({ + where: { + id: savedSubscription.id, + }, + data: { + stripeScheduleId: null, + stripeSubscriptionId: null, + status: SubscriptionStatus.Active, + recurring: SubscriptionRecurring.Lifetime, + end: null, }, - data, }); + + await this.stripe.subscriptions.cancel( + savedSubscription.stripeSubscriptionId, + { + prorate: true, + } + ); } else { - // create invoice - const price = stripeInvoice.lines.data[0].price; - - if (!price || price.type !== 'recurring') { - throw new Error('Unexpected invoice with no recurring price'); - } - - if (!price.lookup_key) { - throw new Error('Unexpected subscription with no key'); - } - - const [plan, recurring] = decodeLookupKey(price.lookup_key); - - await this.db.userInvoice.create({ + await this.db.userSubscription.create({ data: { userId: user.id, - stripeInvoiceId: stripeInvoice.id, - plan, - recurring, - reason: stripeInvoice.billing_reason ?? 'contact support', - ...(data as any), + stripeSubscriptionId: null, + plan: invoice.plan, + recurring: invoice.recurring, + end: null, + start: new Date(), + status: SubscriptionStatus.Active, + nextBillAt: null, }, }); } + + this.event.emit('user.subscription.activated', { + userId: user.id, + plan: invoice.plan as SubscriptionPlan, + recurring: SubscriptionRecurring.Lifetime, + }); + } + + @OnStripeEvent('customer.subscription.created') + @OnStripeEvent('customer.subscription.updated') + async onSubscriptionChanges(subscription: Stripe.Subscription) { + subscription = await this.stripe.subscriptions.retrieve(subscription.id); + if (subscription.status === 'active') { + const user = await this.retrieveUserFromCustomer( + typeof subscription.customer === 'string' + ? subscription.customer + : subscription.customer.id + ); + + await this.saveSubscription(user, subscription); + } else { + await this.onSubscriptionDeleted(subscription); + } + } + + @OnStripeEvent('customer.subscription.deleted') + async onSubscriptionDeleted(subscription: Stripe.Subscription) { + const user = await this.retrieveUserFromCustomer( + typeof subscription.customer === 'string' + ? subscription.customer + : subscription.customer.id + ); + + const [plan, recurring] = this.decodePlanFromSubscription(subscription); + + this.event.emit('user.subscription.canceled', { + userId: user.id, + plan, + recurring, + }); + + await this.db.userSubscription.deleteMany({ + where: { + stripeSubscriptionId: subscription.id, + }, + }); } private async saveSubscription( @@ -576,6 +656,7 @@ export class SubscriptionService { this.event.emit('user.subscription.activated', { userId: user.id, plan, + recurring, }); let nextBillAt: Date | null = null; @@ -600,44 +681,21 @@ export class SubscriptionService { : null, stripeSubscriptionId: subscription.id, plan, - recurring, status: subscription.status, stripeScheduleId: subscription.schedule as string | null, }; - const currentSubscription = await this.db.userSubscription.findUnique({ + return await this.db.userSubscription.upsert({ where: { - userId_plan: { - userId: user.id, - plan, - }, + stripeSubscriptionId: subscription.id, + }, + update: commonData, + create: { + userId: user.id, + recurring, + ...commonData, }, }); - - if (currentSubscription) { - const update: Prisma.UserSubscriptionUpdateInput = { - ...commonData, - }; - - // a schedule exists, update the recurring to scheduled one - if (update.stripeScheduleId) { - delete update.recurring; - } - - return await this.db.userSubscription.update({ - where: { - id: currentSubscription.id, - }, - data: update, - }); - } else { - return await this.db.userSubscription.create({ - data: { - userId: user.id, - ...commonData, - }, - }); - } } private async getOrCreateCustomer( diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index 16844088e7..f0f315ce6a 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -5,6 +5,7 @@ import type { Payload } from '../../fundamentals/event/def'; export enum SubscriptionRecurring { Monthly = 'monthly', Yearly = 'yearly', + Lifetime = 'lifetime', } export enum SubscriptionPlan { @@ -46,10 +47,12 @@ declare module '../../fundamentals/event/def' { activated: Payload<{ userId: User['id']; plan: SubscriptionPlan; + recurring: SubscriptionRecurring; }>; canceled: Payload<{ userId: User['id']; plan: SubscriptionPlan; + recurring: SubscriptionRecurring; }>; }; } diff --git a/packages/backend/server/src/plugins/payment/webhook.ts b/packages/backend/server/src/plugins/payment/webhook.ts index f2dcf4c396..cf757609e2 100644 --- a/packages/backend/server/src/plugins/payment/webhook.ts +++ b/packages/backend/server/src/plugins/payment/webhook.ts @@ -45,9 +45,16 @@ export class StripeWebhook { setImmediate(() => { // handle duplicated events? // see https://stripe.com/docs/webhooks#handle-duplicate-events - this.event.emitAsync(event.type, event.data.object).catch(e => { - this.logger.error('Failed to handle Stripe Webhook event.', e); - }); + 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); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 6827ee62fe..e65530e4fd 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -189,6 +189,7 @@ enum ErrorNames { BLOB_NOT_FOUND BLOB_QUOTA_EXCEEDED CANT_CHANGE_WORKSPACE_OWNER + CANT_UPDATE_LIFETIME_SUBSCRIPTION COPILOT_ACTION_TAKEN COPILOT_FAILED_TO_CREATE_MESSAGE COPILOT_FAILED_TO_GENERATE_TEXT @@ -657,12 +658,14 @@ type SubscriptionPlanNotFoundDataType { type SubscriptionPrice { amount: Int currency: String! + lifetimeAmount: Int plan: SubscriptionPlan! type: String! yearlyAmount: Int } enum SubscriptionRecurring { + Lifetime Monthly Yearly } @@ -733,8 +736,8 @@ type UserQuotaHumanReadable { type UserSubscription { canceledAt: DateTime createdAt: DateTime! - end: DateTime! - id: String! + end: DateTime + id: String nextBillAt: DateTime """ diff --git a/packages/backend/server/tests/payment/service.spec.ts b/packages/backend/server/tests/payment/service.spec.ts index a34832a05f..5b86e8f099 100644 --- a/packages/backend/server/tests/payment/service.spec.ts +++ b/packages/backend/server/tests/payment/service.spec.ts @@ -84,6 +84,7 @@ test.afterEach.always(async t => { const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`; const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`; +const PRO_LIFETIME = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`; const PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`; const AI_YEARLY = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`; const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`; @@ -105,6 +106,11 @@ const PRICES = { currency: 'usd', lookup_key: PRO_YEARLY, }, + [PRO_LIFETIME]: { + unit_amount: 49900, + currency: 'usd', + lookup_key: PRO_LIFETIME, + }, [PRO_EA_YEARLY]: { recurring: { interval: 'year', @@ -170,10 +176,9 @@ test('should list normal price for unauthenticated user', async t => { const prices = await service.listPrices(); - t.is(prices.length, 3); t.deepEqual( new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY]) + new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) ); }); @@ -190,10 +195,9 @@ test('should list normal prices for authenticated user', async t => { const prices = await service.listPrices(u1); - t.is(prices.length, 3); t.deepEqual( new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY]) + new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) ); }); @@ -210,10 +214,9 @@ test('should list early access prices for pro ea user', async t => { const prices = await service.listPrices(u1); - t.is(prices.length, 3); t.deepEqual( new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_EA_YEARLY, AI_YEARLY]) + new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY]) ); }); @@ -246,10 +249,9 @@ test('should list normal prices for pro ea user with old subscriptions', async t const prices = await service.listPrices(u1); - t.is(prices.length, 3); t.deepEqual( new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY]) + new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) ); }); @@ -266,10 +268,9 @@ test('should list early access prices for ai ea user', async t => { const prices = await service.listPrices(u1); - t.is(prices.length, 3); t.deepEqual( new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY_EA]) + new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY_EA]) ); }); @@ -286,10 +287,9 @@ test('should list early access prices for pro and ai ea user', async t => { const prices = await service.listPrices(u1); - t.is(prices.length, 3); t.deepEqual( new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_EA_YEARLY, AI_YEARLY_EA]) + new Set([PRO_MONTHLY, PRO_LIFETIME, PRO_EA_YEARLY, AI_YEARLY_EA]) ); }); @@ -322,10 +322,9 @@ test('should list normal prices for ai ea user with old subscriptions', async t const prices = await service.listPrices(u1); - t.is(prices.length, 3); t.deepEqual( new Set(prices.map(p => p.lookup_key)), - new Set([PRO_MONTHLY, PRO_YEARLY, AI_YEARLY]) + new Set([PRO_MONTHLY, PRO_YEARLY, PRO_LIFETIME, AI_YEARLY]) ); }); @@ -458,6 +457,22 @@ test('should get correct pro plan price for checking out', async t => { coupon: undefined, }); } + + // 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, { + price: PRO_LIFETIME, + coupon: undefined, + }); + } }); test('should get correct ai plan price for checking out', async t => { @@ -639,6 +654,7 @@ test('should be able to create subscription', async t => { emitStub.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, }) ); @@ -674,6 +690,7 @@ test('should be able to update subscription', async t => { emitStub.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, }) ); @@ -706,6 +723,7 @@ test('should be able to delete subscription', async t => { emitStub.calledOnceWith('user.subscription.canceled', { userId: u1.id, plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, }) ); @@ -749,6 +767,7 @@ test('should be able to cancel subscription', async t => { emitStub.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, }) ); @@ -785,6 +804,7 @@ test('should be able to resume subscription', async t => { emitStub.calledOnceWith('user.subscription.activated', { userId: u1.id, plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, }) ); @@ -929,3 +949,120 @@ test('should operate with latest subscription status', async t => { t.deepEqual(stub.firstCall.args[1], sub); t.deepEqual(stub.secondCall.args[1], sub); }); + +// ============== Lifetime Subscription =============== +const invoice: Stripe.Invoice = { + id: 'in_xxx', + object: 'invoice', + amount_paid: 49900, + total: 49900, + customer: 'cus_1', + currency: 'usd', + status: 'paid', + lines: { + data: [ + { + // @ts-expect-error stub + price: PRICES[PRO_LIFETIME], + }, + ], + }, +}; + +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 emitStub = Sinon.stub(event, 'emit'); + Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any); + await service.saveInvoice(invoice, 'invoice.payment_succeeded'); + + const subInDB = await db.userSubscription.findFirst({ + where: { userId: u1.id }, + }); + + t.true( + emitStub.calledOnceWith('user.subscription.activated', { + userId: u1.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }) + ); + t.is(subInDB?.plan, SubscriptionPlan.Pro); + t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime); + t.is(subInDB?.status, SubscriptionStatus.Active); + t.is(subInDB?.stripeSubscriptionId, null); +}); + +test('should be able to subscribe to lifetime recurring with old subscription', async t => { + const { service, stripe, db, u1, event } = t.context; + + await db.userSubscription.create({ + data: { + userId: u1.id, + stripeSubscriptionId: 'sub_1', + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + status: SubscriptionStatus.Active, + start: new Date(), + end: new Date(), + }, + }); + + const emitStub = Sinon.stub(event, 'emit'); + Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any); + Sinon.stub(stripe.subscriptions, 'cancel').resolves(sub as any); + await service.saveInvoice(invoice, 'invoice.payment_succeeded'); + + const subInDB = await db.userSubscription.findFirst({ + where: { userId: u1.id }, + }); + + t.true( + emitStub.calledOnceWith('user.subscription.activated', { + userId: u1.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }) + ); + t.is(subInDB?.plan, SubscriptionPlan.Pro); + t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime); + t.is(subInDB?.status, SubscriptionStatus.Active); + t.is(subInDB?.stripeSubscriptionId, null); +}); + +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.cancelSubscription('', u1.id, SubscriptionPlan.Pro), + { message: 'Lifetime subscription cannot be canceled.' } + ); + + await t.throwsAsync( + () => + service.updateSubscriptionRecurring( + '', + u1.id, + SubscriptionPlan.Pro, + SubscriptionRecurring.Monthly + ), + { message: 'Can not update lifetime subscription.' } + ); + + await t.throwsAsync( + () => service.resumeCanceledSubscription('', u1.id, SubscriptionPlan.Pro), + { message: 'Lifetime subscription cannot be resumed.' } + ); +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index eaa34637a8..e48e9c4da3 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -38,6 +38,7 @@ enum DescriptionI18NKey { Basic = 'com.affine.payment.billing-setting.current-plan.description', Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly', Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly', + Lifetime = 'com.affine.payment.billing-setting.current-plan.description.lifetime', } const INVOICE_PAGE_SIZE = 12; @@ -204,7 +205,7 @@ const SubscriptionSettings = () => { })} /> )} - {proSubscription.canceledAt ? ( + {proSubscription.end && proSubscription.canceledAt ? ( ; currency: Scalars['String']['output']; + lifetimeAmount: Maybe; plan: SubscriptionPlan; type: Scalars['String']['output']; yearlyAmount: Maybe; } export enum SubscriptionRecurring { + Lifetime = 'Lifetime', Monthly = 'Monthly', Yearly = 'Yearly', } @@ -1027,8 +1030,8 @@ export interface UserSubscription { __typename?: 'UserSubscription'; canceledAt: Maybe; createdAt: Scalars['DateTime']['output']; - end: Scalars['DateTime']['output']; - id: Scalars['String']['output']; + end: Maybe; + id: Maybe; nextBillAt: Maybe; /** * The 'Free' plan just exists to be a placeholder and for the type convenience of frontend. @@ -1213,7 +1216,7 @@ export type CancelSubscriptionMutation = { __typename?: 'Mutation'; cancelSubscription: { __typename?: 'UserSubscription'; - id: string; + id: string | null; status: SubscriptionStatus; nextBillAt: string | null; canceledAt: string | null; @@ -1347,7 +1350,7 @@ export type EarlyAccessUsersQuery = { recurring: SubscriptionRecurring; status: SubscriptionStatus; start: string; - end: string; + end: string | null; } | null; }>; }; @@ -1834,11 +1837,11 @@ export type ResumeSubscriptionMutation = { __typename?: 'Mutation'; resumeSubscription: { __typename?: 'UserSubscription'; - id: string; + id: string | null; status: SubscriptionStatus; nextBillAt: string | null; start: string; - end: string; + end: string | null; }; }; @@ -1955,12 +1958,12 @@ export type SubscriptionQuery = { id: string; subscriptions: Array<{ __typename?: 'UserSubscription'; - id: string; + id: string | null; status: SubscriptionStatus; plan: SubscriptionPlan; recurring: SubscriptionRecurring; start: string; - end: string; + end: string | null; nextBillAt: string | null; canceledAt: string | null; }>; @@ -1990,7 +1993,7 @@ export type UpdateSubscriptionMutation = { __typename?: 'Mutation'; updateSubscriptionRecurring: { __typename?: 'UserSubscription'; - id: string; + id: string | null; plan: SubscriptionPlan; recurring: SubscriptionRecurring; nextBillAt: string | null; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 27579a6239..32351f58b1 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -941,6 +941,7 @@ "com.affine.payment.billing-setting.current-plan.description": "You are currently on the <1>{{planName}} plan.", "com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan.", "com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan.", + "com.affine.payment.billing-setting.current-plan.description.lifetime": "You are currently on the believer <1>{{planName}} plan.", "com.affine.payment.billing-setting.expiration-date": "Expiration Date", "com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}", "com.affine.payment.billing-setting.history": "Billing history",