Skip to main content

Journal Entry Manager

Journal Entry Manager

Component File: src/features/accounting/components/JournalEntryManager.tsx Route / navigation: Path /fund-accounting, Zustand accountingTool = journal-entries. See 00-ACCOUNTING-HUB.md. Access Level: Accounting users with JE write permission Last Updated: April 13, 2026 (posting + lock standardization update)

---

Overview

The Journal Entry Manager is the dedicated manual accounting screen for creating, reviewing, editing, and voiding journal entries.

It is one of two active JE UI families:

  • JournalEntryManager.tsx for the full accounting page workflow
  • JournalEntryEditDrawer.tsx for report, donor, reconciliation, and ledger drill-down workflows

Those surfaces are intentionally separate in the UI, but they now share the same data contract:

  • create -> createJournalEntry()
  • edit -> updateFullJournalEntry()
  • void -> useVoidJournalEntry() -> voidJournalEntryWithDependencies()

---

Current Architecture

Data layer

Primary helper file: src/lib/db/journal-entries.ts

Supporting helpers:

  • src/lib/db/journal-entry-voiding.ts
  • src/lib/journalEntryVoid.ts
  • src/lib/voidedEntryDisplay.ts

Query and mutation flow

| Action | Manager UI | Shared helper |
|---|---|---|
| Load paginated entries | `fetchJournalEntriesPaginated()` | `journal-entries.ts` |
| Open details | `fetchJournalEntryByLineId()` | `journal-entries.ts` |
| Create | `createJournalEntry()` | `journal-entries.ts` |
| Edit | `updateFullJournalEntry()` | `journal-entries.ts` |
| Void | `useVoidJournalEntry()` | `useSupabaseData.ts` -> `voidJournalEntryWithDependencies()` |

---

Recent Audit Notes

Date nudge shortcut (Apr 10, 2026)

  • The Journal Entry Manager date boxes now inherit the Fund Accounting date shortcut standard from the shared DatePicker.
  • Focus a valid date field and press `+` to move forward one day or `-` to move backward one day.
  • This applies to create/edit entry dates and JE date filters on the manager screen.

Calculator-style amount entry (Apr 9, 2026)

  • The Journal Entry Manager debit and credit fields are part of the live fund-accounting calculator-input rollout.
  • Users can type arithmetic such as 152+168 directly into a Debit or Credit field; the expression is evaluated and committed on blur.
  • The debit/credit mutual-clear rule still applies: entering a positive debit clears the credit and vice versa.
  • Invalid expressions preserve the last valid amount instead of silently converting to 0.

April 7, 2026

  • Nested JE drawer header actions (⋮): JournalEntryEditDrawer now computes actions first and only renders the ⋮ trigger when at least one header action is available. This prevents dead controls in read-only / voided / edit-mode contexts.
  • Stacking fix for nested drawer menus: The JE drawer's dropdown content is given a local high z-index override so the menu renders above StackedDrawerContent (which uses zIndex: 100000) instead of opening behind the panel.
  • Scope: This is a shared-component behavior update and applies to all report/ledger/donor/reconciliation drill-down flows that reuse JournalEntryEditDrawer.

April 2, 2026

  • Line-level edits and React keys: Journal entry UIs key each line row by journal_entry_lines.id. If the list payload contains duplicate line ids (or empty ids), clearing or editing one line can appear to clear multiple rows. Mitigations in the app:
  • JournalEntryManager and JournalEntryEditDrawer apply debit/credit/account updates and line removal to the first matching id only (defensive).
  • New client-side lines use ids like line-<timestamp>-<random> / new-<timestamp>-<random> to avoid rapid double-click collisions on Date.now()-only ids.
  • warnDuplicateJournalLineIds() in src/lib/journalEntryValidation.ts logs a console warning when an opened entry’s lines contain duplicate ids — indicates a data or RPC mapping issue to investigate (e.g. get_journal_entries_page / fetchJournalEntriesPaginated shaping in journal-entries.ts).

March 8, 2026

  • JE voids from the manager no longer use a manager-only cleanup path for deposit-linked entries.
  • All JE UI entry points now route through the same shared void orchestration hook.
  • Manager success state is only committed after the void mutation succeeds.
  • Backend void logic now rejects:
  • blank reasons
  • entries already in reconciliation
  • entries in locked periods on any affected line/fund

Existing UI split

The manager still owns its own create/edit form instead of reusing the shared drawer UI. That is a UI-standardization gap, not a persistence-contract gap.

---

Journal Entry Description Standard

All journal entry descriptions follow a single standard. Void status is communicated exclusively through boolean flags and the UI badge — never through the description text.

Canonical rules

| Concern | Standard | Example |
|---------|----------|---------|
| **Normal entries** | Plain business description. No system prefixes. Use ` - ` as the separator between logical segments. | `Donation revenue`, `Check deposit - 3 items` |
| **Voided originals** | `is_voided = true` + UI "Voided" badge. Description stays unchanged — do NOT prepend VOID/VOIDED. | Description: `Donation revenue` (badge shows voided state) |
| **Void reversal lines** | `is_void_reversal = true`. Description follows `VOID: <original description> - <reason> (Voided on <date>)`. | `VOID: Donation revenue - Incorrect donor (Voided on 2026-04-09)` |
| **Non-void reversals** | `reverseJournalEntry()` defaults to `Reverse entry - <original description>` (or user-supplied plain text). No colon-style prefix. | `Reverse entry - Grant revenue - Acme Foundation` |
| **Donation notes** | `VOIDED: <reason>` appended to `donations.notes` by `softDeleteDonation()`. This is a narrative note, not a JE description. | `VOIDED: Duplicate entry` |

Separator and casing rules

  • Use - (space-hyphen-space) between logical segments in system-generated JE descriptions.
  • Do not use em dashes, bracket prefixes, or colon-style prefixes for new system-generated JE descriptions.
  • Use sentence case for the first segment (Check deposit, not Check Deposit). Proper nouns and names keep their original casing.
  • Preferred structure: {action or source} - {entity/contact name} - {qualifier} where trailing qualifiers are optional.

Partial reversals

Some flows reverse only a subset of a larger logical batch. Those are not standard JE void reversals.

  • Use voidJournalEntry() only when reversing the full logical JE batch and setting is_void_reversal.
  • When reversing only part of a batch (for example, one donation inside a deposit), create a compensating JE with createJournalEntry().
  • Partial reversals must use plain business descriptions such as Deposit item reversal - <reason> and must not use the VOID: prefix.

Approved description patterns

| Source module | Entry description | Line description(s) |
|---------------|-------------------|----------------------|
| Manual JE | user-supplied | user-supplied or inherited from entry |
| Donation | `Donation received - $N.NN` | `Donation received` / `Donation revenue` |
| Check deposit | `Check deposit - N check(s)` | `Check deposit - summary` / per-check lines |
| Regular deposit | `Regular deposit - N item(s)` | `Regular deposit - summary` / per-item lines |
| Deposit item reversal | `Deposit item reversal - reason` | `Deposit item reversal - donationId` |
| Pledge accrual | `Pledge accrual - Donor - Campaign` | receivable / revenue lines |
| Pledge payment | `Pledge payment - Donor - Campaign` | payment / receivable lines |
| Pledge write-off | `Pledge write-off - Donor - reason` | bad debt / receivable lines |
| Grant awarded | `Grant awarded - Grantor - Name` | receivable / revenue lines |
| Grant payment | `Grant payment - Grantor - Name` | payment / receivable lines |
| Grant write-off | `Grant write-off - Grantor - reason` | bad debt / receivable lines |
| Bill opened | `Bill opened - Vendor - Desc` | `Vendor - desc` / `Payable to Vendor` |
| Bill payment | `Bill payment - Vendor - Desc` | debit line + `method payment - ref` |
| Expense payment | `Expense payment - context` | `Vendor - desc` per expense |
| Event revenue | `Event revenue - Name - Registrant` | same on both lines |
| Admin fee | `Admin fee allocation - Entity (rate)` | per-account lines |
| Stripe payment | `Stripe payment received - Name` | `Donation revenue - Name` |
| Stripe recurring | `Recurring payment - Name` | `Recurring donation revenue - Name` |
| Stripe fee | `Stripe processing fee - chargeId` | same on both lines |
| Quick entry | user-supplied, no prefix | user-supplied or inherited |
| Memorized transaction | template name | template lines or inherited |
| Non-void reversal | `Reverse entry - <original description>` | default from `reverseJournalEntry()` unless user override |
| Void reversal | `VOID: <original> - <reason> (Voided on <date>)` | system-generated by `voidJournalEntry()` only |

What NOT to do

  • Never prefix a JE description with VOIDED:, VOIDED -, VOIDED-TRANCHE:, or similar. The is_voided flag and the UI badge are the single source of truth.
  • Never use bracket prefixes such as [Cash] or [Check] in persisted JE descriptions.
  • Never use em dashes or colon-style prefixes for new system-generated JE descriptions.
  • Never rely on description-text parsing to determine void status. Use is_voided / is_void_reversal booleans.
  • Never manually set is_voided = true without also setting voided_at. Use the shared void helpers.
  • The VOID: prefix is reserved for system-generated reversal line descriptions only (created by voidJournalEntry()).

Migration / import hygiene

When writing migrations or imports that void entries, always:

1. Set is_voided = true and voided_at on the original lines. 2. Create proper reversal lines via VOID-JE-* entry numbers with is_void_reversal = true. 3. Do not embed void status in the description text. 4. Call sync_je_sequence_to_max() if hardcoding entry numbers.

---

Title Field

journal_entry_lines.title (added 2026-07-24) is an optional short header label for the journal entry — for example "March Payroll", "Donation — Acme Foundation". It renders above the per-line description in the JE manager list, the JE drawer (view + edit), the General Ledger, the Balance Sheet by Fund drill-down drawer, and the Reconciliation drawer. When title is NULL the description renders alone (no behavior change vs the pre-2026-07-24 UI).

Storage / replication invariant

  • Stored on every journal_entry_lines row sharing an entry_number. Replicated identically across all lines.
  • Read pattern: lines[0].title is the entry-level value. Adapters in src/lib/db/journal-entries.ts and src/lib/db/ledger-reads.ts already pull from the first line.
  • See `documentation/contracts/JOURNAL-ENTRY-STANDARDS.md` §"Title Field" for the persistence contract.

Manual entry behavior

  • Both the full-page Journal Entry Manager create/edit form and the shared JournalEntryEditDrawer expose a single optional Title input above the description input.
  • Empty / whitespace input is normalized to NULL by createJournalEntry() / updateFullJournalEntry() (NULLIF(btrim(...), '') semantics).
  • To clear an existing title, the user clears the input and saves; updateFullJournalEntry() maps the cleared field to '' so update_journal_entry_batch_atomic runs the title UPDATE (passing NULL would mean "leave untouched").
  • Period locks still apply to title-only edits (v1 decision).

Auto-titles for system writers

Every system-generated JE writer populates title so the list/search experience is consistent. Catalog as of 2026-07-24:

| Source module | Auto-title formula |
|---------------|--------------------|
| Quick Entry | `Quick Entry — {payeeOrPayer}` (or `Quick Entry` if no contact) |
| Donation | `Donation — {donorName}` |
| Regular deposit | `Regular Deposit — {N} item(s)` |
| Check deposit | `Check Deposit — {N} check(s)` |
| Bill opened | `Bill — {vendor} #{billNumber}` |
| Bill payment | `Bill Payment — {vendor} #{billNumber}` |
| Pledge accrual | `Pledge — {donorName} ({campaign})` |
| Pledge payment | `Pledge Payment — {donorName}` |
| Grant awarded | `Grant — {grantorName} ({grantName})` |
| Grant payment | `Grant Payment — {grantorName}` |
| Admin fee allocation | `Admin Fee — {entityName} ({rate}%)` |
| Expense payment | `Expense — {vendorOrContext}` |
| Memorized transaction | `{memTx.name}` |
| Stripe payment | `Stripe — {donorName} ($amount)` |
| Stripe recurring fee | `Stripe Recurring Fee — {YYYY-MM}` |
| Reversal (`reverseJournalEntry`) | `Reversal — {originalTitle \|\| originalDescription}` |
| Void reversal (`voidJournalEntry`) | `Reversal — {originalTitle \|\| originalDescription}` |

Writer source-of-truth lives in src/lib/db/*.ts and supabase/functions/*/index.ts. When adding a new writer, populate title from the same fields you use for description, prefixed by the source action.

Search

The JE Manager and General Ledger search inputs are routed through get_journal_entries_page's p_search parameter, which (since migration 20260724000001_add_title_to_journal_entry_rpcs.sql) ORs against jel.title. Users can therefore find entries by title with no UI changes; matches are backed by the partial GIN trigram index journal_entry_lines_title_trgm_idx.

---

Business Rules

Entry numbering

  • Standard format for new entries: JE-YYYY-0000001
  • Generated by get_next_journal_entry_number() RPC (atomic, uses pg_advisory_xact_lock)
  • Reversal entries use VOID-JE-YYYY-0000001
  • Entry list status filtering uses line booleans (is_voided, is_void_reversal) rather than legacy *-VOIDED entry-number suffixes.
  • The RPC includes a collision safety check: if the generated number already exists in journal_entry_lines, it increments and retries (up to 100 attempts)
  • Client recovery (March 30, 2026): createJournalEntry() in journal-entries.ts mirrors the Stripe webhook: if the RPC fails with the repeated-collision exhaustion message (Failed to find unused journal entry number after ... attempts), Postgres P0001, or an empty number, it runs sync_je_sequence_to_max(p_org_id, p_year) and retries the RPC once before using the timestamp-style fallback number.
  • Sequence sync requirement: The journal_entry_sequences table tracks the last-used number per org per year. Any raw SQL INSERT into journal_entry_lines with a hardcoded entry_number MUST update this counter. After bulk imports or migrations, call sync_je_sequence_to_max(p_org_id, p_year) to ensure the counter is correct. Failing to sync causes entry_number collisions on subsequent RPC calls (see DEVELOPER-PLAYBOOK §16, §20.2)
  • Legacy entry numbers remain visible in historical data, including shorter numeric values (JE-2026-327) and split / repair suffixes (JE-2026-327-A, ...-RESTORE-BULK-...). The manager must read and search those values correctly, but new writes should use the 7-digit canonical format.
  • Historical renumbering is intentionally out of scope for normal JE manager work. Re-numbering legacy entries requires a dedicated migration and explicit approval because JE numbers are coupled to void metadata, sibling grouping, reporting, and repair tooling.
  • Browser JE writes are atomic at the database boundary: create/edit/void operations call transactional RPC wrappers so partial writes cannot persist when any step fails.

Validation

  • description required
  • at least one line required
  • debits must equal credits within 0.01
  • each line must have an account
  • each line must resolve to a valid fund
  • create and new-line update payloads must precheck get_period_lock_date for each posting fund + line date before RPC write

Parent-org posting normalization

  • Parent-org JE writes resolve through organizations.settings.default_posting_fund_id only.
  • The configured posting fund must be a direct child fund / accounting_fund, active (or null status), and visible.
  • Writes are blocked when the mapping is missing or invalid; parent-org name/slug heuristics are not allowed.

Edit restrictions

Editing is blocked when the target lines are in a locked accounting period or already reconciled.

Void restrictions

Voiding is blocked when:

  • no reason is provided
  • the entry is already voided
  • the entry is a void reversal
  • any target line is in reconciliation
  • any target line is in a locked period
  • the original entry is unbalanced

---

Deposit-Linked Void Behavior

A JE that belongs to a deposit batch is not voided as a standalone ledger-only action.

The shared hook resolves whether the line belongs to a deposit batch and, if so, routes through voidDepositBatch() so that:

1. the JE is reversed 2. linked donations are voided 3. the deposit batch returns to pending

This keeps JE Manager behavior aligned with General Ledger, reports, donor drill-downs, and any other surface using the shared JE drawer.

---

Read vs Write Ownership

Manager-owned UI responsibilities

  • accounting-page filters
  • entry list pagination
  • full-page create/edit form
  • dedicated void dialog

Shared logic responsibilities

  • JE numbering
  • balancing rules
  • period-lock enforcement
  • reconciliation enforcement
  • void metadata and reversal linkage
  • deposit-aware void orchestration

---

Related Files

  • src/features/accounting/components/JournalEntryManager.tsx
  • src/components/shared/JournalEntryEditDrawer.tsx
  • src/hooks/useSupabaseData.ts
  • src/lib/db/journal-entries.ts
  • src/lib/db/journal-entry-voiding.ts
  • documentation/pages/accounting/02-ACCOUNTING-SYSTEM-INTEGRATION.md

Synced from IFMmvp-Frontend documentation: pages/accounting/05-JOURNAL-ENTRY-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