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