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 enumsReceiptCategory+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: receiptstationary β office(both 6300); the twoothers unify.receiptCategoryToExpense/vendorCategoryToExpenseadapters +coerceExpenseCategoryfor read-time normalisation; dev-time GL-drift assertion. (Merged #738.) - Codex P2 (732bda6b) β
coerceExpenseCategorycoerces labels + synonyms, not just slugs. - Phase 1 prep β AI prompt (4cb42c7c, merged #740) β
lib/ai/expensePrompt.tsbuilds a prompt fragment listing all 18 unified categories + synonym hints, spliced into the Telegram receipt processor's category section (replaces receipt-onlyRECEIPT_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 toothervia the existing schema-rejection fallback. The schema + type cutover to the full 18-leaf set is NOT yet landed.~~ β done:receiptProcessor.tsusesz.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;
coerceExpenseCategorynormalises at read).
Related¶
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 (noSource: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-enumand Phase 1 via PR #740 branchclaude/expense-prompt-unifiedβ neither carries this session's-yxMLMbranch namespace (my branches are consistentlyclaude/*-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 flagsfeedback_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 tootherβ 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 (theEXPENSE_CATEGORY_TO_GLmap 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/nightlyonly, notmain; under the currentmainπ’ Β·nightlyπ΄ signal, the finishing agent will need to decide how that reachesmain.
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 nowmainπ‘π‘ (Vercel, manual) Β·nightlyπ΄ β committed tomainonly; no auto-deploy (Vercel deploys are manual). Mirrored totaskboard. - 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 isz.enum(EXPENSE_CATEGORIES), Drive folder routing already usesEXPENSE_CATEGORY_LABEL(all 18 slugs β real folders, no mis-route), and the Telegramcattap-handler already coerces viacoerceExpenseCategory. So a Telegram SaaS receipt already classifies + files ascloud_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 ascloud_servicesetc. via Telegram (the AI + the tap-handler already accepted it).pages/api/admin/migrate-to-drive.tsβ the last consumer narrowing to the legacyVendorCategoryenum widened toisExpenseCategory/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'scoerceExpenseCategory+ 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 --noEmitclean;vitest204/204 across__tests__/lib/telegram+__tests__/lib/accounting+__tests__/components/receipts(incl. the legacycategories.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.