Table of Contents
- Architecture
- Modules
- Sections and groups
- Budget data model
- Month snapshot data model
- Postings (transaction ledger)
- Month lifecycle
- Zero-amount calculation
- Routes
- Budget (src/quartermaster/routes.py)
- Month (src/quartermaster/routes_month.py)
- Health (src/quartermaster/routes_health.py)
- HTMX conventions
- Observability
- Visual identity
- Testing
Architecture
Small FastAPI app rendering Jinja2 templates with HTMX-driven partial
updates. SQLite is the only storage. SQLAlchemy 2.x is the ORM; Alembic
manages schema. uv manages Python deps. Structured JSON logging to
stdout is the runtime-observability contract; see
Operations.
Modules
src/quartermaster/
main.py FastAPI app factory
routes.py Budget configuration HTTP handlers
routes_month.py Monthly view HTTP handlers
routes_health.py GET /healthz (unauthenticated, separate router)
service.py Budget queries, totals, target, zero-amount, group views
month_service.py Month snapshot, deviation, per-month CRUD, lifecycle, group views
groups.py Group enum, labels, section->group mapping, default open
models.py SQLAlchemy models, Section enum, MonthState enum
db.py Engine, session, PRAGMA foreign_keys=ON
config.py DB URL resolution
logging_config.py Loads LOG_CONFIG from logconfig.json; AccessLogFilter
logconfig.json dictConfig source-of-truth (tests + uvicorn both consume)
templates/ Jinja2 templates (base, index, month, partials)
static/
app.css Full stylesheet (Barlow Condensed + ledger layout)
brand/ Logo assets (full, mark-wide, shield-wide)
Sections and groups
Section enum (budget + month)
income, fixed_bill, debt_minimum, food, subscription, other, sinking_fund
Group enum (display-layer)
income -> [income]
committed -> [fixed_bill, debt_minimum] + Primary Debt Target card
savings -> [sinking_fund]
flexible -> [food, subscription, other]
Default open: income=yes, committed=no, savings=no, flexible=yes
The Primary Debt Target is NOT a section. It is a singleton pointer that selects one of the Debt Minimums rows as the current focus. It is excluded from zero-amount math since it has no total of its own (its amount is already counted inside Debt Minimums).
Budget data model
entry
id, section, name, amount, notes (nullable, up to 1024 chars),
created_at, updated_at
debt_target (singleton, id = 1)
id, debt_minimum_id FK entry(id) ON DELETE SET NULL, updated_at
Deleting the entry a target points at nulls the pointer; no cascade to the entry itself.
Month snapshot data model
month
id, year_month UNIQUE ("YYYY-MM"),
state ('planning' | 'active' | 'closed', default 'planning'),
activated_at NULL, closed_at NULL,
created_at
month_entry
id, month_id FK month(id) ON DELETE CASCADE,
section, name, planned, notes NULL,
origin_name NULL, origin_planned NULL,
source_entry_id FK entry(id) ON DELETE SET NULL,
created_at, updated_at
posting
id, month_entry_id FK month_entry(id) ON DELETE CASCADE,
occurred_on DATE, amount NUMERIC(10,2),
description NULL, payee NULL,
created_at, updated_at
month_debt_target (singleton per month)
month_id PK FK month(id) ON DELETE CASCADE,
month_entry_id FK month_entry(id) ON DELETE SET NULL,
updated_at
month_entry.applied is a derived Python property, not a column:
class MonthEntry(Base):
@property
def applied(self) -> Decimal:
return sum((p.amount for p in self.postings), Decimal("0")).quantize(...)
The relationship uses lazy="selectin" so a page that iterates
month.entries fires one extra SELECT for all postings at once,
rather than N+1 lazy loads.
Creating a month copies every budget entry into month_entry and
preserves origin_name, origin_planned, and notes. source_entry_id
links back to the budget but is informational only; the snapshot is
self-contained so the month keeps working even after the referenced
budget row is deleted.
Deviation states
Computed from (name, planned) vs (origin_name, origin_planned):
unchanged— both match. No tag, progress bar renders in the section baseline colour.edited— name or planned differs. "Modified" tag in ochre; progress bar recoloured to indicate drift.new_in_month— no origin (row added after snapshot). "New this month" tag in indigo; progress bar tinted indigo.
Notes changes do NOT flip the deviation state. Notes are annotation, not plan drift. Posting activity also does NOT flip deviation; postings are normal month flow.
Postings (transaction ledger)
Each month_entry owns a list of postings. A posting is a single
debit or credit in the ledger:
occurred_on(required) — ISO date; not constrained to the month (a posting dated May 3 may live under the April entry if you want).amount(required) — can be negative for refunds or corrections.description,payee— optional free-text.
Some entries will have one posting per month (mortgage, rent); others will have many (groceries). The UI exposes them inside each expanded entry row with an inline add form.
User-facing language is "transactions" throughout; the schema and ORM
use "posting" (classical accounting term, avoids collision with
SQLAlchemy's own Session.transaction concept).
Per-month debt target
Copied from the budget's current debt target at creation time, resolved
through source_entry_id. Fully editable per month.
Month lifecycle
Three explicit states. Transitions happen via dedicated routes and are validated on the server.
Planning -- POST /month/{ym}/activate --> Active
Active -- POST /month/{ym}/close --> Closed (applied zero must be $0.00)
Closed -- POST /month/{ym}/reopen --> Active
Each transition stamps or clears the relevant timestamp. The close gate is authoritative on the server; the UI disables the Close button with a tooltip when the balance is nonzero but would not be trusted alone.
month_service.ensure_editable(month) is the guard that every mutation
route calls. When the month is closed it raises MonthLifecycleError,
which the route layer translates to an HTTP 400. Inputs on a closed
month carry disabled; delete buttons, add forms, and the target form
are omitted entirely from rendered HTML.
Reopen does NOT reverse any sweep — nothing is ever swept automatically. Reopening just sets the state back to Active and lets you edit again.
Zero-amount calculation
On both pages:
zero = sum(income.amount | .applied | .planned)
- sum(non_income.amount | .applied | .planned)
Debt Target is excluded from the calculation because it is a pointer, not a totalable section.
On the budget page there is one value. On the month page there are two: Planned (over the planned column) and Applied (over the applied column).
Colour tone:
tone-zero→ sage (balanced)tone-positive→ ochre (unassigned, under-budget)tone-negative→ burgundy (over-budget)
Routes
Budget (src/quartermaster/routes.py)
GET / index page
POST /sections/{section}/entries add entry
DELETE /entries/{entry_id} remove entry
POST /entries/{entry_id}/save update entry (name, amount, notes)
POST /debt-target set / clear debt target
Month (src/quartermaster/routes_month.py)
GET /month/{year_month} view page or create-flow
POST /month/{year_month}/create snapshot the budget (lands in Planning)
POST /month/{year_month}/activate Planning -> Active
POST /month/{year_month}/close Active -> Closed (requires applied zero = 0)
POST /month/{year_month}/reopen Closed -> Active
POST /month/{year_month}/sections/{section}/entries add month entry
POST /month/{year_month}/entries/{entry_id} update (name / planned / notes)
DELETE /month/{year_month}/entries/{entry_id} remove month entry
POST /month/{year_month}/entries/{entry_id}/postings add a transaction
POST /month/{year_month}/postings/{posting_id} update a transaction
DELETE /month/{year_month}/postings/{posting_id} delete a transaction
POST /month/{year_month}/target set / clear per-month target
update_month_entry no longer accepts an applied field. Applied
is managed by posting create / update / delete; direct writes are
not allowed.
All mutation routes return the section partial plus OOB swaps for:
- The debt target card when a Debt Minimums row was added / edited / deleted
- The Zero Amount widget
- All four group totals (
#group-total-{group})
OOB is used so section-level changes keep the page-level summary widgets accurate without a reload.
Health (src/quartermaster/routes_health.py)
GET /healthz liveness + DB reachability (unauthenticated)
Separate router, zero app-level dependencies. Success: 200
{"status":"ok"}. Failure: 503
{"status":"error","detail":"<exception-class-name>"} — class name
only, no message or traceback leaked. A failed probe emits a
structured warning log with event=healthz_failed, error_class=<cls>.
HTMX conventions
- Month entry rows carry inline inputs for
nameandplannedplus a notes input inside the expanded body. Each input hashx-postandhx-trigger="change". Each input sends only its own field; the server accepts any subset. - Applied is rendered as static text (not an input). To change
applied, the user expands the row's
<details>and edits or adds postings. - Posting rows inside the expanded body have four inline inputs each
(date, description, payee, amount) also using
hx-post+hx-trigger="change". The add-posting form is a normal<form>that POSTs and re-renders the section on success. - Per-section add forms (adding a new entry) are hidden behind a
small
+ add <section>disclosure so the default view is dense. - Section partials swap via
hx-target="#section-{section}"+hx-swap="outerHTML". - OOB swaps use
hx-swap-oob="outerHTML"on the top-level element of the partial so the same id can be re-rendered anywhere in the page. - Transition buttons (activate, close, reopen) POST with
hx-swap="none"and the server returns 204 +HX-Redirect: /month/{year_month}so the page re-renders cleanly in the new state.
Observability
See Operations for the Logs and Health sections. Brief summary:
src/quartermaster/logconfig.jsonis the single source of truth for thelogging.config.dictConfig. Loaded intoLOG_CONFIGinlogging_config.pyat import time; same file read by uvicorn CLI via--log-config.- Formatter:
pythonjsonlogger.json.JsonFormatterwithrename_fieldsmappingasctime/levelname/name→timestamp/level/logger. AccessLogFilterinlogging_config.pyenriches uvicorn access records withevent="http_request",method,path,status,client_ip.- Five seed app events at the main mutation sites plus one
healthz_failedevent at the probe.
Visual identity
- Typography: Barlow Condensed (300-800 + italic) imported from
Google Fonts. Barlow proportional as a secondary pair. All numeric
columns carry
font-variant-numeric: tabular-numsplusfont-feature-settings: "lnum" 1, "tnum" 1. - Palette (CSS variables in
app.css):--paper #f5efe0warm cream background with layered radial gradients for depth--ink #1a1814warm near-black--accent #732629burgundy, sampled from the logo shield--ochre #9c6b1aunder-budget / deviation--sage #2d4a30balanced / at-plan--indigo #353a5enew-in-month
- Layout primitives:
.zero-widgetgrid at the top of each page; logo on the left, zero in the centre, flanking labelsdetails.groupcollapsible groups with a hairline CSS chevron that rotates on[open].sectionwith a small-caps header and an inline subtotal- Month entry rows are
<details class="entry-block">blocks with a 5-column summary grid (caret / name / planned / applied / actions). The 2px progress bar rides the summary's bottom border. - Budget entry rows use
table.entrieswith a 4-column grid. .target-sectionwith a burgundy left bar and ↳ margin glyph.state-badgetracked-caps label with bullet separators
Testing
- Service tests hit an in-memory SQLite engine directly.
- Route tests use FastAPI's
TestClientwith a dependency override to share the same in-memory engine across threads viaStaticPool. - Backup script tests shell out and assert on filenames and sqlite round-trips.
- Logging tests instantiate the JSON formatter from
LOG_CONFIGdirectly, push a record through aStringIOhandler, assert on the parsed output.AccessLogFilteris tested with a synthetic uvicornLogRecord. Seed events are tested viacaplog. The dictConfig smoke test saves / restores logger state in a try/finally so it cannot leakpropagate=Falseto subsequent tests. /healthzis tested with and without a failing session.- Full suite (148 tests) runs in under 6 seconds.