Skip to main content
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:
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.)

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