Posting engine + validation engine (Phase 1 Tasks 1.B.1 – 1.B.2)

Status: ✅ Complete

Plan reference: Tasks 1.B.1 + 1.B.2 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md (operations workspace root)

What it is

A single typed entry point for any journal-posting flow that needs validation, Shariah checks, and atomic subledger writes. Calling code expresses what business event happened (a discriminated-union PostingRequest); the engine handles how it ends up in the GL and subledgers.

Phase 0 had one posting path (journalEntriesService.create + createSystemJournal). It worked but it knew nothing about subledgers, nothing about Shariah, and nothing about per-module business rules. The posting engine fills that gap without replacing the existing primitives — it composes them.

The discriminated union

ethica-api/src/finance/posting/posting-request.types.ts defines 15 variants — one per business event:

type PostingRequest =
  | ManualPosting
  | SubscriptionPosting
  | RedemptionPosting
  | EquityTradePosting
  | SukukTradePosting
  | DividendPosting
  | SukukIncomePosting
  | ManagementFeePosting
  | PayrollPosting
  | DepreciationPosting
  | NavAdjustmentPosting
  | FxRevaluationPosting
  | PurificationPosting
  | ZakatPosting
  | MudarabaProfitPosting;

Each variant carries the minimum information its handler needs — a subscription carries clientId, fundId, amount, currency, cashAccount; an equity trade carries securityId, brokerId, side, quantity, price, fees. The engine doesn't know what an "equity trade" means; the handler does.

Today only ManualPosting is handler-registered. Blocks 1.C / 1.D / 2 / 3 register their own handlers via PostingEngine.registerHandler(...) at Nest startup. The union exists in full so the engine's type-narrowing compiles cleanly across the whole module surface.

The handler interface

ethica-api/src/finance/posting/posting-request.types.ts:

interface PostingHandler<T extends PostingRequest> {
  type: T['type'];
  buildLines(request: T): BuildLinesResult;
  postSubledgers?(
    request: T,
    journalId: string,
    journalLineIds: Record<string, string>,
    transaction: Transaction,
  ): Promise<string[]>;
}
  • buildLines is pure: it returns the journal-line spec + sourceModule + narration. No DB calls. No side effects.
  • postSubledgers is optional. If present, the engine calls it AFTER the GL journal lands, passing the line-id map so the handler can wire its subledger rows to specific GL legs.

The current built-in ManualPostingHandler is the simplest case — it passes the operator-supplied lines straight through.

The engine — PostingEngine.post(request, actor, ipAddress)

ethica-api/src/finance/posting/posting-engine.service.ts

Algorithm:

sequelize.transaction(async (txn) => {
  1. validationEngine.validate(request, txn)
  2. shariahResult = shariahEngine.evaluatePosting(request)
     if !shariahResult.allowed → throw ForbiddenException
  3. handler = handlers.get(request.type)
     if !handler → throw BadRequestException
  4. built = handler.buildLines(request)
  5. journal = journalsService.createSystemJournal(
       { date, sourceModule, narration, fundId, ..., lines: built.lines },
       actor, ipAddress, txn
     )
  6. lineIdByAccount = mapAccountToLineId(journal.lines)
  7. subledgerIds = handler.postSubledgers?(request, journal.id, lineIdByAccount, txn) ?? []
  8. return { journalId, reference, subledgerEntryIds }
})

Everything inside one Sequelize transaction. If any step fails, the GL write and the subledger writes both roll back. This is the contract Block 1.A subledgers were designed around — it now finally holds end-to-end.

Why we modified createSystemJournal

createSystemJournal predates Block 1.A and didn't accept a transaction parameter. Block 1.B adds it as an optional last argument, propagated through every Sequelize call inside the method. Backwards-compatible: callers who pass nothing get the old behaviour (auto-commits per call). The posting engine always passes its outer transaction.

When invoked inside an outer transaction, createSystemJournal returns the freshly-posted row with its in-memory lines attached instead of re-fetching via findOne (the fresh row isn't visible to a separate read until the tx commits). Without a transaction, the legacy findOne shape is preserved exactly.

Validation engine — ValidationEngine.validate(request, txn?)

ethica-api/src/finance/posting/validation-engine.service.ts

Two layers:

Structural rules — applied to every request:

  • Date is ISO YYYY-MM-DD
  • fundId, when present, points to an active fund
  • For manual posting only: lines balance within ₦0.01, each line has either a debit or a credit (never both), amounts > 0, accounts match ^\d{4,10}$, currencies are ISO 4217

Per-module rules — registered via registerRules(sourceModule, rules[]) at startup. Other blocks attach their domain checks: equity trade handler will check the security is screened; redemption will check the client has the units; payroll will check the period is open. Today the registry is empty; the API is in place.

A failure does NOT short-circuit — all checks run, then a ValidationException is thrown with the full failure list so the API returns every problem in one round-trip. Codes are stable strings (BAD_DATE, FUND_INACTIVE, UNBALANCED, LINE_BOTH_SIDES, ...) for callers to switch on.

Endpoints

Block 1.B adds no public POSTING endpoint yet — the engine is reached by other backend code, not direct HTTP. It will be wrapped by Block 1.D's trade controllers and by Block 1.C's NAV publisher.

createSystemJournal itself remains available via the existing JournalEntriesService for legacy code paths that haven't migrated yet (notably the Mudarabah subscription in investments.service.ts).

Files

  • Typesethica-api/src/finance/posting/posting-request.types.ts
  • Engineethica-api/src/finance/posting/posting-engine.service.ts
  • Validationethica-api/src/finance/posting/validation-engine.service.ts
  • Manual handlerethica-api/src/finance/posting/handlers/manual.handler.ts
  • Testsposting-engine.service.spec.ts, validation-engine.service.spec.ts

Tests

11 specs across the engine + validator covering: happy-path manual posting, Shariah rejection raises ForbiddenException, validation rejection raises BadRequestException, unregistered handler raises BadRequestException, every structural rule (bad date, inactive fund, unbalanced lines, both-sided line, bad currency), and per-module rule registration. All inside the wider 89-test finance suite.

Design rationale worth knowing

  • Why a discriminated union, not a method-per-event? The union lets one engine route every event through identical validation + Shariah + transaction wrapping. Adding a new event = define a variant + register a handler. No engine changes.
  • Why optional postSubledgers instead of a separate engine? Subledger writes are tightly coupled to specific GL legs (each subledger row needs the corresponding journalLineId). Splitting them would require passing the GL writes' output to a second engine — more coordination for no isolation gain.
  • Why isn't investments.service.ts migrated? Phase 1 is purely additive on production code. The migration happens in Block 1.D (Task 1.D.1) behind a feature flag, so the existing Mudarabah flow can be cut over once the new path has been exercised end-to-end.
  • Why expose ValidationException vs a generic BadRequestException? Callers (controllers, downstream services) can instanceof-check it and surface the structured failures array to the UI for field-level error mapping.