feat(server): support lifetime subscription (#7405)

closes CLOUD-48

- [x] lifetime subscription quota
- [ ] tests
This commit is contained in:
forehalo 2024-07-08 07:41:26 +00:00
parent 7235779b02
commit de91027852
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
17 changed files with 447 additions and 165 deletions

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "user_subscriptions" ALTER COLUMN "stripe_subscription_id" DROP NOT NULL,
ALTER COLUMN "end" DROP NOT NULL;

View File

@ -377,14 +377,14 @@ model UserSubscription {
plan String @db.VarChar(20) plan String @db.VarChar(20)
// yearly/monthly // yearly/monthly
recurring String @db.VarChar(20) recurring String @db.VarChar(20)
// subscription.id // subscription.id, null for linefetime payment
stripeSubscriptionId String @unique @map("stripe_subscription_id") stripeSubscriptionId String? @unique @map("stripe_subscription_id")
// subscription.status, active/past_due/canceled/unpaid... // subscription.status, active/past_due/canceled/unpaid...
status String @db.VarChar(20) status String @db.VarChar(20)
// subscription.current_period_start // subscription.current_period_start
start DateTime @map("start") @db.Timestamptz(6) start DateTime @map("start") @db.Timestamptz(6)
// subscription.current_period_end // subscription.current_period_end, null for lifetime payment
end DateTime @map("end") @db.Timestamptz(6) end DateTime? @map("end") @db.Timestamptz(6)
// subscription.billing_cycle_anchor // subscription.billing_cycle_anchor
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6) nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
// subscription.canceled_at // subscription.canceled_at

View File

@ -155,6 +155,25 @@ export const Quotas: Quota[] = [
copilotActionLimit: 10, 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) { export function getLatestQuota(type: QuotaType) {
@ -165,6 +184,7 @@ export function getLatestQuota(type: QuotaType) {
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1); export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1); export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
export const LifetimeProPlan = getLatestQuota(QuotaType.LifetimeProPlanV1);
export const Quota_FreePlanV1_1 = { export const Quota_FreePlanV1_1 = {
feature: Quotas[5].feature, feature: Quotas[5].feature,

View File

@ -3,7 +3,6 @@ import { PrismaClient } from '@prisma/client';
import type { EventPayload } from '../../fundamentals'; import type { EventPayload } from '../../fundamentals';
import { OnEvent, PrismaTransaction } from '../../fundamentals'; import { OnEvent, PrismaTransaction } from '../../fundamentals';
import { SubscriptionPlan } from '../../plugins/payment/types';
import { FeatureManagementService } from '../features/management'; import { FeatureManagementService } from '../features/management';
import { FeatureKind } from '../features/types'; import { FeatureKind } from '../features/types';
import { QuotaConfig } from './quota'; import { QuotaConfig } from './quota';
@ -152,15 +151,18 @@ export class QuotaService {
async onSubscriptionUpdated({ async onSubscriptionUpdated({
userId, userId,
plan, plan,
recurring,
}: EventPayload<'user.subscription.activated'>) { }: EventPayload<'user.subscription.activated'>) {
switch (plan) { switch (plan) {
case SubscriptionPlan.AI: case 'ai':
await this.feature.addCopilot(userId, 'subscription activated'); await this.feature.addCopilot(userId, 'subscription activated');
break; break;
case SubscriptionPlan.Pro: case 'pro':
await this.switchUserQuota( await this.switchUserQuota(
userId, userId,
QuotaType.ProPlanV1, recurring === 'lifetime'
? QuotaType.LifetimeProPlanV1
: QuotaType.ProPlanV1,
'subscription activated' 'subscription activated'
); );
break; break;
@ -175,16 +177,22 @@ export class QuotaService {
plan, plan,
}: EventPayload<'user.subscription.canceled'>) { }: EventPayload<'user.subscription.canceled'>) {
switch (plan) { switch (plan) {
case SubscriptionPlan.AI: case 'ai':
await this.feature.removeCopilot(userId); await this.feature.removeCopilot(userId);
break; break;
case SubscriptionPlan.Pro: case 'pro': {
await this.switchUserQuota( // edge case: when user switch from recurring Pro plan to `Lifetime` plan,
userId, // a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
QuotaType.FreePlanV1, const quota = await this.getUserQuota(userId);
'subscription canceled' if (quota.feature.name !== QuotaType.LifetimeProPlanV1) {
); await this.switchUserQuota(
userId,
QuotaType.FreePlanV1,
'subscription canceled'
);
}
break; break;
}
default: default:
break; break;
} }

View File

@ -17,6 +17,7 @@ import { ByteUnit, OneDay, OneKB } from './constant';
export enum QuotaType { export enum QuotaType {
FreePlanV1 = 'free_plan_v1', FreePlanV1 = 'free_plan_v1',
ProPlanV1 = 'pro_plan_v1', ProPlanV1 = 'pro_plan_v1',
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
// only for test, smaller quota // only for test, smaller quota
RestrictedPlanV1 = 'restricted_plan_v1', RestrictedPlanV1 = 'restricted_plan_v1',
} }
@ -25,6 +26,7 @@ const quotaPlan = z.object({
feature: z.enum([ feature: z.enum([
QuotaType.FreePlanV1, QuotaType.FreePlanV1,
QuotaType.ProPlanV1, QuotaType.ProPlanV1,
QuotaType.LifetimeProPlanV1,
QuotaType.RestrictedPlanV1, QuotaType.RestrictedPlanV1,
]), ]),
configs: z.object({ configs: z.object({

View File

@ -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) {}
}

View File

@ -408,6 +408,10 @@ export const USER_FRIENDLY_ERRORS = {
args: { plan: 'string', recurring: 'string' }, args: { plan: 'string', recurring: 'string' },
message: 'You are trying to access a unknown subscription plan.', 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 errors
copilot_session_not_found: { copilot_session_not_found: {

View File

@ -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 { export class CopilotSessionNotFound extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('resource_not_found', 'copilot_session_not_found', message); super('resource_not_found', 'copilot_session_not_found', message);
@ -521,6 +527,7 @@ export enum ErrorNames {
SAME_SUBSCRIPTION_RECURRING, SAME_SUBSCRIPTION_RECURRING,
CUSTOMER_PORTAL_CREATE_FAILED, CUSTOMER_PORTAL_CREATE_FAILED,
SUBSCRIPTION_PLAN_NOT_FOUND, SUBSCRIPTION_PLAN_NOT_FOUND,
CANT_UPDATE_LIFETIME_SUBSCRIPTION,
COPILOT_SESSION_NOT_FOUND, COPILOT_SESSION_NOT_FOUND,
COPILOT_SESSION_DELETED, COPILOT_SESSION_DELETED,
NO_COPILOT_PROVIDER_AVAILABLE, NO_COPILOT_PROVIDER_AVAILABLE,

View File

@ -53,12 +53,15 @@ class SubscriptionPrice {
@Field(() => Int, { nullable: true }) @Field(() => Int, { nullable: true })
yearlyAmount?: number | null; yearlyAmount?: number | null;
@Field(() => Int, { nullable: true })
lifetimeAmount?: number | null;
} }
@ObjectType('UserSubscription') @ObjectType('UserSubscription')
export class UserSubscriptionType implements Partial<UserSubscription> { export class UserSubscriptionType implements Partial<UserSubscription> {
@Field({ name: 'id' }) @Field(() => String, { name: 'id', nullable: true })
stripeSubscriptionId!: string; stripeSubscriptionId!: string | null;
@Field(() => SubscriptionPlan, { @Field(() => SubscriptionPlan, {
description: description:
@ -75,8 +78,8 @@ export class UserSubscriptionType implements Partial<UserSubscription> {
@Field(() => Date) @Field(() => Date)
start!: Date; start!: Date;
@Field(() => Date) @Field(() => Date, { nullable: true })
end!: Date; end!: Date | null;
@Field(() => Date, { nullable: true }) @Field(() => Date, { nullable: true })
trialStart?: Date | null; trialStart?: Date | null;
@ -187,11 +190,19 @@ export class SubscriptionResolver {
const monthlyPrice = prices.find(p => p.recurring?.interval === 'month'); const monthlyPrice = prices.find(p => p.recurring?.interval === 'month');
const yearlyPrice = prices.find(p => p.recurring?.interval === 'year'); 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'; const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd';
return { return {
currency, currency,
amount: monthlyPrice?.unit_amount, amount: monthlyPrice?.unit_amount,
yearlyAmount: yearlyPrice?.unit_amount, yearlyAmount: yearlyPrice?.unit_amount,
lifetimeAmount: lifetimePrice?.unit_amount,
}; };
} }

View File

@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter'; import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
import type { import type {
Prisma,
User, User,
UserInvoice, UserInvoice,
UserStripeCustomer, UserStripeCustomer,
@ -16,6 +15,7 @@ import { CurrentUser } from '../../core/auth';
import { EarlyAccessType, FeatureManagementService } from '../../core/features'; import { EarlyAccessType, FeatureManagementService } from '../../core/features';
import { import {
ActionForbidden, ActionForbidden,
CantUpdateLifetimeSubscription,
Config, Config,
CustomerPortalCreateFailed, CustomerPortalCreateFailed,
EventEmitter, EventEmitter,
@ -131,7 +131,11 @@ export class SubscriptionService {
} }
const [plan, recurring, variant] = decodeLookupKey(price.lookup_key); 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; 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 }); throw new SubscriptionAlreadyExists({ plan });
} }
@ -224,8 +233,19 @@ export class SubscriptionService {
tax_id_collection: { tax_id_collection: {
enabled: true, enabled: true,
}, },
// discount
...(discounts.length ? { discounts } : { allow_promotion_codes: true }), ...(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, success_url: redirectUrl,
customer: customer.stripeCustomerId, customer: customer.stripeCustomerId,
customer_update: { customer_update: {
@ -264,6 +284,12 @@ export class SubscriptionService {
throw new SubscriptionNotExists({ plan }); throw new SubscriptionNotExists({ plan });
} }
if (!subscriptionInDB.stripeSubscriptionId) {
throw new CantUpdateLifetimeSubscription(
'Lifetime subscription cannot be canceled.'
);
}
if (subscriptionInDB.canceledAt) { if (subscriptionInDB.canceledAt) {
throw new SubscriptionHasBeenCanceled(); throw new SubscriptionHasBeenCanceled();
} }
@ -315,6 +341,12 @@ export class SubscriptionService {
throw new SubscriptionNotExists({ plan }); throw new SubscriptionNotExists({ plan });
} }
if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) {
throw new CantUpdateLifetimeSubscription(
'Lifetime subscription cannot be resumed.'
);
}
if (!subscriptionInDB.canceledAt) { if (!subscriptionInDB.canceledAt) {
throw new SubscriptionHasBeenCanceled(); throw new SubscriptionHasBeenCanceled();
} }
@ -368,6 +400,12 @@ export class SubscriptionService {
throw new SubscriptionNotExists({ plan }); throw new SubscriptionNotExists({ plan });
} }
if (!subscriptionInDB.stripeSubscriptionId) {
throw new CantUpdateLifetimeSubscription(
'Can not update lifetime subscription.'
);
}
if (subscriptionInDB.canceledAt) { if (subscriptionInDB.canceledAt) {
throw new SubscriptionHasBeenCanceled(); 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.created')
@OnStripeEvent('invoice.updated')
@OnStripeEvent('invoice.finalization_failed') @OnStripeEvent('invoice.finalization_failed')
@OnStripeEvent('invoice.payment_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); stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id);
if (!stripeInvoice.customer) { if (!stripeInvoice.customer) {
throw new Error('Unexpected invoice with no customer'); throw new Error('Unexpected invoice with no customer');
@ -487,12 +477,6 @@ export class SubscriptionService {
: stripeInvoice.customer.id : stripeInvoice.customer.id
); );
const invoice = await this.db.userInvoice.findUnique({
where: {
stripeInvoiceId: stripeInvoice.id,
},
});
const data: Partial<UserInvoice> = { const data: Partial<UserInvoice> = {
currency: stripeInvoice.currency, currency: stripeInvoice.currency,
amount: stripeInvoice.total, amount: stripeInvoice.total,
@ -524,39 +508,135 @@ export class SubscriptionService {
} }
} }
// update invoice // create invoice
if (invoice) { const price = stripeInvoice.lines.data[0].price;
await this.db.userInvoice.update({
where: { if (!price) {
stripeInvoiceId: stripeInvoice.id, 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 { } else {
// create invoice await this.db.userSubscription.create({
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({
data: { data: {
userId: user.id, userId: user.id,
stripeInvoiceId: stripeInvoice.id, stripeSubscriptionId: null,
plan, plan: invoice.plan,
recurring, recurring: invoice.recurring,
reason: stripeInvoice.billing_reason ?? 'contact support', end: null,
...(data as any), 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( private async saveSubscription(
@ -576,6 +656,7 @@ export class SubscriptionService {
this.event.emit('user.subscription.activated', { this.event.emit('user.subscription.activated', {
userId: user.id, userId: user.id,
plan, plan,
recurring,
}); });
let nextBillAt: Date | null = null; let nextBillAt: Date | null = null;
@ -600,44 +681,21 @@ export class SubscriptionService {
: null, : null,
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
plan, plan,
recurring,
status: subscription.status, status: subscription.status,
stripeScheduleId: subscription.schedule as string | null, stripeScheduleId: subscription.schedule as string | null,
}; };
const currentSubscription = await this.db.userSubscription.findUnique({ return await this.db.userSubscription.upsert({
where: { where: {
userId_plan: { stripeSubscriptionId: subscription.id,
userId: user.id, },
plan, 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( private async getOrCreateCustomer(

View File

@ -5,6 +5,7 @@ import type { Payload } from '../../fundamentals/event/def';
export enum SubscriptionRecurring { export enum SubscriptionRecurring {
Monthly = 'monthly', Monthly = 'monthly',
Yearly = 'yearly', Yearly = 'yearly',
Lifetime = 'lifetime',
} }
export enum SubscriptionPlan { export enum SubscriptionPlan {
@ -46,10 +47,12 @@ declare module '../../fundamentals/event/def' {
activated: Payload<{ activated: Payload<{
userId: User['id']; userId: User['id'];
plan: SubscriptionPlan; plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
}>; }>;
canceled: Payload<{ canceled: Payload<{
userId: User['id']; userId: User['id'];
plan: SubscriptionPlan; plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
}>; }>;
}; };
} }

View File

@ -45,9 +45,16 @@ export class StripeWebhook {
setImmediate(() => { setImmediate(() => {
// handle duplicated events? // handle duplicated events?
// see https://stripe.com/docs/webhooks#handle-duplicate-events // see https://stripe.com/docs/webhooks#handle-duplicate-events
this.event.emitAsync(event.type, event.data.object).catch(e => { this.event
this.logger.error('Failed to handle Stripe Webhook event.', e); .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) { } catch (err: any) {
throw new InternalServerError(err.message); throw new InternalServerError(err.message);

View File

@ -189,6 +189,7 @@ enum ErrorNames {
BLOB_NOT_FOUND BLOB_NOT_FOUND
BLOB_QUOTA_EXCEEDED BLOB_QUOTA_EXCEEDED
CANT_CHANGE_WORKSPACE_OWNER CANT_CHANGE_WORKSPACE_OWNER
CANT_UPDATE_LIFETIME_SUBSCRIPTION
COPILOT_ACTION_TAKEN COPILOT_ACTION_TAKEN
COPILOT_FAILED_TO_CREATE_MESSAGE COPILOT_FAILED_TO_CREATE_MESSAGE
COPILOT_FAILED_TO_GENERATE_TEXT COPILOT_FAILED_TO_GENERATE_TEXT
@ -657,12 +658,14 @@ type SubscriptionPlanNotFoundDataType {
type SubscriptionPrice { type SubscriptionPrice {
amount: Int amount: Int
currency: String! currency: String!
lifetimeAmount: Int
plan: SubscriptionPlan! plan: SubscriptionPlan!
type: String! type: String!
yearlyAmount: Int yearlyAmount: Int
} }
enum SubscriptionRecurring { enum SubscriptionRecurring {
Lifetime
Monthly Monthly
Yearly Yearly
} }
@ -733,8 +736,8 @@ type UserQuotaHumanReadable {
type UserSubscription { type UserSubscription {
canceledAt: DateTime canceledAt: DateTime
createdAt: DateTime! createdAt: DateTime!
end: DateTime! end: DateTime
id: String! id: String
nextBillAt: DateTime nextBillAt: DateTime
""" """

View File

@ -84,6 +84,7 @@ test.afterEach.always(async t => {
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`; const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`; 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}_${SubscriptionPriceVariant.EA}`;
const AI_YEARLY = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`; 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}_${SubscriptionPriceVariant.EA}`;
@ -105,6 +106,11 @@ const PRICES = {
currency: 'usd', currency: 'usd',
lookup_key: PRO_YEARLY, lookup_key: PRO_YEARLY,
}, },
[PRO_LIFETIME]: {
unit_amount: 49900,
currency: 'usd',
lookup_key: PRO_LIFETIME,
},
[PRO_EA_YEARLY]: { [PRO_EA_YEARLY]: {
recurring: { recurring: {
interval: 'year', interval: 'year',
@ -170,10 +176,9 @@ test('should list normal price for unauthenticated user', async t => {
const prices = await service.listPrices(); const prices = await service.listPrices();
t.is(prices.length, 3);
t.deepEqual( t.deepEqual(
new Set(prices.map(p => p.lookup_key)), 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); const prices = await service.listPrices(u1);
t.is(prices.length, 3);
t.deepEqual( t.deepEqual(
new Set(prices.map(p => p.lookup_key)), 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); const prices = await service.listPrices(u1);
t.is(prices.length, 3);
t.deepEqual( t.deepEqual(
new Set(prices.map(p => p.lookup_key)), 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); const prices = await service.listPrices(u1);
t.is(prices.length, 3);
t.deepEqual( t.deepEqual(
new Set(prices.map(p => p.lookup_key)), 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); const prices = await service.listPrices(u1);
t.is(prices.length, 3);
t.deepEqual( t.deepEqual(
new Set(prices.map(p => p.lookup_key)), 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); const prices = await service.listPrices(u1);
t.is(prices.length, 3);
t.deepEqual( t.deepEqual(
new Set(prices.map(p => p.lookup_key)), 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); const prices = await service.listPrices(u1);
t.is(prices.length, 3);
t.deepEqual( t.deepEqual(
new Set(prices.map(p => p.lookup_key)), 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, 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 => { 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', { emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id, userId: u1.id,
plan: SubscriptionPlan.Pro, plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
}) })
); );
@ -674,6 +690,7 @@ test('should be able to update subscription', async t => {
emitStub.calledOnceWith('user.subscription.activated', { emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id, userId: u1.id,
plan: SubscriptionPlan.Pro, plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
}) })
); );
@ -706,6 +723,7 @@ test('should be able to delete subscription', async t => {
emitStub.calledOnceWith('user.subscription.canceled', { emitStub.calledOnceWith('user.subscription.canceled', {
userId: u1.id, userId: u1.id,
plan: SubscriptionPlan.Pro, plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
}) })
); );
@ -749,6 +767,7 @@ test('should be able to cancel subscription', async t => {
emitStub.calledOnceWith('user.subscription.activated', { emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id, userId: u1.id,
plan: SubscriptionPlan.Pro, plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
}) })
); );
@ -785,6 +804,7 @@ test('should be able to resume subscription', async t => {
emitStub.calledOnceWith('user.subscription.activated', { emitStub.calledOnceWith('user.subscription.activated', {
userId: u1.id, userId: u1.id,
plan: SubscriptionPlan.Pro, 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.firstCall.args[1], sub);
t.deepEqual(stub.secondCall.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.' }
);
});

View File

@ -38,6 +38,7 @@ enum DescriptionI18NKey {
Basic = 'com.affine.payment.billing-setting.current-plan.description', Basic = 'com.affine.payment.billing-setting.current-plan.description',
Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly', Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly',
Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly', 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; const INVOICE_PAGE_SIZE = 12;
@ -204,7 +205,7 @@ const SubscriptionSettings = () => {
})} })}
/> />
)} )}
{proSubscription.canceledAt ? ( {proSubscription.end && proSubscription.canceledAt ? (
<SettingRow <SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()} name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t[ desc={t[

View File

@ -259,6 +259,7 @@ export enum ErrorNames {
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND', BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED', BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
CANT_CHANGE_WORKSPACE_OWNER = 'CANT_CHANGE_WORKSPACE_OWNER', CANT_CHANGE_WORKSPACE_OWNER = 'CANT_CHANGE_WORKSPACE_OWNER',
CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION',
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE', COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT', COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT',
@ -950,12 +951,14 @@ export interface SubscriptionPrice {
__typename?: 'SubscriptionPrice'; __typename?: 'SubscriptionPrice';
amount: Maybe<Scalars['Int']['output']>; amount: Maybe<Scalars['Int']['output']>;
currency: Scalars['String']['output']; currency: Scalars['String']['output'];
lifetimeAmount: Maybe<Scalars['Int']['output']>;
plan: SubscriptionPlan; plan: SubscriptionPlan;
type: Scalars['String']['output']; type: Scalars['String']['output'];
yearlyAmount: Maybe<Scalars['Int']['output']>; yearlyAmount: Maybe<Scalars['Int']['output']>;
} }
export enum SubscriptionRecurring { export enum SubscriptionRecurring {
Lifetime = 'Lifetime',
Monthly = 'Monthly', Monthly = 'Monthly',
Yearly = 'Yearly', Yearly = 'Yearly',
} }
@ -1027,8 +1030,8 @@ export interface UserSubscription {
__typename?: 'UserSubscription'; __typename?: 'UserSubscription';
canceledAt: Maybe<Scalars['DateTime']['output']>; canceledAt: Maybe<Scalars['DateTime']['output']>;
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
end: Scalars['DateTime']['output']; end: Maybe<Scalars['DateTime']['output']>;
id: Scalars['String']['output']; id: Maybe<Scalars['String']['output']>;
nextBillAt: Maybe<Scalars['DateTime']['output']>; nextBillAt: Maybe<Scalars['DateTime']['output']>;
/** /**
* The 'Free' plan just exists to be a placeholder and for the type convenience of frontend. * 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'; __typename?: 'Mutation';
cancelSubscription: { cancelSubscription: {
__typename?: 'UserSubscription'; __typename?: 'UserSubscription';
id: string; id: string | null;
status: SubscriptionStatus; status: SubscriptionStatus;
nextBillAt: string | null; nextBillAt: string | null;
canceledAt: string | null; canceledAt: string | null;
@ -1347,7 +1350,7 @@ export type EarlyAccessUsersQuery = {
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
status: SubscriptionStatus; status: SubscriptionStatus;
start: string; start: string;
end: string; end: string | null;
} | null; } | null;
}>; }>;
}; };
@ -1834,11 +1837,11 @@ export type ResumeSubscriptionMutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
resumeSubscription: { resumeSubscription: {
__typename?: 'UserSubscription'; __typename?: 'UserSubscription';
id: string; id: string | null;
status: SubscriptionStatus; status: SubscriptionStatus;
nextBillAt: string | null; nextBillAt: string | null;
start: string; start: string;
end: string; end: string | null;
}; };
}; };
@ -1955,12 +1958,12 @@ export type SubscriptionQuery = {
id: string; id: string;
subscriptions: Array<{ subscriptions: Array<{
__typename?: 'UserSubscription'; __typename?: 'UserSubscription';
id: string; id: string | null;
status: SubscriptionStatus; status: SubscriptionStatus;
plan: SubscriptionPlan; plan: SubscriptionPlan;
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
start: string; start: string;
end: string; end: string | null;
nextBillAt: string | null; nextBillAt: string | null;
canceledAt: string | null; canceledAt: string | null;
}>; }>;
@ -1990,7 +1993,7 @@ export type UpdateSubscriptionMutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
updateSubscriptionRecurring: { updateSubscriptionRecurring: {
__typename?: 'UserSubscription'; __typename?: 'UserSubscription';
id: string; id: string | null;
plan: SubscriptionPlan; plan: SubscriptionPlan;
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
nextBillAt: string | null; nextBillAt: string | null;

View File

@ -941,6 +941,7 @@
"com.affine.payment.billing-setting.current-plan.description": "You are currently on the <1>{{planName}} plan</1>.", "com.affine.payment.billing-setting.current-plan.description": "You are currently on the <1>{{planName}} plan</1>.",
"com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan</1>.", "com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan</1>.",
"com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan</1>.", "com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan</1>.",
"com.affine.payment.billing-setting.current-plan.description.lifetime": "You are currently on the believer <1>{{planName}} plan</1>.",
"com.affine.payment.billing-setting.expiration-date": "Expiration Date", "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.expiration-date.description": "Your subscription is valid until {{expirationDate}}",
"com.affine.payment.billing-setting.history": "Billing history", "com.affine.payment.billing-setting.history": "Billing history",