diff --git a/e2e-business-check.cjs b/e2e-business-check.cjs new file mode 100644 index 0000000..1857dbb --- /dev/null +++ b/e2e-business-check.cjs @@ -0,0 +1,34 @@ +const { chromium } = require('playwright'); +const BASE = 'https://moslem04.falahos.my/'; + +async function main() { + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await page.goto(BASE, { waitUntil: 'networkidle' }); + const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); }; + + await clickTab('Assets'); + await page.locator('.field:has-text("Description") input').fill('Bakery Sdn Bhd shares'); + await page.locator('.field:has-text("Estimated value") input').fill('300000'); + await page.locator('.form-card select').first().selectOption('Business interest'); + await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); + await page.waitForTimeout(200); + + await clickTab('Coverage'); + const rowText = await page.locator('.asset-row', { hasText: 'Bakery' }).textContent(); + console.log('Coverage row for business asset:', rowText.trim()); + + await clickTab('Nominate'); + const assetSelect = page.locator('select').first(); + const val = await assetSelect.evaluate(el => Array.from(el.options).find(o => o.textContent.includes('Bakery'))?.value); + await assetSelect.selectOption(val); + await page.waitForTimeout(200); + const suggestionText = await page.locator('.suggestion').textContent().catch(() => 'NO SUGGESTION'); + console.log('Suggestion for business asset:', suggestionText.trim()); + + const channelOptions = await page.locator('.form-card select').nth(1).locator('option').allTextContents(); + console.log('Available channel types:', channelOptions); + + await browser.close(); +} +main(); diff --git a/e2e-business.cjs b/e2e-business.cjs new file mode 100644 index 0000000..28cfe2d --- /dev/null +++ b/e2e-business.cjs @@ -0,0 +1,117 @@ +// Verifies business interests get covered via a proper continuity instrument, +// including the sole-proprietorship warning and the waqf-enterprise redirect. +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); + }; + + // Add a company-shares business asset + await clickTab('Assets'); + await page.locator('.field:has-text("Description") input').fill('Bakery Sdn Bhd shares'); + await page.locator('.field:has-text("Estimated value") input').fill('300000'); + await page.locator('.form-card select').first().selectOption('Business interest'); + await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); + await page.waitForTimeout(200); + + await clickTab('Coverage'); + const rowBefore = await page.locator('.asset-row', { hasText: 'Bakery' }).textContent(); + record('Coverage: business interest initially exposed with continuity suggestion', rowBefore.includes('Not covered') && /business continuity/i.test(rowBefore), rowBefore.trim().slice(0, 200)); + + // Go to Nominate, select business-continuity channel + await clickTab('Nominate'); + await selectByText(page.locator('select').first(), 'Bakery'); + await page.waitForTimeout(200); + await page.locator('.form-card select').nth(1).selectOption('business-continuity'); + await page.waitForTimeout(200); + + const businessFieldsVisible = await page.locator('.trust-fields').isVisible(); + record('Nomination: business continuity fields appear', businessFieldsVisible); + + // Default structure = company, default instrument = shareholder-buy-sell — no warning expected + const warningVisibleForCompany = await page.locator('.business-warning').isVisible().catch(() => false); + record('Nomination: no sole-prop warning shown for company structure', !warningVisibleForCompany); + + // Switch to sole proprietorship -> warning should appear + await page.locator('.trust-fields select').first().selectOption('sole-prop'); + await page.waitForTimeout(200); + const soleWarningVisible = await page.locator('.business-warning').first().isVisible(); + const soleWarningText = await page.locator('.business-warning').first().textContent(); + record('Nomination: sole-proprietorship shows "dies with you" warning', soleWarningVisible && soleWarningText.includes('dies with you'), soleWarningText.trim().slice(0, 150)); + + // Switch back to company, pick shareholder buy-sell, fill and save + await page.locator('.trust-fields select').first().selectOption('company'); + await page.waitForTimeout(150); + const instrumentSelect = page.locator('.trust-fields select').nth(1); + await instrumentSelect.selectOption('shareholder-buy-sell'); + await page.waitForTimeout(150); + + const inputs = page.locator('.trust-fields .field input'); + await inputs.nth(0).fill('Co-founder Rahman'); + await inputs.nth(1).fill('Fair market valuation, Takaful-funded'); + await page.locator('button.btn-primary', { hasText: 'Add business continuity instrument' }).click(); + await page.waitForTimeout(300); + + const rowCreated = await page.locator('.nomination-row', { hasText: 'Bakery' }).isVisible(); + record('Nomination: business continuity row created', rowCreated); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.locator('.nomination-row', { hasText: 'Bakery' }).locator('.export-btn').click() + ]); + record('Nomination: business continuity export downloads', download.suggestedFilename() === 'business-continuity-draft.txt', download.suggestedFilename()); + + // Coverage should now show 100% + await clickTab('Coverage'); + const pct = await page.locator('.big-percent').textContent(); + record('Coverage: business interest now covered (100%)', pct.trim() === '100%', pct); + + // Waqf-enterprise redirect path: try selecting it and confirm Add button disables + await clickTab('Nominate'); + await page.locator('.form-card select').nth(1).selectOption('business-continuity'); + await page.waitForTimeout(150); + await page.locator('.trust-fields select').nth(1).selectOption('waqf-enterprise'); + await page.waitForTimeout(150); + const redirectNoteVisible = await page.locator('.business-warning', { hasText: 'Family Waqf Designator' }).isVisible(); + const addDisabled = await page.locator('button.btn-primary', { hasText: 'business continuity' }).isDisabled(); + record('Nomination: waqf-enterprise option redirects to Family Waqf, disables Add', redirectNoteVisible && addDisabled); + + // Death Trigger packet for business asset + await clickTab('Trigger'); + const attestorInputs = page.locator('.attestor-row input'); + await attestorInputs.nth(0).fill('Executor A'); + await page.locator('.attestor-row .confirm-btn').nth(0).click(); + await attestorInputs.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-BIZ-001'); + await page.waitForTimeout(200); + await page.locator('button.btn-danger-solid').click(); + await page.waitForTimeout(300); + + const packetForBiz = await page.locator('.packet-row', { hasText: 'Bakery' }).isVisible(); + record('Death Trigger: execution packet generated for business via continuity instrument', packetForBiz); + + 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/CoverageDashboard.svelte b/src/lib/CoverageDashboard.svelte index 2dce8e1..c988121 100644 --- a/src/lib/CoverageDashboard.svelte +++ b/src/lib/CoverageDashboard.svelte @@ -14,6 +14,7 @@ takaful: 'Takaful / insurance nomination', 'bank-mandate': 'Bank death mandate', trust: 'Trust / nominee holding', + 'business-continuity': 'Business continuity instrument', 'hibah-flagged-capped': 'Hibah flagged (marad al-mawt) — capped, partially exposed', none: 'Not covered — exposed to Faraid/probate' }; diff --git a/src/lib/DeathTrigger.svelte b/src/lib/DeathTrigger.svelte index 80196f2..5dce845 100644 --- a/src/lib/DeathTrigger.svelte +++ b/src/lib/DeathTrigger.svelte @@ -77,6 +77,8 @@ ? 'ACTION: File a death claim with the Takaful/insurance provider. As a nominated\ntrust under Insurance Act 1996 s.166 (or Takaful equivalent), payout goes directly\nto the named beneficiary — no probate required.' : row.channel === 'bank-mandate' ? '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.' : '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;', diff --git a/src/lib/NominationRegistry.svelte b/src/lib/NominationRegistry.svelte index 6e96fb8..fbc9bb3 100644 --- a/src/lib/NominationRegistry.svelte +++ b/src/lib/NominationRegistry.svelte @@ -19,10 +19,22 @@ { value: 'epf', label: 'EPF nomination', note: 'Pays the nominee directly under EPF Act 1991 s.51 — bypasses probate by law.' }, { 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: '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.' } ]; - const emptyForm = () => ({ linkedAssetId: '', type: 'epf', institution: '', nomineeeName: '', referenceNumber: '', trusteeName: '', successorTrustee: '', trustBeneficiaries: '' }); + const BUSINESS_STRUCTURES = [ + { value: 'sole-prop', label: 'Sole proprietorship', warning: 'A sole proprietorship legally dies with you — there is no entity left to transfer. The only fast-path options are converting it to a company with a shareholder agreement, or dedicating it as a waqf-owned enterprise now, while alive.' }, + { value: 'partnership', label: 'Partnership', warning: null }, + { value: 'company', label: 'Company (shares)', warning: null } + ]; + const BUSINESS_INSTRUMENTS = [ + { value: 'partnership-continuation', label: 'Partnership continuation clause', note: 'Prevents automatic dissolution on a partner\'s death — but does not by itself transfer the deceased partner\'s share; pair it with a buy-sell agreement for that.' }, + { value: 'shareholder-buy-sell', label: 'Shareholder agreement with buy-sell provision', note: 'Pre-agreed, often Takaful-funded, automatic buyout of the deceased\'s shares by co-shareholders at a pre-set valuation — the standard fast-path for company shares.' }, + { 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: '' }); let nominations = $state(load('nominations', [])); let form = $state(emptyForm()); @@ -31,6 +43,9 @@ if (!form.linkedAssetId) return; if (form.type === 'trust') { if (!form.trusteeName) return; + } 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.nomineeeName) { return; } @@ -39,6 +54,37 @@ form = emptyForm(); } + function exportBusinessContinuity(n) { + const asset = assets.find(a => a.id === n.linkedAssetId); + const structureLabel = BUSINESS_STRUCTURES.find(s => s.value === n.businessStructure)?.label; + const instrumentLabel = BUSINESS_INSTRUMENTS.find(i => i.value === n.businessInstrument)?.label; + const lines = [ + 'BUSINESS CONTINUITY INSTRUMENT — DRAFTING AID', + `Generated: ${new Date().toISOString().slice(0, 10)}`, + `Business: ${asset ? asset.description : '(asset removed)'}`, + `Structure: ${structureLabel}`, + `Instrument: ${instrumentLabel}`, + `Successor / buyer: ${n.successorOwner}`, + `Terms: ${n.buySellTerms || '________________________'}`, + '', + n.businessInstrument === 'shareholder-buy-sell' + ? 'ACTION: this shares are transferred per a pre-agreed buy-sell provision in the\nshareholder agreement, often funded by a Takaful/insurance payout timed to the\nbuyout value. Once the agreement is executed and (ideally) funded, the transfer\nat death is a private buyout between shareholders — not a probate filing.' + : 'ACTION: a partnership continuation clause prevents automatic dissolution on\ndeath, but the deceased partner\'s share itself still needs a buy-sell provision\nor a will-directed transfer to actually move — pair this with a buy-sell\nagreement for full coverage.', + '', + 'ACTION REQUIRED: this document is a drafting aid only. A lawyer must draft the', + 'actual shareholder/partnership agreement, and it must be signed and (for a', + 'buy-sell) ideally funded by a Takaful/insurance policy while all parties are', + 'alive 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 = 'business-continuity-draft.txt'; a.click(); + URL.revokeObjectURL(url); + } + function exportTrustDeed(n) { const asset = assets.find(a => a.id === n.linkedAssetId); const lines = [ @@ -107,12 +153,37 @@ + {:else if form.type === 'business-continuity'} +
+ + {#if BUSINESS_STRUCTURES.find(s => s.value === form.businessStructure)?.warning} +

{BUSINESS_STRUCTURES.find(s => s.value === form.businessStructure).warning}

+ {/if} + + +

{BUSINESS_INSTRUMENTS.find(i => i.value === form.businessInstrument)?.note}

+ + {#if form.businessInstrument === 'waqf-enterprise'} +

Go to the Family Waqf Designator tab and select this business as the corpus asset — nothing to save here.

+ {:else} + + + {/if} +
{:else} {/if} - + {#each nominations as n (n.id)} @@ -121,16 +192,20 @@
{asset ? asset.description : '(asset removed)'} - {channelInfo?.label}{n.type !== 'trust' ? ` · ${n.institution || '—'}` : ''} + {channelInfo?.label}{n.type !== 'trust' && n.type !== 'business-continuity' ? ` · ${n.institution || '—'}` : ''} {#if n.type === 'trust'} Trustee: {n.trusteeName} · Successor: {n.successorTrustee || '—'} Beneficiaries: {n.trustBeneficiaries || '—'} + {:else if n.type === 'business-continuity'} + {BUSINESS_INSTRUMENTS.find(i => i.value === n.businessInstrument)?.label} + Successor: {n.successorOwner} · Terms: {n.buySellTerms || '—'} {:else} Nominee: {n.nomineeeName} · Ref: {n.referenceNumber || '—'} {/if}
{#if n.type === 'trust'}{/if} + {#if n.type === 'business-continuity'}{/if}
@@ -161,4 +236,5 @@ .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; } + .business-warning { font-size: 11.5px; color: #EF4444; background: rgba(239,68,68,0.08); border-radius: 8px; padding: 8px 10px; margin-bottom: 12px; line-height: 1.5; } diff --git a/src/lib/nonprobate.js b/src/lib/nonprobate.js index 4dc9576..9f85259 100644 --- a/src/lib/nonprobate.js +++ b/src/lib/nonprobate.js @@ -63,7 +63,7 @@ export function computeCoverage() { 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: 'trust', label: 'Pre-funded trust / nominee holding', note: 'Business interests generally need a shareholder agreement or trust structure set up now — a legal step beyond this app.' }; + 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.' }; 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.' };