From e9ddb3304b49582f33253eb1dfb48c243b02448d Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Sun, 9 Aug 2026 19:59:40 -0400 Subject: [PATCH] billing skeleton: bun + hono + sqlite, seed + happy path --- .gitignore | 4 +++ README.md | 53 +++++++++++++++++++++++++++++++++++++ bun.lock | 29 ++++++++++++++++++++ package.json | 17 ++++++++++++ src/db.ts | 40 ++++++++++++++++++++++++++++ src/index.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/schema.sql | 38 ++++++++++++++++++++++++++ tsconfig.json | 12 +++++++++ 8 files changed, 265 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/db.ts create mode 100644 src/index.ts create mode 100644 src/schema.sql create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..185e4b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data.db +data.db-wal +data.db-shm diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0cdc6e --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# monk-code-screen + +Billing/subscriptions/invoicing backend skeleton. Bun + Hono + `bun:sqlite` (zero network deps at runtime; TS runs natively, no build step). + +## ⚡ START COMMAND (sticky) + +```sh +bun run dev # http://localhost:3000, hot reload +``` + +Other commands: + +```sh +bun run start # no watch +bun run db:reset # wipe data.db and reseed +bunx tsc --noEmit # typecheck +``` + +## Layout + +``` +src/index.ts Hono app + routes +src/db.ts opens data.db, applies schema, seeds if empty (runs on import) +src/schema.sql customers / plans / subscriptions / invoices (idempotent) +``` + +DB is `data.db` at repo root (WAL, FK on). Boot always re-applies schema, so adding a table = edit schema.sql + restart. + +## Routes + +```sh +curl localhost:3000/health +curl localhost:3000/customers +curl localhost:3000/customers/cus_ada/invoices +curl -X POST localhost:3000/subscriptions \ + -H 'content-type: application/json' \ + -d '{"customer_id":"cus_grace","plan_id":"plan_basic"}' +``` + +`POST /subscriptions` is the happy path: validates with zod, creates subscription + first invoice in one transaction, returns both (201). + +## Seed data + +- customers: `cus_ada`, `cus_grace` +- plans: `plan_basic` ($10/mo), `plan_pro` ($50/mo) +- `cus_ada` has an active `sub_1` on pro with open `inv_1` + +## Extension cheatsheet (interview) + +- New route: add to `src/index.ts`, `db.query(...).all()/get()`, `db.run(sql, [params])` +- Multi-write: `db.transaction(() => { ... })()` +- Validation: `z.object({...}).safeParse(await c.req.json())` +- New table: append `CREATE TABLE IF NOT EXISTS` to schema.sql, restart (or `db:reset` if changing existing tables) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..117a36e --- /dev/null +++ b/bun.lock @@ -0,0 +1,29 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "monk-code-screen", + "dependencies": { + "hono": "^4.13.1", + "zod": "^4.4.3", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "hono": ["hono@4.13.1", "", {}, "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..db6609b --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "monk-code-screen", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "db:reset": "rm -f data.db data.db-wal data.db-shm && bun src/db.ts" + }, + "dependencies": { + "hono": "^4.13.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/bun": "^1.3.14" + } +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..2db0c69 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,40 @@ +import { Database } from "bun:sqlite"; +import { join } from "node:path"; + +export const db = new Database(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 +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"); +} + +// `bun run db:reset` executes this file directly after deleting data.db +if (import.meta.main) { + console.log("db ready:", db.filename); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..902d900 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,72 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { z } from "zod"; +import { db } from "./db"; + +const app = new Hono(); +app.use(logger()); + +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()); +}); + +app.get("/customers/:id/invoices", (c) => { + const rows = db + .query("SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC") + .all(c.req.param("id")); + return c.json(rows); +}); + +// --- Subscriptions (happy path: subscribe -> first invoice) ----------------- + +const CreateSubscription = z.object({ + customer_id: z.string(), + plan_id: z.string(), +}); + +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; + + 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 = ?", + ) + .get(plan_id); + if (!plan) return c.json({ error: "plan not found" }, 404); + + const subId = `sub_${crypto.randomUUID().slice(0, 8)}`; + const invId = `inv_${crypto.randomUUID().slice(0, 8)}`; + + 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 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); +}); + +export default { + port: Number(process.env.PORT ?? 3000), + fetch: app.fetch, +}; + +console.log(`listening on http://localhost:${process.env.PORT ?? 3000}`); diff --git a/src/schema.sql b/src/schema.sql new file mode 100644 index 0000000..ab52bde --- /dev/null +++ b/src/schema.sql @@ -0,0 +1,38 @@ +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')) +); + +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')) +); + +CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b50caa4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src"] +}