Parent / line-item architecture: one charge, many real expenses. Written 2026-07-17 (end of shift).
On 2026-07-17 a Chase statement charge of $60.00 (Amazon order
112-5047119-7910611) was stored as expense 1504 with the generic
category 143 "Amazon". The order's real contents — a Kirundo dress ($53.98),
mascara ($7.45), and a Roku remote ($19.88) — belong in different
categories (clothing, personal care, electronics). One row with one
category_id cannot represent that honestly: today we categorize the
payment channel, not the expense.
The same flaw applies to multi-item receipts. The $3,047 Children's Vision receipt
(expense 1502) contains 9 separate donation line items forced into a
single row — and it was initially miscategorized precisely because a single row invites a
single lazy vendor-level guess.
vw_monthly_expense_summary is a
bare SUM(e.amount) GROUP BY category over all rows. Parent and children
coexisting today would immediately corrupt every monthly report.
verify_statement_totals, and (b) anchors check_duplicates — delete it and the
next scan of the same statement re-imports the $60 as new. The parent stays as the
audit & reconciliation anchor, excluded from all category sums.
ALTER TABLE expenses
ADD COLUMN parent_expense_id BIGINT UNSIGNED NULL,
ADD COLUMN expense_role ENUM('STANDALONE','PARENT','LINE_ITEM')
NOT NULL DEFAULT 'STANDALONE',
ADD CONSTRAINT fk_expenses_parent
FOREIGN KEY (parent_expense_id) REFERENCES expenses(id),
ADD INDEX idx_expenses_parent (parent_expense_id);
Backward compatible: every existing row (≈884 as of tonight, incl. 1504–1506) remains
valid as STANDALONE, exactly like the expense_status migration on
2026-07-17. Integrity invariants (enforced in the repository layer, checked by tests):
LINE_ITEM ⟺ parent_expense_id IS NOT NULL; the parent must have role PARENT.expense_role != 'PARENT'.PARENT has category_id NULL (it is not an expense; it is a payment event).id_light for children: <parent_id_light>-item-<n> (unique key preserved).Views needing the expense_role != 'PARENT' predicate (audit each; these are the
known summers): vw_monthly_expense_summary, vw_report_financials,
vw_large_transactions, vw_balance_by_org, and any
vw_*transaction* view that joins expenses.
All new code lives behind ABC ports with concretes injected at the composition root
(the CLI main() / dashboard boot — never inside the domain logic), matching the
established pattern in rol_finances and the dashboard JS. Four ports, one
Abstract Factory:
class LineItemSource(ABC):
"""Yields candidate line items for a charge/receipt. Pure read."""
@abstractmethod
def line_items(self, charge: ChargeContext) -> list[LineItem]: ...
class ItemizationPolicy(ABC):
"""Decides whether itemization is HONEST for this charge. Fail closed."""
@abstractmethod
def evaluate(self, charge: ChargeContext,
items: list[LineItem]) -> ItemizationDecision: ...
class ItemCategorizer(ABC):
"""Assigns a category to ONE line item (not to the whole charge)."""
@abstractmethod
def categorize(self, item: LineItem,
charge: ChargeContext) -> CategoryAssignment: ...
class ItemizedExpenseRepository(ABC):
"""Persists parent + children ATOMICALLY (one transaction), or
re-parents an existing STANDALONE row into a PARENT + children."""
@abstractmethod
def store_itemized(self, parent: ExpenseDraft,
children: list[ExpenseDraft]) -> ItemizedStoreResult: ...
@abstractmethod
def itemize_existing(self, expense_id: int,
children: list[ExpenseDraft]) -> ItemizedStoreResult: ...
rol_finances/tools/itemization/factory.py
class ItemizationFactory(ABC):
"""GoF Abstract Factory: one factory per document family guarantees the
source/policy/categorizer/repository quartet is mutually consistent.
Client code (Mazda tools, store scripts) NEVER constructs concretes."""
@abstractmethod
def line_item_source(self) -> LineItemSource: ...
@abstractmethod
def itemization_policy(self) -> ItemizationPolicy: ...
@abstractmethod
def item_categorizer(self) -> ItemCategorizer: ...
@abstractmethod
def repository(self) -> ItemizedExpenseRepository: ...
class ReceiptItemizationFactory(ItemizationFactory):
"""Receipts: items come from the parsed receipt itself; policy is
ExactReconciliationPolicy (receipt lines ALWAYS sum to the total —
if they don't, the parse is wrong, refuse)."""
class AmazonStatementItemizationFactory(ItemizationFactory):
"""Amazon charges: items from AmazonOrderSpreadsheetSource (wraps
lookup_amazon_order.py's lookup); policy additionally handles the
split-shipment case → NOT_ITEMIZABLE unless items reconcile to
the charge amount exactly."""
# Registry keyed by document family; the ONLY place concretes are wired.
def factory_for(doc_family: str) -> ItemizationFactory: ...
| Port | Concrete | Notes |
|---|---|---|
LineItemSource | ReceiptLineItemSource | Adapts parse_and_categorize.py --json line output. |
LineItemSource | AmazonOrderSpreadsheetSource | Adapts the existing lookup_amazon_order.py lookup (extract Order Number → itemized rows from vendor_reference/amazon_orders_2025_itemized.xlsx). Digital orders (D01-…) are not in the sheet → returns empty → policy says NOT_ITEMIZABLE → row stays STANDALONE. Correct, not a bug. |
ItemizationPolicy | ExactReconciliationPolicy | sum(items) == charge amount (cent-exact) else NOT_ITEMIZABLE. The default and only Phase-1 policy. A tolerance policy may come later; do not build it speculatively. |
ItemCategorizer | VendorCategoryStoreCategorizer | Deterministic first: reuse VendorCategoryStore patterns against the item name. |
ItemCategorizer | LlmItemCategorizer | Fallback for novel item names. Use a cheap/fast model (GPT-5.4) — "what category is a Roku remote" is a simple reading task, not frontier work. Decorate it with a ReviewFlaggingCategorizer so low-confidence answers mark the child for human review instead of silently guessing (Decorator, same gate philosophy as Mazda's proposal gates). |
ItemizedExpenseRepository | MySqlItemizedExpenseRepository | Single transaction; enforces every invariant above; refuses partial writes (fail closed, like tonight's receipt-storage hardening). |
AmazonOrderSpreadsheetSource with a tolerance policy, or a
receipt source with the split-shipment policy, produces quiet data corruption. The factory
makes invalid combinations unrepresentable — client code can only ask for a consistent
family. That is the textbook GoF justification, not ceremony.
| Phase | Scope | Done when |
|---|---|---|
| P0Schema + views | DDL above; add expense_role != 'PARENT' to summing views; repository-layer invariant checks; migration test against a DB snapshot. |
All 884 rows STANDALONE; every report total unchanged (byte-identical before/after). |
| P0Ports + factories + MySQL repo | Files above with unit tests (fake repos/sources — FakeElement-style test seams, no live DB needed). | store_itemized and itemize_existing round-trip on a test DB; partial-write refusal proven by test. |
| P1Receipts pilot | ReceiptItemizationFactory end-to-end. Pilot on expense 1502 (Children's Vision, 9 donation lines, $3,047 — lines sum exactly, no ambiguity). Re-parent via itemize_existing. |
1502 becomes PARENT + 9 LINE_ITEMs summing to $3,047; monthly totals unchanged; report shows 9 categorized rows. |
| P1Report + dashboard rendering | report.html: parent renders as a header row (no amount in the sum column), children indented beneath. Category picker's (date, abs(amount)) matching must become id-based for LINE_ITEMs — children share a date and can collide on amount. Update REPORT_OUTPUT_CONTRACT + rol-finance-reports-controller.js (pure render methods, injected deps, per house style). |
Recategorizing a single line item via the picker updates only that child, DB + HTML. |
| P2Amazon statements | AmazonStatementItemizationFactory. Only charges whose spreadsheet items reconcile exactly get itemized; others stay STANDALONE + review flag. Candidate: re-parent 1504 once shipment allocation is known (mom filling the sheet's Card Statement Reference column would unlock exact reconciliation). |
A reconciling Amazon charge itemizes; tonight's $60 (non-reconciling) correctly refuses. |
| P2Mazda integration | New MCP tool(s) wrapping factory_for(...) + the pipeline; wrapper rule: "when a charge has an itemizable source, attempt itemization; NEVER hand-build parent/child SQL"; Trainer rubric addition: parent/child sum integrity + no PARENT in totals. Restart mazda-tools-mcp after wiring. |
A scanned multi-item receipt auto-itemizes in a live Trainer-watched run. |
e_two_e_processing/duplicate_checker.py — statement lines must match against PARENT/STANDALONE only, never LINE_ITEMs (a $19.88 child must not "duplicate" a real $19.88 charge elsewhere).store_statement_transactions.py / parse_and_categorize.py --save — route through the repository port; keep the fail-closed behavior added 2026-07-16.create_id_light / generate_id_light — child suffix scheme.vw_* views listed above; grep for any raw SUM( over expenses in dashboard/server.py too.receipt_url/source_file.rol_finances is live and multi-agent-dirty — git status + diff before editing, stage by filename, never -A, never stash the whole tree.mazda-tools-mcp restart (cached map).resolve_vendor_key() matches alias regexes against the RAW description, but many of the ~261 aliases in vendor_category.yaml are written in normalized (underscore) form and may never match anything. Tonight's 3 *_general aliases were deliberately written raw-form and verified. A systematic audit belongs to whoever touches VendorCategoryStoreCategorizer.check_vendor_key fuzzy matcher too permissive — resolved Amazon rows to Apple/Audible with recognized:true (Trainer report 2026-07-17, wrap-v024 covers receipts but not statement rows). The item-level categorizer must not inherit this: plausibility-gate all fuzzy matches.lookup_amazon_order.py + spreadsheet working via executor_run.
Three general vendor aliases live in vendor_category.yaml. Nothing in this plan is
started; everything above is design.