LoanEligibility
Four questions (income, debts, work, amount) and, as they are answered, whether a loan is likely, the amount within reach and why.
Demo
Can I borrow?
You are likely to be eligible
- Within reach
- $49,000
- Payment for what you want
- $315.03 / month
- Debt-to-income with it
- 23.0%
- Helps: Your debts take 15.5% of your income now.
- Helps: With this loan's $315.03 a month they would take 23.0%; lenders look for 40% or less.
- Helps: A full-time job is the steadiest income lenders see.
- Helps: $15,000 is within the $49,000 your income leaves room for.
At 9.5% a year over 60 months, with debts up to 40% of income. Not an offer: a lender decides after a credit check.
Start an applicationon_change: none yet
Check before you apply
Answer the questions
Give your income and the amount you want, and we will show what is within reach.
At 6.9% a year over 84 months, with debts up to 36% of income. Not an offer: a lender decides after a credit check.
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/loan_eligibility.qweimport ui/theme.qwe use theme_cssuse ui/loan_eligibility.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
component LoanEligibilityDemoLive() { state verdict = "none yet" <div.demo-stack> <LoanEligibility id="loan-eligibility-demo-filled" name="filled" income={4200} debts={650} amount={15000} apply_href="#apply" on_change={(r) => { verdict = r.verdict ?? "none yet" }}/> <p.demo-note id="loan-eligibility-demo-note">on_change: {verdict}</p> </div>}view LoanEligibilityDemo() { <div.demo-stack> <LoanEligibilityDemoLive/> <LoanEligibility id="loan-eligibility-demo-empty" name="empty" label="Check before you apply" rate={6.9} term={84} max_dti={36} currency="EUR" locale="de-DE"/> </div>}Source
// LoanEligibility: a quick check before applying. Four questions (monthly income,// what goes to debts each month, work, and the amount wanted) and, as they are// answered, the result: likely eligible, maybe, or unlikely; the most within reach// at the rate and term assumed; and why, each reason marked as helping or not. It// works as lenders' first look does: what the debts would take of the income with// this loan's payment (the debt-to-income ratio) against a limit. Worked in// decimals. The fields are real <input>s and a <select> named `name` + "_income",// "_debts", "_work" and "_amount", so a plain <form> sends them.//// <LoanEligibility rate={9.5} term={60} max_dti={40}/>// <LoanEligibility income={4200} debts={650} work="employed" amount={15000} apply_href="/apply"// on_change={(r) => { verdict = r.verdict }}/>//// income, debts, amount: answers to start with (none). work: "employed" (default),// "part_time", "self_employed", "retired" or "unemployed". rate: the yearly rate// assumed, in percent (9.5); term: in months (60). max_dti: the most of the income,// in percent, that debts may take with the new payment (40). currency: ISO code// ("USD"); locale: how amounts are written (the program's; en-US). name: the// prefix of the fields' names and ids ("eligibility"). apply_href: a link to// apply, shown when it is likely or maybe. label: the heading and what screen// readers call it ("Can I borrow?"). on_change(r): inside one of your components,// given { verdict ("likely", "maybe", "unlikely" or null until income and amount// are answered), payment, within, dti } after each change. id, class.//// loan_eligibility_assess(income, debts, work, amount, rate, term, max_dti) gives// that map and the reasons, from text as typed, for the server too.import ui/theme.qwe use ui_clslet loan_eligibility_css = css`.ui-loan-eligibility { container-type: inline-size; min-width: 0; 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-loan-eligibility-grid { display: grid; }@container (min-width: 640px) { .ui-loan-eligibility-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }.ui-loan-eligibility-form { display: grid; gap: 14px; align-content: start; padding: 20px 22px; }.ui-loan-eligibility-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-loan-eligibility-field { display: grid; gap: 6px; }.ui-loan-eligibility-field label { font-size: 13px; font-weight: 500; }.ui-loan-eligibility-field small { color: var(--ui-muted); font-size: 12px; }.ui-loan-eligibility-box { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-bg); transition: border-color .15s, box-shadow .15s; }.ui-loan-eligibility-box:hover { border-color: var(--ui-strong); }.ui-loan-eligibility-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-loan-eligibility-box span { color: var(--ui-muted); font-size: 13px; }.ui-loan-eligibility-box input, .ui-loan-eligibility-box select { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent; color: var(--ui-fg); font: 500 15px var(--ui-font); font-variant-numeric: tabular-nums; }.ui-loan-eligibility-box select { cursor: pointer; }.ui-loan-eligibility-result { display: grid; gap: 14px; align-content: start; padding: 20px 22px; border-top: 1px solid var(--ui-border); background: var(--ui-subtle); border-radius: 0 0 var(--ui-radius) var(--ui-radius); --ui-loan-eligibility-tone: var(--ui-muted); }@container (min-width: 640px) { .ui-loan-eligibility-result { border-top: 0; border-left: 1px solid var(--ui-border); border-radius: 0 var(--ui-radius) var(--ui-radius) 0; } }.ui-loan-eligibility-likely { --ui-loan-eligibility-tone: var(--ui-success); }.ui-loan-eligibility-maybe { --ui-loan-eligibility-tone: var(--ui-warning); }.ui-loan-eligibility-unlikely { --ui-loan-eligibility-tone: var(--ui-danger); }.ui-loan-eligibility-verdict { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 18px; font-weight: 700; line-height: 1.25; }.ui-loan-eligibility-icon { display: grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 50%; font-size: 15px; background: color-mix(in srgb, var(--ui-loan-eligibility-tone) 16%, transparent); color: var(--ui-loan-eligibility-tone); }.ui-loan-eligibility-reach { display: grid; grid-template-columns: 1fr auto; gap: 6px 14px; margin: 0; padding: 12px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-surface); border: 1px solid var(--ui-border); }.ui-loan-eligibility-reach dt { color: var(--ui-muted); }.ui-loan-eligibility-reach dd { margin: 0; text-align: right; font-weight: 650; font-variant-numeric: tabular-nums; }.ui-loan-eligibility-reasons { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }.ui-loan-eligibility-reasons li { display: grid; grid-template-columns: 18px 1fr; gap: 8px; align-items: start; }.ui-loan-eligibility-reasons li::before { content: "✓"; display: grid; place-items: center; width: 18px; height: 18px; margin-top: 1px; border-radius: 50%; background: var(--ui-success-soft); color: var(--ui-success); font-size: 11px; font-weight: 700; }.ui-loan-eligibility-reasons li[data-good="false"]::before { content: "!"; background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-loan-eligibility-empty { margin: 0; color: var(--ui-muted); }.ui-loan-eligibility-note { margin: 0; color: var(--ui-muted); font-size: 12px; }.ui-loan-eligibility-apply { justify-self: start; display: inline-flex; align-items: center; height: 36px; padding: 0 16px; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; text-decoration: none; }.ui-loan-eligibility-apply:hover { filter: brightness(1.08); }.ui-loan-eligibility-apply:focus-visible { outline: none; box-shadow: var(--ui-focus); }`let loan_eligibility_work = [ { value: "employed", label: "Employed, full time" }, { value: "part_time", label: "Employed, part time" }, { value: "self_employed", label: "Self-employed" }, { value: "retired", label: "Retired, with a pension" }, { value: "unemployed", label: "Not working" },]// Text as typed ("4,200", "$ 650") as a decimal, or null when there is none.fn loan_eligibility_read(text) -> dec? { if text == null { return null } let d = dec(re.replace(str(text), "[^0-9.]", "")) is_err(d) ? null : d}// x to the power n, for a whole n, exactly (pow takes no decimals).fn loan_eligibility_pow(x: dec, n: int) -> dec { let out = 1m let i = 0 while i < n { out = out * x i += 1 } out}fn loan_eligibility_money(x: dec, currency: str, locale: str?, whole: bool = false) -> str { let m = money.of(x, currency, "half_up") if is_err(m) { return str(x) } let text = locale != null ? money.format(m, locale) : money.format(m) let units = money.minor_units(currency) !whole || units == 0 || x != floor(x) ? text : re.replace(text, `[.,]0{${units}}(\\D*)$`, "$1")}// The check: { verdict, payment, within, dti, dti_now, reasons: [{ text, good }] }, the// amounts as decimals; verdict null (and no reasons) until income and amount are given.fn loan_eligibility_assess(income, debts, work: str, amount, rate, term: int, max_dti, currency: str = "USD", locale: str? = null) -> map { let inc = loan_eligibility_read(income) let owe = loan_eligibility_read(debts) ?? 0m let want = loan_eligibility_read(amount) let fmt = (x: dec, whole: bool) => loan_eligibility_money(x, currency, locale, whole) if inc == null || inc <= 0 || want == null || want <= 0 { return { verdict: null, payment: null, within: null, dti: null, dti_now: null, reasons: [] } } let r = dec(rate) / 1200 let f = loan_eligibility_pow(1m + r, term) let payment = r > 0 ? round(want * r * f / (f - 1m), 2, "half_up") : round(want / term, 2, "ceiling") let limit = dec(max_dti) let room = inc * limit / 100 - owe // The most whose payment fits in the room, rounded down to a hundred. let most = room <= 0 ? 0m : (r > 0 ? room * (f - 1m) / (r * f) : room * term) let within = floor(most / 100) * 100 let dti_now = round(owe * 100 / inc, 1) let dti = round((owe + payment) * 100 / inc, 1) let fits = want <= within let close = !fits && want <= within * 1.15m let steady = work == "employed" || work == "retired" let verdict = work == "unemployed" ? "unlikely" : fits ? (steady ? "likely" : "maybe") : close ? "maybe" : "unlikely" let work_reason = match work { "employed" => { text: "A full-time job is the steadiest income lenders see.", good: true } "retired" => { text: "A pension counts as a regular income.", good: true } "part_time" => { text: "Part-time pay is accepted, though some lenders ask for a year in the job.", good: false } "self_employed" => { text: "Self-employed income is checked with two years of accounts or tax returns.", good: false } _ => { text: "Lenders need a regular income: a job, a business or a pension.", good: false } } let reasons = [ { text: `Your debts take ${dti_now}% of your income now.`, good: dti_now <= limit }, { text: `With this loan's ${fmt(payment, false)} a month they would take ${dti}%; lenders look for ${limit}% or less.`, good: dti <= limit }, work_reason, fits ? { text: `${fmt(want, true)} is within the ${fmt(within, true)} your income leaves room for.`, good: true } : { text: within > 0 ? `${fmt(want, true)} is more than the ${fmt(within, true)} your income leaves room for.` : "Your debts leave no room for another payment.", good: false }, ] { verdict, payment, within, dti, dti_now, reasons }}component LoanEligibility(income = null, debts = null, work: str = "employed", amount = null, rate = 9.5, term: int = 60, max_dti = 40, currency: str = "USD", locale: str? = null, name: str = "eligibility", apply_href: str? = null, label: str = "Can I borrow?", on_change = null, id: str? = null, class: str? = null) { style global loan_eligibility_css state income_t = income == null ? "" : str(income) state debts_t = debts == null ? "" : str(debts) state work_v = work state amount_t = amount == null ? "" : str(amount) let res = loan_eligibility_assess(income_t, debts_t, work_v, amount_t, rate, term, max_dti, currency, locale) let base = `loan-eligibility-${name}` let fmt = (x: dec, whole: bool) => loan_eligibility_money(x, currency, locale, whole) let tell = () => { if on_change != null { let r = loan_eligibility_assess(income_t, debts_t, work_v, amount_t, rate, term, max_dti, currency, locale) on_change({ verdict: r.verdict, payment: r.payment, within: r.within, dti: r.dti }) } } let heading = match res.verdict { "likely" => "You are likely to be eligible" "maybe" => "You may be eligible" "unlikely" => "You are unlikely to be eligible" _ => "Answer the questions" } let icon = match res.verdict { "likely" => "✓" "maybe" => "?" "unlikely" => "✕" _ => "…" } <section class={ui_cls(["ui-loan-eligibility", class])} id={id} aria-label={label}> <div.ui-loan-eligibility-grid> <div.ui-loan-eligibility-form> <h3.ui-loan-eligibility-title>{label}</h3> <div.ui-loan-eligibility-field> <label for={`${base}-income`}>Monthly income, before tax</label> <span.ui-loan-eligibility-box><span aria-hidden="true">{currency}</span> <input id={`${base}-income`} name={`${name}_income`} type="text" inputmode="decimal" autocomplete="off" value={income_t} placeholder="4,000" @input={(e) => { income_t = e.value; tell() }}/> </span> </div> <div.ui-loan-eligibility-field> <label for={`${base}-debts`}>Monthly debt payments</label> <span.ui-loan-eligibility-box><span aria-hidden="true">{currency}</span> <input id={`${base}-debts`} name={`${name}_debts`} type="text" inputmode="decimal" autocomplete="off" value={debts_t} placeholder="0" aria-describedby={`${base}-debts-hint`} @input={(e) => { debts_t = e.value; tell() }}/> </span> <small id={`${base}-debts-hint`}>Cards, car and other loans; not rent.</small> </div> <div.ui-loan-eligibility-field> <label for={`${base}-work`}>Work</label> <span.ui-loan-eligibility-box> <select id={`${base}-work`} name={`${name}_work`} @change={(e) => { work_v = e.value; tell() }}> {for w in loan_eligibility_work => <option value={w.value} selected={work_v == w.value} key={w.value}>{w.label}</option>} </select> </span> </div> <div.ui-loan-eligibility-field> <label for={`${base}-amount`}>Amount you want to borrow</label> <span.ui-loan-eligibility-box><span aria-hidden="true">{currency}</span> <input id={`${base}-amount`} name={`${name}_amount`} type="text" inputmode="decimal" autocomplete="off" value={amount_t} placeholder="15,000" @input={(e) => { amount_t = e.value; tell() }}/> </span> </div> </div> <div class={ui_cls(["ui-loan-eligibility-result", res.verdict != null && `ui-loan-eligibility-${res.verdict}`])} data-verdict={res.verdict} role="status" aria-live="polite"> <p.ui-loan-eligibility-verdict> <span.ui-loan-eligibility-icon aria-hidden="true">{icon}</span> <span data-key="verdict">{heading}</span> </p> {res.verdict == null ? <p.ui-loan-eligibility-empty>Give your income and the amount you want, and we will show what is within reach.</p> : <dl.ui-loan-eligibility-reach> <dt>Within reach</dt><dd data-key="within">{fmt(res.within, true)}</dd> <dt>Payment for what you want</dt><dd data-key="payment">{fmt(res.payment, false)} / month</dd> <dt>Debt-to-income with it</dt><dd data-key="dti">{res.dti}%</dd> </dl>} {len(res.reasons) > 0 && <ul.ui-loan-eligibility-reasons aria-label="Why"> {for [i, r] in enumerate(res.reasons) => <li key={i} data-good={r.good ? "true" : "false"}> <span><span.ui-sr>{r.good ? "Helps: " : "Holds back: "}</span>{r.text}</span> </li>} </ul>} <p.ui-loan-eligibility-note>At {dec(rate)}% a year over {term} months, with debts up to {dec(max_dti)}% of income. Not an offer: a lender decides after a credit check.</p> {apply_href != null && (res.verdict == "likely" || res.verdict == "maybe") && <a.ui-loan-eligibility-apply href={apply_href}>Start an application</a>} </div> </div> </section>}Related
Thread 0 posts
On LoanEligibility: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.