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:
+23
-14
@@ -45,21 +45,30 @@ differentiators:
|
|||||||
- **Coverage %, genealogy tree, insurance/liabilities, asset verification** — this granularity
|
- **Coverage %, genealogy tree, insurance/liabilities, asset verification** — this granularity
|
||||||
does not exist anywhere else in the set.
|
does not exist anywhere else in the set.
|
||||||
|
|
||||||
## Gaps to close
|
## Gaps — status
|
||||||
|
|
||||||
1. **No legally-filable Will output.** The single biggest gap — every commercial competitor's
|
1. **Legally-filable Will output — partially closed.** Wassiyah tab now generates a formatted,
|
||||||
core deliverable is a document that can be taken to a lawyer/court/Amanah Raya. Nur Falah
|
statutory-style DRAFT will document (declaration/revocation, executor appointment, bequest
|
||||||
generates coverage data and "execution packets," not a notarizable legal instrument.
|
schedule, witness attestation blocks) via a print-to-PDF flow, clearly watermarked "DRAFT —
|
||||||
2. **No Zakat calculator.** Increasingly table-stakes for this category (Rafiq's core pitch),
|
NOT EXECUTED." This is a real time-saver for a lawyer to review and finalize, not a claim
|
||||||
and a natural extension of the existing Asset Registry.
|
that the app produces a legally executed document — that still requires physical signing,
|
||||||
3. **Single madhab assumption.** The Faraid calculator doesn't expose or let a user pick a
|
witnessing, and jurisdiction-specific legal review. A fully legally-valid, notarized/filed
|
||||||
school of jurisprudence — Rafiq's explicit 5-school support is a credibility signal worth
|
output remains out of scope.
|
||||||
matching for a global (not just Malaysia) audience.
|
2. **Zakat calculator — closed.** New per-member Zakat tab: Nisab check + 2.5% on cash, gold,
|
||||||
4. **No human-in-the-loop / professional tier.** Every competitor pairs software with a lawyer
|
silver, business assets, and investments, minus deductible liabilities.
|
||||||
or consultant.
|
3. **Single madhab assumption — partially closed.** Faraid calculator now has a madhab
|
||||||
5. **No AI guidance layer.** Legacy Logic's "personalized report" and Rafiq's contextual
|
selector (Shafi'i/Hanafi/Maliki/Hanbali) encoding the one well-documented divergence this
|
||||||
coaching both nudge users toward gaps in plain language; Nur Falah's Coverage Dashboard shows
|
engine's rules actually touch — whether radd (return of residue) extends to a sole-heir
|
||||||
the gap but doesn't recommend what to do about it beyond static info panels.
|
spouse. Ja'fari (Shia) is deliberately gated as unsupported rather than silently computed
|
||||||
|
with Sunni rules, since it uses a structurally different classification system, not a
|
||||||
|
parameter tweak.
|
||||||
|
4. **No human-in-the-loop / professional tier — open.** Every competitor pairs software with a
|
||||||
|
lawyer or consultant; Nur Falah remains self-serve only.
|
||||||
|
5. **AI guidance layer — closed.** Coverage Dashboard now surfaces a rule-based
|
||||||
|
recommendations list (exposed assets, missing Wassiyah, unverified assets, unlinked
|
||||||
|
liabilities, no insurance logged, no Zakat set up, insufficient attestors, no agent
|
||||||
|
assigned, no Waqf configured) — every recommendation traces to a concrete fact already in
|
||||||
|
the app's data, not a generated guess.
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Verifies the second round of competitive-gap closures: the Coverage
|
||||||
|
// Dashboard's rule-based recommendations engine, the Faraid Calculator's
|
||||||
|
// madhab selector (including honest Ja'fari gating), and the Wassiyah tab's
|
||||||
|
// draft will document generator.
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
|
||||||
|
const BASE = 'https://moslem04.falahos.my/';
|
||||||
|
const results = [];
|
||||||
|
const consoleErrors = [];
|
||||||
|
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => consoleErrors.push(e.message));
|
||||||
|
|
||||||
|
await signInFreshFamily(page, BASE, 'e2e-gaps2');
|
||||||
|
|
||||||
|
// ── Recommendations engine on Coverage Dashboard ──
|
||||||
|
// Fresh family, no assets yet: no exposed-asset recommendation, but the
|
||||||
|
// "no agent assigned" and "no insurance" rules should still fire since
|
||||||
|
// they don't depend on estateTotal > 0.
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
const recSectionVisible = await page.locator('.recommendations').isVisible().catch(() => false);
|
||||||
|
record('Coverage: recommendations section renders for a fresh family', recSectionVisible);
|
||||||
|
const noAgentRec = await page.locator('.rec-row', { hasText: 'No estate agent' }).isVisible().catch(() => false);
|
||||||
|
record('Coverage: flags missing estate agent/mutawalli', noAgentRec);
|
||||||
|
|
||||||
|
// Add an asset to trigger the exposed-asset recommendation
|
||||||
|
await page.locator('nav button[aria-label="Assets"]').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.locator('.form-card .field:has-text("Description") input').fill('Savings account');
|
||||||
|
await page.locator('.form-card .field:has-text("Estimated value") input').fill('50000');
|
||||||
|
await page.locator('.form-card button.btn-primary', { hasText: 'Add asset' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await page.locator('nav button[aria-label="Coverage"]').click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const exposedRec = await page.locator('.rec-row', { hasText: 'exposed to the slow' }).isVisible().catch(() => false);
|
||||||
|
record('Coverage: flags exposed asset once one is logged', exposedRec);
|
||||||
|
const wassiyahRec = await page.locator('.rec-row', { hasText: 'No Wassiyah bequests' }).isVisible().catch(() => false);
|
||||||
|
record('Coverage: flags missing Wassiyah once estate has value', wassiyahRec);
|
||||||
|
|
||||||
|
// ── Faraid Calculator: madhab selector ──
|
||||||
|
await page.locator('nav button[aria-label="Faraid"]').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const madhabSelectVisible = await page.locator('.field:has-text("Madhab") select').isVisible().catch(() => false);
|
||||||
|
record('Faraid: madhab selector is present', madhabSelectVisible);
|
||||||
|
|
||||||
|
// Set up: wife only, no other heirs -> sole-heir radd divergence case
|
||||||
|
await page.locator('.field:has-text("Number of surviving wives") input').fill('1');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
await page.locator('.field:has-text("Madhab") select').selectOption('shafii');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const shafiiShare = await page.locator('.share-row', { hasText: 'Wife' }).locator('.share-frac').textContent();
|
||||||
|
record("Faraid: Shafi'i excludes spouse from radd (wife keeps 1/4)", shafiiShare.trim() === '1/4', shafiiShare);
|
||||||
|
const unallocatedNoteVisible = await page.locator('.note-box', { hasText: 'unallocated' }).isVisible().catch(() => false);
|
||||||
|
record("Faraid: Shafi'i shows unallocated-residue note for sole-heir spouse", unallocatedNoteVisible);
|
||||||
|
|
||||||
|
await page.locator('.field:has-text("Madhab") select').selectOption('hanafi');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const hanafiShare = await page.locator('.share-row', { hasText: 'Wife' }).locator('.share-frac').textContent();
|
||||||
|
record('Faraid: Hanafi extends radd to sole-heir spouse (takes whole estate)', hanafiShare.trim() === '1', hanafiShare);
|
||||||
|
|
||||||
|
await page.locator('.field:has-text("Madhab") select').selectOption('jaafari');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const jaafariGateVisible = await page.locator('.reframe-note', { hasText: "Ja'fari" }).isVisible().catch(() => false);
|
||||||
|
const sharesHiddenForJaafari = await page.locator('.results').isVisible().catch(() => false);
|
||||||
|
record("Faraid: Ja'fari is honestly gated as unsupported, not silently computed", jaafariGateVisible && !sharesHiddenForJaafari);
|
||||||
|
|
||||||
|
// ── Wassiyah: draft will document generator ──
|
||||||
|
await page.locator('.field:has-text("Madhab") select').selectOption('shafii'); // reset, avoid cross-test pollution
|
||||||
|
await page.locator('nav button[aria-label="Wassiyah"]').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.locator('.field:has-text("Full legal name") input').fill('Ahmad bin Ismail');
|
||||||
|
await page.locator('.field:has-text("Recipient name") input').fill('Nur Charity Foundation');
|
||||||
|
await page.locator('.field:has-text("Relation to you") input').fill('charity');
|
||||||
|
await page.locator('.field:has-text("Description") input').fill('Cash bequest');
|
||||||
|
await page.locator('.field:has-text("Value") input').first().fill('5000');
|
||||||
|
await page.locator('button.btn-primary', { hasText: 'Add bequest' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const [docPage] = await Promise.all([
|
||||||
|
page.waitForEvent('popup'),
|
||||||
|
page.locator('button.btn-secondary', { hasText: 'Generate draft will document' }).click()
|
||||||
|
]);
|
||||||
|
await docPage.waitForLoadState();
|
||||||
|
const docText = await docPage.locator('body').innerText();
|
||||||
|
record('Will document: opens a new tab with the testator name', docText.includes('Ahmad bin Ismail'), docText.slice(0, 100));
|
||||||
|
record('Will document: watermarked DRAFT / not executed', /draft.*not executed/i.test(docText));
|
||||||
|
record('Will document: includes the bequest recipient', docText.includes('Nur Charity Foundation'));
|
||||||
|
record('Will document: includes witness attestation section', /Witness Attestation/i.test(docText));
|
||||||
|
await docPage.close();
|
||||||
|
|
||||||
|
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
const passCount = results.filter(r => r.pass).length;
|
||||||
|
const failCount = results.length - passCount;
|
||||||
|
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
|
||||||
|
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
|
||||||
|
process.exit(failCount > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
|
||||||
@@ -4,27 +4,57 @@
|
|||||||
// still fall into the slow court queue because no fast-path instrument covers it.
|
// still fall into the slow court queue because no fast-path instrument covers it.
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { computeCoverage, suggestedChannel } from './nonprobate.js';
|
import { computeCoverage, suggestedChannel } from './nonprobate.js';
|
||||||
import { activeFamilyId } from './family.js';
|
import { buildRecommendations } from './recommendations.js';
|
||||||
import { listAssets, listHibahGifts, listNominations, listAllWaqfForFamily } from './db.js';
|
import { activeFamilyId, listFamilyMembers } from './family.js';
|
||||||
|
import { session } from './auth.js';
|
||||||
|
import {
|
||||||
|
listAssets, listHibahGifts, listNominations, listAllWaqfForFamily,
|
||||||
|
listWassiyahBequests, listInsurancePolicies, listLiabilities, getZakatRecord,
|
||||||
|
getMemberTrigger, listMemberAttestors
|
||||||
|
} from './db.js';
|
||||||
import Disclaimer from './Disclaimer.svelte';
|
import Disclaimer from './Disclaimer.svelte';
|
||||||
import InfoPanel from './InfoPanel.svelte';
|
import InfoPanel from './InfoPanel.svelte';
|
||||||
|
|
||||||
let familyId = $state(null);
|
let familyId = $state(null);
|
||||||
activeFamilyId.subscribe(v => familyId = v);
|
activeFamilyId.subscribe(v => familyId = v);
|
||||||
|
let memberId = $state(null);
|
||||||
|
session.subscribe(v => memberId = v?.user?.id ?? null);
|
||||||
|
|
||||||
let coverage = $state({ rows: [], total: 0, fastTotal: 0, exposedTotal: 0, fastPercent: 0 });
|
let coverage = $state({ rows: [], total: 0, fastTotal: 0, exposedTotal: 0, fastPercent: 0 });
|
||||||
|
let recommendations = $state([]);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!familyId) return;
|
if (!familyId || !memberId) return;
|
||||||
const [assets, gifts, nominations, waqfDesignations] = await Promise.all([
|
const [assets, gifts, nominations, waqfDesignations, wassiyah, insurance, liabilities, zakat, members, trigger] = await Promise.all([
|
||||||
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId)
|
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId),
|
||||||
|
listWassiyahBequests(familyId, memberId), listInsurancePolicies(familyId, memberId), listLiabilities(familyId),
|
||||||
|
getZakatRecord(familyId, memberId), listFamilyMembers(familyId), getMemberTrigger(memberId, familyId)
|
||||||
]);
|
]);
|
||||||
const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean);
|
const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean);
|
||||||
coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds });
|
coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds });
|
||||||
|
|
||||||
|
const attestors = trigger ? await listMemberAttestors(memberId, familyId) : [];
|
||||||
|
const myWaqf = waqfDesignations.find(w => w.author_id === memberId);
|
||||||
|
const hasCashOrGold = assets.some(a => a.type === 'Cash / Bank' || a.type === 'Jewelry / valuables');
|
||||||
|
|
||||||
|
recommendations = buildRecommendations({
|
||||||
|
exposedTotal: coverage.exposedTotal,
|
||||||
|
exposedCount: coverage.rows.filter(r => !r.fast).length,
|
||||||
|
unverifiedCount: assets.filter(a => !a.verified).length,
|
||||||
|
wassiyahCount: wassiyah.length,
|
||||||
|
estateTotal: coverage.total,
|
||||||
|
insuranceCount: insurance.length,
|
||||||
|
zakatConfigured: !!zakat && Number(zakat.nisabThreshold) > 0,
|
||||||
|
hasCashOrGold,
|
||||||
|
unlinkedLiabilityCount: liabilities.filter(l => !l.linkedAssetId).length,
|
||||||
|
confirmedAttestorCount: attestors.filter(a => a.confirmed).length,
|
||||||
|
hasAgent: members.some(m => m.role === 'agent' && m.status === 'active'),
|
||||||
|
waqfConfigured: !!myWaqf
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(refresh);
|
onMount(refresh);
|
||||||
$effect(() => { familyId; refresh(); });
|
$effect(() => { familyId; memberId; refresh(); });
|
||||||
|
|
||||||
const CHANNEL_LABELS = {
|
const CHANNEL_LABELS = {
|
||||||
hibah: 'Hibah — lifetime gift, completed',
|
hibah: 'Hibah — lifetime gift, completed',
|
||||||
@@ -85,6 +115,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if recommendations.length > 0}
|
||||||
|
<div class="recommendations">
|
||||||
|
<h3>Recommended next steps</h3>
|
||||||
|
{#each recommendations as rec}
|
||||||
|
<div class="rec-row rec-{rec.severity}">
|
||||||
|
<span class="rec-dot"></span>
|
||||||
|
<div class="rec-body">
|
||||||
|
<p>{rec.text}</p>
|
||||||
|
<span class="rec-tab">Go to: {rec.tab}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="asset-list">
|
<div class="asset-list">
|
||||||
{#each coverage.rows as r (r.asset.id)}
|
{#each coverage.rows as r (r.asset.id)}
|
||||||
{@const suggestion = !r.fast ? suggestedChannel(r.asset.type) : null}
|
{@const suggestion = !r.fast ? suggestedChannel(r.asset.type) : null}
|
||||||
@@ -140,4 +185,14 @@
|
|||||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #EF4444; margin-top: 4px; flex-shrink: 0; }
|
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #EF4444; margin-top: 4px; flex-shrink: 0; }
|
||||||
.status-dot.on { background: #2ECC71; }
|
.status-dot.on { background: #2ECC71; }
|
||||||
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
||||||
|
|
||||||
|
.recommendations { margin-bottom: 20px; }
|
||||||
|
.recommendations h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; font-family: 'DM Serif Display', serif; }
|
||||||
|
.rec-row { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||||
|
.rec-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
|
||||||
|
.rec-high .rec-dot { background: #EF4444; }
|
||||||
|
.rec-medium .rec-dot { background: #C9A84C; }
|
||||||
|
.rec-low .rec-dot { background: #8A8478; }
|
||||||
|
.rec-body p { font-size: 12.5px; color: #E8E4DC; line-height: 1.5; margin-bottom: 3px; }
|
||||||
|
.rec-tab { font-size: 10.5px; color: #8A8478; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { calculateFaraid, applyEstateValue } from './calc/faraid.js';
|
import { calculateFaraid, applyEstateValue, SUPPORTED_MADHABS, MADHAB_LABELS } from './calc/faraid.js';
|
||||||
import Disclaimer from './Disclaimer.svelte';
|
import Disclaimer from './Disclaimer.svelte';
|
||||||
import InfoPanel from './InfoPanel.svelte';
|
import InfoPanel from './InfoPanel.svelte';
|
||||||
|
|
||||||
|
const MADHAB_OPTIONS = [...SUPPORTED_MADHABS, 'jaafari'];
|
||||||
|
let madhab = $state('shafii');
|
||||||
let deceasedGender = $state('male');
|
let deceasedGender = $state('male');
|
||||||
let estateValue = $state(0);
|
let estateValue = $state(0);
|
||||||
let spouseCount = $state(0);
|
let spouseCount = $state(0);
|
||||||
@@ -23,7 +25,8 @@
|
|||||||
const r = calculateFaraid({
|
const r = calculateFaraid({
|
||||||
deceasedGender, spouseCount, sons, daughters, father, mother,
|
deceasedGender, spouseCount, sons, daughters, father, mother,
|
||||||
fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
|
fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
|
||||||
});
|
}, madhab);
|
||||||
|
if (r.unsupported) return r;
|
||||||
return applyEstateValue(r, estateValue || 0);
|
return applyEstateValue(r, estateValue || 0);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
what="This works out the fixed Islamic inheritance shares — who in your family is legally entitled to what, according to the Quran. Think of it as 'if this asset ends up going through the normal court process, here's how it would be split.'"
|
what="This works out the fixed Islamic inheritance shares — who in your family is legally entitled to what, according to the Quran. Think of it as 'if this asset ends up going through the normal court process, here's how it would be split.'"
|
||||||
how="Answer simple questions about who in your family is still alive: spouse, children, parents, siblings. The calculator does the math instantly — no need to know any religious terminology."
|
how="Answer simple questions about who in your family is still alive: spouse, children, parents, siblings. The calculator does the math instantly — no need to know any religious terminology."
|
||||||
fields={[
|
fields={[
|
||||||
|
{ label: 'Madhab', hint: 'The school of jurisprudence to apply. Most calculations are identical across schools — this only affects the rare case of a spouse being the sole heir with no other relative at all.' },
|
||||||
{ label: 'Deceased\'s gender', hint: 'Whose estate is being calculated.' },
|
{ label: 'Deceased\'s gender', hint: 'Whose estate is being calculated.' },
|
||||||
{ label: 'Estate value', hint: 'Optional — enter a number if you want to see actual amounts, not just fractions.' },
|
{ label: 'Estate value', hint: 'Optional — enter a number if you want to see actual amounts, not just fractions.' },
|
||||||
{ label: 'Wives / husband, sons, daughters, father, mother', hint: 'Tick or enter a count for whoever is still alive.' },
|
{ label: 'Wives / husband, sons, daughters, father, mother', hint: 'Tick or enter a count for whoever is still alive.' },
|
||||||
@@ -43,6 +47,16 @@
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Madhab (school of jurisprudence)</span>
|
||||||
|
<select bind:value={madhab}>
|
||||||
|
{#each MADHAB_OPTIONS as m}<option value={m}>{MADHAB_LABELS[m]}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if madhab === 'jaafari'}
|
||||||
|
<div class="reframe-note">Ja'fari (Shia) inheritance uses a fundamentally different classification system, not a variant of the Sunni rules this engine models — it is not calculated here to avoid showing you a wrong number. Please consult a Ja'fari-qualified scholar or a dedicated Shia inheritance tool.</div>
|
||||||
|
{/if}
|
||||||
<div class="reframe-note">This calculator is informational — it shows what would apply to whatever is <em>left</em> in your estate. It is not itself a fast-path mechanism: anything still in your estate at death goes through the conventional Faraid/probate process. Check the Coverage Dashboard and use Hibah/Waqf/Nomination to move assets out of this calculation entirely.</div>
|
<div class="reframe-note">This calculator is informational — it shows what would apply to whatever is <em>left</em> in your estate. It is not itself a fast-path mechanism: anything still in your estate at death goes through the conventional Faraid/probate process. Check the Coverage Dashboard and use Hibah/Waqf/Nomination to move assets out of this calculation entirely.</div>
|
||||||
<p class="sub">Answer simple questions about surviving family — no fiqh terminology needed.</p>
|
<p class="sub">Answer simple questions about surviving family — no fiqh terminology needed.</p>
|
||||||
|
|
||||||
@@ -84,26 +98,31 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="results">
|
{#if !result.unsupported}
|
||||||
<h3>Shares</h3>
|
<div class="results">
|
||||||
{#each result.shares as s}
|
<h3>Shares</h3>
|
||||||
<div class="share-row">
|
{#each result.shares as s}
|
||||||
<span class="share-heir">{s.heir}</span>
|
<div class="share-row">
|
||||||
<span class="share-frac">{s.fraction}</span>
|
<span class="share-heir">{s.heir}</span>
|
||||||
{#if estateValue > 0}<span class="share-amount">{s.amount.toLocaleString()}</span>{/if}
|
<span class="share-frac">{s.fraction}</span>
|
||||||
</div>
|
{#if estateValue > 0}<span class="share-amount">{s.amount.toLocaleString()}</span>{/if}
|
||||||
{/each}
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
{#if result.awlApplied}
|
{#if result.awlApplied}
|
||||||
<p class="note-box">Fixed shares exceeded the estate, so 'awl (proportional reduction) has been applied — every fixed share above is already reduced proportionally to fit the whole estate.</p>
|
<p class="note-box">Fixed shares exceeded the estate, so 'awl (proportional reduction) has been applied — every fixed share above is already reduced proportionally to fit the whole estate.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if result.raddApplied}
|
{#if result.raddApplied}
|
||||||
<p class="note-box">There was leftover residue with no residuary ('asabah) heir, so radd (return of surplus) has been applied — the shares above already include each eligible heir's proportional bonus.</p>
|
<p class="note-box">There was leftover residue with no residuary ('asabah) heir, so radd (return of surplus) has been applied — the shares above already include each eligible heir's proportional bonus.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if result.isUmariyyatayn}
|
{#if result.isUmariyyatayn}
|
||||||
<p class="note-box">Umariyyatayn / gharrawain ruling applied: the mother's share is one-third of the remainder after the spouse's share, not one-third of the whole estate.</p>
|
<p class="note-box">Umariyyatayn / gharrawain ruling applied: the mother's share is one-third of the remainder after the spouse's share, not one-third of the whole estate.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{#if result.unallocatedResidue > 0}
|
||||||
|
<p class="note-box">{(result.unallocatedResidue * 100).toFixed(1)}% of the estate is unallocated under {MADHAB_LABELS[madhab]} fiqh — the spouse is excluded from radd in this school, so this remainder is not distributed to a private heir (classically: held for the public treasury / Bayt al-Mal). Consult a local scholar/authority on how this is handled in your jurisdiction.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Disclaimer />
|
<Disclaimer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
|
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
|
||||||
import { activeFamilyId } from './family.js';
|
import { activeFamilyId } from './family.js';
|
||||||
import { session } from './auth.js';
|
import { session } from './auth.js';
|
||||||
import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings } from './db.js';
|
import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings, getMemberTrigger } from './db.js';
|
||||||
|
import { buildWillDocumentHtml } from './willDocument.js';
|
||||||
import Disclaimer from './Disclaimer.svelte';
|
import Disclaimer from './Disclaimer.svelte';
|
||||||
import InfoPanel from './InfoPanel.svelte';
|
import InfoPanel from './InfoPanel.svelte';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
let form = $state({ recipient: '', relation: '', description: '', value: '', recipientEmail: '' });
|
let form = $state({ recipient: '', relation: '', description: '', value: '', recipientEmail: '' });
|
||||||
let witness1 = $state('');
|
let witness1 = $state('');
|
||||||
let witness2 = $state('');
|
let witness2 = $state('');
|
||||||
|
let testatorName = $state('');
|
||||||
let overrideAcknowledged = $state(false);
|
let overrideAcknowledged = $state(false);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
jurisdiction = settings.jurisdiction || 'UK';
|
jurisdiction = settings.jurisdiction || 'UK';
|
||||||
witness1 = settings.witness1 || '';
|
witness1 = settings.witness1 || '';
|
||||||
witness2 = settings.witness2 || '';
|
witness2 = settings.witness2 || '';
|
||||||
|
testatorName = settings.testator_name || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +46,23 @@
|
|||||||
$effect(() => { familyId; authorId; refresh(); });
|
$effect(() => { familyId; authorId; refresh(); });
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
await upsertWassiyahSettings(familyId, authorId, { jurisdiction, witness1, witness2 });
|
await upsertWassiyahSettings(familyId, authorId, { jurisdiction, witness1, witness2, testatorName });
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentEmail = $state(null);
|
||||||
|
session.subscribe(v => currentEmail = v?.user?.email ?? null);
|
||||||
|
|
||||||
|
async function openDraftWillDocument() {
|
||||||
|
await saveSettings();
|
||||||
|
const trigger = await getMemberTrigger(authorId, familyId);
|
||||||
|
const html = buildWillDocumentHtml({
|
||||||
|
testatorName, email: currentEmail, jurisdiction, witness1, witness2,
|
||||||
|
executorName: trigger?.executor_name || '', bequests, estateTotal: total, cap,
|
||||||
|
generatedDate: new Date().toISOString().slice(0, 10)
|
||||||
|
});
|
||||||
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0));
|
const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0));
|
||||||
@@ -125,6 +144,7 @@
|
|||||||
<div class="meter-row"><span>Bequeathed so far</span><strong class:danger={exceedsCap}>{bequestTotal.toLocaleString()}</strong></div>
|
<div class="meter-row"><span>Bequeathed so far</span><strong class:danger={exceedsCap}>{bequestTotal.toLocaleString()}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="field"><span>Full legal name</span><input type="text" bind:value={testatorName} onblur={saveSettings} placeholder="as it should appear on the will document" /></label>
|
||||||
<label class="field"><span>Jurisdiction</span>
|
<label class="field"><span>Jurisdiction</span>
|
||||||
<select bind:value={jurisdiction} onchange={saveSettings}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select>
|
<select bind:value={jurisdiction} onchange={saveSettings}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select>
|
||||||
</label>
|
</label>
|
||||||
@@ -160,6 +180,8 @@
|
|||||||
<label class="field"><span>Witness 2</span><input type="text" bind:value={witness2} onblur={saveSettings} /></label>
|
<label class="field"><span>Witness 2</span><input type="text" bind:value={witness2} onblur={saveSettings} /></label>
|
||||||
|
|
||||||
<button class="btn-primary" disabled={exceedsCap && !overrideAcknowledged} onclick={exportDraft}>Export draft (PDF + text)</button>
|
<button class="btn-primary" disabled={exceedsCap && !overrideAcknowledged} onclick={exportDraft}>Export draft (PDF + text)</button>
|
||||||
|
<button class="btn-secondary" disabled={exceedsCap && !overrideAcknowledged} onclick={openDraftWillDocument}>Generate draft will document</button>
|
||||||
|
<p class="will-doc-note">Opens a formatted, statutory-style DRAFT will in a new tab — use your browser's print dialog to save it as a PDF. Clearly watermarked "not executed": a real will still requires physical signing and witnessing to take legal effect.</p>
|
||||||
|
|
||||||
<Disclaimer />
|
<Disclaimer />
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +206,9 @@
|
|||||||
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
||||||
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
|
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
|
||||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 10px; }
|
||||||
|
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.will-doc-note { font-size: 11px; color: #8A8478; line-height: 1.5; margin-top: 8px; }
|
||||||
.block-error { font-size: 12px; color: #EF4444; line-height: 1.5; }
|
.block-error { font-size: 12px; color: #EF4444; line-height: 1.5; }
|
||||||
.bequest-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 13px; color: #E8E4DC; }
|
.bequest-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 13px; color: #E8E4DC; }
|
||||||
.muted { color: #8A8478; font-size: 11.5px; }
|
.muted { color: #8A8478; font-size: 11.5px; }
|
||||||
|
|||||||
+33
-5
@@ -7,6 +7,18 @@
|
|||||||
// blocking for spouse/children/parents/siblings (full, consanguine, uterine).
|
// blocking for spouse/children/parents/siblings (full, consanguine, uterine).
|
||||||
// Does not yet model grandparents, grandchildren, or extended 'asabah chains —
|
// Does not yet model grandparents, grandchildren, or extended 'asabah chains —
|
||||||
// tracked as a follow-up, not silently assumed correct for those cases.
|
// 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); }
|
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
|
||||||
|
|
||||||
@@ -32,8 +44,12 @@ class Fraction {
|
|||||||
* spouseCount, deceasedGender ('male'|'female'),
|
* spouseCount, deceasedGender ('male'|'female'),
|
||||||
* sons, daughters, father, mother (bool),
|
* sons, daughters, father, mother (bool),
|
||||||
* fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
|
* 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 {
|
const {
|
||||||
deceasedGender = 'male',
|
deceasedGender = 'male',
|
||||||
spouseCount = 0,
|
spouseCount = 0,
|
||||||
@@ -189,18 +205,30 @@ export function calculateFaraid(heirs) {
|
|||||||
s.fraction = s.fraction.add(bonus);
|
s.fraction = s.fraction.add(bonus);
|
||||||
}
|
}
|
||||||
} else {
|
} 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'));
|
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 {
|
return {
|
||||||
shares: shares.map(s => ({ ...s, fraction: s.fraction.toString(), fractionValue: s.fraction.toNumber() })),
|
shares: shares.map(s => ({ ...s, fraction: s.fraction.toString(), fractionValue: s.fraction.toNumber() })),
|
||||||
awlApplied,
|
awlApplied,
|
||||||
raddApplied,
|
raddApplied,
|
||||||
isUmariyyatayn
|
isUmariyyatayn,
|
||||||
|
madhab,
|
||||||
|
unallocatedResidue: residue.toNumber() > 0 ? residue.toNumber() : 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,5 +105,30 @@ check('Mother + father only, no spouse/children/siblings', {
|
|||||||
mother: true, father: true
|
mother: true, father: true
|
||||||
}, { 'Mother': 1 / 3, 'Father (residuary)': 2 / 3 });
|
}, { '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`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
if (fail > 0) process.exit(1);
|
if (fail > 0) process.exit(1);
|
||||||
|
|||||||
+1
-1
@@ -222,7 +222,7 @@ export async function getWassiyahSettings(familyId, authorId) {
|
|||||||
export async function upsertWassiyahSettings(familyId, authorId, fields) {
|
export async function upsertWassiyahSettings(familyId, authorId, fields) {
|
||||||
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
|
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
|
||||||
family_id: familyId, author_id: authorId, jurisdiction: fields.jurisdiction, witness1: fields.witness1, witness2: fields.witness2,
|
family_id: familyId, author_id: authorId, jurisdiction: fields.jurisdiction, witness1: fields.witness1, witness2: fields.witness2,
|
||||||
updated_at: new Date().toISOString()
|
testator_name: fields.testatorName, updated_at: new Date().toISOString()
|
||||||
}, { onConflict: 'family_id,author_id' });
|
}, { onConflict: 'family_id,author_id' });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Rule-based guidance engine — turns the app's own data into plain-language
|
||||||
|
// next steps, the way Legacy Logic's "personalized report" and Rafiq's
|
||||||
|
// contextual coaching do. Deliberately rule-based against data already
|
||||||
|
// captured in this app, not a call to an external model: every recommendation
|
||||||
|
// here traces to a concrete fact (an unverified asset, a missing attestor,
|
||||||
|
// zero policies logged), not a generated guess.
|
||||||
|
export function buildRecommendations(data) {
|
||||||
|
const {
|
||||||
|
exposedTotal = 0, exposedCount = 0, unverifiedCount = 0, wassiyahCount = 0, estateTotal = 0,
|
||||||
|
insuranceCount = 0, zakatConfigured = false, hasCashOrGold = false, unlinkedLiabilityCount = 0,
|
||||||
|
confirmedAttestorCount = 0, hasAgent = false, waqfConfigured = false
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
if (exposedTotal > 0) {
|
||||||
|
items.push({
|
||||||
|
severity: 'high',
|
||||||
|
text: `${exposedCount} asset${exposedCount === 1 ? ' is' : 's are'} still exposed to the slow Faraid/probate queue (${exposedTotal.toLocaleString()} total). Cover the highest-value one first — Hibah, Waqf, or a Nomination.`,
|
||||||
|
tab: 'Coverage'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (estateTotal > 0 && wassiyahCount === 0) {
|
||||||
|
items.push({ severity: 'medium', text: 'No Wassiyah bequests recorded yet. Up to a third of your estate can go to non-heirs or charity — worth setting up even alongside Faraid.', tab: 'Wassiyah' });
|
||||||
|
}
|
||||||
|
if (unverifiedCount > 0) {
|
||||||
|
items.push({ severity: 'medium', text: `${unverifiedCount} asset${unverifiedCount === 1 ? '' : 's'} still unverified — attach proof (title, grant, cover note) and get it confirmed so there's no dispute later.`, tab: 'Assets' });
|
||||||
|
}
|
||||||
|
if (unlinkedLiabilityCount > 0) {
|
||||||
|
items.push({ severity: 'low', text: `${unlinkedLiabilityCount} liabilit${unlinkedLiabilityCount === 1 ? 'y is' : 'ies are'} not linked to the asset securing it — link it so the net estate calculation stays accurate.`, tab: 'Assets' });
|
||||||
|
}
|
||||||
|
if (insuranceCount === 0) {
|
||||||
|
items.push({ severity: 'low', text: 'No life or Takaful policies logged. If you have any, add them — payouts usually go straight to a named beneficiary, outside Faraid entirely.', tab: 'Insurance' });
|
||||||
|
}
|
||||||
|
if (hasCashOrGold && !zakatConfigured) {
|
||||||
|
items.push({ severity: 'low', text: 'You have cash or gold logged but no Zakat calculation set up. Check whether you\'re above Nisab this year.', tab: 'Zakat' });
|
||||||
|
}
|
||||||
|
if (confirmedAttestorCount < 2) {
|
||||||
|
items.push({ severity: 'medium', text: 'Fewer than 2 attestors are confirmed on your death trigger. Without them, your mutawalli/agent can\'t fire the trigger when the time comes.', tab: 'Trigger' });
|
||||||
|
}
|
||||||
|
if (!hasAgent) {
|
||||||
|
items.push({ severity: 'low', text: 'No estate agent/mutawalli assigned to this family yet. Without one, only the owner can execute triggers and confirm assets.', tab: 'Family' });
|
||||||
|
}
|
||||||
|
if (estateTotal > 0 && !waqfConfigured) {
|
||||||
|
items.push({ severity: 'low', text: 'No Waqf designation set up. If any part of your estate is meant as a lasting charitable endowment, this is where to set it aside.', tab: 'Family Waqf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = { high: 0, medium: 1, low: 2 };
|
||||||
|
return items.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Generates a print-ready DRAFT will document from Wassiyah data — a
|
||||||
|
// statutory-style layout (declaration/revocation, executor appointment,
|
||||||
|
// bequest schedule, witness attestation blocks) that a lawyer can review and
|
||||||
|
// finalize, not a document this app claims is legally executed on its own.
|
||||||
|
// Deliberately watermarked and disclaimed throughout: a will only takes
|
||||||
|
// legal effect once physically signed and witnessed per local law (in most
|
||||||
|
// jurisdictions, two witnesses present simultaneously, neither a
|
||||||
|
// beneficiary). No PDF library is added — the browser's own print-to-PDF
|
||||||
|
// keeps this dependency-free and print-perfect, matching the app's existing
|
||||||
|
// Blob-download pattern for execution packets.
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWillDocumentHtml({ testatorName, email, jurisdiction, witness1, witness2, executorName, bequests, estateTotal, cap, generatedDate }) {
|
||||||
|
const name = testatorName?.trim() || '[FULL LEGAL NAME NOT YET ENTERED]';
|
||||||
|
const exec = executorName?.trim() || '[EXECUTOR NOT YET NAMED]';
|
||||||
|
const w1 = witness1?.trim() || '[WITNESS 1 NOT YET NAMED]';
|
||||||
|
const w2 = witness2?.trim() || '[WITNESS 2 NOT YET NAMED]';
|
||||||
|
const bequestTotal = (bequests || []).reduce((s, b) => s + Number(b.value || 0), 0);
|
||||||
|
|
||||||
|
const bequestRows = (bequests || []).map((b, i) => `
|
||||||
|
<tr>
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td>${esc(b.recipient)}${b.relation ? ` (${esc(b.relation)})` : ''}</td>
|
||||||
|
<td>${esc(b.description || '—')}</td>
|
||||||
|
<td class="num">${Number(b.value || 0).toLocaleString()}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8"><title>Draft Will — ${esc(name)}</title>
|
||||||
|
<style>
|
||||||
|
@page { margin: 2.2cm; }
|
||||||
|
body { font-family: Georgia, 'Times New Roman', serif; color: #111; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 24px; }
|
||||||
|
.watermark { text-align: center; background: #fff3f3; border: 2px dashed #c0392b; color: #c0392b; font-weight: bold; padding: 14px; margin-bottom: 24px; letter-spacing: 0.5px; text-transform: uppercase; font-family: Arial, sans-serif; font-size: 13px; }
|
||||||
|
h1 { text-align: center; font-size: 22px; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 4px; }
|
||||||
|
.subtitle { text-align: center; font-size: 12px; color: #555; margin-bottom: 28px; font-family: Arial, sans-serif; }
|
||||||
|
h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #999; padding-bottom: 4px; margin-top: 28px; }
|
||||||
|
p { font-size: 14px; text-align: justify; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
|
||||||
|
th { background: #f4f4f4; font-family: Arial, sans-serif; font-size: 11px; text-transform: uppercase; }
|
||||||
|
td.num, th.num { text-align: right; }
|
||||||
|
.signature-block { margin-top: 40px; }
|
||||||
|
.sig-line { border-top: 1px solid #111; width: 300px; margin-top: 50px; padding-top: 4px; font-size: 12px; font-family: Arial, sans-serif; }
|
||||||
|
.witness-block { display: flex; gap: 40px; margin-top: 30px; }
|
||||||
|
.footer-note { margin-top: 40px; font-size: 11px; color: #777; font-family: Arial, sans-serif; border-top: 1px solid #ddd; padding-top: 12px; }
|
||||||
|
@media print { .no-print { display: none; } }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="watermark">Draft — Not Executed. Requires physical signing and witnessing per local law to take legal effect.</div>
|
||||||
|
|
||||||
|
<h1>Last Will & Testament</h1>
|
||||||
|
<p class="subtitle">(Islamic Wassiyah — limited to one-third of the net estate per Shariah)</p>
|
||||||
|
|
||||||
|
<h2>1. Declaration</h2>
|
||||||
|
<p>I, <strong>${esc(name)}</strong>${email ? ` (${esc(email)})` : ''}, being of sound mind, declare this to be my Will, and I hereby revoke all previous wills and testamentary dispositions made by me. This document expresses my Wassiyah — the portion of my estate I direct outside the fixed Faraid distribution — and does not purport to override any Faraid share owed to my heirs.</p>
|
||||||
|
|
||||||
|
<h2>2. Jurisdiction</h2>
|
||||||
|
<p>This Will is intended to take effect under the laws of <strong>${esc(jurisdiction || '[JURISDICTION NOT YET ENTERED]')}</strong>, in accordance with Shariah principles governing Wassiyah.</p>
|
||||||
|
|
||||||
|
<h2>3. Appointment of Executor</h2>
|
||||||
|
<p>I appoint <strong>${esc(exec)}</strong> as Executor of this Will, to administer my estate, settle my debts, and distribute both the Wassiyah bequests below and the remaining estate according to Faraid.</p>
|
||||||
|
|
||||||
|
<h2>4. One-Third Cap Acknowledgement</h2>
|
||||||
|
<p>My net estate is recorded at approximately <strong>${(estateTotal || 0).toLocaleString()}</strong>. The maximum permissible under Wassiyah (one-third) is approximately <strong>${(cap || 0).toLocaleString()}</strong>. The bequests below total <strong>${bequestTotal.toLocaleString()}</strong>${bequestTotal > (cap || 0) ? ' — THIS EXCEEDS THE ONE-THIRD CAP AND REQUIRES HEIR CONSENT TO STAND; REVIEW BEFORE EXECUTION.' : ', within the permitted limit.'} No bequest below is made to a Quranic fixed heir, who already receives a Faraid share.</p>
|
||||||
|
|
||||||
|
<h2>5. Schedule of Bequests</h2>
|
||||||
|
${bequestRows ? `<table><thead><tr><th>#</th><th>Recipient</th><th>Description</th><th class="num">Value</th></tr></thead><tbody>${bequestRows}</tbody></table>` : '<p><em>No bequests recorded.</em></p>'}
|
||||||
|
<p>The remainder of my estate, after debts, funeral expenses, and the bequests above, is to be distributed among my legal heirs according to Faraid, as determined at the time of my death.</p>
|
||||||
|
|
||||||
|
<h2>6. Witness Attestation</h2>
|
||||||
|
<p>This Will was signed by the testator in the presence of the two witnesses below, present at the same time, who then signed in the presence of the testator and each other. Neither witness should be a beneficiary under this Will.</p>
|
||||||
|
<div class="witness-block">
|
||||||
|
<div><div class="sig-line">Witness 1: ${esc(w1)}<br>Signature & date</div></div>
|
||||||
|
<div><div class="sig-line">Witness 2: ${esc(w2)}<br>Signature & date</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block"><div class="sig-line">Testator signature & date</div></div>
|
||||||
|
|
||||||
|
<div class="footer-note">
|
||||||
|
Generated by Nur Falah on ${esc(generatedDate)} from data entered in the Wassiyah tab. This is a DRAFT ONLY —
|
||||||
|
it has not been reviewed by a lawyer, has not been signed, and has no legal effect until properly executed
|
||||||
|
under the laws of the stated jurisdiction. Faraid shares shown elsewhere in this app are informational and
|
||||||
|
not a substitute for a qualified estate-planning consultation.
|
||||||
|
</div>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user