feat(services): add billing-service (subscriptions, Stripe, usage, licenses, plans)

- Copied as-is from learning_voice_ai_agent/services/billing-service
- 32 tests passing (vitest)
- Fastify 5 + Cosmos DB + Stripe + Zod
- Modules: subscriptions, licenses, plans, usage, stripe
- Port 4002
This commit is contained in:
saravanakumardb1 2026-02-12 11:39:05 -08:00
parent e1ab956ac3
commit fc5f2bf296
28 changed files with 1906 additions and 0 deletions

2
services/billing-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

View File

@ -0,0 +1,16 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 4002
CMD ["node", "dist/server.js"]

View File

@ -0,0 +1,30 @@
{
"name": "@lysnrai/billing-service",
"version": "0.1.0",
"private": true,
"description": "Billing & Entitlement Service — subscriptions, payments, usage, licenses, plans",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/"
},
"dependencies": {
"@azure/cosmos": "^4.2.0",
"fastify": "^5.2.1",
"@fastify/cors": "^10.0.2",
"@fastify/swagger": "^9.4.2",
"fastify-metrics": "^10.3.0",
"stripe": "^17.5.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,33 @@
import { z } from "zod";
const envSchema = z.object({
// Server
PORT: z.coerce.number().default(4002),
HOST: z.string().default("0.0.0.0"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
CORS_ORIGIN: z.string().optional(),
SERVICE_NAME: z.string().default("billing-service"),
// Auth
BILLING_INTERNAL_KEY: z.string().optional(),
// Database
COSMOS_ENDPOINT: z.string().min(1, "COSMOS_ENDPOINT is required"),
COSMOS_KEY: z.string().min(1, "COSMOS_KEY is required"),
COSMOS_DATABASE: z.string().default("lysnrai"),
// Stripe
STRIPE_SECRET_KEY: z.string().min(1, "STRIPE_SECRET_KEY is required"),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PRICE_PRO: z.string().optional(),
STRIPE_PRICE_ENTERPRISE: z.string().optional(),
// External Services
BACKEND_URL: z.string().default("http://localhost:8000"),
// Feature Flags / Limits
PLAN_LIMITS_JSON: z.string().optional(),
USAGE_WARN_THRESHOLD: z.coerce.number().default(0.8),
});
export const config = envSchema.parse(process.env);

View File

@ -0,0 +1,24 @@
/**
* Shared Cosmos DB client for the Billing Service.
*/
import { CosmosClient, Container } from "@azure/cosmos";
let client: CosmosClient | null = null;
function getClient(): CosmosClient {
if (!client) {
const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
if (!endpoint || !key) {
throw new Error("COSMOS_ENDPOINT and COSMOS_KEY must be set");
}
client = new CosmosClient({ endpoint, key });
}
return client;
}
export function getContainer(name: string): Container {
const database = process.env.COSMOS_DATABASE || "lysnrai";
return getClient().database(database).container(name);
}

View File

@ -0,0 +1,40 @@
/**
* Typed service errors for consistent HTTP error responses.
*/
export class ServiceError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = "ServiceError";
}
}
export class NotFoundError extends ServiceError {
constructor(message = "Not found") {
super(404, message);
}
}
export class BadRequestError extends ServiceError {
constructor(message = "Bad request") {
super(400, message);
}
}
export class ForbiddenError extends ServiceError {
constructor(message = "Forbidden") {
super(403, message);
}
}
export class TooManyRequestsError extends ServiceError {
constructor(
message = "Too many requests",
public details?: Record<string, unknown>,
) {
super(429, message);
}
}

View File

@ -0,0 +1,8 @@
/**
* Centralized product identity single source of truth.
* NOTE: The canonical source is shared/product.json at the repo root.
*/
export const PRODUCT_ID = "lysnrai";
export const DISPLAY_NAME = "LysnrAI";
export const LICENSE_PREFIX = "LYSNR";

View File

@ -0,0 +1,43 @@
/**
* Stripe client for the Billing Service.
*
* Multi-tenant: supports per-product Stripe keys via env vars:
* STRIPE_SECRET_KEY default key
* STRIPE_SECRET_KEY_<PRODUCTID> product-specific key (uppercase)
*/
import Stripe from "stripe";
const _cache = new Map<string, Stripe>();
/** Get a Stripe client, optionally per-product (falls back to default key). */
export function getStripeForProduct(productId?: string): Stripe {
const cacheKey = productId || "default";
if (_cache.has(cacheKey)) return _cache.get(cacheKey)!;
// Try product-specific key first, then default
const envKey = productId
? `STRIPE_SECRET_KEY_${productId.toUpperCase()}`
: "STRIPE_SECRET_KEY";
const key = process.env[envKey] || process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error(`Stripe key not configured (tried ${envKey})`);
const client = new Stripe(key);
_cache.set(cacheKey, client);
return client;
}
/** Alias for backward compatibility. */
export function getStripe(): Stripe {
return getStripeForProduct();
}
/** Get price IDs — configurable per product in the future. */
export function getPriceIds(): Record<string, string> {
return {
pro: process.env.STRIPE_PRICE_PRO || "price_pro_placeholder",
enterprise: process.env.STRIPE_PRICE_ENTERPRISE || "price_enterprise_placeholder",
};
}
export const PRICE_IDS: Record<string, string> = getPriceIds();

View File

@ -0,0 +1,71 @@
/**
* Unit tests for licenses module types + validation + key generation.
*/
import { describe, it, expect } from "vitest";
import { GenerateLicenseSchema, ActivateLicenseSchema, DeactivateLicenseSchema } from "./types.js";
describe("GenerateLicenseSchema", () => {
it("accepts valid input with defaults", () => {
const result = GenerateLicenseSchema.safeParse({
userId: "user_123",
plan: "pro",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.maxDevices).toBe(3);
expect(result.data.expiresAt).toBeNull();
}
});
it("accepts custom max devices", () => {
const result = GenerateLicenseSchema.safeParse({
userId: "user_123",
plan: "enterprise",
maxDevices: 10,
expiresAt: "2027-01-01T00:00:00Z",
});
expect(result.success).toBe(true);
});
it("rejects invalid plan", () => {
const result = GenerateLicenseSchema.safeParse({
userId: "user_123",
plan: "premium",
});
expect(result.success).toBe(false);
});
it("rejects missing userId", () => {
const result = GenerateLicenseSchema.safeParse({ plan: "pro" });
expect(result.success).toBe(false);
});
});
describe("ActivateLicenseSchema", () => {
it("accepts valid input", () => {
const result = ActivateLicenseSchema.safeParse({
key: "LYSNR-AB12-CD34-EF56",
deviceId: "device_mac_001",
});
expect(result.success).toBe(true);
});
it("rejects empty key", () => {
const result = ActivateLicenseSchema.safeParse({
key: "",
deviceId: "device_mac_001",
});
expect(result.success).toBe(false);
});
});
describe("DeactivateLicenseSchema", () => {
it("accepts valid input", () => {
const result = DeactivateLicenseSchema.safeParse({
key: "LYSNR-AB12-CD34-EF56",
deviceId: "device_mac_001",
});
expect(result.success).toBe(true);
});
});

View File

@ -0,0 +1,64 @@
/**
* Licenses repository Cosmos DB CRUD.
*/
import crypto from "crypto";
import { getContainer } from "../../lib/cosmos.js";
import { PRODUCT_ID, LICENSE_PREFIX } from "../../lib/product-config.js";
import type { LicenseDoc } from "./types.js";
function container() {
return getContainer("licenses");
}
export function generateKey(): string {
const seg = () => crypto.randomBytes(2).toString("hex").toUpperCase();
return `${LICENSE_PREFIX}-${seg()}-${seg()}-${seg()}`;
}
export async function getByKey(key: string): Promise<LicenseDoc | null> {
const { resources } = await container().items
.query<LicenseDoc>({
query: "SELECT * FROM c WHERE c.productId = @productId AND c.key = @key",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@key", value: key.toUpperCase() },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function getByUserId(userId: string): Promise<LicenseDoc[]> {
const { resources } = await container().items
.query<LicenseDoc>({
query: "SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@userId", value: userId },
],
})
.fetchAll();
return resources;
}
export async function create(doc: LicenseDoc): Promise<LicenseDoc> {
const { resource } = await container().items.create(doc);
return resource as LicenseDoc;
}
export async function update(
id: string,
userId: string,
updates: Partial<LicenseDoc>,
): Promise<LicenseDoc | null> {
try {
const { resource: existing } = await container().item(id, userId).read<LicenseDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, userId).replace(merged);
return resource as LicenseDoc;
} catch {
return null;
}
}

View File

@ -0,0 +1,120 @@
/**
* License key REST endpoints.
*
* POST /licenses/generate generate a new license key
* POST /licenses/activate activate license on a device
* POST /licenses/deactivate deactivate license on a device
* GET /licenses/status/:key get license status
* GET /licenses/user/:userId get user's licenses
*/
import type { FastifyInstance } from "fastify";
import { PRODUCT_ID } from "../../lib/product-config.js";
import { BadRequestError, NotFoundError } from "../../lib/errors.js";
import * as repo from "./repository.js";
import {
GenerateLicenseSchema,
ActivateLicenseSchema,
DeactivateLicenseSchema,
type LicenseDoc,
} from "./types.js";
export async function licenseRoutes(app: FastifyInstance) {
// Generate license
app.post("/licenses/generate", async (req, reply) => {
const parsed = GenerateLicenseSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const input = parsed.data;
const now = new Date().toISOString();
const key = repo.generateKey();
const doc: LicenseDoc = {
id: `lic_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
productId: PRODUCT_ID,
key,
userId: input.userId,
plan: input.plan,
status: "active",
activatedAt: null,
expiresAt: input.expiresAt,
deviceIds: [],
maxDevices: input.maxDevices,
createdAt: now,
updatedAt: now,
};
const created = await repo.create(doc);
reply.code(201);
return created;
});
// Activate
app.post("/licenses/activate", async (req) => {
const parsed = ActivateLicenseSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const { key, deviceId } = parsed.data;
const license = await repo.getByKey(key);
if (!license) throw new NotFoundError("License not found");
if (license.status !== "active") throw new BadRequestError("License is not active");
if (license.expiresAt && new Date(license.expiresAt) < new Date()) {
throw new BadRequestError("License has expired");
}
if (license.deviceIds.includes(deviceId)) {
return license; // Already activated on this device
}
if (license.deviceIds.length >= license.maxDevices) {
throw new BadRequestError(
`Maximum devices (${license.maxDevices}) reached. Deactivate a device first.`,
);
}
const updated = await repo.update(license.id, license.userId, {
deviceIds: [...license.deviceIds, deviceId],
activatedAt: license.activatedAt ?? new Date().toISOString(),
});
if (!updated) throw new NotFoundError("License update failed");
return updated;
});
// Deactivate device
app.post("/licenses/deactivate", async (req) => {
const parsed = DeactivateLicenseSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const { key, deviceId } = parsed.data;
const license = await repo.getByKey(key);
if (!license) throw new NotFoundError("License not found");
const updated = await repo.update(license.id, license.userId, {
deviceIds: license.deviceIds.filter((d) => d !== deviceId),
});
if (!updated) throw new NotFoundError("License update failed");
return updated;
});
// Status
app.get("/licenses/status/:key", async (req) => {
const { key } = req.params as { key: string };
const license = await repo.getByKey(key);
if (!license) throw new NotFoundError("License not found");
return {
key: license.key,
plan: license.plan,
status: license.status,
devicesUsed: license.deviceIds.length,
maxDevices: license.maxDevices,
expiresAt: license.expiresAt,
};
});
// User licenses
app.get("/licenses/user/:userId", async (req) => {
const { userId } = req.params as { userId: string };
const licenses = await repo.getByUserId(userId);
return { licenses };
});
}

View File

@ -0,0 +1,41 @@
/**
* License key types ported from Python backend license routes.
* Key format: LYSNR-XXXX-XXXX-XXXX (uppercase alphanumeric).
*/
import { z } from "zod";
export interface LicenseDoc {
id: string;
productId: string;
key: string;
userId: string;
plan: "free" | "pro" | "enterprise";
status: "active" | "revoked" | "expired";
activatedAt: string | null;
expiresAt: string | null;
deviceIds: string[];
maxDevices: number;
createdAt: string;
updatedAt: string;
}
export const GenerateLicenseSchema = z.object({
userId: z.string().min(1),
plan: z.enum(["free", "pro", "enterprise"]),
maxDevices: z.number().int().min(1).default(3),
expiresAt: z.string().nullable().default(null),
});
export const ActivateLicenseSchema = z.object({
key: z.string().min(1),
deviceId: z.string().min(1),
});
export const DeactivateLicenseSchema = z.object({
key: z.string().min(1),
deviceId: z.string().min(1),
});
export type GenerateLicenseInput = z.infer<typeof GenerateLicenseSchema>;
export type ActivateLicenseInput = z.infer<typeof ActivateLicenseSchema>;

View File

@ -0,0 +1,78 @@
/**
* Unit tests for plans module types + validation + defaults.
*/
import { describe, it, expect } from "vitest";
import { CreatePlanSchema, UpdatePlanSchema, DEFAULT_PLANS } from "./types.js";
describe("DEFAULT_PLANS", () => {
it("has 3 plans", () => {
expect(DEFAULT_PLANS).toHaveLength(3);
});
it("has free, pro, enterprise", () => {
const names = DEFAULT_PLANS.map((p) => p.name);
expect(names).toEqual(["free", "pro", "enterprise"]);
});
it("free plan has correct limits", () => {
const free = DEFAULT_PLANS.find((p) => p.name === "free")!;
expect(free.price).toBe(0);
expect(free.tokens).toBe(10_000);
expect(free.words).toBe(5_000);
expect(free.dictations).toBe(100);
});
it("pro plan has correct price", () => {
const pro = DEFAULT_PLANS.find((p) => p.name === "pro")!;
expect(pro.price).toBe(9.99);
expect(pro.tokens).toBe(100_000);
});
it("enterprise plan has correct limits", () => {
const ent = DEFAULT_PLANS.find((p) => p.name === "enterprise")!;
expect(ent.price).toBe(29.99);
expect(ent.tokens).toBe(1_000_000);
});
});
describe("CreatePlanSchema", () => {
it("accepts valid plan", () => {
const result = CreatePlanSchema.safeParse({
name: "team",
displayName: "Team",
price: 19.99,
tokens: 500_000,
words: 250_000,
dictations: 25_000,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.active).toBe(true);
expect(result.data.features).toEqual([]);
}
});
it("rejects missing name", () => {
const result = CreatePlanSchema.safeParse({
displayName: "Team",
price: 19.99,
tokens: 500_000,
words: 250_000,
dictations: 25_000,
});
expect(result.success).toBe(false);
});
});
describe("UpdatePlanSchema", () => {
it("accepts partial updates", () => {
const result = UpdatePlanSchema.safeParse({ price: 14.99, active: false });
expect(result.success).toBe(true);
});
it("accepts empty object", () => {
const result = UpdatePlanSchema.safeParse({});
expect(result.success).toBe(true);
});
});

View File

@ -0,0 +1,71 @@
/**
* Plans repository Cosmos DB CRUD for plan configurations.
*/
import { getContainer } from "../../lib/cosmos.js";
import { PRODUCT_ID } from "../../lib/product-config.js";
import type { PlanConfig } from "./types.js";
import { DEFAULT_PLANS } from "./types.js";
function container() {
return getContainer("plans");
}
export async function list(): Promise<PlanConfig[]> {
const { resources } = await container().items
.query<PlanConfig>({
query: "SELECT * FROM c WHERE c.productId = @productId ORDER BY c.price ASC",
parameters: [{ name: "@productId", value: PRODUCT_ID }],
})
.fetchAll();
// If no plans in DB yet, return defaults
if (resources.length === 0) {
return getDefaults();
}
return resources;
}
export async function getByName(name: string): Promise<PlanConfig | null> {
const { resources } = await container().items
.query<PlanConfig>({
query: "SELECT * FROM c WHERE c.productId = @productId AND c.name = @name",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@name", value: name },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function create(doc: PlanConfig): Promise<PlanConfig> {
const { resource } = await container().items.create(doc);
return resource as PlanConfig;
}
export async function update(
id: string,
updates: Partial<PlanConfig>,
): Promise<PlanConfig | null> {
try {
const { resource: existing } = await container().item(id, id).read<PlanConfig>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, id).replace(merged);
return resource as PlanConfig;
} catch {
return null;
}
}
export function getDefaults(): PlanConfig[] {
const now = new Date().toISOString();
return DEFAULT_PLANS.map((p) => ({
...p,
id: `plan_${PRODUCT_ID}_${p.name}`,
productId: PRODUCT_ID,
createdAt: now,
updatedAt: now,
}));
}

View File

@ -0,0 +1,84 @@
/**
* Plan configuration REST endpoints.
*
* GET /plans list all plans (from DB or defaults)
* GET /plans/:name get plan by name
* POST /plans create plan
* PUT /plans/:id update plan
* POST /plans/seed seed default plans into DB
*/
import type { FastifyInstance } from "fastify";
import { PRODUCT_ID } from "../../lib/product-config.js";
import { BadRequestError, NotFoundError } from "../../lib/errors.js";
import * as repo from "./repository.js";
import { CreatePlanSchema, UpdatePlanSchema, type PlanConfig } from "./types.js";
export async function planRoutes(app: FastifyInstance) {
// List plans
app.get("/plans", async () => {
return { plans: await repo.list() };
});
// Get by name
app.get("/plans/:name", async (req) => {
const { name } = req.params as { name: string };
const plan = await repo.getByName(name);
if (!plan) {
// Fall back to defaults
const defaults = repo.getDefaults();
const def = defaults.find((d) => d.name === name);
if (!def) throw new NotFoundError("Plan not found");
return def;
}
return plan;
});
// Create plan
app.post("/plans", async (req, reply) => {
const parsed = CreatePlanSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: PlanConfig = {
id: `plan_${PRODUCT_ID}_${input.name}`,
productId: PRODUCT_ID,
...input,
createdAt: now,
updatedAt: now,
};
const created = await repo.create(doc);
reply.code(201);
return created;
});
// Update plan
app.put("/plans/:id", async (req) => {
const { id } = req.params as { id: string };
const parsed = UpdatePlanSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const updated = await repo.update(id, parsed.data);
if (!updated) throw new NotFoundError("Plan not found");
return updated;
});
// Seed defaults
app.post("/plans/seed", async (req, reply) => {
const defaults = repo.getDefaults();
const results: PlanConfig[] = [];
for (const plan of defaults) {
const existing = await repo.getByName(plan.name);
if (!existing) {
results.push(await repo.create(plan));
} else {
results.push(existing);
}
}
reply.code(201);
return { plans: results };
});
}

View File

@ -0,0 +1,81 @@
/**
* Plan configuration types consolidates hardcoded plans from
* Python plans.py, TS stripe.ts, and usage_limits.py into one source.
*/
import { z } from "zod";
export interface PlanConfig {
id: string;
productId: string;
name: string;
displayName: string;
price: number;
tokens: number;
words: number;
dictations: number;
features: string[];
stripePriceId?: string;
active: boolean;
createdAt: string;
updatedAt: string;
}
export const DEFAULT_PLANS: Omit<PlanConfig, "id" | "productId" | "createdAt" | "updatedAt">[] = [
{
name: "free",
displayName: "Free",
price: 0,
tokens: 10_000,
words: 5_000,
dictations: 100,
features: ["basic_dictation", "5_languages"],
active: true,
},
{
name: "pro",
displayName: "Pro",
price: 9.99,
tokens: 100_000,
words: 50_000,
dictations: 5_000,
features: ["basic_dictation", "all_languages", "custom_vocabulary", "priority_support"],
active: true,
},
{
name: "enterprise",
displayName: "Enterprise",
price: 29.99,
tokens: 1_000_000,
words: 500_000,
dictations: 50_000,
features: ["basic_dictation", "all_languages", "custom_vocabulary", "priority_support", "api_access", "sso"],
active: true,
},
];
export const CreatePlanSchema = z.object({
name: z.string().min(1),
displayName: z.string().min(1),
price: z.number().min(0),
tokens: z.number().int().min(0),
words: z.number().int().min(0),
dictations: z.number().int().min(0),
features: z.array(z.string()).default([]),
stripePriceId: z.string().optional(),
active: z.boolean().default(true),
});
export const UpdatePlanSchema = z.object({
displayName: z.string().min(1).optional(),
price: z.number().min(0).optional(),
tokens: z.number().int().min(0).optional(),
words: z.number().int().min(0).optional(),
dictations: z.number().int().min(0).optional(),
features: z.array(z.string()).optional(),
stripePriceId: z.string().optional(),
active: z.boolean().optional(),
});
export type CreatePlanInput = z.infer<typeof CreatePlanSchema>;
export type UpdatePlanInput = z.infer<typeof UpdatePlanSchema>;

View File

@ -0,0 +1,270 @@
/**
* Stripe integration routes checkout, webhook, customer portal.
*
* POST /stripe/checkout create Stripe checkout session
* POST /stripe/webhook handle Stripe webhook events
* POST /stripe/portal create Stripe customer portal session
*
* Multi-tenant: productId is embedded in Stripe metadata and used to route
* webhook events. Supports per-product Stripe keys via STRIPE_SECRET_KEY_<PRODUCTID>.
*/
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyRequest } from "fastify";
import Stripe from "stripe";
import { PRODUCT_ID } from "../../lib/product-config.js";
import { getStripeForProduct, getPriceIds } from "../../lib/stripe.js";
import { BadRequestError } from "../../lib/errors.js";
import * as subRepo from "../subscriptions/repository.js";
import type { SubscriptionDoc, PaymentDoc } from "../subscriptions/types.js";
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000";
/** Sync plan change back to the backend users container (best-effort). */
async function syncUserPlan(userId: string, plan: string, log?: { warn: (...args: unknown[]) => void }): Promise<void> {
try {
await fetch(`${BACKEND_URL}/api/users/${userId}/plan`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan }),
});
} catch (err) {
// Best-effort — don't fail the webhook if backend is unreachable
(log ?? console).warn(`Failed to sync plan to backend for ${userId}:`, err);
}
}
export async function stripeRoutes(app: FastifyInstance) {
// ── Checkout ──────────────────────────────────────────────
app.post("/stripe/checkout", async (req, reply) => {
const { userId, plan, successUrl, cancelUrl, trialDays, promoCode } =
req.body as {
userId: string;
plan: string;
successUrl: string;
cancelUrl: string;
trialDays?: number;
promoCode?: string;
};
if (!userId || !plan || !successUrl || !cancelUrl) {
throw new BadRequestError("userId, plan, successUrl, cancelUrl are required");
}
const stripe = getStripeForProduct(PRODUCT_ID);
const priceIds = getPriceIds();
const priceId = priceIds[plan];
if (!priceId) throw new BadRequestError(`No price configured for plan: ${plan}`);
const params: Stripe.Checkout.SessionCreateParams = {
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata: { userId, productId: PRODUCT_ID, plan },
...(trialDays && trialDays > 0 && {
subscription_data: { trial_period_days: trialDays },
}),
...(promoCode && { allow_promotion_codes: true }),
};
const session = await stripe.checkout.sessions.create(params);
reply.code(201);
return { sessionId: session.id, url: session.url };
});
// ── Webhook ───────────────────────────────────────────────
// Register a raw content-type parser so we receive the untouched body
// for Stripe signature verification.
app.addContentTypeParser(
"application/json",
{ parseAs: "string" },
(_req, body, done) => { done(null, body); },
);
app.post("/stripe/webhook", async (req: FastifyRequest, reply) => {
const sig = req.headers["stripe-signature"] as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!sig || !webhookSecret) {
throw new BadRequestError("Missing stripe-signature or webhook secret");
}
const stripe = getStripeForProduct(PRODUCT_ID);
let event: Stripe.Event;
try {
// req.body is the raw string thanks to the content-type parser above
const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
event = stripe.webhooks.constructEvent(
rawBody,
sig,
webhookSecret,
);
} catch (err) {
app.log.error(`Webhook signature verification failed: ${err}`);
reply.code(400).send({ error: "Invalid signature" });
return;
}
// Route by productId in metadata (multi-tenant)
const metadata = getEventMetadata(event);
const eventProductId = metadata?.productId || PRODUCT_ID;
if (eventProductId !== PRODUCT_ID) {
app.log.info(`Ignoring event for product ${eventProductId}`);
return { received: true, skipped: true };
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const plan = session.metadata?.plan || "pro";
if (userId && session.customer) {
const existing = await subRepo.getByUserId(userId);
const now = new Date();
const periodEnd = new Date(now);
periodEnd.setMonth(periodEnd.getMonth() + 1);
if (existing) {
await subRepo.updateSubscription(existing.id, userId, {
plan: plan as SubscriptionDoc["plan"],
status: "active",
stripeCustomerId: String(session.customer),
stripeSubscriptionId: session.subscription ? String(session.subscription) : undefined,
currentPeriodStart: now.toISOString(),
currentPeriodEnd: periodEnd.toISOString(),
});
} else {
await subRepo.createSubscription({
id: `sub_${userId}_${Date.now()}`,
productId: PRODUCT_ID,
userId,
plan: plan as SubscriptionDoc["plan"],
status: "active",
currentPeriodStart: now.toISOString(),
currentPeriodEnd: periodEnd.toISOString(),
cancelAtPeriodEnd: false,
monthlyPrice: (session.amount_total ?? 0) / 100,
tokensIncluded: 0, // Will be set by plan config
tokensUsed: 0,
stripeCustomerId: String(session.customer),
stripeSubscriptionId: session.subscription ? String(session.subscription) : undefined,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
});
}
// Sync plan to backend users container
await syncUserPlan(userId, plan, app.log);
// Record payment
if (session.amount_total && session.amount_total > 0) {
await subRepo.createPayment({
id: `pay_${randomUUID()}`,
productId: PRODUCT_ID,
userId,
amount: session.amount_total,
currency: session.currency || "usd",
status: "succeeded",
description: `${plan} plan subscription`,
method: "card",
createdAt: now.toISOString(),
});
}
}
break;
}
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id;
const existing = await subRepo.getByStripeCustomerId(customerId);
if (existing) {
const newStatus = sub.cancel_at_period_end ? "cancelled" : (sub.status === "active" ? "active" : "past_due");
await subRepo.updateSubscription(existing.id, existing.userId, {
status: newStatus,
cancelAtPeriodEnd: sub.cancel_at_period_end,
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
});
// Sync plan to backend users container
await syncUserPlan(existing.userId, existing.plan, app.log);
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id;
const existing = await subRepo.getByStripeCustomerId(customerId);
if (existing) {
await subRepo.updateSubscription(existing.id, existing.userId, {
status: "cancelled",
plan: "free",
cancelAtPeriodEnd: false,
});
// Sync plan downgrade to backend users container
await syncUserPlan(existing.userId, "free", app.log);
}
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
if (customerId) {
const existing = await subRepo.getByStripeCustomerId(customerId);
if (existing && invoice.amount_paid > 0) {
await subRepo.createPayment({
id: `pay_${randomUUID()}`,
productId: PRODUCT_ID,
userId: existing.userId,
amount: invoice.amount_paid,
currency: invoice.currency,
status: "succeeded",
description: "Subscription renewal",
method: "card",
invoiceUrl: invoice.hosted_invoice_url ?? undefined,
createdAt: new Date().toISOString(),
});
}
}
break;
}
default:
app.log.info(`Unhandled Stripe event type: ${event.type}`);
}
return { received: true };
});
// ── Customer Portal ───────────────────────────────────────
app.post("/stripe/portal", async (req) => {
const { userId, returnUrl } = req.body as { userId?: string; returnUrl?: string };
if (!userId || !returnUrl) {
throw new BadRequestError("userId and returnUrl are required");
}
const sub = await subRepo.getByUserId(userId);
if (!sub?.stripeCustomerId) {
throw new BadRequestError("No Stripe customer found for this user");
}
const stripe = getStripeForProduct(PRODUCT_ID);
const session = await stripe.billingPortal.sessions.create({
customer: sub.stripeCustomerId,
return_url: returnUrl,
});
return { url: session.url };
});
}
/** Extract metadata from various Stripe event object types. */
function getEventMetadata(event: Stripe.Event): Record<string, string> | null {
const obj = event.data.object as unknown as Record<string, unknown>;
if (obj.metadata && typeof obj.metadata === "object") {
return obj.metadata as Record<string, string>;
}
return null;
}

View File

@ -0,0 +1,85 @@
/**
* Subscriptions + payments repository Cosmos DB CRUD.
*/
import { getContainer } from "../../lib/cosmos.js";
import { PRODUCT_ID } from "../../lib/product-config.js";
import type { SubscriptionDoc, PaymentDoc } from "./types.js";
function subContainer() {
return getContainer("subscriptions");
}
function payContainer() {
return getContainer("payments");
}
// ── Subscriptions ──
export async function getByUserId(userId: string): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer().items
.query<SubscriptionDoc>({
query: "SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@userId", value: userId },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function getByStripeCustomerId(stripeCustomerId: string): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer().items
.query<SubscriptionDoc>({
query: "SELECT * FROM c WHERE c.productId = @productId AND c.stripeCustomerId = @cid ORDER BY c.createdAt DESC",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@cid", value: stripeCustomerId },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function createSubscription(doc: SubscriptionDoc): Promise<SubscriptionDoc> {
const { resource } = await subContainer().items.create(doc);
return resource as SubscriptionDoc;
}
export async function updateSubscription(
id: string,
userId: string,
updates: Partial<SubscriptionDoc>,
): Promise<SubscriptionDoc | null> {
try {
const { resource: existing } = await subContainer().item(id, userId).read<SubscriptionDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await subContainer().item(id, userId).replace(merged);
return resource as SubscriptionDoc;
} catch {
return null;
}
}
// ── Payments ──
export async function getPaymentsByUser(userId: string, limit = 50): Promise<PaymentDoc[]> {
const { resources } = await payContainer().items
.query<PaymentDoc>({
query: "SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@userId", value: userId },
{ name: "@limit", value: limit },
],
})
.fetchAll();
return resources;
}
export async function createPayment(doc: PaymentDoc): Promise<PaymentDoc> {
const { resource } = await payContainer().items.create(doc);
return resource as PaymentDoc;
}

View File

@ -0,0 +1,108 @@
/**
* Subscription + payment REST endpoints.
*
* GET /subscriptions/:userId get user subscription
* POST /subscriptions create subscription
* PUT /subscriptions/:id update subscription
* GET /payments/:userId list user payments
* POST /payments record a payment
*/
import type { FastifyInstance } from "fastify";
import { PRODUCT_ID } from "../../lib/product-config.js";
import { BadRequestError, NotFoundError } from "../../lib/errors.js";
import * as repo from "./repository.js";
import {
CreateSubscriptionSchema,
UpdateSubscriptionSchema,
CreatePaymentSchema,
type SubscriptionDoc,
type PaymentDoc,
} from "./types.js";
export async function subscriptionRoutes(app: FastifyInstance) {
// Get subscription by userId
app.get("/subscriptions/:userId", async (req) => {
const { userId } = req.params as { userId: string };
const sub = await repo.getByUserId(userId);
if (!sub) throw new NotFoundError("Subscription not found");
return sub;
});
// Create subscription
app.post("/subscriptions", async (req, reply) => {
const parsed = CreateSubscriptionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const input = parsed.data;
const now = new Date();
const periodEnd = new Date(now);
if (input.trialDays && input.trialDays > 0) {
periodEnd.setDate(periodEnd.getDate() + input.trialDays);
} else {
periodEnd.setMonth(periodEnd.getMonth() + 1);
}
const doc: SubscriptionDoc = {
id: `sub_${input.userId}_${Date.now()}`,
productId: PRODUCT_ID,
userId: input.userId,
plan: input.plan,
status: input.status,
currentPeriodStart: now.toISOString(),
currentPeriodEnd: periodEnd.toISOString(),
cancelAtPeriodEnd: false,
monthlyPrice: input.monthlyPrice,
tokensIncluded: input.tokensIncluded,
tokensUsed: 0,
...(input.stripeCustomerId && { stripeCustomerId: input.stripeCustomerId }),
...(input.stripeSubscriptionId && { stripeSubscriptionId: input.stripeSubscriptionId }),
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
const created = await repo.createSubscription(doc);
reply.code(201);
return created;
});
// Update subscription by userId (looks up subscription, then updates)
app.put("/subscriptions/:userId", async (req) => {
const { userId } = req.params as { userId: string };
const parsed = UpdateSubscriptionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const existing = await repo.getByUserId(userId);
if (!existing) throw new NotFoundError("Subscription not found");
const updated = await repo.updateSubscription(existing.id, userId, parsed.data);
if (!updated) throw new NotFoundError("Subscription update failed");
return updated;
});
// List payments
app.get("/payments/:userId", async (req) => {
const { userId } = req.params as { userId: string };
const { limit = "50" } = req.query as { limit?: string };
return { payments: await repo.getPaymentsByUser(userId, Number(limit)) };
});
// Create payment
app.post("/payments", async (req, reply) => {
const parsed = CreatePaymentSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const input = parsed.data;
const doc: PaymentDoc = {
id: `pay_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
productId: PRODUCT_ID,
...input,
createdAt: new Date().toISOString(),
};
const created = await repo.createPayment(doc);
reply.code(201);
return created;
});
}

View File

@ -0,0 +1,104 @@
/**
* Unit tests for subscriptions module types + validation.
*/
import { describe, it, expect } from "vitest";
import { CreateSubscriptionSchema, UpdateSubscriptionSchema, CreatePaymentSchema } from "./types.js";
describe("CreateSubscriptionSchema", () => {
it("accepts valid input with required fields", () => {
const result = CreateSubscriptionSchema.safeParse({
userId: "user_123",
plan: "pro",
monthlyPrice: 9.99,
tokensIncluded: 100000,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.status).toBe("active");
}
});
it("accepts trialing status with trial days", () => {
const result = CreateSubscriptionSchema.safeParse({
userId: "user_123",
plan: "enterprise",
monthlyPrice: 29.99,
tokensIncluded: 1000000,
status: "trialing",
trialDays: 14,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.trialDays).toBe(14);
}
});
it("rejects invalid plan", () => {
const result = CreateSubscriptionSchema.safeParse({
userId: "user_123",
plan: "premium",
monthlyPrice: 19.99,
tokensIncluded: 50000,
});
expect(result.success).toBe(false);
});
it("rejects missing userId", () => {
const result = CreateSubscriptionSchema.safeParse({
plan: "pro",
monthlyPrice: 9.99,
tokensIncluded: 100000,
});
expect(result.success).toBe(false);
});
it("rejects negative price", () => {
const result = CreateSubscriptionSchema.safeParse({
userId: "user_123",
plan: "pro",
monthlyPrice: -1,
tokensIncluded: 100000,
});
expect(result.success).toBe(false);
});
});
describe("UpdateSubscriptionSchema", () => {
it("accepts partial updates", () => {
const result = UpdateSubscriptionSchema.safeParse({
plan: "enterprise",
cancelAtPeriodEnd: true,
});
expect(result.success).toBe(true);
});
it("accepts empty object", () => {
const result = UpdateSubscriptionSchema.safeParse({});
expect(result.success).toBe(true);
});
});
describe("CreatePaymentSchema", () => {
it("accepts valid payment", () => {
const result = CreatePaymentSchema.safeParse({
userId: "user_123",
amount: 999,
status: "succeeded",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.currency).toBe("usd");
expect(result.data.method).toBe("card");
}
});
it("rejects invalid status", () => {
const result = CreatePaymentSchema.safeParse({
userId: "user_123",
amount: 999,
status: "completed",
});
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,71 @@
/**
* Subscription and payment types ported from user-dashboard-web repos.
*/
import { z } from "zod";
export interface SubscriptionDoc {
id: string;
productId: string;
userId: string;
plan: "free" | "pro" | "enterprise";
status: "active" | "cancelled" | "past_due" | "trialing";
currentPeriodStart: string;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
monthlyPrice: number;
tokensIncluded: number;
tokensUsed: number;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
createdAt: string;
updatedAt: string;
}
export interface PaymentDoc {
id: string;
productId: string;
userId: string;
amount: number; // Amount in smallest currency unit (cents for USD)
currency: string;
status: "succeeded" | "pending" | "failed" | "refunded";
description: string;
method: string;
invoiceUrl?: string;
createdAt: string;
}
export const CreateSubscriptionSchema = z.object({
userId: z.string().min(1),
plan: z.enum(["free", "pro", "enterprise"]),
status: z.enum(["active", "cancelled", "past_due", "trialing"]).default("active"),
monthlyPrice: z.number().min(0),
tokensIncluded: z.number().int().min(0),
stripeCustomerId: z.string().optional(),
stripeSubscriptionId: z.string().optional(),
trialDays: z.number().int().min(0).optional(),
});
export const UpdateSubscriptionSchema = z.object({
plan: z.enum(["free", "pro", "enterprise"]).optional(),
status: z.enum(["active", "cancelled", "past_due", "trialing"]).optional(),
monthlyPrice: z.number().min(0).optional(),
tokensIncluded: z.number().int().min(0).optional(),
cancelAtPeriodEnd: z.boolean().optional(),
stripeCustomerId: z.string().optional(),
stripeSubscriptionId: z.string().optional(),
});
export const CreatePaymentSchema = z.object({
userId: z.string().min(1),
amount: z.number().int().min(0),
currency: z.string().default("usd"),
status: z.enum(["succeeded", "pending", "failed", "refunded"]),
description: z.string().default(""),
method: z.string().default("card"),
invoiceUrl: z.string().optional(),
});
export type CreateSubscriptionInput = z.infer<typeof CreateSubscriptionSchema>;
export type UpdateSubscriptionInput = z.infer<typeof UpdateSubscriptionSchema>;
export type CreatePaymentInput = z.infer<typeof CreatePaymentSchema>;

View File

@ -0,0 +1,82 @@
/**
* Usage repository Cosmos DB CRUD + aggregation.
*/
import { getContainer } from "../../lib/cosmos.js";
import { PRODUCT_ID } from "../../lib/product-config.js";
import type { UsageDoc, MonthlyUsage } from "./types.js";
function container() {
return getContainer("usage_daily");
}
export async function getByDate(userId: string, date: string): Promise<UsageDoc | null> {
const id = `usg_${date}_${userId}`;
try {
const { resource } = await container().item(id, userId).read<UsageDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function list(
options: { userId?: string; days?: number; limit?: number } = {},
): Promise<UsageDoc[]> {
const { userId, days = 30, limit = 100 } = options;
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
let queryText = "SELECT * FROM c WHERE c.productId = @productId AND c.date >= @since";
const parameters: { name: string; value: string | number }[] = [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@since", value: since },
];
if (userId) {
queryText += " AND c.userId = @userId";
parameters.push({ name: "@userId", value: userId });
}
queryText += " ORDER BY c.date DESC OFFSET 0 LIMIT @limit";
parameters.push({ name: "@limit", value: limit });
const { resources } = await container().items
.query<UsageDoc>({ query: queryText, parameters })
.fetchAll();
return resources;
}
export async function upsert(doc: UsageDoc): Promise<UsageDoc> {
const { resource } = await container().items.upsert<UsageDoc>(doc);
return resource!;
}
export async function getMonthlyUsage(userId: string): Promise<MonthlyUsage> {
const now = new Date();
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const query =
"SELECT VALUE {" +
" totalTokens: SUM(c.tokensUsed), " +
" totalWords: SUM(c.words), " +
" totalDictations: SUM(c.dictations)" +
"} FROM c WHERE c.productId = @productId AND c.userId = @uid AND c.date >= @since";
const { resources } = await container().items
.query<{ totalTokens: number; totalWords: number; totalDictations: number }>({
query,
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@uid", value: userId },
{ name: "@since", value: monthStart },
],
})
.fetchAll();
const row = resources[0];
return {
tokens: row?.totalTokens ?? 0,
words: row?.totalWords ?? 0,
dictations: row?.totalDictations ?? 0,
};
}

View File

@ -0,0 +1,121 @@
/**
* Usage REST endpoints.
*
* GET /usage list usage records (filterable)
* GET /usage/summary aggregated summary
* POST /usage upsert a usage record
* POST /usage/check-limits check if user is within plan limits
*/
import type { FastifyInstance } from "fastify";
import { PRODUCT_ID } from "../../lib/product-config.js";
import { BadRequestError } from "../../lib/errors.js";
import * as repo from "./repository.js";
import { UpsertUsageSchema, CheckLimitsSchema, type UsageDoc, type ModelBreakdown } from "./types.js";
/**
* Plan limits configurable per product via PLAN_LIMITS_JSON env var.
* Format: JSON object keyed by plan name, value is metriclimit map.
* Metric names are product-configurable (not just tokens/words/dictations).
*/
function loadPlanLimits(): Record<string, Record<string, number>> {
const json = process.env.PLAN_LIMITS_JSON;
if (json) {
try { return JSON.parse(json); } catch { /* fall through to defaults */ }
}
return {
free: { tokens: 10_000, words: 5_000, dictations: 100 },
pro: { tokens: 100_000, words: 50_000, dictations: 5_000 },
enterprise: { tokens: 1_000_000, words: 500_000, dictations: 50_000 },
};
}
const PLAN_LIMITS = loadPlanLimits();
const WARN_THRESHOLD = Number(process.env.USAGE_WARN_THRESHOLD || "0.8");
export async function usageRoutes(app: FastifyInstance) {
// List usage
app.get("/usage", async (req) => {
const { userId, days = "30", limit = "100" } = req.query as Record<string, string>;
const records = await repo.list({
userId,
days: Number(days),
limit: Number(limit),
});
return { records, count: records.length };
});
// Summary
app.get("/usage/summary", async (req) => {
const { userId, days = "30" } = req.query as Record<string, string>;
const records = await repo.list({ userId, days: Number(days) });
const byModel: Record<string, { tokens: number; requests: number; cost: number }> = {};
for (const r of records) {
const m = r.model || "gpt-4o-mini";
if (!byModel[m]) byModel[m] = { tokens: 0, requests: 0, cost: 0 };
byModel[m].tokens += r.tokensUsed;
byModel[m].requests += r.dictations;
byModel[m].cost += r.costUsd;
}
const modelBreakdown: ModelBreakdown[] = Object.entries(byModel).map(
([model, s]) => ({ model, ...s }),
);
return {
totalWords: records.reduce((sum, r) => sum + r.words, 0),
totalDictations: records.reduce((sum, r) => sum + r.dictations, 0),
totalTokens: records.reduce((sum, r) => sum + r.tokensUsed, 0),
totalCost: records.reduce((sum, r) => sum + r.costUsd, 0),
records,
modelBreakdown,
};
});
// Upsert usage
app.post("/usage", async (req) => {
const parsed = UpsertUsageSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const input = parsed.data;
const doc: UsageDoc = {
id: `usg_${input.date}_${input.userId}${input.model ? `_${input.model}` : ""}`,
productId: PRODUCT_ID,
...input,
createdAt: new Date().toISOString(),
};
return repo.upsert(doc);
});
// Check limits
app.post("/usage/check-limits", async (req) => {
const parsed = CheckLimitsSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const { userId, plan } = parsed.data;
const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.free;
const usage = await repo.getMonthlyUsage(userId);
const exceeded: string[] = [];
const warnings: string[] = [];
for (const metric of ["tokens", "words", "dictations"] as const) {
const limitVal = limits[metric] ?? 0;
const usageVal = usage[metric] ?? 0;
if (limitVal > 0 && usageVal >= limitVal) exceeded.push(metric);
else if (limitVal > 0 && usageVal >= limitVal * WARN_THRESHOLD) warnings.push(metric);
}
return {
allowed: exceeded.length === 0,
usage,
limits,
warnings,
exceeded,
plan,
};
});
}

View File

@ -0,0 +1,60 @@
/**
* Usage tracking types ported from admin-dashboard-web + Python backend.
*/
import { z } from "zod";
export interface UsageDoc {
id: string;
productId: string;
userId: string;
date: string;
dictations: number;
words: number;
durationMs: number;
tokensUsed: number;
costUsd: number;
model?: string;
createdAt: string;
}
export interface UsageSummary {
totalWords: number;
totalDictations: number;
totalTokens: number;
totalCost: number;
records: UsageDoc[];
modelBreakdown?: ModelBreakdown[];
}
export interface ModelBreakdown {
model: string;
tokens: number;
requests: number;
cost: number;
}
export interface MonthlyUsage {
tokens: number;
words: number;
dictations: number;
}
export const UpsertUsageSchema = z.object({
userId: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dictations: z.number().int().min(0).default(0),
words: z.number().int().min(0).default(0),
durationMs: z.number().int().min(0).default(0),
tokensUsed: z.number().int().min(0).default(0),
costUsd: z.number().min(0).default(0),
model: z.string().optional(),
});
export const CheckLimitsSchema = z.object({
userId: z.string().min(1),
plan: z.enum(["free", "pro", "enterprise"]),
});
export type UpsertUsageInput = z.infer<typeof UpsertUsageSchema>;
export type CheckLimitsInput = z.infer<typeof CheckLimitsSchema>;

View File

@ -0,0 +1,71 @@
/**
* Unit tests for usage module types + validation.
*/
import { describe, it, expect } from "vitest";
import { UpsertUsageSchema, CheckLimitsSchema } from "./types.js";
describe("UpsertUsageSchema", () => {
it("accepts valid usage record", () => {
const result = UpsertUsageSchema.safeParse({
userId: "user_123",
date: "2026-02-10",
dictations: 5,
words: 250,
tokensUsed: 1200,
costUsd: 0.01,
});
expect(result.success).toBe(true);
});
it("accepts with model", () => {
const result = UpsertUsageSchema.safeParse({
userId: "user_123",
date: "2026-02-10",
model: "gpt-4o",
});
expect(result.success).toBe(true);
});
it("rejects invalid date format", () => {
const result = UpsertUsageSchema.safeParse({
userId: "user_123",
date: "2026/02/10",
});
expect(result.success).toBe(false);
});
it("rejects missing userId", () => {
const result = UpsertUsageSchema.safeParse({
date: "2026-02-10",
});
expect(result.success).toBe(false);
});
it("rejects negative values", () => {
const result = UpsertUsageSchema.safeParse({
userId: "user_123",
date: "2026-02-10",
tokensUsed: -100,
});
expect(result.success).toBe(false);
});
});
describe("CheckLimitsSchema", () => {
it("accepts valid input", () => {
const result = CheckLimitsSchema.safeParse({
userId: "user_123",
plan: "pro",
});
expect(result.success).toBe(true);
});
it("rejects invalid plan", () => {
const result = CheckLimitsSchema.safeParse({
userId: "user_123",
plan: "premium",
});
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,100 @@
/**
* Billing & Entitlement Service Fastify server entry point.
*
* Modules: subscriptions, usage, plans, licenses.
* Port: 4002 (configurable via PORT env var).
*/
import { randomUUID } from "node:crypto";
import Fastify from "fastify";
import cors from "@fastify/cors";
import swagger from "@fastify/swagger";
import metricsPlugin from "fastify-metrics";
import { ServiceError } from "./lib/errors.js";
import { subscriptionRoutes } from "./modules/subscriptions/routes.js";
import { usageRoutes } from "./modules/usage/routes.js";
import { planRoutes } from "./modules/plans/routes.js";
import { licenseRoutes } from "./modules/licenses/routes.js";
import { stripeRoutes } from "./modules/stripe/routes.js";
import { config } from "./lib/config.js";
const PORT = config.PORT;
const HOST = config.HOST;
const app = Fastify({ logger: true });
// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated)
const corsOrigin = config.CORS_ORIGIN;
await app.register(cors, {
origin: corsOrigin ? corsOrigin.split(",").map((o) => o.trim()) : true,
});
// OpenAPI spec auto-generation (GET /api/docs/json)
await app.register(swagger, {
openapi: {
info: { title: "Billing & Entitlement Service", version: "0.1.0", description: "Subscriptions, payments, usage, licenses, plans, Stripe" },
servers: [{ url: `http://localhost:${PORT}` }],
},
});
// Prometheus metrics
await app.register(metricsPlugin, { endpoint: "/metrics" });
// x-request-id: propagate incoming header or generate a new one
app.addHook("onRequest", async (req, reply) => {
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
req.headers["x-request-id"] = requestId;
reply.header("x-request-id", requestId);
req.log = req.log.child({ requestId });
});
// Health check
app.get("/health", async (req) => ({
status: "ok",
service: "billing-service",
version: "0.1.0",
timestamp: new Date().toISOString(),
requestId: req.headers["x-request-id"],
}));
// Internal API key auth (skip health, webhook, and when key not configured)
const INTERNAL_KEY = config.BILLING_INTERNAL_KEY;
if (INTERNAL_KEY) {
app.addHook("onRequest", async (req, reply) => {
const path = req.url;
// Skip auth for health check and Stripe webhook (has its own signature verification)
if (path === "/health" || path.includes("/stripe/webhook")) return;
const key = req.headers["x-internal-key"];
if (key !== INTERNAL_KEY) {
reply.code(401).send({ error: "Unauthorized — missing or invalid X-Internal-Key" });
}
});
}
// Custom error handler
app.setErrorHandler((error, _req, reply) => {
if (error instanceof ServiceError) {
const body: Record<string, unknown> = { error: error.message };
if ("details" in error && error.details) body.details = error.details;
reply.code(error.statusCode).send(body);
return;
}
app.log.error(error);
reply.code(500).send({ error: "Internal server error" });
});
// Register route modules
await app.register(subscriptionRoutes, { prefix: "/api" });
await app.register(usageRoutes, { prefix: "/api" });
await app.register(planRoutes, { prefix: "/api" });
await app.register(licenseRoutes, { prefix: "/api" });
await app.register(stripeRoutes, { prefix: "/api" });
// Start
try {
await app.listen({ port: PORT, host: HOST });
app.log.info(`Billing Service listening on ${HOST}:${PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});