diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f23d90..4dced14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/client/routes/playground.tsx b/src/client/routes/playground.tsx index e867a4f..ac238d0 100644 --- a/src/client/routes/playground.tsx +++ b/src/client/routes/playground.tsx @@ -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 (
- +
- Current - History + Billing + Webhook + Misc
- + + + + - +
); } + +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 ? ( +
+
+ + + +
+ +
+ +
+
+ ) : ( +
+ + +
+ ); + + return ( +
+
+
Current: {JSON.stringify(data)}
+ + +
+ + + + {isInitialLoading === false && plan} +
+ ); +}); +BillingPlayground.displayName = 'BillingPlayground'; diff --git a/src/server/app.ts b/src/server/app.ts index acc1f77..b9036fb 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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); diff --git a/src/server/model/billing.ts b/src/server/model/billing.ts new file mode 100644 index 0000000..31c090e --- /dev/null +++ b/src/server/model/billing.ts @@ -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; +} diff --git a/src/server/package.json b/src/server/package.json index 446c9c8..1e7218a 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -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:^", diff --git a/src/server/prisma/migrations/20241003005451_add_lemon_squeezy/migration.sql b/src/server/prisma/migrations/20241003005451_add_lemon_squeezy/migration.sql new file mode 100644 index 0000000..c8662df --- /dev/null +++ b/src/server/prisma/migrations/20241003005451_add_lemon_squeezy/migration.sql @@ -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"); diff --git a/src/server/prisma/schema.prisma b/src/server/prisma/schema.prisma index 0905d22..7ea7b2a 100644 --- a/src/server/prisma/schema.prisma +++ b/src/server/prisma/schema.prisma @@ -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) diff --git a/src/server/prisma/zod/index.ts b/src/server/prisma/zod/index.ts index 825a053..f1285f4 100644 --- a/src/server/prisma/zod/index.ts +++ b/src/server/prisma/zod/index.ts @@ -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" diff --git a/src/server/prisma/zod/lemonsqueezysubscription.ts b/src/server/prisma/zod/lemonsqueezysubscription.ts new file mode 100644 index 0000000..a0ce94b --- /dev/null +++ b/src/server/prisma/zod/lemonsqueezysubscription.ts @@ -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(), +}) diff --git a/src/server/prisma/zod/lemonsqueezytransaction.ts b/src/server/prisma/zod/lemonsqueezytransaction.ts new file mode 100644 index 0000000..59e1bd8 --- /dev/null +++ b/src/server/prisma/zod/lemonsqueezytransaction.ts @@ -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(), +}) diff --git a/src/server/prisma/zod/lemonsqueezywebhookevent.ts b/src/server/prisma/zod/lemonsqueezywebhookevent.ts new file mode 100644 index 0000000..e052ec1 --- /dev/null +++ b/src/server/prisma/zod/lemonsqueezywebhookevent.ts @@ -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 = 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(), +}) diff --git a/src/server/router/billing.ts b/src/server/router/billing.ts new file mode 100644 index 0000000..ab8f0a8 --- /dev/null +++ b/src/server/router/billing.ts @@ -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.'); + } +} diff --git a/src/server/trpc/routers/billing.ts b/src/server/trpc/routers/billing.ts index 9292390..7dfff55 100644 --- a/src/server/trpc/routers/billing.ts +++ b/src/server/trpc/routers/billing.ts @@ -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 { diff --git a/src/server/types/global.d.ts b/src/server/types/global.d.ts index 9d66f61..3ab1530 100644 --- a/src/server/types/global.d.ts +++ b/src/server/types/global.d.ts @@ -8,6 +8,10 @@ import type { declare global { namespace Express { interface User extends JWTPayload {} + + interface Request { + rawBody: unknown; + } } namespace PrismaJson { diff --git a/src/server/utils/env.ts b/src/server/utils/env.ts index 99f2b3b..3369173 100644 --- a/src/server/utils/env.ts +++ b/src/server/utils/env.ts @@ -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,