feat: add insurance/Takaful tracking, liabilities, and asset ownership verification
Per-member Insurance & Takaful tab (life/Takaful/medical/asset policies, same author-owns/family-reads pattern as Wassiyah/Waqf), a family-shared Liabilities section on the Asset Registry (kept distinct from assets since Faraid requires debts settled before distribution), and dual-path asset ownership verification (proof document + confirm by either the mutawalli/agent or any other family member, since not every demo family has an agent assigned). All proof documents share a new private nf-asset-documents storage bucket with the same RLS pattern as person photos. Mutawalli dashboard now surfaces each member's insurance policies. Covered by e2e-insurance-verification.cjs (12/12); full regression sweep 233/233 across all suites.
This commit is contained in:
@@ -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 @@
|
||||
<button onclick={() => remove(a.id)} aria-label="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verify-row">
|
||||
<span class="verify-badge" class:verified={a.verified}>{a.verified ? '✓ Verified' : 'Unverified'}</span>
|
||||
{#if a.proofDocumentPath}
|
||||
<a class="doc-link" href={docUrls[a.proofDocumentPath]} target="_blank" rel="noopener">View proof</a>
|
||||
<button class="doc-remove" onclick={() => removeProof(a.id, a.proofDocumentPath)}>Remove proof</button>
|
||||
{:else}
|
||||
<label class="doc-upload">
|
||||
Attach proof (car grant, land title, cover note…)
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" onchange={(e) => onProofFileChange(a.id, e)} />
|
||||
</label>
|
||||
{/if}
|
||||
<button class="verify-toggle" onclick={() => toggleVerified(a)}>{a.verified ? 'Unverify' : 'Confirm ownership'}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No assets logged yet.</p>
|
||||
{/each}
|
||||
@@ -156,6 +237,70 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="liabilities-section">
|
||||
<div class="section-header">
|
||||
<h3>Liabilities</h3>
|
||||
<InfoPanel
|
||||
title="Liabilities"
|
||||
what="Debts against the estate — loans, mortgages, credit balances. Faraid requires debts to be settled before any distribution, so these are tracked separately from what you own, not attached as a note on an asset."
|
||||
how="Log the lender and outstanding balance. Optionally link it to the asset it's secured against (e.g. a mortgage linked to a property) and attach the loan agreement as proof."
|
||||
fields={[
|
||||
{ label: 'Type', hint: 'Loan, mortgage, credit balance, or other.' },
|
||||
{ label: 'Lender', hint: 'Who the debt is owed to.' },
|
||||
{ label: 'Outstanding balance', hint: 'The amount still owed, not the original loan amount.' },
|
||||
{ label: 'Linked asset', hint: 'Optional — e.g. link a mortgage to the property it secures.' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{#if totalDebt > 0}
|
||||
<div class="total-card debt"><span>Total outstanding debt</span><strong>{totalDebt.toLocaleString()}</strong></div>
|
||||
{/if}
|
||||
<div class="form-card">
|
||||
<label class="field"><span>Type</span>
|
||||
<select bind:value={liabilityForm.liabilityType}>
|
||||
{#each LIABILITY_TYPES as t}<option value={t}>{t}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Lender</span><input type="text" bind:value={liabilityForm.lender} placeholder="e.g. Maybank" /></label>
|
||||
<label class="field"><span>Outstanding balance</span><input type="number" min="0" bind:value={liabilityForm.outstandingBalance} /></label>
|
||||
<label class="field"><span>Linked asset (optional)</span>
|
||||
<select bind:value={liabilityForm.linkedAssetId}>
|
||||
<option value="">— none —</option>
|
||||
{#each assets as a}<option value={a.id}>{a.description}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Notes</span><input type="text" bind:value={liabilityForm.notes} /></label>
|
||||
<button class="btn-primary" onclick={addOrUpdateLiability}>{editingLiabilityId ? 'Update liability' : 'Add liability'}</button>
|
||||
</div>
|
||||
{#each liabilities as l (l.id)}
|
||||
<div class="liability-row">
|
||||
<div class="asset-info">
|
||||
<span class="asset-type">{l.liabilityType}</span>
|
||||
<span class="asset-desc">{l.lender}</span>
|
||||
<span class="asset-meta">{l.notes || '—'}</span>
|
||||
</div>
|
||||
<div class="asset-value debt-value">{Number(l.outstandingBalance || 0).toLocaleString()}</div>
|
||||
<div class="asset-actions">
|
||||
<button onclick={() => editLiability(l)} aria-label="Edit">✎</button>
|
||||
<button onclick={() => removeLiabilityRow(l.id)} aria-label="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verify-row">
|
||||
{#if l.documentPath}
|
||||
<a class="doc-link" href={docUrls[l.documentPath]} target="_blank" rel="noopener">View loan document</a>
|
||||
<button class="doc-remove" onclick={() => removeLiabilityProof(l.id, l.documentPath)}>Remove</button>
|
||||
{:else}
|
||||
<label class="doc-upload">
|
||||
Attach loan document
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" onchange={(e) => onLiabilityFileChange(l.id, e)} />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No liabilities logged.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Disclaimer text="Manual entry only — bank/brokerage account linking and live balance sync are explicitly out of scope for Horizon 1." />
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user