> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hitaji360.com/llms.txt
> Use this file to discover all available pages before exploring further.

# General-Ledger Invariants

> The rules that keep the ledger correct, and exactly where each one is enforced.

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

| Layer                        | File                                                          | Responsibility                                                                                  |
| ---------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Document service             | `services/journal.service.ts`                                 | Balancing, postable-account, voucher-type, status-lifecycle gates on create/update/post/reverse |
| Posting validator            | `posting/posting-validator.service.ts`                        | Pure re-validation of the line set just before ledger rows are written                          |
| Period gate (typed)          | `services/period-close.service.ts` → `assertPostingAllowed()` | Closed/locked-period rejection with the stable `PeriodClosedException`                          |
| Period gate (override-aware) | `posting/period-lock-guard.service.ts`                        | OPEN/CLOSED/LOCKED decision with override + cache, used inside the posting pipeline             |
| Frozen-account gate          | `posting/frozen-account-guard.service.ts`                     | Blocks posting to frozen accounts unless override role                                          |
| Account structure            | `services/account-invariants.service.ts`                      | Reparent / disable / merge / freeze legality                                                    |
| Account merge                | `services/account-merge.service.ts`                           | Transactional GL-rewrite merge, enforces merge rules + GL-history protection                    |

***

## 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 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 pipeline** — `PostingValidatorService.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.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')`
* **`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`:

| Method                                                  | Rule(s)                                                                                                                            | Exception             |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `assertReparentLegal(source, newParent, descendantIds)` | parent ≠ self; new parent must be `isGroup`; **root\_type may not change on reparent**; cannot reparent under a descendant (cycle) | `BadRequestException` |
| `assertDisableLegal(account, disabled, ctx)`            | cannot disable a business-default account; cannot disable an account with unreconciled GL in an open period                        | `BadRequestException` |
| `assertFreezeLegal(account, freeze, ctx)`               | freezing requires the "Frozen accounts override" role                                                                              | `ForbiddenException`  |

> `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()` 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:

| Period status     | `overrideAllowed` | Result                                                                                                    |
| ----------------- | ----------------- | --------------------------------------------------------------------------------------------------------- |
| none found / OPEN | —                 | `{ kind: 'ok' }`                                                                                          |
| LOCKED            | —                 | `{ kind: 'blocked', issue: code 'PERIOD_LOCKED' }` — *no override path; requires a reopen workflow*       |
| CLOSED            | `false`           | `{ kind: 'blocked', issue: code 'PERIOD_CLOSED' }` (message: "requires the 'post-to-closed-period' role") |
| CLOSED            | `true`            | `{ kind: 'override', period }`                                                                            |

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)`:

| Rule                                                         | Exception                                                                                 |
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| source ≠ target                                              | `BadRequestException('Cannot merge an account into itself')`                              |
| same `workspace_id`                                          | `BadRequestException('Merge requires same workspace')`                                    |
| same `root_type`                                             | `BadRequestException('Merge requires same root type')`                                    |
| same `type`                                                  | `BadRequestException('Merge requires same account type')`                                 |
| neither is a system account                                  | `BadRequestException('System accounts cannot be merged')`                                 |
| both are leaves (not `isGroup`)                              | `BadRequestException('Merge is only allowed between leaf accounts')`                      |
| source has **no** GL in a closed/locked period               | `BadRequestException('Cannot merge an account with history in closed or locked periods')` |
| if source has GL rows, caller holds the "Merge with GL" role | `ForbiddenException('… "Merge with GL" override role required')`                          |

`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 !== true` → `BadRequestException('… confirmWithGl must be true to proceed …')`

The rewrite runs in a single transaction (`queryRunner`): it `UPDATE`s
`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? }`):

| Code                     | Raised by                                       | Meaning                                           |
| ------------------------ | ----------------------------------------------- | ------------------------------------------------- |
| `UNBALANCED`             | `PostingValidatorService`                       | Debit total ≠ credit total (> 0.005)              |
| `MISSING_REQUIRED_FIELD` | `PostingValidatorService`                       | Line has both debit and credit                    |
| `ACCOUNT_NOT_POSTABLE`   | `PostingValidatorService`                       | Unknown / group / non-postable account            |
| `ACCOUNT_DISABLED`       | `PostingValidatorService`                       | Posting to an inactive account                    |
| `ACCOUNT_FROZEN`         | `FrozenAccountGuardService`                     | Posting to a frozen account without override      |
| `PERIOD_CLOSED`          | `PeriodCloseService` / `PeriodLockGuardService` | Posting date falls in a closed period             |
| `PERIOD_LOCKED`          | `PeriodCloseService` / `PeriodLockGuardService` | Posting date falls in a locked period             |
| `BOOK_ROUTING_AMBIGUOUS` | `AccountingRoutingService`                      | No default book + >1 active book for the business |

## 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.
