feat: add lemonsqueezy subscription

This commit is contained in:
moonrailgun 2024-10-03 09:39:35 +08:00
parent c70e69879f
commit 74d391afc1
15 changed files with 616 additions and 6 deletions

View File

@ -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

View File

@ -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,115 @@ function PageComponent() {
</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';

View File

@ -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);

153
src/server/model/billing.ts Normal file
View 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;
}

View File

@ -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:^",

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

@ -128,6 +128,31 @@ model WorkspacesOnUsers {
@@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 {
id String @id @unique @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30)

View File

@ -5,6 +5,8 @@ export * from "./session.js"
export * from "./verificationtoken.js"
export * from "./workspace.js"
export * from "./workspacesonusers.js"
export * from "./lemonsqueezysubscription.js"
export * from "./lemonsqueezywebhookevent.js"
export * from "./website.js"
export * from "./websitesession.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,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(),
})

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

@ -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.');
}
}

View File

@ -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.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 {

View File

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

View File

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