RefinanceCalc
Compares a loan with a new one month by month: payment, closing costs, break-even month, interest over the stay, and when it is not worth it.
Demo
Refinance
Worth it: after 84 months you are $16,500.58 ahead.
- Current payment
- $1,688.02
- New payment
- $1,498.12
- Saved each month
- $189.90
- Break-even month
- Month 20
- Interest over your stay, current loan
- $106,400.77
- Interest over your stay, new loan
- $84,900.19
The lower payment alone repays the costs in month 27.
Longer term, costs rolled in
Worth it: after 120 months you are €1,294.49 ahead.
- Current payment
- €1,395.54
- New payment
- €1,103.17
- Saved each month
- €292.37
- Break-even month
- Month 37
- Interest over your stay, current loan
- €107,657.08
- Interest over your stay, new loan
- €102,362.59
Kept to the end, the new loan costs €62,215.48 more in interest and costs than the current one.
Change something to see on_change.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/refinance_calc.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/refinance_calc.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/refinance_calc.qwe
component RefinanceCalcDemoLive() { state note = "Change something to see on_change." <div.demo-stack> <RefinanceCalc id="refinance-calc-demo-live" name="second" title="Longer term, costs rolled in" balance="180000" rate={7} months={240} new_rate={6} new_months={360} costs="4000" rolled={true} stay={120} currency="EUR" on_change={(r) => { note = `on_change: saves ${r.monthly_saving} a month, month ${r.break_even}, ${r.worth ? "worth it" : "not worth it"}.` }}/> <p.demo-note>{note}</p> </div>}view RefinanceCalcDemo() = <div.demo-stack> <RefinanceCalc id="refinance-calc-demo" balance="250000" rate={6.5} months={300} new_rate={5.25} new_months={300} costs="5000" stay={84}/> <RefinanceCalcDemoLive/> </div>Source
ui/refinance_calc.qwe
// RefinanceCalc: whether replacing a loan pays. It runs both loans month by month, each// month's interest rounded half up to the cent and the last payment taking up what is// left, and compares them as someone leaving at that month would: what has been paid so far// plus what is still owed, closing costs included. Comparing payments alone misleads when// a new term is longer, as a lower payment then hides a balance falling more slowly, and// when costs are rolled into the loan, as nothing is paid up front. The break-even month// is the first where the new loan is ahead that way, and how long the payments alone take// to cover the costs is shown too. It says when the move is not worth it and why: the// stay ends before it breaks even, or it never does.//// <RefinanceCalc balance="250000" rate={6.5} months={300} new_rate={5.25} new_months={300} costs="5000" stay={84}/>// <RefinanceCalc balance="180000" rate={7} months={240} new_rate={6} new_months={360} costs="4000" rolled={true}// stay={120} currency="EUR" on_change={(r) => { worth = r.worth }}/>//// balance, rate, months: the current loan: what is owed, the yearly percent and the months// left. new_rate, new_months: the new one. costs: closing costs, paid at the start, or added// to the new loan with rolled. stay: how many months the loan is kept. Rates go up to 100,// months up to 600. currency: ISO code ("USD"; one Quillo does not know falls back to it).// locale: how to write amounts (the program's). title: the heading ("Refinance"). id: a prefix// for its ids ("refinance-calc"), unique on the page. name: a prefix for the names of its// fields ("refinance": refinance_balance, refinance_rate, refinance_months, refinance_new_rate,// refinance_new_months, refinance_costs, refinance_rolled, refinance_stay).// on_change(r): inside one of your components, after each change, given { current_payment,// new_payment, monthly_saving, break_even, costs_covered, interest_current, interest_new, ahead,// worth } when the figures are good: decimals but for break_even and costs_covered, months or// null, and worth, true or false. class: more classes for its outer element.//// refinance_calc_payment(balance, rate, months) and refinance_calc_compare(...) are the logic,// in whole minor units, for a program's own use.import ui/theme.qwe use ui_clslet refinance_calc_css = css`.ui-refinance-calc { container-type: inline-size; display: grid; gap: 16px; min-width: 0; max-width: 680px; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-refinance-calc .ui-refinance-calc-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-refinance-calc-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }.ui-refinance-calc-side { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-refinance-calc-side legend { padding: 0 4px; font-size: 13px; font-weight: 600; }.ui-refinance-calc-field { display: grid; gap: 4px; min-width: 0; }.ui-refinance-calc-field label { font-size: 13px; font-weight: 500; }.ui-refinance-calc-field input { box-sizing: border-box; width: 100%; min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); font-variant-numeric: tabular-nums; }.ui-refinance-calc-field input:hover { border-color: var(--ui-strong); }.ui-refinance-calc-field input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-refinance-calc-field input[aria-invalid] { border-color: var(--ui-danger); }.ui-refinance-calc-check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; }.ui-refinance-calc-check input { margin: 0; accent-color: var(--ui-accent); }.ui-refinance-calc-errors { margin: 0; padding: 0; list-style: none; color: var(--ui-danger); font-size: 13px; }.ui-refinance-calc-out { display: grid; gap: 12px; padding: 12px 16px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-refinance-calc-verdict { margin: 0; padding: 8px 10px; border-radius: var(--ui-radius-sm); font-weight: 600; }.ui-refinance-calc-good { background: var(--ui-success-soft); color: var(--ui-success); }.ui-refinance-calc-poor { background: var(--ui-warning-soft); color: var(--ui-warning); }.ui-refinance-calc-sums { display: grid; grid-template-columns: 1fr auto; gap: 4px 16px; margin: 0; }.ui-refinance-calc-sums dt { color: var(--ui-muted); }.ui-refinance-calc-sums dd { margin: 0; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-refinance-calc-note { margin: 0; color: var(--ui-muted); font-size: 13px; }`// The currency's code, USD when Quillo does not know it.fn refinance_calc_code(currency) -> str { let c = upper(trim(str(currency ?? ""))) type_of(money.minor_units(c)) == "error" ? "USD" : c}// What was typed as a decimal: 0 when empty, null when it is not a plain number from// 0 to most. A comma counts only as a thousands comma, so "12,50" is refused, not 1250.fn refinance_calc_read(text, most) { let t = trim(str(text ?? "")) if t == "" { return 0m } if len(t) > 40 { return null } let plain = re.test(t, "^[0-9]{1,3}(,[0-9]{3})+(\\.[0-9]*)?$") ? replace(t, ",", "") : t if !re.test(plain, "^([0-9]+\\.?[0-9]*|\\.[0-9]+)$") { return null } let d = dec(plain) is_err(d) || d > most ? null : d}// A whole number of months from 1 to 600 as typed, or null.fn refinance_calc_months(text) { let d = refinance_calc_read(text, 600m) d == null || d < 1m || d != floor(d) ? null : int(d)}// x to the power n, a whole n, by squaring.fn refinance_calc_pow(x: dec, n: int) -> dec { let out = 1m let base = x let left = n while left > 0 { if left % 2 == 1 { out = out * base } left = int(floor(left / 2)) if left > 0 { base = base * base } } out}// The monthly payment in minor units: balance × r / (1 − (1 + r)^−n), half up, written so// that the power stays a divisor (at 100% over 50 years it is too large to multiply by).fn refinance_calc_payment(balance: int, rate: dec, months: int) -> int { if rate <= 0m { return int(ceil(dec(balance) / months)) } let r = rate / 1200 let f = refinance_calc_pow(1m + r, months) int(round(dec(balance) * r / (1m - 1m / f), 0, "half_up"))}// The loan month by month: [{ month, interest, paid, owed }], the last payment taking what is left.fn refinance_calc_schedule(balance: int, rate: dec, months: int) -> list { let pay = refinance_calc_payment(balance, rate, months) let owed = balance let rows = [] let m = 1 while m <= months && owed > 0 { let interest = int(round(dec(owed) * rate / 1200, 0, "half_up")) let paid = m == months || owed + interest < pay ? owed + interest : pay owed = owed + interest - paid rows = rows | push({ month: m, interest, paid, owed }) m += 1 } rows}// The month's paid and interest so far, and what is owed, past the end as at the end.fn refinance_calc_running(rows: list) -> list { let paid = 0 let interest = 0 let out = [] for r in rows { paid += r.paid interest += r.interest out = out | push({ paid, interest, owed: r.owed }) } out}// Both loans compared. Amounts in minor units. Gives { current_payment, new_payment,// break_even, costs_covered, interest_current, interest_new, ahead, whole_extra, reason }:// break_even is the first month where the new loan is ahead, counting all paid and all// still owed, or null; ahead is that lead at the end of the stay (below 0 when behind);// whole_extra what the new loan's whole term costs over the old one's in interest and costs.// costs_covered is the month the lower payment alone has repaid costs paid up front.fn refinance_calc_compare(balance: int, rate: dec, months: int, new_rate: dec, new_months: int, costs: int, rolled: bool, stay: int) -> map { let amount = balance + (rolled ? costs : 0) let upfront = rolled ? 0 : costs let old_rows = refinance_calc_schedule(balance, rate, months) let new_rows = refinance_calc_schedule(amount, new_rate, new_months) let old_run = refinance_calc_running(old_rows) let new_run = refinance_calc_running(new_rows) let at = (run: list, m: int) => len(run) == 0 ? { paid: 0, interest: 0, owed: 0 } : run[min([m, len(run)]) - 1] let lead = (m: int) => { let o = at(old_run, m) let n = at(new_run, m) (o.paid + o.owed) - (n.paid + n.owed + upfront) } let horizon = max([months, new_months]) let break_even = null let m = 1 while m <= horizon && break_even == null { if lead(m) >= 0 { break_even = m } m += 1 } let current_payment = refinance_calc_payment(balance, rate, months) let new_payment = refinance_calc_payment(amount, new_rate, new_months) let saving = current_payment - new_payment let costs_covered = upfront == 0 ? 0 : (saving > 0 ? int(ceil(dec(upfront) / saving)) : null) let ahead = lead(stay) let old_end = at(old_run, stay) let new_end = at(new_run, stay) let whole_old = at(old_run, months).interest let whole_new = at(new_run, new_months).interest + costs let reason = ahead > 0 ? "ahead" : (break_even == null ? "never" : (break_even > stay ? "late" : "even")) { current_payment, new_payment, break_even, costs_covered, interest_current: old_end.interest, interest_new: new_end.interest, ahead, whole_extra: whole_new - whole_old, reason }}// All the figures from the fields as typed: { errors, result }; result is null with errors.fn refinance_calc_work(balance_text, rate_text, months_text, new_rate_text, new_months_text, costs_text, rolled: bool, stay_text, code: str) -> map { let errors = [] let minor = (d: dec) => money.to_minor(money.of(d, code, "half_up")) let balance = refinance_calc_read(balance_text, 100000000000m) let rate = refinance_calc_read(rate_text, 100m) let months = refinance_calc_months(months_text) let new_rate = refinance_calc_read(new_rate_text, 100m) let new_months = refinance_calc_months(new_months_text) let costs = refinance_calc_read(costs_text, 1000000000m) let stay = refinance_calc_months(stay_text) if balance == null { errors = errors | push({ field: "balance", text: "The balance must be a number of 0 or more, up to 100,000,000,000." }) } if rate == null { errors = errors | push({ field: "rate", text: "The current rate must be a percent from 0 to 100." }) } if months == null { errors = errors | push({ field: "months", text: "The months left must be a whole number from 1 to 600." }) } if new_rate == null { errors = errors | push({ field: "new_rate", text: "The new rate must be a percent from 0 to 100." }) } if new_months == null { errors = errors | push({ field: "new_months", text: "The new term must be a whole number of months from 1 to 600." }) } if costs == null { errors = errors | push({ field: "costs", text: "The closing costs must be a number of 0 or more." }) } if stay == null { errors = errors | push({ field: "stay", text: "The months you will stay must be a whole number from 1 to 600." }) } if len(errors) > 0 { return { errors, result: null } } if balance <= 0m { return { errors: [{ field: "balance", text: "Enter what is still owed on the current loan." }], result: null } } { errors, result: refinance_calc_compare(minor(balance), rate, months, new_rate, new_months, minor(costs), rolled, stay) }}// The verdict in words, with `shown` writing amounts.fn refinance_calc_verdict(r: map, stay: int, shown) -> str { if r.reason == "ahead" { return `Worth it: after ${stay} months you are ${shown(r.ahead)} ahead.` } let behind = 0 - r.ahead if r.reason == "never" { return "Not worth it: the new loan never catches up with the current one." } if r.reason == "late" { return `Not worth it: it breaks even in month ${r.break_even}, but you leave after ${stay}, ${shown(behind)} behind.` } "Not worth it: the two come out the same over your stay."}component RefinanceCalc(balance = null, rate = null, months = 300, new_rate = null, new_months = 300, costs = null, rolled: bool = false, stay = 60, currency: str = "USD", locale: str? = null, title: str = "Refinance", id: str = "refinance-calc", name: str = "refinance", on_change = null, class: str? = null) { style global refinance_calc_css state balance_text = balance == null ? "" : str(balance) state rate_text = rate == null ? "" : str(rate) state months_text = months == null ? "" : str(months) state new_rate_text = new_rate == null ? "" : str(new_rate) state new_months_text = new_months == null ? "" : str(new_months) state costs_text = costs == null ? "" : str(costs) state roll = rolled state stay_text = stay == null ? "" : str(stay) let code = refinance_calc_code(currency) let work = refinance_calc_work(balance_text, rate_text, months_text, new_rate_text, new_months_text, costs_text, roll, stay_text, code) let r = work.result let ok = r != null let stay_months = refinance_calc_months(stay_text) ?? 0 let shown = (minor: int) => locale != null ? money.format(money.from_minor(minor, code), locale) : money.format(money.from_minor(minor, code)) let bad = (field: str) => work.errors | any((e) => e.field == field) let field_id = (part: str) => `${id}-${part}` let named = (part: str) => `${name}_${part}` let tell = () => { if on_change != null && ok { let amount = (minor: int) => money.from_minor(minor, code).amount on_change({ current_payment: amount(r.current_payment), new_payment: amount(r.new_payment), monthly_saving: amount(r.current_payment - r.new_payment), break_even: r.break_even, costs_covered: r.costs_covered, interest_current: amount(r.interest_current), interest_new: amount(r.interest_new), ahead: amount(r.ahead), worth: r.reason == "ahead" }) } } <section class={ui_cls(["ui-refinance-calc", class])} aria-labelledby={field_id("title")}> <h3.ui-refinance-calc-title id={field_id("title")}>{title}</h3> <div.ui-refinance-calc-sides> <fieldset.ui-refinance-calc-side> <legend>Current loan</legend> <div.ui-refinance-calc-field> <label for={field_id("balance")}>Balance ({code})</label> <input type="text" inputmode="decimal" autocomplete="off" id={field_id("balance")} name={named("balance")} value={balance_text} placeholder="0.00" aria-invalid={bad("balance") ? "true" : null} @input={(e) => { balance_text = e.value; tell() }}/> </div> <div.ui-refinance-calc-field> <label for={field_id("rate")}>Rate a year (%)</label> <input type="text" inputmode="decimal" autocomplete="off" id={field_id("rate")} name={named("rate")} value={rate_text} placeholder="0" aria-invalid={bad("rate") ? "true" : null} @input={(e) => { rate_text = e.value; tell() }}/> </div> <div.ui-refinance-calc-field> <label for={field_id("months")}>Months left</label> <input type="text" inputmode="numeric" autocomplete="off" id={field_id("months")} name={named("months")} value={months_text} aria-invalid={bad("months") ? "true" : null} @input={(e) => { months_text = e.value; tell() }}/> </div> </fieldset> <fieldset.ui-refinance-calc-side> <legend>New loan</legend> <div.ui-refinance-calc-field> <label for={field_id("new-rate")}>Rate a year (%)</label> <input type="text" inputmode="decimal" autocomplete="off" id={field_id("new-rate")} name={named("new_rate")} value={new_rate_text} placeholder="0" aria-invalid={bad("new_rate") ? "true" : null} @input={(e) => { new_rate_text = e.value; tell() }}/> </div> <div.ui-refinance-calc-field> <label for={field_id("new-months")}>Term in months</label> <input type="text" inputmode="numeric" autocomplete="off" id={field_id("new-months")} name={named("new_months")} value={new_months_text} aria-invalid={bad("new_months") ? "true" : null} @input={(e) => { new_months_text = e.value; tell() }}/> </div> <div.ui-refinance-calc-field> <label for={field_id("costs")}>Closing costs ({code})</label> <input type="text" inputmode="decimal" autocomplete="off" id={field_id("costs")} name={named("costs")} value={costs_text} placeholder="0.00" aria-invalid={bad("costs") ? "true" : null} @input={(e) => { costs_text = e.value; tell() }}/> </div> <label.ui-refinance-calc-check><input type="checkbox" name={named("rolled")} checked={roll} @change={(e) => { roll = e.checked; tell() }}/> Add the costs to the loan</label> </fieldset> </div> <div.ui-refinance-calc-field> <label for={field_id("stay")}>Months you will keep it</label> <input type="text" inputmode="numeric" autocomplete="off" id={field_id("stay")} name={named("stay")} value={stay_text} aria-invalid={bad("stay") ? "true" : null} @input={(e) => { stay_text = e.value; tell() }}/> </div> {len(work.errors) > 0 && <ul.ui-refinance-calc-errors role="alert">{for e in work.errors => <li key={e.field}>{e.text}</li>}</ul>} {ok && <div.ui-refinance-calc-out aria-live="polite"> <p class={ui_cls(["ui-refinance-calc-verdict", r.reason == "ahead" ? "ui-refinance-calc-good" : "ui-refinance-calc-poor"])} data-key="verdict">{refinance_calc_verdict(r, stay_months, shown)}</p> <dl.ui-refinance-calc-sums> <dt>Current payment</dt><dd data-key="current">{shown(r.current_payment)}</dd> <dt>New payment</dt><dd data-key="new">{shown(r.new_payment)}</dd> <dt>{r.new_payment > r.current_payment ? "More each month" : "Saved each month"}</dt><dd data-key="saving">{shown(abs(r.current_payment - r.new_payment))}</dd> <dt>Break-even month</dt><dd data-key="break_even">{r.break_even == null ? "Never" : `Month ${r.break_even}`}</dd> <dt>Interest over your stay, current loan</dt><dd data-key="interest_current">{shown(r.interest_current)}</dd> <dt>Interest over your stay, new loan</dt><dd data-key="interest_new">{shown(r.interest_new)}</dd> </dl> {r.costs_covered != null && r.costs_covered > 0 && <p.ui-refinance-calc-note data-key="covered">The lower payment alone repays the costs in month {r.costs_covered}.</p>} {r.whole_extra > 0 && <p.ui-refinance-calc-note data-key="whole">Kept to the end, the new loan costs {shown(r.whole_extra)} more in interest and costs than the current one.</p>} </div>} </section>}Related
BillSplitterSplits a bill with tax and tip equally, by shares or by items, cents shared out by largest remainder so the parts add up exactly.3 views · 6 downloadsBreakEvenCalcFinds break-even units and revenue from costs and price, the margin of safety at a volume, a chart, and the price a target profit needs.4 views · 6 downloadsBudgetPlannerPlans a month in envelopes with the 50/30/20 check, what is left to assign, overspending warnings and exact moves between envelopes.3 views · 5 downloadsCashCountCounts a drawer by notes and coins to a total and an over or short variance, and makes change in the fewest pieces from what is in it.2 views · 8 downloadsCompoundCalcGrows a balance year by year with contributions and interest apart, after inflation, and finds the rate needed to reach a target.4 views · 9 downloadsDebtPayoffCompares avalanche and snowball on several debts with an extra payment: months, interest, payoff order, each month, and warns of a debt that never clears.3 views · 6 downloads
Thread
Thread 0 posts
On RefinanceCalc: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.