Finance

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.

#smart#interactive#finance#loan#mortgage#refinance

Demo

Preview

Refinance

Current loan
New loan

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

Current loan
New loan

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.qwe
main.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>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

On RefinanceCalc: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.