diff --git a/packages/backend/server/migrations/20240924024058_onetime_payment_subscription/migration.sql b/packages/backend/server/migrations/20240924024058_onetime_payment_subscription/migration.sql new file mode 100644 index 0000000000..555ca6dfb7 --- /dev/null +++ b/packages/backend/server/migrations/20240924024058_onetime_payment_subscription/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user_subscriptions" ADD COLUMN "variant" VARCHAR(20); diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 0028280b2a..acf2df7fa6 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -332,9 +332,11 @@ model UserSubscription { id Int @id @default(autoincrement()) @db.Integer userId String @map("user_id") @db.VarChar plan String @db.VarChar(20) - // yearly/monthly + // yearly/monthly/lifetime recurring String @db.VarChar(20) - // subscription.id, null for linefetime payment + // onetime subscription or anything else + 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... status String @db.VarChar(20) diff --git a/packages/backend/server/src/fundamentals/error/def.ts b/packages/backend/server/src/fundamentals/error/def.ts index 096980990e..7d92a1d7e9 100644 --- a/packages/backend/server/src/fundamentals/error/def.ts +++ b/packages/backend/server/src/fundamentals/error/def.ts @@ -443,9 +443,9 @@ export const USER_FRIENDLY_ERRORS = { args: { plan: 'string', recurring: 'string' }, message: 'You are trying to access a unknown subscription plan.', }, - cant_update_lifetime_subscription: { + cant_update_onetime_payment_subscription: { type: 'action_forbidden', - message: 'You cannot update a lifetime subscription.', + message: 'You cannot update an onetime payment subscription.', }, // Copilot errors diff --git a/packages/backend/server/src/fundamentals/error/errors.gen.ts b/packages/backend/server/src/fundamentals/error/errors.gen.ts index 156cf8892c..dc9effcfff 100644 --- a/packages/backend/server/src/fundamentals/error/errors.gen.ts +++ b/packages/backend/server/src/fundamentals/error/errors.gen.ts @@ -390,9 +390,9 @@ export class SubscriptionPlanNotFound extends UserFriendlyError { } } -export class CantUpdateLifetimeSubscription extends UserFriendlyError { +export class CantUpdateOnetimePaymentSubscription extends UserFriendlyError { constructor(message?: string) { - super('action_forbidden', 'cant_update_lifetime_subscription', message); + super('action_forbidden', 'cant_update_onetime_payment_subscription', message); } } @@ -591,7 +591,7 @@ export enum ErrorNames { SAME_SUBSCRIPTION_RECURRING, CUSTOMER_PORTAL_CREATE_FAILED, SUBSCRIPTION_PLAN_NOT_FOUND, - CANT_UPDATE_LIFETIME_SUBSCRIPTION, + CANT_UPDATE_ONETIME_PAYMENT_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 46a1a7e5b8..db5d983a8f 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -30,10 +30,12 @@ import { SubscriptionPlan, SubscriptionRecurring, SubscriptionStatus, + SubscriptionVariant, } from './types'; registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' }); registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' }); +registerEnumType(SubscriptionVariant, { name: 'SubscriptionVariant' }); registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' }); registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' }); @@ -72,6 +74,9 @@ export class UserSubscriptionType implements Partial { @Field(() => SubscriptionRecurring) recurring!: SubscriptionRecurring; + @Field(() => SubscriptionVariant, { nullable: true }) + variant?: SubscriptionVariant | null; + @Field(() => SubscriptionStatus) status!: SubscriptionStatus; @@ -150,6 +155,11 @@ class CreateCheckoutSessionInput { }) plan!: SubscriptionPlan; + @Field(() => SubscriptionVariant, { + nullable: true, + }) + variant?: SubscriptionVariant; + @Field(() => String, { nullable: true }) coupon!: string | null; @@ -236,6 +246,7 @@ export class SubscriptionResolver { user, plan: input.plan, recurring: input.recurring, + variant: input.variant, promotionCode: input.coupon, redirectUrl: this.url.link(input.successCallbackLink), idempotencyKey: input.idempotencyKey, diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 3c1d014a9f..821fde56c3 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -15,10 +15,11 @@ import { CurrentUser } from '../../core/auth'; import { EarlyAccessType, FeatureManagementService } from '../../core/features'; import { ActionForbidden, - CantUpdateLifetimeSubscription, + CantUpdateOnetimePaymentSubscription, Config, CustomerPortalCreateFailed, EventEmitter, + InternalServerError, OnEvent, SameSubscriptionRecurring, SubscriptionAlreadyExists, @@ -32,9 +33,9 @@ import { ScheduleManager } from './schedule'; import { InvoiceStatus, SubscriptionPlan, - SubscriptionPriceVariant, SubscriptionRecurring, SubscriptionStatus, + SubscriptionVariant, } from './types'; const OnStripeEvent = ( @@ -46,20 +47,20 @@ const OnStripeEvent = ( export function encodeLookupKey( plan: SubscriptionPlan, recurring: SubscriptionRecurring, - variant?: SubscriptionPriceVariant + variant?: SubscriptionVariant ): string { return `${plan}_${recurring}` + (variant ? `_${variant}` : ''); } export function decodeLookupKey( key: string -): [SubscriptionPlan, SubscriptionRecurring, SubscriptionPriceVariant?] { +): [SubscriptionPlan, SubscriptionRecurring, SubscriptionVariant?] { const [plan, recurring, variant] = key.split('_'); return [ plan as SubscriptionPlan, recurring as SubscriptionRecurring, - variant as SubscriptionPriceVariant | undefined, + variant as SubscriptionVariant | undefined, ]; } @@ -137,6 +138,12 @@ export class SubscriptionService { } 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 || @@ -167,6 +174,7 @@ export class SubscriptionService { user, recurring, plan, + variant, promotionCode, redirectUrl, idempotencyKey, @@ -174,6 +182,7 @@ export class SubscriptionService { user: CurrentUser; recurring: SubscriptionRecurring; plan: SubscriptionPlan; + variant?: SubscriptionVariant; promotionCode?: string | null; redirectUrl: string; idempotencyKey: string; @@ -186,6 +195,11 @@ 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, @@ -196,9 +210,18 @@ export class SubscriptionService { if ( currentSubscription && - // do not allow to re-subscribe unless the new recurring is `Lifetime` - (currentSubscription.recurring === recurring || - recurring !== SubscriptionRecurring.Lifetime) + // do not allow to re-subscribe unless + !( + /* current subscription is a onetime subscription and so as the one that's checking out */ + ( + (currentSubscription.variant === SubscriptionVariant.Onetime && + 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) + ) + ) ) { throw new SubscriptionAlreadyExists({ plan }); } @@ -211,7 +234,8 @@ export class SubscriptionService { const { price, coupon } = await this.getAvailablePrice( customer, plan, - recurring + recurring, + variant ); let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = []; @@ -241,8 +265,9 @@ export class SubscriptionService { }, // discount ...(discounts.length ? { discounts } : { allow_promotion_codes: true }), - // mode: 'subscription' or 'payment' for lifetime - ...(recurring === SubscriptionRecurring.Lifetime + // mode: 'subscription' or 'payment' for lifetime and onetime payment + ...(recurring === SubscriptionRecurring.Lifetime || + variant === SubscriptionVariant.Onetime ? { mode: 'payment', invoice_creation: { @@ -291,8 +316,8 @@ export class SubscriptionService { } if (!subscriptionInDB.stripeSubscriptionId) { - throw new CantUpdateLifetimeSubscription( - 'Lifetime subscription cannot be canceled.' + throw new CantUpdateOnetimePaymentSubscription( + 'Onetime payment subscription cannot be canceled.' ); } @@ -348,8 +373,8 @@ export class SubscriptionService { } if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) { - throw new CantUpdateLifetimeSubscription( - 'Lifetime subscription cannot be resumed.' + throw new CantUpdateOnetimePaymentSubscription( + 'Onetime payment subscription cannot be resumed.' ); } @@ -407,9 +432,7 @@ export class SubscriptionService { } if (!subscriptionInDB.stripeSubscriptionId) { - throw new CantUpdateLifetimeSubscription( - 'Can not update lifetime subscription.' - ); + throw new CantUpdateOnetimePaymentSubscription(); } if (subscriptionInDB.canceledAt) { @@ -525,7 +548,7 @@ export class SubscriptionService { throw new Error('Unexpected subscription with no key'); } - const [plan, recurring] = decodeLookupKey(price.lookup_key); + const [plan, recurring, variant] = decodeLookupKey(price.lookup_key); const invoice = await this.db.userInvoice.upsert({ where: { @@ -537,7 +560,7 @@ export class SubscriptionService { stripeInvoiceId: stripeInvoice.id, plan, recurring, - reason: stripeInvoice.billing_reason ?? 'contact support', + reason: stripeInvoice.billing_reason ?? 'subscription_update', ...(data as any), }, }); @@ -545,10 +568,13 @@ export class SubscriptionService { // 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); + if (recurring === SubscriptionRecurring.Lifetime) { + await this.saveLifetimeSubscription(user, invoice); + } else if (variant === SubscriptionVariant.Onetime) { + await this.saveOnetimePaymentSubscription(user, invoice); + } } } @@ -607,6 +633,72 @@ export class SubscriptionService { }); } + 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) { @@ -656,7 +748,8 @@ export class SubscriptionService { throw new Error('Unexpected subscription with no key'); } - const [plan, recurring] = this.decodePlanFromSubscription(subscription); + const [plan, recurring, variant] = + this.decodePlanFromSubscription(subscription); const planActivated = SubscriptionActivated.includes(subscription.status); // update features first, features modify are idempotent @@ -689,6 +782,8 @@ export class SubscriptionService { : null, stripeSubscriptionId: subscription.id, plan, + recurring, + variant, status: subscription.status, stripeScheduleId: subscription.schedule as string | null, }; @@ -700,7 +795,6 @@ export class SubscriptionService { update: commonData, create: { userId: user.id, - recurring, ...commonData, }, }); @@ -813,7 +907,7 @@ export class SubscriptionService { private async getPrice( plan: SubscriptionPlan, recurring: SubscriptionRecurring, - variant?: SubscriptionPriceVariant + variant?: SubscriptionVariant ): Promise { if (recurring === SubscriptionRecurring.Lifetime) { const lifetimePriceEnabled = await this.config.runtime.fetch( @@ -845,8 +939,14 @@ export class SubscriptionService { private async getAvailablePrice( customer: UserStripeCustomer, plan: SubscriptionPlan, - recurring: SubscriptionRecurring + 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, @@ -867,7 +967,7 @@ export class SubscriptionService { const price = await this.getPrice( plan, recurring, - canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined + canHaveEADiscount ? SubscriptionVariant.EA : undefined ); return { price, @@ -886,7 +986,7 @@ export class SubscriptionService { const price = await this.getPrice( plan, recurring, - canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined + canHaveEADiscount ? SubscriptionVariant.EA : undefined ); return { diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index f0f315ce6a..aee3470ae9 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -17,8 +17,9 @@ export enum SubscriptionPlan { SelfHosted = 'selfhosted', } -export enum SubscriptionPriceVariant { +export enum SubscriptionVariant { EA = 'earlyaccess', + Onetime = 'onetime', } // see https://stripe.com/docs/api/subscriptions/object#subscription_object-status diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index d93efb9865..88234d6839 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -143,6 +143,7 @@ input CreateCheckoutSessionInput { plan: SubscriptionPlan = Pro recurring: SubscriptionRecurring = Yearly successCallbackLink: String! + variant: SubscriptionVariant } input CreateCopilotPromptInput { @@ -217,7 +218,7 @@ enum ErrorNames { CANNOT_DELETE_ALL_ADMIN_ACCOUNT CANNOT_DELETE_OWN_ACCOUNT CANT_CHANGE_SPACE_OWNER - CANT_UPDATE_LIFETIME_SUBSCRIPTION + CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION CAPTCHA_VERIFICATION_FAILED COPILOT_ACTION_TAKEN COPILOT_FAILED_TO_CREATE_MESSAGE @@ -763,6 +764,11 @@ enum SubscriptionStatus { Unpaid } +enum SubscriptionVariant { + EA + Onetime +} + type UnknownOauthProviderDataType { name: String! } @@ -835,6 +841,7 @@ type UserSubscription { trialEnd: DateTime trialStart: DateTime updatedAt: DateTime! + variant: SubscriptionVariant } type UserType { diff --git a/packages/backend/server/tests/payment/service.spec.ts b/packages/backend/server/tests/payment/service.spec.ts index 4e78231167..1b0532bd0e 100644 --- a/packages/backend/server/tests/payment/service.spec.ts +++ b/packages/backend/server/tests/payment/service.spec.ts @@ -22,9 +22,9 @@ import { } from '../../src/plugins/payment/service'; import { SubscriptionPlan, - SubscriptionPriceVariant, SubscriptionRecurring, SubscriptionStatus, + SubscriptionVariant, } from '../../src/plugins/payment/types'; import { createTestingApp } from '../utils'; @@ -85,9 +85,13 @@ 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 PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`; const AI_YEARLY = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`; -const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionPriceVariant.EA}`; +const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`; +// prices for code redeeming +const PRO_MONTHLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`; +const PRO_YEARLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`; +const AI_YEARLY_CODE = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`; const PRICES = { [PRO_MONTHLY]: { @@ -135,6 +139,21 @@ const PRICES = { currency: 'usd', lookup_key: AI_YEARLY_EA, }, + [PRO_MONTHLY_CODE]: { + unit_amount: 799, + currency: 'usd', + lookup_key: PRO_MONTHLY_CODE, + }, + [PRO_YEARLY_CODE]: { + unit_amount: 8100, + currency: 'usd', + lookup_key: PRO_YEARLY_CODE, + }, + [AI_YEARLY_CODE]: { + unit_amount: 10680, + currency: 'usd', + lookup_key: AI_YEARLY_CODE, + }, }; const sub: Stripe.Subscription = { @@ -951,8 +970,8 @@ test('should operate with latest subscription status', async t => { }); // ============== Lifetime Subscription =============== -const invoice: Stripe.Invoice = { - id: 'in_xxx', +const lifetimeInvoice: Stripe.Invoice = { + id: 'in_1', object: 'invoice', amount_paid: 49900, total: 49900, @@ -969,6 +988,42 @@ const invoice: Stripe.Invoice = { }, }; +const onetimeMonthlyInvoice: Stripe.Invoice = { + id: 'in_2', + object: 'invoice', + amount_paid: 799, + total: 799, + customer: 'cus_1', + currency: 'usd', + status: 'paid', + lines: { + data: [ + { + // @ts-expect-error stub + price: PRICES[PRO_MONTHLY_CODE], + }, + ], + }, +}; + +const onetimeYearlyInvoice: Stripe.Invoice = { + id: 'in_3', + object: 'invoice', + amount_paid: 8100, + total: 8100, + customer: 'cus_1', + currency: 'usd', + status: 'paid', + lines: { + data: [ + { + // @ts-expect-error stub + price: PRICES[PRO_YEARLY_CODE], + }, + ], + }, +}; + test('should not be able to checkout for lifetime recurring if not enabled', async t => { const { service, stripe, u1 } = t.context; @@ -1008,13 +1063,62 @@ test('should be able to checkout for lifetime recurring', async t => { t.true(sessionStub.calledOnce); }); +test('should not be able to checkout for lifetime recurring if already subscribed', async t => { + const { service, u1, db } = t.context; + + await db.userSubscription.create({ + data: { + userId: u1.id, + stripeSubscriptionId: null, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + status: SubscriptionStatus.Active, + start: new Date(), + }, + }); + + await t.throwsAsync( + () => + service.createCheckoutSession({ + user: u1, + recurring: SubscriptionRecurring.Lifetime, + plan: SubscriptionPlan.Pro, + redirectUrl: '', + idempotencyKey: '', + }), + { message: 'You have already subscribed to the pro plan.' } + ); + + await db.userSubscription.updateMany({ + where: { userId: u1.id }, + data: { + stripeSubscriptionId: null, + recurring: SubscriptionRecurring.Monthly, + variant: SubscriptionVariant.Onetime, + end: new Date(Date.now() + 100000), + }, + }); + + await t.throwsAsync( + () => + service.createCheckoutSession({ + user: u1, + recurring: SubscriptionRecurring.Lifetime, + plan: SubscriptionPlan.Pro, + redirectUrl: '', + idempotencyKey: '', + }), + { message: 'You have already subscribed to the pro plan.' } + ); +}); + 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'); + Sinon.stub(stripe.invoices, 'retrieve').resolves(lifetimeInvoice as any); + await service.saveInvoice(lifetimeInvoice, 'invoice.payment_succeeded'); const subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, @@ -1049,9 +1153,9 @@ test('should be able to subscribe to lifetime recurring with old subscription', }); const emitStub = Sinon.stub(event, 'emit'); - Sinon.stub(stripe.invoices, 'retrieve').resolves(invoice as any); + Sinon.stub(stripe.invoices, 'retrieve').resolves(lifetimeInvoice as any); Sinon.stub(stripe.subscriptions, 'cancel').resolves(sub as any); - await service.saveInvoice(invoice, 'invoice.payment_succeeded'); + await service.saveInvoice(lifetimeInvoice, 'invoice.payment_succeeded'); const subInDB = await db.userSubscription.findFirst({ where: { userId: u1.id }, @@ -1086,7 +1190,7 @@ test('should not be able to update lifetime recurring', async t => { await t.throwsAsync( () => service.cancelSubscription('', u1.id, SubscriptionPlan.Pro), - { message: 'Lifetime subscription cannot be canceled.' } + { message: 'Onetime payment subscription cannot be canceled.' } ); await t.throwsAsync( @@ -1097,11 +1201,211 @@ test('should not be able to update lifetime recurring', async t => { SubscriptionPlan.Pro, SubscriptionRecurring.Monthly ), - { message: 'Can not update lifetime subscription.' } + { message: 'You cannot update an onetime payment subscription.' } ); await t.throwsAsync( () => service.resumeCanceledSubscription('', u1.id, SubscriptionPlan.Pro), - { message: 'Lifetime subscription cannot be resumed.' } + { 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({ + user: u1, + recurring: SubscriptionRecurring.Monthly, + plan: SubscriptionPlan.Pro, + variant: SubscriptionVariant.Onetime, + redirectUrl: '', + idempotencyKey: '', + }); + + t.true(checkoutStub.calledOnce); + const arg = checkoutStub.firstCall + .args[0] as Stripe.Checkout.SessionCreateParams; + t.is(arg.mode, 'payment'); + t.is(arg.line_items?.[0].price, PRO_MONTHLY_CODE); +}); + +test('should be able to checkout onetime payment if previous subscription is onetime', async t => { + const { service, u1, stripe, db } = t.context; + + await db.userSubscription.create({ + data: { + userId: u1.id, + stripeSubscriptionId: 'sub_1', + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + variant: SubscriptionVariant.Onetime, + status: SubscriptionStatus.Active, + start: new Date(), + end: new Date(), + }, + }); + + 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({ + user: u1, + recurring: SubscriptionRecurring.Monthly, + plan: SubscriptionPlan.Pro, + variant: SubscriptionVariant.Onetime, + redirectUrl: '', + idempotencyKey: '', + }); + + t.true(checkoutStub.calledOnce); + const arg = checkoutStub.firstCall + .args[0] as Stripe.Checkout.SessionCreateParams; + t.is(arg.mode, 'payment'); + t.is(arg.line_items?.[0].price, PRO_MONTHLY_CODE); +}); + +test('should not be able to checkout out onetime payment if previous subscription is not onetime', async t => { + const { service, u1, db } = 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(), + }, + }); + + await t.throwsAsync( + () => + service.createCheckoutSession({ + user: u1, + recurring: SubscriptionRecurring.Monthly, + plan: SubscriptionPlan.Pro, + variant: SubscriptionVariant.Onetime, + redirectUrl: '', + idempotencyKey: '', + }), + { message: 'You have already subscribed to the pro plan.' } + ); + + await db.userSubscription.updateMany({ + where: { userId: u1.id }, + data: { + stripeSubscriptionId: null, + recurring: SubscriptionRecurring.Lifetime, + }, + }); + + await t.throwsAsync( + () => + service.createCheckoutSession({ + user: u1, + recurring: SubscriptionRecurring.Monthly, + plan: SubscriptionPlan.Pro, + variant: SubscriptionVariant.Onetime, + redirectUrl: '', + idempotencyKey: '', + }), + { message: 'You have already subscribed to the pro plan.' } + ); +}); + +test('should be able to subscribe onetime payment subscription', async t => { + const { service, stripe, 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'); + + 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.Monthly, + }) + ); + t.is(subInDB?.plan, SubscriptionPlan.Pro); + t.is(subInDB?.recurring, SubscriptionRecurring.Monthly); + t.is(subInDB?.status, SubscriptionStatus.Active); + t.is(subInDB?.stripeSubscriptionId, null); + t.is( + subInDB?.end?.toDateString(), + new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toDateString() + ); +}); + +test('should be able to recalculate onetime payment subscription period', async t => { + const { service, stripe, db, u1 } = t.context; + + const stub = Sinon.stub(stripe.invoices, 'retrieve').resolves( + onetimeMonthlyInvoice as any + ); + await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded'); + + let subInDB = await db.userSubscription.findFirst({ + where: { userId: u1.id }, + }); + + t.truthy(subInDB); + + let end = subInDB!.end!; + await service.saveInvoice(onetimeMonthlyInvoice, 'invoice.payment_succeeded'); + subInDB = await db.userSubscription.findFirst({ + where: { userId: u1.id }, + }); + + // add 30 days + 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'); + subInDB = await db.userSubscription.findFirst({ + where: { userId: u1.id }, + }); + + // add 365 days + t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000); + + // make subscription expired + await db.userSubscription.update({ + where: { id: subInDB!.id }, + data: { + end: new Date(Date.now() - 1000), + }, + }); + await service.saveInvoice(onetimeYearlyInvoice, 'invoice.payment_succeeded'); + subInDB = await db.userSubscription.findFirst({ + where: { userId: u1.id }, + }); + + // add 365 days from now + t.is( + subInDB?.end?.toDateString(), + new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString() ); }); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx index 7b837715d5..31c3af17b7 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx @@ -59,6 +59,7 @@ export const AISubscribe = ({ recurring: SubscriptionRecurring.Yearly, idempotencyKey, plan: SubscriptionPlan.AI, + variant: null, coupon: null, successCallbackLink: generateSubscriptionCallbackLink( authService.session.account$.value, diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx index dc76bca6e5..ab435f05bb 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -282,6 +282,7 @@ export const Upgrade = ({ recurring, idempotencyKey, plan: SubscriptionPlan.Pro, // Only support prod plan now. + variant: null, coupon: null, successCallbackLink: generateSubscriptionCallbackLink( authService.session.account$.value, diff --git a/packages/frontend/core/src/desktop/pages/subscribe.tsx b/packages/frontend/core/src/desktop/pages/subscribe.tsx index cf0b0f9a33..be9e935f2f 100644 --- a/packages/frontend/core/src/desktop/pages/subscribe.tsx +++ b/packages/frontend/core/src/desktop/pages/subscribe.tsx @@ -100,6 +100,7 @@ export const Component = () => { plan: targetPlan, coupon: null, recurring: targetRecurring, + variant: null, successCallbackLink: generateSubscriptionCallbackLink( account, targetPlan, diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index be66ef3157..c2c12d30e1 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -192,6 +192,7 @@ export interface CreateCheckoutSessionInput { plan: InputMaybe; recurring: InputMaybe; successCallbackLink: Scalars['String']['input']; + variant: InputMaybe; } export interface CreateCopilotPromptInput { @@ -291,7 +292,7 @@ export enum ErrorNames { CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT', CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT', CANT_CHANGE_SPACE_OWNER = 'CANT_CHANGE_SPACE_OWNER', - CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION', + CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION = 'CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION', CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE', @@ -1063,6 +1064,11 @@ export enum SubscriptionStatus { Unpaid = 'Unpaid', } +export enum SubscriptionVariant { + EA = 'EA', + Onetime = 'Onetime', +} + export interface UnknownOauthProviderDataType { __typename?: 'UnknownOauthProviderDataType'; name: Scalars['String']['output'];