/* eslint-disable no-undef */ const { useState: roiUseState, useMemo: roiUseMemo, useEffect: roiUseEffect, useRef: roiUseRef } = React; const ROI_DEFAULT_SEED = { propertyPrice: 500000, locationLabel: 'Manual property estimate', propertyType: '', sourceModel: 'Manual input', rangeLow: null, rangeHigh: null, mukim: null, scheme: null, district: null, state: null, }; let ROI_UID = 0; const roiUid = () => (ROI_UID += 1); const roiClamp = (value, min, max) => { const n = Number(value); if (!Number.isFinite(n)) return min; return Math.max(min, Math.min(max, n)); }; const roiNum = (value, fallback = 0) => { const n = Number(value); return Number.isFinite(n) ? n : fallback; }; const roiRentSearchLabel = (source) => { if (source.scheme) return source.scheme; if (source.locationLabel && source.locationLabel !== 'Manual property estimate') return source.locationLabel; return source.mukim || 'this area'; }; const roiRentSearchDetail = (source) => { const bits = [source.propertyType, source.district, source.state].filter(Boolean); return bits.join(' · '); }; // Render an empty box instead of a literal 0 so typing starts clean — the // placeholder shows through and there's no leading 0 to delete first. const roiInputValue = (value) => (roiNum(value, 0) === 0 ? '' : value); const roiFmt = (value) => formatRM(Math.round(roiNum(value, 0))); const roiMonthsLabel = (months) => { const m = Math.max(0, Math.round(roiNum(months, 0))); const y = Math.floor(m / 12); const r = m % 12; if (!y) return `${r} mo`; if (!r) return `${y} yr`; return `${y} yr ${r} mo`; }; // "< 1 yr" reads better than "0.0 yr" when rent clears the sunk cost almost at once const roiYearsLabel = (yr) => (yr == null ? null : yr < 1 ? '< 1 yr' : `${yr.toFixed(1)} yr`); const roiMonthlyPayment = (principal, annualRate, years) => { const p = Math.max(0, roiNum(principal, 0)); const months = Math.max(1, Math.round(roiNum(years, 1) * 12)); const rate = Math.max(0, roiNum(annualRate, 0)) / 100 / 12; if (!p) return 0; if (!rate) return p / months; return p * rate / (1 - Math.pow(1 + rate, -months)); }; const roiBuildSchedule = ({ principal, annualRate, years, extraMonthly }) => { const start = Math.max(0, roiNum(principal, 0)); const months = Math.max(1, Math.round(roiNum(years, 1) * 12)); const rate = Math.max(0, roiNum(annualRate, 0)) / 100 / 12; const scheduled = roiMonthlyPayment(start, annualRate, years); const extra = Math.max(0, roiNum(extraMonthly, 0)); let balance = start; let totalInterest = 0; const points = [{ month: 0, balance: start, interest: 0 }]; let month = 0; const maxMonths = months + 1200; while (balance > 0.01 && month < maxMonths) { month += 1; const interest = balance * rate; totalInterest += interest; const due = Math.min(balance + interest, scheduled + extra); const principalPaid = Math.max(0, due - interest); balance = Math.max(0, balance - principalPaid); points.push({ month, balance, interest: totalInterest }); if (!rate && scheduled + extra <= 0) break; } return { points, months: month, monthly: scheduled, totalInterest, totalPaid: start + totalInterest, }; }; const RoiInput = ({ label, value, onChange, suffix, min, max, step = 1, placeholder = '0' }) => ( ); /* One editable [name][amount] line — used for both one-time costs (furnishing, reno…) and extra monthly income. accent tints the remove control. */ const RoiItemRow = ({ name, amount, onName, onAmount, onRemove, accent, namePlaceholder, amountPlaceholder }) => { const field = { width: '100%', background: C.cream, border: `1px solid ${C.earth}40`, color: C.deep, fontFamily: "'DM Sans',sans-serif", fontSize: 13.5, borderRadius: 8, padding: '9px 12px', outline: 'none', }; return (
onName(e.target.value)} style={field}/>
onAmount(e.target.value)} style={{ ...field, padding: '9px 38px 9px 12px', textAlign: 'right' }}/> RM
); }; const RoiMetric = ({ label, value, sub, accent }) => ( {label}
{value}
{sub && (
{sub}
)}
); const RoiTimelineChart = ({ base, extra, principal, hasExtra }) => { const W = 920, H = 280, padL = 74, padR = 28, padTop = 24, padBot = 42; const maxMonth = Math.max( 1, base.points[base.points.length - 1]?.month || 0, extra.points[extra.points.length - 1]?.month || 0, ); const maxAmount = Math.max(1, principal); const x = (month) => padL + (month / maxMonth) * (W - padL - padR); const y = (amount) => padTop + (1 - amount / maxAmount) * (H - padTop - padBot); const path = (points) => points .map((p, i) => `${i ? 'L' : 'M'}${x(p.month).toFixed(1)} ${y(p.balance).toFixed(1)}`) .join(' '); const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => Math.round(maxMonth * t)); const amountTicks = [1, 0.75, 0.5, 0.25, 0].map((t) => Math.round(maxAmount * t)); return ( {amountTicks.map((a) => ( {rmCompact(a)} ))} {ticks.map((m) => ( {Math.round(m / 12)} yr ))} {hasExtra && ( )} {hasExtra && } Remaining principal over time {!hasExtra && ( No extra monthly payment inserted — following the normal schedule. )} normal schedule {hasExtra ? ( with extra payment ) : ( no extra payment added )} ); }; /* ECharts: cumulative rental Income vs Debt outlay (loan + interest + furnishing) over the years. Both rise; where Income overtakes Debt is the break-even — when the portfolio starts to profit. */ const RoiEarningsChart = ({ pts, breakEven, breakEvenValue, loanYears }) => { const elRef = React.useRef(null); const chartRef = React.useRef(null); React.useEffect(() => { if (!window.echarts || !elRef.current) return undefined; const chart = window.echarts.init(elRef.current, null, { renderer: 'canvas' }); chartRef.current = chart; const ro = new ResizeObserver(() => chart.resize()); ro.observe(elRef.current); const t = setTimeout(() => chart.resize(), 300); return () => { clearTimeout(t); ro.disconnect(); chart.dispose(); chartRef.current = null; }; }, []); React.useEffect(() => { const chart = chartRef.current; if (!chart || !window.echarts || !pts || !pts.length) return; const maxT = pts[pts.length - 1].t; const incomeData = pts.map(p => [p.t, Math.round(p.income)]); const paidData = pts.map(p => [p.t, Math.round(p.paid)]); const incGrad = new window.echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: 'rgba(45,122,79,0.26)' }, { offset: 1, color: 'rgba(45,122,79,0)' }, ]); const sign = (v) => (v < 0 ? '−' : '') + rmCompact(Math.abs(v)); chart.setOption({ animationDuration: 1400, backgroundColor: 'transparent', grid: { left: 8, right: 72, top: 28, bottom: 38, containLabel: true }, legend: { top: 0, right: 0, textStyle: { color: C.mid, fontFamily: "'DM Sans',sans-serif", fontSize: 11 }, itemWidth: 18, itemHeight: 10 }, tooltip: { trigger: 'axis', backgroundColor: C.deep, borderColor: C.deep, padding: [8, 10], textStyle: { color: C.cream, fontFamily: "'DM Sans',sans-serif", fontSize: 12 }, axisPointer: { type: 'line', lineStyle: { color: C.earth, width: 1, type: [3, 4] } }, formatter: (ps) => { let s = `
Year ${Math.round(ps[0].value[0])}
`; ps.forEach(p => { s += `
${p.seriesName}: ${sign(p.value[1])}
`; }); const inc = ps.find(p => /Income/.test(p.seriesName)); const dbt = ps.find(p => /Cost|Debt/.test(p.seriesName)); if (inc && dbt) { const gap = inc.value[1] - dbt.value[1]; s += `
${gap >= 0 ? 'Profit' : 'Shortfall'}: ${sign(gap)}
`; } return s; }, }, xAxis: { type: 'value', min: 0, max: maxT, name: 'years', nameLocation: 'middle', nameGap: 26, nameTextStyle: { color: C.mid, fontFamily: "'DM Sans',sans-serif", fontSize: 11 }, axisLine: { lineStyle: { color: C.border } }, axisTick: { show: false }, axisLabel: { color: C.mid, fontFamily: "'JetBrains Mono',monospace", fontSize: 10 }, splitLine: { show: false }, }, yAxis: { type: 'value', min: 0, axisLabel: { color: C.mid, fontFamily: "'JetBrains Mono',monospace", fontSize: 10, formatter: (v) => sign(v) }, splitLine: { lineStyle: { color: C.border, type: [2, 5] } }, }, series: [ { name: 'Income · rentals', type: 'line', smooth: true, showSymbol: false, data: incomeData, lineStyle: { color: C.up, width: 2.8 }, itemStyle: { color: C.up }, areaStyle: { color: incGrad }, z: 3, emphasis: { focus: 'series' }, endLabel: { show: true, distance: 6, formatter: (p) => sign(p.value[1]), color: C.up, fontFamily: "'JetBrains Mono',monospace", fontSize: 11, fontWeight: 700 }, markLine: breakEven != null ? { silent: true, symbol: 'none', lineStyle: { color: C.deep, width: 1.2, type: [4, 4] }, label: { show: true, position: 'insideEndTop', color: C.deep, fontFamily: "'JetBrains Mono',monospace", fontSize: 10, formatter: breakEven < 1 ? 'break-even <1y' : `break-even ${breakEven.toFixed(1)}y` }, data: [{ xAxis: breakEven }], } : undefined, markPoint: breakEven != null ? { symbol: 'pin', symbolSize: 44, symbolOffset: [0, -2], itemStyle: { color: C.up }, label: { show: true, formatter: breakEven < 1 ? '<1y' : `${breakEven.toFixed(1)}y`, color: C.cream, fontFamily: "'DM Sans',sans-serif", fontSize: 10, fontWeight: 700 }, data: [{ coord: [breakEven, Math.round(breakEvenValue || 0)] }], } : undefined, }, { name: 'Cost · interest + one-time', type: 'line', smooth: true, showSymbol: false, data: paidData, lineStyle: { color: C.down, width: 2, type: [6, 4] }, itemStyle: { color: C.down }, z: 2, endLabel: { show: true, distance: 6, formatter: (p) => sign(p.value[1]), color: C.down, fontFamily: "'JetBrains Mono',monospace", fontSize: 11, fontWeight: 700 }, markLine: loanYears != null ? { silent: true, symbol: 'none', lineStyle: { color: C.down, width: 1, type: [2, 4], opacity: 0.6 }, label: { show: true, position: 'insideEndBottom', color: C.down, fontFamily: "'JetBrains Mono',monospace", fontSize: 10, formatter: `loan cleared ${Math.round(loanYears)}y` }, data: [{ xAxis: loanYears }], } : undefined, }, ], }, true); }, [pts, breakEven, breakEvenValue, loanYears]); return
; }; const RoiCalculator = ({ seed }) => { const source = seed && Number(seed.propertyPrice) > 0 ? seed : ROI_DEFAULT_SEED; const rentLabel = roiRentSearchLabel(source); const rentDetail = roiRentSearchDetail(source); const [price, setPrice] = roiUseState(Math.round(source.propertyPrice)); const [depositPct, setDepositPct] = roiUseState(10); const [loanPct, setLoanPct] = roiUseState(90); const [annualRate, setAnnualRate] = roiUseState(4.2); const [years, setYears] = roiUseState(30); const [extraMonthly, setExtraMonthly] = roiUseState(0); // one-time costs that fold into the portfolio outlay (furnishing, reno, legal…) const [costItems, setCostItems] = roiUseState(() => [{ id: roiUid(), name: 'Furnishing', amount: 50000 }]); // income side const [rentalPrice, setRentalPrice] = roiUseState(Math.max(0, Math.round(source.propertyPrice * 0.0035))); const [carparkRent, setCarparkRent] = roiUseState(0); const [incomeItems, setIncomeItems] = roiUseState([]); const [rentEstimate, setRentEstimate] = roiUseState(null); const [rentLoading, setRentLoading] = roiUseState(false); const [rentError, setRentError] = roiUseState(null); const [rentMode, setRentMode] = roiUseState(source.mukim ? 'live' : 'manual'); const [progressVal, setProgressVal] = roiUseState(null); const progressIntervalRef = roiUseRef(null); const startProgress = () => { if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); setProgressVal(0); let val = 0; progressIntervalRef.current = setInterval(() => { // Decelerating fill: fast early, crawls near 85% const remaining = 85 - val; val = Math.min(85, val + Math.max(0.4, remaining * 0.1)); setProgressVal(Math.round(val)); if (val >= 84.9) { clearInterval(progressIntervalRef.current); progressIntervalRef.current = null; } }, 80); }; const completeProgress = () => { if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); progressIntervalRef.current = null; } setProgressVal(100); setTimeout(() => setProgressVal(null), 460); }; const fetchMarketRent = () => { const { mukim, scheme, district, state, propertyType } = source; if (!mukim || rentLoading) return; setRentLoading(true); setRentError(null); startProgress(); window.API.rentComps({ mukim, scheme, district, state, property_type: propertyType, }) .then(data => { setRentEstimate(data); const bestRent = data.median_rent_myr || data.avg_rent_myr; if (bestRent && data.confidence !== 'none') setRentalPrice(Math.round(bestRent)); }) .catch(err => setRentError(err.message || 'Failed to fetch market rent')) .finally(() => { setRentLoading(false); completeProgress(); }); }; roiUseEffect(() => { if (seed && Number(seed.propertyPrice) > 0) { setPrice(Math.round(seed.propertyPrice)); setRentalPrice(Math.max(0, Math.round(seed.propertyPrice * 0.0035))); setRentEstimate(null); setRentError(null); } }, [seed && seed.propertyPrice]); // Auto-fetch when entering live mode (or on first mount when mukim is set) roiUseEffect(() => { if (rentMode === 'live' && source.mukim && !rentEstimate && !rentLoading) { fetchMarketRent(); } }, [rentMode]); const safe = roiUseMemo(() => { const p = roiClamp(price, 1, 100000000); const dep = roiClamp(depositPct, 0, 100); const loan = roiClamp(loanPct, 0, 100); const rate = roiClamp(annualRate, 0, 30); const yrs = roiClamp(years, 1, 40); const extra = Math.max(0, roiNum(extraMonthly, 0)); return { p, dep, loan, rate, yrs, extra }; }, [price, depositPct, loanPct, annualRate, years, extraMonthly]); const oneTimeCost = roiUseMemo( () => costItems.reduce((s, it) => s + Math.max(0, roiNum(it.amount, 0)), 0), [costItems], ); const otherIncome = roiUseMemo( () => incomeItems.reduce((s, it) => s + Math.max(0, roiNum(it.amount, 0)), 0), [incomeItems], ); const monthlyIncome = Math.max(0, roiNum(rentalPrice, 0)) + Math.max(0, roiNum(carparkRent, 0)) + otherIncome; const deposit = safe.p * safe.dep / 100; const principal = safe.p * safe.loan / 100; const baseSchedule = roiUseMemo(() => ( roiBuildSchedule({ principal, annualRate: safe.rate, years: safe.yrs, extraMonthly: 0 }) ), [principal, safe.rate, safe.yrs]); const extraSchedule = roiUseMemo(() => ( roiBuildSchedule({ principal, annualRate: safe.rate, years: safe.yrs, extraMonthly: safe.extra }) ), [principal, safe.rate, safe.yrs, safe.extra]); // Income vs debt over time — the honest break-even. // // The owner pays the FULL installment every month no matter how short the // rent falls, so the loan always clears by the end of its tenure. The // principal you repay turns into equity — you own the unit, it isn't lost — // so rent doesn't have to out-earn the whole loan, only the money that never // comes back: the INTEREST plus the one-time furnishing/reno. Interest stops // the moment the loan is cleared while rent keeps stacking, so there is // ALWAYS a year the rent catches up. That crossing is the real break-even. const roi = roiUseMemo(() => { const M = baseSchedule.monthly; const inc = monthlyIncome; const furnishing = oneTimeCost; const totalInterest = baseSchedule.totalInterest; const loanYears = baseSchedule.months / 12; const sched = baseSchedule.points; // [{ month, balance, interest(cumulative) }] // cumulative interest paid by year t — plateaus at totalInterest once cleared const interestAtYear = (t) => { const idx = Math.min(Math.max(0, Math.round(t * 12)), sched.length - 1); return sched[idx] ? sched[idx].interest : totalInterest; }; const sunkTotal = furnishing + totalInterest; // the plateau rent must beat const beApprox = inc > 0 ? sunkTotal / (inc * 12) : Infinity; const tenureYears = Math.round(safe.yrs); // stretch the horizon past the loan tenure so a slow break-even still shows const horizon = Math.max(tenureYears, Math.min(60, Math.ceil(Number.isFinite(beApprox) ? beApprox + 1 : tenureYears))); const pts = []; let breakEven = null, breakEvenValue = null, prevDiff = null; for (let t = 0; t <= horizon; t++) { const income = inc * 12 * t; // cumulative rentals const paid = furnishing + interestAtYear(t); // sunk cost: one-time + interest const diff = income - paid; // `<= 0` (not `< 0`) so the case where rent already covers the sunk cost // from the very first year — income and cost both start at 0 with no // one-time cost — is caught as break-even ~0 instead of being missed and // mislabelled "rent too low to recover". if (prevDiff !== null && breakEven === null && prevDiff <= 0 && diff >= 0) { const frac = diff === prevDiff ? 0 : (-prevDiff) / (diff - prevDiff); breakEven = (t - 1) + frac; breakEvenValue = inc * 12 * breakEven; // income == cost at the crossing } pts.push({ t, income, paid }); prevDiff = diff; } const netMonthly = inc - M; const grossYield = safe.p ? (inc * 12 / safe.p) * 100 : 0; const coverage = M ? (inc / M) * 100 : 0; const finalProfit = inc * 12 * tenureYears - sunkTotal; // rent out-earned by the time the loan clears const investment = deposit + furnishing; // upfront CASH (deposit + one-time) const roiOnInvestment = investment ? (finalProfit / investment) * 100 : 0; // how long gross rental takes to add up to the upfront cash you put down const upfrontRecoverYears = inc > 0 ? investment / (inc * 12) : null; return { pts, breakEven, breakEvenValue, netMonthly, grossYield, finalProfit, roiOnInvestment, installment: M, income: inc, coverage, totalInterest, furnishing, investment, upfrontRecoverYears, loanYears, tenureYears, horizon }; }, [baseSchedule, deposit, monthlyIncome, oneTimeCost, safe.yrs, safe.p]); const interestSaved = Math.max(0, baseSchedule.totalInterest - extraSchedule.totalInterest); const monthsSaved = Math.max(0, baseSchedule.months - extraSchedule.months); const location = source.locationLabel || ROI_DEFAULT_SEED.locationLabel; const rangeText = source.rangeLow && source.rangeHigh ? `${rmCompact(source.rangeLow)} - ${rmCompact(source.rangeHigh)}` : 'editable estimate'; const onDepositChange = (value) => { const dep = roiClamp(value, 0, 100); setDepositPct(dep); setLoanPct(+(100 - dep).toFixed(2)); }; const onLoanChange = (value) => { const loan = roiClamp(value, 0, 100); setLoanPct(loan); setDepositPct(+(100 - loan).toFixed(2)); }; // line-item helpers shared by the cost + income panels const patchCost = (id, patch) => setCostItems((items) => items.map((it) => (it.id === id ? { ...it, ...patch } : it))); const addCost = () => setCostItems((items) => [...items, { id: roiUid(), name: '', amount: 0 }]); const removeCost = (id) => setCostItems((items) => items.filter((it) => it.id !== id)); const patchIncome = (id, patch) => setIncomeItems((items) => items.map((it) => (it.id === id ? { ...it, ...patch } : it))); const addIncome = () => setIncomeItems((items) => [...items, { id: roiUid(), name: '', amount: 0 }]); const removeIncome = (id) => setIncomeItems((items) => items.filter((it) => it.id !== id)); const addBtnStyle = (accent) => ({ marginTop: 4, alignSelf: 'start', cursor: 'pointer', border: `1px dashed ${accent}`, background: 'transparent', color: accent, fontFamily: "'DM Sans',sans-serif", fontSize: 12.5, fontWeight: 600, borderRadius: 8, padding: '7px 12px', }); const sectionLabel = (color, text) => (
{text}
); return (
ROI Calculator Portfolio cost & income planner
Malaysia loan model
{/* exported valuation context strip */}
Exported valuation {location}
{source.propertyType || 'Property type not selected'}
{source.sourceModel || 'Manual input'} · {rangeText}
{/* INCOME vs COST — two panels side by side (income left, cost right) */}
{/* ── INCOME panel (green-coded) ── */}
{sectionLabel(C.up, 'Income · what you earn')} {roiFmt(monthlyIncome)} / mo
{/* ── Rental price: Manual or Live market estimate ── */}
{/* Mode header row */}
Rental price
{/* Manual mode */} {rentMode === 'manual' && (
setRentalPrice(Math.max(0, roiNum(e.target.value, 0)))} aria-label="Monthly rental price in RM"/> RM / mo
{source.mukim ? (
Enter your estimate, or switch to{' '} setRentMode('live')}> Live estimate {' '} to auto-fill from {rentLabel} listings.
) : (
Enter your expected monthly rental. Import a valuation to unlock live market data.
)}
)} {/* Live mode */} {rentMode === 'live' && (
{/* Progress bar — replaces spinner; shows during load and brief completion flash */} {(rentLoading || progressVal !== null) && (
{progressVal === 100 ? Loaded — listings for {rentLabel} : Looking up listings for {rentLabel} } {rentDetail && progressVal !== 100 && ( {rentDetail} )} {progressVal ?? 0}%
)} {/* Error */} {!rentLoading && rentError && (
{rentError}
)} {/* Success — data with confidence */} {!rentLoading && !rentError && rentEstimate && rentEstimate.confidence !== 'none' && (
Market data · {rentLabel}
{rentDetail && (
{rentDetail}
)} {rentEstimate.confidence} · {rentEstimate.listing_count} listings
{roiFmt(rentEstimate.median_rent_myr || rentEstimate.avg_rent_myr)} / mo {rentEstimate.median_rent_myr ? 'median' : 'avg'}
Range: {roiFmt(rentEstimate.min_rent_myr)} – {roiFmt(rentEstimate.max_rent_myr)} / mo
Applied — or type to override:
setRentalPrice(Math.max(0, roiNum(e.target.value, 0)))} aria-label="Monthly rental — override market estimate"/> RM / mo
)} {/* No listings found */} {!rentLoading && !rentError && rentEstimate && rentEstimate.confidence === 'none' && (
No rental listings found for {rentLabel}. Switch to Manual to enter your own estimate.
)} {/* Initial — effect not yet fired (sub-frame fallback) */} {!rentLoading && !rentError && !rentEstimate && progressVal === null && (
)}
)}
{/* Carpark rental — always visible */} setCarparkRent(Math.max(0, roiNum(v, 0)))} suffix="RM"/> {/* dynamic extra monthly income line items */}
Other monthly income (optional) {incomeItems.length === 0 && (
e.g. storeroom, signage, co-living top-up.
)} {incomeItems.map((it) => ( patchIncome(it.id, { name: v })} onAmount={(v) => patchIncome(it.id, { amount: Math.max(0, roiNum(v, 0)) })} onRemove={() => removeIncome(it.id)}/> ))}
Total monthly income {roiFmt(monthlyIncome)}
{/* ── COST panel (red-coded) ── */}
{sectionLabel(C.down, 'Costs · what you pay')} {roiFmt(deposit + oneTimeCost)} upfront
setPrice(roiClamp(v, 1, 100000000))} suffix="RM"/>
setAnnualRate(roiClamp(v, 0, 30))} suffix="%"/> setYears(roiClamp(v, 1, 40))}/>
{/* extra payment — set apart from the core loan terms: optional, paid on top of the installment to clear the loan faster */}
setExtraMonthly(Math.max(0, roiNum(v, 0)))} suffix="RM"/>
Paid on top of your monthly installment to clear the loan faster and cut total interest. Leave at 0 to keep the normal schedule.
{/* dynamic one-time cost line items */}
One-time costs (furnishing, reno…) {costItems.length === 0 && (
No one-time costs added.
)} {costItems.map((it) => ( patchCost(it.id, { name: v })} onAmount={(v) => patchCost(it.id, { amount: Math.max(0, roiNum(v, 0)) })} onRemove={() => removeCost(it.id)}/> ))}
Total one-time cost {roiFmt(oneTimeCost)}
{/* monthly Income vs Debt summary — the headline A-vs-B comparison */}
Income · monthly rental {roiFmt(roi.income)}
rent + carpark + other / mo
vs
Debt · monthly installment {roiFmt(roi.installment)}
what the bank charges you
Income covers = 100 ? C.up : C.deep }}>{roi.coverage.toFixed(0)}% of the installment = 0 ? C.up : C.down, fontWeight: 600 }}> {roi.netMonthly >= 0 ? `Net +${roiFmt(roi.netMonthly)} / mo in your pocket` : `You top up ${roiFmt(Math.abs(roi.netMonthly))} / mo`}
${roi.horizon} yr`} sub={roi.breakEven != null ? 'rent out-earns interest + costs' : 'rent too low to recover'} accent={roi.breakEven != null ? C.deep : C.down}/> = 0 ? '+' : ''}${roi.roiOnInvestment.toFixed(0)}% on cash in`} accent={roi.finalProfit >= 0 ? C.up : C.down}/>
Income vs debt over time
You repay the loan in full regardless, and the principal becomes equity you keep — so rent only has to out-earn the interest + one-time costs. Interest stops once the loan clears, so rent always catches up: that crossing is break-even.
{roi.breakEven != null ? (roi.breakEven < 1 ? 'recovers within the first year' : `~${roi.breakEven.toFixed(1)} yr to recover`) : 'rent too low to recover'}
Loan timeline
{safe.extra > 0 ? 'Normal schedule compared with recurring extra monthly payment.' : 'Remaining loan principal over the tenure.'}
{safe.extra > 0 && ( {roiFmt(interestSaved)} saved · {roiMonthsLabel(monthsSaved)} faster )}
{safe.extra <= 0 && (
i No extra monthly payment added. The chart below shows your normal schedule only. Type an amount into Extra monthly payment (in the Costs panel) to see how much faster you'd clear the loan and how much interest you'd save.
)}
0}/>
{/* upfront cash payback — the deposit + one-time is real money out; how long gross rental takes to add back up to it */}
Upfront cash {roiFmt(roi.investment)} (deposit + one-time) — real money out of pocket {roi.upfrontRecoverYears != null ? `Gross rental collects it back in ~${roi.upfrontRecoverYears.toFixed(1)} yr` : 'Add rental income to recover it'}
); }; Object.assign(window, { RoiCalculator });