Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions jest.env-setup.cjs
Original file line number Diff line number Diff line change
@@ -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";
7 changes: 4 additions & 3 deletions migrations/0014_create_invoices.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -22,4 +23,4 @@ CREATE INDEX idx_invoice_period
ON invoices(period_id);

CREATE INDEX idx_invoice_developer
ON invoices(developer_id);
ON invoices(developer_id);
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
85 changes: 85 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
126 changes: 126 additions & 0 deletions src/services/InvoiceService.test.ts
Original file line number Diff line number Diff line change
@@ -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<Pool> {
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<void> {
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.
});
});
56 changes: 36 additions & 20 deletions src/services/InvoiceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@ export class InvoiceService {
constructor(private readonly pool: Pool) {}

async generateMonthlyInvoices(periodId: string): Promise<InvoiceGenerationResult> {
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");
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -144,4 +160,4 @@ calloraEvents.emit(
client.release();
}
}
}
}
Loading