- 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
105 lines
2.7 KiB
TypeScript
105 lines
2.7 KiB
TypeScript
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;
|
|
}
|
|
}
|