diff --git a/e2e-zakat.cjs b/e2e-zakat.cjs new file mode 100644 index 0000000..9809274 --- /dev/null +++ b/e2e-zakat.cjs @@ -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); }); diff --git a/src/App.svelte b/src/App.svelte index 4659788..13af60f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} {:else if activeTab === 2} {:else if activeTab === 3} - {:else if activeTab === 4} - {:else if activeTab === 5} - {:else if activeTab === 6} - {:else if activeTab === 7} - {:else if activeTab === 8} - {:else if activeTab === 9} - {:else if activeTab === 10} - {:else if activeTab === 11} - {:else if activeTab === 12} - {:else if activeTab === 13} + {:else if activeTab === 4} + {:else if activeTab === 5} + {:else if activeTab === 6} + {:else if activeTab === 7} + {:else if activeTab === 8} + {:else if activeTab === 9} + {:else if activeTab === 10} + {:else if activeTab === 11} + {:else if activeTab === 12} + {:else if activeTab === 13} + {:else if activeTab === 14}

Settings

diff --git a/src/lib/ZakatCalculator.svelte b/src/lib/ZakatCalculator.svelte new file mode 100644 index 0000000..557391f --- /dev/null +++ b/src/lib/ZakatCalculator.svelte @@ -0,0 +1,105 @@ + + +
+
+

Zakat

+ +
+

Your own Zakat calculation — a personal, ongoing obligation, not tied to death or inheritance.

+ + {#if loaded} +
+ + + + + + + + +
+ +
+
Zakatable wealth{wealth.toLocaleString()}
+
Nisab threshold{(Number(fields.nisabThreshold) || 0).toLocaleString()}
+
{meets ? 'Zakat due (2.5%)' : 'Below Nisab — no Zakat due'}{due.toLocaleString()}
+
+ {/if} + + +
+ + diff --git a/src/lib/calc/zakat.js b/src/lib/calc/zakat.js new file mode 100644 index 0000000..dd09284 --- /dev/null +++ b/src/lib/calc/zakat.js @@ -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; +} diff --git a/src/lib/db.js b/src/lib/db.js index f2c418d..ecb46bc 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -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; +}