Skip to content

Unified ExpenseCategory taxonomy + AI expense-category prompt

What / Why

First steps of merging "Expense Receipts" and "Service Invoices" into a single expense concept β€” one category taxonomy + GL map shared by the Telegram receipt pipeline and the vendor-invoice path, so a Telegram-uploaded SaaS bill no longer gets mis-binned as fnb/other. Multi-phase; additive so far (no Firestore migration).

DONE (origin/nightly, NOT main)

  • Phase 0 β€” taxonomy (1123f52d) β€” lib/accounting/expenseCategories.ts: EXPENSE_CATEGORIES (18-leaf superset of the two legacy enums ReceiptCategory + VendorCategory, snake_case so existing Firestore values stay valid without migration), EXPENSE_CATEGORY_LABEL/_HINT/_TO_GL (merged, zero GL conflicts β€” verified leaf-for-leaf). Owner-locked collapses: receipt stationary β†’ office (both 6300); the two others unify. receiptCategoryToExpense / vendorCategoryToExpense adapters + coerceExpenseCategory for read-time normalisation; dev-time GL-drift assertion. (Merged #738.)
  • Codex P2 (732bda6b) β€” coerceExpenseCategory coerces labels + synonyms, not just slugs.
  • Phase 1 prep β€” AI prompt (4cb42c7c, merged #740) β€” lib/ai/expensePrompt.ts builds a prompt fragment listing all 18 unified categories + synonym hints, spliced into the Telegram receipt processor's category section (replaces receipt-only RECEIPT_CATEGORY_HINT). GL codes deliberately omitted from the prompt (arbitrary numbers don't help the model + risk leaking).
  • Few-shot (22653f80) β€” 8 worked examples for the disambiguation boundaries Gemini fumbles (gas vs transportation, groceries vs fnb, office vs production, vendor-invoice SaaS shape).

REMAINING (per the commit messages)

⚠️ STALE as of 2026-07-01 β€” superseded by the close-out below. The schema/type cutover this section lists as pending was in fact completed by later aliasing work (the AI schema is z.enum(EXPENSE_CATEGORIES); consumers import the unified module). Only one true functional gap remained (the Telegram picker) β€” now closed. Edit note: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM

  • ~~Schema + type cutover (the "follow-up PR") β€” the output schema still constrains accepted slugs to RECEIPT_CATEGORIES; Gemini sees all 18 categories with hints, but any non-receipt slug (e.g. cloud_services, audio_software, productivity) falls through to other via the existing schema-rejection fallback. The schema + type cutover to the full 18-leaf set is NOT yet landed.~~ β€” done: receiptProcessor.ts uses z.enum(EXPENSE_CATEGORIES).
  • ~~Downstream: writer/reader/Firestore cutover to the unified taxonomy (Phase 0 was additive only).~~ β€” done: readers/writers/pickers import the unified module; no Firestore migration needed (old slugs stay valid; coerceExpenseCategory normalises at read).

T-066 (receipt auto-categorize consumes receiptCategoryToGl) Β· [[project_receipts_subsystem_state]] Β· [[feedback_honor_system_design]] (cloud agent's area β€” coordinate before touching the writer/schema)

Attribution / routing note (added 2026-06-29)

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md; not claiming this task β€” adding a signed attribution finding after EOP Local Assistance's status sweep asked whether it's mine (no Source: line existed to attribute it).
  • Source (edit): Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • Finding β€” not attributable to this session (015P6KzVYsQCLgEmUjR9bMwM): Phase 0 shipped via PR #738 branch claude/expense-categories-unified-enum and Phase 1 via PR #740 branch claude/expense-prompt-unified β€” neither carries this session's -yxMLM branch namespace (my branches are consistently claude/*-yxMLM). P2 is explicitly Codex (732bda6b). T-067 is not in my registry work-list. So this is another agent's task β€” most plausibly the receipt/expense subsystem cloud agent (the doc already flags feedback_honor_system_design + the writer/schema as "cloud agent's area"). Commit authorship can't disambiguate (all Claude agents share one git author), but the branch namespace + the explicit multi-agent phases are decisive enough.
  • Disposition recommendation β€” FINISH, don't drop. Phase 0/1 are merged (to nightly) and the AI prompt now lists all 18 categories, but the output schema still only accepts the receipt slugs, so any non-receipt category (cloud_services, audio_software, productivity, …) silently falls back to other β€” i.e. the half-wired state actively defeats the task's purpose. Route the remaining schema/type/writer/reader/Firestore cutover to whoever owns the Telegram receipt writer + extraction pipeline. Offer: the pure-accounting slice (the EXPENSE_CATEGORY_TO_GL map correctness β€” an Accounting (Infrastructure) concern) I'm happy to review or take, but the receipt-pipeline writer/schema is not mine to drive unilaterally.
  • Also note: Phase 0/1 are on origin/nightly only, not main; under the current main 🟒 Β· nightly πŸ”΄ signal, the finishing agent will need to decide how that reaches main.

2026-07-01 β€” FINISHED + owner-assigned to Accounting (Infrastructure) β†’ done

  • βœ… Attestation (Accounting (Infrastructure)): read AGENTS.md; the owner assigned this orphaned task to me to finish ("since you state it as an accounting related issue, let's proceed to have you handle it … finish the task entirely"). Branch signal is now main 🟑🟑 (Vercel, manual) Β· nightly πŸ”΄ β†’ committed to main only; no auto-deploy (Vercel deploys are manual). Mirrored to taskboard.
  • Source: Accounting (Infrastructure) Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • Owner direction (verbatim, 2026-07-01): "since you state it as an accounting related issue, let's proceed to have you handle it." β†’ "Proceed with your plan and finish the task entirely."
  • Correction to the record: Phase 0/1 had already reached main (not nightly-only as the frontmatter said), and the "REMAINING" schema cutover was already done via later aliasing work β€” a read-only scoping sweep of ~13 consumers confirmed ~95% was complete: the AI schema is z.enum(EXPENSE_CATEGORIES), Drive folder routing already uses EXPENSE_CATEGORY_LABEL (all 18 slugs β†’ real folders, no mis-route), and the Telegram cat tap-handler already coerces via coerceExpenseCategory. So a Telegram SaaS receipt already classifies + files as cloud_services.
  • What I changed to close the last gaps:
  • lib/telegram/botMenu.ts (categoryKeyboard) β€” the ONE human-facing picker still bound to the legacy 10-slug set now offers the unified 18 (EXPENSE_CATEGORIES + EXPENSE_CATEGORY_LABEL). This was the only functional gap: a human could not manually re-file a receipt as cloud_services etc. via Telegram (the AI + the tap-handler already accepted it).
  • pages/api/admin/migrate-to-drive.ts β€” the last consumer narrowing to the legacy VendorCategory enum widened to isExpenseCategory / ExpenseCategory (every legacy value is a unified member, so all previously-valid docs still pass).
  • Doc-comments corrected in pages/api/receipts/index.ts + pages/api/records/vendor-invoices/upload.ts (stale legacy enum lists β†’ the unified taxonomy).
  • Kept as compat (deliberately NOT deleted): lib/telegram/categories.ts, lib/accounting/vendorCategories.ts, lib/accounting/receiptCategoryToGl.ts β€” the unified module's coerceExpenseCategory + dev-time GL-drift assertion depend on them to normalise old stored values.
  • Deliberately SKIPPED (optional cosmetic, offered separately): ~9 consumers import the unified module under legacy aliases (EXPENSE_CATEGORIES as RECEIPT_CATEGORIES, …). They already point at the unified module (behaviour is correct); unwinding the aliases is churn across the live receipt pipeline for zero behaviour change β€” the same low-risk technique the original author used. Left as a readability-only follow-up if the owner wants it.
  • Verification: npx tsc --noEmit clean; vitest 204/204 across __tests__/lib/telegram + __tests__/lib/accounting + __tests__/components/receipts (incl. the legacy categories.test.ts, which still passes β€” the compat backbone is untouched).
  • Commit(s): (this commit) on main.
  • Blast radius: the Telegram receipt category picker now lists 18 (was 10); one admin-backfill validator + two doc-comments. No Firestore migration, no data change, no GL-map change. Touches the receipt subsystem (Telegram bot + a records API) β€” owner pre-authorised ("I won't be surprised"). Deploy is manual (Vercel) β€” pushed to main, not auto-deployed.