diff --git a/e2e-trust.cjs b/e2e-trust.cjs new file mode 100644 index 0000000..6179861 --- /dev/null +++ b/e2e-trust.cjs @@ -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); }); diff --git a/src/lib/NominationRegistry.svelte b/src/lib/NominationRegistry.svelte index 5bb5e38..6e96fb8 100644 --- a/src/lib/NominationRegistry.svelte +++ b/src/lib/NominationRegistry.svelte @@ -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}{/each} - - - - + {#if form.type === 'trust'} +
+

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.

+ + + +
+ {:else} + + + + {/if} + {#each nominations as n (n.id)} @@ -75,10 +121,18 @@
{asset ? asset.description : '(asset removed)'} - {channelInfo?.label} · {n.institution || '—'} - Nominee: {n.nomineeeName} · Ref: {n.referenceNumber || '—'} + {channelInfo?.label}{n.type !== 'trust' ? ` · ${n.institution || '—'}` : ''} + {#if n.type === 'trust'} + Trustee: {n.trusteeName} · Successor: {n.successorTrustee || '—'} + Beneficiaries: {n.trustBeneficiaries || '—'} + {:else} + Nominee: {n.nomineeeName} · Ref: {n.referenceNumber || '—'} + {/if} +
+
+ {#if n.type === 'trust'}{/if} +
-
{:else}

No nominations logged yet.

@@ -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; }