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
2 changes: 2 additions & 0 deletions migrations/0014_create_invoices.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS invoice_line_items;
DROP TABLE IF EXISTS invoices;
25 changes: 25 additions & 0 deletions migrations/0014_create_invoices.sql
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 6 additions & 1 deletion src/events/event.emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import type {
LowBalanceAlertData,
NewApiCallData,
SettlementCompletedData,
InvoiceCreatedData,
WebhookPayload,
} from '../webhooks/webhook.types.js';

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<K extends CalloraEventName> = (
Expand All @@ -31,6 +32,7 @@ const createListenerSetMap = (): ListenerSetMap => ({
new_api_call: new Set<CalloraEventListener<'new_api_call'>>(),
settlement_completed: new Set<CalloraEventListener<'settlement_completed'>>(),
low_balance_alert: new Set<CalloraEventListener<'low_balance_alert'>>(),
invoice_created: new Set<CalloraEventListener<'invoice_created'>>(),
});

async function handleEvent<K extends CalloraEventName>(
Expand Down Expand Up @@ -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);
});
147 changes: 147 additions & 0 deletions src/services/InvoiceService.ts
Original file line number Diff line number Diff line change
@@ -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<InvoiceGenerationResult> {
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<string, any[]>();

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();
}
}
}
10 changes: 9 additions & 1 deletion src/webhooks/webhook.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type WebhookEventType =
| 'settlement_completed'
| 'low_balance_alert'
| 'quota.threshold.reached';
| 'invoice_created'

export interface WebhookConfig {
developerId: string;
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions src/workers/monthlyInvoiceJob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { InvoiceService } from "../services/invoiceService.js";

export class MonthlyInvoiceJob {
constructor(
private readonly invoiceService: InvoiceService,
) {}

async run(): Promise<void> {
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);
}
}