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

@ -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 = {
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,
"tag": "0000_bouncy_payback",
"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 {
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>;

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