Add Umami analytics, subscription tiers, tool-call metering
- Analytics: <Analytics /> in root layout loads umami when NEXT_PUBLIC_UMAMI_* env are set - Subscription: free/pro tiers in Postgres, daily quotas (10msg/5tool free, 1000/500 pro) - Chat route: checkAndConsume(chat_message) gate before streamText, 429 when exhausted - Tool metering: getWeather wrapped with withLimit decorator that deducts tool_call quota - Pricing page at /pricing with upgrade button - /api/billing/checkout posts to EGBE payment gateway, /api/billing/webhook verifies HMAC and upgrades user on checkout.session.completed - UsageBadge in chat header polls /api/usage every 15s - Dropped now-unused entitlements.ts
This commit is contained in:
parent
3e21c2334c
commit
22a172e92d
23 changed files with 1374 additions and 86 deletions
22
.env.example
22
.env.example
|
|
@ -5,8 +5,28 @@ AUTH_SECRET=replace-me
|
||||||
DATABASE_URL=postgres://user:pass@host:5432/db
|
DATABASE_URL=postgres://user:pass@host:5432/db
|
||||||
|
|
||||||
# EGBE LiteLLM proxy — auto-injected by app-deploy.sh from the deploying instance
|
# EGBE LiteLLM proxy — auto-injected by app-deploy.sh from the deploying instance
|
||||||
EGBE_AI_API_URL=https://proxy.gcp.egbe.dev/v1
|
EGBE_AI_API_URL=https://litellm.gcp.egbe.dev
|
||||||
EGBE_AI_API_KEY=
|
EGBE_AI_API_KEY=
|
||||||
|
|
||||||
# Public origin of this app — auto-injected by app-deploy.sh
|
# Public origin of this app — auto-injected by app-deploy.sh
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# --- analytics (umami) ---
|
||||||
|
# Auto-injected by app-deploy.sh after `analytics-site-create.sh <slug> <host> "<Name>"`
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
|
||||||
|
# --- billing (payment gateway) ---
|
||||||
|
# Auto-injected by app-deploy.sh from the deploying instance
|
||||||
|
EGBE_PAYMENT_GATEWAY_URL=https://payments.gcp.egbe.dev
|
||||||
|
EGBE_GATEWAYS_TOKEN=
|
||||||
|
EGBE_PAYMENT_GATEWAY_WEBHOOK_SECRET=
|
||||||
|
EGBE_PAYMENT_GATEWAY_WEBHOOK_HEADER=x-egbe-payment-signature
|
||||||
|
|
||||||
|
# Stripe price ID for the Pro plan — set per-product after creating the product via
|
||||||
|
# the payment gateway API. Without it the upgrade button returns 501.
|
||||||
|
PRO_STRIPE_PRICE_ID=
|
||||||
|
|
||||||
|
# App slug used in the webhook URL when creating checkout sessions. Must match the
|
||||||
|
# slug you passed to app-deploy.sh.
|
||||||
|
APP_SLUG=chatbot
|
||||||
|
|
|
||||||
75
README.md
75
README.md
|
|
@ -1,38 +1,59 @@
|
||||||
# EGBE Chatbot Template
|
# EGBE Chatbot Template
|
||||||
|
|
||||||
Next.js chatbot wired to the EGBE LiteLLM proxy. Based on [vercel/chatbot](https://github.com/vercel/chatbot) (Apache 2.0), stripped of Vercel-specific bits and rewired for our infra.
|
Next.js chatbot wired to the EGBE infra. Based on [vercel/chatbot](https://github.com/vercel/chatbot) (Apache 2.0).
|
||||||
|
|
||||||
## Stack
|
## What's wired
|
||||||
|
|
||||||
- **Next.js 16** (App Router, standalone output)
|
- **AI** via `@ai-sdk/openai-compatible` → `EGBE_AI_API_URL` (LiteLLM proxy)
|
||||||
- **AI SDK** + `@ai-sdk/openai-compatible` → routes to `EGBE_AI_API_URL` (our LiteLLM)
|
- **Auth** — NextAuth credentials + guest mode
|
||||||
- **NextAuth** (credentials + guest)
|
- **DB** — Drizzle ORM + Postgres (provisioned by app-deploy.sh)
|
||||||
- **Drizzle ORM** + Postgres (DB gateway)
|
- **Analytics** — Umami `<Script>` mounted when `NEXT_PUBLIC_UMAMI_*` env are set
|
||||||
- **shadcn/ui** + Tailwind
|
- **Subscriptions** — Free / Pro tiers, daily quotas, tool-call metering, real Stripe checkout via the EGBE payment gateway
|
||||||
|
|
||||||
## Available models (via LiteLLM)
|
## Models (via LiteLLM)
|
||||||
|
|
||||||
`claude-sonnet`, `claude-haiku`, `gpt-5.4`, `gpt-5.4-mini`, `kimi-k2.6`, `minimax-m2.7`, `qwen3-coder`.
|
`gpt-5.4-mini` (default), `gpt-5.4`, `claude-sonnet`, `claude-haiku`, `kimi-k2.6`, `minimax-m2.7`, `qwen3-coder`. Edit `lib/ai/models.ts`.
|
||||||
|
|
||||||
Edit `lib/ai/models.ts` to add or remove.
|
## Subscription model
|
||||||
|
|
||||||
|
Two tiers (see `lib/subscription/config.ts`):
|
||||||
|
|
||||||
|
| Tier | Messages / day | Tool calls / day |
|
||||||
|
|------|----------------|------------------|
|
||||||
|
| Free | 10 | 5 |
|
||||||
|
| Pro | 1000 | 500 |
|
||||||
|
|
||||||
|
- **Message limit** — enforced in `app/(chat)/api/chat/route.ts` via `checkAndConsume(userId, "chat_message")` before `streamText`.
|
||||||
|
- **Tool limit** — `getWeather` is wrapped with `withLimit(tool, "tool_call", userId)` (see `lib/ai/tools/with-limit.ts`). Every tool call deducts 1 unit; if quota is hit the tool returns `{ error: "limit_exceeded", ... }` which the model sees and explains to the user.
|
||||||
|
- **Quota badge** — `<UsageBadge />` in the chat header polls `/api/usage` every 15s.
|
||||||
|
- **Upgrade flow** — `/pricing` → POST `/api/billing/checkout` → redirect to Stripe → webhook at `/api/billing/webhook` flips the user's tier on `checkout.session.completed`.
|
||||||
|
|
||||||
|
To add a new tool with metering, just wrap it the same way:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const meteredFoo = withLimit(myFooTool, "tool_call", session.user.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
To add a new limit kind, edit `lib/subscription/config.ts` (`LimitKey`, `LIMITS`) and `lib/subscription/service.ts`.
|
||||||
|
|
||||||
## Deploy (from inside an OpenClaw instance)
|
## Deploy (from inside an OpenClaw instance)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
${SKILLS_DIR}/scripts/app-deploy.sh <slug> chatbot-template
|
# 1. provision Umami site (optional — chatbot works without analytics)
|
||||||
|
${SKILLS_DIR}/../analytics/scripts/analytics-site-create.sh chatbot chatbot.${EGBE_INSTANCE_PUBLIC_ID}.p.egbe.app "Chatbot"
|
||||||
|
|
||||||
|
# 2. (optional) create a Stripe product + price for the Pro plan
|
||||||
|
# Without PRO_STRIPE_PRICE_ID the upgrade button returns 501.
|
||||||
|
curl -s -H "Authorization: Bearer ${EGBE_GATEWAYS_TOKEN}" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Chatbot Pro"}' "${EGBE_PAYMENT_GATEWAY_URL}/v1/products"
|
||||||
|
# then create a price referencing the returned product id, copy the price id
|
||||||
|
|
||||||
|
# 3. deploy
|
||||||
|
APP_ENV_JSON='{"AUTH_SECRET":"'$(openssl rand -base64 32)'","PRO_STRIPE_PRICE_ID":"price_..."}' \
|
||||||
|
${SKILLS_DIR}/scripts/app-deploy.sh chatbot chatbot-template
|
||||||
```
|
```
|
||||||
|
|
||||||
`app-deploy.sh` auto-injects:
|
`app-deploy.sh` auto-injects: `DATABASE_URL`, `EGBE_AI_API_*`, `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_UMAMI_*`, `EGBE_PAYMENT_GATEWAY_*`, `EGBE_GATEWAYS_TOKEN`. You pass `AUTH_SECRET` and `PRO_STRIPE_PRICE_ID` (if billing is desired) via `APP_ENV_JSON`.
|
||||||
- `DATABASE_URL` (per-app Postgres)
|
|
||||||
- `EGBE_AI_API_URL`, `EGBE_AI_API_KEY` (instance LiteLLM key)
|
|
||||||
- `NEXT_PUBLIC_BASE_URL` (the deployed `*.p.egbe.app` host)
|
|
||||||
|
|
||||||
You also need `AUTH_SECRET` — set it via `APP_ENV_JSON`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_ENV_JSON='{"AUTH_SECRET":"'$(openssl rand -base64 32)'"}' \
|
|
||||||
${SKILLS_DIR}/scripts/app-deploy.sh mychat chatbot-template
|
|
||||||
```
|
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
|
|
@ -44,14 +65,6 @@ pnpm db:migrate
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tracking upstream
|
|
||||||
|
|
||||||
Pull new vercel/chatbot features manually — `origin/upstream` is not configured by default here. Add it back with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git remote add upstream https://github.com/vercel/chatbot.git
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache 2.0 — see `LICENSE`. Derivative work of Vercel, Inc.'s [chatbot](https://github.com/vercel/chatbot).
|
Apache 2.0 — derivative work of Vercel, Inc.'s [chatbot](https://github.com/vercel/chatbot).
|
||||||
|
|
|
||||||
41
app/(chat)/api/billing/checkout/route.ts
Normal file
41
app/(chat)/api/billing/checkout/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createCheckoutSession,
|
||||||
|
isBillingConfigured,
|
||||||
|
} from "@/lib/billing/gateway";
|
||||||
|
import { ChatbotError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBillingConfigured()) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "billing_not_configured",
|
||||||
|
message:
|
||||||
|
"Set EGBE_PAYMENT_GATEWAY_URL, EGBE_GATEWAYS_TOKEN, and PRO_STRIPE_PRICE_ID to enable upgrades.",
|
||||||
|
},
|
||||||
|
{ status: 501 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSlug = process.env.APP_SLUG ?? "chatbot";
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ?? new URL(request.url).origin;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createCheckoutSession({
|
||||||
|
userId: session.user.id,
|
||||||
|
customerEmail: session.user.email ?? undefined,
|
||||||
|
appSlug,
|
||||||
|
baseUrl,
|
||||||
|
});
|
||||||
|
return Response.json({ url: result.url, id: result.id });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "checkout failed";
|
||||||
|
return Response.json({ error: "checkout_failed", message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/(chat)/api/billing/webhook/route.ts
Normal file
99
app/(chat)/api/billing/webhook/route.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import {
|
||||||
|
verifyWebhookSignature,
|
||||||
|
webhookHeaderName,
|
||||||
|
} from "@/lib/billing/gateway";
|
||||||
|
import { webhookEvent } from "@/lib/db/schema";
|
||||||
|
import { cancelSubscription, upgradeToPro } from "@/lib/subscription/service";
|
||||||
|
|
||||||
|
const client = postgres(process.env.DATABASE_URL ?? "");
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
type StripeCheckoutSession = {
|
||||||
|
id: string;
|
||||||
|
payment_status?: string;
|
||||||
|
customer?: string;
|
||||||
|
subscription?: string;
|
||||||
|
metadata?: { userId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type StripeSubscription = {
|
||||||
|
id: string;
|
||||||
|
customer?: string;
|
||||||
|
current_period_end?: number;
|
||||||
|
metadata?: { userId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebhookEnvelope = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
data: { object: StripeCheckoutSession | StripeSubscription };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const rawBody = await request.text();
|
||||||
|
const signature = request.headers.get(webhookHeaderName);
|
||||||
|
|
||||||
|
if (!verifyWebhookSignature(rawBody, signature)) {
|
||||||
|
return Response.json({ error: "invalid_signature" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let envelope: WebhookEnvelope;
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "invalid_json" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(webhookEvent)
|
||||||
|
.where(eq(webhookEvent.id, envelope.id))
|
||||||
|
.limit(1);
|
||||||
|
if (existing[0]) {
|
||||||
|
return Response.json({ ok: true, dedup: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleEvent(envelope);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("webhook handler failed", error);
|
||||||
|
return Response.json({ error: "handler_failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(webhookEvent)
|
||||||
|
.values({ id: envelope.id, type: envelope.type })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEvent(envelope: WebhookEnvelope) {
|
||||||
|
switch (envelope.type) {
|
||||||
|
case "checkout.session.completed": {
|
||||||
|
const obj = envelope.data.object as StripeCheckoutSession;
|
||||||
|
const userId = obj.metadata?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await upgradeToPro(userId, {
|
||||||
|
stripeCustomerId: obj.customer,
|
||||||
|
stripeSubscriptionId: obj.subscription,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const obj = envelope.data.object as StripeSubscription;
|
||||||
|
const userId = obj.metadata?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await cancelSubscription(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,7 @@ import {
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
} from "ai";
|
} from "ai";
|
||||||
import { auth, type UserType } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
|
||||||
import {
|
import {
|
||||||
allowedModelIds,
|
allowedModelIds,
|
||||||
DEFAULT_CHAT_MODEL,
|
DEFAULT_CHAT_MODEL,
|
||||||
|
|
@ -15,10 +14,10 @@ import {
|
||||||
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
||||||
import { getLanguageModel } from "@/lib/ai/providers";
|
import { getLanguageModel } from "@/lib/ai/providers";
|
||||||
import { getWeather } from "@/lib/ai/tools/get-weather";
|
import { getWeather } from "@/lib/ai/tools/get-weather";
|
||||||
|
import { withLimit } from "@/lib/ai/tools/with-limit";
|
||||||
import {
|
import {
|
||||||
deleteChatById,
|
deleteChatById,
|
||||||
getChatById,
|
getChatById,
|
||||||
getMessageCountByUserId,
|
|
||||||
getMessagesByChatId,
|
getMessagesByChatId,
|
||||||
saveChat,
|
saveChat,
|
||||||
saveMessages,
|
saveMessages,
|
||||||
|
|
@ -28,6 +27,10 @@ import {
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
import { ChatbotError } from "@/lib/errors";
|
import { ChatbotError } from "@/lib/errors";
|
||||||
import { checkIpRateLimit } from "@/lib/ratelimit";
|
import { checkIpRateLimit } from "@/lib/ratelimit";
|
||||||
|
import {
|
||||||
|
checkAndConsume,
|
||||||
|
LimitExceededError,
|
||||||
|
} from "@/lib/subscription/service";
|
||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
||||||
import { generateTitleFromUserMessage } from "../../actions";
|
import { generateTitleFromUserMessage } from "../../actions";
|
||||||
|
|
@ -69,19 +72,25 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
await checkIpRateLimit(getClientIp(request));
|
await checkIpRateLimit(getClientIp(request));
|
||||||
|
|
||||||
const userType: UserType = session.user.type;
|
|
||||||
|
|
||||||
const messageCount = await getMessageCountByUserId({
|
|
||||||
id: session.user.id,
|
|
||||||
differenceInHours: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (messageCount > entitlementsByUserType[userType].maxMessagesPerHour) {
|
|
||||||
return new ChatbotError("rate_limit:chat").toResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const isToolApprovalFlow = Boolean(messages);
|
const isToolApprovalFlow = Boolean(messages);
|
||||||
|
|
||||||
|
if (!isToolApprovalFlow) {
|
||||||
|
try {
|
||||||
|
await checkAndConsume(session.user.id, "chat_message");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LimitExceededError) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
code: "rate_limit:chat",
|
||||||
|
message: `Daily message limit reached (${error.used}/${error.limit} on ${error.tier} tier). Upgrade to Pro at /pricing.`,
|
||||||
|
},
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const chat = await getChatById({ id });
|
const chat = await getChatById({ id });
|
||||||
let messagesFromDb: DBMessage[] = [];
|
let messagesFromDb: DBMessage[] = [];
|
||||||
let titlePromise: Promise<string> | null = null;
|
let titlePromise: Promise<string> | null = null;
|
||||||
|
|
@ -165,6 +174,8 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const modelMessages = await convertToModelMessages(uiMessages);
|
const modelMessages = await convertToModelMessages(uiMessages);
|
||||||
|
|
||||||
|
const meteredWeather = withLimit(getWeather, "tool_call", session.user.id);
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
const stream = createUIMessageStream({
|
||||||
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
|
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
|
||||||
execute: async ({ writer: dataStream }) => {
|
execute: async ({ writer: dataStream }) => {
|
||||||
|
|
@ -176,7 +187,7 @@ export async function POST(request: Request) {
|
||||||
experimental_activeTools:
|
experimental_activeTools:
|
||||||
isReasoningModel && !supportsTools ? [] : ["getWeather"],
|
isReasoningModel && !supportsTools ? [] : ["getWeather"],
|
||||||
tools: {
|
tools: {
|
||||||
getWeather,
|
getWeather: meteredWeather,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
16
app/(chat)/api/usage/route.ts
Normal file
16
app/(chat)/api/usage/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { ChatbotError } from "@/lib/errors";
|
||||||
|
import { getQuotaSummary } from "@/lib/subscription/service";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
|
}
|
||||||
|
const summary = await getQuotaSummary(session.user.id);
|
||||||
|
return Response.json(summary, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Analytics } from "@/components/analytics";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
|
@ -68,6 +69,7 @@ export default function RootLayout({
|
||||||
__html: THEME_COLOR_SCRIPT,
|
__html: THEME_COLOR_SCRIPT,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Analytics />
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
|
|
|
||||||
96
app/pricing/page.tsx
Normal file
96
app/pricing/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { PricingClient } from "@/components/pricing-client";
|
||||||
|
import { LIMITS, type Tier } from "@/lib/subscription/config";
|
||||||
|
import { ensureSubscription } from "@/lib/subscription/service";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function PricingPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ upgraded?: string; canceled?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/api/auth/guest?redirectUrl=%2Fpricing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = await ensureSubscription(session.user.id);
|
||||||
|
const params = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-dvh max-w-5xl flex-col gap-8 px-6 py-12">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<a className="text-muted-foreground text-sm hover:text-foreground" href="/">
|
||||||
|
← Back to chat
|
||||||
|
</a>
|
||||||
|
<h1 className="font-semibold text-3xl">Pricing</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
You are currently on the <strong>{sub.tier}</strong> tier.
|
||||||
|
{params.upgraded === "1" && (
|
||||||
|
<span className="ml-2 text-green-600">
|
||||||
|
Welcome to Pro — limits updated.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{params.canceled === "1" && (
|
||||||
|
<span className="ml-2 text-amber-600">Checkout canceled.</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Plan
|
||||||
|
currentTier={sub.tier as Tier}
|
||||||
|
description="Try the chatbot with a small daily quota."
|
||||||
|
price="Free"
|
||||||
|
tier="free"
|
||||||
|
/>
|
||||||
|
<Plan
|
||||||
|
currentTier={sub.tier as Tier}
|
||||||
|
description="Higher daily quotas for serious use."
|
||||||
|
price="$10 / month"
|
||||||
|
tier="pro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PricingClient currentTier={sub.tier as Tier} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Plan({
|
||||||
|
tier,
|
||||||
|
price,
|
||||||
|
description,
|
||||||
|
currentTier,
|
||||||
|
}: {
|
||||||
|
tier: Tier;
|
||||||
|
price: string;
|
||||||
|
description: string;
|
||||||
|
currentTier: Tier;
|
||||||
|
}) {
|
||||||
|
const tierLimits = LIMITS[tier];
|
||||||
|
const isCurrent = tier === currentTier;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-2xl border border-border/40 bg-card p-6 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="font-medium text-lg capitalize">{tier}</div>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-muted-foreground text-xs">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-2xl">{price}</div>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<li>• {tierLimits.chat_message} messages / day</li>
|
||||||
|
<li>• {tierLimits.tool_call} tool calls / day</li>
|
||||||
|
<li>• All available models</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/analytics.tsx
Normal file
20
components/analytics.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
export function Analytics() {
|
||||||
|
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||||
|
const scriptUrl = process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL;
|
||||||
|
|
||||||
|
if (!(websiteId && scriptUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
async
|
||||||
|
data-website-id={websiteId}
|
||||||
|
defer
|
||||||
|
src={scriptUrl}
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PanelLeftIcon } from "lucide-react";
|
import { PanelLeftIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
import { VercelIcon } from "./icons";
|
import { UsageBadge } from "./usage-badge";
|
||||||
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
|
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
|
||||||
|
|
||||||
function PureChatHeader({
|
function PureChatHeader({
|
||||||
|
|
@ -34,15 +33,6 @@ function PureChatHeader({
|
||||||
<PanelLeftIcon className="size-4" />
|
<PanelLeftIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link
|
|
||||||
className="flex size-8 items-center justify-center rounded-lg md:hidden"
|
|
||||||
href="https://vercel.com/templates/next.js/chatbot"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<VercelIcon size={14} />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{!isReadonly && (
|
{!isReadonly && (
|
||||||
<VisibilitySelector
|
<VisibilitySelector
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
|
@ -50,19 +40,9 @@ function PureChatHeader({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<div className="ml-auto flex items-center gap-2">
|
||||||
asChild
|
<UsageBadge />
|
||||||
className="hidden rounded-lg bg-foreground px-4 text-background hover:bg-foreground/90 md:ml-auto md:flex"
|
</div>
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="https://vercel.com/templates/next.js/chatbot"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<VercelIcon size={16} />
|
|
||||||
Deploy with Vercel
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
components/chat/usage-badge.tsx
Normal file
44
components/chat/usage-badge.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import type { QuotaSummary } from "@/lib/subscription/service";
|
||||||
|
import { fetcher } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function UsageBadge() {
|
||||||
|
const { data } = useSWR<QuotaSummary>(
|
||||||
|
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/usage`,
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: 15_000,
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPro = data.tier === "pro";
|
||||||
|
const lowQuota =
|
||||||
|
!isPro &&
|
||||||
|
(data.messages.remaining <= 2 || data.toolCalls.remaining <= 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||||
|
lowQuota
|
||||||
|
? "border-amber-500/40 bg-amber-500/10 text-amber-600 hover:bg-amber-500/15"
|
||||||
|
: "border-border/40 text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
href="/pricing"
|
||||||
|
title={`${data.tier} tier — ${data.toolCalls.used}/${data.toolCalls.limit} tool calls today`}
|
||||||
|
>
|
||||||
|
<span className="uppercase tracking-wider opacity-60">{data.tier}</span>
|
||||||
|
<span>
|
||||||
|
{data.messages.used}/{data.messages.limit} msgs
|
||||||
|
</span>
|
||||||
|
{isPro ? null : <span aria-hidden>→</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/pricing-client.tsx
Normal file
48
components/pricing-client.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Tier } from "@/lib/subscription/config";
|
||||||
|
|
||||||
|
export function PricingClient({ currentTier }: { currentTier: Tier }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (currentTier === "pro") {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
You're on Pro. Manage your subscription through the receipt email link.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgrade = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/billing/checkout", { method: "POST" });
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && data.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(data.message ?? "Failed to start checkout");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Checkout failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
className="px-6"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={upgrade}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{loading ? "Opening checkout..." : "Upgrade to Pro"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import type { UserType } from "@/app/(auth)/auth";
|
|
||||||
|
|
||||||
type Entitlements = {
|
|
||||||
maxMessagesPerHour: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const entitlementsByUserType: Record<UserType, Entitlements> = {
|
|
||||||
guest: {
|
|
||||||
maxMessagesPerHour: 10,
|
|
||||||
},
|
|
||||||
regular: {
|
|
||||||
maxMessagesPerHour: 10,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export const DEFAULT_CHAT_MODEL = "claude-sonnet";
|
export const DEFAULT_CHAT_MODEL = "gpt-5.4-mini";
|
||||||
|
|
||||||
export type ModelCapabilities = {
|
export type ModelCapabilities = {
|
||||||
tools: boolean;
|
tools: boolean;
|
||||||
|
|
|
||||||
40
lib/ai/tools/with-limit.ts
Normal file
40
lib/ai/tools/with-limit.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Tool } from "ai";
|
||||||
|
import type { LimitKey } from "@/lib/subscription/config";
|
||||||
|
import {
|
||||||
|
checkAndConsume,
|
||||||
|
LimitExceededError,
|
||||||
|
} from "@/lib/subscription/service";
|
||||||
|
|
||||||
|
export function withLimit<T extends Tool>(
|
||||||
|
tool: T,
|
||||||
|
kind: LimitKey,
|
||||||
|
userId: string
|
||||||
|
): T {
|
||||||
|
const originalExecute = tool.execute;
|
||||||
|
if (!originalExecute) {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: wrapper has to be generic across all tool signatures
|
||||||
|
const wrappedExecute = (async (input: any, options: any) => {
|
||||||
|
try {
|
||||||
|
await checkAndConsume(userId, kind);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LimitExceededError) {
|
||||||
|
return {
|
||||||
|
error: "limit_exceeded",
|
||||||
|
kind: error.kind,
|
||||||
|
tier: error.tier,
|
||||||
|
used: error.used,
|
||||||
|
limit: error.limit,
|
||||||
|
message: `You've hit your ${kind.replace("_", " ")} limit for today (${error.used}/${error.limit} on ${error.tier} tier). Upgrade to Pro for higher limits.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return originalExecute(input, options);
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: tool execute signature varies
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
return { ...tool, execute: wrappedExecute } as T;
|
||||||
|
}
|
||||||
105
lib/billing/gateway.ts
Normal file
105
lib/billing/gateway.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const PAYMENT_GATEWAY_URL = process.env.EGBE_PAYMENT_GATEWAY_URL ?? "";
|
||||||
|
const GATEWAY_TOKEN = process.env.EGBE_GATEWAYS_TOKEN ?? "";
|
||||||
|
const WEBHOOK_SECRET = process.env.EGBE_PAYMENT_GATEWAY_WEBHOOK_SECRET ?? "";
|
||||||
|
const WEBHOOK_HEADER =
|
||||||
|
process.env.EGBE_PAYMENT_GATEWAY_WEBHOOK_HEADER ??
|
||||||
|
"x-egbe-payment-signature";
|
||||||
|
|
||||||
|
export function isBillingConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
PAYMENT_GATEWAY_URL && GATEWAY_TOKEN && process.env.PRO_STRIPE_PRICE_ID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckoutSessionArgs = {
|
||||||
|
userId: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
appSlug: string;
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createCheckoutSession(args: CheckoutSessionArgs) {
|
||||||
|
const priceId = process.env.PRO_STRIPE_PRICE_ID;
|
||||||
|
if (!priceId) {
|
||||||
|
throw new Error("PRO_STRIPE_PRICE_ID not set");
|
||||||
|
}
|
||||||
|
if (!(PAYMENT_GATEWAY_URL && GATEWAY_TOKEN)) {
|
||||||
|
throw new Error("Payment gateway not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
price: priceId,
|
||||||
|
mode: "subscription",
|
||||||
|
quantity: 1,
|
||||||
|
successUrl: `${args.baseUrl}/pricing?upgraded=1`,
|
||||||
|
cancelUrl: `${args.baseUrl}/pricing?canceled=1`,
|
||||||
|
customerEmail: args.customerEmail,
|
||||||
|
appSlug: args.appSlug,
|
||||||
|
webhookPath: "/api/billing/webhook",
|
||||||
|
metadata: { userId: args.userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${PAYMENT_GATEWAY_URL}/v1/checkout-sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${GATEWAY_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Payment gateway error ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<{ id: string; url: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webhookHeaderName = WEBHOOK_HEADER;
|
||||||
|
|
||||||
|
export function verifyWebhookSignature(
|
||||||
|
rawBody: string,
|
||||||
|
signatureHeader: string | null,
|
||||||
|
toleranceSeconds = 300
|
||||||
|
): boolean {
|
||||||
|
if (!(WEBHOOK_SECRET && signatureHeader)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
signatureHeader.split(",").map((part) => {
|
||||||
|
const idx = part.indexOf("=");
|
||||||
|
return idx === -1 ? [part, ""] : [part.slice(0, idx), part.slice(idx + 1)];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const timestamp = parts.t;
|
||||||
|
const v1 = parts.v1;
|
||||||
|
if (!(timestamp && v1)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsNum = Number(timestamp);
|
||||||
|
if (Number.isFinite(tsNum)) {
|
||||||
|
const ageSeconds = Math.abs(Date.now() / 1000 - tsNum);
|
||||||
|
if (ageSeconds > toleranceSeconds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = createHmac("sha256", WEBHOOK_SECRET)
|
||||||
|
.update(`${timestamp}.${rawBody}`)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(
|
||||||
|
Buffer.from(expected, "hex"),
|
||||||
|
Buffer.from(v1, "hex")
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
lib/db/migrations/0001_even_baron_strucker.sql
Normal file
37
lib/db/migrations/0001_even_baron_strucker.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "Subscription" (
|
||||||
|
"userId" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"tier" varchar DEFAULT 'free' NOT NULL,
|
||||||
|
"status" varchar DEFAULT 'active' NOT NULL,
|
||||||
|
"stripeCustomerId" text,
|
||||||
|
"stripeSubscriptionId" text,
|
||||||
|
"currentPeriodEnd" timestamp,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "Usage" (
|
||||||
|
"userId" uuid NOT NULL,
|
||||||
|
"periodKey" varchar(10) NOT NULL,
|
||||||
|
"messageCount" integer DEFAULT 0 NOT NULL,
|
||||||
|
"toolCallCount" integer DEFAULT 0 NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "Usage_userId_periodKey_pk" PRIMARY KEY("userId","periodKey")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "WebhookEvent" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"receivedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Usage" ADD CONSTRAINT "Usage_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
435
lib/db/migrations/meta/0001_snapshot.json
Normal file
435
lib/db/migrations/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
{
|
||||||
|
"id": "61392e82-35e9-40f4-a8a4-dc50c5f0e24a",
|
||||||
|
"prevId": "e4157317-da3b-4af3-b3e6-dbddfc2e1ced",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.Chat": {
|
||||||
|
"name": "Chat",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"name": "visibility",
|
||||||
|
"type": "varchar",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'private'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"Chat_userId_User_id_fk": {
|
||||||
|
"name": "Chat_userId_User_id_fk",
|
||||||
|
"tableFrom": "Chat",
|
||||||
|
"tableTo": "User",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.Message_v2": {
|
||||||
|
"name": "Message_v2",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"chatId": {
|
||||||
|
"name": "chatId",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "varchar",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"parts": {
|
||||||
|
"name": "parts",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"attachments": {
|
||||||
|
"name": "attachments",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"Message_v2_chatId_Chat_id_fk": {
|
||||||
|
"name": "Message_v2_chatId_Chat_id_fk",
|
||||||
|
"tableFrom": "Message_v2",
|
||||||
|
"tableTo": "Chat",
|
||||||
|
"columnsFrom": [
|
||||||
|
"chatId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.Subscription": {
|
||||||
|
"name": "Subscription",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"tier": {
|
||||||
|
"name": "tier",
|
||||||
|
"type": "varchar",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "varchar",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"stripeCustomerId": {
|
||||||
|
"name": "stripeCustomerId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"stripeSubscriptionId": {
|
||||||
|
"name": "stripeSubscriptionId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"currentPeriodEnd": {
|
||||||
|
"name": "currentPeriodEnd",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"Subscription_userId_User_id_fk": {
|
||||||
|
"name": "Subscription_userId_User_id_fk",
|
||||||
|
"tableFrom": "Subscription",
|
||||||
|
"tableTo": "User",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.Usage": {
|
||||||
|
"name": "Usage",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"periodKey": {
|
||||||
|
"name": "periodKey",
|
||||||
|
"type": "varchar(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"messageCount": {
|
||||||
|
"name": "messageCount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"toolCallCount": {
|
||||||
|
"name": "toolCallCount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"Usage_userId_User_id_fk": {
|
||||||
|
"name": "Usage_userId_User_id_fk",
|
||||||
|
"tableFrom": "Usage",
|
||||||
|
"tableTo": "User",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"Usage_userId_periodKey_pk": {
|
||||||
|
"name": "Usage_userId_periodKey_pk",
|
||||||
|
"columns": [
|
||||||
|
"userId",
|
||||||
|
"periodKey"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.User": {
|
||||||
|
"name": "User",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"name": "emailVerified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"isAnonymous": {
|
||||||
|
"name": "isAnonymous",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.Vote_v2": {
|
||||||
|
"name": "Vote_v2",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"chatId": {
|
||||||
|
"name": "chatId",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"messageId": {
|
||||||
|
"name": "messageId",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"isUpvoted": {
|
||||||
|
"name": "isUpvoted",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"Vote_v2_chatId_Chat_id_fk": {
|
||||||
|
"name": "Vote_v2_chatId_Chat_id_fk",
|
||||||
|
"tableFrom": "Vote_v2",
|
||||||
|
"tableTo": "Chat",
|
||||||
|
"columnsFrom": [
|
||||||
|
"chatId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"Vote_v2_messageId_Message_v2_id_fk": {
|
||||||
|
"name": "Vote_v2_messageId_Message_v2_id_fk",
|
||||||
|
"tableFrom": "Vote_v2",
|
||||||
|
"tableTo": "Message_v2",
|
||||||
|
"columnsFrom": [
|
||||||
|
"messageId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"Vote_v2_chatId_messageId_pk": {
|
||||||
|
"name": "Vote_v2_chatId_messageId_pk",
|
||||||
|
"columns": [
|
||||||
|
"chatId",
|
||||||
|
"messageId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.WebhookEvent": {
|
||||||
|
"name": "WebhookEvent",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"receivedAt": {
|
||||||
|
"name": "receivedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1779705963902,
|
"when": 1779705963902,
|
||||||
"tag": "0000_bouncy_payback",
|
"tag": "0000_bouncy_payback",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779715519996,
|
||||||
|
"tag": "0001_even_baron_strucker",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
integer,
|
||||||
json,
|
json,
|
||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
|
@ -68,3 +69,49 @@ export const vote = pgTable(
|
||||||
);
|
);
|
||||||
|
|
||||||
export type Vote = InferSelectModel<typeof vote>;
|
export type Vote = InferSelectModel<typeof vote>;
|
||||||
|
|
||||||
|
export const subscription = pgTable("Subscription", {
|
||||||
|
userId: uuid("userId")
|
||||||
|
.primaryKey()
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id),
|
||||||
|
tier: varchar("tier", { enum: ["free", "pro"] })
|
||||||
|
.notNull()
|
||||||
|
.default("free"),
|
||||||
|
status: varchar("status", { enum: ["active", "canceled", "past_due"] })
|
||||||
|
.notNull()
|
||||||
|
.default("active"),
|
||||||
|
stripeCustomerId: text("stripeCustomerId"),
|
||||||
|
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||||
|
currentPeriodEnd: timestamp("currentPeriodEnd"),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Subscription = InferSelectModel<typeof subscription>;
|
||||||
|
|
||||||
|
export const usage = pgTable(
|
||||||
|
"Usage",
|
||||||
|
{
|
||||||
|
userId: uuid("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id),
|
||||||
|
periodKey: varchar("periodKey", { length: 10 }).notNull(),
|
||||||
|
messageCount: integer("messageCount").notNull().default(0),
|
||||||
|
toolCallCount: integer("toolCallCount").notNull().default(0),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.userId, table.periodKey] }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Usage = InferSelectModel<typeof usage>;
|
||||||
|
|
||||||
|
export const webhookEvent = pgTable("WebhookEvent", {
|
||||||
|
id: text("id").primaryKey().notNull(),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
receivedAt: timestamp("receivedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WebhookEvent = InferSelectModel<typeof webhookEvent>;
|
||||||
|
|
|
||||||
27
lib/subscription/config.ts
Normal file
27
lib/subscription/config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export type Tier = "free" | "pro";
|
||||||
|
export type LimitKey = "chat_message" | "tool_call";
|
||||||
|
|
||||||
|
export type TierLimits = Record<LimitKey, number>;
|
||||||
|
|
||||||
|
export const LIMITS: Record<Tier, TierLimits> = {
|
||||||
|
free: {
|
||||||
|
chat_message: 10,
|
||||||
|
tool_call: 5,
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
chat_message: 1000,
|
||||||
|
tool_call: 500,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIER_LABELS: Record<Tier, string> = {
|
||||||
|
free: "Free",
|
||||||
|
pro: "Pro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function todayPeriodKey(now = new Date()): string {
|
||||||
|
const y = now.getUTCFullYear();
|
||||||
|
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
210
lib/subscription/service.ts
Normal file
210
lib/subscription/service.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import {
|
||||||
|
subscription,
|
||||||
|
type Subscription,
|
||||||
|
usage,
|
||||||
|
type Usage,
|
||||||
|
} from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
type LimitKey,
|
||||||
|
LIMITS,
|
||||||
|
type Tier,
|
||||||
|
todayPeriodKey,
|
||||||
|
} from "./config";
|
||||||
|
|
||||||
|
const client = postgres(process.env.DATABASE_URL ?? "");
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
export class LimitExceededError extends Error {
|
||||||
|
kind: LimitKey;
|
||||||
|
tier: Tier;
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
constructor(args: {
|
||||||
|
kind: LimitKey;
|
||||||
|
tier: Tier;
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
}) {
|
||||||
|
super(
|
||||||
|
`Limit exceeded for ${args.kind}: ${args.used}/${args.limit} on ${args.tier} tier`
|
||||||
|
);
|
||||||
|
this.kind = args.kind;
|
||||||
|
this.tier = args.tier;
|
||||||
|
this.used = args.used;
|
||||||
|
this.limit = args.limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSubscription(userId: string): Promise<Subscription> {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(subscription)
|
||||||
|
.where(eq(subscription.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
return existing[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(subscription)
|
||||||
|
.values({ userId, tier: "free", status: "active" })
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscription(
|
||||||
|
userId: string
|
||||||
|
): Promise<Subscription | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(subscription)
|
||||||
|
.where(eq(subscription.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUsage(userId: string): Promise<Usage> {
|
||||||
|
const periodKey = todayPeriodKey();
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(usage)
|
||||||
|
.where(and(eq(usage.userId, userId), eq(usage.periodKey, periodKey)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(usage)
|
||||||
|
.values({ userId, periodKey, messageCount: 0, toolCallCount: 0 })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fallback] = await db
|
||||||
|
.select()
|
||||||
|
.from(usage)
|
||||||
|
.where(and(eq(usage.userId, userId), eq(usage.periodKey, periodKey)))
|
||||||
|
.limit(1);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAndConsume(
|
||||||
|
userId: string,
|
||||||
|
kind: LimitKey,
|
||||||
|
units = 1
|
||||||
|
): Promise<{ remaining: number; limit: number; tier: Tier }> {
|
||||||
|
const sub = await ensureSubscription(userId);
|
||||||
|
const tier = sub.tier as Tier;
|
||||||
|
const limit = LIMITS[tier][kind];
|
||||||
|
const column = kind === "chat_message" ? usage.messageCount : usage.toolCallCount;
|
||||||
|
const column_name = kind === "chat_message" ? "messageCount" : "toolCallCount";
|
||||||
|
const periodKey = todayPeriodKey();
|
||||||
|
|
||||||
|
await ensureUsageRow(userId, periodKey);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(usage)
|
||||||
|
.set({
|
||||||
|
[column_name]: sql`${column} + ${units}`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(usage.userId, userId),
|
||||||
|
eq(usage.periodKey, periodKey),
|
||||||
|
sql`${column} + ${units} <= ${limit}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
const current = await getCurrentUsage(userId);
|
||||||
|
const used =
|
||||||
|
kind === "chat_message" ? current.messageCount : current.toolCallCount;
|
||||||
|
throw new LimitExceededError({ kind, tier, used, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = result[0];
|
||||||
|
const used =
|
||||||
|
kind === "chat_message" ? updated.messageCount : updated.toolCallCount;
|
||||||
|
return { remaining: Math.max(0, limit - used), limit, tier };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUsageRow(userId: string, periodKey: string) {
|
||||||
|
await db
|
||||||
|
.insert(usage)
|
||||||
|
.values({ userId, periodKey, messageCount: 0, toolCallCount: 0 })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upgradeToPro(
|
||||||
|
userId: string,
|
||||||
|
args: {
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
currentPeriodEnd?: Date;
|
||||||
|
} = {}
|
||||||
|
): Promise<Subscription> {
|
||||||
|
await ensureSubscription(userId);
|
||||||
|
const [row] = await db
|
||||||
|
.update(subscription)
|
||||||
|
.set({
|
||||||
|
tier: "pro",
|
||||||
|
status: "active",
|
||||||
|
stripeCustomerId: args.stripeCustomerId,
|
||||||
|
stripeSubscriptionId: args.stripeSubscriptionId,
|
||||||
|
currentPeriodEnd: args.currentPeriodEnd,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(subscription.userId, userId))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelSubscription(
|
||||||
|
userId: string
|
||||||
|
): Promise<Subscription> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(subscription)
|
||||||
|
.set({ status: "canceled", updatedAt: new Date() })
|
||||||
|
.where(eq(subscription.userId, userId))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotaSummary(userId: string) {
|
||||||
|
const sub = await ensureSubscription(userId);
|
||||||
|
const tier = sub.tier as Tier;
|
||||||
|
const usage = await getCurrentUsage(userId);
|
||||||
|
const tierLimits = LIMITS[tier];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
status: sub.status,
|
||||||
|
periodKey: usage.periodKey,
|
||||||
|
messages: {
|
||||||
|
used: usage.messageCount,
|
||||||
|
limit: tierLimits.chat_message,
|
||||||
|
remaining: Math.max(0, tierLimits.chat_message - usage.messageCount),
|
||||||
|
},
|
||||||
|
toolCalls: {
|
||||||
|
used: usage.toolCallCount,
|
||||||
|
limit: tierLimits.tool_call,
|
||||||
|
remaining: Math.max(0, tierLimits.tool_call - usage.toolCallCount),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuotaSummary = Awaited<ReturnType<typeof getQuotaSummary>>;
|
||||||
4
proxy.ts
4
proxy.ts
|
|
@ -13,6 +13,10 @@ export async function proxy(request: NextRequest) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/billing/webhook")) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
const token = await getToken({
|
const token = await getToken({
|
||||||
req: request,
|
req: request,
|
||||||
secret: process.env.AUTH_SECRET,
|
secret: process.env.AUTH_SECRET,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue