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:
2026-08-14 10:17:03 +08:00
parent d5e25e9f8e
commit 49706e0cd2
7 changed files with 642 additions and 17 deletions
+136 -1
View File
@@ -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;
}