feat: close final competitive gap — professional review workflow + multi-country (MY/SG/UK)
Closes the 4th and final gap from the competitive benchmark: the human-in-the-loop professional tier every commercial competitor pairs with their software. Deliberately NOT a marketplace or payment integration — a structured review-request status on each member's Wassiyah (not_requested -> requested -> reviewed, with reviewer name recorded), surfaced on the Mutawalli dashboard so nothing quietly ships as final without the legally-required review being flagged. Paired with a new whole-app jurisdiction setting since 'necessary in certain countries' only makes sense per-jurisdiction: - nf_families.jurisdiction (MY/SG/UK), owner-only to change — first UPDATE policy ever added to nf_families, which previously had none. - New Settings-tab jurisdiction selector (JurisdictionSetting.svelte). - Wassiyah tab and the draft will document now carry jurisdiction- specific legal notes: Wills Act 1959 + state Syariah (Malaysia), Wills Act 1838 + AMLA (Singapore), Wills Act 1837 (UK). Singapore added as a full third jurisdiction option, not just a stub. - Zakat calculator's currency label and Nisab default now follow the family's jurisdiction (RM/SGD/GBP) instead of a hardcoded RM guess. Covered by e2e-jurisdiction-review.cjs (13/13). Full regression: 253/253 across all suites (2 reruns confirmed as pre-existing parallel-load flakes, not regressions).
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
import FamilyTree from './lib/FamilyTree.svelte';
|
||||
import InsurancePolicies from './lib/InsurancePolicies.svelte';
|
||||
import ZakatCalculator from './lib/ZakatCalculator.svelte';
|
||||
import JurisdictionSetting from './lib/JurisdictionSetting.svelte';
|
||||
|
||||
let currentLang = $state('en');
|
||||
lang.subscribe(v => currentLang = v);
|
||||
@@ -125,6 +126,8 @@
|
||||
</div>
|
||||
<p class="sub">Signed in as {currentSession.user.email}.</p>
|
||||
|
||||
<JurisdictionSetting />
|
||||
|
||||
<div class="lang-switch">
|
||||
<span class="lang-label">Language / Bahasa</span>
|
||||
<div class="lang-buttons">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
// Whole-app jurisdiction setting, owner-only to change (enforced by RLS on
|
||||
// nf_families, not just this UI). Drives Wassiyah legal notes/will document
|
||||
// format and Zakat currency defaults elsewhere in the app.
|
||||
import { onMount } from 'svelte';
|
||||
import { activeFamilyId, listMyFamilies, getFamilyJurisdiction, setFamilyJurisdiction } from './family.js';
|
||||
|
||||
const JURISDICTIONS = [
|
||||
{ value: 'MY', label: 'Malaysia' },
|
||||
{ value: 'SG', label: 'Singapore' },
|
||||
{ value: 'UK', label: 'United Kingdom' }
|
||||
];
|
||||
|
||||
let familyId = $state(null);
|
||||
activeFamilyId.subscribe(v => familyId = v);
|
||||
|
||||
let jurisdiction = $state('MY');
|
||||
let isOwner = $state(false);
|
||||
let saved = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
if (!familyId) return;
|
||||
const [j, families] = await Promise.all([getFamilyJurisdiction(familyId), listMyFamilies()]);
|
||||
jurisdiction = j;
|
||||
isOwner = families.find(f => f.id === familyId)?.role === 'owner';
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
$effect(() => { familyId; refresh(); });
|
||||
|
||||
async function save() {
|
||||
await setFamilyJurisdiction(familyId, jurisdiction);
|
||||
saved = true;
|
||||
setTimeout(() => saved = false, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="jurisdiction-setting">
|
||||
<span class="label">Jurisdiction</span>
|
||||
{#if isOwner}
|
||||
<select bind:value={jurisdiction} onchange={save}>
|
||||
{#each JURISDICTIONS as j}<option value={j.value}>{j.label}</option>{/each}
|
||||
</select>
|
||||
{#if saved}<span class="saved">Saved</span>{/if}
|
||||
{:else}
|
||||
<span class="readonly">{JURISDICTIONS.find(j => j.value === jurisdiction)?.label || jurisdiction} (only the head of family can change this)</span>
|
||||
{/if}
|
||||
<p class="note">Drives the legal notes shown in Wassiyah, the draft will document format, and Zakat currency defaults.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.jurisdiction-setting { margin-bottom: 16px; }
|
||||
.label { display: block; font-size: 12px; color: #B8B2A6; margin-bottom: 6px; }
|
||||
select { 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%; }
|
||||
.readonly { font-size: 13.5px; color: #E8E4DC; }
|
||||
.saved { font-size: 11px; color: #2ECC71; margin-left: 8px; }
|
||||
.note { font-size: 11px; color: #8A8478; margin-top: 6px; line-height: 1.4; }
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
import { activeFamilyId, listFamilyMembers, listMyFamilies } from './family.js';
|
||||
import { currentUser } from './auth.js';
|
||||
import {
|
||||
listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily,
|
||||
listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily, listAllWassiyahSettingsForFamily,
|
||||
getMemberTrigger, upsertMemberTriggerSetup, fireMemberTrigger,
|
||||
listMemberAttestors, addMemberAttestor, updateMemberAttestorName, setMemberAttestorConfirmed,
|
||||
notifyHeirs
|
||||
@@ -24,6 +24,7 @@
|
||||
let wassiyahByAuthor = $state({});
|
||||
let waqfByAuthor = $state({});
|
||||
let insuranceByMember = $state({});
|
||||
let wassiyahSettingsByAuthor = $state({});
|
||||
|
||||
let trigger = $state(null);
|
||||
let attestors = $state([]);
|
||||
@@ -35,8 +36,8 @@
|
||||
|
||||
async function refresh() {
|
||||
if (!familyId) return;
|
||||
const [allMembers, myFamilies, wassiyah, waqf, insurance] = await Promise.all([
|
||||
listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId), listAllInsuranceForFamily(familyId)
|
||||
const [allMembers, myFamilies, wassiyah, waqf, insurance, wassiyahSettings] = await Promise.all([
|
||||
listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId), listAllInsuranceForFamily(familyId), listAllWassiyahSettingsForFamily(familyId)
|
||||
]);
|
||||
myRole = myFamilies.find(f => f.id === familyId)?.role;
|
||||
members = allMembers.filter(m => m.status === 'active');
|
||||
@@ -54,6 +55,9 @@
|
||||
const insuranceGrouped = {};
|
||||
for (const p of insurance) (insuranceGrouped[p.member_id] ??= []).push(p);
|
||||
insuranceByMember = insuranceGrouped;
|
||||
const settingsGrouped = {};
|
||||
for (const s of wassiyahSettings) settingsGrouped[s.author_id] = s;
|
||||
wassiyahSettingsByAuthor = settingsGrouped;
|
||||
|
||||
if (!selectedMemberId && members.length) selectedMemberId = members[0].user_id;
|
||||
if (selectedMemberId) await loadMemberTrigger(selectedMemberId);
|
||||
@@ -150,6 +154,12 @@
|
||||
<h3>{selectedEmail()}'s documents</h3>
|
||||
<div class="doc-block">
|
||||
<span class="doc-label">Wassiyah bequests</span>
|
||||
{#if wassiyahSettingsByAuthor[selectedMemberId]}
|
||||
{@const rs = wassiyahSettingsByAuthor[selectedMemberId].review_status}
|
||||
<div class="review-badge review-{rs}">
|
||||
{rs === 'reviewed' ? `Reviewed by ${wassiyahSettingsByAuthor[selectedMemberId].reviewer_name}` : rs === 'requested' ? 'Professional review requested' : 'Not yet reviewed'}
|
||||
</div>
|
||||
{/if}
|
||||
{#each wassiyahByAuthor[selectedMemberId] || [] as b}
|
||||
<div class="doc-row">{b.recipient} ({b.relation || '—'}) — {Number(b.value).toLocaleString()}{b.recipient_email ? ` · ${b.recipient_email}` : ''}</div>
|
||||
{:else}<div class="doc-empty">None recorded</div>{/each}
|
||||
@@ -214,6 +224,9 @@
|
||||
.doc-label { display: block; font-size: 10.5px; text-transform: uppercase; color: #8A8478; margin-bottom: 4px; }
|
||||
.doc-row { font-size: 12.5px; color: #E8E4DC; padding: 3px 0; }
|
||||
.doc-empty { font-size: 12px; color: #8A8478; font-style: italic; }
|
||||
.review-badge { display: inline-block; font-size: 10px; padding: 3px 8px; border-radius: 999px; margin-bottom: 6px; background: rgba(255,255,255,0.06); color: #8A8478; }
|
||||
.review-badge.review-requested { background: rgba(201,168,76,0.15); color: #C9A84C; }
|
||||
.review-badge.review-reviewed { background: rgba(46,204,113,0.15); color: #2ECC71; }
|
||||
.trigger-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; }
|
||||
.trigger-card.triggered { background: rgba(239,68,68,0.06); }
|
||||
.trigger-card h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
|
||||
import { activeFamilyId } from './family.js';
|
||||
import { activeFamilyId, getFamilyJurisdiction } from './family.js';
|
||||
import { session } from './auth.js';
|
||||
import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings, getMemberTrigger } from './db.js';
|
||||
import { buildWillDocumentHtml } from './willDocument.js';
|
||||
import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings, getMemberTrigger, requestWassiyahReview, markWassiyahReviewed } from './db.js';
|
||||
import { buildWillDocumentHtml, JURISDICTION_LABELS, JURISDICTION_NOTES } from './willDocument.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
import InfoPanel from './InfoPanel.svelte';
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
let witness2 = $state('');
|
||||
let testatorName = $state('');
|
||||
let overrideAcknowledged = $state(false);
|
||||
let reviewStatus = $state('not_requested');
|
||||
let reviewerName = $state('');
|
||||
let reviewerNameInput = $state('');
|
||||
|
||||
async function refresh() {
|
||||
if (!familyId || !authorId) return;
|
||||
@@ -35,10 +38,16 @@
|
||||
bequests = await listWassiyahBequests(familyId, authorId);
|
||||
const settings = await getWassiyahSettings(familyId, authorId);
|
||||
if (settings) {
|
||||
jurisdiction = settings.jurisdiction || 'UK';
|
||||
jurisdiction = settings.jurisdiction || jurisdiction;
|
||||
witness1 = settings.witness1 || '';
|
||||
witness2 = settings.witness2 || '';
|
||||
testatorName = settings.testator_name || '';
|
||||
reviewStatus = settings.review_status || 'not_requested';
|
||||
reviewerName = settings.reviewer_name || '';
|
||||
} else {
|
||||
// No settings row yet — default the jurisdiction to the family's
|
||||
// whole-app setting rather than a hardcoded guess.
|
||||
jurisdiction = await getFamilyJurisdiction(familyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +58,18 @@
|
||||
await upsertWassiyahSettings(familyId, authorId, { jurisdiction, witness1, witness2, testatorName });
|
||||
}
|
||||
|
||||
async function requestReview() {
|
||||
await saveSettings();
|
||||
await requestWassiyahReview(familyId, authorId);
|
||||
await refresh();
|
||||
}
|
||||
async function markReviewed() {
|
||||
if (!reviewerNameInput.trim()) return;
|
||||
await markWassiyahReviewed(familyId, authorId, reviewerNameInput.trim());
|
||||
reviewerNameInput = '';
|
||||
await refresh();
|
||||
}
|
||||
|
||||
let currentEmail = $state(null);
|
||||
session.subscribe(v => currentEmail = v?.user?.email ?? null);
|
||||
|
||||
@@ -98,9 +119,7 @@
|
||||
'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.',
|
||||
`${JURISDICTION_LABELS[jurisdiction] || jurisdiction} NOTE: ${JURISDICTION_NOTES[jurisdiction] || 'Confirm execution requirements with a locally qualified lawyer before relying on this draft.'}`,
|
||||
'',
|
||||
`Witness 1: ${witness1 || '________________________'}`,
|
||||
`Witness 2: ${witness2 || '________________________'}`,
|
||||
@@ -146,7 +165,9 @@
|
||||
|
||||
<label class="field"><span>Full legal name</span><input type="text" bind:value={testatorName} onblur={saveSettings} placeholder="as it should appear on the will document" /></label>
|
||||
<label class="field"><span>Jurisdiction</span>
|
||||
<select bind:value={jurisdiction} onchange={saveSettings}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select>
|
||||
<select bind:value={jurisdiction} onchange={saveSettings}>
|
||||
{#each Object.entries(JURISDICTION_LABELS) as [code, label]}<option value={code}>{label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-card">
|
||||
@@ -183,6 +204,22 @@
|
||||
<button class="btn-secondary" disabled={exceedsCap && !overrideAcknowledged} onclick={openDraftWillDocument}>Generate draft will document</button>
|
||||
<p class="will-doc-note">Opens a formatted, statutory-style DRAFT will in a new tab — use your browser's print dialog to save it as a PDF. Clearly watermarked "not executed": a real will still requires physical signing and witnessing to take legal effect.</p>
|
||||
|
||||
<div class="review-card review-{reviewStatus}">
|
||||
<h3>Professional review</h3>
|
||||
{#if reviewStatus === 'reviewed'}
|
||||
<p>Reviewed by <strong>{reviewerName}</strong>.</p>
|
||||
{:else if reviewStatus === 'requested'}
|
||||
<p>Review requested — visible to your mutawalli/agent. Mark it reviewed once a professional has actually looked at it.</p>
|
||||
<div class="reviewer-row">
|
||||
<input type="text" bind:value={reviewerNameInput} placeholder="Reviewer's name" />
|
||||
<button class="btn-secondary" onclick={markReviewed} disabled={!reviewerNameInput.trim()}>Mark reviewed</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p>This draft hasn't been reviewed by a legal professional yet. This app cannot produce a legally executed will on its own — a professional review is strongly recommended, and required in some jurisdictions before relying on this document.</p>
|
||||
<button class="btn-secondary" onclick={requestReview}>Request professional review</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Disclaimer />
|
||||
</div>
|
||||
|
||||
@@ -209,6 +246,14 @@
|
||||
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 10px; }
|
||||
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.will-doc-note { font-size: 11px; color: #8A8478; line-height: 1.5; margin-top: 8px; }
|
||||
.review-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-top: 20px; border: 1px solid rgba(255,255,255,0.08); }
|
||||
.review-card.review-reviewed { background: rgba(46,204,113,0.06); border-color: rgba(46,204,113,0.25); }
|
||||
.review-card.review-requested { background: rgba(201,168,76,0.06); border-color: rgba(201,168,76,0.25); }
|
||||
.review-card h3 { font-size: 13.5px; color: #C9A84C; margin-bottom: 8px; }
|
||||
.review-card p { font-size: 12px; color: #E8E4DC; line-height: 1.5; margin-bottom: 10px; }
|
||||
.reviewer-row { display: flex; gap: 8px; }
|
||||
.reviewer-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; }
|
||||
.reviewer-row button { width: auto; margin-top: 0; }
|
||||
.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; }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { activeFamilyId } from './family.js';
|
||||
import { activeFamilyId, getFamilyJurisdiction } from './family.js';
|
||||
import { session } from './auth.js';
|
||||
import { getZakatRecord, upsertZakatRecord } from './db.js';
|
||||
import { zakatableWealth, zakatDue, meetsNisab } from './calc/zakat.js';
|
||||
import { CURRENCY_BY_JURISDICTION, DEFAULT_NISAB_BY_JURISDICTION } from './calc/zakat.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
import InfoPanel from './InfoPanel.svelte';
|
||||
|
||||
@@ -16,21 +17,23 @@
|
||||
let memberId = $state(null);
|
||||
session.subscribe(v => memberId = v?.user?.id ?? null);
|
||||
|
||||
const DEFAULT_NISAB = 24000; // rough 85g-gold-equivalent placeholder in RM; user should override with today's value
|
||||
let currency = $state('RM');
|
||||
|
||||
let fields = $state(emptyFields());
|
||||
let loaded = $state(false);
|
||||
|
||||
function emptyFields() {
|
||||
return { cash: '', gold: '', silver: '', businessAssets: '', investments: '', otherZakatable: '', deductibleLiabilities: '', nisabThreshold: String(DEFAULT_NISAB) };
|
||||
function emptyFields(jurisdiction) {
|
||||
return { cash: '', gold: '', silver: '', businessAssets: '', investments: '', otherZakatable: '', deductibleLiabilities: '', nisabThreshold: String(DEFAULT_NISAB_BY_JURISDICTION[jurisdiction] || 24000) };
|
||||
}
|
||||
|
||||
let fields = $state(emptyFields('MY'));
|
||||
let loaded = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
if (!familyId || !memberId) return;
|
||||
const jurisdiction = await getFamilyJurisdiction(familyId);
|
||||
currency = CURRENCY_BY_JURISDICTION[jurisdiction] || 'RM';
|
||||
const record = await getZakatRecord(familyId, memberId);
|
||||
fields = record
|
||||
? { cash: String(record.cash), gold: String(record.gold), silver: String(record.silver), businessAssets: String(record.businessAssets), investments: String(record.investments), otherZakatable: String(record.otherZakatable), deductibleLiabilities: String(record.deductibleLiabilities), nisabThreshold: String(record.nisabThreshold) }
|
||||
: emptyFields();
|
||||
: emptyFields(jurisdiction);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
@@ -62,18 +65,18 @@
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p class="sub">Your own Zakat calculation — a personal, ongoing obligation, not tied to death or inheritance.</p>
|
||||
<p class="sub">Your own Zakat calculation — a personal, ongoing obligation, not tied to death or inheritance. Figures in {currency}.</p>
|
||||
|
||||
{#if loaded}
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Cash & bank balances</span><input type="number" min="0" bind:value={fields.cash} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Gold (current market value)</span><input type="number" min="0" bind:value={fields.gold} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Silver (current market value)</span><input type="number" min="0" bind:value={fields.silver} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Business assets / inventory</span><input type="number" min="0" bind:value={fields.businessAssets} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Investments / shares / digital assets</span><input type="number" min="0" bind:value={fields.investments} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Other zakatable wealth</span><input type="number" min="0" bind:value={fields.otherZakatable} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Deductible liabilities (due within the year)</span><input type="number" min="0" bind:value={fields.deductibleLiabilities} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Nisab threshold (today's value)</span><input type="number" min="0" bind:value={fields.nisabThreshold} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Cash & bank balances ({currency})</span><input type="number" min="0" bind:value={fields.cash} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Gold — current market value ({currency})</span><input type="number" min="0" bind:value={fields.gold} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Silver — current market value ({currency})</span><input type="number" min="0" bind:value={fields.silver} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Business assets / inventory ({currency})</span><input type="number" min="0" bind:value={fields.businessAssets} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Investments / shares / digital assets ({currency})</span><input type="number" min="0" bind:value={fields.investments} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Other zakatable wealth ({currency})</span><input type="number" min="0" bind:value={fields.otherZakatable} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Deductible liabilities — due within the year ({currency})</span><input type="number" min="0" bind:value={fields.deductibleLiabilities} oninput={scheduleSave} /></label>
|
||||
<label class="field"><span>Nisab threshold — today's value ({currency})</span><input type="number" min="0" bind:value={fields.nisabThreshold} oninput={scheduleSave} /></label>
|
||||
</div>
|
||||
|
||||
<div class="result-card" class:due={meets}>
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
// established for Asset Registry / Faraid.
|
||||
const ZAKAT_RATE = 0.025;
|
||||
|
||||
// Rough starting points, NOT live prices — the app has no gold/silver price
|
||||
// feed (manual entry only, matching the rest of the app). These exist so a
|
||||
// new user isn't staring at a blank/zero Nisab field; they must still verify
|
||||
// today's actual 85g-gold-equivalent value before relying on the result.
|
||||
export const CURRENCY_BY_JURISDICTION = { MY: 'RM', SG: 'SGD', UK: 'GBP' };
|
||||
export const DEFAULT_NISAB_BY_JURISDICTION = { MY: 24000, SG: 8000, UK: 5000 };
|
||||
|
||||
export function zakatableWealth(fields) {
|
||||
const gross = (Number(fields.cash) || 0) + (Number(fields.gold) || 0) + (Number(fields.silver) || 0)
|
||||
+ (Number(fields.businessAssets) || 0) + (Number(fields.investments) || 0) + (Number(fields.otherZakatable) || 0);
|
||||
|
||||
@@ -234,6 +234,29 @@ export async function listAllWassiyahForFamily(familyId) {
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Professional review workflow — a structured status, not a marketplace.
|
||||
// Any family member can request review of their own Wassiyah; anyone (in
|
||||
// practice the mutawalli/agent, coordinating with an actual lawyer outside
|
||||
// the app) can mark it reviewed once done.
|
||||
export async function requestWassiyahReview(familyId, authorId) {
|
||||
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
|
||||
family_id: familyId, author_id: authorId, review_status: 'requested', requested_at: new Date().toISOString(), updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'family_id,author_id' });
|
||||
if (error) throw error;
|
||||
}
|
||||
export async function markWassiyahReviewed(familyId, authorId, reviewerName) {
|
||||
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
|
||||
family_id: familyId, author_id: authorId, review_status: 'reviewed', reviewer_name: reviewerName, reviewed_at: new Date().toISOString(), updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'family_id,author_id' });
|
||||
if (error) throw error;
|
||||
}
|
||||
/** For the mutawalli dashboard: every family member's Wassiyah settings (jurisdiction, review status). */
|
||||
export async function listAllWassiyahSettingsForFamily(familyId) {
|
||||
const { data, error } = await supabase.from('nf_wassiyah_settings').select('*').eq('family_id', familyId);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Per-member death trigger — the mutawalli/owner fires it for a specific
|
||||
// member, never the member themselves (enforced by RLS, not just the UI). ──
|
||||
export async function getMemberTrigger(memberId, familyId) {
|
||||
|
||||
@@ -104,6 +104,18 @@ export async function removeMember(membershipId) {
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/** Whole-app jurisdiction (MY/SG/UK) — drives Wassiyah legal notes, will
|
||||
* document format, and Zakat currency defaults. Owner-only to change. */
|
||||
export async function getFamilyJurisdiction(familyId) {
|
||||
const { data, error } = await supabase.from('nf_families').select('jurisdiction').eq('id', familyId).maybeSingle();
|
||||
if (error) throw error;
|
||||
return data?.jurisdiction || 'MY';
|
||||
}
|
||||
export async function setFamilyJurisdiction(familyId, jurisdiction) {
|
||||
const { error } = await supabase.from('nf_families').update({ jurisdiction }).eq('id', familyId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export function getActiveFamilyId() {
|
||||
let id;
|
||||
activeFamilyId.subscribe(v => id = v)();
|
||||
|
||||
+13
-1
@@ -13,6 +13,17 @@ function esc(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
export const JURISDICTION_LABELS = { MY: 'Malaysia', SG: 'Singapore', UK: 'United Kingdom' };
|
||||
|
||||
// Execution requirements genuinely differ by jurisdiction — kept short and
|
||||
// factual (statute names, witness rules), not legal advice on how they
|
||||
// apply to any specific estate.
|
||||
export const JURISDICTION_NOTES = {
|
||||
MY: "Execution under the Wills Act 1959 applies to non-Muslims and to the discretionary Wassiyah portion for Muslims; the signature must be made or acknowledged in the presence of two witnesses present at the same time, who then also sign. The remainder of the estate is subject to Faraid under the relevant State Islamic law — requirements and the recognized process vary across Malaysia's states, so confirm the current position with a Wasiyyah provider or Syariah-qualified lawyer before relying on this draft.",
|
||||
SG: "Execution under the Wills Act 1838 (Cap. 352) requires the testator's signature made or acknowledged in the presence of two witnesses present at the same time, who then also sign in the testator's presence. For Muslims, the Administration of Muslim Law Act (AMLA) governs Faraid distribution of the remainder through the Syariah Court, while the Wassiyah (discretionary one-third) portion is still executed as a civil will under the Wills Act — have this draft reviewed by a Singapore-qualified lawyer before relying on it.",
|
||||
UK: "Execution under the Wills Act 1837 requires the testator's signature made or acknowledged in the presence of two witnesses present at the same time, who then also sign in the testator's presence. A UK Islamic will operates as an ordinary civil will for legal purposes — English/Scottish/Northern Irish succession law does not itself apply Faraid, so the Wassiyah structure here only takes effect through this document being properly executed. Have this draft reviewed by a UK-qualified solicitor before relying on it."
|
||||
};
|
||||
|
||||
export function buildWillDocumentHtml({ testatorName, email, jurisdiction, witness1, witness2, executorName, bequests, estateTotal, cap, generatedDate }) {
|
||||
const name = testatorName?.trim() || '[FULL LEGAL NAME NOT YET ENTERED]';
|
||||
const exec = executorName?.trim() || '[EXECUTOR NOT YET NAMED]';
|
||||
@@ -58,7 +69,8 @@ export function buildWillDocumentHtml({ testatorName, email, jurisdiction, witne
|
||||
<p>I, <strong>${esc(name)}</strong>${email ? ` (${esc(email)})` : ''}, being of sound mind, declare this to be my Will, and I hereby revoke all previous wills and testamentary dispositions made by me. This document expresses my Wassiyah — the portion of my estate I direct outside the fixed Faraid distribution — and does not purport to override any Faraid share owed to my heirs.</p>
|
||||
|
||||
<h2>2. Jurisdiction</h2>
|
||||
<p>This Will is intended to take effect under the laws of <strong>${esc(jurisdiction || '[JURISDICTION NOT YET ENTERED]')}</strong>, in accordance with Shariah principles governing Wassiyah.</p>
|
||||
<p>This Will is intended to take effect under the laws of <strong>${esc(JURISDICTION_LABELS[jurisdiction] || jurisdiction || '[JURISDICTION NOT YET ENTERED]')}</strong>, in accordance with Shariah principles governing Wassiyah.</p>
|
||||
<p>${JURISDICTION_NOTES[jurisdiction] || 'Jurisdiction-specific execution requirements were not available for this selection — confirm requirements with a locally qualified lawyer before relying on this draft.'}</p>
|
||||
|
||||
<h2>3. Appointment of Executor</h2>
|
||||
<p>I appoint <strong>${esc(exec)}</strong> as Executor of this Will, to administer my estate, settle my debts, and distribute both the Wassiyah bequests below and the remaining estate according to Faraid.</p>
|
||||
|
||||
Reference in New Issue
Block a user