Compare commits

...

4 Commits

Author SHA1 Message Date
moonrailgun
e22cf8d555 feat: add workspace subscription 2024-11-06 01:09:50 +08:00
moonrailgun
76b5d1bb1e refactor: move billing mode inside folder 2024-11-06 01:09:50 +08:00
moonrailgun
6f89d0f2d3 refactor: add apikey check before setup 2024-11-06 01:09:50 +08:00
moonrailgun
9905cc7833 feat: add lemonsqueezy subscription 2024-11-06 01:09:49 +08:00
19 changed files with 781 additions and 7 deletions

View File

@ -441,6 +441,9 @@ importers:
'@auth/express': '@auth/express':
specifier: ^0.5.5 specifier: ^0.5.5
version: 0.5.6(express@4.18.2)(nodemailer@6.9.8) version: 0.5.6(express@4.18.2)(nodemailer@6.9.8)
'@lemonsqueezy/lemonsqueezy.js':
specifier: ^3.3.1
version: 3.3.1
'@paralleldrive/cuid2': '@paralleldrive/cuid2':
specifier: ^2.2.2 specifier: ^2.2.2
version: 2.2.2 version: 2.2.2
@ -2334,6 +2337,14 @@ packages:
'@leichtgewicht/ip-codec@2.0.5': '@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
'@lemonsqueezy/lemonsqueezy.js@3.3.1':
resolution: {integrity: sha512-gM/FdNsK3BlrD6JRrhmiyqBXQsCpzSUdKSoZwJMQfXqfqcK321og+uMssc6HYcygUMrGvPnNJyJ1RqZPFDrgtg==}
engines: {node: '>=20'}
'@ljharb/resumer@0.0.1':
resolution: {integrity: sha512-skQiAOrCfO7vRTq53cxznMpks7wS1va95UCidALlOVWqvBAzwPVErwizDwoMqNVMEn1mDq0utxZd02eIrvF1lw==}
engines: {node: '>= 0.4'}
'@ljharb/through@2.3.11': '@ljharb/through@2.3.11':
resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==} resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -15285,6 +15296,12 @@ snapshots:
'@leichtgewicht/ip-codec@2.0.5': {} '@leichtgewicht/ip-codec@2.0.5': {}
'@lemonsqueezy/lemonsqueezy.js@3.3.1': {}
'@ljharb/resumer@0.0.1':
dependencies:
'@ljharb/through': 2.3.11
'@ljharb/through@2.3.11': '@ljharb/through@2.3.11':
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7

View File

@ -8,6 +8,12 @@ import { useState } from 'react';
import { EditableText } from '@/components/EditableText'; import { EditableText } from '@/components/EditableText';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { WebhookPlayground } from '@/components/WebhookPlayground'; import { WebhookPlayground } from '@/components/WebhookPlayground';
import React from 'react';
import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc';
import { Button } from '@/components/ui/button';
import { useEvent } from '@/hooks/useEvent';
import { useCurrentWorkspaceId } from '@/store/user';
import { Separator } from '@/components/ui/separator';
export const Route = createFileRoute('/playground')({ export const Route = createFileRoute('/playground')({
beforeLoad: () => { beforeLoad: () => {
@ -48,18 +54,22 @@ function PageComponent() {
return ( return (
<div className="h-full w-full p-4"> <div className="h-full w-full p-4">
<Tabs defaultValue="current" className="flex h-full flex-col"> <Tabs defaultValue="billing" className="flex h-full flex-col">
<div> <div>
<TabsList> <TabsList>
<TabsTrigger value="current">Current</TabsTrigger> <TabsTrigger value="billing">Billing</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger> <TabsTrigger value="webhook">Webhook</TabsTrigger>
<TabsTrigger value="misc">Misc</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<TabsContent value="current" className="flex-1 overflow-hidden"> <TabsContent value="billing">
<BillingPlayground />
</TabsContent>
<TabsContent value="webhook" className="flex-1 overflow-hidden">
<WebhookPlayground /> <WebhookPlayground />
</TabsContent> </TabsContent>
<TabsContent value="history"> <TabsContent value="misc">
<div> <div>
<EditableText <EditableText
defaultValue="fooooooooo" defaultValue="fooooooooo"
@ -73,3 +83,114 @@ function PageComponent() {
</div> </div>
); );
} }
export const BillingPlayground: React.FC = React.memo(() => {
const checkoutMutation = trpc.billing.checkout.useMutation({
onError: defaultErrorHandler,
});
const changePlanMutation = trpc.billing.changePlan.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const cancelSubscriptionMutation =
trpc.billing.cancelSubscription.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const workspaceId = useCurrentWorkspaceId();
const { data, refetch, isInitialLoading, isLoading } =
trpc.billing.currentSubscription.useQuery({
workspaceId,
});
const handleCheckoutSubscribe = useEvent(async (tier: 'pro' | 'team') => {
const { url } = await checkoutMutation.mutateAsync({
workspaceId,
tier,
redirectUrl: location.href,
});
location.href = url;
});
const handleChangeSubscribe = useEvent(
async (tier: 'free' | 'pro' | 'team') => {
await changePlanMutation.mutateAsync({
workspaceId,
tier,
});
refetch();
}
);
const plan = data ? (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('free')}
>
Change plan to Free
</Button>
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('pro')}
>
Change plan to Pro
</Button>
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('team')}
>
Change plan to Team
</Button>
</div>
<div>
<Button
loading={cancelSubscriptionMutation.isLoading}
onClick={() =>
cancelSubscriptionMutation.mutateAsync({
workspaceId,
})
}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex gap-2">
<Button
loading={checkoutMutation.isLoading}
onClick={() => handleCheckoutSubscribe('pro')}
>
Upgrade to Pro
</Button>
<Button
loading={checkoutMutation.isLoading}
onClick={() => handleCheckoutSubscribe('team')}
>
Upgrade to Team
</Button>
</div>
);
return (
<div className="flex flex-col gap-2">
<div>
<div>Current: {JSON.stringify(data)}</div>
<Button loading={isLoading} onClick={() => refetch()}>
Refresh
</Button>
</div>
<Separator className="my-2" />
{isInitialLoading === false && plan}
</div>
);
});
BillingPlayground.displayName = 'BillingPlayground';

View File

@ -23,6 +23,7 @@ import { monitorPageManager } from './model/monitor/page/manager.js';
import { ExpressAuth } from '@auth/express'; import { ExpressAuth } from '@auth/express';
import { authConfig } from './model/auth.js'; import { authConfig } from './model/auth.js';
import { prometheusApiVersion } from './middleware/prometheus/index.js'; import { prometheusApiVersion } from './middleware/prometheus/index.js';
import { billingRouter } from './router/billing.js';
const app = express(); const app = express();
@ -32,6 +33,9 @@ app.use(compression());
app.use( app.use(
express.json({ express.json({
limit: '10mb', limit: '10mb',
verify: (req, res, buf) => {
(req as any).rawBody = buf;
},
}) })
); );
app.use(passport.initialize()); app.use(passport.initialize());
@ -51,6 +55,7 @@ app.use(
app.use('/health', healthRouter); app.use('/health', healthRouter);
app.use('/api/auth/*', ExpressAuth(authConfig)); app.use('/api/auth/*', ExpressAuth(authConfig));
app.use('/api/website', websiteRouter); app.use('/api/website', websiteRouter);
app.use('/api/billing', billingRouter);
app.use('/monitor', monitorRouter); app.use('/monitor', monitorRouter);
app.use('/telemetry', telemetryRouter); app.use('/telemetry', telemetryRouter);
app.use('/serverStatus', serverStatusRouter); app.use('/serverStatus', serverStatusRouter);

View File

@ -0,0 +1,194 @@
import {
lemonSqueezySetup,
createCheckout,
updateSubscription,
cancelSubscription as lsCancelSubscription,
} from '@lemonsqueezy/lemonsqueezy.js';
import { env } from '../../utils/env.js';
import { prisma } from '../_client.js';
import { WorkspaceSubscriptionTier } from '@prisma/client';
export const billingAvailable = Boolean(env.billing.lemonSqueezy.apiKey);
if (billingAvailable) {
lemonSqueezySetup({
apiKey: env.billing.lemonSqueezy.apiKey,
onError: (error) => console.error('Error!', error),
});
}
export type SubscriptionTierType =
keyof typeof env.billing.lemonSqueezy.tierVariantId;
export function getTierNameByvariantId(variantId: string) {
const tierName = Object.keys(env.billing.lemonSqueezy.tierVariantId).find(
(key) =>
env.billing.lemonSqueezy.tierVariantId[key as SubscriptionTierType] ===
variantId
);
if (!tierName) {
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;
}
if (
!Object.values(env.billing.lemonSqueezy.tierVariantId).includes(variantId)
) {
return false;
}
return true;
}
export async function createCheckoutBilling(
workspaceId: string,
userId: string,
subscriptionTier: SubscriptionTierType,
redirectUrl?: string
) {
const variantId = env.billing.lemonSqueezy.tierVariantId[subscriptionTier];
if (!variantId) {
throw new Error('Unknown subscription tier');
}
const userInfo = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!userInfo) {
throw new Error('User not found');
}
const subscription = await prisma.lemonSqueezySubscription.findUnique({
where: {
workspaceId,
},
});
if (subscription) {
throw new Error('This workspace already has a subscription');
}
// not existed subscription
const checkout = await createCheckout(
env.billing.lemonSqueezy.storeId,
variantId,
{
checkoutData: {
name: userInfo.nickname ?? undefined,
email: userInfo.email ?? undefined,
custom: {
userId,
workspaceId,
},
},
productOptions: {
redirectUrl,
},
}
);
if (checkout.error) {
throw checkout.error;
}
const checkoutData = checkout.data.data;
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
) {
const variantId = env.billing.lemonSqueezy.tierVariantId[subscriptionTier];
if (!variantId) {
throw new Error('Unknown subscription tier');
}
const subscription = await prisma.lemonSqueezySubscription.findUnique({
where: {
workspaceId,
},
});
if (!subscription) {
throw new Error('Can not found existed subscription');
}
const res = await updateSubscription(subscription.subscriptionId, {
variantId: Number(variantId),
});
if (res.error) {
throw res.error;
}
return res.data.data;
}
export async function cancelSubscription(workspaceId: string) {
const subscription = await prisma.lemonSqueezySubscription.findUnique({
where: {
workspaceId,
},
});
if (!subscription) {
throw new Error('Can not found existed subscription');
}
const res = await lsCancelSubscription(subscription.subscriptionId);
if (res.error) {
throw res.error;
}
return res.data.data;
}

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

@ -26,6 +26,7 @@
"dependencies": { "dependencies": {
"@auth/core": "^0.34.1", "@auth/core": "^0.34.1",
"@auth/express": "^0.5.5", "@auth/express": "^0.5.5",
"@lemonsqueezy/lemonsqueezy.js": "^3.3.1",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@prisma/client": "5.14.0", "@prisma/client": "5.14.0",
"@tianji/shared": "workspace:^", "@tianji/shared": "workspace:^",

View File

@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "LemonSqueezySubscription" (
"subscriptionId" TEXT NOT NULL,
"workspaceId" VARCHAR(30) NOT NULL,
"storeId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"variantId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"cardBrand" TEXT NOT NULL,
"cardLastFour" TEXT NOT NULL,
"renewsAt" TIMESTAMPTZ(6) NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "LemonSqueezySubscription_pkey" PRIMARY KEY ("subscriptionId")
);
-- CreateTable
CREATE TABLE "LemonSqueezyWebhookEvent" (
"id" VARCHAR(30) NOT NULL,
"eventName" TEXT NOT NULL,
"payload" JSON NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LemonSqueezyWebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LemonSqueezySubscription_subscriptionId_key" ON "LemonSqueezySubscription"("subscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "LemonSqueezySubscription_workspaceId_key" ON "LemonSqueezySubscription"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "LemonSqueezyWebhookEvent_id_key" ON "LemonSqueezyWebhookEvent"("id");

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) 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,49 @@ 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 {
subscriptionId String @id @unique
workspaceId String @unique @db.VarChar(30)
storeId String
productId String
variantId String
status String
cardBrand String
cardLastFour String
renewsAt DateTime @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
}
model LemonSqueezyWebhookEvent {
id String @id @unique @default(cuid()) @db.VarChar(30)
eventName String
/// [CommonPayload]
/// @zod.custom(imports.CommonPayloadSchema)
payload Json @db.Json // Other payload info get from query params, should be a object
createdAt DateTime @default(now()) @db.Timestamptz(6)
}
model Website { model Website {
id String @id @unique @default(cuid()) @db.VarChar(30) id String @id @unique @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30) workspaceId String @db.VarChar(30)

View File

@ -5,6 +5,9 @@ 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 "./lemonsqueezywebhookevent.js"
export * from "./website.js" export * from "./website.js"
export * from "./websitesession.js" export * from "./websitesession.js"
export * from "./websiteevent.js" export * from "./websiteevent.js"

View File

@ -0,0 +1,16 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
export const LemonSqueezySubscriptionModelSchema = z.object({
subscriptionId: z.string(),
workspaceId: z.string(),
storeId: z.string(),
productId: z.string(),
variantId: z.string(),
status: z.string(),
cardBrand: z.string(),
cardLastFour: z.string(),
renewsAt: z.date(),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@ -0,0 +1,18 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
// Helper schema for JSON fields
type Literal = boolean | number | string
type Json = Literal | { [key: string]: Json } | Json[]
const literalSchema = z.union([z.string(), z.number(), z.boolean()])
const jsonSchema: z.ZodSchema<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]))
export const LemonSqueezyWebhookEventModelSchema = z.object({
id: z.string(),
eventName: z.string(),
/**
* [CommonPayload]
*/
payload: imports.CommonPayloadSchema,
createdAt: z.date(),
})

View File

@ -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(),

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

@ -0,0 +1,101 @@
import { Router, raw } from 'express';
import crypto from 'crypto';
import { env } from '../utils/env.js';
import { get } from 'lodash-es';
import {
checkIsValidProduct,
getTierEnumByVariantId,
updateWorkspaceSubscription,
} from '../model/billing/index.js';
import { prisma } from '../model/_client.js';
import dayjs from 'dayjs';
export const billingRouter = Router();
billingRouter.post(
'/lemonsqueezy/webhook',
raw({
type: () => true,
}),
async (req, res) => {
const rawBody = String(req.rawBody);
const body = req.body;
signatureIsValid(rawBody, req.get('X-Signature') ?? '');
const eventName = get(body, 'meta.event_name');
const workspaceId = get(body, 'meta.custom_data.workspace_id');
await prisma.lemonSqueezyWebhookEvent.create({
data: {
eventName,
payload: body,
},
});
if (!workspaceId) {
res.status(500).send('No workspace id');
return;
}
if (eventName === 'subscription_updated') {
// update user subscription
const subscriptionId = String(get(body, 'data.id'));
const storeId = String(get(body, 'data.attributes.store_id'));
const productId = String(get(body, 'data.attributes.product_id'));
const variantId = String(get(body, 'data.attributes.variant_id'));
const status = get(body, 'data.attributes.status');
const cardBrand = get(body, 'data.attributes.card_brand');
const cardLastFour = get(body, 'data.attributes.card_last_four');
const renewsAt = dayjs(get(body, 'data.attributes.renews_at')).toDate();
if (!checkIsValidProduct(storeId, variantId)) {
throw new Error(`Invalid product: ${storeId}, ${variantId}`);
}
await prisma.lemonSqueezySubscription.upsert({
create: {
subscriptionId,
workspaceId,
storeId,
productId,
variantId,
status,
cardBrand,
cardLastFour,
renewsAt,
},
update: {
storeId,
productId,
variantId,
status,
cardBrand,
cardLastFour,
renewsAt,
},
where: {
workspaceId,
subscriptionId,
},
});
await updateWorkspaceSubscription(
workspaceId,
getTierEnumByVariantId(variantId)
);
}
res.status(200).send('OK');
}
);
function signatureIsValid(rawBody: string, requestSignature: string) {
const secret = env.billing.lemonSqueezy.signatureSecret;
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
const signature = Buffer.from(requestSignature, 'utf8');
if (!crypto.timingSafeEqual(digest, signature)) {
throw new Error('Invalid signature.');
}
}

View File

@ -1,8 +1,21 @@
import { z } from 'zod'; import { z } from 'zod';
import { OpenApiMetaInfo, router, workspaceProcedure } from '../trpc.js'; import {
OpenApiMetaInfo,
router,
workspaceOwnerProcedure,
workspaceProcedure,
} from '../trpc.js';
import { OPENAPI_TAG } from '../../utils/const.js'; import { OPENAPI_TAG } from '../../utils/const.js';
import { prisma } from '../../model/_client.js'; import { prisma } from '../../model/_client.js';
import { OpenApiMeta } from 'trpc-openapi'; import { OpenApiMeta } from 'trpc-openapi';
import {
cancelSubscription,
changeSubscription,
createCheckoutBilling,
getTierNameByvariantId,
SubscriptionTierType,
} from '../../model/billing/index.js';
import { LemonSqueezySubscriptionModelSchema } from '../../prisma/zod/lemonsqueezysubscription.js';
export const billingRouter = router({ export const billingRouter = router({
usage: workspaceProcedure usage: workspaceProcedure
@ -56,6 +69,87 @@ export const billingRouter = router({
feedEventCount: res._sum.feedEventCount ?? 0, feedEventCount: res._sum.feedEventCount ?? 0,
}; };
}), }),
currentSubscription: workspaceProcedure
.meta(
buildBillingOpenapi({
method: 'GET',
path: '/currentSubscription',
description: 'get workspace current subscription',
})
)
.output(
LemonSqueezySubscriptionModelSchema.merge(
z.object({
tier: z.string(),
})
).nullable()
)
.query(async ({ input }) => {
const { workspaceId } = input;
const res = await prisma.lemonSqueezySubscription.findUnique({
where: {
workspaceId,
},
});
if (!res) {
return null;
}
return { ...res, tier: getTierNameByvariantId(res.variantId) };
}),
checkout: workspaceOwnerProcedure
.input(
z.object({
tier: z.string(),
redirectUrl: z.string().optional(),
})
)
.output(
z.object({
url: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
const { workspaceId, redirectUrl } = input;
const userId = ctx.user.id;
const checkout = await createCheckoutBilling(
workspaceId,
userId,
input.tier as SubscriptionTierType,
redirectUrl
);
const url = checkout.attributes.url;
return { url };
}),
changePlan: workspaceOwnerProcedure
.input(
z.object({
tier: z.string(),
})
)
.output(z.string())
.mutation(async ({ input }) => {
const { workspaceId } = input;
const subscription = await changeSubscription(
workspaceId,
input.tier as SubscriptionTierType
);
return subscription.id;
}),
cancelSubscription: workspaceOwnerProcedure
.output(z.string())
.mutation(async ({ input }) => {
const { workspaceId } = input;
const subscription = await cancelSubscription(workspaceId);
return subscription.id;
}),
}); });
function buildBillingOpenapi(meta: OpenApiMetaInfo): OpenApiMeta { function buildBillingOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {

View File

@ -8,6 +8,10 @@ import type {
declare global { declare global {
namespace Express { namespace Express {
interface User extends JWTPayload {} interface User extends JWTPayload {}
interface Request {
rawBody: unknown;
}
} }
namespace PrismaJson { namespace PrismaJson {

View File

@ -46,6 +46,18 @@ export const env = {
clientSecret: process.env.AUTH_CUSTOM_SECRET, clientSecret: process.env.AUTH_CUSTOM_SECRET,
}, },
}, },
billing: {
lemonSqueezy: {
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',
storeId: process.env.LEMON_SQUEEZY_STORE_ID ?? '',
tierVariantId: {
free: process.env.LEMON_SQUEEZY_SUBSCRIPTION_FREE_ID ?? '',
pro: process.env.LEMON_SQUEEZY_SUBSCRIPTION_PRO_ID ?? '',
team: process.env.LEMON_SQUEEZY_SUBSCRIPTION_TEAM_ID ?? '',
},
},
},
allowRegister: checkEnvTrusty(process.env.ALLOW_REGISTER), allowRegister: checkEnvTrusty(process.env.ALLOW_REGISTER),
allowOpenapi: checkEnvTrusty(process.env.ALLOW_OPENAPI ?? 'true'), allowOpenapi: checkEnvTrusty(process.env.ALLOW_OPENAPI ?? 'true'),
websiteId: process.env.WEBSITE_ID, websiteId: process.env.WEBSITE_ID,