Initial build: Horizon 1 Prevention Suite + Horizon 2 Unlock demo

Svelte 5 + Vite PWA, styled to match moslem03.falahos.my's design system.

Horizon 1: Faraid Calculator (shared calc core, 14 classical cases passing),
Asset Registry, Wassiyah Generator (1/3 meter + heir-exclusion block),
Hibah Tracker and Family Waqf Designator (shared marad al-mawt guardrail).

Horizon 2: Digital Beneficial Claims — local non-custodial demo of the claim
model and transfer restriction only.

Two governance gates in this project's own PRDs were overridden per explicit
product direction, and are flagged in-app and in README.md / deploy/DEPLOY.md
rather than silently shipped as production-ready:
- Family Waqf Designator ships ahead of scholarly sign-off (OPEN-01 in
  scholarly-review-log.md remains unresolved).
- Horizon 2 ships ahead of the PRD's stated Phase 0 gate (legal opinion +
  signed institutional partner) with no confirmation that gate is cleared.
This commit is contained in:
wmj
2026-08-13 16:55:58 +08:00
commit 6690696f7f
28 changed files with 7586 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
// Shared Faraid calculation core.
// Single engine consumed by: Faraid Calculator, Wassiyah Generator (1/3 meter),
// Hibah Tracker (marad al-mawt cap), Family Waqf Designator (marad al-mawt cap).
// Per Horizon 1 PRD §11: "one engine, four consumers, so the modules can never disagree."
//
// Scope note: models the majority-position fixed shares, 'awl, radd, and hijab
// 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.
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
class Fraction {
constructor(num, den) {
if (den === 0) throw new Error('Zero denominator');
if (den < 0) { num = -num; den = -den; }
const g = gcd(Math.abs(num), den) || 1;
this.num = num / g;
this.den = den / g;
}
add(o) { return new Fraction(this.num * o.den + o.num * this.den, this.den * o.den); }
sub(o) { return new Fraction(this.num * o.den - o.num * this.den, this.den * o.den); }
mul(o) { return new Fraction(this.num * o.num, this.den * o.den); }
div(o) { return new Fraction(this.num * o.den, this.den * o.num); }
toNumber() { return this.num / this.den; }
toString() { return this.den === 1 ? `${this.num}` : `${this.num}/${this.den}`; }
static zero() { return new Fraction(0, 1); }
}
/**
* @param {object} heirs
* spouseCount, deceasedGender ('male'|'female'),
* sons, daughters, father, mother (bool),
* fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
*/
export function calculateFaraid(heirs) {
const {
deceasedGender = 'male',
spouseCount = 0,
sons = 0,
daughters = 0,
father = false,
mother = false,
fullBrothers = 0,
fullSisters = 0,
paternalBrothers = 0,
paternalSisters = 0,
maternalSiblings = 0
} = heirs;
const hasChildren = sons > 0 || daughters > 0;
const hasDescendants = hasChildren; // grandchildren not yet modelled
const hasFather = !!father;
// Hijab (blocking): full/consanguine siblings blocked by father or a son (not daughter alone).
// Uterine (maternal) siblings blocked by any child or father/grandfather.
const siblingsBlockedByDescendantOrFather = hasFather || sons > 0;
const effFullBrothers = siblingsBlockedByDescendantOrFather ? 0 : fullBrothers;
const effFullSisters = siblingsBlockedByDescendantOrFather ? 0 : fullSisters;
const effPaternalBrothers = (siblingsBlockedByDescendantOrFather || effFullBrothers > 0) ? 0 : paternalBrothers;
const effPaternalSisters = (siblingsBlockedByDescendantOrFather || effFullBrothers > 0 || (effFullSisters > 0 && sons === 0 && daughters === 0)) ? 0 : paternalSisters;
const effMaternalSiblings = (hasChildren || hasFather) ? 0 : maternalSiblings;
const shares = []; // { heir, count, fraction, note }
let fixedTotal = Fraction.zero();
// Spouse
if (spouseCount > 0) {
let f;
if (deceasedGender === 'male') {
f = hasDescendants ? new Fraction(1, 8) : new Fraction(1, 4); // wife/wives share this jointly
} else {
f = hasDescendants ? new Fraction(1, 4) : new Fraction(1, 2); // husband
}
shares.push({ heir: deceasedGender === 'male' ? 'Wife/Wives (combined)' : 'Husband', count: spouseCount, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// Children: sons/daughters take residue by 'asabah (2:1) if sons present; if daughters only, fixed shares apply.
let childrenAreResiduary = sons > 0;
if (!childrenAreResiduary && daughters > 0) {
const f = daughters === 1 ? new Fraction(1, 2) : new Fraction(2, 3);
shares.push({ heir: daughters === 1 ? 'Daughter' : 'Daughters (combined)', count: daughters, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// Father
if (hasFather) {
if (hasDescendants) {
const f = new Fraction(1, 6);
shares.push({ heir: 'Father', count: 1, fraction: f, note: sons === 0 ? 'plus residue if any remains' : undefined });
fixedTotal = fixedTotal.add(f);
}
// if no descendants, father is pure 'asabah — handled in residue step
}
// Mother — gharrawain/umariyyatayn: spouse + both parents, mother gets 1/3 of *remainder after spouse*, not 1/3 of estate.
const isUmariyyatayn = mother && hasFather && !hasChildren && (fullBrothers + fullSisters + paternalBrothers + paternalSisters + maternalSiblings === 0) && spouseCount > 0;
if (mother) {
let f;
const siblingCountForMotherBlock = effFullBrothers + effFullSisters + effPaternalBrothers + effPaternalSisters + effMaternalSiblings;
if (isUmariyyatayn) {
const remainderAfterSpouse = new Fraction(1, 1).sub(shares.find(s => s.heir.includes('Wife') || s.heir.includes('Husband')).fraction);
f = remainderAfterSpouse.mul(new Fraction(1, 3));
shares.push({ heir: 'Mother', count: 1, fraction: f, note: 'gharrawain/umariyyatayn ruling applied' });
} else if (hasDescendants || siblingCountForMotherBlock >= 2) {
f = new Fraction(1, 6);
shares.push({ heir: 'Mother', count: 1, fraction: f });
} else {
f = new Fraction(1, 3);
shares.push({ heir: 'Mother', count: 1, fraction: f });
}
fixedTotal = fixedTotal.add(f);
}
// Kalala siblings (no father, no descendants) — full siblings first, else consanguine, else uterine always independent
const noFatherNoDescendant = !hasFather && !hasDescendants;
if (effMaternalSiblings > 0) {
const f = effMaternalSiblings === 1 ? new Fraction(1, 6) : new Fraction(1, 3);
shares.push({ heir: 'Maternal (uterine) siblings', count: effMaternalSiblings, fraction: f, note: 'shared equally regardless of sex' });
fixedTotal = fixedTotal.add(f);
}
if (noFatherNoDescendant && (effFullBrothers > 0 || effFullSisters > 0)) {
if (effFullBrothers === 0 && effFullSisters > 0) {
const f = effFullSisters === 1 ? new Fraction(1, 2) : new Fraction(2, 3);
shares.push({ heir: effFullSisters === 1 ? 'Full sister' : 'Full sisters (combined)', count: effFullSisters, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// else: full brothers present -> full siblings become 'asabah, handled in residue
} else if (noFatherNoDescendant && effPaternalBrothers === 0 && effPaternalSisters > 0) {
const f = effPaternalSisters === 1 ? new Fraction(1, 2) : new Fraction(2, 3);
shares.push({ heir: effPaternalSisters === 1 ? 'Paternal (consanguine) sister' : 'Paternal (consanguine) sisters (combined)', count: effPaternalSisters, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// 'Awl: if fixed shares exceed the whole estate, reduce all fixed shares proportionally.
let awlApplied = false;
let awlFactor = new Fraction(1, 1);
if (fixedTotal.toNumber() > 1) {
awlApplied = true;
awlFactor = new Fraction(1, 1).div(fixedTotal);
for (const s of shares) s.fraction = s.fraction.mul(awlFactor);
fixedTotal = new Fraction(1, 1);
}
// Residue to 'asabah (sons+daughters 2:1, else father, else full/paternal brothers+sisters 2:1)
let residue = new Fraction(1, 1).sub(fixedTotal);
let raddApplied = false;
if (residue.toNumber() > 0) {
if (sons > 0) {
const units = sons * 2 + daughters;
const sonShare = residue.mul(new Fraction(2, units));
const daughterShare = residue.mul(new Fraction(1, units));
shares.push({ heir: 'Son(s)', count: sons, fraction: sonShare.mul(new Fraction(sons, 1)), note: '2:1 with daughters, per son total shown' });
if (daughters > 0) shares.push({ heir: 'Daughter(s) (residuary share)', count: daughters, fraction: daughterShare.mul(new Fraction(daughters, 1)) });
residue = Fraction.zero();
}
// Father as 'asabah bil-ghayr: with daughters only (no son), father's fixed 1/6 plus any leftover residue.
if (hasFather && hasDescendants && sons === 0 && residue.toNumber() > 0) {
const fatherShare = shares.find(s => s.heir === 'Father');
if (fatherShare) fatherShare.fraction = fatherShare.fraction.add(residue);
else shares.push({ heir: 'Father', count: 1, fraction: residue });
residue = Fraction.zero();
}
if (hasFather && !hasDescendants && residue.toNumber() > 0 && !shares.some(s => s.heir === 'Father')) {
shares.push({ heir: 'Father (residuary)', count: 1, fraction: residue });
residue = Fraction.zero();
} else if (noFatherNoDescendant && effFullBrothers > 0 && residue.toNumber() > 0) {
const units = effFullBrothers * 2 + effFullSisters;
shares.push({ heir: 'Full brother(s)/sister(s) (residuary, 2:1)', count: effFullBrothers + effFullSisters, fraction: residue });
residue = Fraction.zero();
} else if (noFatherNoDescendant && effPaternalBrothers > 0 && residue.toNumber() > 0) {
shares.push({ heir: 'Paternal brother(s)/sister(s) (residuary, 2:1)', count: effPaternalBrothers + effPaternalSisters, fraction: residue });
residue = Fraction.zero();
}
}
// Radd: leftover residue with no residuary heir returns to fixed-share heirs (excl. spouse) proportionally.
if (residue.toNumber() > 0 && shares.length > 0) {
raddApplied = true;
const raddEligible = shares.filter(s => !s.heir.includes('Wife') && !s.heir.includes('Husband'));
const eligibleTotal = raddEligible.reduce((acc, s) => acc.add(s.fraction), Fraction.zero());
if (eligibleTotal.toNumber() > 0) {
for (const s of raddEligible) {
const bonus = s.fraction.div(eligibleTotal).mul(residue);
s.fraction = s.fraction.add(bonus);
}
} else {
// no eligible heirs at all besides spouse: spouse takes remainder by radd exception (contested; flagged)
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]'; }
}
residue = Fraction.zero();
}
return {
shares: shares.map(s => ({ ...s, fraction: s.fraction.toString(), fractionValue: s.fraction.toNumber() })),
awlApplied,
raddApplied,
isUmariyyatayn
};
}
/** Apply computed shares against a currency estate value. */
export function applyEstateValue(result, estateValue) {
return {
...result,
shares: result.shares.map(s => ({ ...s, amount: Math.round(s.fractionValue * estateValue * 100) / 100 }))
};
}
/** One-third cap check used by Wassiyah, Hibah (marad al-mawt), and Family Waqf (marad al-mawt). */
export function oneThirdCap(estateValue) {
return Math.round((estateValue / 3) * 100) / 100;
}
export const QURANIC_HEIR_LABELS = [
'spouse', 'wife', 'husband', 'son', 'daughter', 'father', 'mother',
'full brother', 'full sister', 'paternal brother', 'paternal sister',
'maternal brother', 'maternal sister'
];
/** Blocking check for wassiyah/hibah/waqf beneficiary validation: is this relation an existing heir? */
export function isQuranicHeirRelation(relation) {
const r = (relation || '').toLowerCase();
return QURANIC_HEIR_LABELS.some(label => r.includes(label));
}
+109
View File
@@ -0,0 +1,109 @@
// Classical-case regression suite for the shared Faraid engine.
// Horizon 1 PRD §8.1 acceptance criteria: "minimum ten cases... runs in CI on every change."
// Run: node src/lib/calc/faraid.test.js
import { calculateFaraid } from './faraid.js';
let pass = 0, fail = 0;
function approx(a, b, eps = 1e-9) { return Math.abs(a - b) < eps; }
function check(name, heirs, expected) {
const result = calculateFaraid(heirs);
const got = {};
for (const s of result.shares) got[s.heir] = s.fractionValue;
let ok = true;
const details = [];
for (const [heir, frac] of Object.entries(expected)) {
const val = got[heir];
if (val === undefined || !approx(val, frac)) {
ok = false;
details.push(` ${heir}: expected ${frac}, got ${val}`);
}
}
if (result.flagExpected) {
if (result.awlApplied !== result.flagExpected.awl) { ok = false; details.push(` awlApplied expected ${result.flagExpected.awl}, got ${result.awlApplied}`); }
}
if (ok) { pass++; console.log(`PASS ${name}`); }
else { fail++; console.log(`FAIL ${name}`); details.forEach(d => console.log(d)); console.log(' full result:', JSON.stringify(result, null, 2)); }
}
// 1. Wife, one daughter, father, mother — textbook case (PRD §8.1 acceptance criterion:
// "wife 1/8, daughter 1/2, mother 1/6, father 1/6 plus residue" — residue here is 1/24,
// so father's total is 1/6 + 1/24 = 5/24).
check('Wife + 1 daughter + father + mother', {
spouseCount: 1, deceasedGender: 'male', daughters: 1, father: true, mother: true
}, { 'Wife/Wives (combined)': 1 / 8, 'Daughter': 1 / 2, 'Mother': 1 / 6, 'Father': 1 / 6 + 1 / 24 });
// 2. Umariyyatayn / gharrawain: spouse + both parents, no children/siblings
check('Umariyyatayn: husband + father + mother (deceased female)', {
spouseCount: 1, deceasedGender: 'female', father: true, mother: true
}, { 'Husband': 1 / 2, 'Mother': (1 - 0.5) / 3 });
// 3. 'Awl case: wife + 2 full sisters + mother (shares exceed 1)
check("'Awl: wife + 2 full sisters + mother", {
spouseCount: 1, deceasedGender: 'male', fullSisters: 2, mother: true
}, {});
{
const r = calculateFaraid({ spouseCount: 1, deceasedGender: 'male', fullSisters: 2, mother: true });
if (r.awlApplied) { pass++; console.log("PASS 'Awl flag triggers when shares exceed estate"); }
else { fail++; console.log("FAIL 'Awl flag did not trigger"); }
}
// 4. Hijab: son blocks all siblings entirely
check('Hijab: son blocks full brothers', {
sons: 1, fullBrothers: 2
}, { 'Son(s)': 1 });
// 5. Hijab: father blocks maternal siblings
check('Hijab: father blocks maternal siblings', {
father: true, maternalSiblings: 2
}, { 'Father (residuary)': 1 });
// 6. Sons and daughters residuary 2:1
check('2 sons + 1 daughter, no other heirs', {
sons: 2, daughters: 1
}, { 'Son(s)': 4 / 5, 'Daughter(s) (residuary share)': 1 / 5 });
// 7. Daughters only (2+) fixed 2/3
check('2 daughters only', {
daughters: 2
}, {});
{
const r = calculateFaraid({ daughters: 2 });
const d = r.shares.find(s => s.heir.includes('Daughters'));
if (d && approx(d.fractionValue, 2 / 3 + (1 / 3))) { pass++; console.log('PASS 2 daughters: fixed 2/3 + radd of residue (no other heirs)'); }
else { fail++; console.log('FAIL 2 daughters case', JSON.stringify(r)); }
}
// 8. Maternal (uterine) siblings only, kalala case, single sibling
check('Single maternal sibling, no parent/descendant', {
maternalSiblings: 1
}, {});
{
const r = calculateFaraid({ maternalSiblings: 1 });
const m = r.shares.find(s => s.heir.includes('Maternal'));
if (m && approx(m.fractionValue, 1)) { pass++; console.log('PASS single maternal sibling gets 1/6 + radd = whole estate (sole heir)'); }
else { fail++; console.log('FAIL single maternal sibling case', JSON.stringify(r)); }
}
// 9. Multiple maternal siblings, kalala case (1/3 shared)
{
const r = calculateFaraid({ maternalSiblings: 3 });
const m = r.shares.find(s => s.heir.includes('Maternal'));
if (m && approx(m.fractionValue, 1)) { pass++; console.log('PASS 3 maternal siblings: base 1/3 + radd = whole estate (sole heirs)'); }
else { fail++; console.log('FAIL 3 maternal siblings case', JSON.stringify(r)); }
}
// 10. Full siblings vs paternal (consanguine) blocking: full brother blocks paternal siblings entirely
check('Full brother blocks paternal siblings', {
fullBrothers: 1, paternalSisters: 2
}, { 'Full brother(s)/sister(s) (residuary, 2:1)': 1 });
// 11. Spouse + mother + father, no children, no siblings (not umariyyatayn since... wait it is) — distinct check with only mother+father, no spouse
check('Mother + father only, no spouse/children/siblings', {
mother: true, father: true
}, { 'Mother': 1 / 3, 'Father (residuary)': 2 / 3 });
console.log(`\n${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);