{"id":2986,"date":"2025-12-06T17:56:03","date_gmt":"2025-12-06T06:56:03","guid":{"rendered":"https:\/\/chipkie.com\/?page_id=2986"},"modified":"2026-08-13T22:40:57","modified_gmt":"2026-08-13T12:40:57","slug":"family-loan-calculator","status":"publish","type":"page","link":"https:\/\/chipkie.com\/uk\/family-loan-calculator\/","title":{"rendered":"Family Loan Calculator"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"2986\" class=\"elementor elementor-2986\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-2189e9a e-con-full e-flex e-con e-parent\" data-id=\"2189e9a\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t<div class=\"elementor-element elementor-element-7e9461f e-con-full elementor-hidden-tablet elementor-hidden-mobile e-flex e-con e-child\" data-id=\"7e9461f\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-97869d9 e-con-full e-flex e-con e-child\" data-id=\"97869d9\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t<div class=\"elementor-element elementor-element-5e6aa69 e-flex e-con-boxed e-con e-child\" data-id=\"5e6aa69\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-7d5e065 elementor-widget elementor-widget-heading\" data-id=\"7d5e065\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<h1 class=\"elementor-heading-title elementor-size-default\">Family Loan Repayment Calculator by Chipkie<\/h1>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-6b3c0d0 elementor-widget elementor-widget-heading\" data-id=\"6b3c0d0\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<h2 class=\"elementor-heading-title elementor-size-default\">Planning a loan between friends or family members? Standard bank calculators often fail because they assume high interest rates and rigid terms.<\/h2>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-6f18409 elementor-align-center elementor-widget elementor-widget-button\" data-id=\"6f18409\" data-element_type=\"widget\" data-e-type=\"widget\" id=\"loan_start_wp\" data-widget_type=\"button.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<div class=\"elementor-button-wrapper\">\n\t\t\t\t\t<a class=\"elementor-button elementor-button-link elementor-size-sm\" href=\"https:\/\/my.chipkie.com\/loans\/create\/\">\n\t\t\t\t\t\t<span class=\"elementor-button-content-wrapper\">\n\t\t\t\t\t\t<span class=\"elementor-button-icon\">\n\t\t\t\t<i aria-hidden=\"true\" class=\"fas fa-play-circle\"><\/i>\t\t\t<\/span>\n\t\t\t\t\t\t\t\t\t<span class=\"elementor-button-text\">Get Started<\/span>\n\t\t\t\t\t<\/span>\n\t\t\t\t\t<\/a>\n\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-a58a069 e-con-full elementor-hidden-tablet elementor-hidden-mobile e-flex e-con e-child\" data-id=\"a58a069\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-7edd473 e-flex e-con-boxed e-con e-parent\" data-id=\"7edd473\" data-element_type=\"container\" data-e-type=\"container\" id=\"intro\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t<div class=\"elementor-element elementor-element-bc7cbbd e-con-full e-flex e-con e-child\" data-id=\"bc7cbbd\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-597cb53 elementor-widget elementor-widget-heading\" data-id=\"597cb53\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<h2 class=\"elementor-heading-title elementor-size-default\">The Chipkie Family Loan Calculator is designed specifically for private lending<\/h2>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-ea3335c elementor-widget elementor-widget-text-editor\" data-id=\"ea3335c\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"text-editor.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<p data-path-to-node=\"12\">Whether you are charging commercial interest or providing an interest-free loan (0% interest) to help a child enter the property market, this tool will calculate your exact weekly or monthly repayments instantly.<\/p>\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-d0f7cbe e-flex e-con-boxed e-con e-parent\" data-id=\"d0f7cbe\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t<div class=\"elementor-element elementor-element-62f13ed e-con-full e-flex e-con e-child\" data-id=\"62f13ed\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-72aa3ca elementor-widget elementor-widget-html\" data-id=\"72aa3ca\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<!--\n  Chipkie Family Loan Calculator \u2014 GB embed (self-contained).\n\n  HOW TO USE (WordPress\/Elementor): paste this whole block into an HTML\n  widget on the GB calculator page. No external scripts, fonts or CSS.\n\n  This file is GENERATED \u2014 one widget codebase serves every market. Edit\n  resources\/marketing\/family-loan-calculator\/src\/ and re-run `node build.mjs`;\n  do not hand-edit or fork this file per market (the market attribute below\n  is the only intended difference).\n\n  Attributes on <chipkie-loan-calc>:\n    market=\"GB\"                       drives currency, wording and the\n                                         market's tax-guidance module\n    config-url=\"...\/api\/calculator\/config\"  live AFR\/usury\/PSA\/pricing data\n    cta-url=\"...\/loans\/create\"           where \"Create this loan\" deep-links\n    comparison-apr=\"12\"                  fallback bank APR if config is unreachable\n    analytics=\"off\"                      add to disable dataLayer events\n\n  Theme overrides (optional) \u2014 set on the page:\n    chipkie-loan-calc { --ck-primary:#004053; --ck-accent:#6dc4bc; }\n-->\n<section aria-label=\"Family loan calculator\">\n  <p style=\"position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)\">\n    Free family loan calculator for the UK: work out repayments, total\n    interest and an amortisation schedule for family loans \u2014 with guidance on\n    HMRC savings income and the Personal Savings Allowance \u2014 then create a\n    signed loan agreement with Chipkie.\n  <\/p>\n  <chipkie-loan-calc\n    market=\"GB\"\n    config-url=\"https:\/\/my.chipkie.com\/api\/calculator\/config\"\n    cta-url=\"https:\/\/my.chipkie.com\/loans\/create\"\n    comparison-apr=\"12\"\n  ><\/chipkie-loan-calc>\n<\/section>\n<script>\n(() => {\n'use strict';\n\/**\n * Chipkie family-loan calculator \u2014 amortisation engine.\n *\n * EXACT JavaScript port of the app's loan maths so the marketing widget quotes\n * the same numbers the app will (docs\/specs\/family-loan-calculator-scope.md\n * makes widget\/app divergence a P1 bug):\n *   - app\/Services\/LoanService.php      (numInstalments, periodicRate,\n *                                        instalmentAmount, getInstalments,\n *                                        instalmentDueDate)\n *   - app\/Services\/AmortisationService.php (buildSchedule reconcile walk,\n *                                        simulatePayoff, suggestExtra)\n *   - app\/Support\/Afr.php \/ Usury.php   (assess\/check, data-driven)\n *\n * Parity is enforced two ways: tests\/Feature\/CalculatorParityTest.php\n * regenerates golden-vectors.json from the real PHP services, and\n * test\/math.test.mjs asserts this port reproduces it to the cent.\n *\/\n\n\/\/ \u2500\u2500 PHP-compatible rounding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\/\/ PHP's round() pre-corrects float representation (round(2.675, 2) === 2.68);\n\/\/ a naive Math.round(x*100)\/100 gives 2.67. Snap to 14 significant digits\n\/\/ before rounding, half away from zero, to mirror PHP.\nfunction phpRound(x, places = 2) {\n  if (!isFinite(x)) return x;\n  const f = Math.pow(10, places);\n  const scaled = Number((Math.abs(x) * f).toPrecision(14));\n  return (Math.sign(x) || 1) * Math.round(scaled) \/ f;\n}\n\nconst round2 = (x) => phpRound(x, 2);\n\n\/\/ \u2500\u2500 Dates (UTC-noon internally: no DST\/locale drift) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction dateParts(iso) {\n  const [y, m, d] = String(iso).slice(0, 10).split('-').map(Number);\n  return { y, m, d };\n}\n\nfunction toUtc(iso) {\n  const { y, m, d } = dateParts(iso);\n  return new Date(Date.UTC(y, m - 1, d, 12));\n}\n\nfunction isoOf(dt) {\n  return dt.toISOString().slice(0, 10);\n}\n\n\/** Whole days from a to b (negative when b is earlier). *\/\nfunction diffDays(aIso, bIso) {\n  return Math.round((toUtc(bIso) - toUtc(aIso)) \/ 86400000);\n}\n\n\/**\n * Calendar-month add with END-OF-MONTH OVERFLOW (Jan 31 + 1mo = Mar 3) \u2014\n * the same normalisation PHP's DateTime\/Carbon::addMonths and JS setMonth\n * apply, and what CreateLoan.vue's addMonths() produces for the form's\n * default end date.\n *\/\nfunction addMonthsIso(iso, months) {\n  const { y, m, d } = dateParts(iso);\n  return isoOf(new Date(Date.UTC(y, m - 1 + months, d, 12)));\n}\n\nfunction addDaysIso(iso, days) {\n  const { y, m, d } = dateParts(iso);\n  return isoOf(new Date(Date.UTC(y, m - 1, d + days, 12)));\n}\n\n\/**\n * Full calendar months from a to b \u2014 PHP DateInterval semantics (what\n * Carbon::diffInMonths returns for two date-only values): whole-of-calendar\n * months, minus one when b's day-of-month hasn't reached a's yet.\n *\/\nfunction diffInMonths(aIso, bIso) {\n  const a = dateParts(aIso);\n  const b = dateParts(bIso);\n  let months = (b.y - a.y) * 12 + (b.m - a.m);\n  if (b.d < a.d) months -= 1;\n  return Math.max(0, months);\n}\n\n\/\/ \u2500\u2500 LoanService port \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst FREQUENCIES = ['weekly', 'fortnightly', 'monthly'];\n\n\/** LoanService::numInstalments \u2014 floors the span; end <= start \u2192 0. *\/\nfunction numInstalments(startIso, endIso, frequency) {\n  if (diffDays(startIso, endIso) <= 0) return 0;\n  const days = diffDays(startIso, endIso);\n  switch (frequency) {\n    case 'weekly':\n      return Math.floor(days \/ 7);\n    case 'fortnightly':\n      \/\/ Carbon: diffInWeeks (int) first, THEN halve and floor.\n      return Math.floor(Math.floor(days \/ 7) \/ 2);\n    case 'monthly':\n      return diffInMonths(startIso, endIso);\n    default:\n      throw new Error('Invalid frequency');\n  }\n}\n\n\/** LoanService::periodicRate \u2014 daily = annual\/365.25; weekly \u00d77, fortnightly \u00d714, monthly = annual\/12. *\/\nfunction periodicRate(interestPct, frequency) {\n  if (interestPct <= 0) return 0;\n  const annual = interestPct \/ 100;\n  const daily = annual \/ 365.25;\n  switch (frequency) {\n    case 'weekly':\n      return daily * 7;\n    case 'fortnightly':\n      return daily * 14;\n    case 'monthly':\n      return annual \/ 12;\n    default:\n      throw new Error('Invalid frequency');\n  }\n}\n\n\/** LoanService::instalmentAmount \u2014 standard reducing-balance annuity, 2 dp. *\/\nfunction instalmentAmount(amount, interestPct, startIso, endIso, frequency) {\n  const n = numInstalments(startIso, endIso, frequency);\n  if (n < 1) return round2(amount);\n  if (interestPct <= 0) return round2(amount \/ n);\n  const r = periodicRate(interestPct, frequency);\n  const x = Math.pow(1 + r, n);\n  return round2((amount * r * x) \/ (x - 1));\n}\n\n\/** LoanService::instalmentDueDate \u2014 index is 0-based; the first payment is one period after start. *\/\nfunction instalmentDueDate(startIso, index, frequency) {\n  const periods = index + 1;\n  switch (frequency) {\n    case 'weekly':\n      return addDaysIso(startIso, periods * 7);\n    case 'fortnightly':\n      return addDaysIso(startIso, periods * 14);\n    case 'monthly':\n      return addMonthsIso(startIso, periods);\n    default:\n      throw new Error('Invalid frequency');\n  }\n}\n\n\/**\n * LoanService::getInstalments \u2014 the schedule total distributed across n\n * instalments in WHOLE CENTS (leftover cents spread one each across the\n * earliest instalments) so the schedule sums exactly to the total.\n *\/\nfunction getInstalments(startIso, endIso, frequency, amount, interestPct) {\n  const n = numInstalments(startIso, endIso, frequency);\n  if (n < 1) return [];\n\n  const per = instalmentAmount(amount, interestPct, startIso, endIso, frequency);\n  const total = interestPct > 0 ? round2(per * n) : round2(amount);\n\n  const totalCents = Math.round(total * 100);\n  const base = Math.trunc(totalCents \/ n);\n  const remainder = totalCents - base * n;\n\n  const out = [];\n  for (let i = 0; i < n; i++) {\n    const cents = base + (i < remainder ? 1 : 0);\n    out.push({ amount: cents \/ 100, due_date: instalmentDueDate(startIso, i, frequency) });\n  }\n  return out;\n}\n\n\/**\n * AmortisationService::buildSchedule \u2014 per-payment interest\/principal split on\n * the reducing balance, then the reconcile walk (payments fixed; the rounding\n * remainder reallocated between interest and principal from the last row\n * backward) so principal sums EXACTLY to the loan amount and the final\n * balance is exactly zero.\n *\/\nfunction buildSchedule({ amount, interest, startDate, endDate, frequency }) {\n  const principal = round2(Number(amount));\n  const rate = Number(interest);\n  const instalments = getInstalments(startDate, endDate, frequency, principal, rate);\n  const periodic = periodicRate(rate, frequency);\n\n  const rows = [];\n  let balance = principal;\n  instalments.forEach((inst, i) => {\n    const payment = round2(inst.amount);\n    let interestPart = periodic > 0 ? round2(balance * periodic) : 0;\n    if (interestPart > payment) interestPart = payment;\n    const principalPart = round2(payment - interestPart);\n    balance = round2(balance - principalPart);\n    rows.push({\n      number: i + 1,\n      due_date: inst.due_date,\n      payment,\n      interest: interestPart,\n      principal: principalPart,\n    });\n  });\n\n  const n = rows.length;\n  if (n > 0) {\n    const sumP = round2(rows.reduce((s, r) => s + r.principal, 0));\n    let residual = round2(principal - sumP);\n    for (let i = n - 1; i >= 0 && Math.abs(residual) >= 0.005; i--) {\n      const room = residual > 0 ? round2(rows[i].interest) : round2(-rows[i].principal);\n      const add = residual > 0 ? Math.min(residual, room) : Math.max(residual, room);\n      rows[i].principal = round2(rows[i].principal + add);\n      rows[i].interest = round2(rows[i].payment - rows[i].principal);\n      residual = round2(residual - add);\n    }\n  }\n\n  let bal = principal;\n  for (const r of rows) {\n    bal = round2(bal - r.principal);\n    r.balance = bal;\n  }\n  const principalSum = round2(principal - bal);\n  const repaid = round2(rows.reduce((s, r) => s + r.payment, 0));\n  const totalInterest = round2(rows.reduce((s, r) => s + r.interest, 0));\n\n  return {\n    interest_rate: round2(rate),\n    frequency,\n    projected: true,\n    rows,\n    totals: {\n      principal: round2(principalSum),\n      interest: totalInterest,\n      repaid,\n      instalments: n,\n    },\n  };\n}\n\n\/\/ \u2500\u2500 Early payoff (AmortisationService::earlyPayoff, 'recurring' mode, from start) \u2500\u2500\n\/** AmortisationService::simulatePayoff \u2014 one reducing-balance walk. *\/\nfunction simulatePayoff(balance, payment, periodic, guard) {\n  let interest = 0;\n  let periods = 0;\n  let bal = balance;\n  while (bal > 0.01 && periods < guard) {\n    const charge = periodic <= 0 ? 0 : round2(bal * periodic);\n    const principalPart = round2(payment - charge);\n    if (principalPart <= 0) break;\n    interest = round2(interest + charge);\n    bal = round2(bal - principalPart);\n    periods++;\n  }\n  return { interest, periods };\n}\n\n\/** AmortisationService::suggestExtra \u2014 round the payment up to a friendly increment. *\/\nfunction suggestExtra(regularPayment) {\n  if (regularPayment <= 0) return 0;\n  const increment = regularPayment < 50 ? 5 : regularPayment < 500 ? 10 : 50;\n  let target = Math.ceil(regularPayment \/ increment) * increment;\n  if (target <= regularPayment + 0.005) target += increment;\n  return round2(target - regularPayment);\n}\n\n\/**\n * Recurring-extra projection from the loan's start (the widget's case: no\n * payments made yet, balance = full principal) \u2014 the same walk the app's\n * premium early-payoff tool runs, so the teaser numbers match it.\n *\/\nfunction earlyPayoff(schedule, principal, extra) {\n  const rows = schedule.rows;\n  const baselineCount = rows.length;\n  if (baselineCount === 0 || principal <= 0.01) {\n    return { state: 'complete', payments_saved: 0, interest_saved: 0, new_payoff: null };\n  }\n  const periodic = periodicRate(schedule.interest_rate, schedule.frequency);\n  const regular = round2(rows[0].payment);\n  const guard = baselineCount + 5;\n  const baseline = simulatePayoff(principal, regular, periodic, guard);\n  const boosted = round2(regular + Math.max(0, round2(extra)));\n\n  \/\/ Boosted payment big enough to clear everything in one hit.\n  const firstCharge = periodic <= 0 ? 0 : round2(principal * periodic);\n  if (boosted >= principal + firstCharge) {\n    return {\n      state: 'pays_off_now',\n      regular_payment: regular,\n      payments_saved: Math.max(0, baselineCount - 1),\n      interest_saved: round2(Math.max(0, baseline.interest - firstCharge)),\n      new_interest: firstCharge,\n      new_payoff: rows[0].due_date,\n      suggested_extra: suggestExtra(regular),\n    };\n  }\n\n  const accel = simulatePayoff(principal, boosted, periodic, guard);\n  const periods = Math.max(1, Math.min(accel.periods, baselineCount));\n  return {\n    state: 'projected',\n    regular_payment: regular,\n    payments_saved: Math.max(0, baselineCount - periods),\n    interest_saved: round2(Math.max(0, baseline.interest - accel.interest)),\n    new_interest: accel.interest,\n    new_payoff: rows[periods - 1].due_date,\n    suggested_extra: suggestExtra(regular),\n  };\n}\n\n\/\/ \u2500\u2500 US tax intelligence (App\\Support\\Afr \/ Usury ports; data-driven) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\/**\n * Afr::applicableRate + assess. `afr` is the payload from the calculator\n * config endpoint: { rates: {short, mid, long}, short_max_months,\n * mid_max_months, de_minimis, month }. Missing\/partial data \u2192 dormant\n * (has_data false) \u2014 never a guessed 0%.\n *\/\nfunction afrAssess(ratePct, termMonths, amount, afr) {\n  const none = { has_data: false, applicable_afr: null, band: null, below: false, de_minimis_exempt: false };\n  const r = afr && afr.rates;\n  if (!r || !isFinite(r.short) || !isFinite(r.mid) || !isFinite(r.long)) return none;\n\n  const shortMax = Number(afr.short_max_months ?? 36);\n  const midMax = Number(afr.mid_max_months ?? 108);\n  const band = termMonths <= shortMax ? 'short' : termMonths <= midMax ? 'mid' : 'long';\n  const applicable = Number(r[band]);\n  const deMinimis = Number(afr.de_minimis ?? 10000);\n\n  return {\n    has_data: true,\n    applicable_afr: applicable,\n    band,\n    below: ratePct != null && ratePct < applicable - 0.0001,\n    de_minimis_exempt: amount != null && amount <= deMinimis,\n  };\n}\n\n\/**\n * Usury::check. `states` is the config map (lowercase state name\/code \u2192\n * number | 'none'; absent = not assessed). Conservative: unknown state \u2192\n * 'unknown' (\"check your state\"), never a guessed cap.\n *\/\nfunction usuryCheck(ratePct, state, states) {\n  const empty = { status: 'unknown', cap: null, state: state || null };\n  if (!state || !states) return empty;\n  const v = states[String(state).trim().toLowerCase()];\n  if (v === undefined || v === null) return empty;\n  if (typeof v === 'string' && v.toLowerCase() === 'none') return { status: 'none', cap: null, state };\n  const cap = Number(v);\n  if (!isFinite(cap)) return empty;\n  const over = ratePct != null && ratePct > cap + 0.0001;\n  return { status: over ? 'over' : 'ok', cap, state };\n}\n\n\/**\n * <chipkie-loan-calc> \u2014 Chipkie's family loan calculator (marketing embed).\n *\n * Self-contained custom element (shadow DOM: no style\/ID collisions inside\n * WordPress\/Elementor). All amortisation maths is the exact LoanService port\n * in math.mjs, so the numbers here are the numbers the app quotes.\n *\n * Attributes (all optional):\n *   market=\"US\"            \u2014 market code; US enables the tax-intelligence panel\n *   config-url=\"\u2026\"         \u2014 GET endpoint (api.calculator.config); live AFR \/\n *                            usury \/ prices. Unreachable \u2192 tax panel dormant.\n *   lead-url=\"\u2026\"           \u2014 POST endpoint (api.calculator.lead)\n *   cta-url=\"\u2026\"            \u2014 loan-create deep-link base (\u2026\/loans\/create)\n *   comparison-apr=\"12\"    \u2014 fallback bank APR if the config fetch fails\n *   analytics=\"off\"        \u2014 disable dataLayer events\n *\n * Theming: override any --ck-* custom property from page CSS, e.g.\n *   chipkie-loan-calc { --ck-primary: #004053; }\n *\/\n\n\nconst WIDGET_CSS = `\/* Chipkie family-loan calculator \u2014 shadow-DOM styles.\n   Brand tokens (tailwind.config.js) as custom properties on :host; pages can\n   override any --ck-* token from outside (custom props pierce the shadow). *\/\n\n:host {\n  \/* Brand (defaults = Chipkie palette) *\/\n  --ck-primary: #004053;        \/* navy *\/\n  --ck-accent: #6dc4bc;         \/* brand teal *\/\n  --ck-accent-dark: #007c89;    \/* dark teal *\/\n  --ck-highlight: rgb(245, 131, 103); \/* salmon *\/\n  --ck-bg: #ffffff;\n  --ck-panel: #f2f8f8;          \/* light teal wash *\/\n  --ck-text: #1a202c;\n  --ck-muted: #52606d;\n  --ck-border: #d8e4e6;\n  --ck-good-bg: #e3f5ee;\n  --ck-good-text: #14532d;\n  --ck-warn-bg: #fdf3e3;\n  --ck-warn-border: #eab308;\n  --ck-warn-text: #713f12;\n  --ck-alert-bg: #fdeceb;\n  --ck-alert-border: #dc6d5a;\n  --ck-alert-text: #7f1d1d;\n  --ck-radius: 14px;\n  --ck-font: \"Nexa Book\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n\n  display: block;\n  font-family: var(--ck-font);\n  color: var(--ck-text);\n  line-height: 1.45;\n  container-type: inline-size;\n}\n\n@media (prefers-color-scheme: dark) {\n  :host {\n    --ck-bg: #0f1e24;\n    --ck-panel: #16303a;\n    --ck-text: #e8f1f2;\n    --ck-muted: #9fb6bc;\n    --ck-border: #2a4650;\n    --ck-good-bg: #123527;\n    --ck-good-text: #a7e3c4;\n    --ck-warn-bg: #38300f;\n    --ck-warn-text: #f5e3a3;\n    --ck-alert-bg: #3a1a17;\n    --ck-alert-text: #f3b8ae;\n  }\n}\n\n*, *::before, *::after { box-sizing: border-box; }\n\n.calc {\n  background: var(--ck-bg);\n  border: 1px solid var(--ck-border);\n  border-top: 6px solid var(--ck-accent);\n  border-radius: var(--ck-radius);\n  box-shadow: 0 12px 30px -12px rgba(0, 32, 42, 0.25);\n  padding: 26px;\n  max-width: 920px;\n  margin: 0 auto;\n}\n\n.calc-header { text-align: center; margin-bottom: 22px; }\n.calc-header h3 { margin: 0 0 6px; font-size: 1.55rem; font-weight: 700; color: var(--ck-primary); }\n@media (prefers-color-scheme: dark) { .calc-header h3 { color: var(--ck-accent); } }\n.calc-header p { margin: 0; color: var(--ck-muted); font-size: 0.95rem; }\n\n\/* The headline answer (imputed-interest mode). Stacks on narrow screens \u2014\n   the rate is the thing they came for, so it must never be the part that\n   gets clipped. *\/\n.answer {\n  margin: 14px auto 0;\n  display: flex;\n  flex-wrap: wrap;\n  align-items: baseline;\n  justify-content: center;\n  gap: 4px 10px;\n  padding: 12px 14px;\n  border: 1px solid color-mix(in srgb, var(--ck-primary) 25%, transparent);\n  border-radius: 12px;\n  background: color-mix(in srgb, var(--ck-primary) 5%, transparent);\n}\n\/* \\`display:flex\\`\/\\`grid\\` BEAT the hidden attribute's UA \\`display:none\\`, so a\n   hidden box still paints \u2014 an empty bordered rectangle above the calculator\n   on every page that is not in that mode. Only the screenshot showed it. *\/\n.answer[hidden], .compare[hidden] { display: none; }\n.answer-label { color: var(--ck-muted); font-size: 0.9rem; }\n.answer-value { font-size: 1.9rem; font-weight: 700; line-height: 1.1; color: var(--ck-primary); }\n@media (prefers-color-scheme: dark) { .answer-value { color: var(--ck-accent); } }\n.answer-note { color: var(--ck-muted); font-size: 0.82rem; flex-basis: 100%; }\n\n\/* Gift-or-loan comparison. Two cards, side by side where there is room and\n   stacked where there isn't \u2014 at 390px a two-column grid would shrink both to\n   unreadable. Gift first: it is the choice people drift into by accident. *\/\n.compare { margin-top: 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; text-align: left; }\n@container (max-width: 700px) { .compare { grid-template-columns: 1fr; } }\n.compare-card {\n  padding: 14px;\n  border: 1px solid var(--ck-border);\n  border-radius: 12px;\n  background: var(--ck-panel);\n}\n.compare-card--loan { border-color: color-mix(in srgb, var(--ck-primary) 35%, transparent); }\n.compare-card h4 {\n  margin: 0 0 8px; font-size: 1rem; font-weight: 700; color: var(--ck-primary);\n}\n@media (prefers-color-scheme: dark) { .compare-card h4 { color: var(--ck-accent); } }\n.compare-card ul { margin: 0; padding-left: 18px; }\n.compare-card li { margin-bottom: 7px; font-size: 0.9rem; line-height: 1.45; color: var(--ck-text); }\n.compare-card li:last-child { margin-bottom: 0; }\n\n.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 26px; }\n@container (max-width: 700px) { .cols { grid-template-columns: 1fr; } }\n\n.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }\n\n.field { margin-bottom: 14px; }\n.field label {\n  display: block; margin-bottom: 6px;\n  font-weight: 600; font-size: 0.86rem; color: var(--ck-muted);\n}\n.field input[type=\"number\"], .field input[type=\"text\"], .field input[type=\"date\"],\n.field input[type=\"email\"], .field select {\n  width: 100%; padding: 11px 12px;\n  border: 2px solid var(--ck-border); border-radius: 8px;\n  font-size: 1rem; font-family: inherit;\n  background: var(--ck-bg); color: var(--ck-text);\n}\n.field input:focus-visible, .field select:focus-visible, button:focus-visible,\ninput[type=\"range\"]:focus-visible, a:focus-visible, details summary:focus-visible {\n  outline: 3px solid var(--ck-accent); outline-offset: 1px;\n}\n.field .hint { font-size: 0.78rem; color: var(--ck-muted); margin-top: 4px; }\n.field .error { font-size: 0.82rem; color: var(--ck-alert-text); background: var(--ck-alert-bg); border-radius: 6px; padding: 4px 8px; margin-top: 5px; }\n\ninput[type=\"range\"] { width: 100%; accent-color: var(--ck-accent-dark); margin: 6px 0 0; }\n\n.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }\n.chip {\n  border: 1.5px solid var(--ck-accent-dark); border-radius: 999px;\n  background: transparent; color: var(--ck-accent-dark);\n  padding: 5px 13px; font-size: 0.85rem; font-weight: 600; font-family: inherit;\n  cursor: pointer;\n}\n.chip[aria-pressed=\"true\"] { background: var(--ck-accent-dark); color: #fff; }\n.chip:hover { background: var(--ck-accent); border-color: var(--ck-accent); color: #06272e; }\n\n\/* Results *\/\n.results {\n  background: var(--ck-panel);\n  border: 1px solid var(--ck-border);\n  border-radius: var(--ck-radius);\n  padding: 20px;\n  min-height: 200px; \/* reserve space \u2014 no layout shift on first calc *\/\n}\n.headline { text-align: center; padding-bottom: 14px; border-bottom: 2px solid var(--ck-border); margin-bottom: 14px; }\n.headline .label { color: var(--ck-muted); font-size: 0.9rem; }\n.headline .value { font-size: 2.1rem; font-weight: 800; color: var(--ck-primary); }\n@media (prefers-color-scheme: dark) { .headline .value { color: var(--ck-accent); } }\n.headline .per { font-size: 1rem; font-weight: 600; color: var(--ck-muted); }\n\n.rrow { display: flex; justify-content: space-between; gap: 12px; margin: 7px 0; font-size: 0.95rem; }\n.rrow .k { color: var(--ck-muted); }\n.rrow .v { font-weight: 700; color: var(--ck-text); font-variant-numeric: tabular-nums; }\n\n.savings {\n  background: var(--ck-good-bg); color: var(--ck-good-text);\n  border-radius: 10px; padding: 13px 15px; margin-top: 14px;\n  font-size: 0.93rem;\n}\n.savings strong { font-size: 1.05rem; }\n.savings .src { display: block; margin-top: 5px; font-size: 0.76rem; opacity: 0.85; }\n\n\/* Advisory panels (AFR \/ usury \/ gift tax) *\/\n.advice { border-radius: 10px; padding: 12px 14px; margin-top: 12px; font-size: 0.9rem; }\n.advice.info { background: var(--ck-panel); border: 1px solid var(--ck-border); color: var(--ck-text); }\n.advice.warn { background: var(--ck-warn-bg); border-left: 4px solid var(--ck-warn-border); color: var(--ck-warn-text); }\n.advice.alert { background: var(--ck-alert-bg); border-left: 4px solid var(--ck-alert-border); color: var(--ck-alert-text); }\n.advice .fix { margin-top: 8px; }\n.advice .title { font-weight: 700; display: block; margin-bottom: 3px; }\n.smallprint { font-size: 0.74rem; color: var(--ck-muted); margin-top: 10px; }\n\n\/* Charts *\/\n.chart-wrap { margin-top: 16px; }\n.chart-wrap h4 { margin: 0 0 6px; font-size: 0.9rem; color: var(--ck-muted); font-weight: 600; }\n.chart-wrap svg { width: 100%; height: auto; display: block; }\n.legend { display: flex; gap: 16px; font-size: 0.78rem; color: var(--ck-muted); margin-top: 4px; flex-wrap: wrap; }\n.legend .sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }\n\n\/* Early payoff *\/\n.payoff { margin-top: 18px; padding: 14px; border: 1px dashed var(--ck-accent-dark); border-radius: 10px; }\n.payoff h4 { margin: 0 0 4px; font-size: 0.95rem; color: var(--ck-primary); }\n@media (prefers-color-scheme: dark) { .payoff h4 { color: var(--ck-accent); } }\n.payoff .out { font-size: 0.92rem; margin-top: 8px; }\n.payoff .teaser { font-size: 0.78rem; color: var(--ck-muted); margin-top: 6px; }\n\n\/* Scenarios *\/\n.scenarios { margin-top: 18px; }\n.scenarios table, .schedule table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }\n.scenarios th, .scenarios td, .schedule th, .schedule td {\n  padding: 6px 8px; text-align: right; border-bottom: 1px solid var(--ck-border);\n  font-variant-numeric: tabular-nums;\n}\n.scenarios th:first-child, .scenarios td:first-child,\n.schedule th:first-child, .schedule td:first-child { text-align: left; }\n.scenarios th, .schedule th { color: var(--ck-muted); font-weight: 600; }\n.table-scroll { overflow-x: auto; }\n\n\/* Schedule *\/\n.schedule { margin-top: 18px; }\n.schedule summary { cursor: pointer; font-weight: 700; color: var(--ck-accent-dark); }\n.schedule .actions { margin: 10px 0; display: flex; gap: 10px; flex-wrap: wrap; }\n\nbutton.btn {\n  font-family: inherit; font-size: 0.9rem; font-weight: 700;\n  border-radius: 8px; padding: 9px 16px; cursor: pointer;\n  border: 2px solid var(--ck-accent-dark);\n  background: transparent; color: var(--ck-accent-dark);\n}\nbutton.btn:hover { background: var(--ck-accent-dark); color: #fff; }\nbutton.btn.primary { background: var(--ck-primary); border-color: var(--ck-primary); color: #fff; }\nbutton.btn.primary:hover { background: var(--ck-accent-dark); border-color: var(--ck-accent-dark); }\n\n\/* CTA + capture *\/\n.cta {\n  margin-top: 22px; padding: 20px;\n  background: var(--ck-primary); color: #fff;\n  border-radius: var(--ck-radius); text-align: center;\n}\n.cta h4 { margin: 0 0 6px; font-size: 1.2rem; }\n.cta p { margin: 0 0 12px; font-size: 0.9rem; opacity: 0.92; }\n.cta a.go {\n  display: inline-block; background: var(--ck-highlight); color: #3b1006;\n  font-weight: 800; text-decoration: none; border-radius: 10px;\n  padding: 12px 26px; font-size: 1.02rem;\n}\n.cta a.go:hover { filter: brightness(1.08); }\n.cta ul { list-style: none; display: flex; gap: 7px 18px; flex-wrap: wrap; justify-content: center; padding: 0; margin: 14px 0 0; font-size: 0.82rem; opacity: 0.95; }\n.cta ul li::before { content: \"\u2713 \"; color: var(--ck-accent); font-weight: 800; }\n\n.capture { margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; align-items: center; }\n.capture input[type=\"email\"] {\n  flex: 1 1 220px; max-width: 300px; padding: 10px 12px;\n  border-radius: 8px; border: none; font-family: inherit; font-size: 0.92rem;\n}\n.capture .msg { flex-basis: 100%; font-size: 0.85rem; }\n.hp { position: absolute; left: -5000px; width: 1px; height: 1px; overflow: hidden; } \/* honeypot *\/\n\n.visually-hidden {\n  position: absolute; width: 1px; height: 1px; margin: -1px;\n  clip-path: inset(50%); overflow: hidden; white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n  .savings, .advice { animation: ck-fade 0.35s ease-in; }\n  @keyframes ck-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }\n}\n`;\n\nconst US_STATES = {\n  AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',\n  CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', DC: 'District of Columbia',\n  FL: 'Florida', GA: 'Georgia', HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois',\n  IN: 'Indiana', IA: 'Iowa', KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana',\n  ME: 'Maine', MD: 'Maryland', MA: 'Massachusetts', MI: 'Michigan',\n  MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri', MT: 'Montana',\n  NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey',\n  NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota',\n  OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',\n  RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota',\n  TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia',\n  WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming',\n};\n\nconst LIMITS = { amount: [500, 10_000_000], rate: [0, 100], term: [1, 360] };\nconst BAND_LABELS = { short: 'short-term', mid: 'mid-term', long: 'long-term' };\n\nconst esc = (s) => String(s).replace(\/[&<>\"']\/g, (c) => ({\n  '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;',\n}[c]));\n\nfunction todayIso() {\n  const d = new Date();\n  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\n}\n\n\/** utm\/click params on the CURRENT (marketing) page, forwarded on the CTA + lead. *\/\nfunction pageAttribution() {\n  const keep = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid', 'msclkid'];\n  const out = {};\n  try {\n    const q = new URLSearchParams(window.location.search);\n    for (const k of keep) {\n      const v = q.get(k);\n      if (v) out[k] = v.slice(0, 255);\n    }\n    if (document.referrer) out.referrer = document.referrer.slice(0, 255);\n  } catch { \/* SSR\/sandbox: no attribution *\/ }\n  return out;\n}\n\nclass ChipkieLoanCalc extends HTMLElement {\n  connectedCallback() {\n    if (this._ready) return;\n    this._ready = true;\n\n    this.market = (this.getAttribute('market') || 'US').toUpperCase();\n    \/\/ MODE \u2014 same engine, different question. One page cannot rank for \"family\n    \/\/ loan calculator\" AND \"how much interest must I charge\", so each search\n    \/\/ intent gets its own page; but a second calculator (or a second AFR read)\n    \/\/ would be the two-paths problem this codebase keeps paying for. The mode\n    \/\/ only reframes what is ALREADY computed.\n    \/\/   calculator        the general tool (default, unchanged)\n    \/\/   imputed-interest  US: leads with the minimum rate the IRS expects\n    this.mode = (this.getAttribute('mode') || 'calculator').toLowerCase();\n    this.configUrl = this.getAttribute('config-url') || 'https:\/\/my.chipkie.com\/api\/calculator\/config';\n    this.leadUrl = this.getAttribute('lead-url')\n      || this.configUrl.replace(\/\\\/config.*$\/, '\/lead');\n    this.ctaUrl = this.getAttribute('cta-url') || 'https:\/\/my.chipkie.com\/loans\/create';\n    this.analyticsOn = this.getAttribute('analytics') !== 'off';\n\n    \/\/ Config payload (AFR\/usury\/prices). Starts with safe fallbacks; the\n    \/\/ fetch upgrades it. Everything tax-related stays DORMANT without data \u2014\n    \/\/ never a guessed rate (mirrors App\\Support\\Afr's philosophy).\n    this.cfg = {\n      market: this.market,\n      currency: { code: this.market === 'GB' ? 'GBP' : this.market === 'AU' ? 'AUD' : 'USD',\n        locale: this.market === 'GB' ? 'en-GB' : this.market === 'AU' ? 'en-AU' : 'en-US' },\n      comparison: {\n        apr: Number(this.getAttribute('comparison-apr')) || 12,\n        source: 'typical bank personal-loan APR',\n        as_of: '',\n      },\n      contract: { available: false, price: null },\n      afr: null, usury: null, gift_tax: null,\n    };\n\n    this.s = { \/\/ scenario state\n      amount: 20000, rate: 0, term: 36, frequency: 'monthly',\n      start: todayIso(), state: '', extra: 0,\n    };\n    this.pinned = [];\n    this._eventsSent = {};\n\n    this.root = this.attachShadow({ mode: 'open' });\n    this.render();\n    this.bind();\n    this.loadConfig();\n    this.recalc();\n  }\n\n  \/\/ \u2500\u2500 static shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  render() {\n    const us = this.market === 'US';\n    const freqOptions = us\n      ? '<option value=\"monthly\">Monthly<\/option><option value=\"fortnightly\">Every 2 weeks<\/option><option value=\"weekly\">Weekly<\/option>'\n      : '<option value=\"monthly\">Monthly<\/option><option value=\"fortnightly\">Fortnightly<\/option><option value=\"weekly\">Weekly<\/option>';\n    const stateOptions = ['<option value=\"\">Select a state (optional)<\/option>']\n      .concat(Object.entries(US_STATES).map(([c, n]) => `<option value=\"${c}\">${esc(n)}<\/option>`)).join('');\n\n    this.root.innerHTML = `\n      <style>${WIDGET_CSS}<\/style>\n      <div class=\"calc\">\n        <div class=\"calc-header\">\n          ${this.mode === 'minimum-rate' ? `\n          <h3>What's the minimum interest rate for a family loan?<\/h3>\n          <p>${{ US: 'In the US there is one, and it moves every month. Enter the amount and term to see it.',\n                 AU: 'In Australia there isn\\u2019t one \\u2014 but a 0% loan has consequences worth knowing about.',\n                 GB: 'In the UK there isn\\u2019t one \\u2014 but what you charge changes what you owe.' }[this.market]\n              || 'It depends where you are. Here\\u2019s what applies to you.'}<\/p>`\n          : this.mode === 'imputed-interest' ? `\n          <h3>How much interest do I have to charge?<\/h3>\n          <p>Enter the amount and how long the loan runs. The minimum rate is the\n            IRS applicable federal rate for that term \u2014 charge less and the\n            difference can be treated as interest you never received.<\/p>`\n          : this.mode === 'gift-or-loan' ? `\n          <h3>Is it a gift or a loan?<\/h3>\n          <p>Same money, two very different sets of consequences. Enter the amount\n            to see what each choice means${{ US: ' for the IRS', AU: ' for the ATO and Centrelink', GB: ' for HMRC' }[this.market] || ''},\n            and what it takes to make a loan actually count as one.<\/p>`\n          : `\n          <h3>Family Loan Calculator<\/h3>\n          <p>Work out repayments for the Bank of ${us ? 'Mom' : 'Mum'} &amp; Dad \u2014 and keep it\n            ${{ US: 'right with the IRS', AU: 'tax-smart with the ATO', GB: 'tax-smart with HMRC' }[this.market] || 'fair for everyone'}.<\/p>`}\n          <div id=\"answer\" class=\"answer\" hidden aria-live=\"polite\"><\/div>\n          <div id=\"compare\" class=\"compare\" hidden aria-live=\"polite\"><\/div>\n          <div id=\"nominimum\" class=\"answer\" hidden aria-live=\"polite\"><\/div>\n        <\/div>\n        <div class=\"cols\">\n          <div class=\"inputs\">\n            <div class=\"field\">\n              <label for=\"amount\">Loan amount<\/label>\n              <input id=\"amount\" type=\"number\" inputmode=\"decimal\" min=\"${LIMITS.amount[0]}\" max=\"${LIMITS.amount[1]}\" step=\"100\" value=\"20000\" autocomplete=\"off\">\n              <input id=\"amount-slider\" type=\"range\" min=\"500\" max=\"250000\" step=\"500\" value=\"20000\" aria-label=\"Loan amount slider\">\n              <div class=\"error\" id=\"amount-err\" hidden><\/div>\n            <\/div>\n            <div class=\"grid2\">\n              <div class=\"field\">\n                <label for=\"rate\">Interest rate (% \/ year)<\/label>\n                <input id=\"rate\" type=\"number\" inputmode=\"decimal\" min=\"0\" max=\"100\" step=\"0.1\" value=\"0\" autocomplete=\"off\">\n                <div class=\"chips\" role=\"group\" aria-label=\"Rate quick picks\">\n                  <button type=\"button\" class=\"chip\" data-rate=\"0\" aria-pressed=\"true\">0%<\/button>\n                  <button type=\"button\" class=\"chip\" id=\"afr-chip\" hidden aria-pressed=\"false\"><\/button>\n                <\/div>\n                <div class=\"error\" id=\"rate-err\" hidden><\/div>\n              <\/div>\n              <div class=\"field\">\n                <label for=\"term\">Term (months)<\/label>\n                <input id=\"term\" type=\"number\" inputmode=\"numeric\" min=\"1\" max=\"360\" step=\"1\" value=\"36\" autocomplete=\"off\">\n                <div class=\"chips\" role=\"group\" aria-label=\"Term quick picks\">\n                  <button type=\"button\" class=\"chip\" data-term=\"12\">1 yr<\/button>\n                  <button type=\"button\" class=\"chip\" data-term=\"24\">2 yrs<\/button>\n                  <button type=\"button\" class=\"chip\" data-term=\"36\" aria-pressed=\"true\">3 yrs<\/button>\n                  <button type=\"button\" class=\"chip\" data-term=\"60\">5 yrs<\/button>\n                <\/div>\n                <div class=\"error\" id=\"term-err\" hidden><\/div>\n              <\/div>\n            <\/div>\n            <div class=\"grid2\">\n              <div class=\"field\">\n                <label for=\"frequency\">Repayment frequency<\/label>\n                <select id=\"frequency\">${freqOptions}<\/select>\n              <\/div>\n              <div class=\"field\">\n                <label for=\"start\">First payment period starts<\/label>\n                <input id=\"start\" type=\"date\" value=\"${todayIso()}\">\n              <\/div>\n            <\/div>\n            ${us ? `\n            <div class=\"field\">\n              <label for=\"state\">Borrower's state<\/label>\n              <select id=\"state\">${stateOptions}<\/select>\n              <div class=\"hint\">Used for state interest-limit guidance \u2014 Chipkie's US loan agreements are governed by the borrower's state.<\/div>\n            <\/div>` : ''}\n            <div id=\"tax-panel\" aria-live=\"polite\"><\/div>\n          <\/div>\n\n          <div class=\"outputs\">\n            <div class=\"results\" aria-live=\"polite\">\n              <div class=\"headline\">\n                <div class=\"label\">Each repayment<\/div>\n                <div class=\"value\" id=\"per\">\u2014<\/div>\n                <div class=\"per\" id=\"per-unit\"><\/div>\n              <\/div>\n              <div class=\"rrow\"><span class=\"k\">Number of payments<\/span><span class=\"v\" id=\"npay\">\u2014<\/span><\/div>\n              <div class=\"rrow\"><span class=\"k\">Total interest<\/span><span class=\"v\" id=\"tint\">\u2014<\/span><\/div>\n              <div class=\"rrow\"><span class=\"k\">Total repaid<\/span><span class=\"v\" id=\"trep\">\u2014<\/span><\/div>\n              <div class=\"rrow\"><span class=\"k\">Debt-free by<\/span><span class=\"v\" id=\"payoff\">\u2014<\/span><\/div>\n              <div class=\"savings\" id=\"savings\" hidden><\/div>\n            <\/div>\n\n            <div class=\"chart-wrap\" id=\"chart-balance\"><\/div>\n            <div class=\"chart-wrap\" id=\"chart-split\"><\/div>\n\n            <div class=\"payoff\">\n              <h4>Pay it off sooner<\/h4>\n              <label for=\"extra\" style=\"font-size:0.85rem;color:var(--ck-muted)\">Extra on every payment: <strong id=\"extra-label\">+0<\/strong><\/label>\n              <input id=\"extra\" type=\"range\" min=\"0\" max=\"500\" step=\"10\" value=\"0\">\n              <div class=\"out\" id=\"payoff-out\"><\/div>\n              <div class=\"teaser\">Chipkie's early-payoff planner tracks this against real payments once your loan is set up.<\/div>\n            <\/div>\n          <\/div>\n        <\/div>\n\n        <div class=\"scenarios\" id=\"scenarios\" hidden>\n          <h4 style=\"margin:0 0 6px;font-size:0.95rem;\">Compare scenarios<\/h4>\n          <div class=\"table-scroll\" id=\"scenario-table\"><\/div>\n        <\/div>\n        <div class=\"chips\" style=\"margin-top:12px\">\n          <button type=\"button\" class=\"chip\" id=\"pin\">\uff0b Pin this scenario to compare<\/button>\n          <button type=\"button\" class=\"chip\" id=\"unpin\" hidden>Clear pinned<\/button>\n        <\/div>\n\n        <details class=\"schedule\" id=\"schedule\">\n          <summary>Full payment schedule<\/summary>\n          <div class=\"actions\">\n            <button type=\"button\" class=\"btn\" id=\"csv\">Download CSV<\/button>\n          <\/div>\n          <div class=\"table-scroll\" id=\"schedule-table\"><\/div>\n        <\/details>\n\n        <div class=\"cta\">\n          <h4>Make it official<\/h4>\n          <p>Turn this into a signed agreement with payment tracking, reminders, and a tax-ready annual summary \u2014 set up in minutes.<\/p>\n          <a class=\"go\" id=\"cta-link\" href=\"${esc(this.ctaUrl)}\" target=\"_blank\" rel=\"noopener\">Create this loan with Chipkie \u2192<\/a>\n          <ul id=\"value-stack\">\n            <li>Legally-binding contract, e-signed<\/li>\n            <li>Payment tracking &amp; friendly reminders<\/li>\n            <li>Tax-ready annual summary<\/li>\n          <\/ul>\n          <form class=\"capture\" id=\"capture\" novalidate>\n            <label class=\"visually-hidden\" for=\"lead-email\">Email address<\/label>\n            <input id=\"lead-email\" type=\"email\" placeholder=\"you@example.com\" autocomplete=\"email\">\n            <input class=\"hp\" type=\"text\" name=\"website\" tabindex=\"-1\" autocomplete=\"off\" aria-hidden=\"true\">\n            <button type=\"submit\" class=\"btn\" style=\"border-color:#fff;color:#fff\">Save my breakdown<\/button>\n            <span class=\"msg\" id=\"capture-msg\" role=\"status\"><\/span>\n          <\/form>\n        <\/div>\n        <p class=\"smallprint\" id=\"footnote\"><\/p>\n      <\/div>`;\n  }\n\n  $(id) { return this.root.getElementById(id); }\n\n  \/\/ \u2500\u2500 events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  bind() {\n    const on = (id, ev, fn) => this.$(id) && this.$(id).addEventListener(ev, fn);\n\n    on('amount', 'input', () => { this.s.amount = Number(this.$('amount').value); this.syncSlider(); this.recalc(); });\n    on('amount-slider', 'input', () => {\n      this.s.amount = Number(this.$('amount-slider').value);\n      this.$('amount').value = String(this.s.amount);\n      this.recalc();\n    });\n    on('rate', 'input', () => { this.s.rate = Number(this.$('rate').value); this.markRateChips(); this.recalc(); });\n    on('term', 'input', () => { this.s.term = Number(this.$('term').value); this.markTermChips(); this.recalc(); });\n    on('frequency', 'change', () => { this.s.frequency = this.$('frequency').value; this.recalc(); });\n    on('start', 'change', () => { this.s.start = this.$('start').value || todayIso(); this.recalc(); });\n    on('state', 'change', () => { this.s.state = this.$('state').value; this.recalc(); });\n    on('extra', 'input', () => { this.s.extra = Number(this.$('extra').value); this.renderPayoff(); });\n\n    this.root.querySelectorAll('.chip[data-rate]').forEach((b) =>\n      b.addEventListener('click', () => { this.setRate(Number(b.dataset.rate)); }));\n    this.root.querySelectorAll('.chip[data-term]').forEach((b) =>\n      b.addEventListener('click', () => {\n        this.s.term = Number(b.dataset.term);\n        this.$('term').value = String(this.s.term);\n        this.markTermChips();\n        this.recalc();\n      }));\n\n    on('afr-chip', 'click', () => {\n      const a = this.currentAfr();\n      if (a?.has_data) {\n        this.setRate(a.applicable_afr);\n        this.track('afr_fix', { rate: a.applicable_afr });\n      }\n    });\n\n    on('pin', 'click', () => this.pinScenario());\n    on('unpin', 'click', () => { this.pinned = []; this.renderScenarios(); });\n    on('csv', 'click', () => this.downloadCsv());\n    on('schedule', 'toggle', () => { if (this.$('schedule').open) this.track('schedule_open'); });\n    on('cta-link', 'click', () => this.track('cta_click', { amount: this.s.amount, rate: this.s.rate }));\n    on('capture', 'submit', (e) => { e.preventDefault(); this.submitLead(); });\n  }\n\n  setRate(r) {\n    this.s.rate = phpRound(Number(r), 2);\n    this.$('rate').value = String(this.s.rate);\n    this.markRateChips();\n    this.recalc();\n  }\n\n  syncSlider() {\n    const v = Math.min(250000, Math.max(500, this.s.amount || 500));\n    this.$('amount-slider').value = String(v);\n  }\n\n  markRateChips() {\n    this.root.querySelectorAll('.chip[data-rate]').forEach((b) =>\n      b.setAttribute('aria-pressed', String(Number(b.dataset.rate) === this.s.rate)));\n    const a = this.currentAfr();\n    this.$('afr-chip')?.setAttribute('aria-pressed', String(!!a?.has_data && a.applicable_afr === this.s.rate));\n  }\n\n  markTermChips() {\n    this.root.querySelectorAll('.chip[data-term]').forEach((b) =>\n      b.setAttribute('aria-pressed', String(Number(b.dataset.term) === this.s.term)));\n  }\n\n  \/\/ \u2500\u2500 live config (AFR \/ usury \/ prices) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  async loadConfig() {\n    const cacheKey = `ck-calc-config-${this.market}`;\n    try {\n      const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');\n      if (cached && Date.now() - cached.at < 24 * 3600 * 1000) {\n        this.applyConfig(cached.data);\n      }\n    } catch { \/* ignore bad cache *\/ }\n\n    try {\n      \/\/ data: URLs (demo\/tests) carry the payload inline \u2014 no query appended.\n      const url = this.configUrl.startsWith('data:')\n        ? this.configUrl\n        : `${this.configUrl}?market=${encodeURIComponent(this.market)}`;\n      const res = await fetch(url, { mode: 'cors' });\n      if (!res.ok) return;\n      const data = await res.json();\n      this.applyConfig(data);\n      try { localStorage.setItem(cacheKey, JSON.stringify({ at: Date.now(), data })); } catch { \/* full\/blocked *\/ }\n    } catch { \/* offline \u2192 widget stays fully functional, tax panel dormant *\/ }\n  }\n\n  applyConfig(data) {\n    if (!data || typeof data !== 'object') return;\n    this.cfg = { ...this.cfg, ...data };\n\n    \/\/ In imputed-interest mode the page's whole job is to answer \"what rate?\",\n    \/\/ so answer it on arrival rather than starting at 0% and waiting for the\n    \/\/ visitor to find the AFR chip. ONCE only, and only if they have not\n    \/\/ already typed a rate \u2014 never overwrite a person's own input.\n    if ((this.mode === 'imputed-interest' || this.mode === 'minimum-rate')\n        && !this._afrPrefilled && !this.s.rate) {\n      const a = this.currentAfr();\n      if (a?.has_data && a.applicable_afr) {\n        this._afrPrefilled = true;\n        this.setRate(a.applicable_afr);\n\n        return; \/\/ setRate recalcs\n      }\n    }\n\n    this.recalc();\n  }\n\n  \/**\n   * The headline answer, in imputed-interest mode only.\n   *\n   * Says nothing when there is no AFR data \u2014 the same rule as the rest of the\n   * tax layer and as App\\Support\\Afr: dormant beats guessed. A page that\n   * invents \"0%\" here would be telling a stranger they can lend interest-free.\n   *\/\n  updateAnswer() {\n    const el = this.$('answer');\n    if (!el) return;\n    \/\/ Serves BOTH the imputed-interest page and the minimum-rate page. The US\n    \/\/ answer to \"what's the minimum\" IS the AFR, so this deliberately reuses the\n    \/\/ one AFR read rather than growing a second path to keep in step.\n    if (this.mode !== 'imputed-interest' && this.mode !== 'minimum-rate') { el.hidden = true; return; }\n\n    const a = this.currentAfr();\n    if (!a?.has_data || !a.applicable_afr) {\n      el.hidden = true;\n\n      return;\n    }\n\n    \/\/ \"June 2026\", never the raw \"2026-06\" the API ships. An ISO month in front\n    \/\/ of a customer is the Loan Pulse defect again \u2014 caught by the browser pass\n    \/\/ both times, because it looks fine in a string assertion.\n    const month = this.monthName(this.cfg.afr?.month);\n    const monthTxt = month ? ` for ${esc(month)}` : '';\n    const stale = this.cfg.afr?.stale\n      ? ' <span class=\"answer-note\">\u2014 verify this month\\'s figure before you rely on it<\/span>'\n      : '';\n    \/\/ A cleared Term field is Number('') === 0 \u2014 never headline an AFR \"for a\n    \/\/ 0-month loan\"; ask for the term instead of asserting nonsense.\n    if (!Number.isInteger(this.s.term) || this.s.term < 1) {\n      el.hidden = false;\n      el.innerHTML = `<span class=\"answer-label\">Enter a loan term to see the minimum rate<\/span>`;\n      return;\n    }\n    el.hidden = false;\n    el.innerHTML = `<span class=\"answer-label\">Minimum rate for a ${this.s.term}-month loan<\/span>\n      <strong class=\"answer-value\">${esc(String(a.applicable_afr))}%<\/strong>\n      <span class=\"answer-note\">IRS applicable federal rate${monthTxt}<\/span>${stale}`;\n  }\n\n  \/**\n   * \"There is no minimum\" \\u2014 the AU and GB answer (minimum-rate mode only).\n   *\n   * ONLY THE US HAS A STATUTORY MINIMUM. Australia and the UK have no minimum\n   * interest rate for a private loan at all: 0% is allowed. Inventing a number\n   * for those markets to make the page symmetrical would be the worst kind of\n   * wrong \\u2014 confident, plausible, and acting on it costs the reader money.\n   * (Division 7A's benchmark rate is company-to-shareholder, not friend-to-\n   * friend, and is deliberately not cited here.)\n   *\n   * So the honest answer is \"there isn't one\" plus the constraint that DOES\n   * bite at 0%, taken from the reviewed figures the config already serves. If\n   * we hold no figure, the caveat degrades to qualitative rather than guessing.\n   *\/\n  updateMinimum() {\n    const el = this.$('nominimum');\n    if (!el) return;\n    if (this.mode !== 'minimum-rate' || this.market === 'US') { el.hidden = true; return; }\n\n    const au = this.cfg.au_tax, gb = this.cfg.gb_tax;\n    let caveat = '';\n\n    if (this.market === 'AU' && au?.gifting_free_area) {\n      const asOf = au.as_of ? ` (${esc(String(au.as_of))})` : '';\n      caveat = `But if you are on \\u2014 or heading for \\u2014 an Age Pension, an interest-free\n        loan can be treated as a gift, and Centrelink counts gifts above\n        ${this.money(au.gifting_free_area, 0)} a year${au.gifting_free_area_5yr\n          ? ` or ${this.money(au.gifting_free_area_5yr, 0)} over five years` : ''}${asOf}.\n        Charging interest, and keeping a record of repayments, is what keeps it a loan.`;\n    } else if (this.market === 'GB' && gb?.psa_basic) {\n      const asOf = gb.as_of ? ` (${esc(String(gb.as_of))})` : '';\n      caveat = `But interest you DO receive is savings income. Your Personal Savings\n        Allowance \\u2014 ${this.money(gb.psa_basic, 0)} at the basic rate${gb.psa_higher\n          ? `, ${this.money(gb.psa_higher, 0)} at the higher rate` : ''}${asOf} \\u2014\n        may cover it; anything above that is reportable to HMRC.`;\n    } else {\n      caveat = `What matters instead is that it is clearly a loan: agreed in writing,\n        with a repayment schedule you both keep to.`;\n    }\n\n    el.hidden = false;\n    el.innerHTML = `<span class=\"answer-label\">Minimum interest rate<\/span>\n      <strong class=\"answer-value\">There isn\\u2019t one<\/strong>\n      <span class=\"answer-note\">${caveat}<\/span>`;\n  }\n\n  \/**\n   * The gift-or-loan comparison (gift-or-loan mode only).\n   *\n   * Two cards, same money. Built ENTIRELY from figures the config endpoint\n   * already serves \u2014 gift_tax (US), au_tax (AU), gb_tax (GB) \u2014 because those\n   * are the reviewed ones on the Tax & compliance board. Where we do not hold a\n   * number we stay QUALITATIVE rather than reaching for one from memory: GB gets\n   * no IHT figures here for exactly that reason, matching how the existing GB\n   * advisory already hedges.\n   *\n   * Careful with the gift side. The common overstatement is \"you'll owe gift\n   * tax\" \u2014 for almost everyone the annual exclusion and then the lifetime\n   * exemption mean no tax is actually due, just a form. Saying otherwise would\n   * frighten people out of the right choice.\n   *\/\n  updateCompare() {\n    const el = this.$('compare');\n    if (!el) return;\n    if (this.mode !== 'gift-or-loan') { el.hidden = true; return; }\n\n    const amt = this.s.amount;\n    const gift = [];\n    const loan = [];\n\n    if (this.market === 'US') {\n      const ex = this.cfg.gift_tax?.annual_exclusion;\n      const asOf = this.cfg.gift_tax?.as_of ? ` (${esc(this.cfg.gift_tax.as_of)})` : '';\n      if (ex) {\n        gift.push(amt > ex\n          ? `${this.money(amt, 0)} is over the ${this.money(ex, 0)} annual exclusion${asOf}, so the excess\n             is reportable on Form 709 and counts against your lifetime exemption. Most people owe\n             <strong>no tax<\/strong> \u2014 it is paperwork, not a bill.`\n          : `${this.money(amt, 0)} is within the ${this.money(ex, 0)} annual exclusion${asOf} \u2014 nothing to\n             report, nothing to pay.`);\n      }\n      gift.push('You cannot ask for it back. A gift is final.');\n      const a = this.currentAfr();\n      loan.push(a?.has_data && a.applicable_afr\n        ? `Charge at least the applicable federal rate (<strong>${esc(String(a.applicable_afr))}%<\/strong> for\n           this term) or the IRS can treat the shortfall as interest you received anyway.`\n        : 'Charge at least the IRS applicable federal rate for the term, or the shortfall can be treated as interest you received anyway.');\n      loan.push('Put it in writing. Without a written agreement and a repayment record, the IRS can treat the whole thing as a gift.');\n    }\n\n    if (this.market === 'AU') {\n      const t = this.cfg.au_tax || {};\n      if (t.gifting_free_area) {\n        gift.push(`Counts toward the Centrelink gifting free areas (${this.money(t.gifting_free_area, 0)} a year \/\n          ${this.money(t.gifting_free_area_5yr, 0)} over 5 years${t.as_of ? `, ${esc(t.as_of)}` : ''}), which can\n          affect an Age Pension. This is an <strong>assessment<\/strong> question, not a tax bill.`);\n      }\n      gift.push('You cannot ask for it back. A gift is final.');\n      loan.push('<strong>0% is fine<\/strong> \u2014 the ATO sets no minimum rate for private family loans.');\n      loan.push('If you do charge interest, it is assessable income for you as the lender.');\n      loan.push('Put it in writing. A documented loan with repayments is what keeps it a loan rather than a gift.');\n    }\n\n    if (this.market === 'GB') {\n      const t = this.cfg.gb_tax || {};\n      gift.push('Can matter for inheritance tax depending on the amount and when it was given \u2014 worth checking your own position.');\n      gift.push('You cannot ask for it back. A gift is final.');\n      loan.push('<strong>0% is fine<\/strong> \u2014 HMRC sets no minimum rate for family loans.');\n      if (t.psa_basic) {\n        loan.push(`If you charge interest it is savings income; your Personal Savings Allowance\n          (${this.money(t.psa_basic, 0)} basic \/ ${this.money(t.psa_higher, 0)} higher${t.as_of ? `, ${esc(t.as_of)}` : ''})\n          may cover it.`);\n      }\n      loan.push('Put it in writing. A documented loan stays clearly distinct from a gift.');\n    }\n\n    if (!gift.length && !loan.length) { el.hidden = true; return; }\n\n    el.hidden = false;\n    el.innerHTML = `\n      <div class=\"compare-card\">\n        <h4>If you call it a gift<\/h4>\n        <ul>${gift.map((s) => `<li>${s}<\/li>`).join('')}<\/ul>\n      <\/div>\n      <div class=\"compare-card compare-card--loan\">\n        <h4>If you call it a loan<\/h4>\n        <ul>${loan.map((s) => `<li>${s}<\/li>`).join('')}<\/ul>\n      <\/div>`;\n  }\n\n  \/** 'YYYY-MM' \u2192 'June 2026'. Null when absent or malformed \u2014 never guessed. *\/\n  monthName(iso) {\n    if (!\/^\\d{4}-\\d{2}$\/.test(String(iso || ''))) return null;\n    const [y, m] = String(iso).split('-').map(Number);\n    if (m < 1 || m > 12) return null;\n    try {\n      return new Date(Date.UTC(y, m - 1, 15)).toLocaleDateString(\n        this.cfg.currency?.locale || 'en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });\n    } catch { return null; }\n  }\n\n  currentAfr() {\n    return afrAssess(this.s.rate, this.s.term, this.s.amount, this.cfg.afr);\n  }\n\n  \/\/ \u2500\u2500 validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  validate() {\n    const errs = {};\n    const [aMin, aMax] = LIMITS.amount;\n    if (!isFinite(this.s.amount) || this.s.amount < aMin || this.s.amount > aMax) {\n      errs.amount = `Enter an amount between ${this.money(aMin, 0)} and ${this.money(aMax, 0)}.`;\n    }\n    if (!isFinite(this.s.rate) || this.s.rate < LIMITS.rate[0] || this.s.rate > LIMITS.rate[1]) {\n      errs.rate = 'Enter a rate between 0 and 100%.';\n    }\n    if (!Number.isInteger(this.s.term) || this.s.term < LIMITS.term[0] || this.s.term > LIMITS.term[1]) {\n      errs.term = 'Enter a term between 1 and 360 months.';\n    }\n    for (const f of ['amount', 'rate', 'term']) {\n      const el = this.$(`${f}-err`);\n      if (!el) continue;\n      el.hidden = !errs[f];\n      el.textContent = errs[f] || '';\n      this.$(f)?.setAttribute('aria-invalid', String(!!errs[f]));\n    }\n    return Object.keys(errs).length === 0;\n  }\n\n  \/\/ \u2500\u2500 formatting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  money(n, digits = 2) {\n    const c = this.cfg.currency || {};\n    try {\n      return new Intl.NumberFormat(c.locale || 'en-US', {\n        style: 'currency', currency: c.code || 'USD',\n        minimumFractionDigits: digits, maximumFractionDigits: digits,\n      }).format(n);\n    } catch {\n      return `$${Number(n).toFixed(digits)}`;\n    }\n  }\n\n  dateLabel(iso) {\n    try {\n      return new Date(`${iso}T12:00:00Z`).toLocaleDateString(this.cfg.currency?.locale || 'en-US', {\n        year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC',\n      });\n    } catch { return iso; }\n  }\n\n  perUnit() {\n    return { weekly: 'per week', fortnightly: this.market === 'US' ? 'every 2 weeks' : 'per fortnight', monthly: 'per month' }[this.s.frequency];\n  }\n\n  \/\/ \u2500\u2500 the pipeline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  scheduleFor(ratePct) {\n    const end = addMonthsIso(this.s.start, this.s.term);\n    return buildSchedule({\n      amount: this.s.amount, interest: ratePct,\n      startDate: this.s.start, endDate: end, frequency: this.s.frequency,\n    });\n  }\n\n  recalc() {\n    \/\/ Before the validity gate: the answer depends on TERM and the AFR data,\n    \/\/ not on the whole scenario being valid, and it is the reason the page\n    \/\/ exists. A typo in the amount must not blank out the number they came for.\n    this.updateAnswer();\n    this.updateCompare();\n    this.updateMinimum();\n\n    if (!this.validate()) {\n      this.root.querySelector('.results').style.opacity = '0.45';\n      return;\n    }\n    this.root.querySelector('.results').style.opacity = '';\n\n    const end = addMonthsIso(this.s.start, this.s.term);\n    const n = numInstalments(this.s.start, end, this.s.frequency);\n    if (n < 1) {\n      this.$('per').textContent = '\u2014';\n      this.$('per-unit').textContent = 'Term too short for this frequency';\n      return;\n    }\n\n    const sched = this.scheduleFor(this.s.rate);\n    const per = instalmentAmount(this.s.amount, this.s.rate, this.s.start, end, this.s.frequency);\n    this.current = { sched, per, end, n };\n\n    this.$('per').textContent = this.money(per);\n    this.$('per-unit').textContent = this.perUnit();\n    this.$('npay').textContent = String(n);\n    this.$('tint').textContent = this.money(sched.totals.interest);\n    this.$('trep').textContent = this.money(sched.totals.repaid);\n    this.$('payoff').textContent = this.dateLabel(sched.rows[n - 1].due_date);\n\n    this.renderSavings(sched);\n    this.renderTaxPanel();\n    this.renderCharts(sched);\n    this.renderPayoff();\n    this.renderSchedule(sched);\n    this.updateCta();\n    this.renderFootnote();\n    this.trackDebounced('used', { amount: this.s.amount, rate: this.s.rate, term: this.s.term, frequency: this.s.frequency });\n  }\n\n  renderSavings(sched) {\n    const cmp = this.cfg.comparison;\n    const box = this.$('savings');\n    if (!cmp || !(this.s.rate < cmp.apr)) { box.hidden = true; this._bankSched = null; return; }\n    \/\/ Always refresh the comparison schedule (the balance chart overlays it)\n    \/\/ even when the savings are too small to celebrate.\n    const bank = this.scheduleFor(cmp.apr);\n    this._bankSched = bank;\n    const saved = phpRound(bank.totals.repaid - sched.totals.repaid, 2);\n    if (saved <= 10) { box.hidden = true; return; }\n    box.hidden = false;\n    box.innerHTML = `\ud83c\udf89 A family loan at ${esc(String(this.s.rate))}% saves <strong>${esc(this.money(saved))}<\/strong>\n      vs a bank loan at ${esc(String(cmp.apr))}% APR.\n      <span class=\"src\">Comparison: ${esc(cmp.source || 'bank personal-loan APR')}${cmp.as_of ? `, ${esc(cmp.as_of)}` : ''}.<\/span>`;\n  }\n\n  \/\/ \u2500\u2500 US tax intelligence \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  renderTaxPanel() {\n    const panel = this.$('tax-panel');\n    if (!panel) return;\n    if (this.market !== 'US') { this.renderNonUsTaxPanel(panel); return; }\n\n    const parts = [];\n    const a = this.currentAfr();\n    const chip = this.$('afr-chip');\n\n    if (a.has_data) {\n      const afrPct = `${a.applicable_afr}%`;\n      \/\/ \"June 2026\", never the raw \"2026-06\" the API ships. This shipped LIVE\n      \/\/ reading \"\u2026for this term (short-term (2026-06))\" while the answer block\n      \/\/ on the SAME page formatted it correctly \u2014 one call site was fixed and\n      \/\/ its sibling was not. Found by a browser pass, because an ISO month\n      \/\/ passes every string assertion you would think to write.\n      const monthName = this.monthName(this.cfg.afr?.month);\n      const month = monthName ? ` (${monthName})` : '';\n      const stale = this.cfg.afr?.stale ? ' \u2014 may be out of date, verify the current month' : '';\n      if (chip) {\n        chip.hidden = false;\n        chip.textContent = `AFR ${afrPct}`;\n        chip.title = 'Set the rate to the IRS Applicable Federal Rate for this term';\n      }\n      parts.push(`<div class=\"advice info\">IRS Applicable Federal Rate for this term\n        (${BAND_LABELS[a.band]}${month}): <strong>${esc(afrPct)}<\/strong>${esc(stale)}.<\/div>`);\n\n      if (a.below && !a.de_minimis_exempt) {\n        const gt = this.cfg.gift_tax;\n        const gtLine = gt?.annual_exclusion\n          ? ` Forgone interest may also count toward the annual gift-tax exclusion (${this.money(gt.annual_exclusion, 0)} per person${gt.as_of ? `, ${esc(gt.as_of)}` : ''}).`\n          : '';\n        parts.push(`<div class=\"advice warn\"><span class=\"title\">Below-market rate \u2014 the IRS may impute interest<\/span>\n          On loans over ${this.money(this.cfg.afr?.de_minimis ?? 10000, 0)}, charging less than the AFR can mean the IRS\n          treats the difference as taxable \"imputed\" interest to the lender (IRC \u00a77872).${gtLine}\n          <div class=\"fix\"><button type=\"button\" class=\"chip\" id=\"afr-fix\">Use the AFR rate (${esc(afrPct)})<\/button><\/div>\n        <\/div>`);\n        this.trackOnce(`afr_warning-${this.s.rate}-${this.s.amount}`, 'afr_warning', { rate: this.s.rate, amount: this.s.amount });\n      } else if (a.below && a.de_minimis_exempt) {\n        parts.push(`<div class=\"advice info\">At ${this.money(this.cfg.afr?.de_minimis ?? 10000, 0)} or less, family \"gift loans\"\n          are generally exempt from the IRS imputed-interest rules (IRC \u00a77872) \u2014 low-rate lending is usually fine at this size.<\/div>`);\n      }\n    } else if (chip) {\n      chip.hidden = true;\n    }\n\n    if (this.s.state && this.cfg.usury?.states) {\n      const u = usuryCheck(this.s.rate, this.s.state, this.cfg.usury.states);\n      if (u.status === 'over') {\n        parts.push(`<div class=\"advice alert\"><span class=\"title\">Above ${esc(US_STATES[this.s.state] || this.s.state)}'s commonly-cited interest cap<\/span>\n          ${esc(US_STATES[this.s.state] || this.s.state)}'s general usury limit is commonly cited at <strong>${esc(String(u.cap))}%<\/strong>\n          \u2014 this rate may not be enforceable there.\n          <div class=\"fix\"><button type=\"button\" class=\"chip\" id=\"usury-fix\">Reduce to ${esc(String(u.cap))}%<\/button><\/div>\n        <\/div>`);\n        this.trackOnce(`usury-${this.s.state}`, 'usury_warning', { state: this.s.state, rate: this.s.rate });\n      } else if (u.status === 'unknown' && this.s.rate >= 10) {\n        parts.push(`<div class=\"advice info\">We don't have ${esc(US_STATES[this.s.state] || this.s.state)}'s interest limit on file \u2014\n          at ${esc(String(this.s.rate))}% it's worth checking your state's usury cap.<\/div>`);\n      }\n    }\n\n    \/\/ Re-rendering destroys the element holding keyboard focus (activating\n    \/\/ \"Use the AFR rate\" via Enter dropped focus to <body>, restarting the Tab\n    \/\/ order from the page top). Remember which fix button had focus and give\n    \/\/ it back to its replacement.\n    const focusedFix = (panel.contains(document.activeElement) && document.activeElement.id) || null;\n    panel.innerHTML = parts.join('');\n    if (focusedFix) panel.querySelector('#' + focusedFix)?.focus();\n    panel.querySelector('#afr-fix')?.addEventListener('click', () => {\n      this.setRate(a.applicable_afr);\n      this.track('afr_fix', { rate: a.applicable_afr });\n    });\n    panel.querySelector('#usury-fix')?.addEventListener('click', () => {\n      const u = usuryCheck(this.s.rate, this.s.state, this.cfg.usury.states);\n      if (u.cap != null) this.setRate(u.cap);\n    });\n    this.markRateChips();\n  }\n\n  \/**\n   * AU \/ GB advisory modules \u2014 same hedged, guidance-only voice as the app's\n   * tax-summary notes. Figures come from the config endpoint (au_tax\/gb_tax)\n   * so the embed never hard-codes a reviewable number; without data the\n   * panel stays dormant, exactly like the US AFR panel.\n   *\/\n  renderNonUsTaxPanel(panel) {\n    const parts = [];\n\n    if (this.market === 'AU' && this.cfg.au_tax) {\n      const t = this.cfg.au_tax;\n      if (this.s.rate > 0) {\n        parts.push(`<div class=\"advice info\">Interest you receive is generally <strong>assessable income<\/strong>\n          for you as the lender \u2014 Chipkie's tax-ready annual summary keeps the record for tax time.<\/div>`);\n      } else if (t.gifting_free_area) {\n        parts.push(`<div class=\"advice info\">No ATO minimum rate applies to private family loans \u2014 <strong>0% is fine<\/strong>.\n          A written agreement keeps it clearly a loan rather than a gift, which can matter for Age Pension\n          gifting rules (gifting free areas: ${this.money(t.gifting_free_area, 0)} a year \/\n          ${this.money(t.gifting_free_area_5yr, 0)} over 5 years${t.as_of ? `, ${esc(t.as_of)}` : ''}).<\/div>`);\n      }\n    }\n\n    if (this.market === 'GB' && this.cfg.gb_tax) {\n      const t = this.cfg.gb_tax;\n      if (this.s.rate > 0 && t.psa_basic) {\n        parts.push(`<div class=\"advice info\">Interest you receive counts as <strong>savings income<\/strong>.\n          Your Personal Savings Allowance (${this.money(t.psa_basic, 0)} basic rate \/\n          ${this.money(t.psa_higher, 0)} higher rate${t.as_of ? `, ${esc(t.as_of)}` : ''}) may cover some or\n          all of it \u2014 anything above that is reportable to HMRC.<\/div>`);\n      } else if (this.s.rate <= 0) {\n        parts.push(`<div class=\"advice info\">No HMRC minimum rate applies to family loans \u2014 <strong>0% is fine<\/strong>.\n          A written agreement keeps the loan clearly distinct from a gift, which can matter for\n          inheritance-tax purposes.<\/div>`);\n      }\n    }\n\n    panel.innerHTML = parts.join('');\n  }\n\n  \/\/ \u2500\u2500 charts (inline SVG, no deps) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  renderCharts(sched) {\n    const bal = this.$('chart-balance');\n    const rows = sched.rows;\n    const bank = (this.s.rate < (this.cfg.comparison?.apr ?? Infinity)) ? this._bankSched : null;\n    const W = 600; const H = 200; const P = { l: 8, r: 8, t: 8, b: 22 };\n    const n = rows.length;\n    const maxY = this.s.amount;\n    const x = (i) => P.l + (i \/ n) * (W - P.l - P.r);\n    const y = (v) => P.t + (1 - v \/ maxY) * (H - P.t - P.b);\n    const line = (rs) => `M ${x(0)} ${y(this.s.amount)} ` + rs.map((r, i) => `L ${x(i + 1)} ${y(Math.max(0, r.balance))}`).join(' ');\n\n    const bankPath = bank && bank.rows.length === n\n      ? `<path d=\"${line(bank.rows)}\" fill=\"none\" stroke=\"var(--ck-highlight)\" stroke-width=\"2\" stroke-dasharray=\"5 4\"\/>`\n      : '';\n    bal.innerHTML = `\n      <h4 id=\"chart-balance-h\">Balance over time<\/h4>\n      <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-labelledby=\"chart-balance-h\">\n        <line x1=\"${P.l}\" y1=\"${y(0)}\" x2=\"${W - P.r}\" y2=\"${y(0)}\" stroke=\"var(--ck-border)\"\/>\n        <line x1=\"${P.l}\" y1=\"${y(maxY \/ 2)}\" x2=\"${W - P.r}\" y2=\"${y(maxY \/ 2)}\" stroke=\"var(--ck-border)\" stroke-dasharray=\"2 4\"\/>\n        ${bankPath}\n        <path d=\"${line(rows)}\" fill=\"none\" stroke=\"var(--ck-accent-dark)\" stroke-width=\"2.5\"\/>\n        <text x=\"${P.l}\" y=\"${H - 6}\" font-size=\"11\" fill=\"var(--ck-muted)\">start<\/text>\n        <text x=\"${W - P.r}\" y=\"${H - 6}\" font-size=\"11\" fill=\"var(--ck-muted)\" text-anchor=\"end\">${esc(this.dateLabel(rows[n - 1].due_date))}<\/text>\n      <\/svg>\n      <div class=\"legend\">\n        <span><span class=\"sw\" style=\"background:var(--ck-accent-dark)\"><\/span>This family loan<\/span>\n        ${bankPath ? `<span><span class=\"sw\" style=\"background:var(--ck-highlight)\"><\/span>Bank loan at ${esc(String(this.cfg.comparison.apr))}% APR<\/span>` : ''}\n      <\/div>`;\n\n    const split = this.$('chart-split');\n    const ti = sched.totals.interest; const tp = sched.totals.principal;\n    const total = ti + tp;\n    const pw = total > 0 ? (tp \/ total) * 100 : 100;\n    split.innerHTML = `\n      <h4 id=\"chart-split-h\">Where each ${this.market === 'US' ? 'dollar' : 'payment'} goes<\/h4>\n      <svg viewBox=\"0 0 600 44\" role=\"img\" aria-labelledby=\"chart-split-h\">\n        <rect x=\"0\" y=\"6\" width=\"${(pw \/ 100) * 600}\" height=\"22\" rx=\"4\" fill=\"var(--ck-accent-dark)\"\/>\n        <rect x=\"${(pw \/ 100) * 600}\" y=\"6\" width=\"${600 - (pw \/ 100) * 600}\" height=\"22\" rx=\"4\" fill=\"var(--ck-highlight)\"\/>\n      <\/svg>\n      <div class=\"legend\">\n        <span><span class=\"sw\" style=\"background:var(--ck-accent-dark)\"><\/span>Principal ${esc(this.money(tp))}<\/span>\n        <span><span class=\"sw\" style=\"background:var(--ck-highlight)\"><\/span>Interest ${esc(this.money(ti))}<\/span>\n      <\/div>`;\n  }\n\n  \/\/ \u2500\u2500 early payoff \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  renderPayoff() {\n    if (!this.current) return;\n    const { sched, per } = this.current;\n    const inc = per < 50 ? 5 : per < 500 ? 10 : 50;\n    const slider = this.$('extra');\n    slider.max = String(inc * 20);\n    slider.step = String(inc);\n    if (this.s.extra > Number(slider.max)) this.s.extra = 0;\n    slider.value = String(this.s.extra);\n    this.$('extra-label').textContent = `+${this.money(this.s.extra, 0)} ${this.perUnit()}`;\n\n    const out = this.$('payoff-out');\n    if (this.s.extra <= 0) {\n      out.innerHTML = `<span style=\"color:var(--ck-muted)\">Drag to see how a little extra shortens the loan.<\/span>`;\n      return;\n    }\n    const p = earlyPayoff(sched, this.s.amount, this.s.extra);\n    if (p.state === 'pays_off_now') {\n      out.innerHTML = `That extra clears the whole loan on the first payment \u2014 debt-free by <strong>${esc(this.dateLabel(p.new_payoff))}<\/strong>.`;\n      return;\n    }\n    const saved = p.interest_saved > 0 ? ` and save <strong>${esc(this.money(p.interest_saved))}<\/strong> in interest` : '';\n    out.innerHTML = `Pay <strong>${esc(this.money(this.s.extra, 0))}<\/strong> extra ${esc(this.perUnit())} \u2192\n      debt-free <strong>${p.payments_saved} payment${p.payments_saved === 1 ? '' : 's'} sooner<\/strong>\n      (${esc(this.dateLabel(p.new_payoff))})${saved}.`;\n  }\n\n  \/\/ \u2500\u2500 scenarios \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  pinScenario() {\n    if (!this.current) {\n      \/\/ A silent no-op reads as a broken button (UX audit): say what's needed.\n      const wrap = this.$('scenarios');\n      if (wrap) {\n        wrap.hidden = false;\n        wrap.innerHTML = '<p class=\"answer-note\">Fix the highlighted fields above, then pin the scenario to compare.<\/p>';\n      }\n      return;\n    }\n    if (this.pinned.length >= 3) this.pinned.shift();\n    const { sched, per, n } = this.current;\n    this.pinned.push({\n      amount: this.s.amount, rate: this.s.rate, term: this.s.term,\n      frequency: this.s.frequency, per, n,\n      interest: sched.totals.interest, repaid: sched.totals.repaid,\n      payoff: sched.rows[n - 1].due_date,\n    });\n    this.renderScenarios();\n    this.track('pin', { count: this.pinned.length });\n  }\n\n  renderScenarios() {\n    const wrap = this.$('scenarios');\n    const unpin = this.$('unpin');\n    if (this.pinned.length === 0) { wrap.hidden = true; unpin.hidden = true; return; }\n    wrap.hidden = false; unpin.hidden = false;\n    const rows = this.pinned.map((p, i) => `<tr>\n      <td>Scenario ${String.fromCharCode(65 + i)}<\/td>\n      <td>${esc(this.money(p.amount, 0))}<\/td>\n      <td>${esc(String(p.rate))}%<\/td>\n      <td>${p.term} mo<\/td>\n      <td>${esc(this.money(p.per))} ${esc({ weekly: '\/wk', fortnightly: '\/2wk', monthly: '\/mo' }[p.frequency])}<\/td>\n      <td>${esc(this.money(p.interest))}<\/td>\n      <td>${esc(this.money(p.repaid))}<\/td>\n    <\/tr>`).join('');\n    this.$('scenario-table').innerHTML = `<table>\n      <thead><tr><th scope=\"col\">Pinned<\/th><th scope=\"col\">Amount<\/th><th scope=\"col\">Rate<\/th><th scope=\"col\">Term<\/th>\n      <th scope=\"col\">Repayment<\/th><th scope=\"col\">Interest<\/th><th scope=\"col\">Total repaid<\/th><\/tr><\/thead>\n      <tbody>${rows}<\/tbody><\/table>`;\n  }\n\n  \/\/ \u2500\u2500 schedule table + CSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  renderSchedule(sched) {\n    const rows = sched.rows;\n    const cells = (r) => `<tr><td>${r.number}<\/td><td>${esc(this.dateLabel(r.due_date))}<\/td>\n      <td>${esc(this.money(r.payment))}<\/td><td>${esc(this.money(r.interest))}<\/td>\n      <td>${esc(this.money(r.principal))}<\/td><td>${esc(this.money(r.balance))}<\/td><\/tr>`;\n    \/\/ Very long weekly schedules: elide the middle in the TABLE (CSV has it all).\n    let body;\n    if (rows.length > 120) {\n      body = rows.slice(0, 100).map(cells).join('')\n        + `<tr><td colspan=\"6\" style=\"text-align:center;color:var(--ck-muted)\">\u2026 ${rows.length - 112} payments elided \u2014 download the CSV for every row \u2026<\/td><\/tr>`\n        + rows.slice(-12).map(cells).join('');\n    } else {\n      body = rows.map(cells).join('');\n    }\n    this.$('schedule-table').innerHTML = `<table>\n      <thead><tr><th scope=\"col\">#<\/th><th scope=\"col\">Due<\/th><th scope=\"col\">Payment<\/th>\n      <th scope=\"col\">Interest<\/th><th scope=\"col\">Principal<\/th><th scope=\"col\">Balance<\/th><\/tr><\/thead>\n      <tbody>${body}<\/tbody><\/table>`;\n  }\n\n  downloadCsv() {\n    if (!this.current) return;\n    const { sched } = this.current;\n    const lines = ['number,due_date,payment,interest,principal,balance'];\n    for (const r of sched.rows) {\n      lines.push([r.number, r.due_date, r.payment.toFixed(2), r.interest.toFixed(2), r.principal.toFixed(2), r.balance.toFixed(2)].join(','));\n    }\n    lines.push('', `# Chipkie family loan calculator \u2014 ${this.s.amount} at ${this.s.rate}% over ${this.s.term} months (${this.s.frequency})`);\n    const blob = new Blob([lines.join('\\n')], { type: 'text\/csv' });\n    const a = document.createElement('a');\n    a.href = URL.createObjectURL(blob);\n    a.download = 'chipkie-family-loan-schedule.csv';\n    a.click();\n    setTimeout(() => URL.revokeObjectURL(a.href), 5000);\n    this.track('csv');\n  }\n\n  \/\/ \u2500\u2500 funnel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  updateCta() {\n    const q = new URLSearchParams({\n      mode: 'chat',\n      market: this.market,\n      amount: String(this.s.amount),\n      rate: String(this.s.rate),\n      frequency: this.s.frequency,\n      term_months: String(this.s.term),\n      ck_src: 'calculator',\n      ck_calc: `a${this.s.amount}-r${this.s.rate}-t${this.s.term}-${this.s.frequency}`,\n    });\n    if (this.s.state) q.set('state', this.s.state);\n    for (const [k, v] of Object.entries(pageAttribution())) {\n      if (k !== 'referrer') q.set(k, v);\n    }\n    this.$('cta-link').href = `${this.ctaUrl}?${q.toString()}`;\n\n    const price = this.cfg.contract?.available && this.cfg.contract?.price;\n    const stack = this.$('value-stack');\n    if (price && !stack.querySelector('.price-li')) {\n      const li = document.createElement('li');\n      li.className = 'price-li';\n      li.textContent = `Contract from ${this.money(price)} \u2014 no subscription needed`;\n      stack.appendChild(li);\n    }\n    \/\/ Full markets (AU) also sell the automated-payments subscription.\n    const premium = this.cfg.premium?.available && this.cfg.premium?.price;\n    if (premium && !stack.querySelector('.premium-li')) {\n      const li = document.createElement('li');\n      li.className = 'premium-li';\n      li.textContent = `Optional automated payments from ${this.money(premium)}\/mo`;\n      stack.appendChild(li);\n    }\n  }\n\n  async submitLead() {\n    const email = this.$('lead-email').value.trim();\n    const msg = this.$('capture-msg');\n    if (!\/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$\/.test(email)) {\n      msg.textContent = 'Please enter a valid email.';\n      return;\n    }\n    const { sched, per, n } = this.current || {};\n    const body = {\n      email,\n      market: this.market,\n      state: this.s.state || null,\n      website: this.root.querySelector('.hp').value, \/\/ honeypot\n      scenario: {\n        amount: this.s.amount, rate: this.s.rate, term_months: this.s.term,\n        frequency: this.s.frequency, start: this.s.start,\n        per_payment: per ?? null, payments: n ?? null,\n        total_interest: sched?.totals.interest ?? null,\n        total_repaid: sched?.totals.repaid ?? null,\n      },\n      attribution: pageAttribution(),\n    };\n    msg.textContent = 'Saving\u2026';\n    try {\n      const res = await fetch(this.leadUrl, {\n        method: 'POST', mode: 'cors',\n        headers: { 'Content-Type': 'application\/json', Accept: 'application\/json' },\n        body: JSON.stringify(body),\n      });\n      if (!res.ok) throw new Error(String(res.status));\n      msg.textContent = 'Saved! Grab the CSV above for a copy now \u2014 and watch your inbox.';\n      this.$('lead-email').value = '';\n      this.track('email_capture');\n    } catch {\n      msg.textContent = 'Couldn\u2019t save right now \u2014 the Download CSV button above works offline.';\n    }\n  }\n\n  \/\/ \u2500\u2500 analytics (dataLayer only; silent no-op without it) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  track(event, params = {}) {\n    if (!this.analyticsOn) return;\n    try { (window.dataLayer = window.dataLayer || []).push({ event: `ck_calc_${event}`, ...params }); } catch { \/* no-op *\/ }\n  }\n\n  trackOnce(key, event, params) {\n    if (this._eventsSent[key]) return;\n    this._eventsSent[key] = true;\n    this.track(event, params);\n  }\n\n  trackDebounced(event, params) {\n    clearTimeout(this._tdb);\n    this._tdb = setTimeout(() => this.track(event, params), 800);\n  }\n\n  renderFootnote() {\n    const bits = ['Figures are a guide only and match how Chipkie schedules loans (reducing-balance).'];\n    if (this.market === 'US') {\n      bits.push('AFR, imputed-interest, gift-tax and state interest-limit notes are general guidance, not tax or legal advice \u2014 please verify current IRS and state rules before relying on them.');\n      if (this.cfg.usury?.as_of) bits.push(`State limits last reviewed ${this.cfg.usury.as_of}.`);\n    } else if (this.market === 'AU') {\n      bits.push('ATO and Age Pension notes are general guidance, not tax or financial advice \u2014 please verify current ATO and Services Australia rules before relying on them.');\n    } else if (this.market === 'GB') {\n      bits.push('Savings-income and inheritance-tax notes are general guidance, not tax advice \u2014 please verify current HMRC rules before relying on them.');\n    }\n    this.$('footnote').textContent = bits.join(' ');\n  }\n}\n\nif (!customElements.get('chipkie-loan-calc')) {\n  customElements.define('chipkie-loan-calc', ChipkieLoanCalc);\n}\n\n})();\n<\/script>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-773117a elementor-widget__width-initial elementor-widget elementor-widget-image\" data-id=\"773117a\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"image.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<img fetchpriority=\"high\" decoding=\"async\" width=\"768\" height=\"960\" src=\"https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2024\/04\/how-to-calculate-interest-rates-768x960.jpg\" class=\"attachment-medium_large size-medium_large wp-image-2425\" alt=\"how to calculate interest rates\" srcset=\"https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2024\/04\/how-to-calculate-interest-rates-768x960.jpg 768w, https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2024\/04\/how-to-calculate-interest-rates-240x300.jpg 240w, https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2024\/04\/how-to-calculate-interest-rates-819x1024.jpg 819w, https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2024\/04\/how-to-calculate-interest-rates.jpg 1080w\" sizes=\"(max-width: 768px) 100vw, 768px\" \/>\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-8f9d1f7 e-flex e-con-boxed e-con e-parent\" data-id=\"8f9d1f7\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-7b9ae67 elementor-widget elementor-widget-text-editor\" data-id=\"7b9ae67\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"text-editor.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<h3><b>How to use this calculator<\/b><\/h3><ul data-path-to-node=\"18\"><li><p data-path-to-node=\"18,0,0\"><b>Loan Amount:<\/b> Enter the total principal amount being lent (e.g., $50,000).<\/p><\/li><li><p data-path-to-node=\"18,1,0\"><b>Interest Rate:<\/b> Enter the agreed annual interest rate. If this is a &#8220;Bank of Mum and Dad&#8221; gift or soft loan, simply enter <b>0<\/b>.<\/p><\/li><li><p data-path-to-node=\"18,2,0\"><b>Loan Term:<\/b> Enter the total number of months the borrower has to pay the money back (e.g., 60 months for a 5-year loan).<\/p><\/li><\/ul>\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-86f00e5 e-flex e-con-boxed e-con e-parent\" data-id=\"86f00e5\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t<div class=\"elementor-element elementor-element-034853b e-con-full e-flex e-con e-child\" data-id=\"034853b\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-550fa23 elementor-widget elementor-widget-text-editor\" data-id=\"550fa23\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"text-editor.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<h3><b>Why do I need to calculate repayments for a family loan?<\/b><\/h3>\n<p data-path-to-node=\"19\">Even if you trust your family member implicitly, a verbal agreement often leads to confusion. &#8220;Pay me back when you can&#8221; is the most dangerous phrase in family finance.<\/p>\n<p data-path-to-node=\"20\">Using a calculator to set a fixed repayment amount creates:<\/p>\n<ol start=\"1\" data-path-to-node=\"21\">\n<li>\n<p data-path-to-node=\"21,0,0\"><b>Discipline:<\/b> The borrower can budget for the expense.<\/p>\n<\/li>\n<li>\n<p data-path-to-node=\"21,1,0\"><b>Transparency:<\/b> Both parties know exactly when the debt will be cleared.<\/p>\n<\/li>\n<li>\n<p data-path-to-node=\"21,2,0\"><b>Fairness:<\/b> If there are siblings, documenting the loan ensures no accusations of favoritism later.<\/p>\n<\/li>\n<\/ol>\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-f697cce e-con-full e-flex e-con e-child\" data-id=\"f697cce\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t<div class=\"elementor-element elementor-element-51ad7d4 e-con-full e-flex e-con e-child\" data-id=\"51ad7d4\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-12adb0a elementor-widget elementor-widget-text-editor\" data-id=\"12adb0a\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"text-editor.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<h3><b>Can I lend money to family at 0% interest?<\/b><\/h3>\n<p data-path-to-node=\"22\">Yes. In Australia, you are generally free to lend money to family members at whatever interest rate you choose, including 0%. This is often called an interest-free loan.<\/p>\n<p data-path-to-node=\"23\">However, if you do charge interest, that interest is considered <b>taxable income<\/b> by the ATO and must be declared on your tax return. The borrower generally cannot claim a tax deduction for the interest unless the loan is used for income-producing purposes (like buying an investment property).<\/p>\n<p data-path-to-node=\"24\"><i>&gt; <b>Note:<\/b> Chipkie does not provide tax advice. Please consult a qualified accountant regarding your specific situation.<\/i><\/p>\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-18c90f7 e-con-full e-flex e-con e-child\" data-id=\"18c90f7\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-a11fdcd elementor-widget elementor-widget-text-editor\" data-id=\"a11fdcd\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"text-editor.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<h3><b>Turning these numbers into a legal agreement<\/b><\/h3>\n<p data-path-to-node=\"25\">Calculating the repayment is step one. Step two is documenting it.<\/p>\n<p data-path-to-node=\"26\">Without a written agreement, a transfer of money can be mistaken for a gift by the courts (in family law disputes) or by the ATO. A formal <b>Family Loan Agreement<\/b> protects both the lender&#8217;s capital and the borrower&#8217;s relationship.<\/p>\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-b2fb5c2 e-flex e-con-boxed e-con e-parent\" data-id=\"b2fb5c2\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;background_background&quot;:&quot;classic&quot;}\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t<div class=\"elementor-element elementor-element-9c228ee e-con-full e-flex e-con e-child\" data-id=\"9c228ee\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-12a94f7 elementor-widget elementor-widget-heading\" data-id=\"12a94f7\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"heading.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<h2 class=\"elementor-heading-title elementor-size-default\">Chipkie is free for everyone!<\/h2>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-606c919 elementor-widget elementor-widget-text-editor\" data-id=\"606c919\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"text-editor.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<p>At Chipkie, it\u2019s our mission to see your communities flourish through uncertain times. And as a country renowned for looking out for our mates, we want to make it easier to lend a hand when the people we love need it most. That\u2019s why it\u2019s 100% free to join, no hidden catches, no hidden fee\u2019s. Just good Karma.<\/p>\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-7229bf4 elementor-align-left elementor-mobile-align-center elementor-widget elementor-widget-button\" data-id=\"7229bf4\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"button.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<div class=\"elementor-button-wrapper\">\n\t\t\t\t\t<a class=\"elementor-button elementor-button-link elementor-size-sm\" href=\"https:\/\/chipkie.com\/uk\/pricing\/\">\n\t\t\t\t\t\t<span class=\"elementor-button-content-wrapper\">\n\t\t\t\t\t\t\t\t\t<span class=\"elementor-button-text\">Find out more<\/span>\n\t\t\t\t\t<\/span>\n\t\t\t\t\t<\/a>\n\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-680bc08 e-con-full e-flex e-con e-child\" data-id=\"680bc08\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-d2f2888 elementor-widget elementor-widget-image\" data-id=\"d2f2888\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"image.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<img decoding=\"async\" width=\"600\" height=\"463\" data-src=\"https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2023\/12\/high-five.png\" class=\"attachment-medium_large size-medium_large wp-image-1204 lazyload\" alt=\"High five for Chipkie!\" data-srcset=\"https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2023\/12\/high-five.png 600w, https:\/\/chipkie.com\/uk\/wp-content\/uploads\/sites\/9\/2023\/12\/high-five-300x232.png 300w\" data-sizes=\"(max-width: 600px) 100vw, 600px\" src=\"data:image\/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==\" style=\"--smush-placeholder-width: 600px; --smush-placeholder-aspect-ratio: 600\/463;\" \/>\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>Family Loan Repayment Calculator by Chipkie Planning a loan between friends or family members? Standard bank calculators often fail because they assume high interest rates and rigid terms. Get Started The Chipkie Family Loan Calculator is designed specifically for private lending Whether you are charging commercial interest or providing an interest-free loan (0% interest) to &#8230; <a title=\"Family Loan Calculator\" class=\"read-more\" href=\"https:\/\/chipkie.com\/uk\/family-loan-calculator\/\" aria-label=\"Read more about Family Loan Calculator\">Read more<\/a><\/p>\n","protected":false},"author":3,"featured_media":0,"parent":0,"menu_order":10,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-2986","page","type-page","status-publish"],"_links":{"self":[{"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/pages\/2986","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/comments?post=2986"}],"version-history":[{"count":65,"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/pages\/2986\/revisions"}],"predecessor-version":[{"id":3587,"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/pages\/2986\/revisions\/3587"}],"wp:attachment":[{"href":"https:\/\/chipkie.com\/uk\/wp-json\/wp\/v2\/media?parent=2986"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}