ROL Finances — Next Shift Handoff

Expense Itemization

Parent / line-item architecture: one charge, many real expenses. Written 2026-07-17 (end of shift).

Why this exists

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.

The double-count trap. If we store each item as its own expense row, the parent charge must stop counting as an expense — otherwise the $60 charge plus its items appear in every total. Verified 2026-07-17: 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.
Do not delete the parent ("nuke" ≠ DELETE). The parent row is demoted, not removed. It is the only thing that (a) reconciles against the bank statement in 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.
The split-shipment trap (Amazon-specific). The $60 charge covers an unknown subset of the $85 order (Amazon splits orders across shipments; the spreadsheet's Card Statement Reference column is unfilled). We cannot honestly itemize a charge whose item allocation we don't know. Rule: itemize only when line items reconcile exactly to the charge amount; otherwise the row stays STANDALONE with a review flag. Partial guessing is fabricating data — the same honesty rule Mazda's Trainer enforces on evidence.

Schema (Phase 0 — the only DDL)

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):

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.

Architecture — program to the interface

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:

rol_finances/tools/itemization/ports.py  (all new — names are the contract, adjust freely)
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: ...

Concrete implementations (first wave)

PortConcreteNotes
LineItemSourceReceiptLineItemSourceAdapts parse_and_categorize.py --json line output.
LineItemSourceAmazonOrderSpreadsheetSourceAdapts 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.
ItemizationPolicyExactReconciliationPolicysum(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.
ItemCategorizerVendorCategoryStoreCategorizerDeterministic first: reuse VendorCategoryStore patterns against the item name.
ItemCategorizerLlmItemCategorizerFallback 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).
ItemizedExpenseRepositoryMySqlItemizedExpenseRepositorySingle transaction; enforces every invariant above; refuses partial writes (fail closed, like tonight's receipt-storage hardening).
Why Abstract Factory and not just DI of four params: the quartet is correlated. Pairing 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.

Phases (do them in order)

PhaseScopeDone 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.

Blast radius — audit each before P1 ships

Standing constraints (project rules — do not relearn these the hard way)

Open follow-ups inherited by this work (context, not blockers)

State as of end of shift 2026-07-17: all four Chase Jan-2025 transactions stored flat & correct (1503 Apple/142, 1504 Amazon/143, 1505 AMZN Digital/143, 1506 OpenAI/398). 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.