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

# Multi-Tenancy & Business Isolation

> How the Accounting service keeps one tenant's (and one business's) books from ever bleeding into another's.

How the Accounting service keeps one tenant's (and one business's) books from
ever bleeding into another's. Grounded in the real guards, decorators and
services under `hitaji-erp-api/src/`.

## The three scoping identifiers

Accounting isolation is a **layered** model, not a single tenant column:

| Identifier     | What it is                                                                                                        | How it's carried                             | Trust                                                              |
| -------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
| `tenant_id`    | The hard isolation boundary. A row in tenant A is invisible to tenant B, full stop.                               | JWT `tenant_id` / `tenantId` claim (signed)  | Trusted only from the signed JWT claim (or the admin-API-key path) |
| `workspace_id` | The **accounting book** the row belongs to. `bookId` ↔ `workspaceId` are 1:1 during the migration window.         | Request body (`POST`/`PUT`) or query (`GET`) | Validated against the book by `AccountingWorkspaceGuard`           |
| `business_id`  | Which business inside the tenant the operation is scoped to. For transactional rows, resolved *through* the book. | `X-Business-Id` HTTP header                  | Validated against the book's `businessId` (fail-closed)            |

> **Naming trap.** `@CurrentTenant()` is a **legacy alias for `@TenantId()`** — it
> returns the validated tenant id, *not* a workspace id, even though many
> call-sites bind it to a parameter named `workspaceId`. Prefer `@TenantId()` in
> new code. (See `tenant/decorators/current-tenant.decorator.ts`.)

```mermaid theme={null}
flowchart TD
    JWT["JWT (signed)\ntenant_id claim"] -->|extractTenantId| TID[tenant_id]
    HDR["X-Business-Id header"] --> BID[business_id]
    REQ["body/query workspaceId"] --> WID[workspace_id]
    TID --> GUARD{AccountingWorkspaceGuard}
    WID --> GUARD
    BID --> GUARD
    GUARD -->|"book.tenantId == tenant_id\nbook.businessId == business_id"| OK[allow]
    GUARD -->|"mismatch / missing header"| DENY["403 / 400 (fail-closed)"]
```

## How tenant id is resolved (and why the header is not enough)

`extractBusinessContext()` (`common/business-context/business-context.ts`) and the
accounting guard's `extractTenantId()` resolve the tenant id in this order:

1. JWT `tenant_id` claim (snake\_case, legacy)
2. JWT `tenantId` claim (camelCase, newer issuers)
3. `request.tenantId` set by `AdminApiKeyGuard` after it validates the admin API
   key — the **only** path where a raw `X-Tenant-Id` header is honored
4. (admin-key callers only) raw `X-Tenant-Id` header

For a normal JWT-authenticated user, **only the signed claim is trusted**. A
client-supplied `X-Tenant-Id` header is ignored for such callers — honoring it
would let any user impersonate another tenant simply by setting a header (the
claim is signed; the header is not). The accounting guard's `extractTenantId()`
deliberately returns `null` for a missing claim rather than falling back to the
JWT `sub` (user id), so a missing tenant claim is treated as "unknown tenant,"
not "this user's id."

## The `AccountingWorkspaceGuard` (the front door)

Every workspace-routed accounting endpoint sits behind `AccountingWorkspaceGuard`
(`accounting/guards/accounting-workspace.guard.ts`), alongside `TenantAuthGuard`
and the `@RequirePermissions` permission gate. Its job:

> Validate that `workspace_id` resolves to an **active accounting book owned by
> the caller's tenant**, and — when `X-Business-Id` is supplied — that the book
> belongs to that business.

Key behaviors:

* **Reads *and* writes are covered.** It extracts `workspaceId` from **both** the
  query and the body (`extractWorkspaceId`). Reading the query alone would let a
  body-routed write (`POST`/`PUT`) move `workspaceId` into the JSON body and skip
  the consistency check entirely.
* **`X-Business-Id` is required when a workspace is present.** The guard calls
  `WorkspaceBusinessScopeService.assertWorkspaceInBusiness({ …, requireLinkedBusiness: true })`.
  With `requireLinkedBusiness: true`, an **absent** `X-Business-Id` header is a
  **deny** (`ForbiddenException`), not "any business of the tenant." This closes
  the old hole where an unscoped finance role could read across every business by
  omitting the header.
* **No workspace → pass.** Endpoints that don't route by `workspaceId` (bridge,
  settings, some list endpoints) return `true` from the guard; those controllers
  enforce business-id consistency themselves.
* **No tenant → pass to downstream.** A missing tenant id means a pre-auth state;
  the guard defers to the auth layers rather than guessing.

## The fail-closed core: `WorkspaceBusinessScopeService`

`assertWorkspaceInBusiness()` (`common/business-context/workspace-business-scope.service.ts`)
is the shared workspace↔business consistency check. It loads the book by either
`id = workspaceId` or `workspaceId = workspaceId` (both scoped to `tenant_id`,
`is_deleted = false`) and then:

| Condition                                                                        | Outcome                                                                       |
| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| No book matches the workspace in this tenant                                     | `BadRequestException('Workspace … does not belong to any accounting book.')`  |
| `requireLinkedBusiness` and no `X-Business-Id`                                   | `ForbiddenException('X-Business-Id header is required …')`                    |
| Book has **no** `businessId` but route is business-scoped (or a header was sent) | `BadRequestException('Accounting book … is not linked to a business.')`       |
| `X-Business-Id` present **and** `book.businessId !== businessId`                 | `ForbiddenException('… belongs to a different business than X-Business-Id.')` |
| Otherwise                                                                        | pass                                                                          |

The cross-business mismatch (last-but-one row) is the most dangerous routing
error — a client pointing at a book from a different business than the UI context
claims — and it is rejected with **403**, not silently allowed.

## Per-business books: `AccountingRoutingService`

Inbound product postings (payroll, retail, SACCO, etc.) don't carry a
`workspaceId` — they carry a `businessId` and rely on
`AccountingRoutingService` (`services/accounting-routing.service.ts`) as the
**single authority** for `tenant + business → book → workspace`:

* `resolve({ tenantId, businessId, productKey?, bookId? })` — validates the
  business belongs to the tenant, then picks the target book:
  1. an explicit `bookId` (must belong to that business, else `NotFoundException`)
  2. the business's `defaultBookId`
  3. **exactly one** active book (auto-adopt with a warning)
  4. zero active books → `NotFoundException`; **more than one** active book with
     no default → `BadRequestException` `code: 'BOOK_ROUTING_AMBIGUOUS'`
     ("Refusing to guess").
* `resolveBusinessIdForWorkspace({ tenantId, workspaceId })` — the reverse
  direction, used by modules that already hold a `workspaceId` (e.g.
  `JournalService.postWithCurrentPipeline`). Throws if the book is unlinked to a
  business.
* `resolveByProduct(...)` is intentionally a **hard rejection** — after the
  multi-business cutover every adapter must pass an explicit `businessId`.
  `resolveContextByProduct(...)` is the narrow exception for products with no
  businessId at all (e.g. personal-finance reimbursements): it routes only when
  **exactly one** business is enrolled in the product, otherwise returns `null`
  so the caller degrades (skips GL) instead of mis-routing money.

## The `@BusinessCtx()` decorator (the cross-module pattern)

For controllers that take business context directly (the HR/payroll modules, and
the accounting bridge/settings endpoints that don't route by `workspaceId`), the
canonical accessor is the `@BusinessCtx()` parameter decorator
(`common/business-context/business-context.decorator.ts`), which yields a
normalized `RequestBusinessContext`:

```ts theme={null}
interface RequestBusinessContext {
  tenantId: string | null;   // signed JWT claim (or admin-key path)
  businessId: string | null; // X-Business-Id header
  productKey: string | null; // X-Product-Key header
}
```

It **never fabricates** missing ids — every absent field is `null`, forcing the
handler to make an explicit decision. Two narrowing helpers enforce that decision:

* `requireTenantId(ctx)` → `UnauthorizedException('Tenant context required')` if null.
* `requireBusinessId(ctx)` → `UnauthorizedException('X-Business-Id header is required …')`
  if null — use this on routes that are always scoped to a single business.

> Native accounting CRUD controllers (`invoices`, `bills`, `payments`, …) instead
> compose `TenantAuthGuard` + `AccountingWorkspaceGuard` + `@CurrentTenant()`
> (= `@TenantId()`) and route by `workspaceId`. The two patterns coexist:
> workspace-routed endpoints get their business check from
> `AccountingWorkspaceGuard`; bridge/settings/HR endpoints get theirs from
> `@BusinessCtx()` + `requireBusinessId()`.

## The gotcha: reads, lists *and* mutations must all be scoped

Tenant/business isolation is **not** a single guard at the door — every
repository query must also carry the scoping predicates, because a guard only
proves the *book* is in-tenant/in-business, not that a given *row* is.

The consistent pattern across services (e.g. `PeriodCloseService`,
`JournalService`, `AccountMergeService`) is to filter **every** query by
`tenant_id` AND `workspace_id`:

```ts theme={null}
// reads / lists
await repo.find({ where: { tenantId, workspaceId, isDeleted: false } });

// mutations re-scope in the WHERE clause, never trust the id alone
`UPDATE accounting_ledger_entries SET account_id = $1
   WHERE account_id = $2 AND workspace_id = $3 AND tenant_id = $4`
```

This is the exact bug class that the HR full-suite QA campaign closed across the
ERP: a `businessId`/`tenantId` stamped on **create** but **ignored on
read/list/mutate/delete** lets one business read or mutate another's money, GL,
and PII (a suite-wide cross-business IDOR). The rule:

> **If you add a handler, scope the read, the list, the update, *and* the delete —
> not just the insert.** A row lookup by `id` alone (`findOne({ where: { id } })`)
> is a cross-tenant/cross-business leak waiting to happen; always include
> `tenantId` (and `workspaceId`/`businessId` where the entity carries it).

## Quick checklist for a new accounting endpoint

1. Guard with `TenantAuthGuard` + `AccountingWorkspaceGuard` (workspace-routed) or
   read `@BusinessCtx()` + `requireBusinessId()` (business-routed).
2. Take the tenant from `@CurrentTenant()`/`@TenantId()` (the signed claim) — never
   from a header for a normal user.
3. Pass `tenantId` + `workspaceId` (and `businessId` where applicable) into
   **every** repository call — `find`, `findOne`, `update`, `softDelete`.
4. For inbound product postings, resolve the book via `AccountingRoutingService`
   with an explicit `businessId`; let `BOOK_ROUTING_AMBIGUOUS` surface rather than
   guessing a book.

## Gaps / notes for reviewers

* `business_id` is a real column on a subset of master/internal tables (`tax_codes`,
  `withholding_codes`, `withholding_balances`, `payment_terms`,
  `journal_entry_templates`, and the posting-internals `accounting_intent_outbox` /
  `accounting_source_links` / `accounting_posting_profiles`). Transactional rows
  (invoices, bills, payments, journal entries, ledger entries) do **not** carry
  `business_id`; their owning business is derived from the book via
  `AccountingRoutingService`. Confirm the entity's columns before assuming a
  `businessId` filter is available on a given table.
* Row-level scoping is enforced by convention in service/repository code, not by a
  Postgres RLS policy. There is no database-level tenant guarantee — the
  application predicates are the guarantee.
