Skip to main content
A developer’s map of the Hitaji 360 accounting service. This document describes how the module is layered, how its ~40 controllers and services group by domain, how a write request travels from HTTP down to a balanced double-entry ledger posting, and where the multi-tenancy and period guards sit. All paths below are relative to hitaji-erp-api/src/accounting.

Purpose & scope

The accounting module is the single general-ledger authority for the whole platform. It owns the chart of accounts, the journal/ledger, invoices, bills, expenses, payments, budgets, contacts, financial reports — and, crucially, the bridge through which every other product (HR/payroll, SACCO, retail, fixed-assets, agri360, faith360, personal-finance) posts money into the ledger without touching the GL primitives directly. See posting-engine.md and accounting-bridge.md for the two deepest seams.

Layering

A few cross-cutting layers wrap every write:
  • Auth/tenant: TenantAuthGuard (src/tenant/guards), the global PermissionsGuard (src/auth/permissions.guard.ts) keyed off @RequirePermissions(...), and the @BusinessCtx() decorator (src/common/business-context) which resolves the active businessId.
  • Idempotency: the @Idempotent() decorator (src/common/idempotency) on mutating bridge endpoints.
  • Events: EventEmitter2 — posting emits JOURNAL_POSTED_EVENT (events/journal-posted.event.ts); listeners live in listeners/.
  • Scheduling: @nestjs/schedule cron jobs (outbox drain, idempotency-key prune, overdue reminders, recurring transactions).

Module map

accounting.module.ts registers 39 controllers and a large provider list. The controllers/services group into the following domains.

General ledger core

Receivables / payables (sub-ledger documents)

Cash, bank, petty cash

Periods, close, tax

Reporting & budgets

Integration surface (the bridge)

Settings, audit, documents

workspace-settings.controller.ts, print-settings.controller.ts, terms-templates.controller.ts, accounting-audit.controller.ts over print-settings.service.ts, terms-templates.service.ts, accounting-audit.service.ts, document-sequence.service.ts, document-template.service.ts, document-pdf.service.ts, pdf-render.service.ts.

How a write becomes a ledger posting

There are two front doors that converge on the same posting engine.

Front door A — a user posts a manual journal

JournalService.post() (services/journal.service.ts:324) re-checks balance, calls PeriodCloseService.assertPostingAllowed(), transitions the workflow state, flips the entry to POSTED, then calls the private postWithCurrentPipeline() (journal.service.ts:718) which builds an AccountingPostingContext (resolving businessId via AccountingRoutingService.resolveBusinessIdForWorkspace) and hands off to AccountingPostingService.postJournalEntryWithContext().

Front door B — another product posts through the bridge

Either way, control reaches AccountingPostingService, the single posting engine. It (accounting-posting.service.ts:198 validateAndPrepareRows):
  1. Loads the referenced accounts (tenant + workspace scoped; throws if any account is out of scope — loadAccounts).
  2. Runs PostingValidatorService.validate() — balance check (|Dr−Cr| ≤ 0.005), no line with both debit and credit, accounts postable/active.
  3. Runs FrozenAccountGuardService.check() and PeriodLockGuardService.check().
  4. Optionally merges duplicate rows (PostingRowMergerService) and appends a round-off row (RoundOffCalculatorService).
  5. If any issue is collected, throws BadRequestException with code: 'POSTING_FAILED'; otherwise writes via AccountingLedgerRepository.createEntriesForJournalEntry(), which performs the insert inside a single transaction with a ledger-existence guard and (when supplied) applies WHT balance deltas in the same transaction.
The full engine internals — idempotency keys, the intent→outbox→ledger pipeline, posting profiles, and bulk posting — are documented in posting-engine.md.

Where multi-tenancy and period guards sit

  • Tenant isolation is enforced at every layer, not just the edge. The JWT yields a tenantId (extracted in src/tenant); domain services thread it into every repository call; the ledger repository scopes every query by tenant_id + workspace_id; and AccountingPostingService.loadAccounts() hard-fails if a journal line references an account outside the active tenant/workspace.
  • Business scoping: an accounting workspaceId is an accounting book id, not a task-management workspace (see the repo CLAUDE.md). The @BusinessCtx() decorator resolves the active businessId; AccountingRoutingService maps businessId → book → workspaceId and refuses to guess when a business has multiple active books with no default (BOOK_ROUTING_AMBIGUOUS).
  • Period locking is centralised in two places that agree on stable codes:
    • PeriodCloseService.assertPostingAllowed() throws the typed PeriodClosedException with code: 'PERIOD_CLOSED' | 'PERIOD_LOCKED'. Called by JournalService.post/reverse/submitForApproval. Downstream products (the SACCO bridge) pattern-match on body.code — do not replace it with a generic throw.
    • PeriodLockGuardService.check() (posting/period-lock-guard.service.ts) is the engine-level guard inside validateAndPrepareRows. It returns ok | override | blocked and is overridable via context.overrides.periodLock (the post-to-closed-period role).

Extension points

  • New product posting source → do not add a controller. Publish a journal intent through AccountingBridgeService and register a JournalSourceType mapping (accounting-bridge.service.ts mapIntentToSourceType). See accounting-bridge.md.
  • New account-mapping rule → add an AccountingPostingProfile (entities/accounting-posting-profile.entity.ts) keyed by (book, productKey, transactionType), resolved by PostingProfileResolver.
  • New financial report → extend financial-reports.service.ts; read docs/accounting-reports.md first (reconciliation contracts, snapshot hooks).
  • New posting-time validation/guard → add a service under posting/ and wire it into AccountingPostingService.validateAndPrepareRows so it runs for both front doors.
  • New entity column → ship a TypeORM migration (migrations/); synchronize: false in every environment, including local dev.