Setup & Foundations
This page covers the configuration layer everything else in the module builds on: the chart of accounts, how automated postings decide which account to hit, responsibility centers and cost allocation, branches, fiscal periods, automation controls, the audit log, and multi-currency exchange rates.
Business view
Before a clinic can use any of the accounting reports, someone needs to set up a few things once:
- Chart of Accounts — the list of accounts (Cash, Patients Receivable, VAT Payable, Service Revenue, Doctor Commission Expense, and so on) that make up the clinic's books. Every clinic starts with a sensible pre-built list; most clinics never need to change it.
- Account Mapping — tells the system which of those accounts each automatic posting should use. For example, when a patient pays an invoice, which Cash account gets debited? Account Mapping answers that, grouped into 11 categories (Receivables & Cash, Revenue, VAT & Tax, Inventory & COGS, Expenses & Fees, Payroll & Doctors, Payables, Fixed Assets, Insurance Claims, Foreign Exchange, and an Advanced group for equity/inter-branch accounts) with 43 individually-mappable roles. Every role ships with a best-practice default, so a clinic only touches this screen if their books are structured differently than the default.
- Responsibility Centers — an optional way to tag spending and revenue by department, location, or program, independent of the account structure, so a clinic can run a P&L per center. Beyond plain cost tracking, a clinic can also auto-assign a center to postings by doctor/branch/procedure group ("Revenue Mappings") and allocate a cost center's shared overhead out to other centers by a driver ("Allocation Rules").
- Branches — if a clinic has more than one location, branch-level reporting (contribution, P&L, inter-branch balances) is available without needing a separate chart of accounts per branch — one ledger, filtered/grouped by branch.
- Fiscal Periods — the monthly lock. Once a month is closed, nothing can post into it by mistake (a "soft" close allows adjustments through a permission gate; a "hard" close is final). Closing a fiscal year sweeps all revenue and expense into Retained Earnings, per branch.
- Automations — toggles for whether specific event types (invoices, payments, expenses, depreciation, commission, and so on) post automatically and whether they land as a finished ("Posted") entry or a Draft that needs review.
- Audit Log — a record of who did what and when, for the journal-entry lifecycle and period locks.
- Exchange Rates — for clinics that transact in more than one currency, a rate table (manual or from an external source) used to convert amounts into the clinic's home currency.
Technical view
Chart of Accounts
Account model (packages/prisma/schema.prisma:8161-8202) — one chart per company (not per branch). Key fields: code, name/nameAr, type (AccountType: ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE, CONTRA_ASSET, CONTRA_LIABILITY, CONTRA_REVENUE), subType (AccountSubType, 23 values e.g. ACCOUNTS_RECEIVABLE, CASH_AND_EQUIVALENTS, TAX_PAYABLE), parentId (self-relation hierarchy), isPostable, isSystem, plClassification (COST_OF_REVENUE | OPERATING | BELOW_EBITDA | NONE — drives income-statement bucketing), plGroup (14 finer sub-groups, e.g. CLINICAL_MATERIALS, PAYROLL, OCCUPANCY), cashFlowCategory (drives which cash-flow-statement bucket the account's movement lands in), active.
Every company is seeded from a starter template (packages/server/src/accounting/coaTemplate.js, run by provisionCompanyAccounting in packages/server/src/accounting/provisioning.js); isSystem: true accounts (e.g. 1110 Cash, 1210 Patients Receivable, 2200 VAT Payable) can never be edited or deleted because the posting engine's defaults depend on them.
Mutations (packages/server/src/resolvers/mutations/ledgerMutations.js): addAccount (:857), editAccount (:895 — type/subType are locked after creation to protect historical classification; walks the parent chain to reject hierarchy cycles), setAccountPlClassification/setAccountPlGroup (:953, :964), deactivateAccount/activateAccount/deleteAccount (:1888, :1900, :1910 — deactivate/delete both call assertAccountNotMapped so an account in active use by a posting role can't be silently orphaned; delete additionally requires zero journal-line activity and zero children).
Mobile: chartOfAccounts/ChartOfAccountsScreen.js (list/tree toggle, search) and AccountFormScreen.js (create/edit; typing a code auto-infers the account type and nearest parent by prefix — accountUtils.js). Permission: the accounts query needs one of VIEW_GL | VIEW_REPORTS | MANAGE_COA (packages/server/src/permissions/permissions.js:356); every write needs MANAGE_COA (:3014-3017).
Two form-level gaps worth knowing about: the mobile create form's SUB_TYPES_BY_TYPE.EXPENSE list omits INTEREST_EXPENSE even though the Prisma enum defines it, and ACCOUNT_TYPES omits all three contra types (CONTRA_ASSET, CONTRA_LIABILITY, CONTRA_REVENUE) — contra accounts can only exist if they were seeded by the starter template, never created by hand.
Account Mapping
The catalog of 30 mappable "roles" lives in packages/server/src/accounting/accountRoles.js (ACCOUNT_ROLES, grouped by ACCOUNT_ROLE_GROUPS). Each role has an allowedTypes list (so the picker only shows type-compatible accounts) and a defaultCode. Resolution precedence (packages/server/src/accounting/accountMapping.js:computeAccountRoleMappings, :26-49): a company-specific PostingRule override → a legacy AccountingSettings.default*AccountId field → the template default. setAccountRoleMapping (ledgerMutations.js:563) validates the target account belongs to the company, is active and isPostable, and is a type the role allows — passing accountId: null resets to the default. Requires MANAGE_COA.
Screen: accountMapping/AccountMappingScreen.js — grouped, searchable, each row shows the role's label and description text (the same copy visible in the screenshot) plus a "Customized"/"Default" tag and a reset button. One rough edge: a read-only user (no manageCoa) still sees interactive-looking pickers that silently no-op on tap rather than being visually disabled.
Responsibility Centers
ResponsibilityCenter (schema.prisma:8541-8564, renamed from the earlier "Cost Center" concept — the field is responsibilityCenterId everywhere) — hierarchical (self-relation), optionally tied to one branch, unique per (companyId, code). Each center has a kind (ResponsibilityCenterKind, :8531-8536: COST | REVENUE | PROFIT | INVESTMENT, default COST) — so a center isn't purely a cost-tracking tag any more; branches in the sandbox, for example, are seeded as INVESTMENT centers. Mutations: addResponsibilityCenter, editResponsibilityCenter, setResponsibilityCenterActive (ledgerMutations.js:1067/1085/1115) — note there is no delete mutation, only deactivate, and editResponsibilityCenter only rejects direct self-parenting, not a longer cycle (unlike editAccount's full ancestor walk — a real gap if someone deliberately swaps two centers' parents). responsibilityCenterPnl report (packages/server/src/accounting/reports/responsibilityCenter.js:14-83) buckets any journal line with no responsibilityCenterId into an explicit "Unassigned" row so totals always reconcile to the company income statement. Permission: reading responsibility centers only needs one of MANAGE_COA | VIEW_REPORTS | VIEW_GL | POST_JOURNAL (broad, since anyone posting a manual journal needs to pick one); writing needs MANAGE_COA.
Responsibility Center Mappings (Revenue Mappings)
ResponsibilityCenterMapping (schema.prisma:8577-8593) is a small rule table, shown in the UI as "Revenue Mappings": it maps an intrinsic posting dimension — PROCEDURE_GROUP, DOCTOR, or BRANCH (MappingDim enum, :8567-8571) — plus a specific sourceId (a given procedure group/doctor/branch) to a target ResponsibilityCenter. It is a lookup table consulted at posting time, not a live foreign key on JournalLine; the resolved center is denormalized onto JournalLine.responsibilityCenterId when the entry posts. Multiple rules can match the same posting (e.g. a line has both a doctor and a branch), so AccountingSettings.mappingPrecedence (a MappingDim[], default [BRANCH]) decides which dimension wins; revenueMappingEnabled (default true) turns the whole feature off if unset. Mutations addResponsibilityCenterMapping/editResponsibilityCenterMapping/deleteResponsibilityCenterMapping (ledgerMutations.js:1124/1138/1157) — unlike ResponsibilityCenter itself, mappings genuinely hard-delete. Gated MANAGE_COA.
Allocation Rules
A second, independent mechanism for moving cost between centers: AllocationRule (schema.prisma:8608-8621) spreads a COST-kind center's pooled overhead onto one or more PROFIT/INVESTMENT-kind target centers (AllocationTarget, :8623-8632), by a driver (AllocationDriver: MANUAL_WEIGHT | CHAIR_MINUTES | PROCEDURE_COUNT, default MANUAL_WEIGHT — only MANUAL_WEIGHT reads the target's weight field directly; the other two drivers compute their own weighting). Running a rule for a period (runAllocation, posting logic in packages/server/src/accounting/posting/allocation.js) creates an AllocationRun (:8634-8647, status POSTED | REVERSED) and posts one real, balanced journal entry that nets to zero — crediting the source cost center and debiting the target centers proportionally. reverseAllocationRun undoes it the same way a journal reversal does. addAllocationRule/editAllocationRule/deleteAllocationRule (ledgerMutations.js:1166/1184/1214 — delete is blocked once any run exists) show no guard preventing a target center from being the same as the rule's own source center, unlike the responsibility-center self-parent check above — a theoretical self-allocation gap, not yet verified against a live attempt. Setup, execution, and reversal are all gated MANAGE_COA (permissions.js:2981-2985).
Branches
There is no branchId on Account — the chart of accounts is single and company-wide. Branch is a dimension on JournalEntry/JournalLine, not a separate ledger, alongside doctor, patient, supplier, insurer, tax code, operation, inventory item, fixed asset, responsibility center, lab, procedure group, and chart (clinical speciality). Branch-level reports (branchContribution, branchProfitAndLoss, branchEliminationReport — powering the three tabs on branches/BranchesScreen.js) are computed by filtering/grouping journal lines by branchId. Cross-branch treasury transfers post an inter-branch due-to/due-from pair (roles DUE_FROM_BRANCH/DUE_TO_BRANCH) so each branch's books self-balance, and closeFiscalYear groups its zeroing entries by (accountId, branchId) so each branch's net result lands in Retained Earnings tagged to that branch.
Fiscal Periods
FiscalPeriod model (schema.prisma:8241-8255), status OPEN | SOFT_CLOSED | HARD_CLOSED (PeriodStatus, :8235-8239). Business logic in packages/server/src/accounting/close.js:
closeAccountingPeriod(:11) — soft or hard close a month; auto-creates theFiscalPeriodrow if it doesn't exist yet.reopenAccountingPeriod(:36) — blocks reopening a hard-closed period; only soft closes can be reopened.closeFiscalYear(:76) — idempotent per(companyId, year); sums all posted lines for the year grouped by(accountId, branchId), zeroes every revenue/expense/contra-revenue account, and plugs the net result into theRETAINED_EARNINGSrole account per branch. IfhardCloseis set, also hard-closes every period in the year.
What actually blocks posting is assertPeriodPostable inside the posting engine (packages/server/src/accounting/posting/engine.js:286-293, called from both direct-post and approve-and-post paths): HARD_CLOSED always blocks; SOFT_CLOSED blocks unless the entry is flagged isAdjustment: true and AccountingSettings.enforceOpenPeriodPosting is on (the default). Reversals are always posted as adjustments so they can land in a soft-closed period regardless.
There is a second, unused implementation of the same logic — packages/server/src/accounting/periodGuard.js (assertPeriodOpen) — whose own docstring says it's meant to be "wired in Phase 2." It's fully built and unit-tested but never imported by anything outside its own test file; the real enforcement lives entirely in engine.js. Treat periodGuard.js as dead code, not a second code path to worry about.
Screen: fiscalPeriods/FiscalPeriodsScreen.js, gated CLOSE_PERIOD. One parity gap: the web version checks periodCloseChecklist (draft/pending-approval counts) before letting a user close a period; the mobile screen never queries it, so a mobile user gets no warning about unposted entries in a period they're about to close.
Automations
Two layers, both under packages/server/src/resolvers/mutations/ledgerMutations.js and gated MANAGE_COA:
- Per-event toggles —
PostingAutomationSetting(schema.prisma:8782-8795), one row per(companyId, eventType)(INVOICE | PAYMENT | EXPENSE | EXPENSE_PAYMENT | INCOME | INVENTORY | DEPRECIATION | COMMISSION | CLAIM | TREASURY | FIXED_ASSET), each withautoPostanddefaultStatus(POSTED | DRAFT). No row for an event type means "auto-post as Posted" — the safe default for existing tenants.updatePostingAutomation(ledgerMutations.js:1802). Screen:automations/AutomationsScreen.js. - Company-wide controls —
AccountingSettingsfields:blockSelfApproval(off by default —schema.prisma:8495, opt-in segregation of duties on journal approval, so a single-accountant clinic can approve its own entries out of the box),enforceOpenPeriodPosting(defaulttrue, see Fiscal Periods above),requireResponsibilityCenterOnManual(renamed fromrequireCostCenterOnManual, defaultfalse, forces every manual-journal line to carry a responsibility center),fallbackResponsibilityCenterId(used when a mapping resolves nothing),revenueMappingEnabled/mappingPrecedence(see Responsibility Center Mappings, above),glDriftAlertsEnabled/glDriftToleranceMinor,inventoryCostingMethod.updateAccountingControls(ledgerMutations.js:1825).
Both blockSelfApproval and requireResponsibilityCenterOnManual now have full toggle/picker UI on both platforms (packages/clinic-web/src/components/dashboard/accounting/Automations/Automations.js:131-178, packages/clinic-mobile/.../automations/AutomationsScreen.js:107-190) — this closes what used to be a real gap (no UI to change either setting short of a direct database edit) and a mobile/web parity gap (updateAccountingControls used to be web-only); both are fixed as of this PR.
Audit Log
LedgerHistory model (schema.prisma:8437-8451) — append-only, entityType/entityId/field/oldValue/newValue/action (CREATE | UPDATE | DELETE | POST | APPROVE | REVERSE | LOCK_PERIOD), purged after AccountingSettings.auditRetentionDays (default 2555 days ≈ 7 years, for KSA tax-record retention). Query: auditLog() in packages/server/src/accounting/reports/audit.js. Screen: auditLog/AuditLogScreen.js, gated VIEW_GL only (not MANAGE_COA — anyone who can see the ledger can see who touched it).
The audit log covers less than its own schema comment claims. recordHistory(...) is only called for the journal-entry lifecycle (submit/reject/approve/post/reverse/attach), fiscal-period locks, and (newly) expense-claim actions. Despite the LedgerHistory.entityType comment explicitly naming Account and TaxCode as tracked entities, no code path writes an audit entry for account creation/editing/activation/deletion, P&L reclassification, account-role remapping, tax-code changes, responsibility-center/responsibility-center-mapping/allocation-rule changes, currency changes, or automation-setting changes. A clinic could silently remap a Revenue role to a different account with zero trace in this screen. This is the single biggest gap between what the data model promises and what the feature actually delivers — see For Quality.
Exchange Rates
Currency (schema.prisma:8066-8073, global registry, no company scoping) and ExchangeRate (:8103-8120, companyId nullable — null means a shared global/fallback rate, so one tenant can't overwrite the rate another tenant translates with). FxRateSource: MANUAL | SAMA | ECB | CUSTOM_API. Mutations: setExchangeRate (gated MANAGE_FX_RATES), addCurrency/editCurrency (ledgerMutations.js:1701/1711 — editCurrency blocks deactivating a company's functional currency).
Mobile has a full exchangeRates/ExchangeRatesScreen.js for rates, but no currency-master screen at all — adding a currency is web-only, and editCurrency has no UI on either platform (a currency, once added, can never be renamed or deactivated except by a direct mutation call).