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

# Accounting Posting Engine

> A deep dive into how a product transaction becomes a balanced, idempotent, double-entry ledger posting.

A deep dive into how a product transaction becomes a balanced, idempotent,
double-entry ledger posting. This covers the **intent → outbox → ledger**
pipeline, the idempotency keys and dedupe keys that make it safe to replay,
posting profiles and account routing, bulk posting, and how a new posting source
plugs in.

All paths are relative to `hitaji-erp-api/src/accounting`.

## The two write paths and one engine

There are two ways money enters the ledger; both converge on
`AccountingPostingService` (`services/accounting-posting.service.ts`).

1. **Synchronous bridge call** — a product posts a journal intent and waits for
   the result. `AccountingBridgeService.publishJournalIntent`
   (`services/accounting-bridge.service.ts`) resolves routing + accounts, calls
   `JournalService.create` then `JournalService.post`, which invokes the posting
   engine. Covered in [`accounting-bridge.md`](/accounting/developer/accounting-bridge).
2. **Asynchronous outbox** — a product writes an intent to a durable outbox row
   and returns immediately. A cron drains the outbox and replays each intent
   *through the same bridge*. This is the path described below as the "intent →
   outbox → ledger pipeline".

```mermaid theme={null}
flowchart LR
    P[Product module] -->|writeJournalIntent| OW[AccountingOutboxWriterService]
    OW -->|INSERT dedupe-keyed row| OB[(accounting_intent_outbox)]
    IP[AccountingIntentProcessorService<br/>cron every 30s] -->|findPendingItems| OB
    IP -->|publishJournalIntent| BR[AccountingBridgeService]
    BR --> JS[JournalService.create + post]
    JS --> PE[AccountingPostingService]
    PE --> LR[AccountingLedgerRepository]
    LR -->|SERIALIZABLE txn| GL[(accounting_ledger_entries)]
```

## Stage 1 — the outbox writer

`AccountingOutboxWriterService` (`services/accounting-outbox-writer.service.ts`)
turns an intent payload into a durable `accounting_intent_outbox` row.

* **Dedupe key** is deterministic: `` `${productKey}:${sourceType}:${sourceId}` ``
  (`generateDedupeKey`). Before inserting it calls
  `findByDedupeKey(tenantId, dedupeKey)`; on a hit it returns the existing
  outbox id rather than enqueuing a duplicate. The entity enforces this at the
  DB level too — `@Index(['tenantId','dedupeKey'], { unique: true })`
  (`entities/accounting-intent-outbox.entity.ts`).
* **`bookId` is intentionally left null** — "resolved at processing time by the
  bridge from `businessId`". The column is nullable for exactly this reason; it
  was once `NOT NULL` and 500'd every write.
* The row starts `status = PENDING`, `attemptCount = 0`, `availableAt = now()`.

## Stage 2 — the intent processor (drain loop)

`AccountingIntentProcessorService`
(`services/accounting-intent-processor.service.ts`) drains the outbox.

```
@Cron(EVERY_30_SECONDS) handleOutboxCron()
  → processOutbox()
      pending = findPendingItems(BATCH_SIZE=20)   // PENDING && availableAt<=now, ORDER BY dateCreated ASC
      for each chunk of CONCURRENCY=5:
        Promise.allSettled(chunk.map(processItem))
```

`processItem(item)` (`accounting-intent-processor.service.ts:88`):

1. **Optimistic lock** — `lockForProcessing(id, tenantId)` does
   `UPDATE ... SET status=PROCESSING WHERE id=? AND status=PENDING`. If
   `affected === 0` another worker already took it → return `false` (skipped).
   This is what makes the loop safe to run on multiple instances.
2. **Dispatch** — document intents (payload has a `document` property) go to
   `bridgeService.publishDocumentIntent`; everything else to
   `bridgeService.publishJournalIntent`. Dates stored as ISO strings are revived
   (`reconstructJournalPayload` / `reconstructDocumentPayload`).
3. **On success** — `markCompleted(id, tenantId)` (status `COMPLETED`,
   `processedAt = now`).
4. **On failure** — retry-or-dead-letter (next section).

### Retries, backoff, and the dead-letter queue

`MAX_RETRIES = 5`. On a thrown error the processor computes
`nextAttempt = item.attemptCount + 1`:

* If `nextAttempt >= MAX_RETRIES` → `markDeadLetter(...)` (terminal
  `DEAD_LETTER`, no further retries).
* Otherwise → `markFailed(id, tenantId, error, nextAvailableAt)` which resets
  the row to `PENDING`, sets a future `availableAt`, and increments
  `attempt_count` atomically (`"attempt_count" + 1`).

Backoff is exponential — `calculateBackoff(n) = 4^n × 30s` → **30s, 2m, 8m,
32m, 2h**. The processor re-throws after recording the failure so
`processOutbox` counts it.

> Dead-letter is the diagnostic surface. A recurring DEAD\_LETTER for a money path
> usually means a *configuration* gap (e.g. a missing system account in the
> book's chart), not a code bug — the engine fails loud rather than posting a
> half-entry.

## Stage 3 — the bridge and account resolution

Whichever path triggered it, the bridge does the same work before any GL row
exists (`AccountingBridgeService.publishJournalIntent`):

1. **Source-link idempotency** — looks up the `accounting_source_links` row for
   `(tenant, productKey, sourceType, sourceId)`. A `POSTED` link short-circuits
   with `created: false`.
2. **Routing** — `AccountingRoutingService.resolve({tenantId, businessId,
   bookId?})` maps the business to a concrete `bookId` + compatibility
   `workspaceId`. It honours an explicit `targetBookId` (multi-book products
   like fixed-assets), else the business `defaultBookId`, else the single active
   book — and throws `BOOK_ROUTING_AMBIGUOUS` rather than guessing among
   several.
3. **Profile / account resolution** — `PostingProfileResolver.resolveJournalLines`
   converts intent lines keyed by `accountSystemCode` into journal lines keyed
   by account UUID (next section).
4. Creates + posts the journal via `JournalService`, then stamps the source link
   `POSTED`.

### Posting profiles and system-code → account routing

`PostingProfileResolver` (`services/posting-profile-resolver.service.ts`)
removes hard-coded account IDs from products. Two mechanisms:

* **`resolveProfile(tenantId, bookId, transactionType)`** loads an
  `AccountingPostingProfile` (`entities/accounting-posting-profile.entity.ts`) —
  a `(book, productKey, transactionType)`-keyed row whose `rulesJson` follows
  `PostingProfileRule` (`interfaces/bridge.interfaces.ts`):
  `{ lines: [{ role: 'debit'|'credit', accountSystemCode }], autoPost?, journalSourceType? }`.
  Resolution prefers the highest `version` of a book-specific active profile.
* **`resolveJournalLines(intentLines, workspaceId, tenantId)`** is the path
  actually used by the bridge today. For each line:
  * a direct `accountId` wins verbatim (fixed-assets resolves accounts upstream);
  * otherwise `accountSystemCode` is looked up in the book's chart
    (`AccountsRepository.findBySystemCodes`), falling back to a built-in
    `DEFAULT_ACCOUNTS` map (`constants/default-accounts`);
  * **fail-loud (`gl2`)**: a *value-bearing* line whose account can't resolve
    throws `BadRequestException` with `code: 'JOURNAL_ACCOUNT_UNRESOLVED'` and
    the offending system codes — a missing control account surfaces as a precise
    error rather than a confusing "UNBALANCED" / "fewer than 2 lines". Zero-value
    placeholder lines are silently dropped.

## Stage 4 — the posting engine

`AccountingPostingService.postJournalEntryWithContext(entry, context)`
(`services/accounting-posting.service.ts:68`) is the heart.

### Idempotency key (engine-level, distinct from source links)

If `context.idempotencyKey` is set, the engine **claims the slot before
posting**:

```ts theme={null}
INSERT INTO posting_idempotency_keys (tenantId, journalEntryId, idempotencyKey)
// unique index (tenantId, journalEntryId, idempotencyKey)
// duplicate-key error → log + return (skip the write) — no TOCTOU window
```

The unique index (`entities/posting-idempotency-key.entity.ts`) is the ordering
authority; a duplicate insert means "already posted", so the method returns
without writing. Keys older than 30 days are pruned by a daily 3 AM cron
(`pruneIdempotencyKeys`).

### Validate → guard → merge → round-off

`validateAndPrepareRows` (line 198) builds the row drafts:

1. `loadAccountsCached(entry)` — loads referenced accounts (5s cache), hard-fails
   on any account outside the tenant/workspace.
2. `PostingValidatorService.validate(lines, accounts)` (`posting/posting-validator.service.ts`)
   collects `PostingIssue`s: `UNBALANCED` (|ΣDr − ΣCr| > 0.005),
   `MISSING_REQUIRED_FIELD` (both debit and credit set),
   `ACCOUNT_NOT_POSTABLE` (group / non-postable), `ACCOUNT_DISABLED`.
3. `FrozenAccountGuardService.check(...)` and
   `PeriodLockGuardService.check(...)` — the latter returns
   `ok | override | blocked`, emitting `PERIOD_LOCKED` / `PERIOD_CLOSED` issues
   (overridable via `context.overrides.periodLock`).
4. `PostingRowMergerService.merge(rows)` (opt-in via `context.mergeRows`) nets
   duplicate `(accountId, contact, category, costCenter, book, dims)` rows and
   drops rows that net to zero (`posting/posting-row-merger.service.ts`).
5. `RoundOffCalculatorService.calculate(...)` (opt-in via
   `context.applyRoundOff`) appends a single balancing round-off row against the
   `SYS_ROUND_OFF` system account (5-minute cache).

If `issues.length > 0`, it throws `BadRequestException` with
`code: 'POSTING_FAILED'` and the full issue list. Otherwise it calls the ledger
repository and emits `JOURNAL_POSTED_EVENT` (`events/journal-posted.event.ts`).

The `AccountingPostingContext` (`posting/posting-context.ts`) carries all the
opt-in flags; `withDefaults()` defaults `mergeRows`, `applyRoundOff`,
`isOpening`, and `overrides` to off/empty so legacy callers (`JournalService`)
keep one ledger row per journal line, while v2 callers (Invoice/Bill/Payment)
opt into merge + round-off.

### The ledger write (ordering & atomicity)

`AccountingLedgerRepository.createEntriesForJournalEntry`
(`repositories/accounting-ledger.repository.ts:103`) runs **one
`dataSource.transaction`**:

1. **Existence guard** — `count` ledger rows for this `journalEntryId`; if any
   exist, throw `Ledger entries already exist for posted journal entry` (replay
   protection at the GL layer).
2. **Write rows** — if the caller supplied pre-merged `rowDrafts`, they're
   written verbatim (`toLedgerEntryFromDraft`); otherwise one ledger row is
   synthesized per journal line (`toLedgerEntry`). Per-line `costCenterId` is
   folded into `dimensions.cost_center_id` so the Cost-Center P\&L sees tagged
   rows.
3. **WHT balance deltas** — when `whtBalanceDeltas` is present, a raw
   `INSERT ... ON CONFLICT DO UPDATE` upserts `withholding_balances` **inside the
   same transaction**, so a WHT failure rolls back the GL insert.

`AccountingLedgerEntry` (`entities/accounting-ledger-entry.entity.ts`) is the
immutable GL row: `debit`/`credit` `numeric(18,4)`, scoped by
`tenant_id`+`workspace_id`, carrying `sourceType`/`sourceId` for drill-through,
`dimensions` jsonb, `postingBatchId`, and reversal pointers
(`isReversal`, `reversalOfLedgerEntryId`).

## Reversals

`AccountingPostingService.createReversalEntries` delegates to
`AccountingLedgerRepository.createReversalEntries`, which loads the original
ledger rows and writes mirror rows with `isReversal = true` and
`reversalOfLedgerEntryId` pointing back at each original. At the bridge level,
`reverseBySource` flips the source link to `REVERSED` and records a *new*
`*_reversal` source link for the reversal journal so forward and reversal
reconcile separately.

## Bulk posting

`BulkPostingService.publishBulkJournalIntents`
(`services/bulk-posting.service.ts`) is **best-effort, not all-or-nothing**:

* Max `1000` intents per batch.
* Each intent is independent — a failure on intent N does **not** roll back
  0..N-1. (An outer transaction would be a lie because the bridge opens its own
  connection internally.)
* **In-batch dedupe** — intents sharing
  `(tenantId, productKey, sourceType, sourceId)` collapse: the first is
  published, later ones are reported `status='skipped', reason='duplicate_in_batch'`.
* **Bridge-level idempotency still applies** — an intent whose source already
  has a `POSTED` link comes back `created:false` → reported
  `reason='already_posted'`.
* Returns per-row `{index, status: posted|skipped|failed, reason?, result?, error?}`
  plus totals.

## Ordering & idempotency guarantees — summary

| Layer        | Key                                    | Mechanism                                              | Guarantee                           |
| ------------ | -------------------------------------- | ------------------------------------------------------ | ----------------------------------- |
| Outbox       | `dedupeKey` = `product:type:id`        | unique index + `findByDedupeKey`                       | one outbox row per source           |
| Outbox drain | row id                                 | `UPDATE ... WHERE status=PENDING` optimistic lock      | one worker processes a row          |
| Bridge       | source link `(tenant,product,type,id)` | `IDX_acctsrc_product_source` unique, claim-before-post | one journal per source              |
| Journal      | `(sourceType, sourceId)`               | `uq_journal_entries_source` partial unique             | duplicate-create recovers by lookup |
| Engine       | `idempotencyKey`                       | `posting_idempotency_keys` unique, insert-before-post  | duplicate post skips write          |
| Ledger       | `journalEntryId`                       | in-txn existence `count` guard                         | rows written at most once           |

## Extension points

* **Add a posting source** → see [`accounting-bridge.md`](/accounting/developer/accounting-bridge):
  publish a `JournalIntentPayload`, then add the `intentType → JournalSourceType`
  mapping in `accounting-bridge.service.ts` `mapIntentToSourceType` (an unmapped
  intent posts under `INTEGRATION` and logs a warning — a reconciliation blind
  spot, never a blocked posting).
* **Add an account-mapping profile** → insert an `AccountingPostingProfile`
  (versioned, `(book, productKey, transactionType)`).
* **Add a posting-time guard/validation** → add a service under `posting/` and
  call it from `validateAndPrepareRows` so it runs for sync *and* async paths.
* **Add a new dimension on GL rows** → extend `GLRowDraft`
  (`posting/posting-row-merger.service.ts`) and the `toLedgerEntry*` mappers, and
  include it in `PostingRowMergerService.keyOf` if it should affect netting.
