From 0419c58de1af2e3d4e5ac08790fd73bfdbb9d209 Mon Sep 17 00:00:00 2001 From: Eric Harding Date: Sat, 11 Jul 2026 18:23:25 -0400 Subject: [PATCH] Design review --- DESIGN.md | 55 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 7446017..f570deb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -41,6 +41,10 @@ and no API/JSON layer to maintain. TypeScript stays on the server where the logi Five tables plus one view. Money stored as **INTEGER cents** to avoid float rounding. Dates stored as ISO-8601 TEXT (`YYYY-MM-DD` or full datetime). +**Discounts:** entered as a line item with a negative `unit_price` (e.g. a `labor` line "10% discount" at `-$5.00`). No special column needed. + +**Soft deletes:** orders are never hard-deleted. Setting `deleted_at` hides them from all normal queries; they can be recovered if needed. + ```sql CREATE TABLE customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -79,7 +83,7 @@ CREATE TABLE service_orders ( 'WALK IN SERVICE')), service_rep TEXT, technician TEXT, - status TEXT DEFAULT 'OPEN', -- OPEN / IN PROGRESS / DONE / PAID + status TEXT DEFAULT 'OPEN' CHECK (status IN ('OPEN','IN PROGRESS','DONE','PAID')), date_in TEXT DEFAULT (date('now')), date_out TEXT, due_date TEXT, @@ -92,7 +96,8 @@ CREATE TABLE service_orders ( service_call INTEGER DEFAULT 0, disposal INTEGER DEFAULT 0, amount_paid INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')) + created_at TEXT DEFAULT (datetime('now')), + deleted_at TEXT -- soft delete; NULL = active ); CREATE TABLE line_items ( @@ -113,22 +118,34 @@ CREATE TABLE work_log ( logged_at TEXT DEFAULT (datetime('now')) ); +-- ROUND() keeps cents integer despite REAL quantity (e.g. 1.5 hrs × 175_00 ¢). CREATE VIEW order_totals AS SELECT so.id AS service_order_id, - COALESCE(SUM(CASE WHEN li.kind='part' THEN li.quantity*li.unit_price END),0) AS total_parts, - COALESCE(SUM(CASE WHEN li.kind='labor' THEN li.quantity*li.unit_price END),0) AS total_labor, - COALESCE(SUM(CASE WHEN li.kind='sublet' THEN li.quantity*li.unit_price END),0) AS total_sublet, - COALESCE(SUM(li.quantity*li.unit_price),0) - + so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total + COALESCE(SUM(CASE WHEN li.kind='part' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_parts, + COALESCE(SUM(CASE WHEN li.kind='labor' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_labor, + COALESCE(SUM(CASE WHEN li.kind='sublet' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_sublet, + COALESCE(SUM(ROUND(li.quantity*li.unit_price)),0) + + so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total, + COALESCE(SUM(ROUND(li.quantity*li.unit_price)),0) + + so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal + - so.amount_paid AS balance_due FROM service_orders so LEFT JOIN line_items li ON li.service_order_id = so.id +WHERE so.deleted_at IS NULL GROUP BY so.id; + +CREATE INDEX idx_line_items_order ON line_items(service_order_id); +CREATE INDEX idx_work_log_order ON work_log(service_order_id); +CREATE INDEX idx_clocks_customer ON clocks(customer_id); +CREATE INDEX idx_orders_customer ON service_orders(customer_id); ``` **Order numbers:** `order_number` starts at 8247 (next after the sample) and -increments. Simplest approach: `MAX(order_number)+1` at insert time inside a -transaction. `better-sqlite3` is synchronous so there's no race on a single-user app. +increments. Simplest approach: `COALESCE(MAX(order_number), 8246) + 1` at insert +time inside a transaction — the `COALESCE` handles an empty table correctly. +`better-sqlite3` is synchronous so there's no race on a single-user app. Gaps from +soft-deleted orders are fine; numbers are never recycled. ## 5. Application Structure @@ -136,7 +153,7 @@ transaction. `better-sqlite3` is synchronous so there's no race on a single-user clock-tracker/ ├─ src/ │ ├─ server.ts # Express app, route wiring -│ ├─ db.ts # better-sqlite3 connection + prepared statements +│ ├─ db.ts # better-sqlite3 connection + prepared statements; runs PRAGMA foreign_keys=ON │ ├─ schema.sql # the DDL above (run on first boot if db missing) │ ├─ money.ts # cents <-> "$1.75" helpers │ ├─ routes/ @@ -167,9 +184,10 @@ clock-tracker/ | POST | `/orders` | redirect | Create order + customer/clock | | GET | `/orders/:id` | full page | Order detail (work + parts) | | POST | `/orders/:id/line-items` | HTML fragment | Add a labor/part line (HTMX) | -| DELETE | `/orders/:id/line-items/:lineId` | empty/200 | Remove a line (HTMX) | +| DELETE | `/orders/:id/line-items/:lineId` | HTML fragment | Remove a line + OOB totals update (HTMX) | | POST | `/orders/:id/work-log` | HTML fragment | Add a work-log note (HTMX) | | POST | `/orders/:id` | HTML fragment | Update status/dates/totals | +| DELETE | `/orders/:id` | redirect | Soft-delete order (sets deleted_at) | | GET | `/orders/:id/invoice` | full page | Printable invoice | | GET | `/customers` | full page | Customer list/search | @@ -213,13 +231,20 @@ The app itself never sees or stores credentials. ## 10. Backups The database is one file (`data/app.db`). A nightly `cron` job copies it somewhere -safe: +safe using `better-sqlite3`'s built-in backup API (avoids needing the `sqlite3` CLI): -``` -sqlite3 data/app.db ".backup '/backups/app-$(date +%F).db'" +```ts +// scripts/backup.ts +import Database from 'better-sqlite3'; +import { execSync } from 'child_process'; +const date = execSync('date +%F').toString().trim(); +const db = new Database('data/app.db'); +await db.backup(`/backups/app-${date}.db`); +db.close(); ``` -Keep ~30 days. That's the entire disaster-recovery plan, and it's enough. +Run with `tsx scripts/backup.ts` from cron. Keep ~30 days. That's the entire +disaster-recovery plan, and it's enough. ## 11. Future (explicitly deferred)