Journal Entries & Ledgers
Business view
Every financial event in a double-entry system becomes a journal entry: a set of debit and credit lines that must sum to zero. In this module, journal entries come from two places:
- Automated entries — most of the time, staff never see this happening. A patient pays an invoice, a doctor's commission accrues, a stock item is consumed, a fixed asset depreciates — the system posts the matching journal entry in the background.
- Manual entries — an accountant can post a journal entry by hand for anything the automation doesn't cover: corrections, accruals, opening balances. The system won't let a manual entry post unless debits equal credits.
Manual entries can optionally go through an approval step before they post (draft → submitted for approval → approved & posted), and someone other than the person who created the entry has to approve it — the system won't let you approve your own journal entry, to prevent one person from being able to move money around unchecked.
Recurring journals automate the accrual pattern accountants use constantly: post the same entry every month (rent, a subscription), and — if set up that way — automatically reverse it the following month once the real invoice arrives.
Once entries are posted, there are two ways to look at the ledger: General Ledger browses every account with a balance and lets you drill into any one; Account Ledger picks a single account and shows its full statement — opening balance, every transaction, running balance, and what was on the other side of each entry.
Technical view
Manual journal entry lifecycle
JournalEntry (packages/prisma/schema.prisma:8292-8331) — status is a JournalStatus with exactly four values: DRAFT, PENDING_APPROVAL, POSTED, REVERSED (:8285). JournalLine (:8369-8434) carries both transaction-currency and functional-currency debit/credit plus a wide set of analytic dimensions: branch, doctor, patient, supplier, insurer (field name insuranceCompanyId), tax code, operation, inventory item, fixed asset, responsibility center (renamed from "cost center" — see Setup & Foundations), lab, procedure group, chart (clinical speciality), and treasury. JournalEntrySequence (:8360-8367) is a per-(company, year) atomic counter that produces gapless JE-YYYY-NNNNN references for posted entries only — drafts get a placeholder DRAFT-<uuid> reference and never consume the counter.
Lifecycle, in packages/server/src/resolvers/mutations/ledgerMutations.js and packages/server/src/accounting/posting/engine.js:
- Direct post —
postManualJournal(ledgerMutations.js:572) →createManualJournal(:306) →postJournalEntry(engine.js:481-581). Status is conditional, not alwaysPOSTED: it postsPENDING_APPROVALif the company has turned onblockSelfApproval, otherwisePOSTEDimmediately (see Segregation of duties, below). - Save as draft —
saveDraftJournal(ledgerMutations.js:590) — editing a draft fully replaces it (delete + recreate), not a patch. - Submit for approval —
submitJournalForApproval(:615), only fromDRAFT. - Reject —
rejectJournal(:631),PENDING_APPROVAL → DRAFT. - Approve —
approveJournal(:646) →promoteDraftToPosted(engine.js:590-643), which re-validates the balance, re-checks every account is still postable/active, and re-runs the fiscal-period guard before minting the sequential reference. If the entry was alreadyPOSTED(the direct-post path, when self-approval isn't blocked), "approving" it is a post-hoc sign-off only —approvedById/approvedAtare stamped, nothing else changes. An entry that already has anapprovedByIdcan't be approved again. - Delete draft —
deleteDraftJournal(:605),DRAFTonly; posted entries must be reversed instead. - Reverse —
reverseJournal(:686) →reverseEntry(engine.js:691-748),POSTED,source === MANUAL, and not already reversed. The reversal posts as a brand-new entry with flipped debits/credits andisAdjustment: true— the original entry is never flipped toREVERSED, by design (the code's own comment explains that doing so and excluding it from reports "would leave the reversal un-offset"), so status-filtered reports still see both legs and net to zero. There is novoidJournal/cancelJournalmutation — reverse is the only undo path for a posted entry.
Debits = credits is enforced twice: once when a manual entry is built (normalizeManualLines, ledgerMutations.js:208 — checks each line has exactly one of debit/credit, not the overall balance), and again at posting time (engine.js:520-526, in functional currency — a multi-currency entry doesn't need to balance per-currency, only after conversion). promoteDraftToPosted re-validates a third time (engine.js:614-616) in case of drift between submission and approval.
Segregation of duties: AccountingSettings.blockSelfApproval is off by default (schema.prisma:8495, @default(false)) — the code's own comment calls this deliberate: "a single-accountant clinic can approve its own entries out of the box." When a company turns it on, postManualJournal posts as PENDING_APPROVAL instead of POSTED (ledgerMutations.js:574-575), and approveJournal blocks the submitter from approving their own entry (:660-667).
Fiscal-period enforcement: see Setup & Foundations — the same assertPeriodPostable check applies here.
Known lifecycle gaps
JournalStatushas noAPPROVEDvalue in the schema, yet both the mobile and web UIs carry a deadAPPROVEDstatus-map entry, filter tab, and (on web) an unreachablecanReversebranch checking for it. Harmless, but confusing if you go looking for what "Approved" means.- Mobile mislabels pending-approval entries as "Draft" in the journal-entries list (its
STATUS_MAPhas noPENDING_APPROVALkey, unlike the details screen), and has no tab to filter for entries awaiting approval. - Mobile has no draft-save, submit-for-approval, or reject UI at all —
ManualJournalScreen.jsonly posts directly. The GraphQL mutation files exist and are unused. Web has the full lifecycle (ManualJournal.jshas both Post and Save as Draft buttons;JournalEntries.js/JournalEntryDrawer.jswire submit/reject/approve/reverse/delete all correctly). If your workflow depends on the approval chain, use the web app. - Attaching a file to a journal entry has no UI anywhere —
attachFileToJournalEntry/detachFileFromJournalEntryare fully implemented server-side, but no screen calls them; both apps only render already-attached files read-only.
Journal Entries list & details
Mobile and web are two independently-built frontends with different file layouts here — mobile: journalEntries/JournalEntriesScreen.js (a real segmented status tab bar) and a separate journalEntryDetails/JournalEntryDetailsScreen.js. Web: JournalEntries/JournalEntries.js (status as a dropdown <Select>, not tabs) with entry details rendered in an inline JournalEntryDrawer.js rather than a separate page. Both support filters for created-by, journal date range, and created-date range (kept separate from journal date on purpose), a full line table, related entries (other entries sharing the same sourceType/sourceId, e.g. an invoice's revenue entry alongside its COGS and commission entries), and an audit/history timeline sourced from LedgerHistory via the auditLog query. Permission: VIEW_GL.
Recurring journals
RecurringJournal (schema.prisma:8847-8870) — frequency (RecurrenceFrequency: MONTHLY | QUARTERLY | YEARLY), dayOfMonth, a JSON lines template, autoReverse. Generation is cron-driven: packages/server/src/accounting/recurring.js (runRecurringJournals, lines 27-100) finds journals whose nextRunAt has passed and posts one occurrence per missed period (capped at 240 catch-up runs as a runaway guard), idempotent via sourceId = "<journalId>:<YYYY-MM>" so a re-run never double-posts; each journal's failure is caught individually so one bad template doesn't abort the whole company's run. If autoReverse is set, it also posts a mirror reversing entry dated the first day of the next period — the standard accrual/auto-reverse pattern. Scheduled monthly on the 1st at 07:00 (packages/server/src/cronJobs/cronJobs.js:137, spec '0 7 1 * *', Redis-locked) as part of a broader monthly accountingCron batch that also runs depreciation, audit-log purging, pending-posting retries, orphaned-posting scans, and the reconciliation report. Mutations createRecurringJournal/editRecurringJournal/deleteRecurringJournal (ledgerMutations.js:764/791/817) all require POST_JOURNAL; the manual-trigger mutation runRecurringJournals (:825) additionally accepts CLOSE_PERIOD as an alternative permission.
General Ledger vs. Account Ledger
Both are powered by packages/server/src/accounting/reports/accountLedger.js. General Ledger — glAccountBalances — a flat, expandable list of every account with activity (opening/period debit/period credit/closing), hierarchy-aware via a post-order DFS roll-up (mobile: generalLedger/GeneralLedgerScreen.js; web: GeneralLedger/GeneralLedgerTree.js). Account Ledger — accountLedger — a single account's full statement: opening balance, every transaction with a contra-accounts column (what was on the other side of each entry), running balance, and closing balance, capped at 5,000 rows with a truncated flag rather than silently dropping data (mobile: accountLedger/AccountLedgerScreen.js; web: AccountLedger/AccountLedger.js, whose only built-in warning is about the 5,000-row cap — not branch self-balancing). The "may not self-balance" branch-view warning (company-level, unbranched entries are pooled separately) is shown on General Ledger, Trial Balance, Financial Statements, and Receivables, not Account Ledger.
The posting outbox (PendingPosting)
Operational writes (an invoice, a payment, an expense) commit first and post to the ledger asynchronously through a durable outbox — PendingPosting (schema.prisma:8337-8354) records the source event with a status — a plain string defaulting to "PENDING" (not a Prisma enum; the values PENDING | FAILED | DONE are only a code comment), an attempt count, and a next-retry time, so a transient posting failure never blocks the front-desk operation that triggered it and gets retried automatically (packages/server/src/accounting/resilientPosting.js, swept every 10 minutes). There's a pendingPostings query for inspecting the outbox (packages/server/src/resolvers/queries/subledgerQueries.js:251-267, gated VIEW_GL), but no screen in either app calls it — it exists for backend diagnostics only, not as an end-user feature.