diff --git a/Architecture.md b/Architecture.md index 767766b..fd6d954 100644 --- a/Architecture.md +++ b/Architecture.md @@ -68,17 +68,36 @@ month month_entry id, month_id FK month(id) ON DELETE CASCADE, - section, name, planned, applied, notes NULL, + 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: + +```python +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 @@ -97,7 +116,26 @@ Computed from `(name, planned)` vs `(origin_name, origin_planned)`: tag in indigo; progress bar tinted indigo. Notes changes do NOT flip the deviation state. Notes are annotation, not -plan drift. +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 @@ -164,17 +202,24 @@ 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 +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 / applied / notes) +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}/target set / clear per-month target +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 @@ -186,10 +231,19 @@ accurate without a reload. ## HTMX conventions -* Month entry rows carry three inline inputs (`name`, `planned`, - `applied`) plus a notes input, each with `hx-post` and - `hx-trigger="change"`. Each input sends only its own field; the server - accepts any subset. +* 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 `
` 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 `
` + that POSTs and re-renders the section on success. +* Per-section add forms (adding a new entry) are hidden behind a + small `+ add
` 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 @@ -218,8 +272,10 @@ accurate without a reload. * `details.group` collapsible groups with a hairline CSS chevron that rotates on `[open]` * `.section` with a small-caps header and an inline subtotal - * `table.entries` with a 4-column grid (name / planned / applied / - actions) and a 2px progress bar on each row's bottom border + * Month entry rows are `
` 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 diff --git a/Home.md b/Home.md index 329fc5a..e7e2ab1 100644 --- a/Home.md +++ b/Home.md @@ -23,7 +23,10 @@ real spending on the applied side. minimums + Primary Debt Target), **Savings** (sinking funds), and **Flexible** (food, subscriptions, other). * `/month/YYYY-MM` — a snapshot of the budget for a specific calendar - month. Adds an `applied` column per entry to track actuals. Rows carry + month. Each entry becomes a `
` block: the summary row shows + planned (click-to-edit) and applied (derived); expand the row to see + its backing ledger of transactions (postings) and add more. Applied + is always `sum(postings.amount)`, never typed directly. Rows carry deviation tags when edited or added post-snapshot. * **Zero Amount header** on both pages. Green when every dollar is assigned, amber when unassigned income remains, red when over-budget. @@ -31,6 +34,10 @@ real spending on the applied side. number on month pages. * **Per-entry notes** — free-text annotation on every row. Copied through at snapshot time; editable inline. +* **Backing transaction ledger** — every month entry owns a list of + postings (date, amount, optional description and payee). Applied is + derived from the ledger. One posting for fixed items like rent, + many for variable ones like groceries. ## Month lifecycle @@ -64,8 +71,9 @@ where leftover applied dollars belong; filling it is the user's job. Shipped: initial scaffold, monthly snapshot with deviation tags, database backup script with alembic auto-hook, zero-amount header, section groups with collapsible headers, sinking funds section, per-entry notes, month -lifecycle with the balance gate, UI redesign in Barlow Condensed + logo. -See the [Roadmap](Roadmap) for what is next. +lifecycle with the balance gate, UI redesign in Barlow Condensed + logo, +backing transaction ledger with `applied` derived from postings. See the +[Roadmap](Roadmap) for what is next. ## Code diff --git a/Operations.md b/Operations.md index 32d5139..9a40a11 100644 --- a/Operations.md +++ b/Operations.md @@ -133,6 +133,7 @@ Applied migrations at time of writing: | `03ebe3c07262` | add month snapshot tables (`month`, `month_entry`, `month_debt_target`) | | `ec804bdf366d` | add `notes` column to `entry` and `month_entry` | | `a4ec4f8f6e9f` | add month lifecycle columns (`state`, `activated_at`, `closed_at`) | +| `cc60e7f73a1c` | add `posting` ledger table, seed opening-balance postings, drop `month_entry.applied` | After pulling new code, `uv run alembic upgrade head` walks the chain and the backup hook fires between each hop. diff --git a/Roadmap.md b/Roadmap.md index 2fffa46..eab0dc2 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -13,19 +13,31 @@ | 13 | Notes field per entry | 2026-04-17 | | 15 | Month lifecycle: Planning, Active, Closed with reconciliation gate | 2026-04-17 | | 17 | UI redesign: condensed-sans ledger style with logo in the zero hero | 2026-04-17 | +| 19 | Backing transaction ledger: replace applied field with Postings | 2026-04-17 | ## Deferred Things we have explicitly called out as future work. File an issue when the time comes. -### Transaction log behind applied +### Constrain posting dates to the month -Replace the hand-edited `applied` value with a log of dated -transactions per entry per month. `applied` becomes a computed sum. -Implies a new `month_transaction` table, a UI for entering -transactions, and a migration path that preserves existing applied -values as an opening balance. +Postings are free-dated today (a May 3 transaction can live on an +April entry). Most users would expect the date to fall within the +month of the entry. A small validator + UI hint would tighten this +without closing the escape hatch for one-off overrides. + +### Bank-statement reconciliation + +A view that ingests a CSV or OFX export and matches rows against +existing postings. Would need a "cleared" flag on postings and a +match-by-amount + date workflow. + +### Recurring posting schedules + +For regular bills (mortgage on the 1st, subscription on the 15th), +let the user register a recurrence rule on a month_entry so the +posting appears automatically when activating a new month. ### Closed-month "archived" visual treatment