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)
// 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

View File

@ -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,

View File

@ -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;
}

View File

@ -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({

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' },
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: {

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 {
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,

View File

@ -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<UserSubscription> {
@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<UserSubscription> {
@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,
};
}

View File

@ -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<UserInvoice> = {
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(

View File

@ -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;
}>;
};
}

View File

@ -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);

View File

@ -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
"""

View File

@ -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.' }
);
});

View File

@ -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 ? (
<SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t[

View File

@ -259,6 +259,7 @@ export 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 = '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',
@ -950,12 +951,14 @@ export interface SubscriptionPrice {
__typename?: 'SubscriptionPrice';
amount: Maybe<Scalars['Int']['output']>;
currency: Scalars['String']['output'];
lifetimeAmount: Maybe<Scalars['Int']['output']>;
plan: SubscriptionPlan;
type: Scalars['String']['output'];
yearlyAmount: Maybe<Scalars['Int']['output']>;
}
export enum SubscriptionRecurring {
Lifetime = 'Lifetime',
Monthly = 'Monthly',
Yearly = 'Yearly',
}
@ -1027,8 +1030,8 @@ export interface UserSubscription {
__typename?: 'UserSubscription';
canceledAt: Maybe<Scalars['DateTime']['output']>;
createdAt: Scalars['DateTime']['output'];
end: Scalars['DateTime']['output'];
id: Scalars['String']['output'];
end: Maybe<Scalars['DateTime']['output']>;
id: Maybe<Scalars['String']['output']>;
nextBillAt: Maybe<Scalars['DateTime']['output']>;
/**
* 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;

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.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.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.description": "Your subscription is valid until {{expirationDate}}",
"com.affine.payment.billing-setting.history": "Billing history",