4 Architecture
claude-code edited this page 2026-04-19 12:32:09 -06:00

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 name and planned plus a notes input inside the expanded body. Each input has hx-post and hx-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.json is the single source of truth for the logging.config.dictConfig. Loaded into LOG_CONFIG in logging_config.py at import time; same file read by uvicorn CLI via --log-config.
  • Formatter: pythonjsonlogger.json.JsonFormatter with rename_fields mapping asctime/levelname/nametimestamp/level/logger.
  • AccessLogFilter in logging_config.py enriches uvicorn access records with event="http_request", method, path, status, client_ip.
  • Five seed app events at the main mutation sites plus one healthz_failed event 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-nums plus font-feature-settings: "lnum" 1, "tnum" 1.
  • Palette (CSS variables in app.css):
    • --paper #f5efe0 warm cream background with layered radial gradients for depth
    • --ink #1a1814 warm near-black
    • --accent #732629 burgundy, sampled from the logo shield
    • --ochre #9c6b1a under-budget / deviation
    • --sage #2d4a30 balanced / at-plan
    • --indigo #353a5e new-in-month
  • Layout primitives:
    • .zero-widget grid at the top of each page; logo on the left, zero in the centre, flanking labels
    • details.group collapsible groups with a hairline CSS chevron that rotates on [open]
    • .section with 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.entries with a 4-column grid.
    • .target-section with a burgundy left bar and ↳ margin glyph
    • .state-badge tracked-caps label with bullet separators

Testing

  • Service tests hit an in-memory SQLite engine directly.
  • Route tests use FastAPI's TestClient with a dependency override to share the same in-memory engine across threads via StaticPool.
  • Backup script tests shell out and assert on filenames and sqlite round-trips.
  • Logging tests instantiate the JSON formatter from LOG_CONFIG directly, push a record through a StringIO handler, assert on the parsed output. AccessLogFilter is tested with a synthetic uvicorn LogRecord. Seed events are tested via caplog. The dictConfig smoke test saves / restores logger state in a try/finally so it cannot leak propagate=False to subsequent tests.
  • /healthz is tested with and without a failing session.
  • Full suite (148 tests) runs in under 6 seconds.