feat: add Zakat calculator (per-member, Nisab + 2.5% rate)
Closes the Zakat gap identified in the competitive benchmark against Rafiq. Per-member module (nf_zakat_records, same author-owns/family-reads RLS pattern as Insurance/Wassiyah/Waqf) computing 2.5% due on zakatable wealth (cash, gold, silver, business assets, investments) once above a user-entered Nisab threshold, minus deductible short-term liabilities. Manual entry only, matching the app's existing philosophy — no live gold price feed. Covered by e2e-zakat.cjs (7/7).
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Verifies the Zakat calculator: entering zakatable wealth computes the
|
||||
// correct 2.5% due once above Nisab, values autosave and persist across a
|
||||
// remount, and a fresh family (same account) doesn't see this member's figures.
|
||||
const { chromium } = require('playwright');
|
||||
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
|
||||
const BASE = 'https://moslem04.falahos.my/';
|
||||
const results = [];
|
||||
const consoleErrors = [];
|
||||
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||
page.on('pageerror', e => consoleErrors.push(e.message));
|
||||
|
||||
await signInFreshFamily(page, BASE, 'e2e-zakat');
|
||||
await page.locator('nav button[aria-label="Zakat"]').click();
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Below nisab: default nisab is 24000, wealth 10000 -> no zakat due
|
||||
await page.locator('.field:has-text("Cash") input').fill('10000');
|
||||
await page.waitForTimeout(1000);
|
||||
const belowNisabText = await page.locator('.result-row.main span').textContent();
|
||||
record('Zakat: below Nisab shows "no Zakat due"', belowNisabText.includes('Below Nisab'), belowNisabText);
|
||||
|
||||
// Above nisab: cash 30000, gold 10000 = 40000 wealth, nisab 24000 -> due = 40000*0.025 = 1000
|
||||
await page.locator('.field:has-text("Cash") input').fill('30000');
|
||||
await page.locator('.field:has-text("Gold") input').fill('10000');
|
||||
await page.waitForTimeout(1000);
|
||||
const dueText = await page.locator('.result-row.main strong').textContent();
|
||||
record('Zakat: correct 2.5% computed once above Nisab', dueText.trim() === '1,000', dueText);
|
||||
|
||||
const wealthText = await page.locator('.result-row', { hasText: 'Zakatable wealth' }).locator('strong').textContent();
|
||||
record('Zakat: zakatable wealth sums categories correctly', wealthText.trim() === '40,000', wealthText);
|
||||
|
||||
// Deductible liabilities reduce the base
|
||||
await page.locator('.field:has-text("Deductible liabilities") input').fill('5000');
|
||||
await page.waitForTimeout(1000);
|
||||
const wealthAfterDebt = await page.locator('.result-row', { hasText: 'Zakatable wealth' }).locator('strong').textContent();
|
||||
record('Zakat: deductible liabilities reduce zakatable wealth', wealthAfterDebt.trim() === '35,000', wealthAfterDebt);
|
||||
|
||||
// Persistence: reload the tab (remount) and confirm autosave held
|
||||
await page.locator('nav button[aria-label="Coverage"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('nav button[aria-label="Zakat"]').click();
|
||||
await page.waitForTimeout(800);
|
||||
const cashPersisted = await page.locator('.field:has-text("Cash") input').inputValue();
|
||||
record('Zakat: autosaved figures persist across a tab remount', cashPersisted === '30000', cashPersisted);
|
||||
|
||||
// Isolation: a different family (same account) starts with defaults, not this member's figures
|
||||
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await signInFreshFamily(isolationPage, BASE, 'e2e-zakat-isolation');
|
||||
await isolationPage.locator('nav button[aria-label="Zakat"]').click();
|
||||
await isolationPage.waitForTimeout(600);
|
||||
const isolatedCash = await isolationPage.locator('.field:has-text("Cash") input').inputValue();
|
||||
record('Isolation: a different family does not see this member\'s Zakat figures', isolatedCash === '', isolatedCash);
|
||||
await isolationPage.close();
|
||||
|
||||
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
|
||||
|
||||
await browser.close();
|
||||
const passCount = results.filter(r => r.pass).length;
|
||||
const failCount = results.length - passCount;
|
||||
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
|
||||
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
|
||||
process.exit(failCount > 0 ? 1 : 0);
|
||||
}
|
||||
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
|
||||
+14
-12
@@ -19,6 +19,7 @@
|
||||
import MutawalliDashboard from './lib/MutawalliDashboard.svelte';
|
||||
import FamilyTree from './lib/FamilyTree.svelte';
|
||||
import InsurancePolicies from './lib/InsurancePolicies.svelte';
|
||||
import ZakatCalculator from './lib/ZakatCalculator.svelte';
|
||||
|
||||
let currentLang = $state('en');
|
||||
lang.subscribe(v => currentLang = v);
|
||||
@@ -42,8 +43,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
|
||||
const icons = ['🎯', '📊', '📁', '🛡️', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
|
||||
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
|
||||
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
|
||||
let activeTab = $state(0);
|
||||
|
||||
function handleKeydown(e) {
|
||||
@@ -101,16 +102,17 @@
|
||||
{:else if activeTab === 1}<FaraidCalculator />
|
||||
{:else if activeTab === 2}<AssetRegistry />
|
||||
{:else if activeTab === 3}<InsurancePolicies />
|
||||
{:else if activeTab === 4}<WassiyahGenerator />
|
||||
{:else if activeTab === 5}<HibahTracker />
|
||||
{:else if activeTab === 6}<FamilyWaqfDesignator />
|
||||
{:else if activeTab === 7}<NominationRegistry />
|
||||
{:else if activeTab === 8}<DeathTrigger />
|
||||
{:else if activeTab === 9}<MutawalliDashboard />
|
||||
{:else if activeTab === 10}<FamilyTree />
|
||||
{:else if activeTab === 11}<DigitalClaims />
|
||||
{:else if activeTab === 12}<FamilyManagement />
|
||||
{:else if activeTab === 13}
|
||||
{:else if activeTab === 4}<ZakatCalculator />
|
||||
{:else if activeTab === 5}<WassiyahGenerator />
|
||||
{:else if activeTab === 6}<HibahTracker />
|
||||
{:else if activeTab === 7}<FamilyWaqfDesignator />
|
||||
{:else if activeTab === 8}<NominationRegistry />
|
||||
{:else if activeTab === 9}<DeathTrigger />
|
||||
{:else if activeTab === 10}<MutawalliDashboard />
|
||||
{:else if activeTab === 11}<FamilyTree />
|
||||
{:else if activeTab === 12}<DigitalClaims />
|
||||
{:else if activeTab === 13}<FamilyManagement />
|
||||
{:else if activeTab === 14}
|
||||
<div class="module">
|
||||
<div class="module-header">
|
||||
<h2>Settings</h2>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { activeFamilyId } from './family.js';
|
||||
import { session } from './auth.js';
|
||||
import { getZakatRecord, upsertZakatRecord } from './db.js';
|
||||
import { zakatableWealth, zakatDue, meetsNisab } 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);
|
||||
|
||||
const DEFAULT_NISAB = 24000; // rough 85g-gold-equivalent placeholder in RM; user should override with today's value
|
||||
|
||||
let fields = $state(emptyFields());
|
||||
let loaded = $state(false);
|
||||
|
||||
function emptyFields() {
|
||||
return { cash: '', gold: '', silver: '', businessAssets: '', investments: '', otherZakatable: '', deductibleLiabilities: '', nisabThreshold: String(DEFAULT_NISAB) };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!familyId || !memberId) return;
|
||||
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();
|
||||
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.</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>
|
||||
</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>
|
||||
@@ -0,0 +1,24 @@
|
||||
// Shared Zakat calculation core — Nisab check + 2.5% (1/40) on qualifying
|
||||
// wealth held above threshold. Nisab is entered as a value, not a live gold
|
||||
// price feed — matches the app's "manual entry only" philosophy already
|
||||
// established for Asset Registry / Faraid.
|
||||
const ZAKAT_RATE = 0.025;
|
||||
|
||||
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);
|
||||
const deductible = Number(fields.deductibleLiabilities) || 0;
|
||||
return Math.max(0, gross - deductible);
|
||||
}
|
||||
|
||||
export function zakatDue(fields) {
|
||||
const wealth = zakatableWealth(fields);
|
||||
const nisab = Number(fields.nisabThreshold) || 0;
|
||||
if (nisab <= 0 || wealth < nisab) return 0;
|
||||
return wealth * ZAKAT_RATE;
|
||||
}
|
||||
|
||||
export function meetsNisab(fields) {
|
||||
const nisab = Number(fields.nisabThreshold) || 0;
|
||||
return nisab > 0 && zakatableWealth(fields) >= nisab;
|
||||
}
|
||||
@@ -490,3 +490,25 @@ export async function setAssetVerified(assetId, verifiedBy, verified) {
|
||||
}).eq('id', assetId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ── Zakat — per-member, one live record per family/member pair. ──
|
||||
export async function getZakatRecord(familyId, memberId) {
|
||||
const { data, error } = await supabase.from('nf_zakat_records').select('*').eq('family_id', familyId).eq('member_id', memberId).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return null;
|
||||
return {
|
||||
cash: data.cash, gold: data.gold, silver: data.silver, businessAssets: data.business_assets,
|
||||
investments: data.investments, otherZakatable: data.other_zakatable,
|
||||
deductibleLiabilities: data.deductible_liabilities, nisabThreshold: data.nisab_threshold
|
||||
};
|
||||
}
|
||||
export async function upsertZakatRecord(familyId, memberId, fields) {
|
||||
const { error } = await supabase.from('nf_zakat_records').upsert({
|
||||
family_id: familyId, member_id: memberId,
|
||||
cash: Number(fields.cash) || 0, gold: Number(fields.gold) || 0, silver: Number(fields.silver) || 0,
|
||||
business_assets: Number(fields.businessAssets) || 0, investments: Number(fields.investments) || 0,
|
||||
other_zakatable: Number(fields.otherZakatable) || 0, deductible_liabilities: Number(fields.deductibleLiabilities) || 0,
|
||||
nisab_threshold: Number(fields.nisabThreshold) || 0, updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'family_id,member_id' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user