Skip to main content
The bridge is the single integration surface through which every other product module — HR/payroll, SACCO, retail, fixed-assets, agri360, faith360, personal-finance — posts money into the general ledger. Products do not call 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_links row (entities/accounting-source-link.entity.ts), giving drill-through, reconciliation, replay, and dedupe.
  • Routing — products supply a businessId, not a book; the bridge resolves business → book → workspaceId and 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). A JournalIntentLine has no cost center; it lives in dimensions.cost_center_id and 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):
  1. Fast-path idempotencyfindSourceLink(...); a POSTED link returns immediately with created: false.
  2. RoutingroutingService.resolve({tenantId, businessId, bookId: targetBookId}).
  3. Claim the source link before postingclaimJournalSourceLink. The partial unique index IDX_acctsrc_product_source over (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-existing PENDING/FAILED link is claimed atomically (tryAtomicClaimExistingLink — a single UPDATE ... WHERE posting_status IN (PENDING,FAILED)) so two callers can’t both adopt it and leave a stale FAILED next to a live POSTED journal.
  4. Resolve linesprofileResolver.resolveJournalLines; fewer than 2 resolved lines → BadRequestException.
  5. Create + post the journal via JournalService. A uq_journal_entries_source violation (a second caller slipped through the narrow window) is recovered by journalRepository.findBySource rather than erroring.
  6. Finalize — stamp the source link POSTED with the journalEntryId. Any throw stamps it FAILED with lastError.
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 active EmploymentContract (workflowState = ACTIVE and docstatus = 1 — a draft Active contract must not authorize a route) in that business, then delegates to AccountingRoutingService.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).
The 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, employeeId FK). Safe under concurrency: a racing create that loses the partial-unique index (23505) re-reads the winner.
Staff money paths stamp this 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:
What the bridge then does, end to end:
  1. Idempotency check on (payroll, payroll_run, run.id) — a re-fire of the same run returns the prior result, no duplicate JE.
  2. Routing resolves businessId → bookId → workspaceId.
  3. resolveJournalLines maps salary_expense/paye_payable/salary_payable to the book’s chart UUIDs (a missing control account → JOURNAL_ACCOUNT_UNRESOLVED).
  4. JournalService.create + post runs the full posting engine (balance, period/frozen guards, ledger write) — see posting-engine.md.
  5. The source link is stamped POSTED with the journalEntryId; the connector gets back { created: true, accountingDocumentId, accountingBookId } and can store the JE id for drill-through.
To later reverse the run (e.g. correction), the connector calls the same bridge:
which reverses the journal, flips the source link to REVERSED, and records a payroll_run_reversal source link for the reversal entry.

Extension points

  • Onboard a new product → call publishJournalIntent (sync) or AccountingOutboxWriterService.writeJournalIntent (async). Always pass a stable (productKey, sourceType, sourceId) triple — it is your idempotency and drill-through key.
  • Add an intentType → extend mapIntentToSourceType; add the enum value to JournalSourceType (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 StaffPostingBookResolver and the counterparty through EmployeeContactProvisioningService; never invent book selection in the connector.
  • Support a new document type → wire BILL/CREDIT_NOTE in publishDocumentIntent and mapDocumentType (currently only INVOICE).