hitaji-erp-api/src/accounting/. When you add a new posting path, you are
responsible for routing it through these same gates — none of them are enforced
by a database constraint alone.
Where invariants live
1. Double-entry must balance
A journal entry’s debits must equal its credits (tolerance ≤ 0.01) and a single line may never carry both a debit and a credit.- Enforced on create/update —
JournalService.create()/update():debit > 0 && credit > 0→BadRequestException('A journal line cannot have both debit and credit values')debit === 0 && credit === 0→BadRequestException('A journal line must have either a debit or credit value')Math.abs(totalDebit - totalCredit) > 0.01→BadRequestException('Journal entry must balance. Debit: …, Credit: …')- Fewer than 2 lines →
BadRequestException('Journal entry must have at least 2 lines')
- Re-enforced on post —
JournalService.post()recomputes the totals from the persisted lines and throwsBadRequestException('Journal entry does not balance')if they drift (guards against a line edited out-of-band after draft validation). - Re-enforced in the pipeline —
PostingValidatorService.validate()accumulates debit/credit across the line set and emits acode: 'UNBALANCED'issue whenMath.abs(totalDebit - totalCredit) > 0.005, plus aMISSING_REQUIRED_FIELDissue for any line with both sides set.
The balancing check runs three times along the path (create, post, pipeline) on purpose — back-dated edits and system-generated entries enter the ledger by different doors, and each door re-checks.
2. Only postable accounts can receive lines
A line must reference an account that exists, is active, is a leaf (not a group), and allows direct posting.JournalService.create()/update()batch-load every referenced account and reject:- unknown account →
BadRequestException('Account <id> not found') !account.isActive→BadRequestException('Account <code> is not active')account.isGroup→BadRequestException('… is a group account and cannot be posted to directly')!account.allowDirectPosting→BadRequestException('… does not allow direct posting')
- unknown account →
PostingValidatorService.validate()repeats this independently of the service, emitting issuesACCOUNT_NOT_POSTABLE(unknown / group / non-postable) andACCOUNT_DISABLED(inactive). This is the gate every non-journal posting path (invoice, bill, payment bridges) flows through.
3. Normal balances & account structure
Account carries root_type (ASSET/LIABILITY/EQUITY/INCOME/EXPENSE),
normal_side (DEBIT/CREDIT) and an optional balance_must_be constraint hint.
Structural integrity is enforced by AccountInvariantsService:
root_type is the invariant that protects the financial statements — a reparent
that changed an EXPENSE account into an ASSET subtree would silently corrupt the
P&L/Balance-Sheet split, so it is refused outright.
4. System-account protection
Account.is_system = true marks accounts the platform depends on (AR, AP, tax
payable, retained earnings, opening-balance equity, etc., identified by
system_code).
- Merge —
AccountInvariantsService.assertMergeLegal()throwsBadRequestException('System accounts cannot be merged')if either side is a system account. - System accounts are also typically
isGroup = falseleaves withallow_direct_postingcontrolled by the chart template; the postable-account gate (§2) still applies to them.
5. Frozen-account guard
An account can be frozen (is_frozen, with frozen_at/frozen_by_user_id/frozen_reason)
to stop further postings without disabling it.
-
FrozenAccountGuardService.check(accountIds, accounts, overrideAllowed)returns one of:{ kind: 'ok' }— no frozen accounts touched{ kind: 'override', frozenAccounts }— frozen accounts touched but caller holds the override role{ kind: 'blocked', issues }— emitscode: 'ACCOUNT_FROZEN'issues ('Account <code> is frozen; posting requires override.')
assertFreezeLegal()(§3).
6. Period / posting-date guards
Posting into a non-OPEN period is the most common correctness failure, so there are two enforcers with a shared status model (AccountingPeriodStatus:
OPEN / CLOSED / LOCKED).
6a. PeriodCloseService.assertPostingAllowed(postingDate, workspaceId, tenantId)
Looks up any CLOSED or LOCKED period covering postingDate for the
tenant+workspace and, if found, throws a typed PeriodClosedException
(a BadRequestException subclass) carrying a stable code:
code: 'PERIOD_CLOSED'for a closed periodcode: 'PERIOD_LOCKED'for a locked period
JournalService:
create() (refuse back-dated drafts up front), post(), reverse() (checks the
reversal date), submitForApproval(), and onApproved() (re-checks before the
approval-time post).
Stability contract (see repoCLAUDE.md): downstream products (e.g. the SACCO bridge UI) pattern-match onbody.code. Do not replace this with a genericthrow new BadRequestException(...)— it breaks those consumers.
6b. PeriodLockGuardService.check({ tenantId, workspaceId, postingDate, overrideAllowed })
The override-aware gate used inside the posting pipeline. It caches the
resolved period for 60 s (PERIOD_CACHE_TTL_MS) and returns:
The cache is invalidated by
PeriodCloseService on close() and reopen()
(periodLockGuard.invalidateCache()), so a freshly closed/reopened period takes
effect immediately rather than after the TTL.
CLOSED is overridable, LOCKED is not. A CLOSED period can be posted into
with the right role (corrections); a LOCKED period can only be posted into after
going through PeriodReopenRequest.
Period creation invariants
PeriodCloseService.create() rejects endDate < startDate
(BadRequestException) and any period that overlaps an existing non-deleted
period ('Accounting period overlaps with existing period <name>').
7. Status / lifecycle invariants on journal entries
JournalEntry.status is DRAFT → POSTED → REVERSED, enforced in JournalService:
update()/delete()— onlyDRAFTentries ('Only DRAFT journal entries can be updated/deleted').post()—POSTEDagain →'Journal entry is already posted';REVERSED→'Cannot post a reversed entry'.reverse()— onlyPOSTEDentries ('Only posted entries can be reversed'); creates a new entry with debits/credits swapped, voucher typeREVERSAL, carrying the sameagainst_invoice_id/against_bill_idtags so the AR/AP sub-ledger nets to zero per document.preview()— onlyDRAFTentries.submitForApproval()— onlyDRAFTentries.
Reserved voucher types
SYSTEM_ONLY_VOUCHER_TYPES = { CLOSING, PAYMENT_RECONCILIATION }. Supplying one
of these to create()/update() is rejected:
'Voucher type <type> is reserved for system use and cannot be created manually.'
OPENING voucher type forces is_opening = true.
8. Account-merge invariants (AccountMergeService.merge)
Merging consolidates one leaf account’s history into another. Legality is checked
by AccountInvariantsService.assertMergeLegal(source, target, ctx):
AccountMergeService.merge() adds runtime guards on top:
- source/target must exist in the tenant+workspace →
NotFoundException - source already merged (
mergedIntoAccountIdset) →NotFoundException('… was already merged into …') - GL present but
confirmWithGl !== true→BadRequestException('… confirmWithGl must be true to proceed …')
queryRunner): it UPDATEs
accounting_ledger_entries and journal_lines SET account_id = target
(scoped by account_id, workspace_id, tenant_id), marks the source
mergedIntoAccountId = target + isActive = false, and writes an AccountMerge
audit row — all committed atomically, then a best-effort audit event
(auditService.record) outside the transaction (an audit-write failure must not
fail an already-committed merge).
Posting-issue codes (quick reference)
Codes emitted by the pipeline validators (posting/posting-issue.ts shape:
{ severity, code, message, fieldPath?, details? }):
Gaps / notes for reviewers
- The validator/guard services are pure (they take already-loaded accounts and
return issues); the decision to honor an issue (block vs. override) belongs to
the orchestrating posting service (
accounting-posting.service.ts), not documented in full here. - “Override roles” (
Merge with GL,Frozen accounts override,post-to-closed-period) are resolved upstream and passed in as booleans (userHasMergeWithGlRole,overrideAllowed); the role-name → permission mapping lives in the auth layer, not in these services.