feat: add workspace subscription
This commit is contained in:
parent
76b5d1bb1e
commit
e22cf8d555
@ -86,7 +86,6 @@ function PageComponent() {
|
|||||||
|
|
||||||
export const BillingPlayground: React.FC = React.memo(() => {
|
export const BillingPlayground: React.FC = React.memo(() => {
|
||||||
const checkoutMutation = trpc.billing.checkout.useMutation({
|
const checkoutMutation = trpc.billing.checkout.useMutation({
|
||||||
onSuccess: defaultSuccessHandler,
|
|
||||||
onError: defaultErrorHandler,
|
onError: defaultErrorHandler,
|
||||||
});
|
});
|
||||||
const changePlanMutation = trpc.billing.changePlan.useMutation({
|
const changePlanMutation = trpc.billing.changePlan.useMutation({
|
||||||
|
@ -6,8 +6,11 @@ import {
|
|||||||
} from '@lemonsqueezy/lemonsqueezy.js';
|
} from '@lemonsqueezy/lemonsqueezy.js';
|
||||||
import { env } from '../../utils/env.js';
|
import { env } from '../../utils/env.js';
|
||||||
import { prisma } from '../_client.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({
|
lemonSqueezySetup({
|
||||||
apiKey: env.billing.lemonSqueezy.apiKey,
|
apiKey: env.billing.lemonSqueezy.apiKey,
|
||||||
onError: (error) => console.error('Error!', error),
|
onError: (error) => console.error('Error!', error),
|
||||||
@ -25,12 +28,28 @@ export function getTierNameByvariantId(variantId: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!tierName) {
|
if (!tierName) {
|
||||||
throw 'Unknown';
|
throw new Error('Unknown Tier Name');
|
||||||
}
|
}
|
||||||
|
|
||||||
return tierName;
|
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) {
|
export function checkIsValidProduct(storeId: string, variantId: string) {
|
||||||
if (String(storeId) !== env.billing.lemonSqueezy.storeId) {
|
if (String(storeId) !== env.billing.lemonSqueezy.storeId) {
|
||||||
return false;
|
return false;
|
||||||
@ -104,6 +123,26 @@ export async function createCheckoutBilling(
|
|||||||
return checkoutData;
|
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(
|
export async function changeSubscription(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
subscriptionTier: SubscriptionTierType
|
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)
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
|
||||||
|
subscription WorkspaceSubscription?
|
||||||
|
|
||||||
users WorkspacesOnUsers[]
|
users WorkspacesOnUsers[]
|
||||||
websites Website[]
|
websites Website[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
@ -128,6 +130,24 @@ model WorkspacesOnUsers {
|
|||||||
@@index([workspaceId])
|
@@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 {
|
model LemonSqueezySubscription {
|
||||||
subscriptionId String @id @unique
|
subscriptionId String @id @unique
|
||||||
workspaceId String @unique @db.VarChar(30)
|
workspaceId String @unique @db.VarChar(30)
|
||||||
|
@ -5,6 +5,7 @@ export * from "./session.js"
|
|||||||
export * from "./verificationtoken.js"
|
export * from "./verificationtoken.js"
|
||||||
export * from "./workspace.js"
|
export * from "./workspace.js"
|
||||||
export * from "./workspacesonusers.js"
|
export * from "./workspacesonusers.js"
|
||||||
|
export * from "./workspacesubscription.js"
|
||||||
export * from "./lemonsqueezysubscription.js"
|
export * from "./lemonsqueezysubscription.js"
|
||||||
export * from "./lemonsqueezywebhookevent.js"
|
export * from "./lemonsqueezywebhookevent.js"
|
||||||
export * from "./website.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 z from "zod"
|
||||||
import * as imports from "./schemas/index.js"
|
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
|
// Helper schema for JSON fields
|
||||||
type Literal = boolean | number | string
|
type Literal = boolean | number | string
|
||||||
@ -25,6 +25,7 @@ export const WorkspaceModelSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema> {
|
export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema> {
|
||||||
|
subscription?: CompleteWorkspaceSubscription | null
|
||||||
users: CompleteWorkspacesOnUsers[]
|
users: CompleteWorkspacesOnUsers[]
|
||||||
websites: CompleteWebsite[]
|
websites: CompleteWebsite[]
|
||||||
notifications: CompleteNotification[]
|
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
|
* NOTE: Lazy required in case of potential circular dependencies within schema
|
||||||
*/
|
*/
|
||||||
export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.lazy(() => WorkspaceModelSchema.extend({
|
export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.lazy(() => WorkspaceModelSchema.extend({
|
||||||
|
subscription: RelatedWorkspaceSubscriptionModelSchema.nullish(),
|
||||||
users: RelatedWorkspacesOnUsersModelSchema.array(),
|
users: RelatedWorkspacesOnUsersModelSchema.array(),
|
||||||
websites: RelatedWebsiteModelSchema.array(),
|
websites: RelatedWebsiteModelSchema.array(),
|
||||||
notifications: RelatedNotificationModelSchema.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 crypto from 'crypto';
|
||||||
import { env } from '../utils/env.js';
|
import { env } from '../utils/env.js';
|
||||||
import { get } from 'lodash-es';
|
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 { prisma } from '../model/_client.js';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
@ -75,6 +79,10 @@ billingRouter.post(
|
|||||||
subscriptionId,
|
subscriptionId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await updateWorkspaceSubscription(
|
||||||
|
workspaceId,
|
||||||
|
getTierEnumByVariantId(variantId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send('OK');
|
res.status(200).send('OK');
|
||||||
|
Loading…
Reference in New Issue
Block a user