feat: add lemonsqueezy subscription
This commit is contained in:
parent
c70e69879f
commit
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,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';
|
||||
|
@ -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
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": {
|
||||
"@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");
|
@ -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)
|
||||
|
@ -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"
|
||||
|
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 { 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 {
|
||||
|
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