Skip to main content

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: handleConfirmBatchPayment now tracks created payment JEs and paid expense IDs, then runs compensating rollback in catch (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 both pending and rejected pages 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 (entityId fallback), which unblocks budget-item expense creation paths that originate from organization_id.
  • P1 — Schema parity: Added supabase/migrations/20260614124500_sync_expenses_status_check_with_voided.sql so expenses_status_check always 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.ts now 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 - VendorSearch now uses the RPC `search_vendors_autocomplete` through searchVendors() in src/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.50 directly in the amount box; the value is committed as a number on blur or Enter.
  • 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.category only via updateExpense().
  • Supported statuses: pending, approved, and paid only (other DB statuses such as reimbursed are rejected by recodeExpenseCategoryForFinance() 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 from paid_at, not voided, not reconciled / clearing) and updates journal_entry_lines.account_id, then updates expenses.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 expenses namespace with keys such as dialogs.recodeTitle, recode.save, and toast.recodeSuccess (no extra expenses. prefix in t() 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 VendorSearch in 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 vendor text and vendorId so the new expense writes vendor_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 / updateDraftExpense now support vendorId updates through vendor_id.

Files changed:

  • src/features/accounting/components/ExpensesManager.tsx
  • src/components/shared/AddVendorDialog.tsx (new)
  • src/lib/db/expenses.ts
  • documentation/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.jsoncaptureUpload.* (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 under expenses.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 shared ReceiptViewer; 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 uses stopPropagation so 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 Paid now 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.tsx
  • src/i18n/locales/en/expenses.json
  • src/i18n/locales/es/expenses.json
  • src/i18n/locales/fr/expenses.json
  • src/i18n/locales/de/expenses.json
  • src/i18n/locales/zh/expenses.json
  • src/i18n/locales/th/expenses.json
  • src/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, and createExpense() stores both vendor text and vendor_id.
  • Custom payee: user types a free-text payee/vendor, and createExpense() stores vendor text with vendor_id = null.
  • This is intentional: not every payee should become a vendor record, but vendor-linked reporting relies on vendor_id when 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 (VendorSearch in Add Expense)
  • src/components/shared/VendorSearch.tsx (selected vendor ID vs custom text behavior)
  • src/lib/db/expenses.ts (createExpense() stores vendor_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_page SECURITY DEFINER RPC: single HTTP round-trip with get_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 summary with pending_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 with keepPreviousData for smooth transitions.
  • Paid tab search is now server-side (in the RPC), not client-side.
  • PaginationControls component 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 qualified objects.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 PaginationControls component now uses t() 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 RPC
  • fix_meeting_recordings_qualified_objects_name — storage policy fix

Files Changed:

  • src/lib/db/expenses.tsfetchExpensesPage(), PaginatedExpensesResult, ExpenseSummaryStats interfaces
  • ExpensesManager.tsx — per-tab paginated queries, PaginationControls, summary stats
  • ReimbursementsManager.tsx — per-tab paginated queries, PaginationControls, summary stats
  • src/components/shared/PaginationControls.tsx — i18n wrapping
  • 6 locale files (common.json) — pagination.* keys
  • 06-EXPENSES-MANAGER.md — this changelog
  • DEVELOPER-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 (purposes table). Users interpreted "fundraiser" as a campaign/event, not a program area designation.
  • The underlying purposeId field 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 changelog
  • DEVELOPER-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`

  • receipts bucket had two SELECT policies: blanket "Public read access" (no auth) AND org-scoped "Users can view receipts from their org" (with is_storage_folder_in_org_tree()). Postgres OR's all matching policies — during createSignedUrl(), recursive policy evaluation caused 42P17 infinite recursion.
  • Same bug class as the Mar 10 org-logos fix (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 on qr-codes bucket (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

  • purposes query enabled was missing isMappingInitialized(). 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 guard
  • src/lib/db/expenses.ts — dead 'reimbursed' status cleanup
  • 06-EXPENSES-MANAGER.md — this changelog
  • DEVELOPER-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 the ManualExpense interface — just never rendered.
  • Fix: Restored Submitted By, Paid By, and Check # columns to the Paid grouped view table. Widened min-w from 900px to 1100px to 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)) in fetchExpenses() and fetchDraftExpenses().
  • Removed dead 'reimbursed' status from fetchExpenses() 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/data
  • src/lib/db/expenses.ts — §22 date fixes, dead status removal
  • 06-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 All action
  • Pay All auto-selects all approved expenses for that fund and opens the same batch payment dialog used by Pay Selected
  • Single-row Mark Paid and group-level Pay All now 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 EmptyState component
  • 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, variant flip)
  • 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 PaymentGroup interface, paymentGroups/filteredPaymentGroups memos, old single-payment Dialog JSX
  • Moved ClipboardList into main lucide-react import block, removed unused List import
  • Export: raw number for Amount (not formatCurrency string) + columnFormats for XLSX + i18n headers

Schema Changes:

  • expenses.payment_group_id (UUID, nullable) — groups expenses in same bank transaction
  • expenses.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_paid notification 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 referenced NEW.submitted_by but the expenses table column is actually submitted_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) with selectedEntity (slug), but getEntityId() could return null if called before entity mapping was initialized, causing the filter to fail silently
  • Solution:
  • Added organizationId (raw UUID) to the Expense interface 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:
  • Expense interface now includes organizationId: string alongside entityId
  • Filter logic: exp.organizationId === getActualOrgId(selectedEntity) instead of exp.entityId === selectedEntity
  • Import added: getActualOrgId from entityMapping.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 expenses table was missing the paid_at column, which would cause "Mark as Paid" to fail
  • Solution: Added paid_at TIMESTAMP column to the expenses table via migration
  • Problem 2: The "Approved By" field showed hardcoded "Admin User" instead of actual approver name
  • Solution: Updated handleApprove to use useAuth hook and display actual user's name
  • Technical: Migration add_paid_at_to_expenses applied; Added useAuth import and user destructuring

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 pending and rejected statuses
  • 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 handleRequestRevision function and revision dialog

Submitter Name Display (Jan 12, 2026)

  • Problem: The "Submitted By" column showed user IDs instead of names
  • Solution: Updated fetchExpenses in db.ts to join with the users table and extract first_name and last_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 expenseEligibleEntities filter 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 useMemo to 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:

pendingapprovedpaid

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, and Delete
  • 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 Paid reuses the exact same payment dialog as Pay Selected and now includes optional Payee Line / Check # / Memo capture before final payment confirmation
  • Undo returns an approved expense to pending

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_reference is 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_paid notifications

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 pdfClient before `scanDocument` OCR; submission still stores a JPEG through the normal receipt upload path
  • Fund/entity selection excludes parent_org rows through useExpenseEligibleEntities()
  • Budget and purpose linking are available in the add-expense flow
  • Receipt viewing is available anywhere a receiptUrl exists
  • Shared EmptyState is 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

  • status
  • organizationId
  • entityId
  • vendor
  • description
  • amount
  • date
  • submittedBy / submittedByUserId
  • approvedBy / approvedAt
  • paidAt / paidByName / paidFromAccountId
  • checkNumber
  • receiptUrl
  • revisionNotes
  • paymentGroupId
  • paymentReference

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

  • canWriteAccounting gates expense creation
  • canApproveExpenses gates approve / request revision / pay / undo / delete actions
  • Only pending expenses are eligible for batch approve
  • Only approved expenses are eligible for payment
  • Single-row Mark Paid intentionally 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 Receipt
  • src/lib/ocrUtils.tsscanDocument for receipt OCR
  • React Query
  • Shared UI components (EmptyState, PillTabs, ReceiptViewer, VendorSearch, CurrencyInput, SimpleExportDialog)

External

  • lucide-react
  • sonner

Related Documentation


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

Ready to get started?Start Plus Trial