diff --git a/DEMO_DATA.md b/DEMO_DATA.md index d7cd9cf..a6c776c 100644 --- a/DEMO_DATA.md +++ b/DEMO_DATA.md @@ -34,3 +34,25 @@ Ran `verify-demo-families.cjs` against the live app (not just SQL success) — confirms sign-in, correct asset count, non-zero estate total, coverage percentage, correct person count, and non-empty tree render for all 5 families. 35/35 passing. + +## Insurance/Takaful, Liabilities & Asset Verification (added later) + +Three additions, covered by `e2e-insurance-verification.cjs` (12/12): + +- **Insurance & Takaful tab** — per-member (`nf_insurance_policies`), same + author-owns-writes/family-reads pattern as Wassiyah/Waqf. Life, Takaful, + medical, or asset-specific policies with an optional proof document. +- **Liabilities** (bottom of the Assets tab) — family-shared + (`nf_liabilities`), kept distinct from assets because Faraid requires debts + settled before distribution. Optionally linked to the asset it's secured + against, with a loan-document attachment. +- **Asset ownership verification** — every asset in the Asset Registry can + now take a proof document (car grant, land title, cover note) and a + verified/unverified badge. Either the mutawalli/agent OR any other family + member can confirm — not gated to a single approver, since several demo + families have no agent assigned. +- The Mutawalli dashboard's per-member document summary now also lists each + member's insurance/Takaful policies. +- All proof documents (policies, liabilities, assets) share one private + Storage bucket, `nf-asset-documents`, with the same family-membership RLS + pattern used for person photos. diff --git a/e2e-insurance-verification.cjs b/e2e-insurance-verification.cjs new file mode 100644 index 0000000..c23f31d --- /dev/null +++ b/e2e-insurance-verification.cjs @@ -0,0 +1,112 @@ +// Verifies the newest feature set end-to-end against the live backend: +// per-member Insurance/Takaful policies, family-shared Liabilities, and +// asset-ownership verification (proof document + dual-path confirm). +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-insurance'); + + // ── Insurance & Takaful tab ── + await page.locator('nav button[aria-label="Insurance"]').click(); + await page.waitForTimeout(600); + + await page.locator('.form-card .field:has-text("Type") select').selectOption('takaful'); + await page.locator('.form-card .field:has-text("Provider") input').fill('Etiqa Takaful'); + await page.locator('.form-card .field:has-text("Policy number") input').fill('TKF-00123'); + await page.locator('.form-card .field:has-text("Sum assured") input').fill('250000'); + await page.locator('.form-card .field:has-text("Beneficiary name") input').fill('Aisha binti Rahman'); + await page.locator('.form-card .field:has-text("Beneficiary relation") input').fill('spouse'); + await page.locator('.form-card button.btn-primary', { hasText: 'Add policy' }).click(); + await page.waitForTimeout(1000); + + const policyRowVisible = await page.locator('.policy-row', { hasText: 'Etiqa Takaful' }).isVisible().catch(() => false); + record('Insurance: policy added and listed', policyRowVisible); + + const totalCardVisible = await page.locator('.total-card strong', { hasText: '250,000' }).isVisible().catch(() => false); + record('Insurance: total sum assured reflects the added policy', totalCardVisible); + + // Edit the policy + await page.locator('.policy-row', { hasText: 'Etiqa Takaful' }).locator('button[aria-label="Edit"]').click(); + await page.waitForTimeout(300); + await page.locator('.form-card .field:has-text("Notes") input').fill('Renewed 2026'); + await page.locator('.form-card button.btn-primary', { hasText: 'Update policy' }).click(); + await page.waitForTimeout(800); + record('Insurance: editing a policy succeeds without error', consoleErrors.length === 0, consoleErrors.join(' || ')); + + // ── Asset ownership verification (Assets tab) ── + await page.locator('nav button[aria-label="Assets"]').click(); + await page.waitForTimeout(600); + await page.locator('.form-card .field:has-text("Description") input').fill('Family sedan'); + await page.locator('.form-card .field:has-text("Estimated value") input').fill('45000'); + await page.locator('.form-card select').first().selectOption('Vehicle'); + await page.locator('.form-card button.btn-primary', { hasText: 'Add asset' }).click(); + await page.waitForTimeout(1000); + + const unverifiedBadge = await page.locator('.verify-badge', { hasText: 'Unverified' }).isVisible().catch(() => false); + record('Asset Verification: new asset starts unverified', unverifiedBadge); + + await page.locator('button.verify-toggle', { hasText: 'Confirm ownership' }).first().click(); + await page.waitForTimeout(1000); + const verifiedBadge = await page.locator('.verify-badge.verified', { hasText: 'Verified' }).isVisible().catch(() => false); + record('Asset Verification: confirming ownership flips the badge to Verified', verifiedBadge); + + await page.locator('button.verify-toggle', { hasText: 'Unverify' }).first().click(); + await page.waitForTimeout(1000); + const revertedToUnverified = await page.locator('.verify-badge', { hasText: 'Unverified' }).isVisible().catch(() => false); + record('Asset Verification: unverify reverts the badge', revertedToUnverified); + + // ── Liabilities (Assets tab, bottom section) ── + await page.locator('.liabilities-section .field:has-text("Type") select').selectOption('mortgage'); + await page.locator('.liabilities-section .field:has-text("Lender") input').fill('Maybank Islamic'); + await page.locator('.liabilities-section .field:has-text("Outstanding balance") input').fill('180000'); + await page.locator('.liabilities-section .field:has-text("Linked asset") select').selectOption({ label: 'Family sedan' }); + await page.locator('.liabilities-section button.btn-primary', { hasText: 'Add liability' }).click(); + await page.waitForTimeout(1000); + + const liabilityRowVisible = await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).isVisible().catch(() => false); + record('Liabilities: liability added and listed', liabilityRowVisible); + + const debtTotalVisible = await page.locator('.total-card.debt strong', { hasText: '180,000' }).isVisible().catch(() => false); + record('Liabilities: total outstanding debt card shows the correct sum', debtTotalVisible); + + await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).locator('button[aria-label="Remove"]').click(); + await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).waitFor({ state: 'detached', timeout: 10000 }).catch(() => {}); + const liabilityRemoved = await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).isVisible().catch(() => false); + record('Liabilities: removing a liability removes it from the list', !liabilityRemoved); + + // ── Mutawalli dashboard should surface the insurance policy for this member (owner-only family: owner sees their own row) ── + await page.locator('nav button[aria-label="Mutawalli"]').click(); + await page.waitForTimeout(800); + const mutawalliText = await page.locator('.module').innerText().catch(() => ''); + const mutawalliGated = mutawalliText.includes('Only the mutawalli'); + record('Mutawalli: owner-only family either sees the dashboard or the correct gate message', mutawalliGated || /INSURANCE/i.test(mutawalliText)); + + // ── Isolation: a second fresh family (same account) must not see this policy/liability/verification data ── + const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await signInFreshFamily(isolationPage, BASE, 'e2e-insurance-isolation'); + await isolationPage.locator('nav button[aria-label="Insurance"]').click(); + await isolationPage.waitForTimeout(600); + const leakedPolicy = await isolationPage.locator('.policy-row', { hasText: 'Etiqa Takaful' }).isVisible().catch(() => false); + record('Isolation: a different family sees none of this insurance data', !leakedPolicy); + 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 e82ffcc..4659788 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,6 +18,7 @@ import FamilyManagement from './lib/FamilyManagement.svelte'; import MutawalliDashboard from './lib/MutawalliDashboard.svelte'; import FamilyTree from './lib/FamilyTree.svelte'; + import InsurancePolicies from './lib/InsurancePolicies.svelte'; let currentLang = $state('en'); lang.subscribe(v => currentLang = v); @@ -41,8 +42,8 @@ } }); - const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings']; - const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️']; + const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings']; + const icons = ['🎯', '📊', '📁', '🛡️', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️']; let activeTab = $state(0); function handleKeydown(e) { @@ -99,16 +100,17 @@ {#if activeTab === 0} {: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 === 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}

Settings

diff --git a/src/lib/AssetRegistry.svelte b/src/lib/AssetRegistry.svelte index f1b7f26..6dfa791 100644 --- a/src/lib/AssetRegistry.svelte +++ b/src/lib/AssetRegistry.svelte @@ -2,21 +2,34 @@ import { onMount } from 'svelte'; import { activeFamilyId } from './family.js'; import { currentUser } from './auth.js'; - import { listAssets, addAsset, updateAsset, removeAsset, listTrustedContacts, addTrustedContact, removeTrustedContact, estateTotal } from './db.js'; + import { + listAssets, addAsset, updateAsset, removeAsset, listTrustedContacts, addTrustedContact, removeTrustedContact, estateTotal, + uploadAssetDocument, removeAssetDocument, getDocumentUrl, setAssetVerified, + listLiabilities, addLiability, updateLiability, removeLiability, uploadLiabilityDocument, removeLiabilityDocument + } from './db.js'; import Disclaimer from './Disclaimer.svelte'; import InfoPanel from './InfoPanel.svelte'; const TYPES = ['Property', 'Cash / Bank', 'Vehicle', 'Business interest', 'Digital assets', 'Jewelry / valuables', 'Other']; + const LIABILITY_TYPES = ['loan', 'mortgage', 'credit', 'other']; let familyId = $state(null); activeFamilyId.subscribe(v => familyId = v); let assets = $state([]); let trustedContacts = $state([]); + let liabilities = $state([]); + let docUrls = $state({}); let form = $state(emptyForm()); let editingId = $state(null); let contactForm = $state({ name: '', method: '' }); + let liabilityForm = $state(emptyLiabilityForm()); + let editingLiabilityId = $state(null); + + function emptyLiabilityForm() { + return { liabilityType: LIABILITY_TYPES[0], lender: '', outstandingBalance: '', linkedAssetId: '', notes: '' }; + } function emptyForm() { return { type: TYPES[0], description: '', value: '', location: '', ownershipShare: 100 }; @@ -26,6 +39,11 @@ if (!familyId) return; assets = await listAssets(familyId); trustedContacts = await listTrustedContacts(familyId); + liabilities = await listLiabilities(familyId); + const urls = {}; + for (const a of assets) if (a.proofDocumentPath) urls[a.proofDocumentPath] = await getDocumentUrl(a.proofDocumentPath); + for (const l of liabilities) if (l.documentPath) urls[l.documentPath] = await getDocumentUrl(l.documentPath); + docUrls = urls; } onMount(refresh); @@ -66,6 +84,56 @@ await refresh(); } + async function onProofFileChange(assetId, e) { + const file = e.target.files?.[0]; + if (!file) return; + await uploadAssetDocument(familyId, assetId, file); + e.target.value = ''; + await refresh(); + } + async function removeProof(assetId, docPath) { + await removeAssetDocument(assetId, docPath); + await refresh(); + } + async function toggleVerified(a) { + await setAssetVerified(a.id, currentUser()?.id, !a.verified); + await refresh(); + } + + async function addOrUpdateLiability() { + if (!liabilityForm.lender) return; + if (editingLiabilityId) { + await updateLiability(editingLiabilityId, liabilityForm); + editingLiabilityId = null; + } else { + await addLiability(familyId, currentUser()?.id, liabilityForm); + } + liabilityForm = emptyLiabilityForm(); + await refresh(); + } + function editLiability(l) { + editingLiabilityId = l.id; + liabilityForm = { ...l, outstandingBalance: String(l.outstandingBalance ?? '') }; + } + async function removeLiabilityRow(id) { + await removeLiability(id); + if (editingLiabilityId === id) { editingLiabilityId = null; liabilityForm = emptyLiabilityForm(); } + await refresh(); + } + async function onLiabilityFileChange(liabilityId, e) { + const file = e.target.files?.[0]; + if (!file) return; + await uploadLiabilityDocument(familyId, liabilityId, file); + e.target.value = ''; + await refresh(); + } + async function removeLiabilityProof(liabilityId, docPath) { + await removeLiabilityDocument(liabilityId, docPath); + await refresh(); + } + + const totalDebt = $derived((liabilities || []).reduce((sum, l) => sum + (Number(l.outstandingBalance) || 0), 0)); + function exportSummary() { const total = estateTotal(assets); const lines = [ @@ -136,6 +204,19 @@
+
+ {a.verified ? '✓ Verified' : 'Unverified'} + {#if a.proofDocumentPath} + View proof + + {:else} + + {/if} + +
{:else}

No assets logged yet.

{/each} @@ -156,6 +237,70 @@ {/each} +
+
+

Liabilities

+ +
+ {#if totalDebt > 0} +
Total outstanding debt{totalDebt.toLocaleString()}
+ {/if} +
+ + + + + + +
+ {#each liabilities as l (l.id)} +
+
+ {l.liabilityType} + {l.lender} + {l.notes || '—'} +
+
{Number(l.outstandingBalance || 0).toLocaleString()}
+
+ + +
+
+
+ {#if l.documentPath} + View loan document + + {:else} + + {/if} +
+ {:else} +

No liabilities logged.

+ {/each} +
+ @@ -187,5 +332,19 @@ .contacts-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 4px; } .note { font-size: 11.5px; color: #8A8478; margin-bottom: 12px; } .contact-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); } + .verify-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 0 12px; margin-top: -6px; border-bottom: 1px solid rgba(255,255,255,0.06); } + .verify-badge { font-size: 10.5px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,0.06); color: #8A8478; } + .verify-badge.verified { background: rgba(46,204,113,0.15); color: #2ECC71; } + .doc-link { font-size: 11.5px; color: #C9A84C; text-decoration: underline; } + .doc-remove, .verify-toggle { background: none; border: 1px solid rgba(255,255,255,0.15); color: #B8B2A6; font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer; } + .doc-upload { font-size: 11px; color: #8A8478; cursor: pointer; } + .doc-upload input { display: none; } + .liabilities-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; } + .section-header { display: flex; align-items: center; margin-bottom: 4px; } + .liabilities-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 0; font-family: 'DM Serif Display', serif; } + .total-card.debt { background: rgba(231,76,60,0.08); border-color: rgba(231,76,60,0.25); } + .total-card.debt strong { color: #E74C3C; } + .liability-row { display: flex; align-items: center; gap: 10px; padding: 12px 0 6px; border-bottom: none; } + .debt-value { color: #E74C3C; font-weight: 600; font-size: 13px; } .contact-row button { background: none; border: none; color: #8A8478; cursor: pointer; } diff --git a/src/lib/InsurancePolicies.svelte b/src/lib/InsurancePolicies.svelte new file mode 100644 index 0000000..3e125b4 --- /dev/null +++ b/src/lib/InsurancePolicies.svelte @@ -0,0 +1,185 @@ + + +
+
+

Insurance & Takaful

+ +
+

Log your own life, Takaful, medical, or asset insurance — visible to your mutawalli/agent for claims.

+ + {#if totalCover > 0} +
Total sum assured{totalCover.toLocaleString()}
+ {/if} + +
+ + + + + + + + + +
+ +
+ {#each policies as p (p.id)} +
+
+ {typeLabel(p.policyType)} + {p.provider} {p.policyNumber ? `· ${p.policyNumber}` : ''} + Beneficiary: {p.beneficiaryName || '—'} {p.beneficiaryRelation ? `(${p.beneficiaryRelation})` : ''} + {#if p.expiryDate}Expires {p.expiryDate}{/if} +
+ {#if p.sumAssured}
{Number(p.sumAssured).toLocaleString()}
{/if} +
+ + +
+
+
+ {#if p.documentPath} + View policy document + + {:else} + + {/if} +
+ {:else} +

No insurance or Takaful policies logged yet.

+ {/each} +
+ + +
+ + diff --git a/src/lib/MutawalliDashboard.svelte b/src/lib/MutawalliDashboard.svelte index bcdbfb9..acc6abb 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, + listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily, getMemberTrigger, upsertMemberTriggerSetup, fireMemberTrigger, listMemberAttestors, addMemberAttestor, updateMemberAttestorName, setMemberAttestorConfirmed, notifyHeirs @@ -23,6 +23,7 @@ let selectedMemberId = $state(null); let wassiyahByAuthor = $state({}); let waqfByAuthor = $state({}); + let insuranceByMember = $state({}); let trigger = $state(null); let attestors = $state([]); @@ -34,8 +35,8 @@ async function refresh() { if (!familyId) return; - const [allMembers, myFamilies, wassiyah, waqf] = await Promise.all([ - listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId) + const [allMembers, myFamilies, wassiyah, waqf, insurance] = await Promise.all([ + listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId), listAllInsuranceForFamily(familyId) ]); myRole = myFamilies.find(f => f.id === familyId)?.role; members = allMembers.filter(m => m.status === 'active'); @@ -50,6 +51,9 @@ const waqfGrouped = {}; for (const w of waqf) (waqfGrouped[w.author_id] ??= []).push(w); waqfByAuthor = waqfGrouped; + const insuranceGrouped = {}; + for (const p of insurance) (insuranceGrouped[p.member_id] ??= []).push(p); + insuranceByMember = insuranceGrouped; if (!selectedMemberId && members.length) selectedMemberId = members[0].user_id; if (selectedMemberId) await loadMemberTrigger(selectedMemberId); @@ -156,6 +160,12 @@
Mutawalli: {w.mutawalli || '—'} · {(w.nf_waqf_beneficiaries || []).length} beneficiaries
{:else}
None recorded
{/each} +
+ Insurance & Takaful + {#each insuranceByMember[selectedMemberId] || [] as p} +
{p.policy_type} — {p.provider || '—'} {p.sum_assured ? `· ${Number(p.sum_assured).toLocaleString()}` : ''} · beneficiary {p.beneficiary_name || '—'}
+ {:else}
None recorded
{/each} +
diff --git a/src/lib/db.js b/src/lib/db.js index 4899d31..f2c418d 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -10,7 +10,10 @@ import { supabase } from './supabaseClient.js'; export async function listAssets(familyId) { const { data, error } = await supabase.from('nf_assets').select('*').eq('family_id', familyId).order('created_at'); if (error) throw error; - return (data || []).map(a => ({ id: a.id, type: a.type, description: a.description, value: a.value, location: a.location, ownershipShare: a.ownership_share })); + return (data || []).map(a => ({ + id: a.id, type: a.type, description: a.description, value: a.value, location: a.location, ownershipShare: a.ownership_share, + verified: a.verified, verifiedBy: a.verified_by, verifiedAt: a.verified_at, proofDocumentPath: a.proof_document_path + })); } export async function addAsset(familyId, userId, a) { const { data, error } = await supabase.from('nf_assets').insert({ @@ -355,3 +358,135 @@ export async function removeRelationship(id) { const { error } = await supabase.from('nf_relationships').delete().eq('id', id); if (error) throw error; } + +// ── Insurance / Takaful — per-member: each family member logs their own +// policies, but any family member (and the mutawalli dashboard) can read them. ── +export async function listInsurancePolicies(familyId, memberId) { + const { data, error } = await supabase.from('nf_insurance_policies').select('*').eq('family_id', familyId).eq('member_id', memberId).order('created_at'); + if (error) throw error; + return (data || []).map(p => ({ + id: p.id, policyType: p.policy_type, provider: p.provider, policyNumber: p.policy_number, + sumAssured: p.sum_assured, beneficiaryName: p.beneficiary_name, beneficiaryRelation: p.beneficiary_relation, + expiryDate: p.expiry_date, notes: p.notes, documentPath: p.document_path + })); +} +export async function addInsurancePolicy(familyId, memberId, createdBy, p) { + const { data, error } = await supabase.from('nf_insurance_policies').insert({ + family_id: familyId, member_id: memberId, created_by: createdBy, policy_type: p.policyType, + provider: p.provider, policy_number: p.policyNumber, sum_assured: p.sumAssured ? Number(p.sumAssured) : null, + beneficiary_name: p.beneficiaryName, beneficiary_relation: p.beneficiaryRelation, expiry_date: p.expiryDate || null, notes: p.notes + }).select().single(); + if (error) throw error; + return data; +} +export async function updateInsurancePolicy(id, p) { + const { error } = await supabase.from('nf_insurance_policies').update({ + policy_type: p.policyType, provider: p.provider, policy_number: p.policyNumber, + sum_assured: p.sumAssured ? Number(p.sumAssured) : null, beneficiary_name: p.beneficiaryName, + beneficiary_relation: p.beneficiaryRelation, expiry_date: p.expiryDate || null, notes: p.notes, + updated_at: new Date().toISOString() + }).eq('id', id); + if (error) throw error; +} +export async function removeInsurancePolicy(id) { + const { error } = await supabase.from('nf_insurance_policies').delete().eq('id', id); + if (error) throw error; +} +/** For the mutawalli dashboard: every family member's insurance/Takaful policies. */ +export async function listAllInsuranceForFamily(familyId) { + const { data, error } = await supabase.from('nf_insurance_policies').select('*').eq('family_id', familyId).order('created_at'); + if (error) throw error; + return data || []; +} + +const POLICY_DOC_BUCKET = 'nf-asset-documents'; +export async function uploadPolicyDocument(familyId, policyId, file) { + const ext = (file.name.split('.').pop() || 'pdf').toLowerCase(); + const path = `${familyId}/policy-${policyId}/${Date.now()}.${ext}`; + const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type }); + if (upErr) throw upErr; + const { error: dbErr } = await supabase.from('nf_insurance_policies').update({ document_path: path, updated_at: new Date().toISOString() }).eq('id', policyId); + if (dbErr) throw dbErr; + return path; +} +export async function removePolicyDocument(policyId, docPath) { + if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]); + const { error } = await supabase.from('nf_insurance_policies').update({ document_path: null, updated_at: new Date().toISOString() }).eq('id', policyId); + if (error) throw error; +} + +// ── Liabilities — family-shared, same ownership model as Assets. Kept +// distinct because Faraid requires debts settled before distribution. ── +export async function listLiabilities(familyId) { + const { data, error } = await supabase.from('nf_liabilities').select('*').eq('family_id', familyId).order('created_at'); + if (error) throw error; + return (data || []).map(l => ({ + id: l.id, liabilityType: l.liability_type, lender: l.lender, outstandingBalance: l.outstanding_balance, + linkedAssetId: l.linked_asset_id, notes: l.notes, documentPath: l.document_path + })); +} +export async function addLiability(familyId, createdBy, l) { + const { data, error } = await supabase.from('nf_liabilities').insert({ + family_id: familyId, created_by: createdBy, liability_type: l.liabilityType, lender: l.lender, + outstanding_balance: l.outstandingBalance ? Number(l.outstandingBalance) : null, + linked_asset_id: l.linkedAssetId || null, notes: l.notes + }).select().single(); + if (error) throw error; + return data; +} +export async function updateLiability(id, l) { + const { error } = await supabase.from('nf_liabilities').update({ + liability_type: l.liabilityType, lender: l.lender, outstanding_balance: l.outstandingBalance ? Number(l.outstandingBalance) : null, + linked_asset_id: l.linkedAssetId || null, notes: l.notes, updated_at: new Date().toISOString() + }).eq('id', id); + if (error) throw error; +} +export async function removeLiability(id) { + const { error } = await supabase.from('nf_liabilities').delete().eq('id', id); + if (error) throw error; +} +export async function uploadLiabilityDocument(familyId, liabilityId, file) { + const ext = (file.name.split('.').pop() || 'pdf').toLowerCase(); + const path = `${familyId}/liability-${liabilityId}/${Date.now()}.${ext}`; + const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type }); + if (upErr) throw upErr; + const { error: dbErr } = await supabase.from('nf_liabilities').update({ document_path: path, updated_at: new Date().toISOString() }).eq('id', liabilityId); + if (dbErr) throw dbErr; + return path; +} +export async function removeLiabilityDocument(liabilityId, docPath) { + if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]); + const { error } = await supabase.from('nf_liabilities').update({ document_path: null, updated_at: new Date().toISOString() }).eq('id', liabilityId); + if (error) throw error; +} + +// ── Asset ownership verification — a proof document (car grant, land title, +// cover note, loan agreement) plus a dual-path confirm: either the +// mutawalli/agent OR any other family member can mark an asset verified. ── +export async function uploadAssetDocument(familyId, assetId, file) { + const ext = (file.name.split('.').pop() || 'pdf').toLowerCase(); + const path = `${familyId}/asset-${assetId}/${Date.now()}.${ext}`; + const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type }); + if (upErr) throw upErr; + const { error: dbErr } = await supabase.from('nf_assets').update({ proof_document_path: path }).eq('id', assetId); + if (dbErr) throw dbErr; + return path; +} +export async function removeAssetDocument(assetId, docPath) { + if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]); + const { error } = await supabase.from('nf_assets').update({ proof_document_path: null }).eq('id', assetId); + if (error) throw error; +} +export async function getDocumentUrl(path) { + if (!path) return null; + const { data, error } = await supabase.storage.from(POLICY_DOC_BUCKET).createSignedUrl(path, 3600); + if (error) return null; + return data.signedUrl; +} +/** Verifying isn't restricted to the mutawalli — any other family member who has seen the proof can confirm. */ +export async function setAssetVerified(assetId, verifiedBy, verified) { + const { error } = await supabase.from('nf_assets').update({ + verified, verified_by: verified ? verifiedBy : null, verified_at: verified ? new Date().toISOString() : null + }).eq('id', assetId); + if (error) throw error; +}