9eb2ce7ce3
mutawalli executes on any member's trigger, heir email notification Per explicit product direction: "all members of the family can make their own wassiyah or waqif. The mutawali or the trustee agent can access and execute those wassiyah and waqif upon any event triggers. Warith or the heir will be automatically notified via email." Schema: nf_family_members.role now includes 'member' (authors own documents, doesn't manage the family). nf_wassiyah_settings/nf_wassiyah_bequests/ nf_waqf_designations gained author_id — each is now per-author, not per-family. Added recipient_email / beneficiary_email columns for warith notification targets. New nf_member_triggers (composite PK family_id+ member_id) and nf_member_attestors: a per-member death trigger, separate from the legacy family-wide nf_death_triggers (kept for backward compatibility, still exercised by existing suites). RLS: any family member can READ any other member's Wassiyah/Waqf (the mutawalli needs full visibility to execute), but only the document's own author can WRITE to it — not even the owner. Firing a member's trigger requires the caller to have role agent/owner AND not be the member themselves (enforced in the policy's WITH CHECK, not just the UI) — matches "the mutawalli executes, never for themselves." New UI: FamilyManagement gained a role selector (member vs agent) on invites. New MutawalliDashboard.svelte — the trustee's execution surface: pick any family member, see their Wassiyah/Waqf read-only, set up attestors + death cert ref, fire their trigger (blocked for self both by disabled UI and by RLS), then trigger heir email notifications. Edge Function notify-heirs deployed (Deno, uses Resend): reads the triggered member's Wassiyah recipients and Waqf beneficiaries wherever an email was recorded, sends each a notice. Returns a clear 501 rather than failing silently until RESEND_API_KEY is set as a project secret. Three real bugs found via testing against the live backend, not visible from code review alone: - listFamilyMembers() never selected user_id — every member-scoped lookup on the new dashboard was silently keying off undefined. - nf_member_triggers keyed by member_id alone: since a person can belong to multiple families, firing a trigger in one family marked them "triggered" in every other family they belong to. Fixed to composite (family_id, member_id) key. - Classic Svelte 5 $state pitfall: (proxyObject[key] ??= []).push(item) mutates the plain array literal the ??= expression evaluates to, not the proxy-wrapped array Svelte actually tracks — so pushed items were silently invisible to the UI forever. Fixed by building on a plain object and assigning to the $state variable once. Also found and fixed the same design smell in the older WassiyahGenerator/FamilyWaqfDesignator authorId handling: it was snapshotted once via currentUser()?.id at mount instead of read live off the session store, which could silently break writes on a remount that happened before session hydration finished — now reads live and guards refresh() on it being present. CoverageDashboard and DeathTrigger updated to check ANY family member's Waqf corpus for coverage (not just one author's), since coverage is a family-wide view even though authorship is per-member now. e2e-per-member.cjs: new suite covering the full flow — owner invites a member and an agent; member authors a private Wassiyah (invisible to other members, confirming per-author isolation); mutawalli sees it on their dashboard and fires the member's trigger; member cannot fire their own; heir notification call completes with either Sent or a clear "not configured" failure, never hangs. 11/11 passing. Full regression sweep after these changes: e2e-uat 32/32 (stable across 3 consecutive runs), e2e-fastpath 16/16, e2e-trust 12/12, e2e-business 10/10, e2e-digital-vehicle 10/10, e2e-property 9/9, e2e-other 4/4, e2e-info 31/31, e2e-family-agent 12/12 (updated for the new invite-form role selector), e2e-per-member 11/11 — 178/178 total, no regressions.
249 lines
16 KiB
Svelte
249 lines
16 KiB
Svelte
<script>
|
|
// The execution layer. Everything else in this app exists to get assets OUT of
|
|
// the probate-bound estate before death. This module is what fires WHEN death
|
|
// happens: verify it via multiple attestors (not a single point of failure or
|
|
// fraud), then generate ready-to-file execution packets for every fast-path
|
|
// instrument already on record — so a family is filing paperwork in days, not
|
|
// waiting on a Shariah Court docket for years.
|
|
//
|
|
// What this can and cannot do, stated plainly:
|
|
// - CAN: generate the correct claim/transfer paperwork for each covered asset,
|
|
// pre-filled from the Asset Registry, Hibah, Waqf, and Nomination records.
|
|
// - CANNOT: itself move money, transfer land title, or force an institution to
|
|
// act — EPF, the bank, Takaful, and the Land Office are the ones who actually
|
|
// execute, on their own timelines. This app's job is to remove every reason
|
|
// for THEM to be slow (no missing paperwork, no ambiguity about who gets what,
|
|
// no court step required for covered assets) so their part can be done in the
|
|
// 1-2 week window instead of stacking behind a 2-year probate queue.
|
|
import { onMount } from 'svelte';
|
|
import { computeCoverage } from './nonprobate.js';
|
|
import { activeFamilyId, listMyFamilies } from './family.js';
|
|
import { listAssets, listHibahGifts, listNominations, listAllWaqfForFamily, listAttestors, addAttestor as addAttestorDb, updateAttestorName, setAttestorConfirmed, getDeathTrigger, upsertDeathTriggerSetup, updateDeathTriggerField, fireDeathTrigger } from './db.js';
|
|
import { supabase } from './supabaseClient.js';
|
|
import InfoPanel from './InfoPanel.svelte';
|
|
|
|
let familyId = $state(null);
|
|
activeFamilyId.subscribe(v => familyId = v);
|
|
|
|
let coverage = $state({ rows: [], total: 0, fastTotal: 0, exposedTotal: 0, fastPercent: 0 });
|
|
let myRole = $state(null);
|
|
let executorName = $state('');
|
|
let attestors = $state([]);
|
|
let deathCertRef = $state('');
|
|
let dateOfDeath = $state('');
|
|
let triggered = $state(false);
|
|
let error = $state('');
|
|
|
|
async function refresh() {
|
|
if (!familyId) return;
|
|
const [assets, gifts, nominations, waqfDesignations, families] = await Promise.all([
|
|
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId), listMyFamilies()
|
|
]);
|
|
const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean);
|
|
coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds });
|
|
myRole = families.find(f => f.id === familyId)?.role;
|
|
|
|
attestors = await listAttestors(familyId);
|
|
// Seed empty attestor rows with real DB rows immediately (not lazily on
|
|
// first blur) — a lazy id meant two different code paths could race to
|
|
// create the same row, and setAttestorConfirmed could end up updating a
|
|
// stale/duplicate id while the visible row kept whichever id resolved last.
|
|
while (attestors.length < 2) {
|
|
const row = await addAttestorDb(familyId, '');
|
|
attestors = [...attestors, { id: row.id, name: '', confirmed: false }];
|
|
}
|
|
const trig = await getDeathTrigger(familyId);
|
|
if (trig) {
|
|
executorName = trig.executor_name || '';
|
|
dateOfDeath = trig.date_of_death || '';
|
|
deathCertRef = trig.death_cert_ref || '';
|
|
triggered = !!trig.triggered;
|
|
}
|
|
}
|
|
|
|
onMount(refresh);
|
|
$effect(() => { familyId; refresh(); });
|
|
|
|
async function saveExecutorField(column, value) {
|
|
await updateDeathTriggerField(familyId, column, value || null);
|
|
}
|
|
|
|
async function addAttestorRow() {
|
|
const row = await addAttestorDb(familyId, '');
|
|
attestors = [...attestors, { id: row.id, name: '', confirmed: false }];
|
|
}
|
|
|
|
async function saveAttestorName(a) {
|
|
await updateAttestorName(a.id, a.name);
|
|
}
|
|
|
|
async function toggleConfirm(a) {
|
|
a.confirmed = !a.confirmed;
|
|
attestors = [...attestors];
|
|
await setAttestorConfirmed(a.id, a.confirmed);
|
|
}
|
|
|
|
const confirmedCount = $derived(attestors.filter(a => a.confirmed && a.name).length);
|
|
const threshold = 2;
|
|
const canTrigger = $derived(confirmedCount >= threshold && deathCertRef && dateOfDeath && myRole === 'owner');
|
|
|
|
async function fireTrigger() {
|
|
error = '';
|
|
if (!canTrigger) return;
|
|
try {
|
|
await upsertDeathTriggerSetup(familyId, { executorName, dateOfDeath, deathCertRef });
|
|
await fireDeathTrigger(familyId);
|
|
triggered = true;
|
|
} catch (e) {
|
|
error = 'Only the head of family (owner) can fire the death trigger. ' + (e.message || '');
|
|
}
|
|
}
|
|
|
|
async function resetDemo() {
|
|
await supabase.from('nf_death_triggers').update({ triggered: false }).eq('family_id', familyId);
|
|
triggered = false;
|
|
}
|
|
|
|
function downloadPacket(row) {
|
|
const lines = [
|
|
'EXECUTION PACKET — NON-PROBATE ASSET TRANSFER',
|
|
`Asset: ${row.asset.description} (${row.asset.type})`,
|
|
`Channel: ${row.channel}`,
|
|
`Value: ${row.ownedValue.toLocaleString()}`,
|
|
`Date of death: ${dateOfDeath}`,
|
|
`Death certificate reference: ${deathCertRef}`,
|
|
`Attestors confirming: ${attestors.filter(a => a.confirmed && a.name).map(a => a.name).join(', ')}`,
|
|
'',
|
|
row.channel === 'hibah'
|
|
? 'ACTION: This asset was already gifted (Hibah) with completed offer, acceptance,\nand possession prior to death. It is not part of the estate. No probate filing is\nrequired for this asset. Attach the Hibah record and recipient acknowledgment as\nproof of prior transfer if the institution requests confirmation.'
|
|
: row.channel === 'waqf'
|
|
? 'ACTION: This asset was already dedicated as Waqf prior to death. It is not part\nof the estate. File the waqfiyya deed with the relevant state waqf authority for\nadministrative continuity — this is a registration step, not a probate step.'
|
|
: row.channel === 'epf'
|
|
? 'ACTION: File an EPF death claim (Borang KWSP 9K or current equivalent) with the\ndeath certificate. EPF pays the nominee directly under EPF Act 1991 s.51 —\nno Letters of Administration required.'
|
|
: row.channel === 'takaful'
|
|
? 'ACTION: File a death claim with the Takaful/insurance provider. As a nominated\ntrust under Insurance Act 1996 s.166 (or Takaful equivalent), payout goes directly\nto the named beneficiary — no probate required.'
|
|
: row.channel === 'bank-mandate'
|
|
? 'ACTION: Notify the bank with the death certificate and the death-mandate /\njoint-account documentation on file. Funds release per the mandate terms — this\nis a bank process, not a court process.'
|
|
: row.channel === 'business-continuity'
|
|
? 'ACTION: Execute the pre-agreed buy-sell provision or partnership continuation\nclause on file. If Takaful-funded, file the Takaful death claim to release the\nbuyout funds. This is a private agreement between co-owners/shareholders, not a\nprobate filing.'
|
|
: row.channel === 'digital-custody'
|
|
? 'ACTION: File the death claim with the custodial platform\'s own beneficiary\nfeature, or have the named multi-sig co-signer/key-escrow holder execute access\nper the plan on file. This is a platform or private-key process, not a probate\nfiling — and never requires disclosing a seed phrase to this app.'
|
|
: 'ACTION: Contact the trustee/nominee holder to execute the pre-arranged transfer\nunder the trust deed. This is an internal trustee record update, not a probate\nfiling.',
|
|
'',
|
|
'This packet was generated by Nur Falah on a local device. It is a drafting aid;',
|
|
'the receiving institution\'s own claim process still applies. Its purpose is to',
|
|
'remove ambiguity and missing paperwork as a source of delay.'
|
|
];
|
|
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 = `execution-packet-${row.asset.description.replace(/\s+/g, '-').toLowerCase()}.txt`; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
</script>
|
|
|
|
<div class="module">
|
|
<div class="module-header">
|
|
<h2>Death Trigger & Execution</h2>
|
|
<InfoPanel
|
|
title="Death Trigger & Execution"
|
|
what="This is what actually fires when someone passes away. It can't be triggered by one person alone (to prevent fraud or a mistake) — you need at least two trusted people to confirm the death, plus a death certificate reference. Once confirmed, the app generates ready-to-file paperwork for every asset you've already covered, so your family can start claiming things within days instead of waiting on a court."
|
|
how="Set this up now, while everyone is healthy — name your executor and at least two attestors (people who will confirm the death when it happens). When the time comes, those attestors log in and confirm, someone enters the death certificate number, and the trigger fires — generating a downloadable packet for each covered asset."
|
|
fields={[
|
|
{ label: 'Executor', hint: 'The person responsible for carrying out your wishes.' },
|
|
{ label: 'Attestors', hint: 'At least two people who will confirm the death actually happened — this is a safety check, not a formality.' },
|
|
{ label: 'Date of death & death certificate reference', hint: 'Required before the trigger can fire — this ties everything to an official record.' }
|
|
]}
|
|
/>
|
|
</div>
|
|
<p class="sub">Set up now, fires at death. This is the mechanism that turns "arranged" into "distributed" — target: days, not the ~2-year Faraid/probate route.</p>
|
|
|
|
{#if !triggered}
|
|
<div class="setup-card">
|
|
<h3>1. Executor</h3>
|
|
<label class="field"><span>Executor name</span><input type="text" bind:value={executorName} oninput={e => saveExecutorField('executor_name', e.target.value)} /></label>
|
|
|
|
<h3>2. Attestors</h3>
|
|
<p class="note">At least {threshold} confirmations plus a death certificate reference are required to fire the trigger — no single person can trigger this alone, and it cannot fire silently.</p>
|
|
{#each attestors as a}
|
|
<div class="attestor-row">
|
|
<input type="text" placeholder="Attestor name" bind:value={a.name} onblur={() => saveAttestorName(a)} />
|
|
<button class="confirm-btn" class:on={a.confirmed} onclick={() => toggleConfirm(a)}>{a.confirmed ? 'Confirmed' : 'Confirm death'}</button>
|
|
</div>
|
|
{/each}
|
|
<button class="btn-secondary" onclick={addAttestorRow}>Add another attestor</button>
|
|
|
|
<h3>3. Death record</h3>
|
|
<label class="field"><span>Date of death</span><input type="date" bind:value={dateOfDeath} oninput={e => saveExecutorField('date_of_death', e.target.value)} /></label>
|
|
<label class="field"><span>Death certificate reference number</span><input type="text" bind:value={deathCertRef} oninput={e => saveExecutorField('death_cert_ref', e.target.value)} /></label>
|
|
|
|
{#if myRole === 'agent'}
|
|
<div class="agent-restriction">As an estate agent, you can set up everything above — but only the head of family (owner) can fire the trigger. Ask them to review and confirm when the time comes.</div>
|
|
{/if}
|
|
{#if error}<p class="error-text">{error}</p>{/if}
|
|
|
|
<div class="trigger-status">
|
|
<span>{confirmedCount} / {threshold} attestor confirmations</span>
|
|
{#if canTrigger}<span class="ready">Ready to trigger</span>{/if}
|
|
</div>
|
|
|
|
<button class="btn-danger-solid" disabled={!canTrigger} onclick={fireTrigger}>Fire death trigger — begin execution</button>
|
|
</div>
|
|
{:else}
|
|
<div class="triggered-banner">
|
|
<strong>TRIGGERED.</strong> Death confirmed {dateOfDeath} by {confirmedCount} attestors. Execution packets below are ready for each covered asset.
|
|
</div>
|
|
|
|
<div class="coverage-summary">
|
|
<span>{coverage.fastPercent}% of the estate has an execution packet below.</span>
|
|
{#if coverage.exposedTotal > 0}
|
|
<span class="exposed-note">{coverage.exposedTotal.toLocaleString()} has no fast-path coverage and must go through the conventional Faraid/probate process — this was the gap the Coverage Dashboard flagged before death.</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#each coverage.rows.filter(r => r.fast) as r (r.asset.id)}
|
|
<div class="packet-row">
|
|
<div>
|
|
<strong>{r.asset.description}</strong>
|
|
<span class="muted">{r.channel} · {r.ownedValue.toLocaleString()}</span>
|
|
</div>
|
|
<button class="btn-small" onclick={() => downloadPacket(r)}>Download packet</button>
|
|
</div>
|
|
{/each}
|
|
|
|
<button class="btn-secondary" onclick={resetDemo}>Reset (testing only)</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.module { padding: 4px 0 40px; }
|
|
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
|
|
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 0; }
|
|
.sub { font-size: 13px; color: #8A8478; margin-bottom: 18px; line-height: 1.5; }
|
|
.setup-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 16px; }
|
|
.setup-card h3 { font-size: 14px; color: #C9A84C; margin: 16px 0 8px; }
|
|
.setup-card h3:first-child { margin-top: 0; }
|
|
.note { font-size: 11.5px; color: #8A8478; margin-bottom: 10px; line-height: 1.5; }
|
|
.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%; }
|
|
.attestor-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
|
.attestor-row input { flex: 1; 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; }
|
|
.confirm-btn { padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.1); color: #EF4444; font-size: 11px; cursor: pointer; white-space: nowrap; }
|
|
.confirm-btn.on { border-color: rgba(46,204,113,0.4); background: rgba(46,204,113,0.15); color: #2ECC71; }
|
|
.btn-secondary { width: 100%; padding: 10px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; font-weight: 600; cursor: pointer; margin: 8px 0 4px; font-size: 13px; }
|
|
.trigger-status { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #B8B2A6; margin: 16px 0 10px; }
|
|
.ready { color: #2ECC71; font-weight: 600; }
|
|
.agent-restriction { font-size: 12px; color: #C9A84C; background: rgba(201,168,76,0.08); border-radius: 8px; padding: 10px 12px; margin: 10px 0; line-height: 1.5; }
|
|
.error-text { font-size: 12px; color: #EF4444; margin: 8px 0; }
|
|
.btn-danger-solid { width: 100%; padding: 14px; border-radius: 8px; border: none; background: #EF4444; color: white; font-weight: 700; cursor: pointer; }
|
|
.btn-danger-solid:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
|
|
.triggered-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 10px; padding: 14px; font-size: 13px; color: #E8E4DC; margin-bottom: 14px; line-height: 1.5; }
|
|
.coverage-summary { background: rgba(255,255,255,0.03); border-radius: 10px; padding: 12px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #B8B2A6; }
|
|
.exposed-note { color: #EF4444; }
|
|
.packet-row { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: rgba(46,204,113,0.05); border-radius: 10px; margin-bottom: 8px; font-size: 13px; color: #E8E4DC; }
|
|
.muted { display: block; color: #8A8478; font-size: 11px; margin-top: 2px; }
|
|
.btn-small { background: rgba(46,204,113,0.15); color: #2ECC71; border: none; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; font-weight: 600; }
|
|
</style>
|