Cover digital assets and vehicles with proper fast-path instruments

Digital assets: nonprobate.js's suggestion previously claimed channel
'bank-mandate' with label 'Multi-sig / pre-authorized release' — a
mismatch, since bank-mandate's fields (institution/nominee/reference)
don't fit crypto custody at all. New "digital-custody" channel with
custody-type-specific fields: exchange beneficiary feature, self-custody
multi-sig (warns that it only works if a living co-signer is already
configured), or key-escrow with executor (warns explicitly: never store
the actual seed phrase or private key in this app or any single record —
only a reference to WHERE access is arranged).

Vehicles/jewelry/valuables: were lumped into the same "trust" suggestion
as land, which is overkill for movable property that hands over cleanly
by a simple Hibah. Suggestion now points straight to Hibah for these;
land keeps the trust suggestion since it has no such easy path.

CoverageDashboard's suggestion-inline text simplified from an ad-hoc
ternary chain into a clean NEXT_STEP_TAB map, now correctly covering
hibah/trust/digital-custody/business-continuity.

e2e-digital-vehicle.cjs: new suite verifying digital asset exposed ->
custody-type warnings -> export -> covered, and vehicle exposed -> Hibah
suggestion (not trust) -> covered via Hibah link -> 100% combined
coverage. 10/10 passing. Re-verified all four prior suites (32/32,
16/16, 12/12, 10/10) — 80/80 total, no regressions.
This commit is contained in:
wmj
2026-08-13 17:41:25 +08:00
parent 051c9d9a94
commit 07b9f4f07a
5 changed files with 192 additions and 7 deletions
+116
View File
@@ -0,0 +1,116 @@
// Verifies digital assets get covered via a proper digital-custody plan (not the
// mismatched bank-mandate fields), and vehicles get correctly suggested Hibah
// (not overkill trust structure) and can actually be covered that way.
const { chromium } = require('playwright');
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 page.goto(BASE, { waitUntil: 'networkidle' });
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); };
const selectByText = async (locator, text) => {
const val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text);
await locator.selectOption(val);
};
// ── Digital asset ──
await clickTab('Assets');
await page.locator('.field:has-text("Description") input').fill('Bitcoin cold wallet');
await page.locator('.field:has-text("Estimated value") input').fill('150000');
await page.locator('.form-card select').first().selectOption('Digital assets');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200);
await clickTab('Coverage');
const digitalRowBefore = await page.locator('.asset-row', { hasText: 'Bitcoin' }).textContent();
record('Coverage: digital asset suggests Digital custody plan (not mismatched Multi-sig/bank-mandate label)', digitalRowBefore.includes('Digital custody plan'), digitalRowBefore.trim().slice(0, 200));
await clickTab('Nominate');
await selectByText(page.locator('select').first(), 'Bitcoin');
await page.waitForTimeout(200);
await page.locator('.form-card select').nth(1).selectOption('digital-custody');
await page.waitForTimeout(200);
const digitalFieldsVisible = await page.locator('.trust-fields').isVisible();
record('Nomination: digital-custody-specific fields appear', digitalFieldsVisible);
// Switch to multisig -> warning appears (only one select in the digital-custody fields: Custody type)
const custodySelect = page.locator('.trust-fields select').nth(0);
await custodySelect.selectOption('multisig');
await page.waitForTimeout(150);
const multisigWarningVisible = await page.locator('.business-warning').isVisible();
record('Nomination: multi-sig custody shows the co-signer-setup warning', multisigWarningVisible);
await custodySelect.selectOption('key-escrow');
await page.waitForTimeout(150);
const escrowWarningVisible = await page.locator('.business-warning').isVisible();
const escrowWarningText = await page.locator('.business-warning').textContent();
record('Nomination: key-escrow shows "never store the actual key" warning', escrowWarningVisible && escrowWarningText.includes('Never store'), escrowWarningText.trim().slice(0, 150));
const digitalInputs = page.locator('.trust-fields input');
await digitalInputs.nth(0).fill('Ledger self-custody');
await digitalInputs.nth(1).fill('Executor Ahmad');
await digitalInputs.nth(2).fill('Sealed instructions with lawyer, ref #45');
await page.locator('button.btn-primary', { hasText: 'digital custody plan' }).click();
await page.waitForTimeout(300);
const digitalRowCreated = await page.locator('.nomination-row', { hasText: 'Bitcoin' }).isVisible();
record('Nomination: digital custody row created', digitalRowCreated);
const [dlDigital] = await Promise.all([
page.waitForEvent('download'),
page.locator('.nomination-row', { hasText: 'Bitcoin' }).locator('.export-btn').click()
]);
record('Nomination: digital custody plan export downloads, no seed phrase in filename/content risk', dlDigital.suggestedFilename() === 'digital-custody-plan-draft.txt', dlDigital.suggestedFilename());
await clickTab('Coverage');
const digitalPct = await page.locator('.big-percent').textContent();
record('Coverage: digital asset now covered', digitalPct.trim() === '100%', digitalPct);
// ── Vehicle ──
await clickTab('Assets');
await page.locator('.field:has-text("Description") input').fill('Toyota Vios');
await page.locator('.field:has-text("Estimated value") input').fill('45000');
await page.locator('.form-card select').first().selectOption('Vehicle');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200);
await clickTab('Coverage');
const vehicleRowBefore = await page.locator('.asset-row', { hasText: 'Toyota' }).textContent();
record('Coverage: vehicle suggests Hibah directly (not overkill trust structure)', vehicleRowBefore.includes('Hibah') && !vehicleRowBefore.includes('trust setup'), vehicleRowBefore.trim().slice(0, 200));
// Cover the vehicle via Hibah
await clickTab('Hibah');
await page.locator('.field:has-text("Recipient") input').fill('My Son');
await page.locator('.field:has-text("Relation to you") input').fill('nephew');
await page.locator('.field:has-text("Asset / gift description") input').fill('Car gift');
const linkSelect = page.locator('select').first();
await selectByText(linkSelect, 'Toyota');
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click();
await page.waitForTimeout(200);
await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click();
await page.waitForTimeout(200);
await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click();
await page.waitForTimeout(200);
await clickTab('Coverage');
const overallPct = await page.locator('.big-percent').textContent();
record('Coverage: 100% after digital asset (custody) + vehicle (hibah) both covered', overallPct.trim() === '100%', overallPct);
record('No uncaught JS console errors', 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); });
+9 -1
View File
@@ -15,9 +15,17 @@
'bank-mandate': 'Bank death mandate',
trust: 'Trust / nominee holding',
'business-continuity': 'Business continuity instrument',
'digital-custody': 'Digital custody plan',
'hibah-flagged-capped': 'Hibah flagged (marad al-mawt) — capped, partially exposed',
none: 'Not covered — exposed to Faraid/probate'
};
const NEXT_STEP_TAB = {
hibah: 'the Hibah tab',
trust: 'Nomination Registry (trust setup)',
'digital-custody': 'Nomination Registry (digital custody)',
'business-continuity': 'Nomination Registry (business continuity)'
};
</script>
<div class="module">
@@ -58,7 +66,7 @@
<strong>{r.asset.description}</strong>
<span class="muted">{r.ownedValue.toLocaleString()} · {CHANNEL_LABELS[r.channel]}</span>
{#if suggestion}
<span class="suggestion-inline">Next step: {suggestion.label} — go to {suggestion.channel === 'trust' ? 'a legal advisor for trust setup' : 'Nomination Registry'} or {r.asset.type?.toLowerCase().includes('property') ? 'Hibah/Waqf' : 'Hibah'}.</span>
<span class="suggestion-inline">Next step: {suggestion.label} — go to {NEXT_STEP_TAB[suggestion.channel] || 'Nomination Registry'}.</span>
{/if}
</div>
<span class="status-dot" class:on={r.fast}></span>
+2
View File
@@ -79,6 +79,8 @@
? 'ACTION: Notify the bank with the death certificate and the death-mandate /\njoint-account documentation on file. Funds release per the mandate terms — this\nis a bank process, not a court process.'
: row.channel === 'business-continuity'
? 'ACTION: Execute the pre-agreed buy-sell provision or partnership continuation\nclause on file. If Takaful-funded, file the Takaful death claim to release the\nbuyout funds. This is a private agreement between co-owners/shareholders, not a\nprobate filing.'
: row.channel === 'digital-custody'
? 'ACTION: File the death claim with the custodial platform\'s own beneficiary\nfeature, or have the named multi-sig co-signer/key-escrow holder execute access\nper the plan on file. This is a platform or private-key process, not a probate\nfiling — and never requires disclosing a seed phrase to this app.'
: 'ACTION: Contact the trustee/nominee holder to execute the pre-arranged transfer\nunder the trust deed. This is an internal trustee record update, not a probate\nfiling.',
'',
'This packet was generated by Nur Falah on a local device. It is a drafting aid;',
+62 -4
View File
@@ -20,7 +20,14 @@
{ value: 'takaful', label: 'Takaful / insurance nomination', note: 'Nomination as trust under Insurance Act 1996 s.166 (or Takaful equivalent) — pays the named beneficiary directly.' },
{ value: 'bank-mandate', label: 'Bank death mandate / joint account', note: 'Ask the bank for a death-benefit nomination, or hold as joint account with survivorship.' },
{ value: 'trust', label: 'Pre-funded trust / nominee holding', note: 'For land or illiquid assets with no nomination bypass — requires setting up a real trust or nominee entity now, while alive.' },
{ value: 'business-continuity', label: 'Business continuity instrument', note: 'A business dies with its structure by default: a sole proprietorship dies with the owner, a partnership dissolves on any partner\'s death unless the agreement says otherwise, and company shares fall into probate like any other asset unless a buy-sell or shareholder agreement already routes them.' }
{ value: 'business-continuity', label: 'Business continuity instrument', note: 'A business dies with its structure by default: a sole proprietorship dies with the owner, a partnership dissolves on any partner\'s death unless the agreement says otherwise, and company shares fall into probate like any other asset unless a buy-sell or shareholder agreement already routes them.' },
{ value: 'digital-custody', label: 'Digital custody plan', note: 'A single seed phrase known only to you is not a fast path — it is a total-loss risk if you die without sharing it. Use the exchange\'s own beneficiary feature where one exists, or a multi-sig / key-escrow arrangement with your executor for self-custody wallets.' }
];
const CUSTODY_TYPES = [
{ value: 'exchange-beneficiary', label: 'Custodial exchange beneficiary feature', warning: null },
{ value: 'multisig', label: 'Self-custody multi-sig wallet', warning: 'Multi-sig only works if the co-signers are set up and able to act now — an executor who is a co-signer can release funds without waiting for probate, but only if the wallet was actually configured this way while you were alive.' },
{ value: 'key-escrow', label: 'Key-escrow with executor', warning: 'Never store the actual seed phrase or private key in this app or any single record your executor can\'t independently verify. This field should reference WHERE the key is escrowed (e.g. a sealed instruction with a lawyer, a hardware device with split-knowledge access) — not the key itself.' }
];
const BUSINESS_STRUCTURES = [
@@ -34,7 +41,7 @@
{ value: 'waqf-enterprise', label: 'Dedicate as waqf-owned enterprise', note: 'Use the Family Waqf Designator for this instead — a waqf-owned enterprise survives you and serves your purpose in perpetuity, same as any other waqf corpus.' }
];
const emptyForm = () => ({ linkedAssetId: '', type: 'epf', institution: '', nomineeeName: '', referenceNumber: '', trusteeName: '', successorTrustee: '', trustBeneficiaries: '', businessStructure: 'company', businessInstrument: 'shareholder-buy-sell', successorOwner: '', buySellTerms: '' });
const emptyForm = () => ({ linkedAssetId: '', type: 'epf', institution: '', nomineeeName: '', referenceNumber: '', trusteeName: '', successorTrustee: '', trustBeneficiaries: '', businessStructure: 'company', businessInstrument: 'shareholder-buy-sell', successorOwner: '', buySellTerms: '', custodyType: 'exchange-beneficiary', platform: '', keyHolderName: '', accessInstructionsRef: '' });
let nominations = $state(load('nominations', []));
let form = $state(emptyForm());
@@ -46,6 +53,8 @@
} else if (form.type === 'business-continuity') {
if (form.businessInstrument === 'waqf-enterprise') return; // redirect only, nothing to save here
if (!form.successorOwner) return;
} else if (form.type === 'digital-custody') {
if (!form.keyHolderName) return;
} else if (!form.nomineeeName) {
return;
}
@@ -85,6 +94,37 @@
URL.revokeObjectURL(url);
}
function exportDigitalCustody(n) {
const asset = assets.find(a => a.id === n.linkedAssetId);
const custodyLabel = CUSTODY_TYPES.find(c => c.value === n.custodyType)?.label;
const lines = [
'DIGITAL CUSTODY PLAN — DRAFTING AID',
`Generated: ${new Date().toISOString().slice(0, 10)}`,
`Asset: ${asset ? asset.description : '(asset removed)'}`,
`Platform / wallet: ${n.platform || '________________________'}`,
`Custody type: ${custodyLabel}`,
`Key holder / executor: ${n.keyHolderName}`,
`Access instructions reference: ${n.accessInstructionsRef || '________________________'}`,
'',
n.custodyType === 'exchange-beneficiary'
? 'ACTION: File the death claim directly with the exchange/custodial platform using\nits own beneficiary/inheritance feature. This is a platform process, not a\nprobate filing — confirm the feature is actually activated on the account now,\nwhile you\'re alive, not assumed to exist.'
: n.custodyType === 'multisig'
? 'ACTION: The named co-signer(s) release funds per the wallet\'s multi-sig\nconfiguration once death is confirmed. This only works if the multi-sig was\nactually set up with a living co-signer — a single-key wallet has no fast path\nat all.'
: 'ACTION: Retrieve the escrowed access instructions from wherever they are held\n(named above) and follow them to access the wallet. This app does not, and must\nnot, store the seed phrase or private key itself.',
'',
'WARNING: never enter an actual seed phrase, private key, or password into this',
'app or any single document. This drafting aid should only ever reference WHERE',
'access is arranged, never the credential itself.',
'',
'DISCLAIMER: Not a fatwa. Not legal advice. Drafting aid only.'
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'digital-custody-plan-draft.txt'; a.click();
URL.revokeObjectURL(url);
}
function exportTrustDeed(n) {
const asset = assets.find(a => a.id === n.linkedAssetId);
const lines = [
@@ -178,12 +218,26 @@
<label class="field"><span>Terms</span><input type="text" bind:value={form.buySellTerms} placeholder="e.g. valuation method, Takaful-funded buyout" /></label>
{/if}
</div>
{:else if form.type === 'digital-custody'}
<div class="trust-fields">
<label class="field"><span>Platform / wallet</span><input type="text" bind:value={form.platform} placeholder="e.g. Coinbase, Ledger self-custody" /></label>
<label class="field"><span>Custody type</span>
<select bind:value={form.custodyType}>
{#each CUSTODY_TYPES as c}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{#if CUSTODY_TYPES.find(c => c.value === form.custodyType)?.warning}
<p class="business-warning">{CUSTODY_TYPES.find(c => c.value === form.custodyType).warning}</p>
{/if}
<label class="field"><span>Key holder / executor</span><input type="text" bind:value={form.keyHolderName} /></label>
<label class="field"><span>Access instructions reference (never the actual key)</span><input type="text" bind:value={form.accessInstructionsRef} placeholder="e.g. sealed letter with lawyer X, ref #123" /></label>
</div>
{:else}
<label class="field"><span>Institution</span><input type="text" bind:value={form.institution} placeholder="e.g. KWSP, Bank Islam, Takaful Ikhlas" /></label>
<label class="field"><span>Nominee name</span><input type="text" bind:value={form.nomineeeName} /></label>
<label class="field"><span>Reference / policy / account number</span><input type="text" bind:value={form.referenceNumber} /></label>
{/if}
<button class="btn-primary" disabled={form.type === 'business-continuity' && form.businessInstrument === 'waqf-enterprise'} onclick={addNomination}>Add {form.type === 'trust' ? 'trust setup' : form.type === 'business-continuity' ? 'business continuity instrument' : 'nomination'}</button>
<button class="btn-primary" disabled={form.type === 'business-continuity' && form.businessInstrument === 'waqf-enterprise'} onclick={addNomination}>Add {form.type === 'trust' ? 'trust setup' : form.type === 'business-continuity' ? 'business continuity instrument' : form.type === 'digital-custody' ? 'digital custody plan' : 'nomination'}</button>
</div>
{#each nominations as n (n.id)}
@@ -192,13 +246,16 @@
<div class="nomination-row">
<div class="nomination-info">
<strong>{asset ? asset.description : '(asset removed)'}</strong>
<span class="muted">{channelInfo?.label}{n.type !== 'trust' && n.type !== 'business-continuity' ? ` · ${n.institution || '—'}` : ''}</span>
<span class="muted">{channelInfo?.label}{!['trust', 'business-continuity', 'digital-custody'].includes(n.type) ? ` · ${n.institution || '—'}` : ''}</span>
{#if n.type === 'trust'}
<span class="muted">Trustee: {n.trusteeName} · Successor: {n.successorTrustee || '—'}</span>
<span class="muted">Beneficiaries: {n.trustBeneficiaries || '—'}</span>
{:else if n.type === 'business-continuity'}
<span class="muted">{BUSINESS_INSTRUMENTS.find(i => i.value === n.businessInstrument)?.label}</span>
<span class="muted">Successor: {n.successorOwner} · Terms: {n.buySellTerms || '—'}</span>
{:else if n.type === 'digital-custody'}
<span class="muted">{CUSTODY_TYPES.find(c => c.value === n.custodyType)?.label} · {n.platform || '—'}</span>
<span class="muted">Key holder: {n.keyHolderName}</span>
{:else}
<span class="muted">Nominee: {n.nomineeeName} · Ref: {n.referenceNumber || '—'}</span>
{/if}
@@ -206,6 +263,7 @@
<div class="row-actions">
{#if n.type === 'trust'}<button class="export-btn" onclick={() => exportTrustDeed(n)}>Export</button>{/if}
{#if n.type === 'business-continuity'}<button class="export-btn" onclick={() => exportBusinessContinuity(n)}>Export</button>{/if}
{#if n.type === 'digital-custody'}<button class="export-btn" onclick={() => exportDigitalCustody(n)}>Export</button>{/if}
<button onclick={() => remove(n.id)}>✕</button>
</div>
</div>
+3 -2
View File
@@ -64,7 +64,8 @@ export function suggestedChannel(assetType) {
const t = (assetType || '').toLowerCase();
if (t.includes('cash') || t.includes('bank')) return { channel: 'bank-mandate', label: 'Bank death mandate / joint account', note: 'Ask your bank about a death-benefit nomination or convert to a joint account with survivorship — pays out directly, no probate.' };
if (t.includes('business')) return { channel: 'business-continuity', label: 'Business continuity instrument', note: 'A sole proprietorship dies with you; a partnership dissolves on death unless it has a continuation clause; company shares fall into probate unless a shareholder buy-sell agreement already routes them. Set up in the Nomination Registry, or dedicate as a waqf-owned enterprise via Family Waqf Designator.' };
if (t.includes('digital')) return { channel: 'bank-mandate', label: 'Multi-sig / pre-authorized release', note: 'Crypto and digital accounts: use a multi-sig wallet or exchange beneficiary feature where available.' };
if (t.includes('property') || t.includes('vehicle') || t.includes('jewelry') || t.includes('valuables')) return { channel: 'trust', label: 'Pre-funded trust / nominee holding, or Hibah/Waqf now', note: 'Land and physical assets have no nomination-based bypass in most jurisdictions. Either complete a Hibah/Waqf now (ownership transfers immediately), or set up a trust/nominee holding structure — both require real-world legal steps this app can prepare paperwork for but cannot execute alone.' };
if (t.includes('digital')) return { channel: 'digital-custody', label: 'Digital custody plan (multi-sig / exchange beneficiary / key escrow)', note: 'Custodial exchange accounts often have a built-in beneficiary/inheritance feature — use it directly. Self-custody wallets need a multi-sig setup or a key-escrow arrangement with your executor; a single seed phrase known only to you is not a fast path, it is a total-loss risk.' };
if (t.includes('vehicle') || t.includes('jewelry') || t.includes('valuables')) return { channel: 'hibah', label: 'Hibah — lifetime gift now', note: 'Movable property like this transfers cleanly by Hibah — offer, acceptance, and possession, done today. No trust or nomination structure is needed for something this easy to hand over while you\'re alive.' };
if (t.includes('property')) return { channel: 'trust', label: 'Pre-funded trust / nominee holding, or Hibah/Waqf now', note: 'Land has no nomination-based bypass in most jurisdictions. Either complete a Hibah/Waqf now (ownership transfers immediately), or set up a trust/nominee holding structure — both require real-world legal steps this app can prepare paperwork for but cannot execute alone.' };
return { channel: 'nomination', label: 'EPF / Takaful nomination', note: 'For retirement funds and insurance/takaful, use the scheme\'s own nomination form — these pay the nominee directly by law, bypassing probate.' };
}