Skip to main content
The rules that keep the ledger correct, and exactly where each one is enforced. Every invariant below maps to real code under 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/updateJournalService.create() / update():
    • debit > 0 && credit > 0BadRequestException('A journal line cannot have both debit and credit values')
    • debit === 0 && credit === 0BadRequestException('A journal line must have either a debit or credit value')
    • Math.abs(totalDebit - totalCredit) > 0.01BadRequestException('Journal entry must balance. Debit: …, Credit: …')
    • Fewer than 2 lines → BadRequestException('Journal entry must have at least 2 lines')
  • Re-enforced on postJournalService.post() recomputes the totals from the persisted lines and throws BadRequestException('Journal entry does not balance') if they drift (guards against a line edited out-of-band after draft validation).
  • Re-enforced in the pipelinePostingValidatorService.validate() accumulates debit/credit across the line set and emits a code: 'UNBALANCED' issue when Math.abs(totalDebit - totalCredit) > 0.005, plus a MISSING_REQUIRED_FIELD issue 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.isActiveBadRequestException('Account <code> is not active')
    • account.isGroupBadRequestException('… is a group account and cannot be posted to directly')
    • !account.allowDirectPostingBadRequestException('… does not allow direct posting')
  • PostingValidatorService.validate() repeats this independently of the service, emitting issues ACCOUNT_NOT_POSTABLE (unknown / group / non-postable) and ACCOUNT_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).
  • MergeAccountInvariantsService.assertMergeLegal() throws BadRequestException('System accounts cannot be merged') if either side is a system account.
  • System accounts are also typically isGroup = false leaves with allow_direct_posting controlled 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 } — emits code: 'ACCOUNT_FROZEN' issues ('Account <code> is frozen; posting requires override.')
    Freezing itself is gated by 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 period
  • code: 'PERIOD_LOCKED' for a locked period
This is called from every lifecycle transition in 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 repo CLAUDE.md): downstream products (e.g. the SACCO bridge UI) pattern-match on body.code. Do not replace this with a generic throw 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() — only DRAFT entries ('Only DRAFT journal entries can be updated/deleted').
  • post()POSTED again → 'Journal entry is already posted'; REVERSED'Cannot post a reversed entry'.
  • reverse() — only POSTED entries ('Only posted entries can be reversed'); creates a new entry with debits/credits swapped, voucher type REVERSAL, carrying the same against_invoice_id/against_bill_id tags so the AR/AP sub-ledger nets to zero per document.
  • preview() — only DRAFT entries.
  • submitForApproval() — only DRAFT entries.

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 (mergedIntoAccountId set) → NotFoundException('… was already merged into …')
  • GL present but confirmWithGl !== trueBadRequestException('… confirmWithGl must be true to proceed …')
The rewrite runs in a single transaction (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.