Expenses Manager
Expenses Manager
Component File: src/features/accounting/components/ExpensesManager.tsx Route / navigation: Path /fund-accounting, Zustand accountingTool = expenses. See 00-ACCOUNTING-HUB.md. Access Level: Parent Org (read_write), Fund User (read_write) Last Updated: April 13, 2026
> Data Layer: This component uses fetchExpensesPage() (RPC-based, SECURITY DEFINER) from src/lib/db/expenses.ts for paginated data loading, plus createExpense(), updateExpenseStatus(), requestExpenseRevision(), createJournalEntry(), updateExpensePaymentGroup(), and `recodeExpenseCategoryForFinance()` (finance-only category / GL recode) from src/lib/db/expenses.ts. There is no active REST expenses service behind this screen.
Recent Fixes
Batch payment rollback + approval metadata hardening (Apr 13, 2026)
- P0 — Batch pay failure rollback:
handleConfirmBatchPaymentnow tracks created payment JEs and paid expense IDs, then runs compensating rollback incatch(clear payment-group link, revert paid expenses to approved, void created JEs) before surfacing failure. - P0 — Duplicate-submit guard: Batch pay now uses an in-flight ref guard so rapid re-clicks cannot trigger concurrent payment postings.
- P0 — Partial rollback warning: If any compensating step fails, UI now emits a high-signal warning to review Journal Entries and paid expenses before retrying.
- P1 — Unapprove audit integrity:
updateExpenseStatus(..., 'pending')and rollback-to-approved paths now clear approval/payment linkage metadata (approved_*,paid_*,payment_group_id,payment_reference) to prevent stale state after reversions. - P1 — Pending review resiliency:
fetchPendingReviewExpensesPage()now merges bothpendingandrejectedpages so “needs revision” rows stay visible even if RPC status handling changes. - P1 — Expense create org fallback:
createExpense()now accepts either an entity slug or a raw organization UUID (entityIdfallback), which unblocks budget-item expense creation paths that originate fromorganization_id. - P1 — Schema parity: Added
supabase/migrations/20260614124500_sync_expenses_status_check_with_voided.sqlsoexpenses_status_checkalways includes'voided'in environments built from tracked Supabase migrations.
General Ledger receipt trace for expense payments (Apr 13, 2026)
- Expense batch pay now writes a stable journal-entry link: each fund-level payment JE is created with
reference_id = expense-payment-group:{paymentGroupId}. - Shared helpers in
src/lib/db/expenses.tsnow own this mapping and lookup: buildExpensePaymentReferenceId(paymentGroupId)extractExpensePaymentGroupIdFromReferenceId(referenceId)fetchExpensePaymentReceiptsByGroup(paymentGroupId)- General Ledger transaction detail (
JournalEntryEditDrawer) can now resolve that payment-group link and show the source reimbursement expense receipts directly from the transaction drawer.
Fast vendor autocomplete standard (Apr 9, 2026)
- Issue - Vendor names in Fund Accounting search boxes could take several seconds to appear on large orgs because the old autocomplete helper preloaded a huge candidate set.
- Fix -
VendorSearchnow uses the RPC `search_vendors_autocomplete` throughsearchVendors()insrc/lib/db/vendors.ts. - Shared search behavior - Shared autocompletes now use a 2-character minimum, 150ms debounce, and 30s stale cache so repeated lookups feel immediate.
- Scope - Applies to the vendor picker in Add/Edit Expense plus other Fund Accounting surfaces that reuse
VendorSearch.
Calculator-style amount entry (Apr 9, 2026)
- The Add Expense amount field is part of the live fund-accounting calculator-input rollout.
- Users can enter arithmetic such as
100+25.50directly in the amount box; the value is committed as a number on blur orEnter. - Scope note: this applies to the accounting-hub Expenses Manager only, not tools-only reimbursement or other non-accounting screens.
Finance recode: change expense category after submission (Apr 6, 2026)
P1 — Bookkeepers can correct miscoded categories without sending expenses back for revision
- Who: Users with `canWriteAccounting` (parent org accounting roles). Independent of expense approval (
canApproveExpenses); finance-only users can recode from the row ⋮ menu when they cannot approve. - Where: Pending, Approved, and Paid tabs (grouped and flat paid views). Tools reimbursements and other intake surfaces keep category read-only after submit by design.
- Unpaid (pending / approved): Updates
expenses.categoryonly viaupdateExpense(). - Supported statuses:
pending,approved, andpaidonly (other DB statuses such asreimbursedare rejected byrecodeExpenseCategoryForFinance()so category-only updates cannot desync from the ledger). - Paid: Finds the posted payment journal expense debit line (match: fund
organization_id, old expense account, rounded debit amount, description"{vendor} - {description}",transaction_date= local payment date frompaid_at, not voided, not reconciled / clearing) and updatesjournal_entry_lines.account_id, then updatesexpenses.category. Respects `get_period_lock_date` for that fund (same rule class as Journal Entry edits). If the expense row update fails after the line moves, the code best-effort reverts the journal line to the old account. - i18n: The recode dialog and toasts use the
expensesnamespace with keys such asdialogs.recodeTitle,recode.save, andtoast.recodeSuccess(no extraexpenses.prefix int()calls). - Limitations: Recode assumes the payment was posted through Expenses Manager (matching JE line text). Per-row and batch Mark Paid both use the same expense debit description pattern (
{vendor} - {description}). Payments recorded only in Journal Entries or with altered line descriptions must be fixed in Journal Entries. Ambiguous matches (0 or 2+ lines) abort with an error. - Tests:
src/lib/expenseFinanceRecode.test.ts(Vitest).
Files: ExpensesManager.tsx, src/lib/db/expenses.ts, src/lib/expenseFinanceRecode.test.ts, 7 × expenses.json, 06-EXPENSES-MANAGER.md (this changelog), DEVELOPER-PLAYBOOK.md §20.2.
Vendor alignment for 1099 prep: inline create, vendor sort/filter, and unlinked payee cleanup (Apr 2, 2026)
P1 — Brought Expense intake/reporting closer to donor CRM workflow for vendor-linked accounting
- Added a shared `AddVendorDialog` (patterned after Add Donor) and wired it into Add Expense:
- New Add New Vendor action next to
VendorSearchin the expense dialog. - Dialog captures nonprofit + legal vendor name (required) and encourages 1099-critical fields (tax ID + mailing address + contact).
- After creation, the form auto-fills both
vendortext andvendorIdso the new expense writesvendor_id. - Added Expenses grid controls for vendor-focused review:
- Global vendor filter input for Pending/Approved/Paid tab views.
- Sort options: date newest, date oldest, vendor A→Z, vendor Z→A.
- Sorting applies to grouped rows and flat paid rows.
- Added Unlinked Payees Cleanup panel in the Paid tab:
- Shows aggregated free-text payees where
vendor_id IS NULL(count, total, latest date). - Allows mapping each payee to an existing vendor and bulk-linking matching paid expenses.
- Intended for post-intake normalization before 1099 exports.
Data-layer additions (`src/lib/db/expenses.ts`)
fetchUnlinkedExpensePayees(entityId, { status, search, limit })linkUnlinkedExpensePayeeToVendor({ entityId, payee, vendorId, status })updateExpense/updateDraftExpensenow supportvendorIdupdates throughvendor_id.
Files changed:
src/features/accounting/components/ExpensesManager.tsxsrc/components/shared/AddVendorDialog.tsx(new)src/lib/db/expenses.tsdocumentation/pages/accounting/06-EXPENSES-MANAGER.md(this changelog)documentation/frontend/DEVELOPER-PLAYBOOK.md(§20 entry)
Add Expense — Scan Receipt aligned with Tools capture UX (Mar 27, 2026)
- Copy: The Scan Receipt tab in the Add Expense dialog uses the same shared strings as Reimbursements and Check Deposit capture: **
common.json→captureUpload.*(upload image or PDF, format line, choose file / take photo, OCR note, drag-and-drop hint, drop overlay). Manual Entry vs Scan Receipt pill labels stay underexpenses.form.***. - PDF: Choosing a PDF rasterizes page 1 with `pdfClient` (
loadPdfDocumentFromFile+renderPdfPageToDataUrl) then runs the same `scanDocument` OCR path as raster images. Failures toast `expenses.form.pdfReadFailed` (all 7 locales). - UX: Drag-leave handling matches the other capture surfaces (avoid flicker when moving over children).
- Files:
ExpensesManager.tsx,src/i18n/locales/*/common.json,src/i18n/locales/*/expenses.json,DEVELOPER-PLAYBOOK.md§20, this doc.
Row click → receipt preview, ⋮ row actions menu (Mar 25, 2026)
UX (Drafts, Pending, Approved, Paid — grouped + flat)
- Removed the dedicated Receipt column with the eye button. When a row has
receiptUrl, click the row (or Enter / Space when focused) opens the sharedReceiptViewer; rows without a receipt show an informational toast (toast.noReceiptAttached). - ⋮ opens a
DropdownMenu: Drafts — Submit, Delete; Pending (approver) — Approve, Request revision, Delete; Approved (approver) — Mark paid, Undo. Non-approvers still see read-only pending copy where applicable. Approved checkbox column usesstopPropagationso toggling selection does not open the viewer. - Pending table: first column is always Status (pending vs needs-revision badge); previously the first cell mixed revision badge with the receipt control.
Files changed: ExpensesManager.tsx, 7 × expenses.json (labels.clickRowViewReceipt, labels.rowActionsMenu, toast.noReceiptAttached), this doc.
Mark Paid Confirmation Metadata (Mar 24, 2026)
P1 — Mark Paid flow now captures optional JE write context
- The existing payment popup opened from per-row
Mark Paidnow presents explicit single-expense confirmation copy and captures three optional metadata inputs: - Payee Line
- Check #
- Memo
- The optional values are now included in the payment journal-entry write description (header + bank credit line context) so the paid item is easier to audit and reconcile later.
- Check number is also persisted to the paid expense status update path and used as fallback payment reference when no separate reference is provided.
- Added i18n coverage for the new single-payment confirmation title/description and optional field labels/placeholders across all Expenses locales.
Files involved:
src/features/accounting/components/ExpensesManager.tsxsrc/i18n/locales/en/expenses.jsonsrc/i18n/locales/es/expenses.jsonsrc/i18n/locales/fr/expenses.jsonsrc/i18n/locales/de/expenses.jsonsrc/i18n/locales/zh/expenses.jsonsrc/i18n/locales/th/expenses.jsonsrc/i18n/locales/ne/expenses.json
Vendor / Payee Linkage Clarification (Mar 22, 2026)
P1 — Vendor assignment consistency for expense intake
- Expense creation supports two valid paths:
- Linked vendor: user selects an existing vendor from
VendorSearch, andcreateExpense()stores bothvendortext andvendor_id. - Custom payee: user types a free-text payee/vendor, and
createExpense()storesvendortext withvendor_id = null. - This is intentional: not every payee should become a vendor record, but vendor-linked reporting relies on
vendor_idwhen present. - Rule: When users intend to attach an expense to a vendor profile, they must pick from the search results (or create the vendor first), not only type the name.
Files involved:
src/features/accounting/components/ExpensesManager.tsx(VendorSearchin Add Expense)src/components/shared/VendorSearch.tsx(selected vendor ID vs custom text behavior)src/lib/db/expenses.ts(createExpense()storesvendor_id)
Server-Side Pagination + RPC + Storage Fix (Mar 17, 2026)
P0 — Replaced client-side 90-day rolling window with server-side RPC pagination
- Old
fetchExpenses()pulled up to 5,000 rows in one query, applied a 90-day rolling window client-side, and resolved submitter/approver names with a 2nd query (2 HTTP round-trips, all data in memory). - New
get_expenses_pageSECURITY DEFINER RPC: single HTTP round-trip withget_user_org_tree_ids()auth, server-side pagination (50 rows/page default), multi-word search, sort options, and joined submitter/approver/paidBy names. - RPC returns
summarywithpending_count,pending_total,approved_count,approved_total,paid_count,paid_total— used for tab pill counts and summary cards (accurate across all pages, not just current page). - Both ExpensesManager (parent org) and ReimbursementsManager (fund user) now use per-tab
fetchExpensesPage()queries withkeepPreviousDatafor smooth transitions. - Paid tab search is now server-side (in the RPC), not client-side.
PaginationControlscomponent added to all tabs in both components.- 90-day rolling window removed — pagination replaces it. All expenses are now visible, most recent first.
P0 — Fixed `meeting-recordings` storage policy 42P17 risk
"Users can view recordings for their org meetings"SELECT policy used qualifiedobjects.name— same bug class as Mar 10 profile photo fix and Mar 13 receipts fix.- Changed to unqualified
name. Eliminates the last known 42P17 risk vector across all storage buckets.
P2 — PaginationControls i18n
- Shared
PaginationControlscomponent now usest()for Previous/Next/Page X of Y. pagination.*keys added to all 6 locale files (common.json).
DB Migrations:
create_get_expenses_page_rpc— new SECURITY DEFINER RPCfix_meeting_recordings_qualified_objects_name— storage policy fix
Files Changed:
src/lib/db/expenses.ts—fetchExpensesPage(),PaginatedExpensesResult,ExpenseSummaryStatsinterfacesExpensesManager.tsx— per-tab paginated queries,PaginationControls, summary statsReimbursementsManager.tsx— per-tab paginated queries,PaginationControls, summary statssrc/components/shared/PaginationControls.tsx— i18n wrapping- 6 locale files (
common.json) —pagination.*keys 06-EXPENSES-MANAGER.md— this changelogDEVELOPER-PLAYBOOK.md— §20 entry
Purpose Linking Relabel (Mar 17, 2026)
P1 — "Link to Fundraiser" label in Add Expense dialog was misleading
- The Add Expense dialog had a "Link to Fundraiser (optional)" field that links expenses to the Purpose Manager system (
purposestable). Users interpreted "fundraiser" as a campaign/event, not a program area designation. - The underlying
purposeIdfield and database schema are correct — only the user-facing labels were wrong. - Fix: Relabeled all 4 related i18n keys from "Fundraiser" to "Purpose" across all 7 locales (en/es/fr/de/zh/th/ne). Updated code comments to reference "purpose" instead of "fundraiser."
- RULE: The expense `purposeId` field links to the Purpose Manager system. Always use "Purpose" terminology in user-facing labels — never "Fundraiser."
Files Changed:
ExpensesManager.tsx— 3 code comments + 4 i18n fallback strings- 7 locale files (
expenses.json) — 4 keys each 06-EXPENSES-MANAGER.md— this changelogDEVELOPER-PLAYBOOK.md— §20 bug-fix entry
Receipt 42P17 Fix + Paid Tab Layout + Cleanup (Mar 13, 2026)
P0 — Receipt images fail to load: `StorageApiError: database error, code: 42P17`
receiptsbucket had two SELECT policies: blanket"Public read access"(no auth) AND org-scoped"Users can view receipts from their org"(withis_storage_folder_in_org_tree()). Postgres OR's all matching policies — duringcreateSignedUrl(), recursive policy evaluation caused 42P17 infinite recursion.- Same bug class as the Mar 10
org-logosfix (DEVELOPER-PLAYBOOK §20.1). - Additionally, the blanket public SELECT was a security issue — receipts contain sensitive expense data (vendor names, amounts, employee details).
- DB Fix: Dropped
"Public read access"policy. Kept authenticated org-scoped policy as sole SELECT. - Also dropped duplicate
"Users can view QR codes from own org folder"SELECT onqr-codesbucket (same pattern — kept the public one since QR codes are shared openly). - 162 expenses with storage-path receipts were affected. All now load correctly.
P1 — Paid tab table layout: excessive gap between Description and Amount
- Description column in Paid grouped and flat views had no explicit
w-[200px]constraint, causing it to absorb all remaining table width and create a large visual gap before the Amount column. - Fix: Added
w-[200px]to Description<TableHead>in both Paid grouped and Paid flat views (matching Pending/Approved tabs).
P2 — Export status column: hardcoded English
- Status column in CSV/Excel/PDF export used
exp.status.charAt(0).toUpperCase() + exp.status.slice(1)— hardcoded English capitalization. - Fix: Wrapped in
t('expenses.expenseStatus.${status}')with English fallback.
P2 — Purposes query missing §11 guard
purposesqueryenabledwas missingisMappingInitialized(). Added.
P2 — Dead `'reimbursed'` status removed from `Expense` interface
- No mutation path ever sets
status = 'reimbursed'. Removed from type union, rolling-window filter branch, and status cast.
Files Changed:
- DB migration:
fix_receipts_qrcodes_42p17_duplicate_select_policies ExpensesManager.tsx— Paid tab Description column widths, export i18n, purposes query guardsrc/lib/db/expenses.ts— dead'reimbursed'status cleanup06-EXPENSES-MANAGER.md— this changelogDEVELOPER-PLAYBOOK.md— §20 bug-fix entry
Paid Tab Column Restoration + Export Audit Trail (Mar 13, 2026)
P0 — Paid Tab Grouped View: Missing Submitted By, Paid By, Check # columns
- The Mar 5 tranched payment rebuild stripped three columns from the Paid grouped view. Only Receipt, Date, Vendor, Description, Amount, Category, and Paid Date survived.
- All three columns were present in the flat view but missing in the grouped view. Data (
submittedBy,paidByName,checkNumber/paymentReference) was already fetched by the DB layer and on theManualExpenseinterface — just never rendered. - Fix: Restored Submitted By, Paid By, and Check # columns to the Paid grouped view table. Widened
min-wfrom900pxto1100pxto accommodate.
P0 — Paid Tab Flat View: Missing Paid By column
- The flat view had Submitted By and Check # but no Paid By column. Added between Category and Nonprofit.
P2 — Export: Full audit trail columns
- Export previously included: Date, Vendor, Description, Amount, Category, Nonprofit, Status, Submitted By, Check #.
- Added: Approved By, Paid By, Paid Date — provides complete audit trail for accounting review.
P1 — §22 compliance: `.split('T')[0]` in `expenses.ts`
- Fixed 5 occurrences of
.split('T')[0]date parsing →toLocalDateString(new Date(timestamp))infetchExpenses()andfetchDraftExpenses(). - Removed dead
'reimbursed'status fromfetchExpenses()type signature (never used by UI or any mutation path).
RULE: Every expense table view (Pending, Approved, Paid grouped, Paid flat) MUST show Submitted By. Paid views MUST also show Paid By. Do NOT strip columns during table rebuilds without verifying parity with other views.
Files Changed:
ExpensesManager.tsx— Paid grouped view columns, Paid flat view columns, export headers/datasrc/lib/db/expenses.ts— §22 date fixes, dead status removal06-EXPENSES-MANAGER.md— this changelog
Batch Approve Hardening + Approved-Tab Pay All (Mar 6, 2026)
P0: Batch Approve Safety + Partial Failure Visibility
confirmBatchApprove()now uses an in-flight guard plus a disabled confirm action to prevent double-submit races- Batch approve now processes each pending expense individually and continues after item-level failures instead of collapsing into one generic result
- Refetch still runs after batch approval, and the toast now clearly reports full success, partial success, or no approvals completed
P1: Approved Tab "Pay All"
- Each Approved-tab fund header now includes a
Pay Allaction Pay Allauto-selects all approved expenses for that fund and opens the same batch payment dialog used byPay Selected- Single-row
Mark Paidand group-levelPay Allnow share the same payment flow, so batch posting behavior stays consistent
P1: i18n Coverage
- Added locale coverage for the remaining approval/payment/revision toasts and notifications
- Added locale keys for
Pay All, paid-search empty states, expense-count labels, and the unknown-fund fallback label
P2: UI Cleanup
- Replaced the inline empty states across Pending, Approved, Paid, and Paid-search no-results paths with the shared
EmptyStatecomponent - Removed dead batch-view scaffolding that no longer drives any rendered UI
Tranched Payment Consolidation + Full Audit (Mar 5, 2026)
P0: Tranched (Compound) JE for All Payments
- Per-row "Mark Paid" button rewired to use batch payment flow — auto-selects the single expense and opens the batch dialog pre-filled with vendor name
- All payments now create ONE compound JE per fund (N expense debit lines + 1 bank credit), matching how checks/ACH actually leave the bank statement
- Old individual-JE path (
handleConfirmPayment) removed entirely - DB Backfill: 11 multi-expense tranches (58 expenses across 8 orgs) had their individual 2-line JEs voided and replaced with compound entries. 90 old lines voided, 69 new compound lines created, net GL impact $0.00. All 70 paid expenses assigned
payment_group_id. Migration:20260305_consolidate_expense_jes_into_tranches.sql
P0: Paid Tab Grouped View — Fund-Based Hierarchy
- Replaced broken
payment_group_id-based grouping with fund-based hierarchy (matching Pending/Approved tabs) - Grouped/Flat toggle now uses ReconciliationManager single-button pattern (
<Layers>icon,variantflip) - Receipt Eye button added to all paid expense rows (grouped + flat)
P1: i18n Sweep
- ~35 hardcoded English strings wrapped with
t()(Breadcrumb, CardTitle, Add Expense dialog labels/placeholders, Export dialog, budget context) - ~45 new keys added to all 6 locale files (en/es/fr/de/zh/th)
P2: Code Cleanup
- Removed dead
PaymentGroupinterface,paymentGroups/filteredPaymentGroupsmemos, old single-payment Dialog JSX - Moved
ClipboardListinto main lucide-react import block, removed unusedListimport - Export: raw number for Amount (not formatCurrency string) +
columnFormatsfor XLSX + i18n headers
Schema Changes:
expenses.payment_group_id(UUID, nullable) — groups expenses in same bank transactionexpenses.payment_reference(VARCHAR, nullable) — check #, Gusto run ID, ACH ref- Partial index:
idx_expenses_payment_group_id - Migration:
20260305_add_expense_payment_group_columns.sql - Migration:
20260305_consolidate_expense_jes_into_tranches.sql(backfill)
Files Changed:
ExpensesManager.tsx— tranched payment rewire, Paid tab rebuild, toggle, receipt Eye, i18n, export fix, code cleanup- 6 locale files (
expenses.json) — ~45 new keys each 06-EXPENSES-MANAGER.md— this changelog
Batch Reimbursement Payment + Bug Fixes (Mar 5, 2026 — earlier)
New Feature: Batch Payment ("Pay Selected")
- Select multiple approved expenses via checkboxes on Approved tab
- "Pay Selected" bar shows count + total; opens batch payment dialog
- Dialog: payee name, bank account, payment reference, fund breakdown, expense list preview
- Creates one compound JE per fund (N debit lines + 1 credit), stamps all expenses with shared
payment_group_id+payment_reference - Sends
expense_paidnotification to each submitter
Expense Approval Trigger Fix (Feb 2, 2026)
- Problem: Parent org admins could not approve expenses - error:
record "new" has no field "submitted_by" - Root Cause: The
queue_expense_notification()database trigger referencedNEW.submitted_bybut theexpensestable column is actuallysubmitted_by_user_id - Solution: Updated the trigger function to use the correct column name
submitted_by_user_id - Migration:
20260202_fix_expense_notification_trigger_column.sql - Affected Users: All parent_org admins attempting to approve/reject expenses
- Technical: The trigger fires on
AFTER INSERT OR UPDATE OF status ON expenses- any status change from 'pending' to 'approved'/'rejected' would fail
Entity Filtering Fix - UUID-Based Matching (Jan 15, 2026)
- Problem: Expenses submitted via Reimbursements were not appearing in the Expenses Manager pending tab, even though they existed in the database
- Root Cause: The frontend filter compared
exp.entityId(slug) withselectedEntity(slug), butgetEntityId()could returnnullif called before entity mapping was initialized, causing the filter to fail silently - Solution:
- Added
organizationId(raw UUID) to theExpenseinterface and data mapping - Updated the filter to use UUID comparison via
getActualOrgId()instead of slug comparison - This eliminates dependency on entity mapping initialization timing
- Technical Details:
Expenseinterface now includesorganizationId: stringalongsideentityId- Filter logic:
exp.organizationId === getActualOrgId(selectedEntity)instead ofexp.entityId === selectedEntity - Import added:
getActualOrgIdfromentityMapping.ts - Pattern: When filtering data by organization, always use UUID comparison (
organizationId) rather than slug comparison (entityId) to avoid timing issues with the entity mapping system
Database Schema Fix & Approver Name (Jan 13, 2026)
- Problem 1: The
expensestable was missing thepaid_atcolumn, which would cause "Mark as Paid" to fail - Solution: Added
paid_at TIMESTAMPcolumn to theexpensestable via migration - Problem 2: The "Approved By" field showed hardcoded "Admin User" instead of actual approver name
- Solution: Updated
handleApproveto useuseAuthhook and display actual user's name - Technical: Migration
add_paid_at_to_expensesapplied; AddeduseAuthimport anduserdestructuring
Revision Workflow & Rejected Expenses Visibility (Jan 12, 2026)
- Problem: The "Request Revision" button was missing from the pending expenses table, and rejected expenses were not visible in the admin UI
- Solution:
- Added "Revise" button next to "Approve" in the pending expenses table
- Updated pending filter to include both
pendingandrejectedstatuses - Added visual distinction for rejected expenses (amber background, "Revision" badge)
- Added revision notes display in the description column
- Behavior:
- Admins can now request revisions with notes
- Rejected expenses appear in the Pending tab with amber styling
- Revision notes are displayed inline for rejected expenses
- Technical: Uses existing
handleRequestRevisionfunction and revision dialog
Submitter Name Display (Jan 12, 2026)
- Problem: The "Submitted By" column showed user IDs instead of names
- Solution: Updated
fetchExpensesindb.tsto join with theuserstable and extractfirst_nameandlast_name - Technical: Added
users:employee_id(first_name, last_name)to the Supabase select query
Parent Org Filtering (Dec 31, 2025)
- Problem: Users could select the parent parent_org organization when adding expenses, causing RLS policy violations since expenses should be submitted at the fund level
- Solution: Added
expenseEligibleEntitiesfilter to exclude parent_org type organizations from the nonprofit dropdown - Behavior:
- In multi-org setups, the parent parent_org org is hidden from the dropdown
- In single-org setups (standalone nonprofit), the org is shown regardless of type
- The 'all' pseudo-entity is always excluded
- Technical: Uses
useMemoto filter entities by type, with special handling for single-org scenarios
Overview
The Expenses Manager is the accounting approval and payment surface for both:
1. Direct organizational expenses submitted directly in this screen 2. Reimbursement-backed expenses that flow into the same approval/payment lifecycle
Intake UX alignment: Add Expense (including Scan Receipt) reuses the same capture/OCR/PDF patterns as [Check Deposit](./08-CHECK-DEPOSIT-MANAGER.md) and [Reimbursements](./07-REIMBURSEMENTS-MANAGER.md). End-to-end batch staging with Add to batch lives on [Regular Deposit](./10-REGULAR-DEPOSIT-MANAGER.md) and the reimbursement Submit tab Complete step; this screen’s add dialog is typically single-expense submit, but the shared capture stack keeps behavior and copy consistent across those flows.
The screen is authoritative for moving expenses through:
pending → approved → paid
It is also the point where approved expenses are converted into the compound journal entries that bank reconciliation matches against.
Current Workflow
Pending Tab
- Grouped by fund/entity
- Supports per-row
Approve,Request Revision, andDelete - Supports fund-level
Approve All - Rejected / revision-requested items remain visible in Pending with inline revision notes
Approved Tab
- Grouped by fund/entity with checkbox selection
- Supports cross-fund
Pay Selected - Supports fund-level
Pay All - Per-row
Mark Paidreuses the exact same payment dialog asPay Selectedand now includes optional Payee Line / Check # / Memo capture before final payment confirmation Undoreturns an approved expense topending
Paid Tab
- Supports grouped and flat views
- Supports text search across payee/vendor/amount/reference/check number/fund
- Uses shared empty-state handling for both no-data and no-search-results cases
Payment Posting and Reconciliation Behavior
When approved expenses are paid, handleConfirmBatchPayment() groups the selected expenses by fund/entity and creates one compound journal entry per fund.
For each fund-level payment journal entry:
- One debit line is created per expense against that expense account
- One balancing credit line is created against the selected bank account for the fund total
After posting:
- Each expense is updated to
paid - Each expense is stamped with a shared
payment_group_id - The optional
payment_referenceis saved back to every expense in the batch - Each payment JE includes
reference_id = expense-payment-group:{paymentGroupId}so GL transaction detail can load linked reimbursement receipts - Optional Payee Line, Check #, and Memo entered in the payment dialog are included in the journal-entry write context for the paid item(s)
- Submitters receive
expense_paidnotifications
This is the behavior bank reconciliation depends on:
- The bank side of the disbursement is represented as a single cash/bank credit per fund-level batch JE
- The JE description uses the payee name and/or payment reference, which is what makes the batch recognizable during reconciliation
- A multi-fund payment intentionally becomes multiple fund-specific compound JEs so each fund stays balanced while still matching the real-world bank disbursement structure
Approval Behavior
Batch approval is intentionally resilient, not all-or-nothing.
Current confirmBatchApprove() behavior:
- Uses an in-flight guard to prevent double-submit races
- Disables the dialog action while processing
- Attempts each pending expense individually
- Continues after item-level failures
- Refetches after the batch finishes
- Reports full success, partial success, or zero-success via toast
Current Data Layer
The authoritative helpers used by this screen are:
fetchExpenses()createExpense()updateExpenseStatus()requestExpenseRevision()requestBatchRevision()createJournalEntry()updateExpensePaymentGroup()
There is no separate internal REST expenses service behind this feature; persistence uses Supabase clients, RPC, and related edge functions.
Key UI and UX Details
- Add Expense dialog requires vendor, description, positive amount, expense account, and entity
- Scan Receipt tab (Add Expense): same `common.captureUpload` i18n as Reimbursements and Check Deposit capture; PDF is rasterized to page 1 via
pdfClientbefore `scanDocument` OCR; submission still stores a JPEG through the normal receipt upload path - Fund/entity selection excludes
parent_orgrows throughuseExpenseEligibleEntities() - Budget and purpose linking are available in the add-expense flow
- Receipt viewing is available anywhere a
receiptUrlexists - Shared
EmptyStateis used across empty Pending, Approved, Paid, and Paid-search states - Payee name is auto-filled only when the selected expenses resolve to a single submitter/vendor fallback; otherwise it is left blank for manual entry
Data Requirements
Core Expense Fields Used by This Screen
statusorganizationIdentityIdvendordescriptionamountdatesubmittedBy/submittedByUserIdapprovedBy/approvedAtpaidAt/paidByName/paidFromAccountIdcheckNumberreceiptUrlrevisionNotespaymentGroupIdpaymentReference
Current Mutations
- Create direct expense
- Approve single expense
- Approve a fund-level batch of pending expenses
- Request revision for a single expense or a grouped batch
- Move approved expense back to pending
- Pay one or more approved expenses through the shared batch-payment flow
- Delete expense
Authorization and Business Rules
canWriteAccountinggates expense creationcanApproveExpensesgates approve / request revision / pay / undo / delete actions- Only
pendingexpenses are eligible for batch approve - Only
approvedexpenses are eligible for payment - Single-row
Mark Paidintentionally routes through the batch-payment flow - Paid expenses are tracked for history and reconciliation, not edited in-place from this screen
Validation and Error Handling
Frontend Validation
- Vendor required
- Description required
- Amount must be greater than zero
- Expense account required
- Entity required
- Revision requests require notes
- Batch payment requires a bank account
Error / Loading Behavior
- Expenses table uses a skeleton while loading
- Add Expense button is disabled while accounts are still loading
- Batch approve shows a disabled confirm action while processing
- Partial batch-approve failures are surfaced instead of hidden behind one generic error
- Receipt loading failures are reported via toast without breaking the rest of the screen
Dependencies
Internal
src/lib/db.ts/src/lib/db/*src/lib/pdfClient.ts— PDF page raster for Add Expense Scan Receiptsrc/lib/ocrUtils.ts—scanDocumentfor receipt OCR- React Query
- Shared UI components (
EmptyState,PillTabs,ReceiptViewer,VendorSearch,CurrencyInput,SimpleExportDialog)
External
lucide-reactsonner
Related Documentation
- 09-RECONCILIATION-MANAGER.md - How compound expense payments appear during bank reconciliation
- 07-REIMBURSEMENTS-MANAGER.md - Reimbursement intake side of the same lifecycle
- 04-GENERAL-LEDGER.md - Where expense payment journal entries appear
- 03-CHART-OF-ACCOUNTS.md - Expense account selection
Synced from IFMmvp-Frontend documentation: pages/accounting/06-EXPENSES-MANAGER.md
Ready to Get Started?
See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.
Questions? Email us at steven@getalignmint.org