From e22cf8d5550619fa2c7b2903b826532adbcc26a1 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Mon, 7 Oct 2024 16:58:00 +0800 Subject: [PATCH] feat: add workspace subscription --- src/client/routes/playground.tsx | 1 - src/server/model/billing/index.ts | 43 +++++++++++++- src/server/model/billing/limit.ts | 57 +++++++++++++++++++ src/server/model/billing/types.ts | 1 + .../migration.sql | 23 ++++++++ src/server/prisma/schema.prisma | 20 +++++++ src/server/prisma/zod/index.ts | 1 + .../prisma/zod/lemonsqueezytransaction.ts | 13 ----- src/server/prisma/zod/workspace.ts | 4 +- .../prisma/zod/workspacesubscription.ts | 25 ++++++++ src/server/router/billing.ts | 10 +++- 11 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 src/server/model/billing/limit.ts create mode 100644 src/server/model/billing/types.ts create mode 100644 src/server/prisma/migrations/20241007083106_add_workspace_subscription/migration.sql delete mode 100644 src/server/prisma/zod/lemonsqueezytransaction.ts create mode 100644 src/server/prisma/zod/workspacesubscription.ts diff --git a/src/client/routes/playground.tsx b/src/client/routes/playground.tsx index ac238d0..855ee80 100644 --- a/src/client/routes/playground.tsx +++ b/src/client/routes/playground.tsx @@ -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({ diff --git a/src/server/model/billing/index.ts b/src/server/model/billing/index.ts index 9e69434..d07020d 100644 --- a/src/server/model/billing/index.ts +++ b/src/server/model/billing/index.ts @@ -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 diff --git a/src/server/model/billing/limit.ts b/src/server/model/billing/limit.ts new file mode 100644 index 0000000..08a841f --- /dev/null +++ b/src/server/model/billing/limit.ts @@ -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, + }; +} diff --git a/src/server/model/billing/types.ts b/src/server/model/billing/types.ts new file mode 100644 index 0000000..034724e --- /dev/null +++ b/src/server/model/billing/types.ts @@ -0,0 +1 @@ +export type TierType = 'free' | 'pro' | 'team' | 'unlimited'; diff --git a/src/server/prisma/migrations/20241007083106_add_workspace_subscription/migration.sql b/src/server/prisma/migrations/20241007083106_add_workspace_subscription/migration.sql new file mode 100644 index 0000000..1ab2ad2 --- /dev/null +++ b/src/server/prisma/migrations/20241007083106_add_workspace_subscription/migration.sql @@ -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'); diff --git a/src/server/prisma/schema.prisma b/src/server/prisma/schema.prisma index 7ea7b2a..e60cf59 100644 --- a/src/server/prisma/schema.prisma +++ b/src/server/prisma/schema.prisma @@ -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) diff --git a/src/server/prisma/zod/index.ts b/src/server/prisma/zod/index.ts index f1285f4..ce05e4e 100644 --- a/src/server/prisma/zod/index.ts +++ b/src/server/prisma/zod/index.ts @@ -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" diff --git a/src/server/prisma/zod/lemonsqueezytransaction.ts b/src/server/prisma/zod/lemonsqueezytransaction.ts deleted file mode 100644 index 59e1bd8..0000000 --- a/src/server/prisma/zod/lemonsqueezytransaction.ts +++ /dev/null @@ -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(), -}) diff --git a/src/server/prisma/zod/workspace.ts b/src/server/prisma/zod/workspace.ts index 6b3ae0e..df179c2 100644 --- a/src/server/prisma/zod/workspace.ts +++ b/src/server/prisma/zod/workspace.ts @@ -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 { + subscription?: CompleteWorkspaceSubscription | null users: CompleteWorkspacesOnUsers[] websites: CompleteWebsite[] notifications: CompleteNotification[] @@ -43,6 +44,7 @@ export interface CompleteWorkspace extends z.infer * NOTE: Lazy required in case of potential circular dependencies within schema */ export const RelatedWorkspaceModelSchema: z.ZodSchema = z.lazy(() => WorkspaceModelSchema.extend({ + subscription: RelatedWorkspaceSubscriptionModelSchema.nullish(), users: RelatedWorkspacesOnUsersModelSchema.array(), websites: RelatedWebsiteModelSchema.array(), notifications: RelatedNotificationModelSchema.array(), diff --git a/src/server/prisma/zod/workspacesubscription.ts b/src/server/prisma/zod/workspacesubscription.ts new file mode 100644 index 0000000..3870ac9 --- /dev/null +++ b/src/server/prisma/zod/workspacesubscription.ts @@ -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 { + 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 = z.lazy(() => WorkspaceSubscriptionModelSchema.extend({ + workspace: RelatedWorkspaceModelSchema, +})) diff --git a/src/server/router/billing.ts b/src/server/router/billing.ts index 216a67f..b3a7626 100644 --- a/src/server/router/billing.ts +++ b/src/server/router/billing.ts @@ -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');