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
|
|
@ -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>>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue