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:
wmj
2026-08-14 12:37:25 +08:00
parent 71aa4ae47c
commit 97a1d2ebe2
11 changed files with 327 additions and 31 deletions
+20 -17
View File
@@ -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 &amp; 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 &amp; 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}>