Compare commits
4 Commits
master
...
feat/lemon
Author | SHA1 | Date | |
---|---|---|---|
|
e22cf8d555 | ||
|
76b5d1bb1e | ||
|
6f89d0f2d3 | ||
|
9905cc7833 |
@ -441,6 +441,9 @@ importers:
|
||||
'@auth/express':
|
||||
specifier: ^0.5.5
|
||||
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':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
@ -2334,6 +2337,14 @@ packages:
|
||||
'@leichtgewicht/ip-codec@2.0.5':
|
||||
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':
|
||||
resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -15285,6 +15296,12 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
|
@ -8,6 +8,12 @@ import { useState } from 'react';
|
||||
import { EditableText } from '@/components/EditableText';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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')({
|
||||
beforeLoad: () => {
|
||||
@ -48,18 +54,22 @@ function PageComponent() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<TabsList>
|
||||
<TabsTrigger value="current">Current</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
<TabsTrigger value="billing">Billing</TabsTrigger>
|
||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||
<TabsTrigger value="misc">Misc</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="current" className="flex-1 overflow-hidden">
|
||||
<TabsContent value="billing">
|
||||
<BillingPlayground />
|
||||
</TabsContent>
|
||||
<TabsContent value="webhook" className="flex-1 overflow-hidden">
|
||||
<WebhookPlayground />
|
||||
</TabsContent>
|
||||
<TabsContent value="history">
|
||||
<TabsContent value="misc">
|
||||
<div>
|
||||
<EditableText
|
||||
defaultValue="fooooooooo"
|
||||
@ -73,3 +83,114 @@ function PageComponent() {
|
||||
</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';
|
||||
|
@ -23,6 +23,7 @@ import { monitorPageManager } from './model/monitor/page/manager.js';
|
||||
import { ExpressAuth } from '@auth/express';
|
||||
import { authConfig } from './model/auth.js';
|
||||
import { prometheusApiVersion } from './middleware/prometheus/index.js';
|
||||
import { billingRouter } from './router/billing.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -32,6 +33,9 @@ app.use(compression());
|
||||
app.use(
|
||||
express.json({
|
||||
limit: '10mb',
|
||||
verify: (req, res, buf) => {
|
||||
(req as any).rawBody = buf;
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(passport.initialize());
|
||||
@ -51,6 +55,7 @@ app.use(
|
||||
app.use('/health', healthRouter);
|
||||
app.use('/api/auth/*', ExpressAuth(authConfig));
|
||||
app.use('/api/website', websiteRouter);
|
||||
app.use('/api/billing', billingRouter);
|
||||
app.use('/monitor', monitorRouter);
|
||||
app.use('/telemetry', telemetryRouter);
|
||||
app.use('/serverStatus', serverStatusRouter);
|
||||
|
194
src/server/model/billing/index.ts
Normal file
194
src/server/model/billing/index.ts
Normal 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;
|
||||
}
|
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';
|
@ -26,6 +26,7 @@
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.1",
|
||||
"@auth/express": "^0.5.5",
|
||||
"@lemonsqueezy/lemonsqueezy.js": "^3.3.1",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@prisma/client": "5.14.0",
|
||||
"@tianji/shared": "workspace:^",
|
||||
|
@ -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");
|
@ -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,49 @@ 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)
|
||||
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 {
|
||||
id String @id @unique @default(cuid()) @db.VarChar(30)
|
||||
workspaceId String @db.VarChar(30)
|
||||
|
@ -5,6 +5,9 @@ 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"
|
||||
export * from "./websitesession.js"
|
||||
export * from "./websiteevent.js"
|
||||
|
16
src/server/prisma/zod/lemonsqueezysubscription.ts
Normal file
16
src/server/prisma/zod/lemonsqueezysubscription.ts
Normal 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(),
|
||||
})
|
18
src/server/prisma/zod/lemonsqueezywebhookevent.ts
Normal file
18
src/server/prisma/zod/lemonsqueezywebhookevent.ts
Normal 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(),
|
||||
})
|
@ -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,
|
||||
}))
|
101
src/server/router/billing.ts
Normal file
101
src/server/router/billing.ts
Normal 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.');
|
||||
}
|
||||
}
|
@ -1,8 +1,21 @@
|
||||
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 { prisma } from '../../model/_client.js';
|
||||
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({
|
||||
usage: workspaceProcedure
|
||||
@ -56,6 +69,87 @@ export const billingRouter = router({
|
||||
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 {
|
||||
|
4
src/server/types/global.d.ts
vendored
4
src/server/types/global.d.ts
vendored
@ -8,6 +8,10 @@ import type {
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface User extends JWTPayload {}
|
||||
|
||||
interface Request {
|
||||
rawBody: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
namespace PrismaJson {
|
||||
|
@ -46,6 +46,18 @@ export const env = {
|
||||
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),
|
||||
allowOpenapi: checkEnvTrusty(process.env.ALLOW_OPENAPI ?? 'true'),
|
||||
websiteId: process.env.WEBSITE_ID,
|
||||
|
Loading…
Reference in New Issue
Block a user