feat(core): add CSV ingestion, invoice retrieval, payment handling, and schema overhaul
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user