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:
- Asset screening — apply AAOIFI screening ratios (debt, cash, impure income) to a security and produce a persisted
ShariahScreeningResultofhalal | haram | doubtful | requires_purification. - 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
- Service —
ethica-api/src/finance/shariah/shariah-rules-engine.service.ts - Controller —
ethica-api/src/finance/shariah/shariah-rules.controller.ts - DTOs —
screen-security.dto.ts,toggle-rule.dto.ts - Models —
ethica-api/src/models/shariah-rule.model.ts,shariah-screening-result.model.ts - Frontend —
ethica-erp/app/(auth)/u/finance/shariah-rules/page.tsx - Hooks —
useShariahRules,useToggleShariahRule,useScreenSecurity,useLatestScreening(inservices/finance.ts) - Tests —
shariah-rules-engine.service.spec.ts
The six seeded rules
Auto-seeded on Nest startup via onModuleInit → seedBuiltinRules. Seed is idempotent: existing rows are left untouched so operator toggles + re-tuned thresholds survive restarts.
ruleCode | Type | What it enforces |
|---|---|---|
BLOCK_INTEREST_INCOME | transaction_block | Reserved for AAOIFI account-tagging — reject any posting that credits an interest_income-tagged account. Placeholder until those tags are added. |
REQUIRE_SHARIAH_TAG | transaction_block | Every line on a manual posting must carry a shariahTag (`halal |
BLOCK_HARAM_SECTOR | transaction_block | Reject 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_THRESHOLD | asset_screening | Equity is haram when interest-bearing debt / market cap ≥ 0.33 (config). |
CASH_RATIO_THRESHOLD | asset_screening | Equity is haram when (cash + receivables) / market cap ≥ 0.50 (config). |
IMPURE_INCOME_THRESHOLD | income_purification | When 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 withreviewNotereason. - Sector is in haram list → reject.
- Screening is
requires_purification→ allowed, butrequiresPurification=trueandimpureRatiopopulated.
- No screening exists → reject (
- For dividend postings — pulls
impureIncomeRatiofrom either the request override or the latest screening; computesharamPortion = grossAmount × ratio(rounded ₦); flipsrequiresPurification=trueif 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:
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /rules | FINANCE_VIEW | List built-in + custom rules |
| POST | /rules/:id/toggle | FINANCE_APPROVE | { active: boolean } |
| POST | /screen-security/:securityId | FINANCE_APPROVE | Body: sector + ratios + note |
| GET | /screen-security/:securityId/latest | FINANCE_VIEW | Latest screening row or 400 |
Frontend — /u/finance/shariah-rules
Two sections:
- Built-in rules table — code, name, type, description, active toggle. Toggling requires
FINANCE_APPROVE. Optimistic UI via React Query invalidation. - Screen a security panel — form with security id + sector + three ratios + note. On submit, runs
useScreenSecurityand 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
configJSONB 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.
onModuleInitis 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 withactive=falsefor audit). - Screening result is append-only. Re-screening a security adds a row; we never overwrite.
getLatestScreeningis 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.