Shariah rules engine (Phase 1 Task 1.B.3)

Status: ✅ Complete

Plan reference: Task 1.B.3 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md (operations workspace root)

What it does

Two responsibilities:

  1. Asset screening — apply AAOIFI screening ratios (debt, cash, impure income) to a security and produce a persisted ShariahScreeningResult of halal | haram | doubtful | requires_purification.
  2. Transaction evaluation — given a PostingRequest, decide whether to allow it, and when income is impure, compute the haram portion that must route to suspended-haram (6201).

The engine is called from PostingEngine.post() before any GL write. Failure halts the post — Shariah is not a warning, it's a gate.

Files

  • Serviceethica-api/src/finance/shariah/shariah-rules-engine.service.ts
  • Controllerethica-api/src/finance/shariah/shariah-rules.controller.ts
  • DTOsscreen-security.dto.ts, toggle-rule.dto.ts
  • Modelsethica-api/src/models/shariah-rule.model.ts, shariah-screening-result.model.ts
  • Frontendethica-erp/app/(auth)/u/finance/shariah-rules/page.tsx
  • HooksuseShariahRules, useToggleShariahRule, useScreenSecurity, useLatestScreening (in services/finance.ts)
  • Testsshariah-rules-engine.service.spec.ts

The six seeded rules

Auto-seeded on Nest startup via onModuleInitseedBuiltinRules. Seed is idempotent: existing rows are left untouched so operator toggles + re-tuned thresholds survive restarts.

ruleCodeTypeWhat it enforces
BLOCK_INTEREST_INCOMEtransaction_blockReserved for AAOIFI account-tagging — reject any posting that credits an interest_income-tagged account. Placeholder until those tags are added.
REQUIRE_SHARIAH_TAGtransaction_blockEvery line on a manual posting must carry a shariahTag (`halal
BLOCK_HARAM_SECTORtransaction_blockReject equity / sukuk trade if the security's latest screening status is haram or the screened sector is in the haram list. Reject if unscreened.
DEBT_RATIO_THRESHOLDasset_screeningEquity is haram when interest-bearing debt / market cap ≥ 0.33 (config).
CASH_RATIO_THRESHOLDasset_screeningEquity is haram when (cash + receivables) / market cap ≥ 0.50 (config).
IMPURE_INCOME_THRESHOLDincome_purificationWhen non-permissible revenue / total revenue ≥ 0.05 (config), the security's dividends require purification.

Thresholds and the haram-sector list live in the rule row's config JSONB — operators can re-tune without a code deploy.

Asset screening — screenSecurity(payload)

Inputs: securityId, optional sector, and the three ratios. Logic:

status = halal
if debtRatio >= debt threshold       → status = haram
if cashRatio >= cash threshold       → status = haram
if status != haram AND impureIncomeRatio >= impure threshold:
   status = requires_purification
if status == halal AND any ratio is missing:
   status = doubtful

Persists a ShariahScreeningResult row tagged with timestamp + reviewer. The latest row per securityId is what evaluatePosting() consults.

Posting evaluation — evaluatePosting(request, subject?)

Returns EvaluationResult { allowed, requiresPurification, haramPortion, impureRatio, violations }:

  • For manual postings — runs REQUIRE_SHARIAH_TAG. Any line missing the tag → allowed=false, violation listed.
  • For equity / sukuk trades — runs BLOCK_HARAM_SECTOR:
    • No screening exists → reject (SCREENED-style violation).
    • Screening is haram → reject with reviewNote reason.
    • Sector is in haram list → reject.
    • Screening is requires_purification → allowed, but requiresPurification=true and impureRatio populated.
  • For dividend postings — pulls impureIncomeRatio from either the request override or the latest screening; computes haramPortion = grossAmount × ratio (rounded ₦); flips requiresPurification=true if ratio ≥ threshold.
  • For all other types — no rules apply today (placeholder). Returns allowed=true.

The dividend handler (Block 2.D) will use haramPortion to route the impure slice to 6201 Suspended Income — Haram. The trade handlers (Block 1.D) will gate the trade on allowed.

Endpoints

All prefixed /finance/shariah:

MethodPathPermissionNotes
GET/rulesFINANCE_VIEWList built-in + custom rules
POST/rules/:id/toggleFINANCE_APPROVE{ active: boolean }
POST/screen-security/:securityIdFINANCE_APPROVEBody: sector + ratios + note
GET/screen-security/:securityId/latestFINANCE_VIEWLatest screening row or 400

Frontend — /u/finance/shariah-rules

Two sections:

  1. Built-in rules table — code, name, type, description, active toggle. Toggling requires FINANCE_APPROVE. Optimistic UI via React Query invalidation.
  2. Screen a security panel — form with security id + sector + three ratios + note. On submit, runs useScreenSecurity and renders the resulting status badge with reasons.

Added to ERP sidebar as Finance → Shariah Rules.

Design choices worth knowing

  • Rules in DB, not code. Thresholds and the haram-sector list live in config JSONB so operators / Shariah supervisors retune without a code deploy. The rule's behavior is hard-coded; the parameters are not.
  • Seed on startup, not migration. onModuleInit is a no-op when rows exist. New rules added in code automatically appear on next deploy. Removed rules don't auto-delete (legacy rows persist with active=false for audit).
  • Screening result is append-only. Re-screening a security adds a row; we never overwrite. getLatestScreening is the read path.
  • Trade handlers will fail closed. Block 1.D's equity handler calls the engine before posting; an unscreened security is rejected, not silently allowed. This is the opposite of Phase 0 behaviour where nothing was checked.

Tests

Twelve specs across the engine spec. Coverage includes: seed is idempotent; six rules created on first run; haram debt ratio classifies as haram; impure income above threshold → requires_purification; missing ratios → doubtful; clean ratios → halal; manual posting without shariahTag is blocked; unscreened equity trade is blocked; haram-screened equity is blocked; dividend with impure ratio populates haramPortion correctly.