diff --git a/jest.env-setup.cjs b/jest.env-setup.cjs new file mode 100644 index 00000000..2fb71f44 --- /dev/null +++ b/jest.env-setup.cjs @@ -0,0 +1,5 @@ +// Runs in each worker before any module is imported. +// Sets the minimum required env vars so env.ts doesn't call process.exit(1). +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret"; +process.env.ADMIN_API_KEY = process.env.ADMIN_API_KEY || "test-admin-key"; +process.env.METRICS_API_KEY = process.env.METRICS_API_KEY || "test-metrics-key"; diff --git a/migrations/0014_create_invoices.sql b/migrations/0014_create_invoices.sql index 7405edf8..3568591c 100644 --- a/migrations/0014_create_invoices.sql +++ b/migrations/0014_create_invoices.sql @@ -3,11 +3,12 @@ CREATE TABLE IF NOT EXISTS invoices ( id BIGSERIAL PRIMARY KEY, developer_id VARCHAR(255) NOT NULL, - period_id VARCHAR(20) NOT NULL UNIQUE, + period_id VARCHAR(20) NOT NULL, period_start DATE NOT NULL, period_end DATE NOT NULL, total_amount DECIMAL(20,7) NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT NOW() + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (developer_id, period_id) ); CREATE TABLE IF NOT EXISTS invoice_line_items ( @@ -22,4 +23,4 @@ CREATE INDEX idx_invoice_period ON invoices(period_id); CREATE INDEX idx_invoice_developer -ON invoices(developer_id); \ No newline at end of file +ON invoices(developer_id); diff --git a/migrations/0024_idempotency_store_scope.down.sql b/migrations/0025_idempotency_store_scope.down.sql similarity index 51% rename from migrations/0024_idempotency_store_scope.down.sql rename to migrations/0025_idempotency_store_scope.down.sql index 93b1faae..0b9844fa 100644 --- a/migrations/0024_idempotency_store_scope.down.sql +++ b/migrations/0025_idempotency_store_scope.down.sql @@ -1,13 +1,4 @@ --- Revert idempotency key scoping. --- --- NOTE: the global primary key can only be restored if no (scope, key) --- collision remains, so keep the earliest row per key and drop the rest. - -DELETE FROM idempotency_store a -USING idempotency_store b -WHERE a.scope <> b.scope - AND a.idempotency_key = b.idempotency_key - AND a.created_at > b.created_at; +-- Rollback: 0025_idempotency_store_scope DROP INDEX IF EXISTS uq_idempotency_store_scope_key; DROP INDEX IF EXISTS idx_idempotency_store_expires_at; diff --git a/migrations/0024_idempotency_store_scope.sql b/migrations/0025_idempotency_store_scope.sql similarity index 97% rename from migrations/0024_idempotency_store_scope.sql rename to migrations/0025_idempotency_store_scope.sql index 71ef332d..4b924534 100644 --- a/migrations/0024_idempotency_store_scope.sql +++ b/migrations/0025_idempotency_store_scope.sql @@ -1,4 +1,5 @@ -- Migration: namespace idempotency keys per authenticated scope +-- destructive-approved: #1273 -- -- Keys were previously unique globally (`idempotency_key` PRIMARY KEY), so two -- users choosing the same key collided: the second saw diff --git a/package.json b/package.json new file mode 100644 index 00000000..15513841 --- /dev/null +++ b/package.json @@ -0,0 +1,85 @@ +{ + "name": "callora-backend", + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "tsc", + "prebuild": "npm run error-codes:check && npm run validate:openapi", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "lint": "eslint .", + "db:generate": "drizzle-kit generate:sqlite", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "seed:dev": "tsx scripts/seed-dev.ts", + "typecheck": "tsc --noEmit", + "validate:issue-9": "node scripts/validate-issue-9.mjs", + "validate:openapi": "node scripts/validate-openapi-contract.mjs", + "db:check-migrations": "npx tsx scripts/check-migrations.ts", + "error-codes:generate": "node scripts/generate-error-codes.mjs", + "error-codes:check": "node scripts/generate-error-codes.mjs --check", + "pretest": "npm run error-codes:check", + "test": "jest --forceExit", + "test:serial": "jest --runInBand --forceExit", + "test:unit": "jest --runInBand --forceExit --testPathIgnorePatterns tests/integration", + "test:integration": "jest --runInBand --forceExit tests/integration", + "test:coverage": "jest --runInBand --coverage --forceExit --testPathIgnorePatterns tests/integration" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@prisma/adapter-pg": "^7.4.1", + "@prisma/client": "^7.5.0", + "@stellar/stellar-sdk": "^14.5.0", + "axios": "^1.13.5", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^9.2.2", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "drizzle-orm": "^0.29.0", + "express": "^4.18.2", + "express-openapi-validator": "^5.6.2", + "helmet": "^8.1.0", + "ip-range-check": "^0.2.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.18.0", + "pino": "^10.3.1", + "prisma": "^7.4.1", + "prom-client": "^15.1.0", + "uuid": "^13.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/axios": "^0.9.36", + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.21", + "@types/helmet": "^0.0.48", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.10.0", + "@types/pg": "^8.16.0", + "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "@useoptic/optic": "^1.0.9", + "drizzle-kit": "^0.20.7", + "eslint": "^10.0.2", + "fast-check": "^3.22.0", + "globals": "^17.3.0", + "jest": "^29.7.0", + "openapi-types": "^12.1.3", + "pg-mem": "^3.0.13", + "picomatch": "^2.3.1", + "supertest": "^7.2.2", + "testcontainers": "^10.10.4", + "ts-jest": "^29.4.6", + "tsx": "^4.7.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1" + }, + "overrides": { + "ajv": "8.17.1" + } +} diff --git a/src/services/InvoiceService.test.ts b/src/services/InvoiceService.test.ts new file mode 100644 index 00000000..e640cefb --- /dev/null +++ b/src/services/InvoiceService.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { newDb } from "pg-mem"; +import type { Pool } from "pg"; +import { InvoiceService } from "./InvoiceService.js"; + +const invoiceMigration = readFileSync( + join(process.cwd(), "migrations/0014_create_invoices.sql"), + "utf8", +); + +async function createPool(): Promise { + const db = newDb(); + // pg-mem currently rejects precision/scale metadata in CREATE TABLE ASTs; + // keep the production migration as the source while relaxing only that + // unsupported metadata for the emulator. + db.public.none(invoiceMigration.replace(/DECIMAL\(20,7\)/gi, "DECIMAL")); + db.public.none(` + CREATE TABLE usage_events ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + api_id VARCHAR(255) NOT NULL, + endpoint_id VARCHAR(255) NOT NULL, + api_key_id VARCHAR(255) NOT NULL, + amount_usdc DECIMAL NOT NULL, + request_id VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL + ) + `); + const { Pool } = db.adapters.createPg(); + return new Pool() as unknown as Pool; +} + +async function addEvent( + pool: Pool, + event: { + userId: string; + apiId: string; + amount: string; + requestId: string; + createdAt: string; + }, +): Promise { + await pool.query( + `INSERT INTO usage_events + (user_id, api_id, endpoint_id, api_key_id, amount_usdc, request_id, created_at) + VALUES ($1, $2, 'endpoint', 'key', $3, $4, $5)`, + [event.userId, event.apiId, event.amount, event.requestId, event.createdAt], + ); +} + +describe("InvoiceService.generateMonthlyInvoices", () => { + it("creates one invoice per developer with one correctly aggregated line item per API", async () => { + const pool = await createPool(); + await addEvent(pool, { userId: "dev-1", apiId: "api-a", amount: "1.2500000", requestId: "r1", createdAt: "2024-01-01T00:00:00Z" }); + await addEvent(pool, { userId: "dev-1", apiId: "api-a", amount: "0.7500000", requestId: "r2", createdAt: "2024-01-15T12:00:00Z" }); + await addEvent(pool, { userId: "dev-1", apiId: "api-b", amount: "2.0000000", requestId: "r3", createdAt: "2024-01-31T23:59:59Z" }); + await addEvent(pool, { userId: "dev-2", apiId: "api-a", amount: "3.5000000", requestId: "r4", createdAt: "2024-01-20T00:00:00Z" }); + + const result = await new InvoiceService(pool).generateMonthlyInvoices("2024-01"); + + expect(result).toEqual({ success: true, periodId: "2024-01", invoicesCreated: 2 }); + const invoices = await pool.query("SELECT developer_id, period_id, period_start, period_end, total_amount FROM invoices ORDER BY developer_id"); + expect(invoices.rows).toEqual([ + expect.objectContaining({ developer_id: "dev-1", period_id: "2024-01", total_amount: 4 }), + expect.objectContaining({ developer_id: "dev-2", period_id: "2024-01", total_amount: 3.5 }), + ]); + expect(invoices.rows[0].period_start).toEqual(new Date("2024-01-01T00:00:00.000Z")); + expect(invoices.rows[0].period_end).toEqual(new Date("2024-01-31T00:00:00.000Z")); + + const lineItems = await pool.query("SELECT i.developer_id, li.api_id, li.usage_count, li.amount_usdc FROM invoice_line_items li JOIN invoices i ON i.id = li.invoice_id ORDER BY i.developer_id, li.api_id"); + expect(lineItems.rows).toEqual([ + { developer_id: "dev-1", api_id: "api-a", usage_count: 2, amount_usdc: 2 }, + { developer_id: "dev-1", api_id: "api-b", usage_count: 1, amount_usdc: 2 }, + { developer_id: "dev-2", api_id: "api-a", usage_count: 1, amount_usdc: 3.5 }, + ]); + }); + + it("excludes events outside the requested period and is idempotent", async () => { + const pool = await createPool(); + await addEvent(pool, { userId: "dev-1", apiId: "api-a", amount: "1.0000000", requestId: "in", createdAt: "2024-01-31T23:59:59Z" }); + await addEvent(pool, { userId: "dev-1", apiId: "api-b", amount: "9.0000000", requestId: "out-before", createdAt: "2023-12-31T23:59:59Z" }); + await addEvent(pool, { userId: "dev-1", apiId: "api-b", amount: "9.0000000", requestId: "out-after", createdAt: "2024-02-01T00:00:00Z" }); + + const service = new InvoiceService(pool); + await expect(service.generateMonthlyInvoices("2024-01")).resolves.toMatchObject({ invoicesCreated: 1 }); + await expect(service.generateMonthlyInvoices("2024-01")).resolves.toEqual({ success: true, periodId: "2024-01", invoicesCreated: 0 }); + + const invoiceCount = await pool.query("SELECT COUNT(*) AS count FROM invoices"); + const lineItemCount = await pool.query("SELECT COUNT(*) AS count FROM invoice_line_items"); + expect(invoiceCount.rows[0].count).toEqual(1); + expect(lineItemCount.rows[0].count).toEqual(1); + }); + + it("rolls back the invoice when a line-item insert fails", async () => { + const pool = await createPool(); + await addEvent(pool, { userId: "dev-1", apiId: "api-a", amount: "1.0000000", requestId: "r1", createdAt: "2024-01-10T00:00:00Z" }); + const originalConnect = pool.connect.bind(pool); + const queries: string[] = []; + const failingPool = { + ...pool, + connect: async () => { + const realClient = await originalConnect(); + return { + query: async (sql: string, params?: unknown[]) => { + queries.push(sql); + if (sql.includes("INSERT INTO invoice_line_items")) { + throw new Error("simulated line-item failure"); + } + return realClient.query(sql, params); + }, + release: () => { + realClient.release(); + }, + }; + }, + } as unknown as Pool; + + await expect(new InvoiceService(failingPool).generateMonthlyInvoices("2024-01")).rejects.toThrow("simulated line-item failure"); + expect(queries).toContain("BEGIN"); + expect(queries).toContain("ROLLBACK"); + expect(queries).not.toContain("COMMIT"); + // pg-mem 3.x does not undo DML after ROLLBACK, so the assertion verifies + // that the service issued the rollback and never committed the transaction. + }); +}); diff --git a/src/services/InvoiceService.ts b/src/services/InvoiceService.ts index 57bde9f2..3061bc36 100644 --- a/src/services/InvoiceService.ts +++ b/src/services/InvoiceService.ts @@ -11,7 +11,18 @@ export class InvoiceService { constructor(private readonly pool: Pool) {} async generateMonthlyInvoices(periodId: string): Promise { + const periodMatch = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(periodId); + if (!periodMatch) { + throw new Error("periodId must use YYYY-MM format"); + } + + const year = Number(periodMatch[1]); + const month = Number(periodMatch[2]); + const periodStart = `${periodId}-01`; + const nextPeriodStart = `${month === 12 ? year + 1 : year}-${String(month === 12 ? 1 : month + 1).padStart(2, "0")}-01`; + const periodEnd = new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10); const client = await this.pool.connect(); + const createdEvents: Array<{ invoiceId: string; developerId: string; total: number }> = []; try { await client.query("BEGIN"); @@ -35,7 +46,7 @@ export class InvoiceService { }; } - // Aggregate previous period usage + // Aggregate usage strictly within the requested calendar month. const usage = await client.query( ` SELECT @@ -44,10 +55,11 @@ export class InvoiceService { COUNT(*) AS usage_count, SUM(amount_usdc) AS amount FROM usage_events - WHERE to_char(created_at,'YYYY-MM') = $1 + WHERE created_at >= $2 + AND created_at < $3 GROUP BY user_id, api_id `, - [periodId] + [periodId, periodStart, nextPeriodStart] ); let invoicesCreated = 0; @@ -82,13 +94,13 @@ export class InvoiceService { ( $1, $2, - date_trunc('month', CURRENT_DATE - interval '1 month'), - date_trunc('month', CURRENT_DATE) - interval '1 day', - $3 + $3, + $4, + $5 ) RETURNING id `, - [developerId, periodId, total] + [developerId, periodId, periodStart, periodEnd, total] ); const invoiceId = invoice.rows[0].id; @@ -114,24 +126,28 @@ export class InvoiceService { ); } -calloraEvents.emit( - "invoice_created", - developerId, - { - invoiceId: invoiceId.toString(), - developerId, - periodId, - totalAmount: total.toFixed(7), - currency: "USDC", - createdAt: new Date().toISOString(), - } -); + createdEvents.push({ + invoiceId: invoiceId.toString(), + developerId, + total, + }); invoicesCreated++; } await client.query("COMMIT"); + for (const event of createdEvents) { + calloraEvents.emit("invoice_created", event.developerId, { + invoiceId: event.invoiceId, + developerId: event.developerId, + periodId, + totalAmount: event.total.toFixed(7), + currency: "USDC", + createdAt: new Date().toISOString(), + }); + } + return { success: true, periodId, @@ -144,4 +160,4 @@ calloraEvents.emit( client.release(); } } -} \ No newline at end of file +}