diff --git a/examples/README.md b/examples/README.md index 9a6efc0..694d2d7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,35 +1,36 @@ # Examples -This directory contains complete, production-ready examples showing how to use the health check and billing idempotency features. +This directory contains complete, runnable examples showing how to use the Callora backend subsystems end-to-end. ## Files ### 1. complete-integration.ts -**Full production application** integrating both health check and billing features. +**End-to-end walkthrough** of billing, vault, and gateway features using +in-memory services. No database, Stellar node, or external dependency +required — just run it. **Features**: -- Express server with health check endpoint -- Idempotent billing deduction endpoint -- Billing status lookup endpoint +- Express server with health check, vault, gateway, usage, and settlement endpoints +- In-memory vault creation and funding (simulated on-chain deposit) +- API gateway with API-key validation, rate limiting, billing deduction, upstream proxy, and usage recording +- Revenue settlement batch that pays developers from accumulated usage fees - Graceful shutdown handling -- Error handling middleware -- Database connection pooling **Usage**: ```bash -# Set environment variables -cp ../.env.example .env -# Edit .env with your configuration - -# Run the server +# No environment variables needed — everything runs in memory npx tsx examples/complete-integration.ts ``` **Endpoints**: -- `GET /api/health` - Health check with component status -- `POST /api/billing/deduct` - Idempotent billing deduction -- `GET /api/billing/status/:requestId` - Check billing request status +- `GET /api/health` — Liveness probe +- `POST /api/vault` — Create a vault (one per user per network) +- `GET /api/vault/balance` — Query vault balance +- `POST /api/vault/fund` — Simulate on-chain deposit +- `ALL /api/gateway/:apiId` — Proxy requests to upstream +- `GET /api/usage/events` — List recorded usage events +- `POST /api/settlement/run` — Run revenue settlement batch ### 2. client-usage.ts diff --git a/examples/complete-integration.ts b/examples/complete-integration.ts index bdb6ae7..3965295 100644 --- a/examples/complete-integration.ts +++ b/examples/complete-integration.ts @@ -1,294 +1,444 @@ /** - * Complete Integration Example - * - * Shows how to integrate both health check and billing idempotency - * features into a production application. + * Complete Integration Example — Billing + Vault + Gateway + * + * A linear, copy-paste-friendly walkthrough that exercises every backend + * subsystem supported today. All services use in-memory stores, so you + * don't need a database, Stellar node, or any other external dependency. + * + * Steps demonstrated: + * 1. Health check + * 2. Create a vault for a developer on testnet + * 3. Fund the vault (simulates an on-chain deposit) + * 4. Proxy a request through the API gateway + * 5. Inspect the recorded usage events + * 6. Run a revenue-settlement batch + * 7. Review final balances + * + * Run: + * npx tsx examples/complete-integration.ts + * + * Soroban contracts docs: https://github.com/CalloraOrg/callora-contracts + * Backend README: https://github.com/CalloraOrg/Callora-Backend#readme */ import express from 'express'; -import { Pool } from 'pg'; -import dotenv from 'dotenv'; -import { BillingService, type SorobanClient } from '../src/services/billing.js'; -import { buildHealthCheckConfig, closeDbPool } from '../src/config/health.js'; -import { performHealthCheck } from '../src/services/healthCheck.js'; - -// Load environment variables -dotenv.config(); - -// Initialize database pool -const pool = new Pool({ - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432', 10), - user: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - database: process.env.DB_NAME || 'callora', - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, -}); +import type { Server } from 'node:http'; +import { InMemoryVaultRepository } from '../src/repositories/vaultRepository.js'; +import { MockSorobanBilling } from '../src/services/billingService.js'; +import { InMemoryRateLimiter } from '../src/services/rateLimiter.js'; +import { InMemoryUsageStore } from '../src/services/usageStore.js'; +import { createGatewayRouter } from '../src/routes/gatewayRoutes.js'; +import { RevenueSettlementService } from '../src/services/revenueSettlementService.js'; +import { InMemorySettlementStore } from '../src/services/settlementStore.js'; +import { MockSorobanSettlementClient } from '../src/services/sorobanSettlement.js'; +import type { ApiKey, ApiRegistryEntry, ApiRegistry } from '../src/types/gateway.js'; -// Mock Soroban client (replace with real implementation) -class MockSorobanClient implements SorobanClient { - async deductBalance(userId: string, amount: string): Promise { - // In production, this would call the actual Soroban smart contract - console.log(`Deducting ${amount} USDC from user ${userId}`); - return `tx_${Date.now()}_${Math.random().toString(36).substring(7)}`; - } -} +// ============================================================================ +// CONSTANTS — tweak these to experiment +// ============================================================================ -// Initialize services -const sorobanClient = new MockSorobanClient(); -const billingService = new BillingService(pool, sorobanClient); -const healthCheckConfig = buildHealthCheckConfig(); +const PORT = parseInt(process.env.PORT || '3000', 10); +const NETWORK = 'testnet'; -// Create Express app -const app = express(); -app.use(express.json()); +const DEVELOPER_ID = 'dev_alice'; +const CONSUMER_ID = 'consumer_bob'; +const API_KEY = 'key_live_abc123'; +const API_ID = 'api_weather'; + +// Mock Soroban vault contract address. +// For real contract IDs see: https://github.com/CalloraOrg/callora-contracts +const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4'; + +// Consumer starts with 100 credits in the billing ledger +const INITIAL_CREDITS = 100; // ============================================================================ -// HEALTH CHECK ENDPOINT +// IN-MEMORY SERVICES // ============================================================================ +const vaultRepo = new InMemoryVaultRepository(); +const billing = new MockSorobanBilling({ [CONSUMER_ID]: INITIAL_CREDITS }); +const rateLimiter = new InMemoryRateLimiter(60, 60_000); +const usageStore = new InMemoryUsageStore(); +const settlementStore = new InMemorySettlementStore(); +const settlementClient = new MockSorobanSettlementClient(/* failureRate */ 0); + /** - * GET /api/health - * - * Returns detailed health status of all system components. - * Used by load balancers and monitoring systems. - * - * Response codes: - * - 200: All critical components healthy - * - 503: One or more critical components down + * Lightweight registry that maps API IDs to their upstream URL and + * developer ownership. The settlement service uses this to route + * accumulated usage fees back to the correct developer. */ -app.get('/api/health', async (_req, res) => { - if (!healthCheckConfig) { - // Fallback to simple health check - return res.json({ status: 'ok', service: 'callora-backend' }); +class SimpleRegistry implements ApiRegistry { + private entries = new Map(); + + register(entry: ApiRegistryEntry): void { + this.entries.set(entry.id, entry); } - try { - const healthStatus = await performHealthCheck(healthCheckConfig); - const statusCode = healthStatus.status === 'down' ? 503 : 200; - res.status(statusCode).json(healthStatus); - } catch (error) { - // Never expose internal errors in health check - res.status(503).json({ - status: 'down', - timestamp: new Date().toISOString(), - checks: { - api: 'ok', - database: 'down', - }, - }); + resolve(slugOrId: string): ApiRegistryEntry | undefined { + return this.entries.get(slugOrId); } -}); +} + +const apiRegistry = new SimpleRegistry(); + +const apiKeys = new Map([ + [API_KEY, { key: API_KEY, developerId: CONSUMER_ID, apiId: API_ID }], +]); + +const settlementService = new RevenueSettlementService( + usageStore, + settlementStore, + apiRegistry, + settlementClient, + { minPayoutUsdc: 1 }, // low threshold so the demo triggers a payout +); // ============================================================================ -// BILLING ENDPOINTS +// MOCK UPSTREAM — stands in for the real API the developer published // ============================================================================ -/** - * POST /api/billing/deduct - * - * Idempotent billing deduction endpoint. - * Uses request_id as idempotency key to prevent double charges. - * - * Request body: - * { - * "requestId": "req_abc123", // Required: Unique idempotency key - * "userId": "user_alice", - * "apiId": "api_weather", - * "endpointId": "endpoint_forecast", - * "apiKeyId": "key_xyz789", - * "amountUsdc": "0.01" - * } - * - * Response: - * { - * "usageEventId": "1", - * "stellarTxHash": "tx_stellar_abc...", - * "alreadyProcessed": false - * } - */ -app.post('/api/billing/deduct', async (req, res) => { - const { requestId, userId, apiId, endpointId, apiKeyId, amountUsdc } = req.body; - - // Validate required fields - if (!requestId) { - return res.status(400).json({ - error: 'request_id is required for idempotency', - code: 'MISSING_REQUEST_ID', - }); - } +function createUpstreamApp(): express.Express { + const upstream = express(); - if (!userId || !apiId || !endpointId || !apiKeyId || !amountUsdc) { - return res.status(400).json({ - error: 'Missing required fields', - code: 'INVALID_REQUEST', + upstream.get('/forecast', (_req, res) => { + res.json({ + location: 'Lagos', + temp_c: 31, + condition: 'Partly cloudy', + fetched_at: new Date().toISOString(), }); - } + }); - // Validate amount - const amount = parseFloat(amountUsdc); - if (isNaN(amount) || amount <= 0) { - return res.status(400).json({ - error: 'Invalid amount', - code: 'INVALID_AMOUNT', - }); - } + upstream.use((_req, res) => { + res.json({ ok: true }); + }); + + return upstream; +} + +// ============================================================================ +// MAIN APP — wires health, vault, gateway, usage, and settlement endpoints +// ============================================================================ - try { - const result = await billingService.deduct({ - requestId, - userId, - apiId, - endpointId, - apiKeyId, - amountUsdc, +function createMainApp(upstreamUrl: string): express.Express { + // Register the weather API now that we know the upstream URL + apiRegistry.register({ + id: API_ID, + slug: 'weather', + base_url: upstreamUrl, + developerId: DEVELOPER_ID, + endpoints: [{ endpointId: 'forecast', path: '/forecast', priceUsdc: 1 }], + }); + + const app = express(); + app.use(express.json()); + + // -- Health --------------------------------------------------------------- + + /** + * GET /api/health + * + * Minimal liveness probe. The production app layers on database and + * Soroban-RPC checks via the HealthCheckConfig — see src/config/health.ts. + */ + app.get('/api/health', (_req, res) => { + res.json({ + status: 'ok', + service: 'callora-backend', + timestamp: new Date().toISOString(), }); + }); - if (!result.success) { - return res.status(500).json({ - error: result.error || 'Billing deduction failed', - code: 'DEDUCTION_FAILED', + // -- Vault: create -------------------------------------------------------- + + /** + * POST /api/vault + * Body: { userId, contractId, network } + * + * Creates a vault (one per user per network). In production this is + * backed by a Soroban contract; here we use InMemoryVaultRepository. + */ + app.post('/api/vault', async (req, res) => { + const { userId, contractId, network } = req.body; + + if (!userId || !contractId || !network) { + res.status(400).json({ error: 'userId, contractId, and network are required' }); + return; + } + + try { + const vault = await vaultRepo.create(userId, contractId, network); + res.status(201).json({ + id: vault.id, + userId: vault.userId, + contractId: vault.contractId, + network: vault.network, + balanceSnapshot: vault.balanceSnapshot.toString(), }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(409).json({ error: message }); } + }); - // Return 200 for duplicate requests, 201 for new requests - const statusCode = result.alreadyProcessed ? 200 : 201; + // -- Vault: query balance ------------------------------------------------- - return res.status(statusCode).json({ - usageEventId: result.usageEventId, - stellarTxHash: result.stellarTxHash, - alreadyProcessed: result.alreadyProcessed, - }); - } catch (error) { - console.error('Billing deduction error:', error); - return res.status(500).json({ - error: 'Internal server error', - code: 'INTERNAL_ERROR', - }); - } -}); + /** + * GET /api/vault/balance?userId=...&network=testnet + * + * Returns the cached on-chain balance for a user's vault. + */ + app.get('/api/vault/balance', async (req, res) => { + const userId = req.query.userId as string; + const network = (req.query.network as string) ?? NETWORK; -/** - * GET /api/billing/status/:requestId - * - * Check the status of a billing request by request_id. - * Useful for checking if a request was already processed. - */ -app.get('/api/billing/status/:requestId', async (req, res) => { - const { requestId } = req.params; + if (!userId) { + res.status(400).json({ error: 'userId query parameter is required' }); + return; + } - if (!requestId) { - return res.status(400).json({ - error: 'request_id is required', - code: 'MISSING_REQUEST_ID', + const vault = await vaultRepo.findByUserId(userId, network); + if (!vault) { + res.status(404).json({ error: `No vault for user "${userId}" on ${network}` }); + return; + } + + res.json({ + id: vault.id, + balanceSnapshot: vault.balanceSnapshot.toString(), + network: vault.network, + lastSyncedAt: vault.lastSyncedAt?.toISOString() ?? null, }); - } + }); - try { - const result = await billingService.getByRequestId(requestId); + // -- Vault: fund (simulate on-chain deposit) ------------------------------ + + /** + * POST /api/vault/fund + * Body: { userId, network?, amountStroops } + * + * In production the balance is synced by a Horizon listener after a real + * Soroban deposit. Here we update the snapshot directly. + * Soroban contract docs: https://github.com/CalloraOrg/callora-contracts + */ + app.post('/api/vault/fund', async (req, res) => { + const { userId, network, amountStroops } = req.body; + + if (!userId || amountStroops === undefined) { + res.status(400).json({ error: 'userId and amountStroops are required' }); + return; + } - if (!result) { - return res.status(404).json({ - error: 'Request not found', - code: 'NOT_FOUND', - }); + const vault = await vaultRepo.findByUserId(userId, network ?? NETWORK); + if (!vault) { + res.status(404).json({ error: 'Vault not found — create one first' }); + return; } - return res.json({ - usageEventId: result.usageEventId, - stellarTxHash: result.stellarTxHash, - processed: true, - }); - } catch (error) { - console.error('Status check error:', error); - return res.status(500).json({ - error: 'Internal server error', - code: 'INTERNAL_ERROR', + const newBalance = vault.balanceSnapshot + BigInt(amountStroops); + const updated = await vaultRepo.updateBalanceSnapshot( + vault.id, + newBalance, + new Date(), + ); + + res.json({ + id: updated.id, + balanceSnapshot: updated.balanceSnapshot.toString(), + lastSyncedAt: updated.lastSyncedAt?.toISOString() ?? null, }); - } -}); + }); -// ============================================================================ -// ERROR HANDLING -// ============================================================================ + // -- Gateway: proxy requests to upstream ---------------------------------- + + /** + * ALL /api/gateway/:apiId + * + * Full proxy flow: + * 1. Validate API key (x-api-key header) + * 2. Rate-limit check + * 3. Deduct billing credit via MockSorobanBilling + * 4. Forward request to upstream + * 5. Record usage event + * 6. Return upstream response + */ + const gatewayRouter = createGatewayRouter({ + billing, + rateLimiter, + usageStore, + upstreamUrl, + apiKeys, + }); + app.use('/api/gateway', gatewayRouter); -// 404 handler -app.use((_req, res) => { - res.status(404).json({ - error: 'Not found', - code: 'NOT_FOUND', + // -- Usage: list recorded events ------------------------------------------ + + app.get('/api/usage/events', (_req, res) => { + const events = usageStore.getEvents(); + res.json({ count: events.length, events }); }); -}); -// Global error handler -app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error('Unhandled error:', err); - res.status(500).json({ - error: 'Internal server error', - code: 'INTERNAL_ERROR', + // -- Settlement: trigger batch -------------------------------------------- + + /** + * POST /api/settlement/run + * + * Runs the revenue settlement batch. Groups unsettled usage events by + * developer and, when they cross the minimum payout threshold, calls + * the Soroban settlement contract to distribute funds. + */ + app.post('/api/settlement/run', async (_req, res) => { + const result = await settlementService.runBatch(); + res.json(result); }); -}); + + // -- 404 fallback --------------------------------------------------------- + + app.use((_req, res) => { + res.status(404).json({ error: 'Not found' }); + }); + + return app; +} // ============================================================================ -// SERVER STARTUP +// DEMO WALKTHROUGH — exercises every step linearly // ============================================================================ -const PORT = process.env.PORT || 3000; - -const server = app.listen(PORT, () => { - console.log(`🚀 Server running on http://localhost:${PORT}`); - console.log(`✅ Health check: http://localhost:${PORT}/api/health`); - console.log(`💰 Billing: http://localhost:${PORT}/api/billing/deduct`); - - if (healthCheckConfig) { - console.log('✅ Detailed health checks enabled'); - if (healthCheckConfig.sorobanRpc) { - console.log(' - Soroban RPC monitoring enabled'); - } - if (healthCheckConfig.horizon) { - console.log(' - Horizon monitoring enabled'); - } +async function runDemo(baseUrl: string): Promise { + const divider = () => console.log('\n' + '='.repeat(64)); + + // Step 1 — Health check + divider(); + console.log('STEP 1 · Health check'); + const health = await fetch(`${baseUrl}/api/health`).then((r) => r.json()); + console.log(health); + + // Step 2 — Create vault for developer + divider(); + console.log('STEP 2 · Create vault for developer on testnet'); + const vault = await fetch(`${baseUrl}/api/vault`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: DEVELOPER_ID, + contractId: CONTRACT_ID, + network: NETWORK, + }), + }).then((r) => r.json()); + console.log(vault); + + // Step 3 — Fund vault (50 USDC = 500 000 000 stroops) + divider(); + console.log('STEP 3 · Fund vault (simulate 50 USDC on-chain deposit)'); + const funded = await fetch(`${baseUrl}/api/vault/fund`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: DEVELOPER_ID, + network: NETWORK, + amountStroops: '500000000', + }), + }).then((r) => r.json()); + console.log(funded); + + // Step 4 — Proxy a consumer request through the gateway + divider(); + console.log('STEP 4 · Proxy request through gateway (consumer calls weather API)'); + const proxyRes = await fetch(`${baseUrl}/api/gateway/${API_ID}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + const proxyBody = await proxyRes.json(); + console.log(` HTTP ${proxyRes.status}`); + console.log(proxyBody); + + // Step 5 — Inspect usage events and send more calls + divider(); + console.log('STEP 5 · Inspect usage events'); + let usage = await fetch(`${baseUrl}/api/usage/events`).then((r) => r.json()); + console.log(` ${usage.count} event(s) recorded so far`); + + // Send four more calls so the settlement threshold (1 USDC) is easily met + for (let i = 0; i < 4; i++) { + await fetch(`${baseUrl}/api/gateway/${API_ID}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); } -}); + usage = await fetch(`${baseUrl}/api/usage/events`).then((r) => r.json()); + console.log(` ${usage.count} event(s) after 4 additional calls`); + + // Step 6 — Revenue settlement + divider(); + console.log('STEP 6 · Revenue settlement (pay developer from usage fees)'); + const batch = await fetch(`${baseUrl}/api/settlement/run`, { + method: 'POST', + }).then((r) => r.json()); + console.log(batch); + + // Step 7 — Final balances + divider(); + console.log('STEP 7 · Final balances'); + + const devBalance = await fetch( + `${baseUrl}/api/vault/balance?userId=${DEVELOPER_ID}&network=${NETWORK}`, + ).then((r) => r.json()); + console.log(` Developer vault : ${devBalance.balanceSnapshot} stroops`); + + const consumerCredits = await billing.checkBalance(CONSUMER_ID); + console.log(` Consumer credits: ${consumerCredits} (started with ${INITIAL_CREDITS})`); + + divider(); + console.log('All steps complete — every subsystem exercised.'); +} // ============================================================================ -// GRACEFUL SHUTDOWN +// ENTRY POINT // ============================================================================ -async function gracefulShutdown(signal: string) { - console.log(`\n${signal} received, shutting down gracefully...`); +let upstreamServer: Server; +let mainServer: Server; - // Stop accepting new connections - server.close(() => { - console.log('HTTP server closed'); +async function start(): Promise { + const upstreamApp = createUpstreamApp(); + upstreamServer = await new Promise((resolve) => { + const srv = upstreamApp.listen(0, () => resolve(srv)); }); + const addr = upstreamServer.address(); + const upstreamPort = typeof addr === 'object' && addr ? addr.port : 0; + const upstreamUrl = `http://localhost:${upstreamPort}`; - // Close database pool - try { - await closeDbPool(); - console.log('Database pool closed'); - } catch (error) { - console.error('Error closing database pool:', error); - } + const mainApp = createMainApp(upstreamUrl); + mainServer = await new Promise((resolve) => { + const srv = mainApp.listen(PORT, () => resolve(srv)); + }); + + console.log(`Mock upstream on ${upstreamUrl}`); + console.log(`Callora gateway on http://localhost:${PORT}\n`); - // Exit process - process.exit(0); + await runDemo(`http://localhost:${PORT}`); + await shutdown(); } -process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); -process.on('SIGINT', () => gracefulShutdown('SIGINT')); +async function shutdown(): Promise { + console.log('\nShutting down...'); + if (mainServer) { + await new Promise((resolve) => mainServer.close(() => resolve())); + } + if (upstreamServer) { + await new Promise((resolve) => upstreamServer.close(() => resolve())); + } + console.log('Done.'); +} -// Handle uncaught errors -process.on('uncaughtException', (error) => { - console.error('Uncaught exception:', error); - gracefulShutdown('UNCAUGHT_EXCEPTION'); -}); +process.on('SIGTERM', () => shutdown().then(() => process.exit(0))); +process.on('SIGINT', () => shutdown().then(() => process.exit(0))); -process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled rejection at:', promise, 'reason:', reason); - gracefulShutdown('UNHANDLED_REJECTION'); +start().catch((err) => { + console.error('Fatal:', err); + process.exit(1); }); -export default app; +export { createMainApp, createUpstreamApp }; diff --git a/src/__tests__/complete-integration.test.ts b/src/__tests__/complete-integration.test.ts new file mode 100644 index 0000000..8926822 --- /dev/null +++ b/src/__tests__/complete-integration.test.ts @@ -0,0 +1,426 @@ +import express from 'express'; +import type { Server } from 'node:http'; +import { InMemoryVaultRepository } from '../repositories/vaultRepository.js'; +import { MockSorobanBilling } from '../services/billingService.js'; +import { InMemoryRateLimiter } from '../services/rateLimiter.js'; +import { InMemoryUsageStore } from '../services/usageStore.js'; +import { createGatewayRouter } from '../routes/gatewayRoutes.js'; +import { RevenueSettlementService } from '../services/revenueSettlementService.js'; +import { InMemorySettlementStore } from '../services/settlementStore.js'; +import { MockSorobanSettlementClient } from '../services/sorobanSettlement.js'; +import type { ApiKey, ApiRegistryEntry, ApiRegistry } from '../types/gateway.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +class SimpleRegistry implements ApiRegistry { + private entries = new Map(); + + register(entry: ApiRegistryEntry): void { + this.entries.set(entry.id, entry); + } + + resolve(slugOrId: string): ApiRegistryEntry | undefined { + return this.entries.get(slugOrId); + } +} + +function buildStack(overrides?: { initialCredits?: number; minPayoutUsdc?: number }) { + const credits = overrides?.initialCredits ?? 100; + const minPayout = overrides?.minPayoutUsdc ?? 1; + + const vaultRepo = new InMemoryVaultRepository(); + const billing = new MockSorobanBilling({ consumer_bob: credits }); + const rateLimiter = new InMemoryRateLimiter(60, 60_000); + const usageStore = new InMemoryUsageStore(); + const settlementStore = new InMemorySettlementStore(); + const settlementClient = new MockSorobanSettlementClient(0); + const apiRegistry = new SimpleRegistry(); + + const apiKeys = new Map([ + ['key_test', { key: 'key_test', developerId: 'consumer_bob', apiId: 'api_weather' }], + ]); + + const settlement = new RevenueSettlementService( + usageStore, + settlementStore, + apiRegistry, + settlementClient, + { minPayoutUsdc: minPayout }, + ); + + return { + vaultRepo, + billing, + rateLimiter, + usageStore, + settlementStore, + settlementClient, + apiRegistry, + apiKeys, + settlement, + }; +} + +// ── Test fixtures ────────────────────────────────────────────────────────── + +const DEVELOPER_ID = 'dev_alice'; +const CONSUMER_ID = 'consumer_bob'; +const API_ID = 'api_weather'; +const API_KEY = 'key_test'; +const NETWORK = 'testnet'; +const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4'; + +let upstreamServer: Server; +let upstreamUrl: string; +let gatewayServer: Server; +let gatewayUrl: string; + +let stack: ReturnType; + +beforeAll(async () => { + // Start mock upstream + const upstream = express(); + upstream.get('/forecast', (_req, res) => { + res.json({ location: 'Lagos', temp_c: 31 }); + }); + upstream.use((_req, res) => { + res.json({ ok: true }); + }); + + upstreamServer = await new Promise((resolve) => { + const srv = upstream.listen(0, () => resolve(srv)); + }); + const addr = upstreamServer.address(); + upstreamUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; +}); + +afterAll(async () => { + if (gatewayServer) await new Promise((r) => gatewayServer.close(() => r())); + if (upstreamServer) await new Promise((r) => upstreamServer.close(() => r())); +}); + +beforeEach(async () => { + // Close previous gateway if running + if (gatewayServer) { + await new Promise((r) => gatewayServer.close(() => r())); + } + + stack = buildStack(); + + stack.apiRegistry.register({ + id: API_ID, + slug: 'weather', + base_url: upstreamUrl, + developerId: DEVELOPER_ID, + endpoints: [{ endpointId: 'forecast', path: '/forecast', priceUsdc: 1 }], + }); + + const app = express(); + app.use(express.json()); + + app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', service: 'callora-backend' }); + }); + + app.post('/api/vault', async (req, res) => { + const { userId, contractId, network } = req.body; + if (!userId || !contractId || !network) { + res.status(400).json({ error: 'userId, contractId, and network are required' }); + return; + } + try { + const vault = await stack.vaultRepo.create(userId, contractId, network); + res.status(201).json({ + id: vault.id, + userId: vault.userId, + contractId: vault.contractId, + network: vault.network, + balanceSnapshot: vault.balanceSnapshot.toString(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(409).json({ error: message }); + } + }); + + app.get('/api/vault/balance', async (req, res) => { + const userId = req.query.userId as string; + const network = (req.query.network as string) ?? 'testnet'; + if (!userId) { + res.status(400).json({ error: 'userId query parameter is required' }); + return; + } + const vault = await stack.vaultRepo.findByUserId(userId, network); + if (!vault) { + res.status(404).json({ error: `No vault for user "${userId}" on ${network}` }); + return; + } + res.json({ + id: vault.id, + balanceSnapshot: vault.balanceSnapshot.toString(), + network: vault.network, + lastSyncedAt: vault.lastSyncedAt?.toISOString() ?? null, + }); + }); + + app.post('/api/vault/fund', async (req, res) => { + const { userId, network, amountStroops } = req.body; + if (!userId || amountStroops === undefined) { + res.status(400).json({ error: 'userId and amountStroops are required' }); + return; + } + const vault = await stack.vaultRepo.findByUserId(userId, network ?? 'testnet'); + if (!vault) { + res.status(404).json({ error: 'Vault not found' }); + return; + } + const newBalance = vault.balanceSnapshot + BigInt(amountStroops); + const updated = await stack.vaultRepo.updateBalanceSnapshot(vault.id, newBalance, new Date()); + res.json({ + id: updated.id, + balanceSnapshot: updated.balanceSnapshot.toString(), + lastSyncedAt: updated.lastSyncedAt?.toISOString() ?? null, + }); + }); + + const gatewayRouter = createGatewayRouter({ + billing: stack.billing, + rateLimiter: stack.rateLimiter, + usageStore: stack.usageStore, + upstreamUrl, + apiKeys: stack.apiKeys, + }); + app.use('/api/gateway', gatewayRouter); + + app.get('/api/usage/events', (_req, res) => { + res.json({ count: stack.usageStore.getEvents().length, events: stack.usageStore.getEvents() }); + }); + + app.post('/api/settlement/run', async (_req, res) => { + const result = await stack.settlement.runBatch(); + res.json(result); + }); + + gatewayServer = await new Promise((resolve) => { + const srv = app.listen(0, () => resolve(srv)); + }); + const gAddr = gatewayServer.address(); + gatewayUrl = `http://localhost:${typeof gAddr === 'object' && gAddr ? gAddr.port : 0}`; +}); + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('Complete Integration — Vault + Billing + Gateway + Settlement', () => { + + it('health check returns ok', async () => { + const res = await fetch(`${gatewayUrl}/api/health`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('ok'); + }); + + it('creates a vault, funds it, and queries the balance', async () => { + // Create + const createRes = await fetch(`${gatewayUrl}/api/vault`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: DEVELOPER_ID, contractId: CONTRACT_ID, network: NETWORK }), + }); + expect(createRes.status).toBe(201); + const created = await createRes.json(); + expect(created.userId).toBe(DEVELOPER_ID); + expect(created.balanceSnapshot).toBe('0'); + + // Fund (50 USDC = 500_000_000 stroops) + const fundRes = await fetch(`${gatewayUrl}/api/vault/fund`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: DEVELOPER_ID, network: NETWORK, amountStroops: '500000000' }), + }); + expect(fundRes.status).toBe(200); + const funded = await fundRes.json(); + expect(funded.balanceSnapshot).toBe('500000000'); + + // Query balance + const balRes = await fetch(`${gatewayUrl}/api/vault/balance?userId=${DEVELOPER_ID}&network=${NETWORK}`); + expect(balRes.status).toBe(200); + const balance = await balRes.json(); + expect(balance.balanceSnapshot).toBe('500000000'); + expect(balance.lastSyncedAt).toBeTruthy(); + }); + + it('rejects duplicate vault creation for same user and network', async () => { + await fetch(`${gatewayUrl}/api/vault`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: DEVELOPER_ID, contractId: CONTRACT_ID, network: NETWORK }), + }); + + const dupRes = await fetch(`${gatewayUrl}/api/vault`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: DEVELOPER_ID, contractId: 'other-contract', network: NETWORK }), + }); + expect(dupRes.status).toBe(409); + }); + + it('returns 404 for vault balance when vault does not exist', async () => { + const res = await fetch(`${gatewayUrl}/api/vault/balance?userId=nonexistent&network=${NETWORK}`); + expect(res.status).toBe(404); + }); + + it('proxies a request through the gateway, deducts credit, and records usage', async () => { + const res = await fetch(`${gatewayUrl}/api/gateway/${API_ID}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + + // Billing deducted (100 - 1 = 99) + const balance = await stack.billing.checkBalance(CONSUMER_ID); + expect(balance).toBe(99); + + // Usage event recorded + const events = stack.usageStore.getEvents(API_KEY); + expect(events.length).toBe(1); + expect(events[0].apiId).toBe(API_ID); + expect(events[0].statusCode).toBe(200); + }); + + it('returns 401 when API key is missing', async () => { + const res = await fetch(`${gatewayUrl}/api/gateway/${API_ID}`, { + method: 'GET', + }); + expect(res.status).toBe(401); + expect(stack.usageStore.getEvents().length).toBe(0); + }); + + it('returns 402 when consumer has insufficient balance', async () => { + stack.billing.setBalance(CONSUMER_ID, 0); + + const res = await fetch(`${gatewayUrl}/api/gateway/${API_ID}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + + expect(res.status).toBe(402); + const body = await res.json(); + expect(body.error).toMatch(/insufficient balance/i); + }); + + it('returns 429 when rate limited', async () => { + stack.rateLimiter.exhaust(API_KEY); + + const res = await fetch(`${gatewayUrl}/api/gateway/${API_ID}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + + expect(res.status).toBe(429); + expect(res.headers.get('retry-after')).toBeTruthy(); + }); + + it('settles revenue after enough usage accumulates', async () => { + // Generate 5 usage events (5 credits total, above 1 USDC threshold) + for (let i = 0; i < 5; i++) { + await fetch(`${gatewayUrl}/api/gateway/${API_ID}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + } + + expect(stack.usageStore.getEvents().length).toBe(5); + + // Run settlement + const res = await fetch(`${gatewayUrl}/api/settlement/run`, { method: 'POST' }); + expect(res.status).toBe(200); + + const batch = await res.json(); + expect(batch.processed).toBe(5); + expect(batch.settledAmount).toBe(5); + expect(batch.errors).toBe(0); + + // All events should now be settled + expect(stack.usageStore.getUnsettledEvents().length).toBe(0); + }); + + it('settlement skips when below minimum payout threshold', async () => { + // Only 1 event (1 credit), but threshold is 1 — meets threshold + // Use a higher threshold to test skipping + stack = buildStack({ minPayoutUsdc: 100 }); + stack.apiRegistry.register({ + id: API_ID, + slug: 'weather', + base_url: upstreamUrl, + developerId: DEVELOPER_ID, + endpoints: [{ endpointId: 'forecast', path: '/forecast', priceUsdc: 1 }], + }); + + // Record one usage event directly + stack.usageStore.record({ + id: 'evt_1', + requestId: 'req_1', + apiKey: API_KEY, + apiKeyId: API_KEY, + apiId: API_ID, + endpointId: 'forecast', + userId: CONSUMER_ID, + amountUsdc: 1, + statusCode: 200, + timestamp: new Date().toISOString(), + }); + + const result = await stack.settlement.runBatch(); + expect(result.processed).toBe(0); + expect(result.settledAmount).toBe(0); + + // Event remains unsettled + expect(stack.usageStore.getUnsettledEvents().length).toBe(1); + }); + + it('end-to-end: vault → gateway → settlement lifecycle', async () => { + // 1. Create and fund developer vault + await fetch(`${gatewayUrl}/api/vault`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: DEVELOPER_ID, contractId: CONTRACT_ID, network: NETWORK }), + }); + await fetch(`${gatewayUrl}/api/vault/fund`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: DEVELOPER_ID, network: NETWORK, amountStroops: '500000000' }), + }); + + // 2. Consumer proxies 3 requests through gateway + for (let i = 0; i < 3; i++) { + const res = await fetch(`${gatewayUrl}/api/gateway/${API_ID}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + expect(res.status).toBe(200); + } + + // 3. Verify usage recorded + const usageRes = await fetch(`${gatewayUrl}/api/usage/events`); + const usage = await usageRes.json(); + expect(usage.count).toBe(3); + + // 4. Verify consumer was charged + const consumerBalance = await stack.billing.checkBalance(CONSUMER_ID); + expect(consumerBalance).toBe(97); // 100 - 3 + + // 5. Settle revenue + const settlementRes = await fetch(`${gatewayUrl}/api/settlement/run`, { method: 'POST' }); + const batch = await settlementRes.json(); + expect(batch.processed).toBe(3); + expect(batch.errors).toBe(0); + + // 6. Developer vault balance unchanged (in-memory vault is independent of billing) + const balRes = await fetch(`${gatewayUrl}/api/vault/balance?userId=${DEVELOPER_ID}&network=${NETWORK}`); + const bal = await balRes.json(); + expect(bal.balanceSnapshot).toBe('500000000'); + }); +});