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

# The Accounting Bridge

> The bridge is the single integration surface through which every other product module — HR/payroll, SACCO, retail, fixed-assets, agri360, faith360,…

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()`.

| Method & path                                         | Purpose                                                |
| ----------------------------------------------------- | ------------------------------------------------------ |
| `POST /journal-intents`                               | Publish one journal intent                             |
| `POST /document-intents`                              | Publish one document intent (invoice/bill/credit note) |
| `POST /bulk-journal-intents`                          | Up to 1000 journal intents in a batch                  |
| `POST /reverse`                                       | Reverse a previously posted artifact by source         |
| `GET /source-links/:productKey/:sourceType/:sourceId` | Posting status for one source                          |
| `POST /source-links/statuses`                         | Posting status for many sources                        |

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`](/accounting/developer/posting-engine)).

### The payload — `JournalIntentPayload` (`interfaces/bridge.interfaces.ts`)

```ts theme={null}
interface JournalIntentPayload {
  tenantId: string;
  businessId: string;
  targetBookId?: string;        // multi-book products (Tax/IFRS/Management)
  productKey: string;           // e.g. 'payroll', 'sacco', 'fixed-assets'
  intentType: string;          // e.g. 'payroll.run.posted' → JournalSourceType
  sourceType: string;          // your record's type, e.g. 'payroll_run'
  sourceId: string;            // your record's id (idempotency coordinate)
  occurredAt: Date;
  idempotencyKey: string;
  actorUserId: string;
  memo?: string;
  dimensions?: Record<string,string>;  // entry-level, e.g. cost_center_id
  lines: JournalIntentLine[];  // keyed by accountSystemCode (or direct accountId)
}
```

> **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

```mermaid theme={null}
sequenceDiagram
    participant M as Product module
    participant B as AccountingBridgeService
    participant R as AccountingRoutingService
    participant PR as PostingProfileResolver
    participant J as JournalService
    participant SL as accounting_source_links

    M->>B: publishJournalIntent(intent)
    B->>SL: findSourceLink(tenant,product,type,id)
    alt existing POSTED
        B-->>M: { created:false, ...existing }  (idempotent hit)
    else
        B->>R: resolve(businessId[,targetBookId]) → book, workspaceId
        B->>SL: claimJournalSourceLink (insert-first / atomic claim)
        B->>PR: resolveJournalLines(systemCode → accountId)
        B->>J: create(entry) then post(entry)
        B->>SL: source link → POSTED (journalEntryId)
        B-->>M: { created:true, accountingDocumentId, accountingBookId }
    end
```

Step by step (`accounting-bridge.service.ts:70`):

1. **Fast-path idempotency** — `findSourceLink(...)`; a `POSTED` link returns
   immediately with `created: false`.
2. **Routing** — `routingService.resolve({tenantId, businessId, bookId:
   targetBookId})`.
3. **Claim the source link before posting** — `claimJournalSourceLink`. 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 lines** — `profileResolver.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:

```ts theme={null}
// inside the payroll module (pseudocode)
const businessId = await staffPostingBookResolver
  .resolveBusinessIdForEmployee(tenantId, anchorEmployeeId);          // or carried on the run
const { bookId } = await staffPostingBookResolver
  .resolveBook(tenantId, anchorEmployeeId, businessId);
const employeeContactId = await employeeContactProvisioning
  .ensureForBusiness(tenantId, employeeId, bookId);

await accountingBridge.publishJournalIntent({
  tenantId,
  businessId,
  productKey: 'payroll',
  intentType: 'payroll.run.posted',          // → JournalSourceType.PAYROLL
  sourceType: 'payroll_run',
  sourceId: run.id,                            // idempotency coordinate
  occurredAt: run.postingDate,
  idempotencyKey: `payroll:${run.id}`,
  actorUserId: userId,
  dimensions: { cost_center_id: run.costCenterId },
  lines: [
    { accountSystemCode: 'salary_expense',  debit: gross,  credit: 0 },
    { accountSystemCode: 'paye_payable',    debit: 0,      credit: paye },
    { accountSystemCode: 'salary_payable',  debit: 0,      credit: net,
      contactId: employeeContactId },
  ],
});
```

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`](/accounting/developer/posting-engine).
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:

```ts theme={null}
await accountingBridge.reverseBySource({
  tenantId, businessId,
  productKey: 'payroll', sourceType: 'payroll_run', sourceId: run.id,
  actorUserId: userId,
});
```

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`).
