From 97a1d2ebe2a821ef2aa711730b3655cce01da3a9 Mon Sep 17 00:00:00 2001 From: Wmj Ismail Date: Fri, 14 Aug 2026 12:37:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20close=20final=20competitive=20gap=20?= =?UTF-8?q?=E2=80=94=20professional=20review=20workflow=20+=20multi-countr?= =?UTF-8?q?y=20(MY/SG/UK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- COMPETITIVE_BENCHMARK.md | 11 ++- e2e-jurisdiction-review.cjs | 113 +++++++++++++++++++++++++++++ src/App.svelte | 3 + src/lib/JurisdictionSetting.svelte | 58 +++++++++++++++ src/lib/MutawalliDashboard.svelte | 19 ++++- src/lib/WassiyahGenerator.svelte | 61 ++++++++++++++-- src/lib/ZakatCalculator.svelte | 37 +++++----- src/lib/calc/zakat.js | 7 ++ src/lib/db.js | 23 ++++++ src/lib/family.js | 12 +++ src/lib/willDocument.js | 14 +++- 11 files changed, 327 insertions(+), 31 deletions(-) create mode 100644 e2e-jurisdiction-review.cjs create mode 100644 src/lib/JurisdictionSetting.svelte diff --git a/COMPETITIVE_BENCHMARK.md b/COMPETITIVE_BENCHMARK.md index 3b4a2ce..bd94f71 100644 --- a/COMPETITIVE_BENCHMARK.md +++ b/COMPETITIVE_BENCHMARK.md @@ -62,8 +62,15 @@ differentiators: spouse. Ja'fari (Shia) is deliberately gated as unsupported rather than silently computed with Sunni rules, since it uses a structurally different classification system, not a parameter tweak. -4. **No human-in-the-loop / professional tier — open.** Every competitor pairs software with a - lawyer or consultant; Nur Falah remains self-serve only. +4. **Human-in-the-loop / professional tier — closed, as a review-request workflow.** Not a + marketplace or payment integration (out of scope) — a structured status on each member's + Wassiyah (Not reviewed → Review requested → Reviewed, with reviewer name recorded), surfaced + on the Mutawalli dashboard so nothing quietly ships as final without the legally-required + review being flagged. Pairs with a new whole-app jurisdiction setting (Malaysia/Singapore/UK, + owner-configurable in Settings) that drives jurisdiction-specific legal notes in both the + Wassiyah tab and the generated draft will document (Wills Act 1959/state Syariah for + Malaysia, Wills Act 1838 + AMLA for Singapore, Wills Act 1837 for the UK) and Zakat + currency/Nisab defaults. 5. **AI guidance layer — closed.** Coverage Dashboard now surfaces a rule-based recommendations list (exposed assets, missing Wassiyah, unverified assets, unlinked liabilities, no insurance logged, no Zakat set up, insufficient attestors, no agent diff --git a/e2e-jurisdiction-review.cjs b/e2e-jurisdiction-review.cjs new file mode 100644 index 0000000..6506e19 --- /dev/null +++ b/e2e-jurisdiction-review.cjs @@ -0,0 +1,113 @@ +// Verifies the whole-app jurisdiction setting (MY/SG/UK), its downstream +// effects on Wassiyah/will document/Zakat currency, and the professional +// review request workflow against the live backend. +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-jurisdiction'); + + // ── Settings: jurisdiction selector, owner can change ── + await page.locator('nav button[aria-label="Settings"]').click(); + await page.waitForTimeout(600); + const jurisdictionSelectVisible = await page.locator('.jurisdiction-setting select').isVisible().catch(() => false); + record('Settings: jurisdiction selector visible for owner', jurisdictionSelectVisible); + + await page.locator('.jurisdiction-setting select').selectOption('SG'); + await page.waitForTimeout(1000); + const savedBadge = await page.locator('.jurisdiction-setting .saved').isVisible().catch(() => false); + record('Settings: changing jurisdiction shows a saved confirmation', savedBadge); + + // ── Zakat: currency defaults follow the family jurisdiction (SGD) ── + await page.locator('nav button[aria-label="Zakat"]').click(); + await page.waitForTimeout(800); + const sgdLabelVisible = await page.locator('.field:has-text("Cash")').textContent(); + record('Zakat: currency label reflects Singapore jurisdiction (SGD)', sgdLabelVisible.includes('SGD'), sgdLabelVisible); + const nisabValue = await page.locator('.field:has-text("Nisab") input').inputValue(); + record('Zakat: Nisab default follows Singapore jurisdiction (8000)', nisabValue === '8000', nisabValue); + + // Log an asset first so the one-third cap isn't zero (a fresh family with + // no assets has cap=0, which would make any bequest below "exceed" it). + await page.locator('nav button[aria-label="Assets"]').click(); + await page.waitForTimeout(500); + await page.locator('.form-card .field:has-text("Description") input').fill('Savings'); + await page.locator('.form-card .field:has-text("Estimated value") input').fill('30000'); + await page.locator('.form-card button.btn-primary', { hasText: 'Add asset' }).click(); + await page.waitForTimeout(1000); + + // ── Wassiyah: jurisdiction dropdown includes Singapore, defaults from family setting ── + await page.locator('nav button[aria-label="Wassiyah"]').click(); + await page.waitForTimeout(800); + const wassiyahJurisdiction = await page.locator('.field:has-text("Jurisdiction") select').inputValue(); + record('Wassiyah: jurisdiction defaults to the family setting (SG)', wassiyahJurisdiction === 'SG', wassiyahJurisdiction); + const sgOptionVisible = await page.locator('.field:has-text("Jurisdiction") select option[value="SG"]').count(); + record('Wassiyah: Singapore is a selectable jurisdiction option', sgOptionVisible === 1); + + // ── Draft will document reflects Singapore-specific legal notes ── + await page.locator('.field:has-text("Full legal name") input').fill('Siti binte Rahman'); + await page.locator('.field:has-text("Recipient name") input').fill('Local Mosque Fund'); + await page.locator('.field:has-text("Relation to you") input').fill('charity'); + await page.locator('.field:has-text("Description") input').fill('Cash gift'); + await page.locator('.field:has-text("Value") input').first().fill('3000'); + await page.locator('button.btn-primary', { hasText: 'Add bequest' }).click(); + await page.waitForTimeout(1000); + + const [docPage] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('button.btn-secondary', { hasText: 'Generate draft will document' }).click() + ]); + await docPage.waitForLoadState(); + const docText = await docPage.locator('body').innerText(); + record('Will document: Singapore-specific legal note present (Wills Act 1838 / AMLA)', docText.includes('1838') && docText.includes('AMLA'), docText.slice(0, 50)); + await docPage.close(); + + // ── Professional review request workflow ── + const notRequestedVisible = await page.locator('.review-card', { hasText: "hasn't been reviewed" }).isVisible().catch(() => false); + record('Wassiyah: starts as not-yet-reviewed', notRequestedVisible); + + await page.locator('button.btn-secondary', { hasText: 'Request professional review' }).click(); + await page.waitForTimeout(1000); + const requestedVisible = await page.locator('.review-card.review-requested').isVisible().catch(() => false); + record('Wassiyah: requesting review updates status', requestedVisible); + + await page.locator('.reviewer-row input').fill('Ahmad & Co Solicitors'); + await page.locator('.reviewer-row button', { hasText: 'Mark reviewed' }).click(); + await page.waitForTimeout(1000); + const reviewedVisible = await page.locator('.review-card.review-reviewed', { hasText: 'Ahmad & Co Solicitors' }).isVisible().catch(() => false); + record('Wassiyah: marking reviewed records the reviewer name', reviewedVisible); + + // Review status surfaces on the Mutawalli dashboard (owner-only family — owner sees own row) + await page.locator('nav button[aria-label="Mutawalli"]').click(); + await page.waitForTimeout(800); + const mutawalliReviewBadge = await page.locator('.review-badge.review-reviewed').isVisible().catch(() => false); + const mutawalliGated = (await page.locator('.module').innerText()).includes('Only the mutawalli'); + record('Mutawalli: review status surfaces on the dashboard (or family is correctly gated)', mutawalliReviewBadge || mutawalliGated); + + // ── Isolation: a different family (same account) is unaffected by this jurisdiction change ── + const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await signInFreshFamily(isolationPage, BASE, 'e2e-jurisdiction-isolation'); + await isolationPage.locator('nav button[aria-label="Settings"]').click(); + await isolationPage.waitForTimeout(600); + const isolatedJurisdiction = await isolationPage.locator('.jurisdiction-setting select').inputValue(); + record('Isolation: a different family defaults to MY, unaffected by the SG change above', isolatedJurisdiction === 'MY', isolatedJurisdiction); + 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 13af60f..86a5463 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@

Signed in as {currentSession.user.email}.

+ +
Language / Bahasa
diff --git a/src/lib/JurisdictionSetting.svelte b/src/lib/JurisdictionSetting.svelte new file mode 100644 index 0000000..6114f04 --- /dev/null +++ b/src/lib/JurisdictionSetting.svelte @@ -0,0 +1,58 @@ + + +
+ Jurisdiction + {#if isOwner} + + {#if saved}Saved{/if} + {:else} + {JURISDICTIONS.find(j => j.value === jurisdiction)?.label || jurisdiction} (only the head of family can change this) + {/if} +

Drives the legal notes shown in Wassiyah, the draft will document format, and Zakat currency defaults.

+
+ + diff --git a/src/lib/MutawalliDashboard.svelte b/src/lib/MutawalliDashboard.svelte index acc6abb..3ff8359 100644 --- a/src/lib/MutawalliDashboard.svelte +++ b/src/lib/MutawalliDashboard.svelte @@ -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 @@

{selectedEmail()}'s documents

Wassiyah bequests + {#if wassiyahSettingsByAuthor[selectedMemberId]} + {@const rs = wassiyahSettingsByAuthor[selectedMemberId].review_status} +
+ {rs === 'reviewed' ? `Reviewed by ${wassiyahSettingsByAuthor[selectedMemberId].reviewer_name}` : rs === 'requested' ? 'Professional review requested' : 'Not yet reviewed'} +
+ {/if} {#each wassiyahByAuthor[selectedMemberId] || [] as b}
{b.recipient} ({b.relation || '—'}) — {Number(b.value).toLocaleString()}{b.recipient_email ? ` · ${b.recipient_email}` : ''}
{:else}
None recorded
{/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; } diff --git a/src/lib/WassiyahGenerator.svelte b/src/lib/WassiyahGenerator.svelte index 3f20ec9..b289c76 100644 --- a/src/lib/WassiyahGenerator.svelte +++ b/src/lib/WassiyahGenerator.svelte @@ -1,10 +1,10 @@