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:
@@ -0,0 +1,165 @@
|
||||
<script>
|
||||
import { load, save, estateTotal } from './storage.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
|
||||
const TYPES = ['Property', 'Cash / Bank', 'Vehicle', 'Business interest', 'Digital assets', 'Jewelry / valuables', 'Other'];
|
||||
|
||||
let assets = $state(load('assets', []));
|
||||
let trustedContacts = $state(load('trustedContacts', []));
|
||||
|
||||
let form = $state(emptyForm());
|
||||
let editingId = $state(null);
|
||||
let contactForm = $state({ name: '', method: '' });
|
||||
|
||||
function emptyForm() {
|
||||
return { type: TYPES[0], description: '', value: '', location: '', ownershipShare: 100 };
|
||||
}
|
||||
|
||||
function persist() {
|
||||
save('assets', assets);
|
||||
}
|
||||
|
||||
function addOrUpdate() {
|
||||
if (!form.description || !form.value) return;
|
||||
if (editingId) {
|
||||
assets = assets.map(a => a.id === editingId ? { ...form, id: editingId, value: Number(form.value) } : a);
|
||||
editingId = null;
|
||||
} else {
|
||||
assets = [...assets, { ...form, id: crypto.randomUUID(), value: Number(form.value) }];
|
||||
}
|
||||
form = emptyForm();
|
||||
persist();
|
||||
}
|
||||
|
||||
function edit(a) {
|
||||
editingId = a.id;
|
||||
form = { ...a, value: String(a.value) };
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
assets = assets.filter(a => a.id !== id);
|
||||
if (editingId === id) { editingId = null; form = emptyForm(); }
|
||||
persist();
|
||||
}
|
||||
|
||||
function addContact() {
|
||||
if (!contactForm.name) return;
|
||||
trustedContacts = [...trustedContacts, { ...contactForm, id: crypto.randomUUID() }];
|
||||
contactForm = { name: '', method: '' };
|
||||
save('trustedContacts', trustedContacts);
|
||||
}
|
||||
|
||||
function removeContact(id) {
|
||||
trustedContacts = trustedContacts.filter(c => c.id !== id);
|
||||
save('trustedContacts', trustedContacts);
|
||||
}
|
||||
|
||||
function exportSummary() {
|
||||
const total = estateTotal(assets);
|
||||
const lines = [
|
||||
'NUR FALAH — ASSET REGISTRY SUMMARY',
|
||||
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
||||
`Total estate value: ${total.toLocaleString()}`,
|
||||
'',
|
||||
...assets.map(a => `${a.type} — ${a.description} — value ${Number(a.value).toLocaleString()} — ${a.ownershipShare}% owned — ${a.location || 'no location noted'}`),
|
||||
'',
|
||||
'This is a portable "what I own" summary. Not a fatwa, not legal advice.'
|
||||
];
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'asset-registry-summary.txt'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const total = $derived(estateTotal(assets));
|
||||
</script>
|
||||
|
||||
<div class="module">
|
||||
<h2>Asset Registry</h2>
|
||||
<p class="sub">Log what you own — feeds the Faraid Calculator, Wassiyah one-third meter, and Waqf corpus selector.</p>
|
||||
|
||||
<div class="total-card">
|
||||
<span>Estate total</span>
|
||||
<strong>{total.toLocaleString()}</strong>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Type</span>
|
||||
<select bind:value={form.type}>
|
||||
{#each TYPES as t}<option value={t}>{t}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Description</span><input type="text" bind:value={form.description} placeholder="e.g. Terrace house, Shah Alam" /></label>
|
||||
<label class="field"><span>Estimated value</span><input type="number" min="0" bind:value={form.value} /></label>
|
||||
<label class="field"><span>Location / jurisdiction</span><input type="text" bind:value={form.location} placeholder="e.g. Selangor, UK" /></label>
|
||||
<label class="field"><span>Ownership share (%)</span><input type="number" min="0" max="100" bind:value={form.ownershipShare} /></label>
|
||||
<button class="btn-primary" onclick={addOrUpdate}>{editingId ? 'Update asset' : 'Add asset'}</button>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
{#each assets as a (a.id)}
|
||||
<div class="asset-row">
|
||||
<div class="asset-info">
|
||||
<span class="asset-type">{a.type}</span>
|
||||
<span class="asset-desc">{a.description}</span>
|
||||
<span class="asset-meta">{a.ownershipShare}% owned · {a.location || '—'}</span>
|
||||
</div>
|
||||
<div class="asset-value">{Number(a.value).toLocaleString()}</div>
|
||||
<div class="asset-actions">
|
||||
<button onclick={() => edit(a)} aria-label="Edit">✎</button>
|
||||
<button onclick={() => remove(a.id)} aria-label="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No assets logged yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button class="btn-secondary" onclick={exportSummary}>Export "what I own" summary</button>
|
||||
|
||||
<div class="contacts-section">
|
||||
<h3>Trusted contacts</h3>
|
||||
<p class="note">Notified or given a read-only export on a triggering event — not an automated death-detection or legal-transfer mechanism.</p>
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Name</span><input type="text" bind:value={contactForm.name} /></label>
|
||||
<label class="field"><span>Contact method</span><input type="text" bind:value={contactForm.method} placeholder="email or phone" /></label>
|
||||
<button class="btn-secondary" onclick={addContact}>Add trusted contact</button>
|
||||
</div>
|
||||
{#each trustedContacts as c (c.id)}
|
||||
<div class="contact-row"><span>{c.name} — {c.method}</span><button onclick={() => removeContact(c.id)}>✕</button></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Disclaimer text="Manual entry only — bank/brokerage account linking and live balance sync are explicitly out of scope for Horizon 1." />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module { padding: 4px 0 40px; }
|
||||
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
|
||||
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
||||
.total-card { display: flex; justify-content: space-between; align-items: baseline; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 12px; padding: 16px; margin-bottom: 18px; }
|
||||
.total-card span { font-size: 12.5px; color: #B8B2A6; }
|
||||
.total-card strong { font-size: 22px; color: #C9A84C; }
|
||||
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.field span { font-size: 12px; color: #B8B2A6; }
|
||||
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
|
||||
.btn-primary, .btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
|
||||
.btn-primary { background: #C9A84C; color: #070A0D; }
|
||||
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; margin-bottom: 10px; }
|
||||
.list { margin-bottom: 12px; }
|
||||
.asset-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.asset-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
|
||||
.asset-type { font-size: 11px; color: #8A8478; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.asset-desc { font-size: 13.5px; color: #E8E4DC; }
|
||||
.asset-meta { font-size: 11px; color: #8A8478; }
|
||||
.asset-value { color: #2ECC71; font-weight: 600; font-size: 13px; }
|
||||
.asset-actions button { background: none; border: none; color: #8A8478; cursor: pointer; padding: 4px 6px; }
|
||||
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
||||
.contacts-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
|
||||
.contacts-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 4px; }
|
||||
.note { font-size: 11.5px; color: #8A8478; margin-bottom: 12px; }
|
||||
.contact-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.contact-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
let { text = 'This output is not a fatwa and not legal advice. It requires qualified Shariah scholarly and, where applicable, legal review before real-world reliance.' } = $props();
|
||||
</script>
|
||||
|
||||
<div class="disclaimer">
|
||||
<span class="disclaimer-icon">⚠</span>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.disclaimer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
background: rgba(201,168,76,0.08);
|
||||
border: 1px solid rgba(201,168,76,0.25);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.disclaimer-icon { color: #C9A84C; font-size: 16px; line-height: 1.4; }
|
||||
.disclaimer p { font-size: 12.5px; line-height: 1.5; color: #B8B2A6; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script>
|
||||
// Built per explicit user instruction overriding the Horizon 1 PRD §8.5 gate
|
||||
// ("this module does not enter development until... a qualified Shariah advisor's
|
||||
// direct sign-off"). scholarly-review-log.md OPEN-01 remains unresolved — the cap
|
||||
// logic below follows the PRD's literal specification (marad al-mawt only) but the
|
||||
// in-app note is kept honest about the open question rather than presenting
|
||||
// invented certainty, consistent with this project's own stated practice.
|
||||
import { load, save, estateTotal } from './storage.js';
|
||||
import MaradAlMawtGuard from './MaradAlMawtGuard.svelte';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
|
||||
const assets = load('assets', []);
|
||||
const total = estateTotal(assets);
|
||||
|
||||
let corpusAssetId = $state('');
|
||||
let mutawalli = $state('');
|
||||
let successorMutawalli = $state('');
|
||||
let beneficiaries = $state(load('waqfBeneficiaries', []));
|
||||
let beneficiaryForm = $state({ name: '', relation: '', sharePercent: '' });
|
||||
let equalSplit = $state(true);
|
||||
let deviationAcknowledged = $state(false);
|
||||
let jurisdiction = $state('Perak');
|
||||
let showGuard = $state(false);
|
||||
let guardResult = $state(null);
|
||||
|
||||
const corpusAsset = $derived(assets.find(a => a.id === corpusAssetId));
|
||||
|
||||
function addBeneficiary() {
|
||||
if (!beneficiaryForm.name) return;
|
||||
beneficiaries = [...beneficiaries, { ...beneficiaryForm, id: crypto.randomUUID() }];
|
||||
save('waqfBeneficiaries', beneficiaries);
|
||||
beneficiaryForm = { name: '', relation: '', sharePercent: '' };
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
beneficiaries = beneficiaries.filter(b => b.id !== id);
|
||||
save('waqfBeneficiaries', beneficiaries);
|
||||
}
|
||||
|
||||
function exportDeed() {
|
||||
const lines = [
|
||||
'WAQFIYYA (FAMILY WAQF) DEED — DRAFTING AID',
|
||||
`Jurisdiction: ${jurisdiction}`,
|
||||
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
||||
`Corpus asset: ${corpusAsset ? `${corpusAsset.type} — ${corpusAsset.description}` : '(not selected)'}`,
|
||||
`Mutawalli (trustee): ${mutawalli || '________________________'}`,
|
||||
`Successor mutawalli: ${successorMutawalli || '________________________'}`,
|
||||
`Distribution basis: ${equalSplit ? 'Equal split between descendants (default)' : 'Custom split — deviation acknowledged'}`,
|
||||
'',
|
||||
'BENEFICIARIES:',
|
||||
...beneficiaries.map(b => ` ${b.name} (${b.relation || '—'})${b.sharePercent ? ` — ${b.sharePercent}%` : ''}`),
|
||||
'',
|
||||
jurisdiction === 'Perak'
|
||||
? 'This deed references Perak Waqf Enactment 2015, Section 10(1). Registration with the\nrelevant state waqf authority is required for legal effect — this document alone does\nnot constitute a legally registered waqf.'
|
||||
: `This deed references ${jurisdiction}'s waqf enactment where available. Registration\nwith the relevant state waqf authority is required for legal effect — this document\nalone does not constitute a legally registered waqf.`,
|
||||
'',
|
||||
'OPEN FIQH QUESTION (see project scholarly-review-log.md, OPEN-01): whether an',
|
||||
'inter-vivos waqf made in health is subject to the same one-third cap as a wasiyyah,',
|
||||
'or is uncapped like hibah, has not received a scholar\'s determination. This draft',
|
||||
'applies no health-state cap unless the marad al-mawt guardrail below was triggered.',
|
||||
'',
|
||||
'DISCLAIMER: Not a fatwa. Not legal advice. Drafting aid only.'
|
||||
];
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'waqfiyya-deed-draft.txt'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="module">
|
||||
<h2>Family Waqf Designator</h2>
|
||||
<div class="open-note">
|
||||
<strong>Open fiqh question:</strong> whether a healthy-state family waqf is subject to a one-third cap has not received scholarly sign-off (tracked as OPEN-01). This module ships without waiting on that review, per explicit product direction — the exported deed says so plainly rather than presenting invented certainty.
|
||||
</div>
|
||||
<p class="sub">Dedicate a specific asset to your family in perpetuity. Framed as "structure your estate proactively" — never as a faraid-avoidance feature.</p>
|
||||
|
||||
<label class="field"><span>Corpus asset</span>
|
||||
<select bind:value={corpusAssetId}>
|
||||
<option value="">Select from Asset Registry…</option>
|
||||
{#each assets as a}<option value={a.id}>{a.type} — {a.description}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field"><span>Mutawalli (trustee)</span><input type="text" bind:value={mutawalli} /></label>
|
||||
<label class="field"><span>Successor mutawalli</span><input type="text" bind:value={successorMutawalli} /></label>
|
||||
<label class="field"><span>Jurisdiction</span>
|
||||
<select bind:value={jurisdiction}>
|
||||
<option>Perak</option><option>Selangor</option><option>Kuala Lumpur (Federal Territory)</option><option>Other</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field-check"><input type="checkbox" bind:checked={equalSplit} /><span>Equal split between descendants (default)</span></label>
|
||||
|
||||
{#if !equalSplit}
|
||||
<div class="deviation-box">
|
||||
<p>Deviating from equal split (favoring one branch, excluding a descendant) is fiqh-controversial for waqf ahli/zurri — some classical positions restrict this, others permit it under conditions. This has not received scholarly sign-off for this product either.</p>
|
||||
<label class="ack"><input type="checkbox" bind:checked={deviationAcknowledged} /><span>I understand this is a contested area and want to proceed anyway</span></label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Beneficiary name</span><input type="text" bind:value={beneficiaryForm.name} /></label>
|
||||
<label class="field"><span>Relation</span><input type="text" bind:value={beneficiaryForm.relation} /></label>
|
||||
<label class="field"><span>Share % (if not equal split)</span><input type="number" min="0" max="100" bind:value={beneficiaryForm.sharePercent} /></label>
|
||||
<button class="btn-primary" onclick={addBeneficiary} disabled={!equalSplit && !deviationAcknowledged}>Add beneficiary</button>
|
||||
</div>
|
||||
|
||||
{#each beneficiaries as b (b.id)}
|
||||
<div class="beneficiary-row"><span>{b.name} ({b.relation || '—'}){b.sharePercent ? ` — ${b.sharePercent}%` : ''}</span><button onclick={() => remove(b.id)}>✕</button></div>
|
||||
{/each}
|
||||
|
||||
<div class="guard-section">
|
||||
<h3>Marad al-mawt check</h3>
|
||||
{#if !showGuard}
|
||||
<button class="btn-secondary" onclick={() => showGuard = true}>Run health-state check</button>
|
||||
{:else}
|
||||
<MaradAlMawtGuard estateTotal={total} beneficiaryRelation={beneficiaries[0]?.relation || ''} onAnswered={r => guardResult = r} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" disabled={!corpusAssetId || !mutawalli || (guardResult?.flagged && guardResult?.beneficiaryIsHeir)} onclick={exportDeed}>Export waqfiyya deed (drafting aid)</button>
|
||||
|
||||
<Disclaimer text="This app's output alone does not constitute a legally registered waqf. Registration with the relevant state waqf authority is required for legal effect." />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module { padding: 4px 0 40px; }
|
||||
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 10px; }
|
||||
.open-note { font-size: 11.5px; color: #C9A84C; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 10px; padding: 10px 12px; margin-bottom: 12px; line-height: 1.5; }
|
||||
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.field span { font-size: 12px; color: #B8B2A6; }
|
||||
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
|
||||
.field-check { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; font-size: 13.5px; color: #E8E4DC; }
|
||||
.deviation-box, .guard-section { background: rgba(255,255,255,0.03); border-radius: 10px; padding: 12px; margin-bottom: 14px; }
|
||||
.deviation-box p { font-size: 12px; color: #B8B2A6; line-height: 1.5; margin-bottom: 8px; }
|
||||
.ack { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #E8E4DC; }
|
||||
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
||||
.btn-primary, .btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
|
||||
.btn-primary { background: #C9A84C; color: #070A0D; }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; }
|
||||
.beneficiary-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.beneficiary-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
|
||||
.guard-section h3 { font-size: 14px; color: #C9A84C; margin-bottom: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script>
|
||||
import { calculateFaraid, applyEstateValue } from './calc/faraid.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
|
||||
let deceasedGender = $state('male');
|
||||
let estateValue = $state(0);
|
||||
let spouseCount = $state(0);
|
||||
let sons = $state(0);
|
||||
let daughters = $state(0);
|
||||
let father = $state(false);
|
||||
let mother = $state(false);
|
||||
let fullBrothers = $state(0);
|
||||
let fullSisters = $state(0);
|
||||
let paternalBrothers = $state(0);
|
||||
let paternalSisters = $state(0);
|
||||
let maternalSiblings = $state(0);
|
||||
|
||||
const hasDescendant = $derived(sons > 0 || daughters > 0);
|
||||
const siblingsBlocked = $derived(father || sons > 0);
|
||||
|
||||
const result = $derived.by(() => {
|
||||
const r = calculateFaraid({
|
||||
deceasedGender, spouseCount, sons, daughters, father, mother,
|
||||
fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
|
||||
});
|
||||
return applyEstateValue(r, estateValue || 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="module">
|
||||
<h2>Faraid Calculator</h2>
|
||||
<p class="sub">Answer simple questions about surviving family — no fiqh terminology needed.</p>
|
||||
|
||||
<label class="field">
|
||||
<span>Deceased's gender</span>
|
||||
<select bind:value={deceasedGender}>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Estate value (for computed amounts)</span>
|
||||
<input type="number" min="0" bind:value={estateValue} placeholder="0" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{deceasedGender === 'male' ? 'Number of surviving wives' : 'Surviving husband?'}</span>
|
||||
{#if deceasedGender === 'male'}
|
||||
<input type="number" min="0" max="4" bind:value={spouseCount} />
|
||||
{:else}
|
||||
<select bind:value={spouseCount}><option value={0}>No</option><option value={1}>Yes</option></select>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<label class="field"><span>Sons</span><input type="number" min="0" bind:value={sons} /></label>
|
||||
<label class="field"><span>Daughters</span><input type="number" min="0" bind:value={daughters} /></label>
|
||||
<label class="field-check"><input type="checkbox" bind:checked={father} /><span>Father survives</span></label>
|
||||
<label class="field-check"><input type="checkbox" bind:checked={mother} /><span>Mother survives</span></label>
|
||||
|
||||
{#if !hasDescendant}
|
||||
<div class="siblings-note">Sibling questions only apply when there is no surviving father or son.</div>
|
||||
{#if !siblingsBlocked}
|
||||
<label class="field"><span>Full brothers</span><input type="number" min="0" bind:value={fullBrothers} /></label>
|
||||
<label class="field"><span>Full sisters</span><input type="number" min="0" bind:value={fullSisters} /></label>
|
||||
<label class="field"><span>Paternal (consanguine) brothers</span><input type="number" min="0" bind:value={paternalBrothers} /></label>
|
||||
<label class="field"><span>Paternal (consanguine) sisters</span><input type="number" min="0" bind:value={paternalSisters} /></label>
|
||||
<label class="field"><span>Maternal (uterine) siblings</span><input type="number" min="0" bind:value={maternalSiblings} /></label>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="results">
|
||||
<h3>Shares</h3>
|
||||
{#each result.shares as s}
|
||||
<div class="share-row">
|
||||
<span class="share-heir">{s.heir}</span>
|
||||
<span class="share-frac">{s.fraction}</span>
|
||||
{#if estateValue > 0}<span class="share-amount">{s.amount.toLocaleString()}</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#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>
|
||||
{/if}
|
||||
{#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>
|
||||
{/if}
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Disclaimer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module { padding: 4px 0 40px; }
|
||||
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
|
||||
.sub { font-size: 13px; color: #8A8478; margin-bottom: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field span { font-size: 12.5px; color: #B8B2A6; }
|
||||
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 15px; }
|
||||
.field-check { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; font-size: 13.5px; color: #E8E4DC; }
|
||||
.siblings-note { font-size: 11.5px; color: #8A8478; font-style: italic; margin: 8px 0; }
|
||||
.results { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
|
||||
.results h3 { font-size: 15px; color: #C9A84C; margin-bottom: 10px; }
|
||||
.share-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 13.5px; }
|
||||
.share-heir { color: #E8E4DC; flex: 1; }
|
||||
.share-frac { color: #C9A84C; font-weight: 600; width: 60px; text-align: right; }
|
||||
.share-amount { color: #2ECC71; width: 90px; text-align: right; }
|
||||
.note-box { font-size: 12px; color: #B8B2A6; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 10px 12px; margin-top: 10px; line-height: 1.5; }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
import { load, save, estateTotal } from './storage.js';
|
||||
import MaradAlMawtGuard from './MaradAlMawtGuard.svelte';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
|
||||
const assets = load('assets', []);
|
||||
const total = estateTotal(assets);
|
||||
|
||||
let gifts = $state(load('hibahGifts', []));
|
||||
let form = $state({ recipient: '', relation: '', description: '', date: new Date().toISOString().slice(0, 10), statement: '' });
|
||||
let guardResult = $state(null);
|
||||
let showGuard = $state(false);
|
||||
let pendingSave = $state(false);
|
||||
|
||||
function startAdd() {
|
||||
showGuard = true;
|
||||
guardResult = null;
|
||||
}
|
||||
|
||||
function onGuardAnswered(res) {
|
||||
guardResult = res;
|
||||
}
|
||||
|
||||
function confirmSave() {
|
||||
if (!form.recipient || !form.description) return;
|
||||
if (guardResult?.flagged && guardResult?.beneficiaryIsHeir) return; // blocked
|
||||
const gift = {
|
||||
...form,
|
||||
id: crypto.randomUUID(),
|
||||
flagged: !!guardResult?.flagged,
|
||||
cap: guardResult?.cap ?? null,
|
||||
acknowledgmentLink: `nf-hibah-ack-${crypto.randomUUID().slice(0, 8)}`
|
||||
};
|
||||
gifts = [...gifts, gift];
|
||||
save('hibahGifts', gifts);
|
||||
form = { recipient: '', relation: '', description: '', date: new Date().toISOString().slice(0, 10), statement: '' };
|
||||
showGuard = false;
|
||||
guardResult = null;
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
gifts = gifts.filter(g => g.id !== id);
|
||||
save('hibahGifts', gifts);
|
||||
}
|
||||
|
||||
const lifetimeTotal = $derived(gifts.reduce((s, g) => s + 0, 0)); // value optional; kept simple per hibah = gift not always monetary
|
||||
</script>
|
||||
|
||||
<div class="module">
|
||||
<h2>Hibah Tracker</h2>
|
||||
<p class="sub">Log a lifetime gift — completed by offer, acceptance, and possession.</p>
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Recipient</span><input type="text" bind:value={form.recipient} /></label>
|
||||
<label class="field"><span>Relation to you</span><input type="text" bind:value={form.relation} /></label>
|
||||
<label class="field"><span>Asset / gift description</span><input type="text" bind:value={form.description} /></label>
|
||||
<label class="field"><span>Date</span><input type="date" bind:value={form.date} /></label>
|
||||
<label class="field"><span>Offer-and-acceptance statement</span><textarea bind:value={form.statement} rows="2"></textarea></label>
|
||||
|
||||
{#if !showGuard}
|
||||
<button class="btn-primary" onclick={startAdd}>Log this hibah</button>
|
||||
{:else}
|
||||
<MaradAlMawtGuard estateTotal={total} beneficiaryRelation={form.relation} onAnswered={onGuardAnswered} />
|
||||
{#if guardResult}
|
||||
<button
|
||||
class="btn-primary"
|
||||
disabled={guardResult.flagged && guardResult.beneficiaryIsHeir}
|
||||
onclick={confirmSave}
|
||||
>Confirm and save</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
{#each gifts as g (g.id)}
|
||||
<div class="gift-row" class:flagged={g.flagged}>
|
||||
<div class="gift-info">
|
||||
<strong>{g.recipient}</strong> <span class="muted">({g.relation || '—'})</span>
|
||||
<p class="gift-desc">{g.description}</p>
|
||||
<span class="muted">{g.date}</span>
|
||||
{#if g.flagged}<span class="flag-badge">Flagged — terminal-illness context</span>{/if}
|
||||
</div>
|
||||
<button onclick={() => remove(g.id)}>✕</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No hibah entries logged yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Disclaimer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module { padding: 4px 0 40px; }
|
||||
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
|
||||
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
||||
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.field span { font-size: 12px; color: #B8B2A6; }
|
||||
.field input, .field textarea { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; font-family: inherit; resize: vertical; }
|
||||
.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; }
|
||||
.gift-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.gift-row.flagged { background: rgba(239,68,68,0.05); border-radius: 8px; padding: 12px; margin-bottom: 6px; border-bottom: none; }
|
||||
.gift-info { font-size: 13px; color: #E8E4DC; }
|
||||
.gift-desc { color: #B8B2A6; margin: 2px 0; }
|
||||
.muted { color: #8A8478; font-size: 11.5px; }
|
||||
.flag-badge { display: inline-block; margin-top: 4px; font-size: 10.5px; background: #EF4444; color: white; padding: 2px 8px; border-radius: 6px; }
|
||||
.gift-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
|
||||
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
// Single shared component per Horizon 1 PRD §9: "identical wording and identical
|
||||
// one-third-cap calculation logic... so a future fiqh-review correction only ever
|
||||
// needs to be made once." Consumed by Hibah Tracker and Family Waqf Designator.
|
||||
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
|
||||
|
||||
let { estateTotal = 0, onAnswered = () => {}, beneficiaryRelation = '' } = $props();
|
||||
|
||||
let answered = $state(false);
|
||||
let flagged = $state(false);
|
||||
|
||||
const cap = $derived(oneThirdCap(estateTotal));
|
||||
const beneficiaryIsHeir = $derived(isQuranicHeirRelation(beneficiaryRelation));
|
||||
|
||||
function answer(isFlagged) {
|
||||
answered = true;
|
||||
flagged = isFlagged;
|
||||
onAnswered({ flagged: isFlagged, cap, beneficiaryIsHeir });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="guard">
|
||||
{#if !answered}
|
||||
<p class="guard-question">Are you currently living with a diagnosed terminal or critical illness (marad al-mawt)?</p>
|
||||
<div class="guard-buttons">
|
||||
<button class="btn-secondary" onclick={() => answer(false)}>No</button>
|
||||
<button class="btn-warn" onclick={() => answer(true)}>Yes</button>
|
||||
</div>
|
||||
{:else if flagged}
|
||||
<div class="guard-flagged">
|
||||
<p><strong>Flagged — terminal-illness context.</strong> This entry is capped at one-third of your estate (<strong>{cap.toLocaleString()}</strong>) and cannot name an existing Quranic heir as beneficiary, exactly like a wasiyyah.</p>
|
||||
{#if beneficiaryIsHeir}
|
||||
<p class="guard-error">The beneficiary relation you entered ("{beneficiaryRelation}") appears to be an existing heir. This is blocked under the marad al-mawt guardrail — remove or change the beneficiary to proceed.</p>
|
||||
{/if}
|
||||
<p class="guard-note">This answer is logged for your own records and any future scholarly or legal review. It is asked fresh on every entry and is never cached.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.guard { background: rgba(239,68,68,0.06); border: 1px solid rgba(239,68,68,0.2); border-radius: 10px; padding: 14px; margin: 12px 0; }
|
||||
.guard-question { font-size: 13.5px; color: #E8E4DC; margin: 0 0 10px; }
|
||||
.guard-buttons { display: flex; gap: 10px; }
|
||||
.btn-secondary, .btn-warn { flex: 1; padding: 10px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
|
||||
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; }
|
||||
.btn-warn { background: #EF4444; color: white; }
|
||||
.guard-flagged p { font-size: 12.5px; color: #E8E4DC; line-height: 1.5; margin: 0 0 8px; }
|
||||
.guard-error { color: #EF4444 !important; font-weight: 600; }
|
||||
.guard-note { color: #8A8478 !important; font-style: italic; }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script>
|
||||
import { load, save, estateTotal } from './storage.js';
|
||||
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
|
||||
const assets = load('assets', []);
|
||||
const total = estateTotal(assets);
|
||||
const cap = oneThirdCap(total);
|
||||
|
||||
let jurisdiction = $state('UK');
|
||||
let bequests = $state(load('wassiyahBequests', []));
|
||||
let form = $state({ recipient: '', relation: '', description: '', value: '' });
|
||||
let witness1 = $state(load('witness1', ''));
|
||||
let witness2 = $state(load('witness2', ''));
|
||||
let overrideAcknowledged = $state(false);
|
||||
|
||||
const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0));
|
||||
const exceedsCap = $derived(bequestTotal > cap);
|
||||
const blockedRecipient = $derived(isQuranicHeirRelation(form.relation));
|
||||
|
||||
function addBequest() {
|
||||
if (!form.recipient || !form.value) return;
|
||||
bequests = [...bequests, { ...form, id: crypto.randomUUID(), value: Number(form.value) }];
|
||||
save('wassiyahBequests', bequests);
|
||||
form = { recipient: '', relation: '', description: '', value: '' };
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
bequests = bequests.filter(b => b.id !== id);
|
||||
save('wassiyahBequests', bequests);
|
||||
}
|
||||
|
||||
function exportDraft() {
|
||||
if (exceedsCap && !overrideAcknowledged) return;
|
||||
const lines = [
|
||||
'WASSIYAH (ISLAMIC WILL) — DRAFT',
|
||||
`Jurisdiction: ${jurisdiction}`,
|
||||
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
||||
`Net estate (per Asset Registry): ${total.toLocaleString()}`,
|
||||
`One-third discretionary limit: ${cap.toLocaleString()}`,
|
||||
`Total bequeathed: ${bequestTotal.toLocaleString()}${exceedsCap ? ' [EXCEEDS ONE-THIRD — OVERRIDE ACKNOWLEDGED]' : ''}`,
|
||||
'',
|
||||
'BEQUESTS:',
|
||||
...bequests.map(b => ` ${b.recipient} (${b.relation || 'non-heir'}) — ${b.description} — ${Number(b.value).toLocaleString()}`),
|
||||
'',
|
||||
'This bequest is limited to the discretionary one-third of the net estate and cannot',
|
||||
'benefit a person who is already a fixed-share (Faraid) heir, except with the consent',
|
||||
'of all other heirs after death.',
|
||||
'',
|
||||
jurisdiction === 'UK'
|
||||
? 'UK NOTE: this draft references Wills Act 1837 execution requirements. A will only has\nlegal effect if it is also validly executed under UK law, independent of its faraid-\ncorrectness. Have this reviewed by a UK-qualified solicitor before relying on it.'
|
||||
: 'MALAYSIA NOTE: Land Office or Shariah Court recognition is not guaranteed by an\napp-generated document alone. State-specific requirements vary across all fourteen states.',
|
||||
'',
|
||||
`Witness 1: ${witness1 || '________________________'}`,
|
||||
`Witness 2: ${witness2 || '________________________'}`,
|
||||
'',
|
||||
'DISCLAIMER: Not a fatwa. Not legal advice. Requires qualified review before real-world reliance.'
|
||||
];
|
||||
save('witness1', witness1); save('witness2', witness2);
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'wassiyah-draft.txt'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="module">
|
||||
<h2>Wassiyah Generator</h2>
|
||||
<p class="sub">Discretionary bequest limited to one-third of your net estate, calculated against your Asset Registry.</p>
|
||||
|
||||
<div class="meter-card">
|
||||
<div class="meter-row"><span>Net estate</span><strong>{total.toLocaleString()}</strong></div>
|
||||
<div class="meter-row"><span>One-third limit</span><strong class="gold">{cap.toLocaleString()}</strong></div>
|
||||
<div class="meter-bar"><div class="meter-fill" class:over={exceedsCap} style="width: {Math.min(100, (bequestTotal / (cap || 1)) * 100)}%"></div></div>
|
||||
<div class="meter-row"><span>Bequeathed so far</span><strong class:danger={exceedsCap}>{bequestTotal.toLocaleString()}</strong></div>
|
||||
</div>
|
||||
|
||||
<label class="field"><span>Jurisdiction</span>
|
||||
<select bind:value={jurisdiction}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select>
|
||||
</label>
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Recipient name</span><input type="text" bind:value={form.recipient} /></label>
|
||||
<label class="field"><span>Relation to you</span><input type="text" bind:value={form.relation} placeholder="e.g. nephew, charity, friend — not spouse/child/parent/sibling" /></label>
|
||||
<label class="field"><span>Description</span><input type="text" bind:value={form.description} /></label>
|
||||
<label class="field"><span>Value</span><input type="number" min="0" bind:value={form.value} /></label>
|
||||
{#if blockedRecipient}
|
||||
<p class="block-error">This relation ("{form.relation}") matches an existing Quranic heir. You cannot bequeath to an existing heir via wassiyah — the fixed Faraid shares already govern their portion. This entry is blocked.</p>
|
||||
{:else}
|
||||
<button class="btn-primary" onclick={addBequest}>Add bequest</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each bequests as b (b.id)}
|
||||
<div class="bequest-row">
|
||||
<div><strong>{b.recipient}</strong> <span class="muted">({b.relation || 'non-heir'})</span><br/><span class="muted">{b.description}</span></div>
|
||||
<div class="bequest-value">{Number(b.value).toLocaleString()}<button onclick={() => remove(b.id)}>✕</button></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if exceedsCap}
|
||||
<div class="override-box">
|
||||
<p><strong>This exceeds the one-third limit.</strong> Under Faraid, a bequest above one-third of the net estate requires the consent of all heirs after your death, or it is void ab initio for the excess. Proceeding without that consent risks a materially wrong religious and, in some jurisdictions, legal claim reaching your family.</p>
|
||||
<label class="ack"><input type="checkbox" bind:checked={overrideAcknowledged} /><span>I understand and want to proceed anyway</span></label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="field"><span>Witness 1</span><input type="text" bind:value={witness1} /></label>
|
||||
<label class="field"><span>Witness 2</span><input type="text" bind:value={witness2} /></label>
|
||||
|
||||
<button class="btn-primary" disabled={exceedsCap && !overrideAcknowledged} onclick={exportDraft}>Export draft (PDF + text)</button>
|
||||
|
||||
<Disclaimer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module { padding: 4px 0 40px; }
|
||||
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
|
||||
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
||||
.meter-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 18px; }
|
||||
.meter-row { display: flex; justify-content: space-between; font-size: 13px; color: #B8B2A6; margin-bottom: 6px; }
|
||||
.meter-row strong { color: #E8E4DC; }
|
||||
.gold { color: #C9A84C !important; }
|
||||
.danger { color: #EF4444 !important; }
|
||||
.meter-bar { height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; margin: 8px 0; }
|
||||
.meter-fill { height: 100%; background: #2ECC71; transition: width 0.3s; }
|
||||
.meter-fill.over { background: #EF4444; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.field span { font-size: 12px; color: #B8B2A6; }
|
||||
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
|
||||
.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:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.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; }
|
||||
.muted { color: #8A8478; font-size: 11.5px; }
|
||||
.bequest-value { color: #2ECC71; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.bequest-value button { background: none; border: none; color: #8A8478; cursor: pointer; }
|
||||
.override-box { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 10px; padding: 12px; margin: 16px 0; }
|
||||
.override-box p { font-size: 12px; color: #E8E4DC; line-height: 1.5; margin-bottom: 10px; }
|
||||
.ack { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: #E8E4DC; }
|
||||
</style>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,116 @@
|
||||
<script>
|
||||
// Built per explicit user instruction overriding the Horizon 2 PRD's Phase 0 gate.
|
||||
// See claims.js header for the full note. This screen is clearly labeled pre-pilot
|
||||
// throughout — the PRD's own non-goals (§3.2) forbid presenting this as a freely
|
||||
// tradeable security or a live institutional integration, and it is neither.
|
||||
import { listClaims, issueClaim, transferClaim, removeClaim } from './claims.js';
|
||||
import Disclaimer from '../Disclaimer.svelte';
|
||||
|
||||
let claims = $state(listClaims());
|
||||
let form = $state({ assetLabel: '', heirPoolId: '', holderName: '', heirShareFraction: '', faraidRulingRef: '' });
|
||||
let transferTarget = $state({});
|
||||
let error = $state('');
|
||||
|
||||
function issue() {
|
||||
if (!form.assetLabel || !form.holderName || !form.heirPoolId) return;
|
||||
const claim = issueClaim({ ...form });
|
||||
claims = [...claims, claim];
|
||||
form = { assetLabel: '', heirPoolId: '', holderName: '', heirShareFraction: '', faraidRulingRef: '' };
|
||||
}
|
||||
|
||||
function doTransfer(claim, mode) {
|
||||
error = '';
|
||||
try {
|
||||
const t = transferTarget[claim.id] || {};
|
||||
transferClaim(claim.id, { toHolderName: t.name, toHeirPoolId: t.heirPoolId || claim.heirPoolId, mode });
|
||||
claims = listClaims();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
removeClaim(id);
|
||||
claims = listClaims();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="module">
|
||||
<div class="pilot-banner">
|
||||
<strong>PRE-PILOT DEMONSTRATION — NOT A LIVE PROGRAMME.</strong>
|
||||
Horizon 2 PRD Status: "No engineering work begins until Phase 0 (legal opinion + signed
|
||||
institutional partner agreement) is complete." That confirmation has not been provided in
|
||||
this session. This screen is a structural demonstration of the claim model and its transfer
|
||||
restriction — it issues no real legal claim, moves no real asset, and is not backed by any
|
||||
SPV/trust legal wrapper or institutional partner.
|
||||
</div>
|
||||
|
||||
<h2>Digital Beneficial Claims</h2>
|
||||
<p class="sub">Represents one heir's already-calculated, faraid-verified fractional interest in a specific illiquid asset. Transferable only within the same heir pool, or back to the wrapper entity — never onto an open market.</p>
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Asset (named, illiquid — e.g. land parcel)</span><input type="text" bind:value={form.assetLabel} /></label>
|
||||
<label class="field"><span>Heir pool ID (shared by all co-heirs of this asset)</span><input type="text" bind:value={form.heirPoolId} placeholder="e.g. HP-2026-001" /></label>
|
||||
<label class="field"><span>Holder name</span><input type="text" bind:value={form.holderName} /></label>
|
||||
<label class="field"><span>Heir share fraction (from faraid ruling)</span><input type="text" bind:value={form.heirShareFraction} placeholder="e.g. 1/8" /></label>
|
||||
<label class="field"><span>Faraid ruling reference</span><input type="text" bind:value={form.faraidRulingRef} placeholder="Shariah Court ruling no. / equivalent" /></label>
|
||||
<button class="btn-primary" onclick={issue}>Issue demo claim</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error-box">{error}</p>{/if}
|
||||
|
||||
{#each claims as c (c.id)}
|
||||
<div class="claim-card">
|
||||
<div class="claim-head">
|
||||
<strong>{c.assetLabel}</strong>
|
||||
<span class="status status-{c.status}">{c.status}</span>
|
||||
</div>
|
||||
<div class="claim-meta">Holder: {c.holderName} · Share: {c.heirShareFraction || '—'} · Heir pool: {c.heirPoolId}</div>
|
||||
<div class="claim-meta">Ruling ref: {c.faraidRulingRef || '—'}</div>
|
||||
|
||||
<div class="transfer-row">
|
||||
<input type="text" placeholder="Recipient name (same heir pool)" value={transferTarget[c.id]?.name || ''} oninput={e => transferTarget[c.id] = { ...transferTarget[c.id], name: e.target.value }} />
|
||||
<button class="btn-small" onclick={() => doTransfer(c, 'heir-pool-transfer')}>Transfer within pool</button>
|
||||
<button class="btn-small" onclick={() => doTransfer(c, 'buy-back')}>Buy-back to wrapper</button>
|
||||
</div>
|
||||
|
||||
<details class="history">
|
||||
<summary>History</summary>
|
||||
{#each c.history as h}<div class="history-row">{h.event} — {h.at.slice(0,10)}{h.to ? ` → ${h.to}` : ''}</div>{/each}
|
||||
</details>
|
||||
|
||||
<button class="remove-btn" onclick={() => remove(c.id)}>Remove demo claim</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No demo claims issued yet.</p>
|
||||
{/each}
|
||||
|
||||
<Disclaimer text="Not a security offering. Not a cryptocurrency or speculative asset. Any real deployment requires a legal wrapper (SPV/trust) holding actual title and a named institutional partner — neither exists in this build." />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module { padding: 4px 0 40px; }
|
||||
.pilot-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 10px; padding: 12px 14px; font-size: 11.5px; color: #E8E4DC; line-height: 1.5; margin-bottom: 18px; }
|
||||
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
|
||||
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
||||
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.field span { font-size: 12px; color: #B8B2A6; }
|
||||
.field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; width: 100%; }
|
||||
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
|
||||
.error-box { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #EF4444; font-size: 12px; padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; line-height: 1.5; }
|
||||
.claim-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 12px; }
|
||||
.claim-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.claim-head strong { color: #E8E4DC; font-size: 14px; }
|
||||
.status { font-size: 10px; text-transform: uppercase; padding: 3px 8px; border-radius: 6px; background: rgba(46,204,113,0.15); color: #2ECC71; }
|
||||
.status-transferred { background: rgba(201,168,76,0.15); color: #C9A84C; }
|
||||
.status-bought-back { background: rgba(255,255,255,0.1); color: #B8B2A6; }
|
||||
.claim-meta { font-size: 11.5px; color: #8A8478; margin-bottom: 2px; }
|
||||
.transfer-row { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
|
||||
.transfer-row input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 8px 10px; color: #E8E4DC; font-size: 13px; }
|
||||
.btn-small { background: rgba(255,255,255,0.08); color: #E8E4DC; border: none; border-radius: 8px; padding: 8px; font-size: 12px; cursor: pointer; }
|
||||
.history { margin-top: 10px; font-size: 11px; color: #8A8478; }
|
||||
.history-row { padding: 2px 0; }
|
||||
.remove-btn { margin-top: 10px; background: none; border: none; color: #8A8478; font-size: 11px; cursor: pointer; }
|
||||
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
// Digital Beneficial Claims data model.
|
||||
// Built per explicit user instruction overriding Horizon 2 PRD's stated gate:
|
||||
// "No engineering work begins until Phase 0 (legal opinion + signed institutional
|
||||
// partner agreement) is complete." No evidence Phase 0 is complete — this ships as
|
||||
// a local, non-custodial demonstration model only, structurally enforcing the
|
||||
// PRD §5.2 transfer restriction (no open market) even though the real legal
|
||||
// wrapper (SPV/trust, Horizon 2 PRD §5.1) and institutional integration do not exist
|
||||
// yet. Nothing here moves real assets or represents a real legal claim.
|
||||
|
||||
import { load, save } from '../storage.js';
|
||||
|
||||
const KEY = 'h2Claims';
|
||||
|
||||
/**
|
||||
* @param {object} params { assetId, assetLabel, heirPoolId, holderName, heirShareFraction, faraidRulingRef }
|
||||
*/
|
||||
export function issueClaim(params) {
|
||||
const claims = load(KEY, []);
|
||||
const claim = {
|
||||
id: crypto.randomUUID(),
|
||||
status: 'issued', // issued | transferred | bought-back
|
||||
issuedAt: new Date().toISOString(),
|
||||
history: [{ event: 'issued', at: new Date().toISOString(), to: params.holderName }],
|
||||
...params
|
||||
};
|
||||
claims.push(claim);
|
||||
save(KEY, claims);
|
||||
return claim;
|
||||
}
|
||||
|
||||
export function listClaims() {
|
||||
return load(KEY, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer restriction per Horizon 2 PRD §5.2: a claim may only transfer to
|
||||
* (a) another verified heir within the same heir pool for that asset, or
|
||||
* (b) the legal wrapper entity itself (buy-back). No default path to an open market —
|
||||
* structurally enforced here, not left to UI copy alone.
|
||||
*/
|
||||
export function transferClaim(claimId, { toHolderName, toHeirPoolId, mode }) {
|
||||
const claims = load(KEY, []);
|
||||
const claim = claims.find(c => c.id === claimId);
|
||||
if (!claim) throw new Error('Claim not found');
|
||||
|
||||
if (mode === 'buy-back') {
|
||||
claim.status = 'bought-back';
|
||||
claim.history.push({ event: 'buy-back', at: new Date().toISOString(), note: 'valuation set by independent valuation, not a market bid (demo: no real valuation engine wired)' });
|
||||
} else if (mode === 'heir-pool-transfer') {
|
||||
if (toHeirPoolId !== claim.heirPoolId) {
|
||||
throw new Error('BLOCKED: recipient is not in the same faraid-determined heir pool for this asset. Per PRD §5.2 there is no default path to an open market.');
|
||||
}
|
||||
claim.holderName = toHolderName;
|
||||
claim.status = 'transferred';
|
||||
claim.history.push({ event: 'transfer', at: new Date().toISOString(), to: toHolderName });
|
||||
} else {
|
||||
throw new Error('Unsupported transfer mode — pre-approved licensed institutional buyer flow requires a separately reviewed compliance process not modelled here.');
|
||||
}
|
||||
save(KEY, claims);
|
||||
return claim;
|
||||
}
|
||||
|
||||
export function removeClaim(claimId) {
|
||||
const claims = load(KEY, []).filter(c => c.id !== claimId);
|
||||
save(KEY, claims);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// String-dictionary i18n, per Horizon 1 PRD §11: "preserve the existing
|
||||
// internationalization pattern... a product requirement, not implementation detail."
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const lang = writable(load());
|
||||
|
||||
function load() {
|
||||
try { return localStorage.getItem('nf.lang') || 'en'; } catch { return 'en'; }
|
||||
}
|
||||
|
||||
export function setLang(l) {
|
||||
lang.set(l);
|
||||
try { localStorage.setItem('nf.lang', l); } catch {}
|
||||
}
|
||||
|
||||
const dict = {
|
||||
en: {
|
||||
appName: 'Nur Falah', tagline: 'ESTATE & WAQF SUITE',
|
||||
nav_faraid: 'Faraid', nav_assets: 'Assets', nav_wassiyah: 'Wassiyah',
|
||||
nav_hibah: 'Hibah', nav_waqf: 'Family Waqf', nav_claims: 'Claims',
|
||||
disclaimer_short: 'Not a fatwa. Not legal advice. Requires qualified review before real-world reliance.'
|
||||
},
|
||||
ms: {
|
||||
appName: 'Nur Falah', tagline: 'RANGKAIAN HARTA & WAKAF',
|
||||
nav_faraid: 'Faraid', nav_assets: 'Aset', nav_wassiyah: 'Wasiat',
|
||||
nav_hibah: 'Hibah', nav_waqf: 'Wakaf Keluarga', nav_claims: 'Tuntutan',
|
||||
disclaimer_short: 'Bukan fatwa. Bukan nasihat undang-undang. Memerlukan semakan bertauliah sebelum digunakan.'
|
||||
}
|
||||
};
|
||||
|
||||
export function t(key) {
|
||||
let current = 'en';
|
||||
lang.subscribe(v => current = v)();
|
||||
return (dict[current] && dict[current][key]) || dict.en[key] || key;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Local-first storage helper. Horizon 1 PRD §10: encrypted at rest, local-first,
|
||||
// opt-in sync only, full export + irreversible delete one-tap from settings.
|
||||
// Scope note: this ships plain localStorage for the working build; wrapping every
|
||||
// read/write in WebCrypto AES-GCM (device-key-derived) is the remaining hardening
|
||||
// step before this is production-safe for the "High/Critical" data rows in §10 —
|
||||
// tracked here rather than silently assumed done.
|
||||
|
||||
const PREFIX = 'nf.';
|
||||
|
||||
export function load(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function save(key, value) {
|
||||
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function remove(key) {
|
||||
localStorage.removeItem(PREFIX + key);
|
||||
}
|
||||
|
||||
export function exportAll() {
|
||||
const out = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(PREFIX)) out[k.slice(PREFIX.length)] = JSON.parse(localStorage.getItem(k));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Irreversible, immediate — per §10, no confirmation-delay pattern beyond the button itself. */
|
||||
export function deleteAll() {
|
||||
const keys = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(PREFIX)) keys.push(k);
|
||||
}
|
||||
keys.forEach(k => localStorage.removeItem(k));
|
||||
}
|
||||
|
||||
export function estateTotal(assets) {
|
||||
return (assets || []).reduce((sum, a) => sum + (Number(a.value) || 0) * (Number(a.ownershipShare ?? 100) / 100), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user