diff --git a/services/billing-service/.gitignore b/services/billing-service/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/services/billing-service/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/services/billing-service/Dockerfile b/services/billing-service/Dockerfile new file mode 100644 index 00000000..4a0d658b --- /dev/null +++ b/services/billing-service/Dockerfile @@ -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"] diff --git a/services/billing-service/package.json b/services/billing-service/package.json new file mode 100644 index 00000000..54238b43 --- /dev/null +++ b/services/billing-service/package.json @@ -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" + } +} diff --git a/services/billing-service/src/lib/config.ts b/services/billing-service/src/lib/config.ts new file mode 100644 index 00000000..64006d99 --- /dev/null +++ b/services/billing-service/src/lib/config.ts @@ -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); diff --git a/services/billing-service/src/lib/cosmos.ts b/services/billing-service/src/lib/cosmos.ts new file mode 100644 index 00000000..e05bac8e --- /dev/null +++ b/services/billing-service/src/lib/cosmos.ts @@ -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); +} diff --git a/services/billing-service/src/lib/errors.ts b/services/billing-service/src/lib/errors.ts new file mode 100644 index 00000000..caa9313d --- /dev/null +++ b/services/billing-service/src/lib/errors.ts @@ -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, + ) { + super(429, message); + } +} diff --git a/services/billing-service/src/lib/product-config.ts b/services/billing-service/src/lib/product-config.ts new file mode 100644 index 00000000..20c7c9be --- /dev/null +++ b/services/billing-service/src/lib/product-config.ts @@ -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"; diff --git a/services/billing-service/src/lib/stripe.ts b/services/billing-service/src/lib/stripe.ts new file mode 100644 index 00000000..0fb33004 --- /dev/null +++ b/services/billing-service/src/lib/stripe.ts @@ -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_ — product-specific key (uppercase) + */ + +import Stripe from "stripe"; + +const _cache = new Map(); + +/** 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 { + return { + pro: process.env.STRIPE_PRICE_PRO || "price_pro_placeholder", + enterprise: process.env.STRIPE_PRICE_ENTERPRISE || "price_enterprise_placeholder", + }; +} + +export const PRICE_IDS: Record = getPriceIds(); diff --git a/services/billing-service/src/modules/licenses/licenses.test.ts b/services/billing-service/src/modules/licenses/licenses.test.ts new file mode 100644 index 00000000..1b9cc4d6 --- /dev/null +++ b/services/billing-service/src/modules/licenses/licenses.test.ts @@ -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); + }); +}); diff --git a/services/billing-service/src/modules/licenses/repository.ts b/services/billing-service/src/modules/licenses/repository.ts new file mode 100644 index 00000000..1d471f0a --- /dev/null +++ b/services/billing-service/src/modules/licenses/repository.ts @@ -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 { + const { resources } = await container().items + .query({ + 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 { + const { resources } = await container().items + .query({ + 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 { + const { resource } = await container().items.create(doc); + return resource as LicenseDoc; +} + +export async function update( + id: string, + userId: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await container().item(id, userId).read(); + 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; + } +} diff --git a/services/billing-service/src/modules/licenses/routes.ts b/services/billing-service/src/modules/licenses/routes.ts new file mode 100644 index 00000000..f61d7a69 --- /dev/null +++ b/services/billing-service/src/modules/licenses/routes.ts @@ -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 }; + }); +} diff --git a/services/billing-service/src/modules/licenses/types.ts b/services/billing-service/src/modules/licenses/types.ts new file mode 100644 index 00000000..4576d2eb --- /dev/null +++ b/services/billing-service/src/modules/licenses/types.ts @@ -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; +export type ActivateLicenseInput = z.infer; diff --git a/services/billing-service/src/modules/plans/plans.test.ts b/services/billing-service/src/modules/plans/plans.test.ts new file mode 100644 index 00000000..5a12990d --- /dev/null +++ b/services/billing-service/src/modules/plans/plans.test.ts @@ -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); + }); +}); diff --git a/services/billing-service/src/modules/plans/repository.ts b/services/billing-service/src/modules/plans/repository.ts new file mode 100644 index 00000000..b950dce5 --- /dev/null +++ b/services/billing-service/src/modules/plans/repository.ts @@ -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 { + const { resources } = await container().items + .query({ + 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 { + const { resources } = await container().items + .query({ + 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 { + const { resource } = await container().items.create(doc); + return resource as PlanConfig; +} + +export async function update( + id: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await container().item(id, id).read(); + 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, + })); +} diff --git a/services/billing-service/src/modules/plans/routes.ts b/services/billing-service/src/modules/plans/routes.ts new file mode 100644 index 00000000..40f604f9 --- /dev/null +++ b/services/billing-service/src/modules/plans/routes.ts @@ -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 }; + }); +} diff --git a/services/billing-service/src/modules/plans/types.ts b/services/billing-service/src/modules/plans/types.ts new file mode 100644 index 00000000..4187c04d --- /dev/null +++ b/services/billing-service/src/modules/plans/types.ts @@ -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[] = [ + { + 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; +export type UpdatePlanInput = z.infer; diff --git a/services/billing-service/src/modules/stripe/routes.ts b/services/billing-service/src/modules/stripe/routes.ts new file mode 100644 index 00000000..fe8e4aa5 --- /dev/null +++ b/services/billing-service/src/modules/stripe/routes.ts @@ -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_. + */ + +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 { + 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 | null { + const obj = event.data.object as unknown as Record; + if (obj.metadata && typeof obj.metadata === "object") { + return obj.metadata as Record; + } + return null; +} diff --git a/services/billing-service/src/modules/subscriptions/repository.ts b/services/billing-service/src/modules/subscriptions/repository.ts new file mode 100644 index 00000000..348a9123 --- /dev/null +++ b/services/billing-service/src/modules/subscriptions/repository.ts @@ -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 { + const { resources } = await subContainer().items + .query({ + 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 { + const { resources } = await subContainer().items + .query({ + 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 { + const { resource } = await subContainer().items.create(doc); + return resource as SubscriptionDoc; +} + +export async function updateSubscription( + id: string, + userId: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await subContainer().item(id, userId).read(); + 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 { + const { resources } = await payContainer().items + .query({ + 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 { + const { resource } = await payContainer().items.create(doc); + return resource as PaymentDoc; +} diff --git a/services/billing-service/src/modules/subscriptions/routes.ts b/services/billing-service/src/modules/subscriptions/routes.ts new file mode 100644 index 00000000..601d2dea --- /dev/null +++ b/services/billing-service/src/modules/subscriptions/routes.ts @@ -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; + }); +} diff --git a/services/billing-service/src/modules/subscriptions/subscriptions.test.ts b/services/billing-service/src/modules/subscriptions/subscriptions.test.ts new file mode 100644 index 00000000..c21955ee --- /dev/null +++ b/services/billing-service/src/modules/subscriptions/subscriptions.test.ts @@ -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); + }); +}); diff --git a/services/billing-service/src/modules/subscriptions/types.ts b/services/billing-service/src/modules/subscriptions/types.ts new file mode 100644 index 00000000..dabab399 --- /dev/null +++ b/services/billing-service/src/modules/subscriptions/types.ts @@ -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; +export type UpdateSubscriptionInput = z.infer; +export type CreatePaymentInput = z.infer; diff --git a/services/billing-service/src/modules/usage/repository.ts b/services/billing-service/src/modules/usage/repository.ts new file mode 100644 index 00000000..930df638 --- /dev/null +++ b/services/billing-service/src/modules/usage/repository.ts @@ -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 { + const id = `usg_${date}_${userId}`; + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function list( + options: { userId?: string; days?: number; limit?: number } = {}, +): Promise { + 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({ query: queryText, parameters }) + .fetchAll(); + return resources; +} + +export async function upsert(doc: UsageDoc): Promise { + const { resource } = await container().items.upsert(doc); + return resource!; +} + +export async function getMonthlyUsage(userId: string): Promise { + 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, + }; +} diff --git a/services/billing-service/src/modules/usage/routes.ts b/services/billing-service/src/modules/usage/routes.ts new file mode 100644 index 00000000..20ba9c7f --- /dev/null +++ b/services/billing-service/src/modules/usage/routes.ts @@ -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 metric→limit map. + * Metric names are product-configurable (not just tokens/words/dictations). + */ +function loadPlanLimits(): Record> { + 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; + 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; + const records = await repo.list({ userId, days: Number(days) }); + + const byModel: Record = {}; + 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, + }; + }); +} diff --git a/services/billing-service/src/modules/usage/types.ts b/services/billing-service/src/modules/usage/types.ts new file mode 100644 index 00000000..c29e9ff6 --- /dev/null +++ b/services/billing-service/src/modules/usage/types.ts @@ -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; +export type CheckLimitsInput = z.infer; diff --git a/services/billing-service/src/modules/usage/usage.test.ts b/services/billing-service/src/modules/usage/usage.test.ts new file mode 100644 index 00000000..eb261fcb --- /dev/null +++ b/services/billing-service/src/modules/usage/usage.test.ts @@ -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); + }); +}); diff --git a/services/billing-service/src/server.ts b/services/billing-service/src/server.ts new file mode 100644 index 00000000..a761132e --- /dev/null +++ b/services/billing-service/src/server.ts @@ -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 = { 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); +} diff --git a/services/billing-service/tsconfig.json b/services/billing-service/tsconfig.json new file mode 100644 index 00000000..d155b3cc --- /dev/null +++ b/services/billing-service/tsconfig.json @@ -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"] +} diff --git a/services/billing-service/vitest.config.ts b/services/billing-service/vitest.config.ts new file mode 100644 index 00000000..0f78dc01 --- /dev/null +++ b/services/billing-service/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + }, +});