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
|
||||
|
||||
# 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=
|
||||
|
||||
# Public origin of this app — auto-injected by app-deploy.sh
|
||||
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
|
||||
|
||||
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 SDK** + `@ai-sdk/openai-compatible` → routes to `EGBE_AI_API_URL` (our LiteLLM)
|
||||
- **NextAuth** (credentials + guest)
|
||||
- **Drizzle ORM** + Postgres (DB gateway)
|
||||
- **shadcn/ui** + Tailwind
|
||||
- **AI** via `@ai-sdk/openai-compatible` → `EGBE_AI_API_URL` (LiteLLM proxy)
|
||||
- **Auth** — NextAuth credentials + guest mode
|
||||
- **DB** — Drizzle ORM + Postgres (provisioned by app-deploy.sh)
|
||||
- **Analytics** — Umami `<Script>` mounted when `NEXT_PUBLIC_UMAMI_*` env are set
|
||||
- **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)
|
||||
|
||||
```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:
|
||||
- `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
|
||||
```
|
||||
`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`.
|
||||
|
||||
## Local dev
|
||||
|
||||
|
|
@ -44,14 +65,6 @@ pnpm db:migrate
|
|||
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
|
||||
|
||||
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,
|
||||
streamText,
|
||||
} from "ai";
|
||||
import { auth, type UserType } from "@/app/(auth)/auth";
|
||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
allowedModelIds,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
|
|
@ -15,10 +14,10 @@ import {
|
|||
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
||||
import { getLanguageModel } from "@/lib/ai/providers";
|
||||
import { getWeather } from "@/lib/ai/tools/get-weather";
|
||||
import { withLimit } from "@/lib/ai/tools/with-limit";
|
||||
import {
|
||||
deleteChatById,
|
||||
getChatById,
|
||||
getMessageCountByUserId,
|
||||
getMessagesByChatId,
|
||||
saveChat,
|
||||
saveMessages,
|
||||
|
|
@ -28,6 +27,10 @@ import {
|
|||
import type { DBMessage } from "@/lib/db/schema";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
import { checkIpRateLimit } from "@/lib/ratelimit";
|
||||
import {
|
||||
checkAndConsume,
|
||||
LimitExceededError,
|
||||
} from "@/lib/subscription/service";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
||||
import { generateTitleFromUserMessage } from "../../actions";
|
||||
|
|
@ -69,19 +72,25 @@ export async function POST(request: 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);
|
||||
|
||||
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 });
|
||||
let messagesFromDb: DBMessage[] = [];
|
||||
let titlePromise: Promise<string> | null = null;
|
||||
|
|
@ -165,6 +174,8 @@ export async function POST(request: Request) {
|
|||
|
||||
const modelMessages = await convertToModelMessages(uiMessages);
|
||||
|
||||
const meteredWeather = withLimit(getWeather, "tool_call", session.user.id);
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
|
||||
execute: async ({ writer: dataStream }) => {
|
||||
|
|
@ -176,7 +187,7 @@ export async function POST(request: Request) {
|
|||
experimental_activeTools:
|
||||
isReasoningModel && !supportsTools ? [] : ["getWeather"],
|
||||
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 { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Analytics } from "@/components/analytics";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ export default function RootLayout({
|
|||
__html: THEME_COLOR_SCRIPT,
|
||||
}}
|
||||
/>
|
||||
<Analytics />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<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";
|
||||
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { VercelIcon } from "./icons";
|
||||
import { UsageBadge } from "./usage-badge";
|
||||
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
|
||||
|
||||
function PureChatHeader({
|
||||
|
|
@ -34,15 +33,6 @@ function PureChatHeader({
|
|||
<PanelLeftIcon className="size-4" />
|
||||
</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 && (
|
||||
<VisibilitySelector
|
||||
chatId={chatId}
|
||||
|
|
@ -50,19 +40,9 @@ function PureChatHeader({
|
|||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="hidden rounded-lg bg-foreground px-4 text-background hover:bg-foreground/90 md:ml-auto md:flex"
|
||||
>
|
||||
<Link
|
||||
href="https://vercel.com/templates/next.js/chatbot"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<VercelIcon size={16} />
|
||||
Deploy with Vercel
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<UsageBadge />
|
||||
</div>
|
||||
</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 = {
|
||||
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,
|
||||
"tag": "0000_bouncy_payback",
|
||||
"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 {
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
|
|
@ -68,3 +69,49 @@ export const vote = pgTable(
|
|||
);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/billing/webhook")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue