feat: add lemonsqueezy subscription
This commit is contained in:
parent
c70e69879f
commit
9905cc7833
@ -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
|
||||||
|
@ -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,115 @@ function PageComponent() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const BillingPlayground: React.FC = React.memo(() => {
|
||||||
|
const checkoutMutation = trpc.billing.checkout.useMutation({
|
||||||
|
onSuccess: defaultSuccessHandler,
|
||||||
|
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 { 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);
|
||||||
|
153
src/server/model/billing.ts
Normal file
153
src/server/model/billing.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
lemonSqueezySetup,
|
||||||
|
createCheckout,
|
||||||
|
updateSubscription,
|
||||||
|
cancelSubscription as lsCancelSubscription,
|
||||||
|
} from '@lemonsqueezy/lemonsqueezy.js';
|
||||||
|
import { env } from '../utils/env.js';
|
||||||
|
import { prisma } from './_client.js';
|
||||||
|
|
||||||
|
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 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return tierName;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
@ -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:^",
|
||||||
|
@ -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");
|
@ -128,6 +128,31 @@ model WorkspacesOnUsers {
|
|||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
@ -5,6 +5,8 @@ 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 "./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"
|
||||||
|
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(),
|
||||||
|
})
|
13
src/server/prisma/zod/lemonsqueezytransaction.ts
Normal file
13
src/server/prisma/zod/lemonsqueezytransaction.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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(),
|
||||||
|
})
|
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(),
|
||||||
|
})
|
93
src/server/router/billing.ts
Normal file
93
src/server/router/billing.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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.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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { 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.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 {
|
||||||
|
4
src/server/types/global.d.ts
vendored
4
src/server/types/global.d.ts
vendored
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user