/* 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' }) => (
{label}
onChange(e.target.value)}
style={{
width: '100%', background: C.cream, border: `1px solid ${C.earth}40`,
color: C.deep, fontFamily: "'DM Sans',sans-serif", fontSize: 14,
borderRadius: 8, padding: suffix ? '11px 44px 11px 12px' : '11px 12px',
outline: 'none',
}}/>
{suffix && (
{suffix}
)}
);
/* 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 (
);
};
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
setRentMode('manual')}
aria-pressed={rentMode === 'manual'}>
Manual
source.mukim && setRentMode('live')}
disabled={!source.mukim}
aria-pressed={rentMode === 'live'}
title={!source.mukim ? 'Import a valuation with a location to unlock live market data' : `Live listings for ${rentLabel}`}>
Live estimate
{/* 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 && (
)}
{/* 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
)}
{/* No listings found */}
{!rentLoading && !rentError && rentEstimate && rentEstimate.confidence === 'none' && (
No rental listings found for {rentLabel} . Switch to Manual to enter your own estimate.
setRentMode('manual')}
style={{ alignSelf: 'start', cursor: 'pointer', border: `1px solid ${C.earth}`, background: 'transparent', color: C.earth, fontFamily: "'DM Sans',sans-serif", fontSize: 12, fontWeight: 600, borderRadius: 7, padding: '6px 12px', transition: 'background 160ms' }}>
Switch to Manual
)}
{/* Initial — effect not yet fired (sub-frame fallback) */}
{!rentLoading && !rentError && !rentEstimate && progressVal === null && (
Preparing fetch for {rentLabel} …
)}
)}
{/* 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)}/>
))}
+ Add income item
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)}/>
))}
+ Add cost item
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 });