Files
nur-falah-prevention/src/lib/calc/faraid.js
T
wmj 6690696f7f 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.
2026-08-13 16:55:58 +08:00

231 lines
11 KiB
JavaScript

// 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));
}