(function(){ window.VB_CALC_VERSION = "2.5.8"; /* v2.5.8 (Oct 2026): - Generic product names: "First-Lien Line (all income deposited)" and "HELOC Chunking". Page labels from the host page are relabeled at load. - Disclosure 1 now says it is not financial, tax, or legal advice. - No invented minimum payments. A debt with a blank minimum, or one that does not cover its monthly interest, blocks the run with a message. - Card balance left after a 0% promo stays on the card only with a user-entered post-promo APR and minimum; otherwise it moves to the line if it fits, or the strategy is marked as not workable. Never guessed. - Home-secured debt warning when a strategy moves non-home debt onto the line secured by the home. */ /* v2.5.7 (Oct 2026): - 0% promo length no longer defaults to 12 months when left blank. A float is modeled only when you enter the promo length from your card terms. - New optional input: back-to-back 0% promos for the expenses float. When one promo ends and its balance is paid (or rolled to the line), the next card's promo starts. Default is 1 (a single promo). - Clearer wording: the expenses float needs no balance transfer and no fee. */ /* v2.5.6 (Oct 2026): - No invented defaults: a blank credit line limit used to become 20% of the mortgage balance, and a blank line rate used to become the mortgage rate. Both silently favored the line strategies. Now a strategy that needs a line is shown as "enter a credit line rate/limit" instead of being guessed. - Neutral result copy: the winner is described only by its computed cost and payoff time ("lowest estimated total cost"), not as the "stronger play". - Disclosures block added under the results. */ /* v2.5.5 (Oct 2026): - first-lien line interest now uses the average daily balance (paycheck lands on day 1, spending drawn evenly through the month), the same way the chunking line works. Before, it charged a full month on the end balance. - Shared helper vbAvgPositive() handles months where the line touches zero. - first-lien line no longer starts with a free safety buffer of cash; the buffer is built from income, same as chunking. - 0% balance transfer now WAITS like the 0% expenses float: surplus fights interest-bearing balances first, the card is paid when nothing else charges interest or when it would not fit the line at promo end, and any remainder rolls to the line at expiry. - Optional "Card APR After Promo": if the line cannot absorb the rollover, the remainder stays on the card at that APR instead of failing silently. Left blank, the strategy is reported as not paid off. - Each simulation returns a cash ledger (cashIn, endCash, principal) used by the invariant tests. */ /* Average of a balance that moves in a straight line from a to b during the month, counting only the part above zero. */ function vbAvgPositive(a, b) { if (a <= 0 && b <= 0) return 0; if (a >= 0 && b >= 0) return (a + b) / 2; if (a < 0) return (b * b) / (2 * (b - a)); return (a * a) / (2 * (a - b)); } /* v2.5.4 (Oct 2026): - Chunks pay ANY interest-bearing debt again (highest rate first, then the mortgage), including debts cheaper than the line. A chunk is paid back fast with all cash flow, so its line interest is small while the target principal drops early. - Credit line interest is now on the AVERAGE balance: the paycheck lands in the line at the start of the month and expenses come out across the month (average = balance - income + outflows / 2, floored at 0). - Optimizer searches every chunk size in 1% steps of the line (up to 100%) AND every chunk frequency from 1 to 24 months, keeps the combination with the lowest total interest across all debts, and breaks ties toward the smaller chunk. Losing combinations are still rejected. */ /* v2.5.3 (Oct 2026) fixes: - Baseline ("stay the course") now includes interest on the other debts paid at their minimums, not just the mortgage. Before, strategies that carried every debt were compared against a mortgage-only number. - Chunks only pay debts with a rate HIGHER than the credit line. Moving a cheaper debt onto the line is negative arbitrage. - A strategy is only marked Recommended if it costs less than staying the course. The optimizer rejects chunk sizes that cost more than baseline. */ /* ============================================================ v2.5.2 (Oct 2026) changelog: - Years remaining now runs WITH the simulation: each Mortgage, HELOC, and Auto loan tracks its remaining months and counts down monthly. On a schedule-level payment the final month clears the last few dollars, so the debt ends exactly at its term (no leftover or overrun). Extra payments/chunks still pay it off earlier (the payment stays the same, like a real loan). - When a calculated payment is recomputed (0% promo ends), it uses the CURRENT remaining months, not the original term. - FIX: carried (other-property) debts were a flat outflow forever, so their payment never came back after the loan ended. They are now simulated month by month (minimums only, never attacked, not counted in payoff time or interest) and their payment returns to cash flow the month after they pay off. - FIX (chunking): once the credit line was clear, surplus cash above the cushion sat idle until the next scheduled chunk. It now goes to the highest-rate debt, then mortgage principal, so freed payments shorten the payoff. - FIX: tiny negative cash from rounding no longer ends a strategy. - Each strategy result includes per-debt payoff months (debtPayoffs). ============================================================ */ /* ============================================================ v2.5.1 (Oct 2026) changelog: - Mortgage, HELOC, and Auto loan rows get a "Yrs left" input (shown only for those types). For HELOCs it is the remaining draw/repayment term. - Amortizing math: when the payment is blank or does not cover interest, the scheduled payment is computed from balance, rate, and years remaining (replaces the interest + 1% estimate for these types). When a payment is entered it is used as is, with a warning if it cannot pay the debt off within the years remaining. - Applies to Primary and other-property (carried) debts alike. - Share links and saved scenarios include years remaining (field "years"); older links load with it blank. ============================================================ */ /* ============================================================ v2.5.0 (Oct 2026) changelog: - Debt rows now have a Type (Mortgage, HELOC, Auto loan, Credit card, Student loan, Personal loan, Other) and, for Mortgage and HELOC rows, a Property label (Primary, Rental 1, free text...). - FIX (multi-property clients): minimum payments were subtracted from cash flow but never applied to the debt balance, so large amortizing debts (a rental mortgage) never shrank and the strategies reported "not paid off". Minimums now pay down each debt every month, and a paid-off debt's minimum is freed to the strategy automatically. - Mortgages and HELOCs on properties other than the Primary are carried separately: their payments reduce cash flow, but they are not attacked by chunks, not counted in payoff time/interest, and not rolled into the first-lien line (they sit on other collateral). - Primary-property 2nd mortgages and HELOCs, and all consumer debts, are attacked highest-rate first as before (avalanche) and consolidate into the first-lien line. - A Primary "Mortgage" row that matches the main loan balance is skipped so the main loan is not counted twice. - Input validation: NaN/negative values are cleaned, rates and terms are clamped, and debts whose minimum does not cover the monthly interest get an estimated minimum (interest + 1% of balance) with a visible warning. - Layout: Other Debts is full width, one responsive row per debt. - Optimal Chunk box moved from the form into the results area. - Share links/saved scenarios carry type and property (v:2); older links still load (missing fields default to Other). - Removed em dashes from user-facing text. ============================================================ */ /* ============================================================ Velocity Banking & first-lien line Simulator: v2.3 Fixes: cash-cushion now works in chunking mode, honest "not paid off" reporting, correct totals after payoff, traditional baseline uses remaining term. New: optional 0% float modeling, optional refinance comparison, and automatic head-to-head comparison of all options with a recommended winner. v2.2: chunk size/frequency are now AUTO-CALCULATED from the user's cash flow (optimal = largest chunk the credit line allows, repeated as soon as surplus pays it back). The confusing "simulate up to" horizon input is removed - the simulation runs for the loan's remaining term. v2.2 also adds "Other Monthly Debt Payments": car loans, credit cards, etc.: which reduce the cash flow available for the strategy and can make first-lien line unviable. The 0% float trick now applies to BOTH chunking and first-lien line. v2.3: REPLACES the redundant lump-sum "Other Monthly Debt Payments" with a dynamic debt list. Users add one row per debt (name, balance, rate, minimum payment). The simulation pays minimums on all debts, then directs chunks/surplus to the highest-rate non-0% debts FIRST before mortgage principal. 0% debts receive minimum payments only. ============================================================ */ const VB_DISCLOSURES = ["This calculator is provided for educational and illustrative purposes only and is not financial, tax, or legal advice. It does not guarantee any interest rate, payment, savings, payoff date, or other outcome, and it is not an offer to lend, a commitment to lend, a rate quote, or a solicitation for any particular loan product or service. All loans are subject to credit approval, property eligibility, and lender guidelines.", "Results are estimates based entirely on the information you enter. Actual results may differ, especially if your income, spending, balances, or interest rates change.", "Credit line interest is estimated on an average daily balance, assuming your full paycheck is deposited at the start of each month and spending is drawn evenly through the month. Actual deposit timing, interest posting, and lender calculation methods may differ.", "Home equity lines of credit and first-lien lines of credit usually carry variable rates tied to an index, such as 30-day average SOFR or the Prime Rate, plus a lender margin, and may include rate floors and caps. This calculator holds every rate fixed for the entire period. A rising rate may increase your cost, and a falling rate may lower it.", "Chunking assumes a revolving credit line you can pay down and draw again. Some home equity products are closed-end or fully amortizing, have a limited draw period, or require principal and interest payments after the draw period ends. Those features are not modeled.", "The First-Lien Line estimate assumes all listed debts are paid off into the line on day one and that the line has room for any 0% card rollover. Closing costs, annual fees, and lender limits are not included unless you enter them.", "0% card strategies use the promotional rate, promotion length, number of back-to-back promotions, and any transfer fee you enter. Charging regular monthly expenses to a 0% purchase card requires no balance transfer and no fee. When a promotion ends, any remaining balance is paid from your credit line, or stays on the card only if you enter the post-promotion APR and minimum payment from your card terms. If neither applies, that strategy is shown as not workable rather than estimated. A missed payment may end a promotion early, and card approval and limits are not guaranteed.", "Your cash cushion is built from your income during the first months and is not counted as paying down debt.", "Payments on mortgages for other properties reduce your cash flow but are not part of the payoff comparison.", "These strategies shift financial responsibility to you. They depend on consistently positive monthly cash flow, and a negative month adds to the balance secured by your home.", "The recommendation shown is based only on the estimated total cost and payoff time calculated from your inputs. Please talk with a licensed loan originator about your specific situation before making any decision."]; function vbInjectDisclosures() { if (document.getElementById("vb-disclosures")) return; const anchor = document.getElementById("vb-optimal-card") || document.querySelector(".vb-result-card"); if (!anchor) return; anchor.insertAdjacentHTML("afterend", '
' + '

Important information about this calculator from The Solavi Team

' + '
    ' + VB_DISCLOSURES.map(function (t) { return "
  1. " + vbEsc(t) + "
  2. "; }).join("") + "
"); } let vbChart = null; const vbMoney = new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 }); function formatYearsMonths(months) { if (!isFinite(months) || months <= 0) return "-"; const years = Math.floor(months / 12); const remMonths = months % 12; if (years <= 0) return remMonths + " mo"; if (remMonths === 0) return years + " yr"; return years + " yr " + remMonths + " mo"; } /* ---------- Traditional amortized mortgage (baseline) ---------- */ function simulateTraditionalMortgage(balance, rateAnnual, termYears) { const r = rateAnnual / 100 / 12; const n = Math.round(termYears * 12); if (balance <= 0 || r <= 0 || n <= 0) { return { months: 0, totalInterest: 0, totalPaid: 0, payment: 0 }; } const payment = balance * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); let month = 0; let totalInterest = 0; let totalPaid = 0; let b = balance; while (b > 0 && month < n + 240) { const interest = b * r; let principal = payment - interest; if (principal < 0) principal = 0; if (principal > b) principal = b; b -= principal; totalInterest += interest; totalPaid += interest + principal; month++; if (b <= 0.01) { b = 0; break; } } return { months: month, totalInterest, totalPaid, payment }; } /* v2.5.3: status quo = mortgage on schedule + every active debt on its minimum. */ function simulateStatusQuo(startBalance, rateAnnual, termYears, debts, maxMonths) { const m = simulateTraditionalMortgage(startBalance, rateAnnual, termYears); let debtInterest = 0, last = m.months; (debts || []).forEach(function (d) { let bal = d.balance, promo = d.promoMonths || 0, pmt = d.minPayment || 0; let left = d.months > 0 ? d.months : 0, mo = 0; while (bal > 0.005 && mo < (maxMonths || 480)) { mo++; if (promo > 0) { promo--; if (promo === 0 && d.derived && left > 0) pmt = vbAmortPayment(bal, d.rate, left / 12); } const r = promo > 0 ? 0 : d.rate; const i = bal * r / 1200; bal += i; debtInterest += i; let p = Math.min(pmt, bal); if (left > 0) { left--; if (left === 0 && d.onSchedule) p = bal; } bal -= p; } if (mo > last) last = mo; }); return { months: last, mortgageInterest: m.totalInterest, debtInterest: debtInterest, totalInterest: m.totalInterest + debtInterest, totalPaid: m.totalPaid, payment: m.payment }; } /* ---------- HELOC chunking (velocity banking) ---------- */ function simulateChunkingStrategy(params) { const { startBalance, loanRateAnnual, termYears, lineRateAnnual, lineLimit, chunkSize, chunkFrequencyMonths, monthlyNetIncome, monthlyExpenses, safetyBuffer, floatAmount, floatExpenses, floatPromoMonths, floatCycles, floatFeePct, maxYears, debts, cashOnHand } = params; const rLoan = loanRateAnnual / 100 / 12; const rLine = lineRateAnnual / 100 / 12; const maxMonths = Math.round(maxYears * 12); // Required mortgage payment is based on the REMAINING term, not the sim horizon. const trad = simulateTraditionalMortgage(startBalance, loanRateAnnual, termYears); const requiredMortgagePayment = trad.payment; let loanBal = startBalance; let lineBal = 0; let month = 0; let totalInterestLoan = 0; let totalInterestLine = 0; let totalInterestDebts = 0; let totalPaid = 0; let totalFees = 0; let cashReserve = 0; let cashIn = Math.max(0, cashOnHand || 0); // v2.5.5 ledger let cardSpendTotal = 0; const startPrincipal = startBalance + (debts || []).reduce(function (t, d) { return t + (d.balance || 0); }, 0); const floatApr = Math.max(0, params.floatApr || 0); const floatMin = Math.max(0, params.floatMin || 0); // v2.5.8: user-entered, never guessed let paidOff = false; const buffer = Math.max(0, safetyBuffer || 0); // v2.3: track each debt's balance separately. Debts accrue interest // monthly; minimums are already in monthlyExpenses (totalOutflow). // 0% promo debts are minimums-only until promo expires. const debtStates = (debts || []).concat((params.carriedDebts || []).map(function (c) { return Object.assign({}, c, { carried: true }); })).map(function (d) { return { name: d.name, type: d.type || "other", rate: d.rate, bal: d.balance, minPayment: d.minPayment || 0, promoLeft: d.promoMonths || 0, carried: !!d.carried, monthsLeft: d.months > 0 ? d.months : 0, onSchedule: !!d.onSchedule, derived: !!d.derived, paidMonth: 0 }; }); // v2.3: cash on hand applied as day-one lump sum: highest-rate debts first, then mortgage principal. let cashRemaining = Math.max(0, cashOnHand || 0); if (cashRemaining > 0) { const sorted = debtStates.filter(function (d) { return !d.carried; }).sort(function (a, b) { const ra = a.promoLeft > 0 ? 0 : a.rate; const rb = b.promoLeft > 0 ? 0 : b.rate; return rb - ra; }); for (let i = 0; i < sorted.length && cashRemaining > 0.005; i++) { const d = sorted[i]; if (d.bal > 0.005) { const pay = Math.min(cashRemaining, d.bal); d.bal -= pay; cashRemaining -= pay; totalPaid += pay; } } if (cashRemaining > 0.005 && loanBal > 0.005) { const pay = Math.min(cashRemaining, loanBal); loanBal -= pay; cashRemaining -= pay; totalPaid += pay; } } // 0% float: immediate lump paydown; transfer fee capitalized into the 0% balance. let floatBal = 0; let floatMonthsLeft = 0; if (floatAmount > 0 && loanBal > 0 && Math.round(floatPromoMonths || 0) > 0) { const f = Math.min(floatAmount, loanBal); const fee = f * (floatFeePct > 0 ? floatFeePct / 100 : 0); loanBal -= f; floatBal = f + fee; floatMonthsLeft = Math.max(0, Math.round(floatPromoMonths || 0)); totalFees += fee; } // v2.3: 0% expenses float: monthly expenses put on 0% card. // Tracks separately from balance transfer; no fee on purchases. let floatExpenseBal = 0; let floatExpenseMonthsLeft = 0; const monthlyFloatExpenses = Math.max(0, floatExpenses || 0); if (monthlyFloatExpenses > 0) { floatExpenseMonthsLeft = Math.max(0, Math.round(floatPromoMonths || 0)); } // v2.5.7: back-to-back promos (user-entered count, default 1). let floatCyclesLeft = monthlyFloatExpenses > 0 && floatExpenseMonthsLeft > 0 ? Math.max(1, Math.round(floatCycles || 1)) : 0; const floatPromoLen = floatExpenseMonthsLeft; // v2.3: include debts in the loop condition const debtsActive = function () { return debtStates.some(function (d) { return d.bal > 0.005 && !d.carried; }); }; while ((loanBal > 0.005 || lineBal > 0.005 || floatBal > 0.005 || floatExpenseBal > 0.005 || debtsActive()) && month < maxMonths) { month++; // 1. Accrue interest (the 0% floats accrue none). const interestLoan = loanBal * rLoan; const lineBalStart = lineBal; // v2.5.4: line interest computed after cash flow (average daily balance) totalInterestLoan += interestLoan; // v2.3: accrue interest on each debt (0% during promo) debtStates.forEach(function (d) { if (d.bal > 0.005) { // Decrement promo; during promo the rate is 0% if (d.promoLeft > 0) { d.promoLeft--; // v2.5.2: recompute a calculated payment from the CURRENT remaining months if (d.promoLeft === 0 && d.derived && d.monthsLeft > 0) { d.minPayment = vbAmortPayment(d.bal, d.rate, d.monthsLeft / 12); } } const effectiveRate = d.promoLeft > 0 ? 0 : d.rate; if (effectiveRate > 0) { const di = d.bal * (effectiveRate / 100 / 12); d.bal += di; if (!d.carried) totalInterestDebts += di; } } }); // v2.5: minimum payments actually pay down each debt (previously // they left cash flow but never reduced the balance). Paid-off // debts stop drawing a minimum, which frees cash to the strategy. let minsPaid = 0; debtStates.forEach(function (d) { if (d.bal <= 0.005 && !d.paidMonth) { d.paidMonth = Math.max(1, month - 1); return; } if (d.bal > 0.005) { // v2.5.2: remaining term counts down with the simulation; on a // schedule-level payment the final month clears the residual. let p = Math.min(d.minPayment, d.bal); if (d.monthsLeft > 0) { d.monthsLeft--; if (d.monthsLeft === 0 && d.onSchedule) p = d.bal; } if (p > 0) { d.bal -= p; minsPaid += p; totalPaid += p; } } if (d.bal <= 0.005 && !d.paidMonth) { d.bal = 0; d.paidMonth = month; } }); // 2. Scheduled mortgage payment: only actual amounts count. let principalPayment = requiredMortgagePayment - interestLoan; if (principalPayment < 0) principalPayment = 0; if (principalPayment > loanBal) principalPayment = loanBal; const actualMortgagePaid = principalPayment + interestLoan; loanBal -= principalPayment; totalPaid += actualMortgagePaid; // 3. Net monthly cash flow builds the reserve. // v2.3: monthly expenses on 0% card don't leave cash; they accumulate on the 0% balance. let expensesOnCard = 0; if (monthlyFloatExpenses > 0 && floatExpenseMonthsLeft > 0) { expensesOnCard = Math.min(monthlyFloatExpenses, monthlyExpenses); floatExpenseBal += expensesOnCard; floatExpenseMonthsLeft--; cardSpendTotal += expensesOnCard; } const cashExpenses = monthlyExpenses - expensesOnCard; // v2.5.4: paycheck deposited into the line on day 1, outflows drawn evenly. const outflows = cashExpenses + actualMortgagePaid + minsPaid; const afterDeposit = lineBalStart - monthlyNetIncome; const avgLineBal = lineBalStart > 0.005 ? Math.min(lineBalStart, vbAvgPositive(afterDeposit, afterDeposit + outflows)) : 0; cashIn += monthlyNetIncome - cashExpenses; const interestLine = avgLineBal * rLine; totalInterestLine += interestLine; const net = monthlyNetIncome - cashExpenses - actualMortgagePaid - interestLine - minsPaid; cashReserve += net; // 4. v2.5.5: the 0% transfer WAITS while anything else charges interest, // unless it would not fit on the line at promo end. if (floatBal > 0.005) { floatMonthsLeft--; const avail = Math.max(0, cashReserve - buffer); const charging = loanBal > 0.005 || lineBal > 0.005 || debtStates.some(function (d) { return !d.carried && d.bal > 0.005 && (d.promoLeft > 0 ? 0 : d.rate) > 0; }); const tooBigForLine = floatBal + floatExpenseBal > Math.max(0, lineLimit - lineBal); if (avail > 0 && (!charging || tooBigForLine)) { const p = Math.min(avail, floatBal); floatBal -= p; cashReserve -= p; totalPaid += p; } if (floatBal > 0.005 && floatMonthsLeft <= 0) { // Promo expired: roll any remainder into the credit line. if (lineBal + floatBal <= lineLimit) { lineBal += floatBal; floatBal = 0; } else if (floatApr > 0 && floatMin > floatBal * floatApr / 1200 + 0.005) { // v2.5.5: remainder stays on the card at its post-promo APR. debtStates.push({ name: "Card after promo", type: "card", rate: floatApr, bal: floatBal, minPayment: floatMin, promoLeft: 0, carried: false, monthsLeft: 0, onSchedule: false, derived: false, paidMonth: 0 }); floatBal = 0; } else { break; // cannot absorb it: strategy fails } } } // 4b. v2.4.1: The 0% expenses balance WAITS: it accrues no interest, so every // surplus dollar fights interest-bearing balances first. The card is paid // early only once NOTHING is still charging interest (line, mortgage, and // non-0% debts all clear), or capped so it never outgrows what the // line can absorb at promo end (a real borrower winds it down rather // than letting the rollover blow the limit). Any remainder rolls to the // line when the promo expires. // (Previously this paid the 0% card whenever the line alone was clear, // which killed the float whenever surplus kept the line at zero: the // mortgage still charges interest, so paying a 0% balance first was // never optimal. Previously it also paid the 0% card with priority // every month, which meant the float never actually floated.) if (floatExpenseBal > 0.005) { const debtsCharging = debtStates.some(function (d) { if (d.carried) return false; const effRate = d.promoLeft > 0 ? 0 : d.rate; return d.bal > 0.005 && effRate > 0; }); const nothingCharging = lineBal <= 0.005 && loanBal <= 0.005 && !debtsCharging; // Projected card balance at promo end vs. room left on the line. const projectedAtExpiry = floatExpenseBal + monthlyFloatExpenses * Math.max(0, floatExpenseMonthsLeft); const lineRoom = Math.max(0, lineLimit - lineBal); const overFloat = projectedAtExpiry > lineRoom; if (nothingCharging || overFloat) { const avail = Math.max(0, cashReserve - buffer); if (avail > 0) { const p = Math.min(avail, floatExpenseBal); floatExpenseBal -= p; cashReserve -= p; totalPaid += p; } } // Promo expired: roll any remainder into the credit line. // (Previously checked the transfer float's counter, which never ticks // down when there is no transfer: the balance sat at 0% forever.) if (floatExpenseBal > 0.005 && floatExpenseMonthsLeft <= 0) { if (lineBal + floatExpenseBal <= lineLimit) { lineBal += floatExpenseBal; floatExpenseBal = 0; } else if (floatApr > 0 && floatMin > floatExpenseBal * floatApr / 1200 + 0.005) { debtStates.push({ name: "Card after promo", type: "card", rate: floatApr, bal: floatExpenseBal, minPayment: floatMin, promoLeft: 0, carried: false, monthsLeft: 0, onSchedule: false, derived: false, paidMonth: 0 }); floatExpenseBal = 0; } else { break; // cannot absorb it: strategy fails } } } // v2.5.7: next back-to-back 0% promo starts once this one is cleared. if (floatCyclesLeft > 1 && floatExpenseMonthsLeft <= 0 && floatExpenseBal <= 0.005) { floatCyclesLeft--; floatExpenseMonthsLeft = floatPromoLen; } // 5. Sweep surplus above the buffer to the line; draw if short. const avail = Math.max(0, cashReserve - buffer); if (avail > 0 && lineBal > 0.005) { const s = Math.min(avail, lineBal); lineBal -= s; cashReserve -= s; totalPaid += s; } else if (cashReserve < -0.005) { // v2.5.2: ignore float rounding dust const needed = -cashReserve; if (lineBal + needed <= lineLimit) { lineBal += needed; cashReserve = 0; } else { break; // line maxed out: strategy fails } } // 5b. v2.4.2: Smart-deploy the 0% float. During the expenses promo the // float frees cash (spending rides the 0% card instead of leaving the // account). Deploy cash above the buffer DIRECTLY against the // highest-rate debts charging more than the line: a straight // refinance of expensive debt onto 0%, which is the whole point of the // float. (Debts cheaper than the line are left for regular chunks, and // the mortgage is handled by chunking: moving them onto the line // would be negative arbitrage. Runs before the scheduled chunk so free // cash is used before drawing the line.) if (floatExpenseMonthsLeft > 0 && monthlyFloatExpenses > 0) { const sortedDebts = debtStates .filter(function (d) { if (d.carried) return false; const effRate = d.promoLeft > 0 ? 0 : d.rate; return d.bal > 0.005 && effRate > lineRateAnnual; }) .sort(function (a, b) { const ra = a.promoLeft > 0 ? 0 : a.rate; const rb = b.promoLeft > 0 ? 0 : b.rate; return rb - ra; }); for (let i = 0; i < sortedDebts.length; i++) { const availCash = cashReserve - buffer; if (availCash <= 0.005) break; const debt = sortedDebts[i]; const p = Math.min(availCash, debt.bal); if (p > 0.005) { debt.bal -= p; cashReserve -= p; totalPaid += p; } } } // 6. Periodic chunk: v2.3 pays highest-rate non-0% debts FIRST, // then mortgage principal. 0% debts get minimums only (already in cash flow). if ( chunkSize > 0 && chunkFrequencyMonths > 0 && month % chunkFrequencyMonths === 0 ) { const space = lineLimit - lineBal; let remaining = Math.min(chunkSize, space); // Pay highest-rate debts first (non-0% only; promo debts are minimums-only until promo ends) const sortedDebts = debtStates .filter(function (d) { if (d.carried) return false; const effectiveRate = d.promoLeft > 0 ? 0 : d.rate; return d.bal > 0.005 && effectiveRate > 0; // v2.5.4: all debts }) .sort(function (a, b) { const rateA = a.promoLeft > 0 ? 0 : a.rate; const rateB = b.promoLeft > 0 ? 0 : b.rate; return rateB - rateA; }); for (let i = 0; i < sortedDebts.length && remaining > 0.005; i++) { const debt = sortedDebts[i]; const p = Math.min(remaining, debt.bal); if (p > 0) { lineBal += p; // borrow from line debt.bal -= p; // pay debt remaining -= p; totalPaid += p; } } // Remainder to mortgage principal if (remaining > 0.005 && loanBal > 0.005) { const c = Math.min(remaining, loanBal); if (c > 0) { lineBal += c; loanBal -= c; } } } // 6b. v2.5.2: idle cash. Once the line is clear, cash above the // buffer used to sit unused (e.g. a carried rental payment freed at // its payoff month never sped anything up). Apply it to the // highest-rate active debt, then mortgage principal. if (lineBal <= 0.005 && floatBal <= 0.005 && floatExpenseBal <= 0.005 && cashReserve - buffer > 0.005) { let idle = cashReserve - buffer; const targets = debtStates .filter(function (d) { return !d.carried && d.bal > 0.005 && (d.promoLeft > 0 ? 0 : d.rate) > 0; }) .sort(function (a, b) { return b.rate - a.rate; }); for (let i = 0; i < targets.length && idle > 0.005; i++) { const p = Math.min(idle, targets[i].bal); targets[i].bal -= p; idle -= p; cashReserve -= p; totalPaid += p; } if (idle > 0.005 && loanBal > 0.005) { const p = Math.min(idle, loanBal); loanBal -= p; cashReserve -= p; totalPaid += p; } } // 7. Done? (v2.3: include debts and expenses float) const allDebtsPaid = !debtsActive(); if (loanBal < 1 && lineBal < 1 && floatBal < 1 && floatExpenseBal < 1 && allDebtsPaid) { loanBal = 0; lineBal = 0; floatBal = 0; floatExpenseBal = 0; debtStates.forEach(function (d) { d.bal = 0; }); paidOff = true; break; } } const totalInterest = totalInterestLoan + totalInterestLine + totalInterestDebts; return { months: month, paidOff, totalInterestLoan, totalInterestLine, totalInterestDebts, totalInterest, totalFees, totalCost: totalInterest + totalFees, totalPaid, loanRemaining: loanBal, debtPayoffs: vbDebtPayoffs(debtStates, month), lineRemaining: lineBal, floatRemaining: floatBal, ledger: { cashIn: cashIn, endCash: cashReserve, startPrincipal: startPrincipal, cardSpend: cardSpendTotal, endBalances: loanBal + lineBal + floatBal + floatExpenseBal + debtStates.reduce(function (t, d) { return t + (d.carried ? 0 : d.bal); }, 0) } }; } /* ---------- First-lien line (all income deposited) ---------- */ function simulateOffsetStrategy(params) { const { startBalance, lineRateAnnual, monthlyNetIncome, monthlyExpenses, safetyBuffer, floatAmount, floatExpenses, floatPromoMonths, floatCycles, floatFeePct, maxYears, debts, cashOnHand } = params; const r = lineRateAnnual / 100 / 12; const maxMonths = Math.round(maxYears * 12); let bal = startBalance; let month = 0; let totalInterest = 0; let totalInterestDebts = 0; let totalPaid = 0; let totalFees = 0; let cashAccount = 0; // v2.5.5: buffer is built from income, not given for free let cashIn = Math.max(0, cashOnHand || 0); let cardSpendTotal = 0; const startPrincipal = startBalance + (debts || []).reduce(function (t, d) { return t + (d.balance || 0); }, 0); // v2.3: track debts (0% promo = minimums only until promo ends) const debtStates = (debts || []).concat((params.carriedDebts || []).map(function (c) { return Object.assign({}, c, { carried: true }); })).map(function (d) { return { name: d.name, type: d.type || "other", rate: d.rate, bal: d.balance, minPayment: d.minPayment || 0, promoLeft: d.promoMonths || 0, carried: !!d.carried, monthsLeft: d.months > 0 ? d.months : 0, onSchedule: !!d.onSchedule, derived: !!d.derived, paidMonth: 0 }; }); const debtsActive = function () { return debtStates.some(function (d) { return d.bal > 0.005 && !d.carried; }); }; // v2.3: cash on hand applied as day-one lump sum: highest-rate debts first, then balance. let cashRemaining = Math.max(0, cashOnHand || 0); if (cashRemaining > 0) { const sorted = debtStates.filter(function (d) { return !d.carried; }).sort(function (a, b) { const ra = a.promoLeft > 0 ? 0 : a.rate; const rb = b.promoLeft > 0 ? 0 : b.rate; return rb - ra; }); for (let i = 0; i < sorted.length && cashRemaining > 0.005; i++) { const d = sorted[i]; if (d.bal > 0.005) { const pay = Math.min(cashRemaining, d.bal); d.bal -= pay; cashRemaining -= pay; totalPaid += pay; } } if (cashRemaining > 0.005 && bal > 0.005) { const pay = Math.min(cashRemaining, bal); bal -= pay; cashRemaining -= pay; totalPaid += pay; } } // 0% float: immediate lump paydown; transfer fee capitalized into the 0% balance. // Same trick as chunking: park debt at 0% instead of paying line rates on it. let floatBal = 0; let floatMonthsLeft = 0; if (floatAmount > 0 && bal > 0) { const f = Math.min(floatAmount, bal); const fee = f * (floatFeePct > 0 ? floatFeePct / 100 : 0); bal -= f; floatBal = f + fee; floatMonthsLeft = Math.max(0, Math.round(floatPromoMonths || 0)); totalFees += fee; } // v2.3: 0% expenses float for offset strategy let floatExpenseBal = 0; let floatExpenseMonthsLeft = 0; const monthlyFloatExpenses = Math.max(0, floatExpenses || 0); if (monthlyFloatExpenses > 0) { floatExpenseMonthsLeft = Math.max(0, Math.round(floatPromoMonths || 0)); } // v2.5.7: back-to-back promos (user-entered count, default 1). let floatCyclesLeft = monthlyFloatExpenses > 0 && floatExpenseMonthsLeft > 0 ? Math.max(1, Math.round(floatCycles || 1)) : 0; const floatPromoLen = floatExpenseMonthsLeft; while ((bal > 0.005 || floatBal > 0.005 || floatExpenseBal > 0.005 || debtsActive()) && month < maxMonths) { month++; cashAccount += monthlyNetIncome; // v2.3: expenses on 0% don't reduce cash; they accumulate on 0% balance let expensesOnCard = 0; if (monthlyFloatExpenses > 0 && floatExpenseMonthsLeft > 0) { expensesOnCard = Math.min(monthlyFloatExpenses, monthlyExpenses); floatExpenseBal += expensesOnCard; floatExpenseMonthsLeft--; cardSpendTotal += expensesOnCard; } cashIn += monthlyNetIncome - (monthlyExpenses - expensesOnCard); let aioOutflows = monthlyExpenses - expensesOnCard; // v2.5.5: drawn across the month cashAccount -= (monthlyExpenses - expensesOnCard); if (cashAccount < 0) { bal += -cashAccount; cashAccount = 0; } // v2.3: accrue debt interest (0% during promo) debtStates.forEach(function (d) { if (d.bal > 0.005) { if (d.promoLeft > 0) { d.promoLeft--; // v2.5.2: recompute a calculated payment from the CURRENT remaining months if (d.promoLeft === 0 && d.derived && d.monthsLeft > 0) { d.minPayment = vbAmortPayment(d.bal, d.rate, d.monthsLeft / 12); } } const effectiveRate = d.promoLeft > 0 ? 0 : d.rate; if (effectiveRate > 0) { const di = d.bal * (effectiveRate / 100 / 12); d.bal += di; if (!d.carried) totalInterestDebts += di; } } }); // v2.5: minimum payments actually pay down each debt (previously // they left cash flow but never reduced the balance). Paid-off // debts stop drawing a minimum, which frees cash to the strategy. let minsPaid = 0; debtStates.forEach(function (d) { if (d.bal <= 0.005 && !d.paidMonth) { d.paidMonth = Math.max(1, month - 1); return; } if (d.bal > 0.005) { // v2.5.2: remaining term counts down with the simulation; on a // schedule-level payment the final month clears the residual. let p = Math.min(d.minPayment, d.bal); if (d.monthsLeft > 0) { d.monthsLeft--; if (d.monthsLeft === 0 && d.onSchedule) p = d.bal; } if (p > 0) { d.bal -= p; minsPaid += p; totalPaid += p; } } if (d.bal <= 0.005 && !d.paidMonth) { d.bal = 0; d.paidMonth = month; } }); cashAccount -= minsPaid; aioOutflows += minsPaid; if (cashAccount < 0) { bal += -cashAccount; cashAccount = 0; } // The 0% float gets first claim on surplus above the buffer: // must kill it before the promo expires or the rate jumps. if (floatBal > 0.005) { floatMonthsLeft--; const availForFloat = Math.max(0, cashAccount - safetyBuffer); if (availForFloat > 0 && bal <= 0.005) { // v2.5.5: waits while the line charges interest const p = Math.min(availForFloat, floatBal); floatBal -= p; cashAccount -= p; totalPaid += p; } if (floatBal > 0.005 && floatMonthsLeft <= 0) { // Promo expired: roll remainder back into the balance at the regular rate. bal += floatBal; floatBal = 0; } } // v2.4: 0% expenses balance WAITS: the first-lien line balance accrues interest // and the card doesn't, so surplus fights the balance first. The card is // paid only once the balance is clear, or rolls back in at promo end. // (Previously this paid the 0% card with priority, which meant the float // never actually floated: entering expenses on 0% changed nothing.) if (floatExpenseBal > 0.005) { if (bal <= 0.005) { const availForFloat = Math.max(0, cashAccount - safetyBuffer); if (availForFloat > 0) { const p = Math.min(availForFloat, floatExpenseBal); floatExpenseBal -= p; cashAccount -= p; totalPaid += p; } } // Promo expired: roll any remainder back into the balance. // (Previously checked the transfer float's counter, which never ticks // down when there is no transfer: the balance sat at 0% forever.) if (floatExpenseBal > 0.005 && floatExpenseMonthsLeft <= 0) { bal += floatExpenseBal; floatExpenseBal = 0; } } // v2.5.7: next back-to-back 0% promo starts once this one is cleared. if (floatCyclesLeft > 1 && floatExpenseMonthsLeft <= 0 && floatExpenseBal <= 0.005) { floatCyclesLeft--; floatExpenseMonthsLeft = floatPromoLen; } if (cashAccount > safetyBuffer) { let sweep = cashAccount - safetyBuffer; // v2.3: pay highest-rate non-0% debts first (promo debts are minimums-only until promo ends) const sortedDebts = debtStates .filter(function (d) { if (d.carried) return false; const effectiveRate = d.promoLeft > 0 ? 0 : d.rate; return d.bal > 0.005 && effectiveRate > 0; }) .sort(function (a, b) { const rateA = a.promoLeft > 0 ? 0 : a.rate; const rateB = b.promoLeft > 0 ? 0 : b.rate; return rateB - rateA; }); for (let i = 0; i < sortedDebts.length && sweep > 0.005; i++) { const debt = sortedDebts[i]; const p = Math.min(sweep, debt.bal); debt.bal -= p; sweep -= p; totalPaid += p; } // Remainder sweeps to the main balance if (sweep > 0.005) { bal -= sweep; if (bal < 0) { cashAccount = safetyBuffer + (-bal); bal = 0; } else { cashAccount = safetyBuffer; } totalPaid += sweep; } else { cashAccount = safetyBuffer; } } // v2.5.5: average daily balance. bal is the end-of-month balance after the // sweep; right after the paycheck landed it was bal - outflows. const interest = vbAvgPositive(bal - aioOutflows, bal) * r; totalInterest += interest; bal += interest; totalPaid += interest; if (bal <= 0.01 && floatBal <= 0.01 && floatExpenseBal <= 0.01 && !debtsActive()) { bal = 0; floatBal = 0; floatExpenseBal = 0; debtStates.forEach(function (d) { d.bal = 0; }); break; } } const allPaid = bal <= 0.01 && floatBal <= 0.01 && floatExpenseBal <= 0.01 && !debtsActive(); const combinedInterest = totalInterest + totalInterestDebts; return { months: month, paidOff: allPaid, totalInterestLine: totalInterest, totalInterestDebts, totalInterest: combinedInterest, totalFees: totalFees, totalCost: combinedInterest + totalFees, totalPaid, remaining: bal, debtPayoffs: vbDebtPayoffs(debtStates, month), floatRemaining: floatBal, ledger: { cashIn: cashIn, endCash: cashAccount, startPrincipal: startPrincipal, cardSpend: cardSpendTotal, endBalances: Math.max(0, bal) + floatBal + floatExpenseBal + debtStates.reduce(function (t, d) { return t + (d.carried ? 0 : d.bal); }, 0) } }; } /* ---------- Rendering ---------- */ function vbScenarioLine(name, s, maxYears) { if (!s || s.months === 0) return name + ": -"; if (!s.paidOff) { return name + ": not paid off within " + maxYears + " yrs"; } return ( name + ": Payoff " + formatYearsMonths(s.months) + " | Interest $" + vbMoney.format(Math.round(s.totalInterest)) + (s.totalFees > 0 ? " (+$" + vbMoney.format(Math.round(s.totalFees)) + " fees)" : "") ); } function renderVelocityTable(trad, strategies) { const tbody = document.querySelector("#vb-summary-table tbody"); if (!tbody) return; tbody.innerHTML = ""; if (!trad || trad.months === 0) return; const addRow = function (label, months, interest, fees, principal, paidOff, maxYears) { const tr = document.createElement("tr"); const cells = [ label, paidOff === false ? "Not paid off in " + maxYears + " yrs" : formatYearsMonths(months), "$" + vbMoney.format(Math.round(interest)), "$" + vbMoney.format(Math.round(principal + interest + (fees || 0))) ]; cells.forEach(function (text) { const td = document.createElement("td"); td.textContent = text; tr.appendChild(td); }); tbody.appendChild(tr); }; const principal = trad.startBalance || 0; const maxYears = trad.maxYears || 30; addRow("Traditional Mortgage", trad.months, trad.totalInterest, 0, principal, true, maxYears); strategies.forEach(function (st) { addRow(st.label, st.s.months, st.s.totalInterest, st.s.totalFees || 0, principal, st.s.paidOff, maxYears); }); } function renderVelocityChart(trad, strategies) { const canvas = document.getElementById("vb-chart"); if (!canvas || typeof Chart === "undefined") return; // v2.5: no crash if Chart.js is missing const ctx = canvas.getContext("2d"); if (!trad || trad.months === 0) { if (vbChart) vbChart.destroy(); return; } const labels = ["Traditional"].concat(strategies.map(function (st) { return st.short; })); const payoffData = [trad.months / 12].concat( strategies.map(function (st) { return st.s.paidOff ? st.s.months / 12 : null; }) ); const interestData = [trad.totalInterest].concat( strategies.map(function (st) { return st.s.paidOff ? st.s.totalInterest : null; }) ); const strip = document.querySelector(".vb-chart-strip"); if (strip) strip.textContent = "Traditional vs Strategies"; if (vbChart) vbChart.destroy(); vbChart = new Chart(ctx, { type: "bar", data: { labels: labels, datasets: [ { label: "Payoff Time (years)", data: payoffData, backgroundColor: "rgba(34, 197, 94, 0.8)", yAxisID: "y" }, { label: "Total Interest ($)", data: interestData, backgroundColor: "rgba(59, 130, 246, 0.75)", yAxisID: "y1" } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: "#e5e7eb", font: { size: 11 } } } }, scales: { x: { ticks: { color: "#cbd5f5", font: { size: 10 } }, grid: { color: "rgba(30, 64, 175, 0.4)" } }, y: { position: "left", ticks: { color: "#cbd5f5", font: { size: 10 }, callback: function (value) { return value.toFixed(1) + " yr"; } }, grid: { color: "rgba(30, 64, 175, 0.35)" } }, y1: { position: "right", ticks: { color: "#e5e7eb", font: { size: 10 }, callback: function (value) { return "$" + (value / 1000).toFixed(0) + "k"; } }, grid: { drawOnChartArea: false } } } } }); } function renderVelocityResults(trad, chunk, aio, refiEntry, floatAmount, floatExpenses) { const tradSummaryEl = document.getElementById("vb-trad-summary"); const velSummaryEl = document.getElementById("vb-velocity-summary"); const savingsMainEl = document.getElementById("vb-savings-main"); const savingsDetailEl = document.getElementById("vb-savings-detail"); const narrativeEl = document.getElementById("vb-narrative"); // Retitle the comparison card (was "Selected Strategy"). if (velSummaryEl) { const labelEl = velSummaryEl.previousElementSibling; if (labelEl && /selected strategy/i.test(labelEl.textContent || "")) { labelEl.textContent = "Strategy Comparison"; } } const resetAll = function () { tradSummaryEl.textContent = "Payoff: - | Interest: -"; velSummaryEl.innerHTML = "Payoff: -"; savingsMainEl.textContent = "Savings: -"; savingsDetailEl.textContent = "Time Saved: - | Interest Saved: -"; if (narrativeEl) { narrativeEl.textContent = "Enter your details above and run a simulation to see how each strategy compares to your current payoff path."; } renderVelocityTable(null, []); renderVelocityChart(null, []); }; if (!trad || trad.months === 0) { resetAll(); return; } tradSummaryEl.textContent = "Payoff: " + formatYearsMonths(trad.months) + " | Interest: $" + vbMoney.format(Math.round(trad.totalInterest)); const maxYears = trad.maxYears || 30; // Refinance (when modeled) is listed right after the current mortgage, // ahead of the payoff strategies. const strategies = []; if (refiEntry) strategies.push(refiEntry); strategies.push( { label: "HELOC Chunking", short: "Chunking", s: chunk }, { label: "First-Lien Line (all income deposited)", short: "First-Lien Line", s: aio } ); // Winner = lowest total cost (interest + fees) among strategies that actually paid off. let winner = null; strategies.forEach(function (st) { if (st.s && st.s.paidOff && st.s.totalCost < trad.totalInterest) { // v2.5.3 if (!winner || st.s.totalCost < winner.s.totalCost) winner = st; } }); velSummaryEl.innerHTML = strategies .map(function (st) { const star = winner === st ? ' ★ Recommended' : ""; if (!st.s) return "
" + st.label + ": enter a credit line rate" + (st.short === "Chunking" ? " and limit" : "") + " to compare
"; return "
" + vbScenarioLine(st.label, st.s, maxYears) + star + "
"; }) .join(""); if (winner) { const interestSaved = trad.totalInterest - winner.s.totalCost; const timeSavedMonths = trad.months - winner.s.months; const timeText = timeSavedMonths > 0 ? formatYearsMonths(timeSavedMonths) + " faster" : timeSavedMonths < 0 ? formatYearsMonths(-timeSavedMonths) + " slower" : "about the same time"; savingsMainEl.textContent = (interestSaved >= 0 ? "Savings: $" : "Difference: $") + vbMoney.format(Math.round(Math.abs(interestSaved))); savingsDetailEl.textContent = winner.label + ": " + timeText + " | Interest saved: $" + vbMoney.format(Math.round(interestSaved)); if (narrativeEl) { let text = "Based on your inputs, " + winner.label + " has the lowest estimated total cost: it may pay off your balances " + timeText + " and cost about $" + vbMoney.format(Math.round(Math.abs(interestSaved))) + " less than keeping your current loans as they are."; if (floatAmount > 0) { text += " (Includes a $" + vbMoney.format(Math.round(floatAmount)) + " 0% float."; if (floatExpenses > 0) { text += " Plus $" + vbMoney.format(Math.round(floatExpenses)) + "/mo expenses on 0%.)"; } else { text += ")"; } } else if (floatExpenses > 0) { text += " (Includes $" + vbMoney.format(Math.round(floatExpenses)) + "/mo expenses on 0%.)"; } narrativeEl.textContent = text; } } else { savingsMainEl.textContent = "Savings: -"; savingsDetailEl.textContent = "No compared strategy costs less than your current loans with these inputs"; if (narrativeEl) { narrativeEl.textContent = !strategies.some(function (st) { return !!st.s; }) ? "Enter a credit line rate and limit to compare strategies. The calculator does not assume a rate or limit for you." : "Under these assumptions, no strategy costs less than keeping your current loans as they are. More monthly surplus or a lower credit line rate may change that."; } } const shown = strategies.filter(function (st) { return !!st.s; }); renderVelocityTable(trad, shown); renderVelocityChart(trad, shown); } /* ---------- Input wiring ---------- */ function vbInjectFloatInputs() { if (document.getElementById("vb-float-amount")) return; const anchorInput = document.getElementById("vb-max-years"); if (!anchorInput) return; const anchorGroup = anchorInput.closest(".vb-form-group") || anchorInput; // v2.3: wrap float in a collapsible "Advanced" section to avoid cluttering the core flow anchorGroup.insertAdjacentHTML( "afterend", '
' + '' + '" + "
" ); // Toggle collapsible const toggle = document.getElementById("vb-float-toggle"); const fields = document.getElementById("vb-float-fields"); if (toggle && fields) { toggle.addEventListener("click", function () { const isHidden = fields.style.display === "none"; fields.style.display = isHidden ? "block" : "none"; toggle.textContent = (isHidden ? "▼" : "▶") + " Advanced: 0% Float Strategies (optional)"; }); } } function vbHideModeToggle() { const radio = document.querySelector("input[name='vb-mode']"); const group = radio && radio.closest(".vb-form-group"); if (group) group.style.display = "none"; } /* Chunk size, chunk frequency, and the simulation horizon are calculated automatically: hiding those inputs. */ function vbHideAutoInputs() { ["vb-chunk-size", "vb-chunk-frequency", "vb-max-years"].forEach(function (id) { const input = document.getElementById(id); const group = input && input.closest(".vb-form-group"); if (group) group.style.display = "none"; }); } function vbInjectOptimalDisplay() { // v2.5: lives in the results area (after the main result card), not in the form. if (document.getElementById("vb-optimal-chunk")) return; const card = document.querySelector(".vb-result-card"); if (!card) return; card.insertAdjacentHTML( "afterend", '
' + '

Optimal Chunking Strategy

' + '
Run the simulation to see your optimal chunk, calculated from your cash flow.
' + 'Chunk size and timing are set automatically to save the most interest, no guesswork.' + '' + "
" ); } /* Optimal chunking from cash flow: take the largest chunk the credit line allows (leaving 5% headroom), then repeat as soon as the monthly surplus pays it back. Larger chunks save more mortgage interest than they cost in credit-line interest, so maxing the line wins. */ /* True optimizer: tries many chunk sizes, runs the full simulation for each, and picks the chunk with the lowest total cost (interest + fees) that pays off. This finds the actual tipping point where a bigger chunk's HELOC interest starts outweighing the mortgage interest saved. */ function computeOptimalChunk(p) { const surplus = p.monthlyIncome - p.monthlyExpenses - (p.debtMinPayments || 0) - p.tradPayment; const notViable = { chunk: 0, freq: 0, surplus: surplus, viable: false }; if (!(surplus > 0) || !(p.lineLimit > 0) || !(p.startBalance > 0)) return notViable; const maxMonths = Math.round((p.maxYears || 30) * 12); let best = null; // v2.5.4: every 1% of the line (no 95% cap) x every frequency 1-24 months. const seen = {}; for (let step = 1; step <= 100; step++) { const chunk = Math.round(Math.min(p.lineLimit * step / 100, p.startBalance) / 100) * 100; if (!(chunk > 500) || seen[chunk]) continue; seen[chunk] = 1; for (let freq = 1; freq <= 24 && freq <= maxMonths; freq++) { const sim = simulateChunkingStrategy({ startBalance: p.startBalance, loanRateAnnual: p.loanRateAnnual, termYears: p.termYears, lineRateAnnual: p.lineRateAnnual, lineLimit: p.lineLimit, chunkSize: chunk, chunkFrequencyMonths: freq, monthlyNetIncome: p.monthlyIncome, monthlyExpenses: p.monthlyExpenses, safetyBuffer: p.safetyBuffer, floatAmount: p.floatAmount || 0, floatExpenses: p.floatExpenses || 0, floatPromoMonths: p.floatPromoMonths || 0, floatCycles: p.floatCycles || 1, floatFeePct: p.floatFeePct || 0, maxYears: p.maxYears, debts: p.debts, carriedDebts: p.carriedDebts || [], cashOnHand: p.cashOnHand || 0, floatApr: p.floatApr || 0, floatMin: p.floatMin || 0 }); if (!sim.paidOff) continue; if (p.baselineCost > 0 && sim.totalCost >= p.baselineCost) continue; // reject losers if (!best || sim.totalCost < best.totalCost - 1) { best = { chunk: chunk, freq: freq, surplus: surplus, viable: true, totalCost: sim.totalCost, months: sim.months }; } } } return best || notViable; } function vbUpdateOptimalDisplay(opt, totalDebtMinPayments, debts, cashOnHand) { const el = document.getElementById("vb-optimal-chunk"); if (!el) return; if (opt.missing) { el.textContent = "Enter a credit line rate and limit to compare chunking. The calculator does not assume one for you."; return; } if (!opt.viable) { const s = opt.surplus > 0 ? "$" + vbMoney.format(Math.round(opt.surplus)) + "/mo" : "zero or negative"; let msg = "Chunking can't beat your current mortgage with this cash flow. Monthly surplus is " + s + "."; if ((totalDebtMinPayments || 0) > 0 && opt.surplus <= 0) { msg += " Your $" + vbMoney.format(Math.round(totalDebtMinPayments)) + "/mo in other debt payments is eating the surplus."; } el.textContent = msg; return; } let html = "$" + vbMoney.format(Math.round(opt.chunk)) + " every " + "" + opt.freq + " month" + (opt.freq === 1 ? "" : "s") + "" + " based on your $" + vbMoney.format(Math.round(opt.surplus)) + "/mo cash flow."; if (debts && debts.length > 0) { let cashLeft = Math.max(0, cashOnHand || 0); const sorted = debts.slice().sort(function (a, b) { const ra = (a.promoMonths || 0) > 0 ? 0 : a.rate; const rb = (b.promoMonths || 0) > 0 ? 0 : b.rate; return rb - ra; }); const surviving = []; sorted.forEach(function (d) { if (cashLeft > 0.005 && d.balance > 0.005) { const pay = Math.min(cashLeft, d.balance); cashLeft -= pay; if (d.balance - pay > 0.005) surviving.push(d); } else if (d.balance > 0.005) { surviving.push(d); } }); const eligible = surviving.filter(function (d) { return ((d.promoMonths || 0) <= 0) && d.rate > 0; }).sort(function (a, b) { return b.rate - a.rate; }); if (eligible.length > 0) { const top = eligible[0]; html += '
Chunks will pay ' + vbEsc(top.label || top.name) + " (" + top.rate + "%) first before mortgage principal."; } } el.innerHTML = html; } function vbEsc(t) { return String(t == null ? "" : t).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } /* v2.5: show which debts are carried separately and any validation notes. */ function vbRenderInputError(invalid) { const n = document.getElementById("vb-narrative"); if (!n) return; n.innerHTML = '
' + "Results not calculated. Each debt needs its minimum payment so nothing is guessed:
"; } // v2.5.8: debts not secured by the home that a strategy moves onto the home-secured line. const VB_UNSECURED_LABEL = { credit_card: "credit card", auto: "auto loan", student: "student loan", personal: "personal loan", other: "other debt" }; function vbRenderSecuredWarning(debts, chunkMoves, aioMoves) { let el = document.getElementById("vb-secured-warning"); const n = document.getElementById("vb-narrative"); if (!el) { if (!n || !n.insertAdjacentHTML) return; n.insertAdjacentHTML("afterend", ''); el = document.getElementById("vb-secured-warning"); if (!el) return; } const moved = (debts || []).filter(function (d) { return VB_UNSECURED_LABEL[d.type] && d.balance > 0; }); const who = []; if (chunkMoves) who.push("HELOC Chunking"); if (aioMoves) who.push("the First-Lien Line"); if (!moved.length || !who.length) { el.innerHTML = ""; el.style.display = "none"; return; } el.style.cssText = "display:block;border:1px solid #d68910;background:#fef5e7;color:#6e4b00;padding:10px 12px;border-radius:6px;margin-top:10px;"; el.innerHTML = "Home-secured debt warning: " + who.join(" and ") + " would pay off " + moved.map(function (d) { return vbEsc(d.label || d.name) + " (" + VB_UNSECURED_LABEL[d.type] + ")"; }).join(", ") + " with a credit line secured by your home. That debt is not secured by your home today. Once it is on the line, missing payments could put your home at risk, and some protections (such as federal student loan repayment options) may no longer apply."; } function vbRenderDebtNotes(carried, warnings, invalid) { const el = document.getElementById("vb-debt-notes"); if (!el) return; const parts = []; if (carried.length > 0) { parts.push( "Carried separately (other properties): " + carried.map(function (d) { return vbEsc(d.label) + " ($" + vbMoney.format(Math.round(d.minPayment)) + "/mo)"; }).join(", ") + ". These payments reduce your cash flow, but they are not part of the payoff comparison or the First-Lien Line." ); } warnings.forEach(function (w) { parts.push(vbEsc(w)); }); (invalid || []).forEach(function (w) { parts.push('Required: ' + vbEsc(w) + ""); }); const fv = function (id) { const n = parseFloat((document.getElementById(id) || {}).value || "0"); return isFinite(n) && n > 0 ? n : 0; }; if (fv("vb-float-apr") > 0 && !(fv("vb-float-min") > 0)) parts.push("Card APR After Promo is entered without Card Minimum Payment After Promo. Any card balance left at promo end must fit on the credit line; nothing is guessed."); el.innerHTML = parts.map(function (p) { return "
" + p + "
"; }).join(""); el.style.display = parts.length ? "block" : "none"; } function vbInjectRefiInputs() { if (document.getElementById("vb-refi-rate")) return; const anchorInput = document.getElementById("vb-float-apr") || document.getElementById("vb-float-fee"); if (!anchorInput) return; const anchorGroup = anchorInput.closest(".vb-form-group") || anchorInput; anchorGroup.insertAdjacentHTML( "afterend", '
' + '' + '' + 'Optional: model a refinance as another comparison row. Leave blank to skip.' + "
" + '
' + '' + '' + "
" + '
' + '' + '' + 'Closing costs are added to the refinance total cost.' + "
" ); } function runVelocitySimulation() { // v2.5: never negative, never NaN/Infinity. const val = function (id) { const n = parseFloat((document.getElementById(id) || {}).value || "0"); return isFinite(n) && n > 0 ? n : 0; }; const startBalance = val("vb-loan-balance"); const loanRate = val("vb-rate"); const termYears = Math.min(40, val("vb-term-remaining")); const monthlyIncome = val("vb-monthly-income"); const monthlyExpenses = val("vb-monthly-expenses"); // v2.3: debts are now a dynamic list (balance + rate per debt). // Minimum payments reduce cash flow; the strategy prioritizes // highest-rate debts for extra payments. // v2.5: typed debts. Mortgages/HELOCs on non-primary properties are // "carried": their payment is a fixed outflow and they are left out of // the payoff race. Everything else is "active": minimums pay it down // monthly and surplus/chunks attack it highest-rate first. const prep = vbPrepareDebts(vbGetDebts(), val("vb-loan-balance")); const debts = prep.active; const activeMinPayments = debts.reduce(function (sum, d) { return sum + (d.minPayment || 0); }, 0); const carriedPayments = prep.carried.reduce(function (sum, d) { return sum + (d.minPayment || 0); }, 0); const totalDebtMinPayments = activeMinPayments + carriedPayments; // Base outflow = living expenses + carried payments. Active minimums are // paid inside the simulations so they are freed when a debt is gone. const totalOutflow = Math.max(0, monthlyExpenses); // v2.5.2: carried payments are paid inside the sims and stop at payoff vbRenderDebtNotes(prep.carried, prep.warnings, prep.invalid); if (prep.invalid.length) { // v2.5.8: never compute a debt with a guessed payment. renderVelocityResults(null, null, null, null, 0, 0); vbRenderInputError(prep.invalid); vbRenderSecuredWarning([], false, false); return; } const safetyBuffer = val("vb-safety-buffer"); const helocRate = val("vb-heloc-rate"); const helocLimit = val("vb-heloc-limit"); const maxYears = termYears || 30; // horizon = the loan's remaining term; no user input needed const floatAmount = val("vb-float-amount"); const floatExpenses = val("vb-float-expenses"); const floatMonths = val("vb-float-months"); const floatFee = val("vb-float-fee"); const floatApr = val("vb-float-apr"); const floatMin = val("vb-float-min"); const floatCycles = val("vb-float-cycles"); // v2.3: cash on hand applied as day-one lump sum (debts highest-rate-first, then mortgage). // Applies to the strategies, not the traditional baseline. const cashOnHand = val("vb-cash-on-hand"); const refiRate = val("vb-refi-rate"); const refiTerm = val("vb-refi-term"); const refiCosts = val("vb-refi-costs"); // Baseline uses the REMAINING term, not the simulation horizon. // v2.5.3: baseline includes the other debts at their minimums. const trad = simulateStatusQuo(startBalance, loanRate, termYears, prep.active, Math.round((termYears || 30) * 12) + 240); trad.startBalance = startBalance; trad.maxYears = maxYears; if (startBalance <= 0 || loanRate <= 0 || termYears <= 0) { renderVelocityResults(null, null, null, null, 0, 0); return; } // Optimal chunking is calculated from cash flow: no manual chunk inputs. // Uses totalOutflow (expenses + debt minimums) so debts reduce the available surplus. // The optimizer runs the full simulation for many chunk sizes and picks the // one with the lowest total cost (true optimum, not a heuristic). const lineLimit = helocLimit; // v2.5.6: no invented default limit const lineReady = helocRate > 0 && helocLimit > 0; const aioReady = helocRate > 0; const optimal = !lineReady ? { chunk: 0, freq: 0, surplus: 0, viable: false, missing: true } : computeOptimalChunk({ startBalance: startBalance, tradPayment: trad.payment, monthlyIncome: monthlyIncome, monthlyExpenses: totalOutflow, debtMinPayments: activeMinPayments + carriedPayments, carriedDebts: prep.carried, loanRateAnnual: loanRate, termYears: termYears, lineRateAnnual: helocRate, lineLimit: lineLimit, safetyBuffer: safetyBuffer, floatAmount: floatAmount, floatExpenses: floatExpenses, floatPromoMonths: floatMonths || 0, floatCycles: floatCycles || 1, floatFeePct: floatFee || 0, maxYears: maxYears, debts: debts, cashOnHand: cashOnHand, floatApr: floatApr, floatMin: floatMin, baselineCost: trad.totalInterest }); vbUpdateOptimalDisplay(optimal, totalDebtMinPayments, debts, cashOnHand); const common = { startBalance, monthlyNetIncome: monthlyIncome, monthlyExpenses: totalOutflow, safetyBuffer: safetyBuffer, maxYears: maxYears, debts: debts, carriedDebts: prep.carried, cashOnHand: cashOnHand }; const chunk = !lineReady ? null : simulateChunkingStrategy( Object.assign({}, common, { loanRateAnnual: loanRate, termYears: termYears, lineRateAnnual: helocRate, lineLimit: lineLimit, chunkSize: optimal.chunk, chunkFrequencyMonths: optimal.freq || 1, floatApr: floatApr, floatMin: floatMin, floatAmount: floatAmount, floatExpenses: floatExpenses, floatPromoMonths: floatMonths || 0, floatCycles: floatCycles || 1, floatFeePct: floatFee || 0 }) ); // v2.4: All-in-one models a TRUE first-position refinance: all other debts // are consolidated into the line on day one (first-lien line structure). // Their minimum payments disappear from monthly outflow since they're absorbed. const totalDebtBalances = debts.reduce(function (sum, d) { return sum + (d.balance || 0); }, 0); const aio = !aioReady ? null : simulateOffsetStrategy( Object.assign({}, common, { startBalance: startBalance + totalDebtBalances, monthlyExpenses: totalOutflow, debts: [], lineRateAnnual: helocRate, floatAmount: floatAmount, floatExpenses: floatExpenses, floatPromoMonths: floatMonths || 0, floatCycles: floatCycles || 1, floatFeePct: floatFee || 0 }) ); // Optional refinance comparison: same balance, new rate/term, plus closing costs. let refiEntry = null; if (refiRate > 0 && refiTerm > 0) { const rr = simulateTraditionalMortgage(startBalance, refiRate, refiTerm); if (rr.months > 0) { refiEntry = { label: "Refinance " + refiRate + "%/" + refiTerm + "yr", short: "Refi", s: { months: rr.months, paidOff: true, totalInterest: rr.totalInterest, totalFees: refiCosts || 0, totalCost: rr.totalInterest + (refiCosts || 0) } }; } } renderVelocityResults(trad, chunk, aio, refiEntry, floatAmount, floatExpenses); vbRenderSecuredWarning(debts, !!(chunk && optimal.chunk > 0), !!aio); } /* v2.3: Dynamic debt list: users add one row per debt with name, balance, interest rate, and minimum payment. Replaces the redundant lump-sum "Other Monthly Debt Payments" field. */ function vbInjectDebtsSection() { if (document.getElementById("vb-debts-section")) return; const anchorInput = document.getElementById("vb-monthly-expenses"); if (!anchorInput) return; const anchorGroup = anchorInput.closest(".vb-form-group") || anchorInput; anchorGroup.insertAdjacentHTML( "afterend", '
' + '' + '' + 'Savings/checking applied as a lump sum on day one. Pays highest-rate debts first, then mortgage principal.' + "
" + '
' + '' + 'List each debt on its own row. Do not list the main loan above. For a Mortgage or HELOC, pick the property it is on: debts on the Primary home are paid down by the strategy; debts on rentals or other properties are carried separately (their payment reduces your cash flow).' + '' + '
' + '' + '' + "
" ); const addBtn = document.getElementById("vb-add-debt"); if (addBtn) { addBtn.addEventListener("click", function () { vbAddDebtRow(); }); } vbAddDebtRow(); } const VB_DEBT_TYPES = [ ["credit_card", "Credit card"], ["auto", "Auto loan"], ["student", "Student loan"], ["personal", "Personal loan"], ["mortgage", "Mortgage"], ["heloc", "HELOC"], ["other", "Other"] ]; const VB_REVOLVING = { credit_card: true, heloc: true }; const VB_PROPERTY_TYPES = { mortgage: true, heloc: true }; const VB_AMORT_TYPES = { mortgage: true, heloc: true, auto: true }; /* v2.5.1: level amortized payment for balance/rate/years. */ /* v2.5.2: per-debt payoff month (0 = still owing at end). */ function vbDebtPayoffs(states, lastMonth) { return states.map(function (d) { return { name: d.name, carried: d.carried, paidMonth: d.paidMonth || (d.bal <= 0.005 ? lastMonth : 0), monthsLeft: d.monthsLeft, balance: d.bal }; }); } function vbAmortPayment(balance, ratePct, years) { const n = Math.round(years * 12); if (!(balance > 0) || !(n > 0)) return 0; const r = ratePct / 100 / 12; if (!(r > 0)) return balance / n; return balance * r / (1 - Math.pow(1 + r, -n)); } function vbAddDebtRow(data) { const container = document.getElementById("vb-debt-rows"); if (!container) return null; const row = document.createElement("div"); row.className = "vb-debt-row"; const opts = VB_DEBT_TYPES.map(function (t) { return '"; }).join(""); const cell = function (label, inner, extra) { return '
" + inner + "
"; }; row.innerHTML = cell("Type", '") + cell("Property", '', " vb-cell-property") + cell("Name", '') + cell("Balance ($)", '') + cell("Rate (%)", '') + cell("Min Pmt ($)", '') + cell("Yrs left", '', " vb-cell-years") + cell("0% Promo Mo.", '') + ''; const typeSel = row.querySelector(".vb-debt-type"); const propCell = row.querySelector(".vb-cell-property"); const yrsCell = row.querySelector(".vb-cell-years"); const sync = function () { const isAmort = !!VB_AMORT_TYPES[typeSel.value]; yrsCell.classList.toggle("vb-cell-off", !isAmort); row.querySelector(".vb-debt-years").disabled = !isAmort; const isProp = !!VB_PROPERTY_TYPES[typeSel.value]; propCell.classList.toggle("vb-cell-off", !isProp); const p = row.querySelector(".vb-debt-property"); p.disabled = !isProp; if (isProp && !p.value) p.value = "Primary"; }; typeSel.value = "credit_card"; typeSel.addEventListener("change", sync); row.querySelector(".vb-debt-remove").addEventListener("click", function () { row.remove(); }); container.appendChild(row); if (data) { const setRow = function (cls, v) { const el = row.querySelector("." + cls); if (el && v !== undefined && v !== null) el.value = v; }; const known = VB_DEBT_TYPES.some(function (t) { return t[0] === data.type; }); typeSel.value = known ? data.type : "other"; setRow("vb-debt-property", data.property); setRow("vb-debt-name", data.name); setRow("vb-debt-balance", data.balance); setRow("vb-debt-rate", data.rate); setRow("vb-debt-minpmt", data.minPayment); setRow("vb-debt-promo", data.promoMonths); setRow("vb-debt-years", data.years); } sync(); return row; } /* Collects debt data from the dynamic rows. Returns array of {name, balance, rate, minPayment, promoMonths}. Skips empty rows. */ function vbGetDebts() { const rows = document.querySelectorAll("#vb-debt-rows .vb-debt-row"); const debts = []; const num = function (row, cls) { const n = parseFloat((row.querySelector(cls) || {}).value || "0"); return isFinite(n) && n > 0 ? n : 0; }; rows.forEach(function (row) { const type = (row.querySelector(".vb-debt-type") || {}).value || "other"; const name = ((row.querySelector(".vb-debt-name") || {}).value || "").trim(); const propRaw = ((row.querySelector(".vb-debt-property") || {}).value || "").trim(); const property = VB_PROPERTY_TYPES[type] ? (propRaw || "Primary") : ""; const balance = num(row, ".vb-debt-balance"); const rate = Math.min(60, num(row, ".vb-debt-rate")); const minPayment = num(row, ".vb-debt-minpmt"); const promoMonths = Math.min(60, Math.round(num(row, ".vb-debt-promo"))); const years = VB_AMORT_TYPES[type] ? Math.min(40, num(row, ".vb-debt-years")) : 0; if (balance > 0) { const typeLabel = (VB_DEBT_TYPES.filter(function (t) { return t[0] === type; })[0] || [0, "Debt"])[1]; debts.push({ type: type, property: property, name: name || typeLabel, balance: balance, rate: rate, minPayment: minPayment, promoMonths: promoMonths, years: years }); } }); return debts; } function vbIsPrimary(p) { return /^(primary|home|main|residence|primary home|primary residence)$/i.test(String(p || "").trim()); } /* v2.5: split and validate debts. Returns {active, carried, warnings}. - carried: Mortgage/HELOC on a non-primary property (fixed outflow). - active: everything else, with a usable minimum payment. - A Primary "Mortgage" matching the main loan balance is skipped. */ /* v2.5.1: for Mortgage/HELOC/Auto rows with years remaining, derive or validate the payment against the amortization schedule. Returns the payment to use (or the entered value when no schedule applies). */ function vbScheduledPayment(d, label, warnings) { const entered = d.minPayment > 0 ? d.minPayment : 0; if (!VB_AMORT_TYPES[d.type] || !(d.years > 0)) return entered; const sched = vbAmortPayment(d.balance, d.rate, d.years); const monthlyInterest = d.balance * (d.rate / 100 / 12); if (!(entered > monthlyInterest + 0.005)) { warnings.push(label + ": payment " + (entered > 0 ? "does not cover the monthly interest" : "is blank") + ", so the scheduled payment of $" + vbMoney.format(Math.round(sched)) + "/mo was calculated from the balance, rate, and " + d.years + " years remaining."); return sched; } if (entered < sched * 0.99) { warnings.push(label + ": the $" + vbMoney.format(Math.round(entered)) + "/mo payment will not pay this off within " + d.years + " years (the schedule needs about $" + vbMoney.format(Math.round(sched)) + "/mo). The entered payment was used; check the balance, rate, or years remaining."); } return entered; } function vbScheduleFlags(d, pmt) { if (!VB_AMORT_TYPES[d.type] || !(d.years > 0)) return { months: 0, onSchedule: false, derived: false }; const sched = vbAmortPayment(d.balance, d.rate, d.years); return { months: Math.round(d.years * 12), onSchedule: pmt >= sched * 0.99, derived: Math.abs(pmt - sched) < 0.01 && !(d.minPayment > 0 && Math.abs(d.minPayment - sched) >= 0.01) }; } function vbPrepareDebts(raw, mainBalance) { const active = []; const carried = []; const warnings = []; const invalid = []; (raw || []).forEach(function (d) { const label = d.name + (d.property ? " (" + d.property + ")" : ""); const isPropDebt = !!VB_PROPERTY_TYPES[d.type]; if (isPropDebt && !vbIsPrimary(d.property)) { let pmt = vbScheduledPayment(d, label, warnings); if (!(pmt > 0)) { warnings.push(label + " has no payment or years remaining entered, so it is not reducing your cash flow. Enter its monthly payment or years remaining."); pmt = 0; } carried.push(Object.assign({}, d, vbScheduleFlags(d, pmt), { label: label, minPayment: pmt })); return; } if (d.type === "mortgage" && mainBalance > 0 && Math.abs(d.balance - mainBalance) <= mainBalance * 0.01) { warnings.push(label + " matches the main loan balance above, so it was skipped to avoid counting it twice."); return; } const monthlyInterest = d.balance * (d.rate / 100 / 12); const promo = d.promoMonths > 0; const pmt = vbScheduledPayment(d, label, warnings); // v2.5.8: no invented minimums. Missing or too-small minimum = input error. if (!(pmt > (promo ? 0 : monthlyInterest) + 0.005)) { invalid.push(label + ": " + (d.minPayment > 0 ? "the minimum payment of $" + vbMoney.format(Math.round(d.minPayment)) + "/mo does not cover the monthly interest of about $" + vbMoney.format(Math.round(monthlyInterest)) + ". Enter the minimum payment from your statement." : "enter the minimum payment from your statement.")); return; } active.push(Object.assign({}, d, vbScheduleFlags(d, pmt), { label: label, minPayment: pmt, revolving: !!VB_REVOLVING[d.type] })); }); return { active: active, carried: carried, warnings: warnings, invalid: invalid }; } /* v2.5.8: generic product names on the host page (no lender or product names). */ function vbApplyGenericLabels() { const h = document.querySelector && document.querySelector(".vb-heading"); if (h) h.textContent = "Velocity Banking Simulator"; const sub = document.querySelector && document.querySelector(".vb-subtitle"); if (sub) sub.textContent = "Compare your current payoff path with HELOC Chunking and a First-Lien Line (all income deposited)."; const lab = document.querySelector && document.querySelector('label[for="vb-heloc-rate"]'); if (lab) lab.textContent = "Credit Line Rate (%)"; const rate = document.getElementById("vb-heloc-rate"); const help = rate && rate.parentNode && rate.parentNode.querySelector ? rate.parentNode.querySelector(".vb-helper") : null; if (help) help.textContent = "Interest rate on the credit line (the HELOC for chunking, or the first-lien line). Use the rate from your lender."; const off = document.querySelector && document.querySelector('input[name="vb-mode"][value="offset"]'); const span = off && off.parentNode && off.parentNode.querySelector ? off.parentNode.querySelector("span") : null; if (span) span.textContent = "First-Lien Line (all income deposited)"; } function vbInit() { vbApplyGenericLabels(); vbHideModeToggle(); vbHideAutoInputs(); vbFixExpensesHelper(); vbInjectDebtsSection(); vbInjectFloatInputs(); vbInjectRefiInputs(); vbInjectOptimalDisplay(); vbInjectDisclosures(); vbInjectShareSection(); const btn = document.getElementById("vb-calc-btn"); if (btn) { btn.addEventListener("click", runVelocitySimulation); } // v2.4: if the URL carries a shared scenario, load it and auto-run. if (vbLoadFromHash()) { runVelocitySimulation(); } } /* v2.3: The monthly expenses helper still says "debts, etc." but debts are now entered separately below. Update it so users don't double-count. */ function vbFixExpensesHelper() { const input = document.getElementById("vb-monthly-expenses"); if (!input) return; const group = input.closest(".vb-form-group"); if (!group) return; const helper = group.querySelector(".vb-helper"); if (helper) { helper.textContent = "Living expenses, not including your mortgage payment or the debts listed below (their minimums are counted separately)."; } } /* v2.3: The monthly expenses helper still says "debts, etc." but debts are now entered separately below. Update it so users don't double-count. */ function vbFixExpensesHelper() { const input = document.getElementById("vb-monthly-expenses"); if (!input) return; const group = input.closest(".vb-form-group"); if (!group) return; const helper = group.querySelector(".vb-helper"); if (helper) { helper.textContent = "Living expenses, not including your mortgage payment or the debts listed below (their minimums are counted separately)."; } } /* ============ Save / Share / Export (v2.4) ============ */ /* Collect every input into a plain object for sharing/saving. */ function vbCollectInputs() { const val = function (id) { return (document.getElementById(id) || {}).value || ""; }; return { v: 2, bal: val("vb-loan-balance"), rate: val("vb-rate"), term: val("vb-term-remaining"), income: val("vb-monthly-income"), expenses: val("vb-monthly-expenses"), cash: val("vb-cash-on-hand"), cushion: val("vb-safety-buffer"), helocRate: val("vb-heloc-rate"), helocLimit: val("vb-heloc-limit"), floatAmt: val("vb-float-amount"), floatExp: val("vb-float-expenses"), floatMo: val("vb-float-months"), floatCy: val("vb-float-cycles"), floatFee: val("vb-float-fee"), floatApr: val("vb-float-apr"), floatMin: val("vb-float-min"), refiRate: val("vb-refi-rate"), refiTerm: val("vb-refi-term"), refiCosts: val("vb-refi-costs"), debts: vbGetDebts() }; } /* Populate the form from a collected inputs object. */ function vbApplyInputs(d) { if (!d || typeof d !== "object") return; const set = function (id, v) { const el = document.getElementById(id); if (el && v !== undefined && v !== "") el.value = v; }; set("vb-loan-balance", d.bal); set("vb-rate", d.rate); set("vb-term-remaining", d.term); set("vb-monthly-income", d.income); set("vb-monthly-expenses", d.expenses); set("vb-cash-on-hand", d.cash); set("vb-safety-buffer", d.cushion); set("vb-heloc-rate", d.helocRate); set("vb-heloc-limit", d.helocLimit); set("vb-float-amount", d.floatAmt); set("vb-float-expenses", d.floatExp); set("vb-float-months", d.floatMo); set("vb-float-cycles", d.floatCy); set("vb-float-fee", d.floatFee); set("vb-float-apr", d.floatApr); set("vb-float-min", d.floatMin); set("vb-refi-rate", d.refiRate); set("vb-refi-term", d.refiTerm); set("vb-refi-costs", d.refiCosts); // Rebuild debt rows (v2.5: type/property; old links default to Other) const container = document.getElementById("vb-debt-rows"); if (container && Array.isArray(d.debts)) { container.innerHTML = ""; if (d.debts.length === 0) { vbAddDebtRow(); } else { d.debts.forEach(function (debt) { if (debt && typeof debt === "object") vbAddDebtRow(debt); }); } } } /* Encode inputs into a shareable URL hash. */ function vbEncodeShareLink() { try { const json = JSON.stringify(vbCollectInputs()); const b64 = btoa(unescape(encodeURIComponent(json))); return window.location.origin + window.location.pathname + "#vb=" + b64; } catch (e) { return null; } } /* On page load, if a #vb= hash is present, apply it and auto-run. */ function vbLoadFromHash() { try { const h = window.location.hash; if (!h || h.indexOf("#vb=") !== 0) return false; const b64 = h.substring(4); const json = decodeURIComponent(escape(atob(b64))); const data = JSON.parse(json); vbApplyInputs(data); return true; } catch (e) { return false; } } /* Saved scenarios in localStorage. */ function vbGetSaved() { try { return JSON.parse(localStorage.getItem("vb-scenarios") || "[]"); } catch (e) { return []; } } function vbSetSaved(list) { try { localStorage.setItem("vb-scenarios", JSON.stringify(list)); } catch (e) {} } /* Inject the Share / Save / Export UI below the results. */ function vbInjectShareSection() { if (document.getElementById("vb-share-section")) return; const anchor = document.getElementById("vb-calc-btn"); if (!anchor) return; const group = anchor.closest(".vb-form-group") || anchor.parentNode; const div = document.createElement("div"); div.id = "vb-share-section"; div.className = "vb-form-group"; div.style.cssText = "margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);"; div.innerHTML = '' + '
' + '' + '' + '' + '
' + '
' + '
'; group.parentNode.insertBefore(div, group.nextSibling); const msg = function (t) { const el = document.getElementById("vb-share-msg"); if (el) el.textContent = t; }; document.getElementById("vb-copy-link").addEventListener("click", function () { const url = vbEncodeShareLink(); if (!url) return msg("Couldn't build the link."); const done = function () { msg("Share link copied. Send it to anyone and they'll see your exact inputs."); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(done, function () { msg("Copy failed. Here's the link: " + url); }); } else { const ta = document.createElement("textarea"); ta.value = url; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); done(); } catch (e) { msg("Copy failed. Here's the link: " + url); } document.body.removeChild(ta); } }); document.getElementById("vb-save-scenario").addEventListener("click", function () { const name = prompt("Name this scenario:", "My scenario"); if (!name) return; const list = vbGetSaved(); list.push({ name: name, ts: Date.now(), data: vbCollectInputs() }); vbSetSaved(list); vbRenderSavedList(); msg('Saved "' + name + '".'); }); document.getElementById("vb-export-print").addEventListener("click", function () { window.print(); }); vbRenderSavedList(); } function vbRenderSavedList() { const el = document.getElementById("vb-saved-list"); if (!el) return; const list = vbGetSaved(); if (list.length === 0) { el.innerHTML = 'No saved scenarios yet.'; return; } el.innerHTML = ""; list.forEach(function (item, idx) { const row = document.createElement("div"); row.style.cssText = "display:flex;gap:8px;align-items:center;margin-bottom:4px;font-size:13px;"; const label = document.createElement("span"); label.textContent = item.name; label.style.cssText = "flex:1;color:#e5e7eb;"; const loadBtn = document.createElement("button"); loadBtn.type = "button"; loadBtn.textContent = "Load"; loadBtn.style.cssText = "padding:4px 10px;border-radius:6px;border:1px solid #4b5563;background:#374151;color:#e5e7eb;cursor:pointer;font-size:12px;"; loadBtn.addEventListener("click", function () { vbApplyInputs(item.data); runVelocitySimulation(); }); const delBtn = document.createElement("button"); delBtn.type = "button"; delBtn.textContent = "✕"; delBtn.title = "Delete"; delBtn.style.cssText = "padding:4px 8px;border-radius:6px;border:1px solid #4b5563;background:#374151;color:#9ca3af;cursor:pointer;font-size:12px;"; delBtn.addEventListener("click", function () { const l = vbGetSaved(); l.splice(idx, 1); vbSetSaved(l); vbRenderSavedList(); }); row.appendChild(label); row.appendChild(loadBtn); row.appendChild(delBtn); el.appendChild(row); }); } window.VB_TEST = { runVelocitySimulation: runVelocitySimulation, vbInjectDisclosures: vbInjectDisclosures, simulateChunkingStrategy: simulateChunkingStrategy, simulateOffsetStrategy: simulateOffsetStrategy, vbAmortPayment: vbAmortPayment, vbPrepareDebts: vbPrepareDebts, computeOptimalChunk: computeOptimalChunk, simulateStatusQuo: simulateStatusQuo, simulateTraditionalMortgage: simulateTraditionalMortgage }; // v2.3: Run immediately if DOM is already ready (GoHighLevel may load scripts after DOMContentLoaded), // otherwise wait for DOMContentLoaded. if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", vbInit); } else { vbInit(); } })();