feat: close 3 more competitive gaps — recommendations engine, multi-madhab Faraid, draft will PDF

Closes 3 of the remaining 4 gaps from the competitive benchmark:

- Coverage Dashboard now has a rule-based recommendations engine
  (recommendations.js) surfacing plain-language next steps from data
  already in the app — exposed assets, missing Wassiyah, unverified
  assets, unlinked liabilities, missing insurance/Zakat setup,
  insufficient attestors, no agent assigned, no Waqf configured.

- Faraid Calculator gains a madhab selector (Shafi'i/Hanafi/Maliki/
  Hanbali) encoding the one well-documented divergence this engine's
  existing rules touch: whether radd extends to a sole-heir spouse
  (Hanafi: yes; Shafi'i/Maliki/Hanbali: no, residue unallocated).
  Ja'fari (Shia) is honestly gated as unsupported rather than silently
  computed with Sunni rules, since it's a structurally different
  classification system, not a parameter tweak. faraid.test.js grows
  from 14 to 17 cases covering the divergence and the gating.

- Wassiyah tab gains a 'Generate draft will document' button producing
  a formatted, statutory-style DRAFT will (declaration/revocation,
  executor appointment, bequest schedule, witness attestation blocks)
  via browser print-to-PDF — no new PDF dependency. Clearly watermarked
  'DRAFT — NOT EXECUTED, requires physical signing and witnessing.'
  Not a claim of legal validity, a lawyer-reviewable starting point.

The 4th gap (human-in-the-loop professional tier) remains open — out
of scope for a self-serve prevention tool. COMPETITIVE_BENCHMARK.md
updated to reflect closed/partially-closed status on each item.

Covered by e2e-gaps-round2.cjs (14/14) plus 3 new faraid.test.js cases.
Full regression: 226/226 across all suites.
This commit is contained in:
2026-08-14 12:15:54 +08:00
parent 972ad7ff68
commit 71aa4ae47c
10 changed files with 454 additions and 49 deletions
+33 -5
View File
@@ -7,6 +7,18 @@
// blocking for spouse/children/parents/siblings (full, consanguine, uterine).
// Does not yet model grandparents, grandchildren, or extended 'asabah chains —
// tracked as a follow-up, not silently assumed correct for those cases.
//
// Madhab scope: this engine's default rules match the Shafi'i-aligned majority
// position used in Malaysian statutory Faraid application, which Maliki and
// Hanbali also follow on the one point this engine models a real divergence
// on. Hanafi fiqh diverges on radd (see below). Ja'fari (Shia) inheritance
// uses a fundamentally different classification system — not a parameter
// tweak on this engine — and is deliberately NOT computed here; see
// SUPPORTED_MADHABS and the 'jaafari' branch in calculateFaraid.
export const SUPPORTED_MADHABS = ['shafii', 'hanafi', 'maliki', 'hanbali'];
export const MADHAB_LABELS = {
shafii: "Shafi'i", hanafi: 'Hanafi', maliki: 'Maliki', hanbali: 'Hanbali', jaafari: "Ja'fari (Shia)"
};
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
@@ -32,8 +44,12 @@ class Fraction {
* spouseCount, deceasedGender ('male'|'female'),
* sons, daughters, father, mother (bool),
* fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
* @param {string} madhab one of SUPPORTED_MADHABS, or 'jaafari' (returns unsupported: true instead of shares)
*/
export function calculateFaraid(heirs) {
export function calculateFaraid(heirs, madhab = 'shafii') {
if (madhab === 'jaafari') {
return { unsupported: true, madhab, shares: [], awlApplied: false, raddApplied: false, isUmariyyatayn: false };
}
const {
deceasedGender = 'male',
spouseCount = 0,
@@ -189,18 +205,30 @@ export function calculateFaraid(heirs) {
s.fraction = s.fraction.add(bonus);
}
} else {
// no eligible heirs at all besides spouse: spouse takes remainder by radd exception (contested; flagged)
// No eligible heirs at all besides spouse: whether the spouse absorbs the
// remainder by radd is where Hanafi fiqh actually diverges from the
// Shafi'i-aligned majority position this engine otherwise follows.
// Hanafi: spouse included in radd, takes the full remainder.
// Shafi'i/Maliki/Hanbali: spouse excluded from radd — remainder is not
// distributed to any private heir (classically: Bayt al-Mal).
const spouseShare = shares.find(s => s.heir.includes('Wife') || s.heir.includes('Husband'));
if (spouseShare) { spouseShare.fraction = spouseShare.fraction.add(residue); spouseShare.note = (spouseShare.note || '') + ' [radd-to-spouse: minority position, flag for scholarly review]'; }
if (madhab === 'hanafi' && spouseShare) {
spouseShare.fraction = spouseShare.fraction.add(residue);
spouseShare.note = (spouseShare.note || '') + ' [Hanafi: radd extends to spouse]';
residue = Fraction.zero();
} else if (spouseShare) {
spouseShare.note = (spouseShare.note || '') + ` [${MADHAB_LABELS[madhab]}: spouse excluded from radd — remainder held for public treasury/Bayt al-Mal, not distributed to a private heir]`;
}
}
residue = Fraction.zero();
}
return {
shares: shares.map(s => ({ ...s, fraction: s.fraction.toString(), fractionValue: s.fraction.toNumber() })),
awlApplied,
raddApplied,
isUmariyyatayn
isUmariyyatayn,
madhab,
unallocatedResidue: residue.toNumber() > 0 ? residue.toNumber() : 0
};
}
+25
View File
@@ -105,5 +105,30 @@ check('Mother + father only, no spouse/children/siblings', {
mother: true, father: true
}, { 'Mother': 1 / 3, 'Father (residuary)': 2 / 3 });
// 12. Madhab divergence — wife is the sole heir (no other heirs at all): Hanafi
// extends radd to the spouse (wife takes 100%); Shafi'i/Maliki/Hanbali exclude
// the spouse from radd (wife keeps her fixed 1/4, remainder unallocated).
{
const hanafi = calculateFaraid({ spouseCount: 1, deceasedGender: 'male' }, 'hanafi');
const wifeHanafi = hanafi.shares.find(s => s.heir.includes('Wife'));
if (wifeHanafi && approx(wifeHanafi.fractionValue, 1) && hanafi.unallocatedResidue === 0) {
pass++; console.log('PASS Madhab: Hanafi extends radd to sole-heir spouse (100%)');
} else { fail++; console.log('FAIL Madhab: Hanafi radd-to-spouse', JSON.stringify(hanafi)); }
const shafii = calculateFaraid({ spouseCount: 1, deceasedGender: 'male' }, 'shafii');
const wifeShafii = shafii.shares.find(s => s.heir.includes('Wife'));
if (wifeShafii && approx(wifeShafii.fractionValue, 1 / 4) && approx(shafii.unallocatedResidue, 3 / 4)) {
pass++; console.log("PASS Madhab: Shafi'i excludes spouse from radd (wife keeps 1/4, 3/4 unallocated)");
} else { fail++; console.log("FAIL Madhab: Shafi'i radd-to-spouse exclusion", JSON.stringify(shafii)); }
}
// 13. Ja'fari is honestly gated as unsupported, not silently computed with Sunni rules.
{
const jaafari = calculateFaraid({ spouseCount: 1, deceasedGender: 'male' }, 'jaafari');
if (jaafari.unsupported === true && jaafari.shares.length === 0) {
pass++; console.log("PASS Madhab: Ja'fari (Shia) is gated as unsupported, not silently miscalculated");
} else { fail++; console.log("FAIL Madhab: Ja'fari gating", JSON.stringify(jaafari)); }
}
console.log(`\n${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);