mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-26 12:38:03 +03:00
feat(server): support lifetime subscription (#7405)
closes CLOUD-48 - [x] lifetime subscription quota - [ ] tests
This commit is contained in:
parent
7235779b02
commit
de91027852
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_subscriptions" ALTER COLUMN "stripe_subscription_id" DROP NOT NULL,
|
||||
ALTER COLUMN "end" DROP NOT NULL;
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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) {}
|
||||
}
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
||||
"""
|
||||
|
@ -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.' }
|
||||
);
|
||||
});
|
||||
|
@ -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[
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user