hitaji-erp-api/src/accounting.
The two write paths and one engine
There are two ways money enters the ledger; both converge onAccountingPostingService (services/accounting-posting.service.ts).
- Synchronous bridge call — a product posts a journal intent and waits for
the result.
AccountingBridgeService.publishJournalIntent(services/accounting-bridge.service.ts) resolves routing + accounts, callsJournalService.createthenJournalService.post, which invokes the posting engine. Covered inaccounting-bridge.md. - 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 callsfindByDedupeKey(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). bookIdis intentionally left null — “resolved at processing time by the bridge frombusinessId”. The column is nullable for exactly this reason; it was onceNOT NULLand 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):
- Optimistic lock —
lockForProcessing(id, tenantId)doesUPDATE ... SET status=PROCESSING WHERE id=? AND status=PENDING. Ifaffected === 0another worker already took it → returnfalse(skipped). This is what makes the loop safe to run on multiple instances. - Dispatch — document intents (payload has a
documentproperty) go tobridgeService.publishDocumentIntent; everything else tobridgeService.publishJournalIntent. Dates stored as ISO strings are revived (reconstructJournalPayload/reconstructDocumentPayload). - On success —
markCompleted(id, tenantId)(statusCOMPLETED,processedAt = now). - 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(...)(terminalDEAD_LETTER, no further retries). - Otherwise →
markFailed(id, tenantId, error, nextAvailableAt)which resets the row toPENDING, sets a futureavailableAt, and incrementsattempt_countatomically ("attempt_count" + 1).
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):
- Source-link idempotency — looks up the
accounting_source_linksrow for(tenant, productKey, sourceType, sourceId). APOSTEDlink short-circuits withcreated: false. - Routing —
AccountingRoutingService.resolve({tenantId, businessId, bookId?})maps the business to a concretebookId+ compatibilityworkspaceId. It honours an explicittargetBookId(multi-book products like fixed-assets), else the businessdefaultBookId, else the single active book — and throwsBOOK_ROUTING_AMBIGUOUSrather than guessing among several. - Profile / account resolution —
PostingProfileResolver.resolveJournalLinesconverts intent lines keyed byaccountSystemCodeinto journal lines keyed by account UUID (next section). - Creates + posts the journal via
JournalService, then stamps the source linkPOSTED.
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 anAccountingPostingProfile(entities/accounting-posting-profile.entity.ts) — a(book, productKey, transactionType)-keyed row whoserulesJsonfollowsPostingProfileRule(interfaces/bridge.interfaces.ts):{ lines: [{ role: 'debit'|'credit', accountSystemCode }], autoPost?, journalSourceType? }. Resolution prefers the highestversionof a book-specific active profile.resolveJournalLines(intentLines, workspaceId, tenantId)is the path actually used by the bridge today. For each line:- a direct
accountIdwins verbatim (fixed-assets resolves accounts upstream); - otherwise
accountSystemCodeis looked up in the book’s chart (AccountsRepository.findBySystemCodes), falling back to a built-inDEFAULT_ACCOUNTSmap (constants/default-accounts); - fail-loud (
gl2): a value-bearing line whose account can’t resolve throwsBadRequestExceptionwithcode: '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.
- a direct
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)
Ifcontext.idempotencyKey is set, the engine claims the slot before
posting:
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:
loadAccountsCached(entry)— loads referenced accounts (5s cache), hard-fails on any account outside the tenant/workspace.PostingValidatorService.validate(lines, accounts)(posting/posting-validator.service.ts) collectsPostingIssues:UNBALANCED(|ΣDr − ΣCr| > 0.005),MISSING_REQUIRED_FIELD(both debit and credit set),ACCOUNT_NOT_POSTABLE(group / non-postable),ACCOUNT_DISABLED.FrozenAccountGuardService.check(...)andPeriodLockGuardService.check(...)— the latter returnsok | override | blocked, emittingPERIOD_LOCKED/PERIOD_CLOSEDissues (overridable viacontext.overrides.periodLock).PostingRowMergerService.merge(rows)(opt-in viacontext.mergeRows) nets duplicate(accountId, contact, category, costCenter, book, dims)rows and drops rows that net to zero (posting/posting-row-merger.service.ts).RoundOffCalculatorService.calculate(...)(opt-in viacontext.applyRoundOff) appends a single balancing round-off row against theSYS_ROUND_OFFsystem account (5-minute cache).
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:
- Existence guard —
countledger rows for thisjournalEntryId; if any exist, throwLedger entries already exist for posted journal entry(replay protection at the GL layer). - 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-linecostCenterIdis folded intodimensions.cost_center_idso the Cost-Center P&L sees tagged rows. - WHT balance deltas — when
whtBalanceDeltasis present, a rawINSERT ... ON CONFLICT DO UPDATEupsertswithholding_balancesinside 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
1000intents 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 reportedstatus='skipped', reason='duplicate_in_batch'. - Bridge-level idempotency still applies — an intent whose source already
has a
POSTEDlink comes backcreated:false→ reportedreason='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 aJournalIntentPayload, then add theintentType → JournalSourceTypemapping inaccounting-bridge.service.tsmapIntentToSourceType(an unmapped intent posts underINTEGRATIONand 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 fromvalidateAndPrepareRowsso it runs for sync and async paths. - Add a new dimension on GL rows → extend
GLRowDraft(posting/posting-row-merger.service.ts) and thetoLedgerEntry*mappers, and include it inPostingRowMergerService.keyOfif it should affect netting.