Compare commits
4 Commits
master
...
feat/lemon
Author | SHA1 | Date | |
---|---|---|---|
|
e22cf8d555 | ||
|
76b5d1bb1e | ||
|
6f89d0f2d3 | ||
|
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,114 @@ function PageComponent() {
|
|||||||
</div>
|
</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 { 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);
|
||||||
|
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": {
|
"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");
|
@ -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)
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
|
||||||
|
subscription WorkspaceSubscription?
|
||||||
|
|
||||||
users WorkspacesOnUsers[]
|
users WorkspacesOnUsers[]
|
||||||
websites Website[]
|
websites Website[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
@ -128,6 +130,49 @@ model WorkspacesOnUsers {
|
|||||||
@@index([workspaceId])
|
@@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 {
|
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,9 @@ 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 "./workspacesubscription.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(),
|
||||||
|
})
|
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 z from "zod"
|
||||||
import * as imports from "./schemas/index.js"
|
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
|
// Helper schema for JSON fields
|
||||||
type Literal = boolean | number | string
|
type Literal = boolean | number | string
|
||||||
@ -25,6 +25,7 @@ export const WorkspaceModelSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema> {
|
export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema> {
|
||||||
|
subscription?: CompleteWorkspaceSubscription | null
|
||||||
users: CompleteWorkspacesOnUsers[]
|
users: CompleteWorkspacesOnUsers[]
|
||||||
websites: CompleteWebsite[]
|
websites: CompleteWebsite[]
|
||||||
notifications: CompleteNotification[]
|
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
|
* NOTE: Lazy required in case of potential circular dependencies within schema
|
||||||
*/
|
*/
|
||||||
export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.lazy(() => WorkspaceModelSchema.extend({
|
export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.lazy(() => WorkspaceModelSchema.extend({
|
||||||
|
subscription: RelatedWorkspaceSubscriptionModelSchema.nullish(),
|
||||||
users: RelatedWorkspacesOnUsersModelSchema.array(),
|
users: RelatedWorkspacesOnUsersModelSchema.array(),
|
||||||
websites: RelatedWebsiteModelSchema.array(),
|
websites: RelatedWebsiteModelSchema.array(),
|
||||||
notifications: RelatedNotificationModelSchema.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 { 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/index.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