feat: add workspace subscription
This commit is contained in:
parent
fa1ff3b5f6
commit
e4b98b1c36
@ -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({
|
||||
|
@ -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
|
||||
|
57
src/server/model/billing/limit.ts
Normal file
57
src/server/model/billing/limit.ts
Normal 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,
|
||||
};
|
||||
}
|
1
src/server/model/billing/types.ts
Normal file
1
src/server/model/billing/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type TierType = 'free' | 'pro' | 'team' | 'unlimited';
|
@ -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');
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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(),
|
||||
})
|
@ -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(),
|
||||
|
25
src/server/prisma/zod/workspacesubscription.ts
Normal file
25
src/server/prisma/zod/workspacesubscription.ts
Normal 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,
|
||||
}))
|
@ -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');
|
||||
|
Loading…
Reference in New Issue
Block a user