Skip to main content
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.
  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”.

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.
processItem(item) (accounting-intent-processor.service.ts:88):
  1. Optimistic locklockForProcessing(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 successmarkCompleted(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_RETRIESmarkDeadLetter(...) (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 × 30s30s, 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. RoutingAccountingRoutingService.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 resolutionPostingProfileResolver.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. If context.idempotencyKey is set, the engine claims the slot before posting:
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 PostingIssues: 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 guardcount 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

Extension points

  • Add a posting source → see accounting-bridge.md: 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.