refactor: merge billing-service into platform-service

Phase 2 of service consolidation (5→2 services).

Moved modules:
- subscriptions (9 tests)
- usage (7 tests)
- plans (9 tests)
- licenses (7 tests)
- stripe (0 tests — webhook signature verified at runtime)

Changes:
- Copied 5 modules + stripe.ts lib from billing-service
- Added billing env vars to config schema (Stripe, internal key, etc.)
- Scoped billing routes with internal key auth guard (Gap 3)
  - When BILLING_INTERNAL_KEY is set, billing routes require x-internal-key header
  - When unset, billing routes are open (dev mode)
  - Stripe routes always outside scope (own webhook signature check)
- Removed billing-service directory

Tests: 115 passing (83 + 32 from billing = 115) 
Build: clean 
This commit is contained in:
saravanakumardb1 2026-02-14 21:31:04 -08:00
parent a862c692ec
commit f13c676139
32 changed files with 42 additions and 288 deletions

49
pnpm-lock.yaml generated
View File

@ -174,55 +174,6 @@ importers:
specifier: ^5.2.1
version: 5.7.4
services/billing-service:
dependencies:
'@azure/cosmos':
specifier: ^4.2.0
version: 4.9.1(@azure/core-client@1.10.1)
'@bytelyst/config':
specifier: workspace:*
version: link:../../packages/config
'@bytelyst/cosmos':
specifier: workspace:*
version: link:../../packages/cosmos
'@bytelyst/errors':
specifier: workspace:*
version: link:../../packages/errors
'@bytelyst/fastify-core':
specifier: workspace:*
version: link:../../packages/fastify-core
'@fastify/cors':
specifier: ^10.0.2
version: 10.1.0
'@fastify/swagger':
specifier: ^9.4.2
version: 9.7.0
fastify:
specifier: ^5.2.1
version: 5.7.4
fastify-metrics:
specifier: ^10.3.0
version: 10.6.0(fastify@5.7.4)
stripe:
specifier: ^17.5.0
version: 17.7.0
zod:
specifier: ^3.24.2
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^22.12.0
version: 22.19.11
tsx:
specifier: ^4.19.2
version: 4.21.0
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/node@22.19.11)(happy-dom@18.0.1)(jsdom@28.0.0)(tsx@4.21.0)(yaml@2.8.2)
services/extraction-service:
dependencies:
'@azure/cosmos':

View File

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

View File

@ -1,44 +0,0 @@
# Build context: repo root (docker compose sets context: .)
FROM node:22-alpine AS builder
RUN npm install -g pnpm@10
WORKDIR /app
# Copy workspace config + lockfile for dependency resolution
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
# Copy all package.json files (pnpm needs these for workspace resolution)
COPY packages/errors/package.json packages/errors/
COPY packages/cosmos/package.json packages/cosmos/
COPY packages/blob/package.json packages/blob/
COPY packages/config/package.json packages/config/
COPY packages/auth/package.json packages/auth/
COPY packages/api-client/package.json packages/api-client/
COPY packages/fastify-core/package.json packages/fastify-core/
COPY packages/logger/package.json packages/logger/
COPY packages/monitoring/package.json packages/monitoring/
COPY packages/react-auth/package.json packages/react-auth/
COPY packages/design-tokens/package.json packages/design-tokens/
COPY packages/testing/package.json packages/testing/
COPY services/billing-service/package.json services/billing-service/
# Install all workspace deps
RUN pnpm install --frozen-lockfile
# Copy source
COPY packages/ packages/
COPY services/billing-service/tsconfig.json services/billing-service/
COPY services/billing-service/src/ services/billing-service/src/
# Build packages first, then service
RUN pnpm -r --filter @lysnrai/billing-service... build
# Deploy to isolated directory (production deps only)
RUN pnpm --filter @lysnrai/billing-service deploy --legacy /app/deploy
# ── Production ─────────────────────────────────────────────
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/deploy ./
ENV NODE_ENV=production
EXPOSE 4002
CMD ["node", "dist/server.js"]

View File

@ -1,34 +0,0 @@
{
"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": {
"@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-core": "workspace:*",
"@azure/cosmos": "^4.2.0",
"fastify": "^5.2.1",
"@fastify/cors": "^10.0.2",
"@fastify/swagger": "^9.4.2",
"fastify-metrics": "^10.3.0",
"stripe": "^17.5.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@ -1,22 +0,0 @@
import { z } from 'zod';
const envSchema = z.object({
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'),
BILLING_INTERNAL_KEY: z.string().optional(),
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_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(),
BACKEND_URL: z.string().default('http://localhost:8000'),
PLAN_LIMITS_JSON: z.string().optional(),
USAGE_WARN_THRESHOLD: z.coerce.number().default(0.8),
});
export const config = envSchema.parse(process.env);

View File

@ -1,28 +0,0 @@
import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos';
import type { ContainerConfig } from '@bytelyst/cosmos';
import { config } from './config.js';
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
plans: { partitionKeyPath: '/id' },
subscriptions: { partitionKeyPath: '/userId' },
payments: { partitionKeyPath: '/userId' },
licenses: { partitionKeyPath: '/userId' },
usage_daily: { partitionKeyPath: '/userId', defaultTtl: 365 * 86400 },
};
export async function initCosmosIfNeeded(): Promise<void> {
registerContainers(CONTAINER_DEFS);
const shouldInit = config.NODE_ENV !== 'production' || process.env.COSMOS_AUTO_INIT === 'true';
if (!shouldInit) return;
try {
await initializeAllContainers();
// eslint-disable-next-line no-console
console.info('[billing-service] Cosmos containers ensured');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(`[billing-service] Cosmos init failed: ${msg}`);
}
}

View File

@ -1,4 +0,0 @@
/**
* Re-export from @bytelyst/cosmos shared across all services.
*/
export { getContainer, getCosmosClient, getDatabase } from '@bytelyst/cosmos';

View File

@ -1,12 +0,0 @@
/**
* Re-export from @bytelyst/errors shared across all services.
*/
export {
ServiceError,
BadRequestError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
ConflictError,
TooManyRequestsError,
} from '@bytelyst/errors';

View File

@ -1,9 +0,0 @@
/**
* Re-export from @bytelyst/config shared product identity.
*/
import { loadProductIdentity } from '@bytelyst/config';
const _id = loadProductIdentity();
export const PRODUCT_ID = _id.productId;
export const DISPLAY_NAME = _id.displayName;
export const LICENSE_PREFIX = _id.licensePrefix;

View File

@ -1,53 +0,0 @@
/**
* Billing & Entitlement Service Fastify server entry point.
*
* Modules: subscriptions, usage, plans, licenses.
* Port: 4002 (configurable via PORT env var).
*/
import { createServiceApp, startService } from '@bytelyst/fastify-core';
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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
await initCosmosIfNeeded();
const app = await createServiceApp({
name: 'billing-service',
version: '0.1.0',
description: 'Subscriptions, payments, usage, licenses, plans, Stripe',
corsOrigin: config.CORS_ORIGIN,
swagger: {
title: 'Billing & Entitlement Service',
description: 'Subscriptions, payments, usage, licenses, plans, Stripe',
port: config.PORT,
},
metrics: true,
});
// Internal API key auth (service-specific — 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' });
}
});
}
// 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' });
await startService(app, { port: config.PORT, host: config.HOST });

View File

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

View File

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

View File

@ -17,6 +17,15 @@ const envSchema = z.object({
// ── Growth (merged) ──
WEBHOOK_INVITATION_REDEEMED_URL: z.string().optional(),
WEBHOOK_REFERRAL_STATUS_URL: z.string().optional(),
// ── Billing (merged) ──
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PRICE_PRO: z.string().optional(),
STRIPE_PRICE_ENTERPRISE: z.string().optional(),
BILLING_INTERNAL_KEY: z.string().optional(),
BACKEND_URL: z.string().default('http://localhost:8000'),
PLAN_LIMITS_JSON: z.string().optional(),
USAGE_WARN_THRESHOLD: z.coerce.number().default(0.8),
});
export const config = envSchema.parse(process.env);

View File

@ -2,7 +2,8 @@
* Platform Service Fastify server entry point.
*
* Modules: auth, audit, notifications, feature flags, blob,
* invitations, referrals, promos (merged from growth-service).
* invitations, referrals, promos (merged from growth-service),
* subscriptions, usage, plans, licenses, stripe (merged from billing-service).
* Port: 4003 (configurable via PORT env var).
*/
@ -16,6 +17,11 @@ import { blobRoutes } from './modules/blob/routes.js';
import { invitationRoutes } from './modules/invitations/routes.js';
import { referralRoutes } from './modules/referrals/routes.js';
import { promoRoutes } from './modules/promos/routes.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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
@ -24,11 +30,11 @@ await initCosmosIfNeeded();
const app = await createServiceApp({
name: 'platform-service',
version: '0.1.0',
description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos',
description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe',
corsOrigin: config.CORS_ORIGIN,
swagger: {
title: 'Platform Service',
description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos',
description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe',
port: config.PORT,
},
metrics: true,
@ -45,5 +51,29 @@ await app.register(blobRoutes, { prefix: '/api' });
await app.register(invitationRoutes, { prefix: '/api' });
await app.register(referralRoutes, { prefix: '/api' });
await app.register(promoRoutes, { prefix: '/api' });
// Billing modules (merged from billing-service)
// Scoped with internal key auth guard when BILLING_INTERNAL_KEY is set (Gap 3)
const BILLING_KEY = config.BILLING_INTERNAL_KEY;
if (BILLING_KEY) {
await app.register(async (billingScope) => {
billingScope.addHook('onRequest', async (req, reply) => {
const key = req.headers['x-internal-key'];
if (key !== BILLING_KEY) {
reply.code(401).send({ error: 'Unauthorized — missing or invalid X-Internal-Key' });
}
});
await billingScope.register(subscriptionRoutes, { prefix: '/api' });
await billingScope.register(usageRoutes, { prefix: '/api' });
await billingScope.register(planRoutes, { prefix: '/api' });
await billingScope.register(licenseRoutes, { prefix: '/api' });
});
} else {
await app.register(subscriptionRoutes, { prefix: '/api' });
await app.register(usageRoutes, { prefix: '/api' });
await app.register(planRoutes, { prefix: '/api' });
await app.register(licenseRoutes, { prefix: '/api' });
}
// Stripe routes outside billing scope (webhook has its own signature verification)
await app.register(stripeRoutes, { prefix: '/api' });
await startService(app, { port: config.PORT, host: config.HOST });