Skip to content

Commit

Permalink
feat(server): support lifetime subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Jul 5, 2024
1 parent 481a226 commit 829e4be
Show file tree
Hide file tree
Showing 17 changed files with 446 additions and 164 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "user_subscriptions" ALTER COLUMN "stripe_subscription_id" DROP NOT NULL,
ALTER COLUMN "end" DROP NOT NULL;
8 changes: 4 additions & 4 deletions packages/backend/server/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/backend/server/src/core/quota/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 100GB
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) {
Expand All @@ -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,
Expand Down
30 changes: 19 additions & 11 deletions packages/backend/server/src/core/quota/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/server/src/core/quota/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand All @@ -25,6 +26,7 @@ const quotaPlan = z.object({
feature: z.enum([
QuotaType.FreePlanV1,
QuotaType.ProPlanV1,
QuotaType.LifetimeProPlanV1,
QuotaType.RestrictedPlanV1,
]),
configs: z.object({
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
}
4 changes: 4 additions & 0 deletions packages/backend/server/src/fundamentals/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/server/src/fundamentals/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/backend/server/src/plugins/payment/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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;
Expand Down Expand Up @@ -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,
};
}

Expand Down
Loading

0 comments on commit 829e4be

Please sign in to comment.