From 98af22a300e06f406cf2930a58b92afbfac7ba8a Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 10 Aug 2026 10:48:40 -0400 Subject: [PATCH] feat(core): add CSV ingestion, invoice retrieval, payment handling, and schema overhaul --- src/db.ts | 29 ++------- src/index.ts | 96 ++++++++++++++++++---------- src/ingest.ts | 167 +++++++++++++++++++++++++++++++++++++++++++++++++ src/schema.sql | 75 ++++++++++++---------- 4 files changed, 278 insertions(+), 89 deletions(-) create mode 100644 src/ingest.ts diff --git a/src/db.ts b/src/db.ts index 2db0c69..5479825 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,37 +1,20 @@ import { Database } from "bun:sqlite"; import { join } from "node:path"; +import { ingestCsv } from "./ingest"; -export const db = new Database(join(import.meta.dir, "..", "data.db"), { create: true }); +export const db = new Database(process.env.DB_PATH ?? join(import.meta.dir, "..", "data.db"), { create: true }); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); // Migrate: schema is idempotent (CREATE TABLE IF NOT EXISTS) db.exec(await Bun.file(join(import.meta.dir, "schema.sql")).text()); -// Seed once, only if empty +// Seed once, only if empty. Circular-safe: ingest.ts touches db only at call time, after it is assigned above. const { n } = db.query<{ n: number }, []>("SELECT count(*) AS n FROM customers").get()!; if (n === 0) { - db.transaction(() => { - db.run( - `INSERT INTO customers (id, name, email) VALUES - ('cus_ada', 'Ada Lovelace', 'ada@example.com'), - ('cus_grace', 'Grace Hopper', 'grace@example.com')`, - ); - db.run( - `INSERT INTO plans (id, name, amount_cents, currency, interval) VALUES - ('plan_basic', 'Basic', 1000, 'usd', 'month'), - ('plan_pro', 'Pro', 5000, 'usd', 'month')`, - ); - db.run( - `INSERT INTO subscriptions (id, customer_id, plan_id, status, current_period_start, current_period_end) VALUES - ('sub_1', 'cus_ada', 'plan_pro', 'active', datetime('now'), datetime('now', '+1 month'))`, - ); - db.run( - `INSERT INTO invoices (id, subscription_id, customer_id, amount_cents, currency, status, due_at) VALUES - ('inv_1', 'sub_1', 'cus_ada', 5000, 'usd', 'open', datetime('now', '+14 days'))`, - ); - })(); - console.log("seeded database"); + const result = ingestCsv(await Bun.file(join(import.meta.dir, "..", "invoices.csv")).text()); + if (result.status === "error") throw new Error(`seed failed: ${result.message}`); + console.log(`seeded from invoices.csv: ${result.ingested} invoices (${result.errors} corrupted)`); } // `bun run db:reset` executes this file directly after deleting data.db diff --git a/src/index.ts b/src/index.ts index 902d900..f18df01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; import { z } from "zod"; import { db } from "./db"; +import { ingestCsv } from "./ingest"; const app = new Hono(); app.use(logger()); @@ -11,7 +12,7 @@ app.get("/health", (c) => c.json({ ok: true })); // --- Customers ------------------------------------------------------------- app.get("/customers", (c) => { - return c.json(db.query("SELECT * FROM customers ORDER BY created_at").all()); + return c.json(db.query("SELECT * FROM customers ORDER BY last_updated").all()); }); app.get("/customers/:id/invoices", (c) => { @@ -21,47 +22,74 @@ app.get("/customers/:id/invoices", (c) => { return c.json(rows); }); -// --- Subscriptions (happy path: subscribe -> first invoice) ----------------- +// --- Ingest ------------------------------------------------------------------ -const CreateSubscription = z.object({ - customer_id: z.string(), - plan_id: z.string(), +// CSV attached as the raw request body; corrupted rows are stored with status 'corrupted' +app.post("/ingest", async (c) => { + const csv = await c.req.text(); + if (csv.trim() === "") return c.json({ error: "attach CSV as the request body" }, 400); + const result = ingestCsv(csv); + if (result.status === "error") return c.json({ error: result.message }, 400); + return c.json(result); }); -app.post("/subscriptions", async (c) => { - const parsed = CreateSubscription.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) return c.json({ error: z.treeifyError(parsed.error) }, 400); - const { customer_id, plan_id } = parsed.data; +// --- Invoices ------------------------------------------------------------------ - const customer = db.query("SELECT id FROM customers WHERE id = ?").get(customer_id); - if (!customer) return c.json({ error: "customer not found" }, 404); - - const plan = db - .query<{ id: string; amount_cents: number; currency: string; interval: string }, [string]>( - "SELECT * FROM plans WHERE id = ?", +// :id is a surrogate id (inv_...) or an invoice_number (INV-1001) +app.get("/invoice/:id", (c) => { + const key = c.req.param("id"); + const invoice = db + .query<{ id: string }, [string, string]>( + "SELECT * FROM invoices WHERE id = ? OR invoice_number = ? ORDER BY created_at DESC", ) - .get(plan_id); - if (!plan) return c.json({ error: "plan not found" }, 404); + .get(key, key); + if (!invoice) return c.json({ error: "invoice not found" }, 404); + const payments = db + .query("SELECT * FROM payments WHERE invoice_id = ? ORDER BY last_updated DESC") + .all(invoice.id); + return c.json({ invoice, payments }); +}); - const subId = `sub_${crypto.randomUUID().slice(0, 8)}`; - const invId = `inv_${crypto.randomUUID().slice(0, 8)}`; +// --- Payments (happy path: pay an invoice, idempotent) ----------------------- - db.transaction(() => { - db.run( - `INSERT INTO subscriptions (id, customer_id, plan_id, status, current_period_start, current_period_end) - VALUES (?, ?, ?, 'active', datetime('now'), datetime('now', ?))`, - [subId, customer_id, plan_id, `+1 ${plan.interval}`], - ); - db.run( - `INSERT INTO invoices (id, subscription_id, customer_id, amount_cents, currency, status, due_at) - VALUES (?, ?, ?, ?, ?, 'open', datetime('now', '+14 days'))`, - [invId, subId, customer_id, plan.amount_cents, plan.currency], - ); - })(); +const CreatePayment = z.object({ + idempotency_key: z.uuid(), + amount: z.number().int().positive(), + currency: z.string().min(1), +}); - const subscription = db.query("SELECT * FROM subscriptions WHERE id = ?").get(subId); - const invoice = db.query("SELECT * FROM invoices WHERE id = ?").get(invId); - return c.json({ subscription, invoice }, 201); +app.post("/payment/:invoice_id", async (c) => { + const parsed = CreatePayment.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: z.treeifyError(parsed.error) }, 400); + const { idempotency_key, amount, currency } = parsed.data; + const key = c.req.param("invoice_id"); + + const invoice = db + .query<{ id: string; status: string }, [string, string]>( + "SELECT id, status FROM invoices WHERE id = ? OR invoice_number = ? ORDER BY created_at DESC", + ) + .get(key, key); + if (!invoice) return c.json({ error: "invoice not found" }, 404); + if (invoice.status !== "open" && invoice.status !== "partially_paid") { + return c.json({ error: `invoice is ${invoice.status}, not payable` }, 409); + } + + // Idempotency: same key replays the original result instead of double-charging + const existing = db + .query("SELECT * FROM payments WHERE idempotency_key = ?") + .get(idempotency_key); + if (existing) return c.json({ payment: existing }, 200); + + const payId = `pay_${crypto.randomUUID().slice(0, 8)}`; + // status starts 'pending'; the external PSP owns transitions from there + db.run( + `INSERT INTO payments (id, idempotency_key, invoice_id, status, amount, currency) + VALUES (?, ?, ?, 'pending', ?, ?)`, + [payId, idempotency_key, invoice.id, amount, currency], + ); + + const payment = db.query("SELECT * FROM payments WHERE id = ?").get(payId); + return c.json({ payment }, 201); }); export default { diff --git a/src/ingest.ts b/src/ingest.ts new file mode 100644 index 0000000..06fb535 --- /dev/null +++ b/src/ingest.ts @@ -0,0 +1,167 @@ +import { db } from "./db"; + +const HEADER = + "invoice_number,customer_name,customer_email,invoice_date,due_date,description,quantity,unit_price,amount,currency"; + +// Handles quoted fields with embedded commas and doubled quotes: `"$12,000.00"` +const splitCsvLine = (line: string): string[] => { + const fields: string[] = []; + let cur = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]!; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + cur += '"'; + i++; + } else { + inQuotes = false; + } + } else { + cur += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === ",") { + fields.push(cur); + cur = ""; + } else { + cur += ch; + } + } + fields.push(cur); + return fields.map((f) => f.trim()); +}; + +// "$12,000.00", "4,500.50 USD", "€1,850.00" -> integer cents; empty/unparseable -> null +const parseMoneyCents = (raw: string): number | null => { + const cleaned = raw.replace(/[^0-9.-]/g, ""); + if (cleaned === "" || cleaned === "-") return null; + const value = Number(cleaned); + return Number.isFinite(value) ? Math.round(value * 100) : null; +}; + +// "2026-01-05", "1/12/26", "2/1/2026", "Jan 16 2026" -> "YYYY-MM-DD"; unparseable -> null +const parseDate = (raw: string): string | null => { + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; + const mdy = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/); + if (mdy) { + const [, m, d, y] = mdy; + return `${y!.length === 2 ? `20${y}` : y}-${m!.padStart(2, "0")}-${d!.padStart(2, "0")}`; + } + const t = Date.parse(raw); + if (Number.isNaN(t)) return null; + const dt = new Date(t); + const mm = String(dt.getMonth() + 1).padStart(2, "0"); + const dd = String(dt.getDate()).padStart(2, "0"); + return `${dt.getFullYear()}-${mm}-${dd}`; +}; + +export type IngestedRow = { + line: number; + id: string; + invoice_number: string; + status: "open" | "corrupted"; + errors: string[]; +}; + +export type IngestResult = + | { status: "ok"; ingested: number; errors: number; rows: IngestedRow[] } + | { status: "error"; message: string }; + +export const ingestCsv = (csv: string): IngestResult => { + const lines = csv.split(/\r?\n/).filter((l) => l.trim() !== ""); + if (lines.length === 0 || splitCsvLine(lines[0]!).join(",") !== HEADER) { + return { status: "error", message: `expected CSV header: ${HEADER}` }; + } + + const rows: IngestedRow[] = []; + db.transaction(() => { + for (let i = 1; i < lines.length; i++) { + const [ + invoice_number = "", + customer_name = "", + customer_email = "", + invoice_date = "", + due_date = "", + description = "", + quantityRaw = "", + unitPriceRaw = "", + amountRaw = "", + currencyRaw = "", + ] = splitCsvLine(lines[i]!); + + const quantity = /^-?\d+$/.test(quantityRaw) ? Number(quantityRaw) : null; + const unit_price = parseMoneyCents(unitPriceRaw); + const amount = parseMoneyCents(amountRaw); + const currency = currencyRaw.toUpperCase(); + const created_at = parseDate(invoice_date); + const due_on = parseDate(due_date); + + const errors: string[] = []; + if (quantity === null) errors.push("missing or invalid quantity"); + else if (quantity < 0) errors.push("negative quantity"); + if (unit_price === null) errors.push("missing or invalid unit_price"); + else if (unit_price < 0) errors.push("negative unit_price"); + if (amount === null) errors.push("missing or invalid amount"); + else if (amount < 0) errors.push("negative amount"); + if (currency === "") errors.push("empty currency code"); + if (created_at === null) errors.push("invalid invoice_date"); + if (due_on === null) errors.push("invalid due_date"); + + // Customer identity: email (case-insensitive) when present, else name among email-less customers + let customer: { id: string } | null = null; + if (customer_email !== "") { + customer = db + .query<{ id: string }, [string]>("SELECT id FROM customers WHERE lower(customer_email) = lower(?)") + .get(customer_email); + } else if (customer_name !== "") { + customer = db + .query<{ id: string }, [string]>("SELECT id FROM customers WHERE customer_email IS NULL AND customer_name = ?") + .get(customer_name); + } + if (customer) { + db.run("UPDATE customers SET last_updated = datetime('now') WHERE id = ?", [customer.id]); + } else if (customer_name !== "" || customer_email !== "") { + const customerId = `cus_${crypto.randomUUID().slice(0, 8)}`; + db.run("INSERT INTO customers (id, customer_name, customer_email) VALUES (?, ?, ?)", [ + customerId, + customer_name !== "" ? customer_name : customer_email, + customer_email !== "" ? customer_email : null, + ]); + customer = { id: customerId }; + } else { + errors.push("missing customer"); + } + + const id = `inv_${crypto.randomUUID().slice(0, 8)}`; + const status = errors.length > 0 ? "corrupted" : "open"; + db.run( + `INSERT INTO invoices (id, invoice_number, customer_id, status, created_at, due_on, description, quantity, unit_price, amount, currency) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + invoice_number, + customer?.id ?? null, + status, + created_at ?? new Date().toISOString().slice(0, 10), + due_on, + description !== "" ? description : null, + quantity, + unit_price, + amount, + currency !== "" ? currency : null, + ], + ); + rows.push({ line: i + 1, id, invoice_number, status, errors }); + } + })(); + + return { + status: "ok", + ingested: rows.length, + errors: rows.filter((r) => r.status === "corrupted").length, + rows, + }; +}; diff --git a/src/schema.sql b/src/schema.sql index ab52bde..bd4348d 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -1,38 +1,49 @@ CREATE TABLE IF NOT EXISTS customers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS plans ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - amount_cents INTEGER NOT NULL, - currency TEXT NOT NULL DEFAULT 'usd', - interval TEXT NOT NULL DEFAULT 'month' CHECK (interval IN ('month', 'year')) -); - -CREATE TABLE IF NOT EXISTS subscriptions ( - id TEXT PRIMARY KEY, - customer_id TEXT NOT NULL REFERENCES customers(id), - plan_id TEXT NOT NULL REFERENCES plans(id), - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'past_due', 'canceled')), - current_period_start TEXT NOT NULL, - current_period_end TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id TEXT PRIMARY KEY, + customer_name TEXT NOT NULL, + customer_email TEXT UNIQUE, -- NULL allowed: some CSV rows arrive without an email + last_updated TEXT NOT NULL DEFAULT (datetime('now')) ); +-- unit_price / amount are integer minor units (cents) CREATE TABLE IF NOT EXISTS invoices ( - id TEXT PRIMARY KEY, - subscription_id TEXT NOT NULL REFERENCES subscriptions(id), - customer_id TEXT NOT NULL REFERENCES customers(id), - amount_cents INTEGER NOT NULL, - currency TEXT NOT NULL DEFAULT 'usd', - status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'paid', 'void')), - due_at TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id TEXT PRIMARY KEY, + invoice_number TEXT NOT NULL, + customer_id TEXT REFERENCES customers(id), + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'partially_paid', 'paid', 'void', 'corrupted')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + due_on TEXT, + description TEXT, + quantity INTEGER, + unit_price INTEGER, + amount INTEGER, + currency TEXT, + -- corrupted rows keep whatever parsed; every other status must be fully formed + CHECK ( + status = 'corrupted' + OR (customer_id IS NOT NULL AND due_on IS NOT NULL AND quantity IS NOT NULL + AND unit_price IS NOT NULL AND amount IS NOT NULL AND currency IS NOT NULL) + ) ); -CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(customer_id); -CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id); +CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + invoice_id TEXT NOT NULL REFERENCES invoices(id), + -- status is owned by the external PSP; no CHECK so new PSP states never break inserts + status TEXT NOT NULL DEFAULT 'pending', + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + amount INTEGER NOT NULL, -- SQLite INTEGER is 64-bit (BigInt-safe) + currency TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_customers_last_updated ON customers(last_updated); +CREATE INDEX IF NOT EXISTS idx_invoices_number ON invoices(invoice_number); +CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoices_created_at ON invoices(created_at); +CREATE INDEX IF NOT EXISTS idx_invoices_due_on ON invoices(due_on); +CREATE INDEX IF NOT EXISTS idx_payments_invoice ON payments(invoice_id); +CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); +CREATE INDEX IF NOT EXISTS idx_payments_last_updated ON payments(last_updated); +CREATE INDEX IF NOT EXISTS idx_payments_completed_at ON payments(completed_at);