Cover land parcels via proper trust setup, not generic nomination fields

Nomination Registry's "trust" channel type previously reused the same
nominee/institution fields as EPF/Takaful/bank nominations — wrong shape
for a trust, which needs a trustee (and successor) holding title, not a
beneficiary named on a policy. Coverage math already counted any
nomination-type entry as fast-path regardless of shape, so land parcels
were silently "covered" without the actual structure a trust requires.

Now: selecting "trust" swaps in trustee/successor-trustee/beneficiaries
fields, and adds a dedicated trust-setup drafting-aid export (same
treatment as the Waqf deed export) explaining what still needs a real
trust company/lawyer to execute.

e2e-trust.cjs: new suite verifying the full path for a land parcel —
exposed -> trust suggestion -> trustee fields -> deed export -> coverage
flips to 100% -> death trigger generates its execution packet. 12/12
passing. Re-verified e2e-uat.cjs (32/32) and e2e-fastpath.cjs (16/16)
with no regressions.
This commit is contained in:
wmj
2026-08-13 17:27:51 +08:00
parent ad2b3c48a3
commit ff47dc9ed2
2 changed files with 175 additions and 10 deletions
+107
View File
@@ -0,0 +1,107 @@
// Verifies land parcels specifically get covered via a trust setup in Nomination Registry.
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); };
// Add a Property-type asset (land parcel)
await clickTab('Assets');
await page.locator('.field:has-text("Description") input').fill('Land Parcel, Perak');
await page.locator('.field:has-text("Estimated value") input').fill('500000');
// type defaults to Property
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200);
// Coverage should show this asset as exposed initially, with a trust suggestion
await clickTab('Coverage');
const rowText = await page.locator('.asset-row', { hasText: 'Land Parcel' }).textContent();
record('Coverage: land parcel initially shown exposed with trust suggestion', rowText.includes('Not covered') && /trust/i.test(rowText), rowText.trim().slice(0, 200));
// Go to Nomination Registry, select trust channel
await clickTab('Nominate');
const assetSelect = page.locator('select').first();
const targetValue = await assetSelect.evaluate(el => {
const opt = Array.from(el.options).find(o => o.textContent.includes('Land Parcel'));
return opt ? opt.value : null;
});
await assetSelect.selectOption(targetValue);
await page.waitForTimeout(200);
const suggestionVisible = await page.locator('.suggestion').isVisible();
record('Nomination: trust suggestion shown for property-type asset', suggestionVisible);
const typeSelect = page.locator('.form-card select').nth(1);
await typeSelect.selectOption('trust');
await page.waitForTimeout(200);
const trustFieldsVisible = await page.locator('.trust-fields').isVisible();
record('Nomination: trust-specific fields (trustee/successor/beneficiaries) appear', trustFieldsVisible);
const trustInputs = page.locator('.trust-fields .field input');
await trustInputs.nth(0).fill('Ahmad bin Ismail');
await trustInputs.nth(1).fill('Faridah binti Omar');
await trustInputs.nth(2).fill('Equal split among 3 children');
await page.locator('button.btn-primary', { hasText: 'Add trust setup' }).click();
await page.waitForTimeout(300);
const trustRowVisible = await page.locator('.nomination-row', { hasText: 'Land Parcel' }).isVisible();
record('Nomination: trust setup row created for land parcel', trustRowVisible);
const exportBtnVisible = await page.locator('.nomination-row', { hasText: 'Land Parcel' }).locator('.export-btn').isVisible();
record('Nomination: trust deed export button present', exportBtnVisible);
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('.nomination-row', { hasText: 'Land Parcel' }).locator('.export-btn').click()
]);
record('Nomination: trust deed downloads correctly', download.suggestedFilename() === 'trust-setup-draft.txt', download.suggestedFilename());
// Coverage should now show land parcel as fast/covered
await clickTab('Coverage');
const pct = await page.locator('.big-percent').textContent();
record('Coverage: land parcel now covered (100%)', pct.trim() === '100%', pct);
const rowAfter = await page.locator('.asset-row', { hasText: 'Land Parcel' }).textContent();
record('Coverage: land parcel row shows trust channel, no longer "Not covered"', !rowAfter.includes('Not covered') && /trust/i.test(rowAfter), rowAfter.trim().slice(0, 200));
const statusDotOn = await page.locator('.asset-row', { hasText: 'Land Parcel' }).locator('.status-dot').evaluate(el => el.classList.contains('on'));
record('Coverage: land parcel status dot is green (on)', statusDotOn);
// Death Trigger should generate a packet for the land parcel via trust channel
await clickTab('Trigger');
const inputs = page.locator('.attestor-row input');
await inputs.nth(0).fill('Executor A');
await page.locator('.attestor-row .confirm-btn').nth(0).click();
await inputs.nth(1).fill('Witness B');
await page.locator('.attestor-row .confirm-btn').nth(1).click();
await page.locator('.field:has-text("Date of death") input').fill('2026-08-13');
await page.locator('.field:has-text("Death certificate reference") input').fill('DC-TEST-001');
await page.waitForTimeout(200);
await page.locator('button.btn-danger-solid').click();
await page.waitForTimeout(300);
const packetForLand = await page.locator('.packet-row', { hasText: 'Land Parcel' }).isVisible();
record('Death Trigger: execution packet generated for land parcel via trust', packetForLand);
const [pdl] = await Promise.all([
page.waitForEvent('download'),
page.locator('.packet-row', { hasText: 'Land Parcel' }).locator('.btn-small').click()
]);
record('Death Trigger: land parcel packet download works', pdl.suggestedFilename().includes('execution-packet'), pdl.suggestedFilename());
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); });
+63 -5
View File
@@ -22,14 +22,51 @@
{ 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.' }
];
const emptyForm = () => ({ linkedAssetId: '', type: 'epf', institution: '', nomineeeName: '', referenceNumber: '', trusteeName: '', successorTrustee: '', trustBeneficiaries: '' });
let nominations = $state(load('nominations', []));
let form = $state({ linkedAssetId: '', type: 'epf', institution: '', nomineeeName: '', referenceNumber: '' });
let form = $state(emptyForm());
function addNomination() {
if (!form.linkedAssetId || !form.nomineeeName) return;
if (!form.linkedAssetId) return;
if (form.type === 'trust') {
if (!form.trusteeName) return;
} else if (!form.nomineeeName) {
return;
}
nominations = [...nominations, { ...form, id: crypto.randomUUID() }];
save('nominations', nominations);
form = { linkedAssetId: '', type: 'epf', institution: '', nomineeeName: '', referenceNumber: '' };
form = emptyForm();
}
function exportTrustDeed(n) {
const asset = assets.find(a => a.id === n.linkedAssetId);
const lines = [
'TRUST / NOMINEE HOLDING SETUP — DRAFTING AID',
`Generated: ${new Date().toISOString().slice(0, 10)}`,
`Asset: ${asset ? `${asset.type}${asset.description}` : '(asset removed)'}`,
`Trustee: ${n.trusteeName}`,
`Successor trustee: ${n.successorTrustee || '________________________'}`,
`Beneficiaries: ${n.trustBeneficiaries || '________________________'}`,
'',
'PURPOSE: this asset has no nomination-based probate bypass available (unlike EPF or',
'Takaful). A pre-funded trust or nominee holding structure — set up and title-transferred',
'NOW, while the settlor is alive — is the only way to move it out of the probate-bound',
'estate. Once the trustee holds legal title on the beneficiaries\' behalf, distribution at',
'death is an internal trustee record update, not a Land Office / court filing.',
'',
'ACTION REQUIRED: this document is a drafting aid only. It does not itself create a trust',
'or transfer title. A licensed trust company, Amanah Raya-equivalent body, or lawyer must',
'draft the actual trust deed and complete the title transfer at the Land Office (or',
'equivalent registry) for this to have legal effect and actually bypass probate.',
'',
'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 = 'trust-setup-draft.txt'; a.click();
URL.revokeObjectURL(url);
}
function remove(id) {
@@ -63,10 +100,19 @@
{#each CHANNEL_TYPES as c}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{#if form.type === 'trust'}
<div class="trust-fields">
<p class="trust-note">Land and other illiquid assets have no legal nomination bypass — a trust needs a trustee, not just a "nominee", and title must actually be transferred to them while you're alive for this to work.</p>
<label class="field"><span>Trustee</span><input type="text" bind:value={form.trusteeName} /></label>
<label class="field"><span>Successor trustee</span><input type="text" bind:value={form.successorTrustee} /></label>
<label class="field"><span>Beneficiaries</span><input type="text" bind:value={form.trustBeneficiaries} placeholder="e.g. equal split among children" /></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>
<button class="btn-primary" onclick={addNomination}>Add nomination</button>
{/if}
<button class="btn-primary" onclick={addNomination}>Add {form.type === 'trust' ? 'trust setup' : 'nomination'}</button>
</div>
{#each nominations as n (n.id)}
@@ -75,11 +121,19 @@
<div class="nomination-row">
<div class="nomination-info">
<strong>{asset ? asset.description : '(asset removed)'}</strong>
<span class="muted">{channelInfo?.label} · {n.institution || '—'}</span>
<span class="muted">{channelInfo?.label}{n.type !== 'trust' ? ` · ${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}
<span class="muted">Nominee: {n.nomineeeName} · Ref: {n.referenceNumber || '—'}</span>
{/if}
</div>
<div class="row-actions">
{#if n.type === 'trust'}<button class="export-btn" onclick={() => exportTrustDeed(n)}>Export</button>{/if}
<button onclick={() => remove(n.id)}>✕</button>
</div>
</div>
{:else}
<p class="empty">No nominations logged yet.</p>
{/each}
@@ -103,4 +157,8 @@
.muted { color: #8A8478; font-size: 11.5px; }
.nomination-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
.trust-fields { background: rgba(201,168,76,0.05); border-radius: 10px; padding: 12px; margin-bottom: 12px; }
.trust-note { font-size: 11.5px; color: #C9A84C; line-height: 1.5; margin-bottom: 10px; }
.row-actions { display: flex; gap: 8px; align-items: center; }
.export-btn { background: rgba(46,204,113,0.15); color: #2ECC71; border: none; border-radius: 8px; padding: 6px 10px; font-size: 11px; cursor: pointer; font-weight: 600; }
</style>