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:
parent
e1ab956ac3
commit
fc5f2bf296
2
services/billing-service/.gitignore
vendored
Normal file
2
services/billing-service/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
16
services/billing-service/Dockerfile
Normal file
16
services/billing-service/Dockerfile
Normal 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"]
|
||||
30
services/billing-service/package.json
Normal file
30
services/billing-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
services/billing-service/src/lib/config.ts
Normal file
33
services/billing-service/src/lib/config.ts
Normal 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);
|
||||
24
services/billing-service/src/lib/cosmos.ts
Normal file
24
services/billing-service/src/lib/cosmos.ts
Normal 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);
|
||||
}
|
||||
40
services/billing-service/src/lib/errors.ts
Normal file
40
services/billing-service/src/lib/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
services/billing-service/src/lib/product-config.ts
Normal file
8
services/billing-service/src/lib/product-config.ts
Normal 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";
|
||||
43
services/billing-service/src/lib/stripe.ts
Normal file
43
services/billing-service/src/lib/stripe.ts
Normal 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();
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
64
services/billing-service/src/modules/licenses/repository.ts
Normal file
64
services/billing-service/src/modules/licenses/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
120
services/billing-service/src/modules/licenses/routes.ts
Normal file
120
services/billing-service/src/modules/licenses/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
41
services/billing-service/src/modules/licenses/types.ts
Normal file
41
services/billing-service/src/modules/licenses/types.ts
Normal 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>;
|
||||
78
services/billing-service/src/modules/plans/plans.test.ts
Normal file
78
services/billing-service/src/modules/plans/plans.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
71
services/billing-service/src/modules/plans/repository.ts
Normal file
71
services/billing-service/src/modules/plans/repository.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
84
services/billing-service/src/modules/plans/routes.ts
Normal file
84
services/billing-service/src/modules/plans/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
81
services/billing-service/src/modules/plans/types.ts
Normal file
81
services/billing-service/src/modules/plans/types.ts
Normal 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>;
|
||||
270
services/billing-service/src/modules/stripe/routes.ts
Normal file
270
services/billing-service/src/modules/stripe/routes.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
108
services/billing-service/src/modules/subscriptions/routes.ts
Normal file
108
services/billing-service/src/modules/subscriptions/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
71
services/billing-service/src/modules/subscriptions/types.ts
Normal file
71
services/billing-service/src/modules/subscriptions/types.ts
Normal 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>;
|
||||
82
services/billing-service/src/modules/usage/repository.ts
Normal file
82
services/billing-service/src/modules/usage/repository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
121
services/billing-service/src/modules/usage/routes.ts
Normal file
121
services/billing-service/src/modules/usage/routes.ts
Normal 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 metric→limit 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
60
services/billing-service/src/modules/usage/types.ts
Normal file
60
services/billing-service/src/modules/usage/types.ts
Normal 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>;
|
||||
71
services/billing-service/src/modules/usage/usage.test.ts
Normal file
71
services/billing-service/src/modules/usage/usage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
100
services/billing-service/src/server.ts
Normal file
100
services/billing-service/src/server.ts
Normal 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);
|
||||
}
|
||||
19
services/billing-service/tsconfig.json
Normal file
19
services/billing-service/tsconfig.json
Normal 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"]
|
||||
}
|
||||
9
services/billing-service/vitest.config.ts
Normal file
9
services/billing-service/vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user