From 2ab8347fe4606265996a196e0123fe61c4e037d5 Mon Sep 17 00:00:00 2001 From: frank0277 Date: Sun, 28 Jun 2026 01:04:27 +0100 Subject: [PATCH] feat: monthly invoice generation worker --- migrations/0014_create_invoices.down.sql | 2 + migrations/0014_create_invoices.sql | 25 ++++ src/events/event.emitter.ts | 7 +- src/services/InvoiceService.ts | 147 +++++++++++++++++++++++ src/webhooks/webhook.types.ts | 10 +- src/workers/monthlyInvoiceJob.ts | 23 ++++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 migrations/0014_create_invoices.down.sql create mode 100644 migrations/0014_create_invoices.sql create mode 100644 src/services/InvoiceService.ts create mode 100644 src/workers/monthlyInvoiceJob.ts diff --git a/migrations/0014_create_invoices.down.sql b/migrations/0014_create_invoices.down.sql new file mode 100644 index 0000000..39f7500 --- /dev/null +++ b/migrations/0014_create_invoices.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS invoice_line_items; +DROP TABLE IF EXISTS invoices; \ No newline at end of file diff --git a/migrations/0014_create_invoices.sql b/migrations/0014_create_invoices.sql new file mode 100644 index 0000000..7405edf --- /dev/null +++ b/migrations/0014_create_invoices.sql @@ -0,0 +1,25 @@ +-- Create invoices table + +CREATE TABLE IF NOT EXISTS invoices ( + id BIGSERIAL PRIMARY KEY, + developer_id VARCHAR(255) NOT NULL, + period_id VARCHAR(20) NOT NULL UNIQUE, + 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() +); + +CREATE TABLE IF NOT EXISTS invoice_line_items ( + id BIGSERIAL PRIMARY KEY, + invoice_id BIGINT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + api_id VARCHAR(255) NOT NULL, + usage_count INTEGER NOT NULL, + amount_usdc DECIMAL(20,7) NOT NULL +); + +CREATE INDEX idx_invoice_period +ON invoices(period_id); + +CREATE INDEX idx_invoice_developer +ON invoices(developer_id); \ No newline at end of file diff --git a/src/events/event.emitter.ts b/src/events/event.emitter.ts index 1107c14..45ae9b7 100644 --- a/src/events/event.emitter.ts +++ b/src/events/event.emitter.ts @@ -5,6 +5,7 @@ import type { LowBalanceAlertData, NewApiCallData, SettlementCompletedData, + InvoiceCreatedData, WebhookPayload, } from '../webhooks/webhook.types.js'; @@ -12,8 +13,8 @@ export interface CalloraEventPayloadMap { new_api_call: NewApiCallData; settlement_completed: SettlementCompletedData; low_balance_alert: LowBalanceAlertData; + invoice_created: InvoiceCreatedData; } - export type CalloraEventName = keyof CalloraEventPayloadMap; export type CalloraEventListener = ( @@ -31,6 +32,7 @@ const createListenerSetMap = (): ListenerSetMap => ({ new_api_call: new Set>(), settlement_completed: new Set>(), low_balance_alert: new Set>(), + invoice_created: new Set>(), }); async function handleEvent( @@ -112,3 +114,6 @@ calloraEvents.on('settlement_completed', (developerId, data) => { calloraEvents.on('low_balance_alert', (developerId, data) => { return handleEvent('low_balance_alert', developerId, data); }); +calloraEvents.on('invoice_created', (developerId, data) => { + return handleEvent('invoice_created', developerId, data); +}); \ No newline at end of file diff --git a/src/services/InvoiceService.ts b/src/services/InvoiceService.ts new file mode 100644 index 0000000..415d332 --- /dev/null +++ b/src/services/InvoiceService.ts @@ -0,0 +1,147 @@ +import type { Pool } from "pg"; +import { calloraEvents } from "../events/event.emitter.js"; + +export interface InvoiceGenerationResult { + success: boolean; + periodId: string; + invoicesCreated: number; +} + +export class InvoiceService { + constructor(private readonly pool: Pool) {} + + async generateMonthlyInvoices(periodId: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + // Idempotency check + const existing = await client.query( + `SELECT id + FROM invoices + WHERE period_id = $1 + LIMIT 1`, + [periodId] + ); + + if (existing.rows.length > 0) { + await client.query("ROLLBACK"); + + return { + success: true, + periodId, + invoicesCreated: 0, + }; + } + + // Aggregate previous period usage + const usage = await client.query( + ` + SELECT + user_id AS developer_id, + api_id, + COUNT(*) AS usage_count, + SUM(amount_usdc) AS amount + FROM usage_events + WHERE to_char(created_at,'YYYY-MM') = $1 + GROUP BY user_id, api_id + `, + [periodId] + ); + + let invoicesCreated = 0; + + const grouped = new Map(); + + for (const row of usage.rows) { + if (!grouped.has(row.developer_id)) { + grouped.set(row.developer_id, []); + } + + grouped.get(row.developer_id)!.push(row); + } + + for (const [developerId, items] of grouped.entries()) { + const total = items.reduce( + (sum, item) => sum + Number(item.amount), + 0 + ); + + const invoice = await client.query( + ` + INSERT INTO invoices + ( + developer_id, + period_id, + period_start, + period_end, + total_amount + ) + VALUES + ( + $1, + $2, + date_trunc('month', CURRENT_DATE - interval '1 month'), + date_trunc('month', CURRENT_DATE) - interval '1 day', + $3 + ) + RETURNING id + `, + [developerId, periodId, total] + ); + + const invoiceId = invoice.rows[0].id; + + for (const item of items) { + await client.query( + ` + INSERT INTO invoice_line_items + ( + invoice_id, + api_id, + usage_count, + amount_usdc + ) + VALUES ($1,$2,$3,$4) + `, + [ + invoiceId, + item.api_id, + item.usage_count, + item.amount, + ] + ); + } + +calloraEvents.emit( + "invoice_created", + developerId, + { + invoiceId: invoiceId.toString(), + developerId, + periodId, + totalAmount: total.toFixed(7), + currency: "USDC", + createdAt: new Date().toISOString(), + } +); + + invoicesCreated++; + } + + await client.query("COMMIT"); + + return { + success: true, + periodId, + invoicesCreated, + }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } +} \ No newline at end of file diff --git a/src/webhooks/webhook.types.ts b/src/webhooks/webhook.types.ts index c462747..ea912cb 100644 --- a/src/webhooks/webhook.types.ts +++ b/src/webhooks/webhook.types.ts @@ -3,6 +3,7 @@ export type WebhookEventType = | 'settlement_completed' | 'low_balance_alert' | 'quota.threshold.reached'; + | 'invoice_created' export interface WebhookConfig { developerId: string; @@ -53,7 +54,14 @@ export interface SettlementCompletedData { txHash: string; settledAt: string; } - +export interface InvoiceCreatedData { + invoiceId: string; + developerId: string; + periodId: string; + totalAmount: string; + currency: string; + createdAt: string; +} export interface LowBalanceAlertData { currentBalance: string; thresholdBalance: string; diff --git a/src/workers/monthlyInvoiceJob.ts b/src/workers/monthlyInvoiceJob.ts new file mode 100644 index 0000000..5d16a75 --- /dev/null +++ b/src/workers/monthlyInvoiceJob.ts @@ -0,0 +1,23 @@ +import { InvoiceService } from "../services/invoiceService.js"; + +export class MonthlyInvoiceJob { + constructor( + private readonly invoiceService: InvoiceService, + ) {} + + async run(): Promise { + const now = new Date(); + + // Only run on the first day of the month + if (now.getDate() !== 1) { + return; + } + + const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + + const periodId = + `${previousMonth.getFullYear()}-${String(previousMonth.getMonth() + 1).padStart(2, "0")}`; + + await this.invoiceService.generateMonthlyInvoices(periodId); + } +} \ No newline at end of file