feat(core): add CSV ingestion, invoice retrieval, payment handling, and schema overhaul

This commit is contained in:
Prad Nukala
2026-08-10 10:48:40 -04:00
parent 85a2016226
commit 98af22a300
4 changed files with 278 additions and 89 deletions
+6 -23
View File
@@ -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
+60 -32
View File
@@ -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(() => {
const CreatePayment = z.object({
idempotency_key: z.uuid(),
amount: z.number().int().positive(),
currency: z.string().min(1),
});
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 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}`],
`INSERT INTO payments (id, idempotency_key, invoice_id, status, amount, currency)
VALUES (?, ?, ?, 'pending', ?, ?)`,
[payId, idempotency_key, invoice.id, amount, currency],
);
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 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);
const payment = db.query("SELECT * FROM payments WHERE id = ?").get(payId);
return c.json({ payment }, 201);
});
export default {
+167
View File
@@ -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,
};
};
+40 -29
View File
@@ -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'))
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'))
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 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);