feat: add workspace subscription

This commit is contained in:
moonrailgun 2024-10-07 16:58:00 +08:00
parent 76b5d1bb1e
commit e22cf8d555
11 changed files with 180 additions and 18 deletions

View File

@ -86,7 +86,6 @@ function PageComponent() {
export const BillingPlayground: React.FC = React.memo(() => {
const checkoutMutation = trpc.billing.checkout.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const changePlanMutation = trpc.billing.changePlan.useMutation({

View File

@ -6,8 +6,11 @@ import {
} from '@lemonsqueezy/lemonsqueezy.js';
import { env } from '../../utils/env.js';
import { prisma } from '../_client.js';
import { WorkspaceSubscriptionTier } from '@prisma/client';
if (env.billing.lemonSqueezy.apiKey) {
export const billingAvailable = Boolean(env.billing.lemonSqueezy.apiKey);
if (billingAvailable) {
lemonSqueezySetup({
apiKey: env.billing.lemonSqueezy.apiKey,
onError: (error) => console.error('Error!', error),
@ -25,12 +28,28 @@ export function getTierNameByvariantId(variantId: string) {
);
if (!tierName) {
throw 'Unknown';
throw new Error('Unknown Tier Name');
}
return tierName;
}
export function getTierEnumByVariantId(
variantId: string
): WorkspaceSubscriptionTier {
const name = getTierNameByvariantId(variantId);
if (name === 'free') {
return WorkspaceSubscriptionTier.FREE;
} else if (name === 'pro') {
return WorkspaceSubscriptionTier.PRO;
} else if (name === 'team') {
return WorkspaceSubscriptionTier.TEAM;
}
return WorkspaceSubscriptionTier.FREE; // not cool, fallback to free
}
export function checkIsValidProduct(storeId: string, variantId: string) {
if (String(storeId) !== env.billing.lemonSqueezy.storeId) {
return false;
@ -104,6 +123,26 @@ export async function createCheckoutBilling(
return checkoutData;
}
export async function updateWorkspaceSubscription(
workspaceId: string,
subscriptionTier: WorkspaceSubscriptionTier
) {
const res = await prisma.workspaceSubscription.upsert({
where: {
workspaceId,
},
create: {
workspaceId,
tier: subscriptionTier,
},
update: {
tier: subscriptionTier,
},
});
return res;
}
export async function changeSubscription(
workspaceId: string,
subscriptionTier: SubscriptionTierType

View File

@ -0,0 +1,57 @@
import { TierType } from './types.js';
interface TierLimit {
maxWebsiteCount: number;
maxWebsiteEventCount: number;
maxMonitorExecutionCount: number;
maxSurveyCount: number;
maxFeedChannelCount: number;
maxFeedEventCount: number;
}
/**
* Limit, Every month
*/
export function getTierLimit(tier: TierType): TierLimit {
if (tier === 'free') {
return {
maxWebsiteCount: 3,
maxWebsiteEventCount: 100_000,
maxMonitorExecutionCount: 100_000,
maxSurveyCount: 3,
maxFeedChannelCount: 3,
maxFeedEventCount: 10_000,
};
}
if (tier === 'pro') {
return {
maxWebsiteCount: 10,
maxWebsiteEventCount: 1_000_000,
maxMonitorExecutionCount: 1_000_000,
maxSurveyCount: 20,
maxFeedChannelCount: 20,
maxFeedEventCount: 100_000,
};
}
if (tier === 'team') {
return {
maxWebsiteCount: -1,
maxWebsiteEventCount: 20_000_000,
maxMonitorExecutionCount: 20_000_000,
maxSurveyCount: -1,
maxFeedChannelCount: -1,
maxFeedEventCount: 1_000_000,
};
}
return {
maxWebsiteCount: -1,
maxWebsiteEventCount: -1,
maxMonitorExecutionCount: -1,
maxSurveyCount: -1,
maxFeedChannelCount: -1,
maxFeedEventCount: -1,
};
}

View File

@ -0,0 +1 @@
export type TierType = 'free' | 'pro' | 'team' | 'unlimited';

View File

@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "WorkspaceSubscriptionTier" AS ENUM ('FREE', 'PRO', 'TEAM', 'UNLIMITED');
-- CreateTable
CREATE TABLE "WorkspaceSubscription" (
"id" VARCHAR(30) NOT NULL,
"workspaceId" VARCHAR(30) NOT NULL,
"tier" "WorkspaceSubscriptionTier" NOT NULL DEFAULT 'FREE',
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "WorkspaceSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkspaceSubscription_workspaceId_key" ON "WorkspaceSubscription"("workspaceId");
-- AddForeignKey
ALTER TABLE "WorkspaceSubscription" ADD CONSTRAINT "WorkspaceSubscription_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Set admin workspace to UNLIMITED
INSERT INTO "WorkspaceSubscription" ("id", "workspaceId", "tier", "createdAt", "updatedAt") VALUES ('cm1yqv4xd002154qnfhzg9i5d', 'clnzoxcy10001vy2ohi4obbi0', 'UNLIMITED', '2024-10-07 08:22:45.169+00', '2024-10-07 08:22:45.169+00');

View File

@ -99,6 +99,8 @@ model Workspace {
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
subscription WorkspaceSubscription?
users WorkspacesOnUsers[]
websites Website[]
notifications Notification[]
@ -128,6 +130,24 @@ model WorkspacesOnUsers {
@@index([workspaceId])
}
enum WorkspaceSubscriptionTier {
FREE
PRO
TEAM
UNLIMITED // This type should only use for special people or admin workspace
}
model WorkspaceSubscription {
id String @id() @default(cuid()) @db.VarChar(30)
workspaceId String @unique @db.VarChar(30)
tier WorkspaceSubscriptionTier @default(FREE) // free, pro, team
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onUpdate: Cascade, onDelete: Cascade)
}
model LemonSqueezySubscription {
subscriptionId String @id @unique
workspaceId String @unique @db.VarChar(30)

View File

@ -5,6 +5,7 @@ export * from "./session.js"
export * from "./verificationtoken.js"
export * from "./workspace.js"
export * from "./workspacesonusers.js"
export * from "./workspacesubscription.js"
export * from "./lemonsqueezysubscription.js"
export * from "./lemonsqueezywebhookevent.js"
export * from "./website.js"

View File

@ -1,13 +0,0 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { TransactionStatus } from "@prisma/client"
export const LemonSqueezyTransactionModelSchema = z.object({
id: z.string(),
userId: z.string(),
workspaceId: z.string(),
checkoutId: z.string(),
status: z.nativeEnum(TransactionStatus),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"
import { CompleteWorkspaceSubscription, RelatedWorkspaceSubscriptionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"
// Helper schema for JSON fields
type Literal = boolean | number | string
@ -25,6 +25,7 @@ export const WorkspaceModelSchema = z.object({
})
export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema> {
subscription?: CompleteWorkspaceSubscription | null
users: CompleteWorkspacesOnUsers[]
websites: CompleteWebsite[]
notifications: CompleteNotification[]
@ -43,6 +44,7 @@ export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema>
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.lazy(() => WorkspaceModelSchema.extend({
subscription: RelatedWorkspaceSubscriptionModelSchema.nullish(),
users: RelatedWorkspacesOnUsersModelSchema.array(),
websites: RelatedWebsiteModelSchema.array(),
notifications: RelatedNotificationModelSchema.array(),

View File

@ -0,0 +1,25 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { WorkspaceSubscriptionTier } from "@prisma/client"
import { CompleteWorkspace, RelatedWorkspaceModelSchema } from "./index.js"
export const WorkspaceSubscriptionModelSchema = z.object({
id: z.string(),
workspaceId: z.string(),
tier: z.nativeEnum(WorkspaceSubscriptionTier),
createdAt: z.date(),
updatedAt: z.date(),
})
export interface CompleteWorkspaceSubscription extends z.infer<typeof WorkspaceSubscriptionModelSchema> {
workspace: CompleteWorkspace
}
/**
* RelatedWorkspaceSubscriptionModelSchema contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedWorkspaceSubscriptionModelSchema: z.ZodSchema<CompleteWorkspaceSubscription> = z.lazy(() => WorkspaceSubscriptionModelSchema.extend({
workspace: RelatedWorkspaceModelSchema,
}))

View File

@ -2,7 +2,11 @@ import { Router, raw } from 'express';
import crypto from 'crypto';
import { env } from '../utils/env.js';
import { get } from 'lodash-es';
import { checkIsValidProduct } from '../model/billing/index.js';
import {
checkIsValidProduct,
getTierEnumByVariantId,
updateWorkspaceSubscription,
} from '../model/billing/index.js';
import { prisma } from '../model/_client.js';
import dayjs from 'dayjs';
@ -75,6 +79,10 @@ billingRouter.post(
subscriptionId,
},
});
await updateWorkspaceSubscription(
workspaceId,
getTierEnumByVariantId(variantId)
);
}
res.status(200).send('OK');