import Database from 'better-sqlite3'; import { Invoice, InvoiceLine, InvoiceRepo } from '../invoicing'; export class SqliteInvoiceRepo implements InvoiceRepo { constructor(private db: Database.Database) { this.db.exec(` CREATE TABLE IF NOT EXISTS invoices ( id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, period TEXT NOT NULL, status TEXT NOT NULL, total_cents INTEGER NOT NULL, credit_cents INTEGER NOT NULL, billable_cents INTEGER NOT NULL, billing_type TEXT NOT NULL DEFAULT 'stripe', po_number TEXT, issued_at_ms INTEGER, due_at_ms INTEGER, paid_at_ms INTEGER ); CREATE TABLE IF NOT EXISTS invoice_lines ( invoice_id TEXT NOT NULL, endpoint_id TEXT NOT NULL, calls INTEGER NOT NULL, cents INTEGER NOT NULL, PRIMARY KEY (invoice_id, endpoint_id) ); `); } save(invoice: Invoice): void { this.db .prepare( `INSERT INTO invoices (id, customer_id, period, status, total_cents, credit_cents, billable_cents, billing_type, po_number, issued_at_ms, due_at_ms, paid_at_ms) VALUES (@id, @customerId, @period, @status, @totalCents, @creditCents, @billableCents, @billingType, @poNumber, @issuedAtMs, @dueAtMs, @paidAtMs) ON CONFLICT(id) DO UPDATE SET customer_id = @customerId, period = @period, status = @status, total_cents = @totalCents, credit_cents = @creditCents, billable_cents = @billableCents, billing_type = @billingType, po_number = @poNumber, issued_at_ms = @issuedAtMs, due_at_ms = @dueAtMs, paid_at_ms = @paidAtMs`, ) .run({ ...invoice, poNumber: invoice.poNumber ?? null, issuedAtMs: invoice.issuedAtMs ?? null, dueAtMs: invoice.dueAtMs ?? null, paidAtMs: invoice.paidAtMs ?? null, }); // Replace the line set atomically with the invoice row. this.db.prepare('DELETE FROM invoice_lines WHERE invoice_id = ?').run(invoice.id); const insert = this.db.prepare( 'INSERT INTO invoice_lines (invoice_id, endpoint_id, calls, cents) VALUES (?, ?, ?, ?)', ); for (const line of invoice.lines) { insert.run(invoice.id, line.endpointId, line.calls, line.cents); } } get(id: string): Invoice | undefined { const row = this.db.prepare('SELECT * FROM invoices WHERE id = ?').get(id) as | Record | undefined; return row ? this.toInvoice(row) : undefined; } list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[] { const where: string[] = []; const params: Record = {}; if (filter.customerId) { where.push('customer_id = @customerId'); params.customerId = filter.customerId; } if (filter.period) { where.push('period = @period'); params.period = filter.period; } if (filter.status) { where.push('status = @status'); params.status = filter.status; } const sql = `SELECT * FROM invoices ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`; const rows = this.db.prepare(sql).all(params) as Record[]; return rows.map((r) => this.toInvoice(r)); } nextSequence(period: string): number { const { n } = this.db .prepare('SELECT COUNT(*) AS n FROM invoices WHERE period = ?') .get(period) as { n: number }; return n + 1; } private toInvoice(row: Record): Invoice { const lines = this.db .prepare('SELECT endpoint_id, calls, cents FROM invoice_lines WHERE invoice_id = ? ORDER BY endpoint_id') .all(row.id as string) as { endpoint_id: string; calls: number; cents: number }[]; return { id: row.id as string, customerId: row.customer_id as string, period: row.period as string, status: row.status as Invoice['status'], lines: lines.map( (l): InvoiceLine => ({ endpointId: l.endpoint_id, calls: l.calls, cents: l.cents }), ), totalCents: row.total_cents as number, creditCents: row.credit_cents as number, billableCents: row.billable_cents as number, billingType: row.billing_type as Invoice['billingType'], poNumber: (row.po_number as string | null) ?? undefined, issuedAtMs: (row.issued_at_ms as number | null) ?? undefined, dueAtMs: (row.due_at_ms as number | null) ?? undefined, paidAtMs: (row.paid_at_ms as number | null) ?? undefined, }; } }