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:
dmitry.galkin 2026-05-25 17:27:08 +04:00
parent 3e21c2334c
commit 22a172e92d
23 changed files with 1374 additions and 86 deletions

View file

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

View file

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

View 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 });
}
}

View 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:
}
}

View file

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

View 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",
},
});
}

View file

@ -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
View 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
View 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"
/>
);
}

View file

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

View 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>
);
}

View 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>
);
}

View file

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

View file

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

View 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
View 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;
}
}

View 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 $$;

View 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": {}
}
}

View file

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

View file

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

View 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
View 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>>;

View file

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