billing skeleton: bun + hono + sqlite, seed + happy path
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data.db
|
||||||
|
data.db-wal
|
||||||
|
data.db-shm
|
||||||
@@ -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)
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user