JournalService, InvoicesService, or PaymentsService directly; they publish
intents and the bridge owns all posting logic.
All paths are relative to hitaji-erp-api/src/accounting.
Why a bridge
Centralising product → GL integration in one place buys:- Idempotency & traceability — every product transaction maps to exactly one
accounting_source_linksrow (entities/accounting-source-link.entity.ts), giving drill-through, reconciliation, replay, and dedupe. - Routing — products supply a
businessId, not a book; the bridge resolvesbusiness → book → workspaceIdand refuses to guess when ambiguous. - Account abstraction — products name accounts by stable
systemCode(cash,sales_revenue,staff_advances); the bridge resolves them to the book’s actual chart UUIDs. - Decoupling — a product never imports a GL service, so the chart, posting pipeline, and period rules can evolve behind a stable intent contract.
The contract surface
HTTP — AccountingBridgeController (controllers/accounting-bridge.controller.ts)
Mounted at /accounting/bridge, guarded by @AdminOrJwtAuth() +
TenantAuthGuard, mutating routes @RequirePermissions('accounting:write') and
@Idempotent(), with the active business resolved via @BusinessCtx().
The body
businessId/productKey win over the header context when both are
present (a product posting on behalf of several businesses carries the target in
the payload).
In-process — AccountingBridgeService (services/accounting-bridge.service.ts)
Co-located product modules can inject AccountingBridgeService and call
publishJournalIntent, publishDocumentIntent, reverseBySource,
getPostingStatus(es) directly — or write to the outbox
(AccountingOutboxWriterService) for async delivery (see
posting-engine.md).
The payload — JournalIntentPayload (interfaces/bridge.interfaces.ts)
Cost center is entry-level, not per-line (gl3). AJournalIntentLinehas no cost center; it lives indimensions.cost_center_idand is projected onto every line. A connector needing different cost centers per line must split into per-cost-center intents.
How publishJournalIntent works
Step by step (accounting-bridge.service.ts:70):
- Fast-path idempotency —
findSourceLink(...); aPOSTEDlink returns immediately withcreated: false. - Routing —
routingService.resolve({tenantId, businessId, bookId: targetBookId}). - Claim the source link before posting —
claimJournalSourceLink. The partial unique indexIDX_acctsrc_product_sourceover(tenant_id, product_key, source_type, source_id)is the ordering authority: exactly one of N concurrent identical intents wins the insert; losers re-read. A pre-existingPENDING/FAILEDlink is claimed atomically (tryAtomicClaimExistingLink— a singleUPDATE ... WHERE posting_status IN (PENDING,FAILED)) so two callers can’t both adopt it and leave a staleFAILEDnext to a livePOSTEDjournal. - Resolve lines —
profileResolver.resolveJournalLines; fewer than 2 resolved lines →BadRequestException. - Create + post the journal via
JournalService. Auq_journal_entries_sourceviolation (a second caller slipped through the narrow window) is recovered byjournalRepository.findBySourcerather than erroring. - Finalize — stamp the source link
POSTEDwith thejournalEntryId. Any throw stamps itFAILEDwithlastError.
publishDocumentIntent is the same shape but creates an invoice via
InvoicesService (only INVOICE is wired today; BILL/CREDIT_NOTE throw).
For documents the source-link claim is the only duplicate guard — there is
no uq_journal_entries_source equivalent on document tables.
intentType → JournalSourceType mapping
mapIntentToSourceType (accounting-bridge.service.ts:680) maps the
product-facing intent string to the GL JournalSourceType enum so postings
reconcile by source bucket — e.g. payroll.run.posted → PAYROLL,
sacco.loan.disbursement → SACCO_LOAN, hr.gratuity.accrued → GRATUITY_PROVISION, separation.settlement.posted → SEPARATION_SETTLEMENT. An
unmapped intent posts under INTEGRATION and logs a warning — the GL still
balances (a missing mapping never blocks money) but it is a reconciliation blind
spot, so add the mapping when introducing a connector.
Staff money — the resolver + provisioning pair
HR/payroll/staff-credit connectors face two extra questions the generic bridge doesn’t: which book? and which GL counterparty? Two services answer them.StaffPostingBookResolver (services/staff-posting-book-resolver.service.ts)
The single authority for “which book does this staff member’s money post into?”.
Every staff connector (advances, employee-loans, separation, payroll matching,
settlement) resolves its book here rather than inventing an answer.
resolveBook(tenantId, employeeId, businessId)— validates the employee has an activeEmploymentContract(workflowState = ACTIVEanddocstatus = 1— a draft Active contract must not authorize a route) in that business, then delegates toAccountingRoutingService.resolve. No active contract →STAFF_NO_ACTIVE_CONTRACT.resolveBusinessIdForEmployee(tenantId, employeeId)— when the business is unknown, derives it from the employee’s active contracts: exactly one → that business; none →STAFF_NO_ACTIVE_CONTRACT; multiple →STAFF_MULTI_BUSINESS(the caller must disambiguate — the resolver never guesses).
EmploymentContract repo is pulled from the shared DataSource so
accounting needn’t import HR into its forFeature.
EmployeeContactProvisioningService (services/employee-contact-provisioning.service.ts)
Lazily provisions (and idempotently reuses) the per-book EMPLOYEE Contact
that represents a staff member as a GL counterparty — the per-tenant → per-book
bridge: one hr_employees row fans out to one EMPLOYEE Contact per accounting
book it transacts in.
ensureForBusiness(tenantId, employeeId, bookId)returns the contact id, creating it on first use (Contact.workspaceId == bookId,type = ContactType.EMPLOYEE,employeeIdFK). Safe under concurrency: a racing create that loses the partial-unique index (23505) re-reads the winner.
contactId on the journal-intent line, so GL
balances sub-ledger by real person and the aged AR/AP “Employee” party views
light up.
Worked example — an external module posts a payroll run
A payroll connector that has just finalized a run posts the accrual:- Idempotency check on
(payroll, payroll_run, run.id)— a re-fire of the same run returns the prior result, no duplicate JE. - Routing resolves
businessId → bookId → workspaceId. resolveJournalLinesmapssalary_expense/paye_payable/salary_payableto the book’s chart UUIDs (a missing control account →JOURNAL_ACCOUNT_UNRESOLVED).JournalService.create+postruns the full posting engine (balance, period/frozen guards, ledger write) — seeposting-engine.md.- The source link is stamped
POSTEDwith thejournalEntryId; the connector gets back{ created: true, accountingDocumentId, accountingBookId }and can store the JE id for drill-through.
REVERSED, and records a
payroll_run_reversal source link for the reversal entry.
Extension points
- Onboard a new product → call
publishJournalIntent(sync) orAccountingOutboxWriterService.writeJournalIntent(async). Always pass a stable(productKey, sourceType, sourceId)triple — it is your idempotency and drill-through key. - Add an
intentType→ extendmapIntentToSourceType; add the enum value toJournalSourceType(enums/journal-source-type.enum.ts) when a new bucket is warranted, so the source-type reconciliation views stay accurate. - New staff money path → resolve the book through
StaffPostingBookResolverand the counterparty throughEmployeeContactProvisioningService; never invent book selection in the connector. - Support a new document type → wire
BILL/CREDIT_NOTEinpublishDocumentIntentandmapDocumentType(currently onlyINVOICE).