Cover business interests via a proper continuity instrument

Same gap as land parcels: business assets were routed into the generic
trust fields (trustee/successor/beneficiaries), which don't fit — a
business needs a continuity instrument, not a trustee holding title.

New "business-continuity" channel in Nomination Registry, modelled on the
digital-waqif docs repo's own business-succession framework (Clause 9):
- Structure selector (sole proprietorship / partnership / company). Sole
  proprietorship surfaces an explicit warning: it legally dies with the
  owner, so the only fast-path options are converting to a company or
  dedicating as a waqf-owned enterprise.
- Instrument selector: partnership continuation clause, shareholder
  buy-sell agreement (the standard fast-path for company shares, often
  Takaful-funded), or a direct redirect to Family Waqf Designator for a
  waqf-owned enterprise (nothing duplicated here — reuses that module).
- Dedicated business-continuity-draft.txt export, same treatment as the
  trust deed and waqfiyya exports.
- Death Trigger packet text updated with the business-specific execution
  action (execute buy-sell / continuation clause, file Takaful claim if
  funded — a private agreement between owners, not a probate filing).

e2e-business.cjs: new suite covering exposed->warning->instrument
selection->export->100% coverage->execution packet, plus the
waqf-enterprise redirect disabling the Add button. 10/10 passing.
Re-verified e2e-uat.cjs (32/32), e2e-fastpath.cjs (16/16), and
e2e-trust.cjs (12/12) — 70/70 total, no regressions.
This commit is contained in:
wmj
2026-08-13 17:32:32 +08:00
parent ff47dc9ed2
commit 051c9d9a94
6 changed files with 235 additions and 5 deletions
+34
View File
@@ -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();
+117
View File
@@ -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); });
+1
View File
@@ -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'
};
+2
View File
@@ -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;',
+80 -4
View File
@@ -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 @@
<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 if form.type === 'business-continuity'}
<div class="trust-fields">
<label class="field"><span>Business structure</span>
<select bind:value={form.businessStructure}>
{#each BUSINESS_STRUCTURES as s}<option value={s.value}>{s.label}</option>{/each}
</select>
</label>
{#if BUSINESS_STRUCTURES.find(s => s.value === form.businessStructure)?.warning}
<p class="business-warning">{BUSINESS_STRUCTURES.find(s => s.value === form.businessStructure).warning}</p>
{/if}
<label class="field"><span>Continuity instrument</span>
<select bind:value={form.businessInstrument}>
{#each BUSINESS_INSTRUMENTS as i}<option value={i.value}>{i.label}</option>{/each}
</select>
</label>
<p class="trust-note">{BUSINESS_INSTRUMENTS.find(i => i.value === form.businessInstrument)?.note}</p>
{#if form.businessInstrument === 'waqf-enterprise'}
<p class="business-warning">Go to the Family Waqf Designator tab and select this business as the corpus asset — nothing to save here.</p>
{:else}
<label class="field"><span>{form.businessInstrument === 'shareholder-buy-sell' ? 'Buying co-shareholder(s)' : 'Successor partner'}</span><input type="text" bind:value={form.successorOwner} /></label>
<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}
<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" onclick={addNomination}>Add {form.type === 'trust' ? 'trust setup' : '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' : 'nomination'}</button>
</div>
{#each nominations as n (n.id)}
@@ -121,16 +192,20 @@
<div class="nomination-row">
<div class="nomination-info">
<strong>{asset ? asset.description : '(asset removed)'}</strong>
<span class="muted">{channelInfo?.label}{n.type !== 'trust' ? ` · ${n.institution || '—'}` : ''}</span>
<span class="muted">{channelInfo?.label}{n.type !== 'trust' && n.type !== 'business-continuity' ? ` · ${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}
<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}
{#if n.type === 'business-continuity'}<button class="export-btn" onclick={() => exportBusinessContinuity(n)}>Export</button>{/if}
<button onclick={() => remove(n.id)}>✕</button>
</div>
</div>
@@ -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; }
</style>
+1 -1
View File
@@ -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.' };