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