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. Seeposting-engine.md and
accounting-bridge.md for the two deepest seams.
Layering
- Auth/tenant:
TenantAuthGuard(src/tenant/guards), the globalPermissionsGuard(src/auth/permissions.guard.ts) keyed off@RequirePermissions(...), and the@BusinessCtx()decorator (src/common/business-context) which resolves the activebusinessId. - Idempotency: the
@Idempotent()decorator (src/common/idempotency) on mutating bridge endpoints. - Events:
EventEmitter2— posting emitsJOURNAL_POSTED_EVENT(events/journal-posted.event.ts); listeners live inlisteners/. - Scheduling:
@nestjs/schedulecron 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
AccountingPostingService, the single posting
engine. It (accounting-posting.service.ts:198 validateAndPrepareRows):
- Loads the referenced accounts (tenant + workspace scoped; throws if any
account is out of scope —
loadAccounts). - Runs
PostingValidatorService.validate()— balance check (|Dr−Cr| ≤ 0.005), no line with both debit and credit, accounts postable/active. - Runs
FrozenAccountGuardService.check()andPeriodLockGuardService.check(). - Optionally merges duplicate rows (
PostingRowMergerService) and appends a round-off row (RoundOffCalculatorService). - If any issue is collected, throws
BadRequestExceptionwithcode: 'POSTING_FAILED'; otherwise writes viaAccountingLedgerRepository.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.
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 insrc/tenant); domain services thread it into every repository call; the ledger repository scopes every query bytenant_id+workspace_id; andAccountingPostingService.loadAccounts()hard-fails if a journal line references an account outside the active tenant/workspace. - Business scoping: an accounting
workspaceIdis an accounting book id, not a task-management workspace (see the repoCLAUDE.md). The@BusinessCtx()decorator resolves the activebusinessId;AccountingRoutingServicemapsbusinessId → book → workspaceIdand 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 typedPeriodClosedExceptionwithcode: 'PERIOD_CLOSED' | 'PERIOD_LOCKED'. Called byJournalService.post/reverse/submitForApproval. Downstream products (the SACCO bridge) pattern-match onbody.code— do not replace it with a generic throw.PeriodLockGuardService.check()(posting/period-lock-guard.service.ts) is the engine-level guard insidevalidateAndPrepareRows. It returnsok | override | blockedand is overridable viacontext.overrides.periodLock(thepost-to-closed-periodrole).
Extension points
- New product posting source → do not add a controller. Publish a journal
intent through
AccountingBridgeServiceand register aJournalSourceTypemapping (accounting-bridge.service.tsmapIntentToSourceType). Seeaccounting-bridge.md. - New account-mapping rule → add an
AccountingPostingProfile(entities/accounting-posting-profile.entity.ts) keyed by(book, productKey, transactionType), resolved byPostingProfileResolver. - New financial report → extend
financial-reports.service.ts; readdocs/accounting-reports.mdfirst (reconciliation contracts, snapshot hooks). - New posting-time validation/guard → add a service under
posting/and wire it intoAccountingPostingService.validateAndPrepareRowsso it runs for both front doors. - New entity column → ship a TypeORM migration (
migrations/);synchronize: falsein every environment, including local dev.