// bridge-page.jsx — the earnings-bridge calculator. // Free: everything on screen. Paid (Deal Pack / Complete): one-page PDF (print) + Excel with live formulas. // 🔴 Nothing the user types leaves the browser: state lives in localStorage only, no network calls with user data. const BRIDGE_KEY = 'vc_bridge_v1'; const CLASSES = ['Defensible', 'Partly defensible', 'Not defensible']; const CATEGORIES = ['A · Owner', 'B · Family', 'C · Personal', 'D · Related party', 'E · One-off', 'F · Other']; const HINTS = { 'A · Owner': 'What the owner takes, minus what it would cost to hire someone to do the same work. Underpaid owners give a negative number.', 'B · Family': 'What family members are paid, minus the market value of the work they actually do.', 'C · Personal': 'The personal share of the car, travel and card costs run through the company. Usually a positive add-back.', 'D · Related party': 'Rent (or royalties) paid to the owner, minus the market rate. Below-market rent gives a negative number.', 'E · One-off': 'One-off costs are added back (+). One-off income is taken out (−). Apply it both ways.', 'F · Other': 'Anything else that will not continue under a new owner.', }; const blankLine = (cat = 'A · Owner') => ({ id: Math.random().toString(36).slice(2, 9), item: '', cat, cls: 'Defensible', amount: '', reason: '' }); const EMPTY = () => ({ business: '', period: '', preparer: '', reported: '', lines: [blankLine('A · Owner'), blankLine('B · Family'), blankLine('C · Personal'), blankLine('D · Related party')], low: '', high: '', asking: '' }); const CASE047 = () => ({ business: 'Teaching case №047 (illustrative)', period: 'FY', preparer: '', reported: '1200000', lines: [ { id: 'a', item: 'Owner remuneration vs replacement cost', cat: 'A · Owner', cls: 'Defensible', amount: '-180000', reason: 'The two founders paid themselves well below the market cost of the roles they fill; a buyer has to pay for that work.' }, { id: 'b', item: 'Family members on payroll', cat: 'B · Family', cls: 'Partly defensible', amount: '-220000', reason: 'Work done by family at below-market pay will cost more under a new owner.' }, { id: 'c', item: 'Personal expenses through the company', cat: 'C · Personal', cls: 'Defensible', amount: '-95000', reason: 'Range Rover lease $34k, personal travel $18.6k, family medical $42.1k (figures as published in the case).' }, { id: 'd', item: 'Below-market related-party rent', cat: 'D · Related party', cls: 'Defensible', amount: '-225000', reason: 'The owner owns the building; a new owner pays a market lease.' }, ], low: '2.1', high: '4.5', asking: '5400000', }); const num = (s) => { const n = parseFloat(String(s).replace(/[^0-9.\-]/g, '')); return isFinite(n) ? n : 0; }; const money = (n) => (n < 0 ? '−' : '') + '$' + Math.round(Math.abs(n)).toLocaleString('en-NZ'); const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '') + Math.round(Math.abs(n)).toLocaleString('en-NZ'); const mult = (n) => (isFinite(n) && n > 0 ? n.toFixed(2) + '×' : '—'); function computeBridge(d) { const reported = num(d.reported); const lines = d.lines.map(l => ({ ...l, value: num(l.amount), counted: l.cls === 'Not defensible' ? 0 : num(l.amount) })); const counted = lines.reduce((a, l) => a + l.counted, 0); const transferable = reported + counted; const low = num(d.low), high = num(d.high), asking = num(d.asking); return { reported, lines, counted, transferable, low, high, asking, valueLow: transferable * low, valueHigh: transferable * high, reportedLow: reported * low, reportedHigh: reported * high, impliedReported: asking > 0 && reported > 0 ? asking / reported : NaN, impliedTransferable: asking > 0 && transferable > 0 ? asking / transferable : NaN }; } function Field({ label, children, hint }) { const v4 = window.v4; return ( ); } const inputStyle = (extra) => ({ width: '100%', boxSizing: 'border-box', padding: '11px 12px', fontSize: 16, fontFamily: window.v4.sans, border: `1.5px solid ${window.v4.ink}`, background: '#fff', color: window.v4.ink, outline: 'none', borderRadius: 0, ...extra }); function V4Bridge() { const v4 = window.v4; const accent = v4.yellow; const accent2 = v4.red; const access = window.useVCAccess(); const [d, setD] = React.useState(() => { try { const s = localStorage.getItem(BRIDGE_KEY); if (s) return JSON.parse(s); } catch (e) {} return EMPTY(); }); const [busy, setBusy] = React.useState(''); const [err, setErr] = React.useState(''); React.useEffect(() => { try { localStorage.setItem(BRIDGE_KEY, JSON.stringify(d)); } catch (e) {} }, [d]); const r = computeBridge(d); const set = (k, v) => setD(x => ({ ...x, [k]: v })); const setLine = (id, k, v) => setD(x => ({ ...x, lines: x.lines.map(l => (l.id === id ? { ...l, [k]: v } : l)) })); const addLine = () => setD(x => ({ ...x, lines: [...x.lines, blankLine('F · Other')] })); const removeLine = (id) => setD(x => ({ ...x, lines: x.lines.filter(l => l.id !== id) })); const hasNumbers = r.reported !== 0; const exportExcel = async () => { setErr(''); setBusy('excel'); try { await window.VCWorkbooks.download('bridge', { business: d.business, period: d.period, reported: r.reported, low: r.low || 3, high: r.high || 4, asking: r.asking, lines: r.lines.filter(l => l.item || l.value).map(l => ({ item: l.item, cat: l.cat, cls: l.cls, amount: l.value, reason: l.reason })), fileName: 'Earnings-Bridge' + (d.business ? '-' + d.business.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) : '') + '.xlsx', }); } catch (e) { setErr(e.message || 'Export failed.'); } setBusy(''); }; const card = { border: `1.5px solid ${v4.ink}`, background: v4.paper, padding: '22px 20px', marginBottom: 18 }; const btn = (bg, color, extra) => ({ background: bg, color, border: `1.5px solid ${v4.ink}`, padding: '12px 16px', fontFamily: v4.mono, fontSize: 12, fontWeight: 700, letterSpacing: 1.5, textTransform: 'uppercase', cursor: 'pointer', ...extra }); const maxBar = Math.max(1, Math.abs(r.reported), Math.abs(r.transferable), ...r.lines.map(l => Math.abs(l.counted))); return (
Start from the EBITDA in the accounts. Add or take out each line a new owner would see differently, and say why. The calculator turns it into transferable EBITDA and a value range. Nothing you type is sent anywhere — it is saved in this browser only.
| Line | Category | Classification | Amount | Reason |
|---|---|---|---|---|
| Reported EBITDA | {money(r.reported)} | |||
| {l.item || '—'} | {l.cat} | {l.cls}{l.cls === 'Not defensible' ? ' (not counted)' : ''} | {signed(l.value)} | {l.reason} |
| Adjustments counted | {signed(r.counted)} | |||
| Transferable EBITDA | {money(r.transferable)} |