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).
109 lines
7.0 KiB
Svelte
109 lines
7.0 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
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';
|
|
|
|
let familyId = $state(null);
|
|
activeFamilyId.subscribe(v => familyId = v);
|
|
// Live off the session store, not a one-time snapshot — same fix as every
|
|
// other per-member module (Wassiyah, Waqf, Insurance): this component
|
|
// remounts on tab switch, and a stale null captured once at mount would
|
|
// silently break every write until the next remount.
|
|
let memberId = $state(null);
|
|
session.subscribe(v => memberId = v?.user?.id ?? null);
|
|
|
|
let currency = $state('RM');
|
|
|
|
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(jurisdiction);
|
|
loaded = true;
|
|
}
|
|
|
|
onMount(refresh);
|
|
$effect(() => { familyId; memberId; refresh(); });
|
|
|
|
let saveTimer;
|
|
function scheduleSave() {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => { upsertZakatRecord(familyId, memberId, fields); }, 500);
|
|
}
|
|
|
|
const wealth = $derived(zakatableWealth(fields));
|
|
const due = $derived(zakatDue(fields));
|
|
const meets = $derived(meetsNisab(fields));
|
|
</script>
|
|
|
|
<div class="module">
|
|
<div class="module-header">
|
|
<h2>Zakat</h2>
|
|
<InfoPanel
|
|
title="Zakat"
|
|
what="Your annual Zakat obligation — 2.5% of qualifying wealth (cash, gold, silver, business assets, investments) held for a full lunar year (haul), once it's above the Nisab threshold. This is a personal obligation, separate from Faraid and Wassiyah, which only apply after death."
|
|
how="Enter your zakatable wealth by category, subtract any short-term deductible liabilities, and set today's Nisab value (85g gold or 595g silver equivalent — check a current gold price, this app doesn't fetch it live). Figures autosave as you type."
|
|
fields={[
|
|
{ label: 'Zakatable wealth', hint: 'Cash, gold, silver, business inventory, investments — anything held for a full lunar year.' },
|
|
{ label: 'Deductible liabilities', hint: 'Short-term debts due, subtracted before checking against Nisab.' },
|
|
{ label: 'Nisab threshold', hint: 'The minimum wealth level before Zakat is due — varies with the current gold/silver price.' }
|
|
]}
|
|
/>
|
|
</div>
|
|
<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 ({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}>
|
|
<div class="result-row"><span>Zakatable wealth</span><strong>{wealth.toLocaleString()}</strong></div>
|
|
<div class="result-row"><span>Nisab threshold</span><strong>{(Number(fields.nisabThreshold) || 0).toLocaleString()}</strong></div>
|
|
<div class="result-row main"><span>{meets ? 'Zakat due (2.5%)' : 'Below Nisab — no Zakat due'}</span><strong>{due.toLocaleString()}</strong></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<Disclaimer text="Manual entry only — Nisab is not fetched from a live gold/silver price feed. Confirm today's threshold and consult a scholar for madhab-specific rulings (this uses a single standard 2.5%/85g-gold ruleset)." />
|
|
</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: 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%; }
|
|
.result-card { background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 12px; padding: 16px; margin-bottom: 18px; }
|
|
.result-card.due { background: rgba(46,204,113,0.08); border-color: rgba(46,204,113,0.3); }
|
|
.result-row { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 13px; color: #B8B2A6; }
|
|
.result-row strong { color: #E8E4DC; font-size: 14px; }
|
|
.result-row.main { border-top: 1px solid rgba(255,255,255,0.1); margin-top: 6px; padding-top: 12px; }
|
|
.result-row.main span { color: #C9A84C; font-weight: 600; }
|
|
.result-row.main strong { color: #2ECC71; font-size: 20px; }
|
|
</style>
|