// ==UserScript==
// @name Hosting Proration Calculator
// @namespace hosting-proration-calc
// @version 2.0
// @description Prorated extension/refund/plan-change/audit calculator for hosting support teams
// @author rpqu + claude opus 4.6
// @match https://YOUR-SITE-HERE.com/*
// @grant none
// @license GPL-3.0-or-later
// ==/UserScript==
/*
Hosting Proration Calculator
Copyright (C) 2026 rpqu
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
(function () {
"use strict";
// ═══════════════════════════════════════════════════════════════════════════
// Storage & Config
// ═══════════════════════════════════════════════════════════════════════════
const SKEY = "hpc_cfg";
const DEFAULTS = { feePct: 0, feeFlat: 0, cur: "$", penType: "none", penVal: 0 };
function loadCfg() {
try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(SKEY)) }; }
catch { return { ...DEFAULTS }; }
}
function saveCfg(c) { localStorage.setItem(SKEY, JSON.stringify(c)); }
// ═══════════════════════════════════════════════════════════════════════════
// Date helpers
// ═══════════════════════════════════════════════════════════════════════════
function addMonths(d, n) {
const r = new Date(d.getFullYear(), d.getMonth() + n, d.getDate());
if (r.getDate() !== d.getDate()) r.setDate(0);
return r;
}
function diffDays(a, b) { return Math.round((b - a) / 864e5); }
function addDays(d, n) { return new Date(+d + n * 864e5); }
function toDate(s) {
if (!s) return null;
const p = s.split("-").map(Number);
if (p.length !== 3 || isNaN(p[0])) return null;
return new Date(p[0], p[1] - 1, p[2]);
}
function fmtISO(d) {
if (!d) return "";
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
}
function fmtShort(d) {
if (!d) return "?";
return d.toLocaleDateString("en-US", { month:"short", day:"numeric", year:"numeric" });
}
function cycleEnd(start, cycle) {
switch (cycle) {
case "daily": return addDays(start, 1);
case "weekly": return addDays(start, 7);
case "fortnightly": return addDays(start, 14);
case "monthly": return addMonths(start, 1);
case "quarterly": return addMonths(start, 3);
case "semi-annual": return addMonths(start, 6);
case "yearly": return addMonths(start, 12);
}
return addMonths(start, 12);
}
// ═══════════════════════════════════════════════════════════════════════════
// Math helpers
// ═══════════════════════════════════════════════════════════════════════════
function calcFee(amount, cfg) {
if (amount <= 0) return 0;
return Math.round((amount * (cfg.feePct / 100) + cfg.feeFlat) * 100) / 100;
}
function calcPenalty(basis, penType, penVal) {
if (penType === "flat") return penVal;
if (penType === "pct_remaining") return Math.round(basis * (penVal / 100) * 100) / 100;
if (penType === "pct_total") return Math.round(basis * (penVal / 100) * 100) / 100;
return 0;
}
// ═══════════════════════════════════════════════════════════════════════════
// State for invoice generation
// ═══════════════════════════════════════════════════════════════════════════
const state = { lastCalc: null };
// ═══════════════════════════════════════════════════════════════════════════
// CSS
// ═══════════════════════════════════════════════════════════════════════════
const CSS = `
:host { all: initial; system-ui, -apple-system, sans-serif; }
*, *::before, *::after { box-sizing: border-box; }
/* Toggle button */
.hpc-toggle {
position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
width: 44px; height: 44px; border-radius: 50%;
background: #0d6efd; color: #fff; border: none; cursor: pointer;
font-size: 20px; display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,.25); transition: transform .15s;
}
.hpc-toggle:hover { transform: scale(1.1); }
/* Panel */
.hpc-panel {
position: fixed; bottom: 74px; right: 20px; z-index: 2147483647;
width: 440px; max-height: 88vh; overflow-y: auto;
background: #fff; border: 1px solid #dee2e6; border-radius: 10px;
box-shadow: 0 4px 24px rgba(0,0,0,.18); display: none; color: #212529;
font-size: 13px; 1.45;
}
.hpc-panel.open { display: block; }
.hpc-panel::-webkit-scrollbar { width: 6px; }
.hpc-panel::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 3px; }
/* Header */
.hpc-hdr {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; background: #0d6efd; color: #fff;
border-radius: 10px 10px 0 0; cursor: move; user-select: none;
}
.hpc-hdr h3 { 0; font-size: 14px; font-weight: 600; }
.hpc-hdr button {
background: none; border: none; color: #fff; font-size: 18px;
cursor: pointer; padding: 0 4px; opacity: .8; 1;
}
.hpc-hdr button:hover { opacity: 1; }
.hpc-body { padding: 0 14px 14px; }
/* Settings */
.hpc-settings { display: none; padding: 8px 0; border-bottom: 1px solid #e9ecef; }
.hpc-settings.open { display: block; }
.hpc-settings .s-row { display: flex; align-items: center; gap: 6px; 5px; }
.hpc-settings label { font-size: 12px; color: #6c757d; min-width: 120px; }
.hpc-settings input {
width: 72px; padding: 3px 6px; border: 1px solid #ced4da; border-radius: 4px;
font-size: 12px; text-align: right;
}
.hpc-settings .s-btns { display: flex; gap: 6px; 6px; }
.hpc-settings .s-btns button, .hpc-btn-sm {
padding: 3px 10px; font-size: 11px; border: 1px solid #ced4da; border-radius: 4px;
background: #fff; color: #495057; cursor: pointer;
}
.hpc-settings .s-btns button:hover, .hpc-btn-sm:hover { background: #e9ecef; }
/* Tabs */
.hpc-tabs { display: flex; flex-wrap: wrap; gap: 0; 10px 0 8px; }
.hpc-tab {
flex: 1 1 auto; min-width: 0; padding: 6px 2px; text-align: center; cursor: pointer;
border: 1px solid #dee2e6; background: #f8f9fa; color: #495057;
font-size: 12px; font-weight: 500; transition: all .12s; white-space: nowrap;
}
.hpc-tab:first-child { border-radius: 6px 0 0 6px; }
.hpc-tab:last-child { border-radius: 0 6px 6px 0; }
.hpc-tab.active { background: #0d6efd; color: #fff; border-color: #0d6efd; }
/* Form */
.hpc-section { 10px; }
.hpc-stitle {
font-size: 11px; text-transform: uppercase; letter-spacing: .5px;
color: #6c757d; 6px; font-weight: 600;
}
.hpc-f { display: flex; align-items: center; 6px; }
.hpc-f label { min-width: 125px; font-size: 12px; color: #495057; flex-shrink: 0; }
.hpc-f input, .hpc-f select {
flex: 1; padding: 5px 8px; border: 1px solid #ced4da; border-radius: 5px;
font-size: 13px; background: #fff; color: #212529; min-width: 0;
}
.hpc-f input[type="number"] { text-align: right; }
.hpc-f select { cursor: pointer; }
.hpc-f .auto {
font-size: 11px; color: #6c757d; 6px; white-space: nowrap; flex-shrink: 0;
}
.hpc-chk { display: flex; align-items: center; gap: 6px; 6px; }
.hpc-chk input[type="checkbox"] { width: auto; 0; flex-shrink: 0; }
.hpc-chk label { font-size: 12px; color: #495057; cursor: pointer; }
.hpc-note { font-size: 11px; color: #6c757d; 2px 0 6px 125px; }
.hpc-adv-btn {
font-size: 11px; color: #6c757d; cursor: pointer; border: none;
background: none; padding: 2px 0; 4px;
}
.hpc-adv-btn:hover { color: #0d6efd; }
.hpc-adv { display: none; }
.hpc-adv.open { display: block; }
/* Radio group */
.hpc-radio { display: flex; gap: 12px; 6px; 125px; }
.hpc-radio label { font-size: 12px; display: flex; align-items: center; gap: 4px; cursor: pointer; color: #495057; }
.hpc-radio input { 0; }
/* Results */
.hpc-res {
10px; padding: 10px; background: #f8f9fa;
border: 1px solid #e9ecef; border-radius: 6px;
"SF Mono","Cascadia Code","Consolas",monospace;
font-size: 11.5px; 1.6; white-space: pre-wrap;
word-break: break-word; min-height: 36px;
}
.hpc-res .hl { color: #0d6efd; font-weight: 600; }
.hpc-res .ok { color: #198754; font-weight: 600; }
.hpc-res .w { color: #dc3545; font-weight: 600; }
.hpc-res .d { color: #6c757d; }
.hpc-res .s { color: #adb5bd; }
.hpc-res-btns { display: flex; gap: 6px; justify-content: flex-end; 6px; }
.hpc-res-btns button {
padding: 3px 10px; font-size: 11px; border: 1px solid #ced4da; border-radius: 4px;
background: #fff; color: #495057; cursor: pointer;
}
.hpc-res-btns button:hover { background: #e9ecef; }
.hpc-pane { display: none; }
.hpc-pane.active { display: block; }
/* Batch cards */
.bc { border: 1px solid #dee2e6; border-radius: 6px; padding: 8px; 8px; background: #fafbfc; }
.bc-hdr { display: flex; align-items: center; justify-content: space-between; 6px; }
.bc-hdr input { border: none; background: transparent; font-weight: 600; font-size: 13px; color: #212529; flex: 1; padding: 2px 0; }
.bc-hdr button { background: none; border: none; color: #adb5bd; font-size: 16px; cursor: pointer; padding: 0 4px; }
.bc-hdr button:hover { color: #dc3545; }
.bc-row { display: flex; gap: 6px; 4px; align-items: center; }
.bc-row label { font-size: 11px; color: #6c757d; min-width: 32px; }
.bc-row input { flex: 1; padding: 3px 6px; border: 1px solid #ced4da; border-radius: 4px; font-size: 12px; min-width: 0; }
.bc-row input[type="number"] { text-align: right; }
.bc-res { font-size: 11px; padding: 4px 0; monospace; color: #495057; }
/* Invoice overlay */
.hpc-overlay {
display: none; position: fixed; inset: 0; z-index: 2147483647;
background: rgba(0,0,0,.4); align-items: center; justify-content: center;
}
.hpc-overlay.open { display: flex; }
.hpc-modal {
background: #fff; border-radius: 10px; padding: 16px; width: 460px; max-height: 80vh;
overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.25);
}
.hpc-modal h4 { 0 0 10px; font-size: 14px; }
.hpc-modal textarea {
width: 100%; height: 300px; "SF Mono","Consolas",monospace;
font-size: 12px; padding: 10px; border: 1px solid #ced4da; border-radius: 6px;
resize: vertical; color: #212529; 1.5;
}
.hpc-modal .m-btns { display: flex; gap: 8px; justify-content: flex-end; 10px; }
.hpc-modal .m-btns button {
padding: 6px 14px; font-size: 12px; border-radius: 5px; cursor: pointer; border: 1px solid #ced4da;
}
.hpc-modal .m-btns .m-primary { background: #0d6efd; color: #fff; border-color: #0d6efd; }
.hpc-modal .m-btns .m-secondary { background: #fff; color: #495057; }
/* FX locked badge */
.fx-lock { display: inline-block; font-size: 10px; background: #d1e7dd; color: #0f5132; padding: 1px 6px; border-radius: 3px; 6px; }
/* Case context bar */
.hpc-ctx {
display: flex; gap: 6px; padding: 7px 14px; border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
}
.hpc-ctx input {
flex: 1; padding: 4px 8px; border: 1px solid #dee2e6; border-radius: 4px;
font-size: 12px; background: #fff; color: #212529; min-width: 0;
}
.hpc-ctx input::placeholder { color: #adb5bd; }
/* Template picker row */
.hpc-tmpl-row { display: flex; gap: 6px; 8px; align-items: center; }
.hpc-tmpl-row label { min-width: 125px; font-size: 12px; color: #6c757d; flex-shrink: 0; }
.hpc-tmpl-row select {
flex: 1; padding: 4px 6px; border: 1px solid #dee2e6; border-radius: 4px;
font-size: 12px; background: #fff; color: #212529; cursor: pointer;
}
.hpc-tmpl-row button {
padding: 3px 8px; font-size: 11px; border: 1px solid #ced4da; border-radius: 4px;
background: #fff; color: #6c757d; cursor: pointer; white-space: nowrap; flex-shrink: 0;
}
.hpc-tmpl-row button:hover { background: #e9ecef; color: #212529; }
/* Template list in settings */
.hpc-tmpl-list { 4px 0 6px; }
.hpc-tmpl-item {
display: flex; align-items: center; gap: 6px; padding: 3px 0;
font-size: 12px; border-bottom: 1px solid #f1f3f5;
}
.hpc-tmpl-item span { flex: 1; color: #212529; cursor: pointer; }
.hpc-tmpl-item span:hover { color: #0d6efd; text-decoration: underline; }
.hpc-tmpl-item .td { font-size: 11px; color: #6c757d; }
.hpc-tmpl-item button { background: none; border: none; color: #adb5bd; cursor: pointer; font-size: 14px; padding: 0 2px; }
.hpc-tmpl-item button:hover { color: #dc3545; }
/* History modal entries */
.hpc-hist-entry {
display: flex; align-items: flex-start; gap: 8px; padding: 8px 0;
border-bottom: 1px solid #f1f3f5; cursor: default;
}
.hpc-hist-entry:last-child { border-bottom: none; }
.hpc-hist-badge {
font-size: 10px; font-weight: 700; padding: 2px 5px; border-radius: 3px;
color: #fff; flex-shrink: 0; 1px; text-transform: uppercase;
}
.badge-ext { background: #0d6efd; }
.badge-ref { background: #198754; }
.badge-plan { background: #6f42c1; }
.badge-audit { background: #fd7e14; }
.badge-batch { background: #0dcaf0; color: #000; }
.badge-fx { background: #6c757d; }
.hpc-hist-info { flex: 1; min-width: 0; }
.hpc-hist-ts { font-size: 10px; color: #6c757d; }
.hpc-hist-ctx { font-size: 11px; color: #495057; font-weight: 500; }
.hpc-hist-lbl { font-size: 12px; color: #212529; 1px; }
.hpc-hist-restore {
padding: 2px 8px; font-size: 11px; border: 1px solid #dee2e6; border-radius: 4px;
background: #fff; color: #0d6efd; cursor: pointer; flex-shrink: 0;
}
.hpc-hist-restore:hover { background: #e7f1ff; }
.hpc-hist-empty { font-size: 12px; color: #6c757d; padding: 12px 0; text-align: center; }
/* Wider modal for history */
.hpc-modal-wide { width: 500px; }
`;
// ═══════════════════════════════════════════════════════════════════════════
// Cycle options HTML (reusable)
// ═══════════════════════════════════════════════════════════════════════════
const CYCLE_OPTS = `
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="fortnightly">Fortnightly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="semi-annual">Semi-annual</option>
<option value="yearly" selected>Yearly</option>`;
const PEN_OPTS = `
<option value="none">None</option>
<option value="flat">Flat fee</option>
<option value="pct_remaining">% of refund</option>
<option value="pct_total">% of total paid</option>`;
// ═══════════════════════════════════════════════════════════════════════════
// HTML
// ═══════════════════════════════════════════════════════════════════════════
const HTML = `
<button class="hpc-toggle" id="hpcBtn" title="Proration Calculator">⚒</button>
<div class="hpc-panel" id="hpcPanel">
<div class="hpc-hdr" id="hpcHdr">
<h3>Proration Calculator</h3>
<div><button id="btnHist" title="History">⏳</button> <button id="btnCfg" title="Settings">⚙</button> <button id="btnX">×</button></div>
</div>
<!-- case context bar -->
<div class="hpc-ctx">
<input type="text" id="ctxTicket" placeholder="Ticket / Case ID" maxlength="50">
<input type="text" id="ctxCustomer" placeholder="Customer name" maxlength="80">
</div>
<div class="hpc-body">
<!-- ═══ Settings ═══ -->
<div class="hpc-settings" id="secCfg">
<div class="s-row"><label>Processor fee %</label><input type="number" step="0.01" min="0" id="cfgPct"></div>
<div class="s-row"><label>Processor flat fee</label><input type="number" step="0.01" min="0" id="cfgFlat"></div>
<div class="s-row"><label>Currency symbol</label><input type="text" maxlength="4" id="cfgCur" style="width:40px;text-align:center"></div>
<div class="s-row"><label>Default penalty</label><select id="cfgPenType">${PEN_OPTS}</select></div>
<div class="s-row" id="cfgPenRow" style="display:none"><label>Penalty amount/%</label><input type="number" step="0.01" min="0" id="cfgPenVal"></div>
<div class="s-btns">
<button id="btnExport">Export Config</button>
<button id="btnImport">Import Config</button>
<input type="file" id="fileImport" accept=".json" style="display:none">
</div>
<div style="8px;border-top:1px solid #e9ecef;padding-top:6px">
<div style="display:flex;align-items:center;justify-content:space-between;4px">
<span style="font-size:12px;font-weight:600;color:#6c757d">Templates</span>
<button class="hpc-btn-sm" id="tmplAddBtn">+ Add</button>
</div>
<div class="hpc-tmpl-list" id="tmplList"></div>
</div>
</div>
<!-- ═══ Tabs ═══ -->
<div class="hpc-tabs">
<div class="hpc-tab active" data-tab="ext">Extend</div>
<div class="hpc-tab" data-tab="ref">Refund</div>
<div class="hpc-tab" data-tab="plan">Plan Δ</div>
<div class="hpc-tab" data-tab="audit">Audit</div>
<div class="hpc-tab" data-tab="batch">Batch</div>
<div class="hpc-tab" data-tab="fx">FX</div>
</div>
<!-- ═══════════ EXTEND ═══════════ -->
<div class="hpc-pane active" id="paneExt">
<div class="hpc-section">
<div class="hpc-stitle">Contract</div>
<div class="hpc-tmpl-row"><label>Template</label><select id="extTmplSel"><option value="">— load template —</option></select><button id="extTmplSave" title="Save current as template">Save as…</button></div>
<div class="hpc-f"><label>Price per cycle</label><input type="number" step="0.01" min="0" id="extPrice" placeholder="45.00"></div>
<div class="hpc-f"><label>Billing cycle</label><select id="extCycle">${CYCLE_OPTS}</select></div>
<div class="hpc-f"><label>Current billing end</label><input type="date" id="extEnd"></div>
<div class="hpc-f"><label>New target end</label><input type="date" id="extTarget"></div>
</div>
<button class="hpc-adv-btn" data-for="extAdv">▾ Advanced</button>
<div class="hpc-adv" id="extAdv">
<div class="hpc-f"><label>Proration divisor</label><input type="number" min="1" id="extDiv" placeholder="auto"><span class="auto" id="extDivA"></span></div>
<div class="hpc-f"><label>Pre-existing credit</label><input type="number" step="0.01" min="0" id="extCred" value="0"></div>
</div>
<div class="hpc-res" id="extR"><span class="d">Fill in the fields above.</span></div>
<div class="hpc-res-btns"><button data-copy="extR">Copy</button><button data-note="ext">Draft Invoice</button></div>
</div>
<!-- ═══════════ REFUND ═══════════ -->
<div class="hpc-pane" id="paneRef">
<div class="hpc-section">
<div class="hpc-stitle">Service period</div>
<div class="hpc-f"><label>Period start</label><input type="date" id="refStart"></div>
<div class="hpc-f"><label>Billing end</label><input type="date" id="refEnd"></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Payment</div>
<div class="hpc-tmpl-row"><label>Template</label><select id="refTmplSel"><option value="">— load template —</option></select><button id="refTmplSave">Save as…</button></div>
<div class="hpc-f"><label>Payment amount</label><input type="number" step="0.01" min="0" id="refPay" placeholder="32.42"></div>
<div class="hpc-f"><label>Credit applied</label><input type="number" step="0.01" min="0" id="refCred" value="0"></div>
<div class="hpc-note">Credit = pre-existing balance (no processor fee on it)</div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Cutoff</div>
<div class="hpc-f"><label>Cutoff date</label><input type="date" id="refCut"></div>
<div class="hpc-chk"><input type="checkbox" id="refCutInc" checked><label for="refCutInc">Include cutoff date in unused days</label></div>
</div>
<button class="hpc-adv-btn" data-for="refAdv">▾ Advanced</button>
<div class="hpc-adv" id="refAdv">
<div class="hpc-f"><label>Proration divisor</label><input type="number" min="1" id="refDiv" placeholder="auto"><span class="auto" id="refDivA"></span></div>
<div class="hpc-f"><label>Processor fee override</label><input type="number" step="0.01" min="0" id="refFee" placeholder="auto"><span class="auto" id="refFeeA"></span></div>
<div class="hpc-chk"><input type="checkbox" id="refRfFee"><label for="refRfFee">Refund itself incurs processor fee</label></div>
<div class="hpc-stitle" style="8px">Early termination penalty</div>
<div class="hpc-f"><label>Penalty type</label><select id="refPenT">${PEN_OPTS}</select></div>
<div class="hpc-f" id="refPenR" style="display:none"><label>Penalty amount/%</label><input type="number" step="0.01" min="0" id="refPenV" value="0"></div>
</div>
<div class="hpc-res" id="refR"><span class="d">Fill in the fields above.</span></div>
<div class="hpc-res-btns"><button data-copy="refR">Copy</button><button data-note="ref">Draft Credit Note</button></div>
</div>
<!-- ═══════════ PLAN CHANGE ═══════════ -->
<div class="hpc-pane" id="panePlan">
<div class="hpc-section">
<div class="hpc-stitle">Old plan</div>
<div class="hpc-tmpl-row"><label>Template</label><select id="plOldTmplSel"><option value="">— load template —</option></select><button id="plOldTmplSave">Save as…</button></div>
<div class="hpc-f"><label>Price per cycle</label><input type="number" step="0.01" min="0" id="plOldP" placeholder="10.00"></div>
<div class="hpc-f"><label>Billing cycle</label><select id="plOldC">${CYCLE_OPTS}</select></div>
<div class="hpc-f"><label>Current period start</label><input type="date" id="plOldS"></div>
<div class="hpc-f"><label>Current period end</label><input type="date" id="plOldE"></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Change</div>
<div class="hpc-f"><label>Change date</label><input type="date" id="plChg"></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">New plan</div>
<div class="hpc-tmpl-row"><label>Template</label><select id="plNewTmplSel"><option value="">— load template —</option></select><button id="plNewTmplSave">Save as…</button></div>
<div class="hpc-f"><label>Price per cycle</label><input type="number" step="0.01" min="0" id="plNewP" placeholder="25.00"></div>
<div class="hpc-f"><label>Billing cycle</label><select id="plNewC">${CYCLE_OPTS}</select></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Cycle handling</div>
<div class="hpc-radio">
<label><input type="radio" name="plMode" value="continue" checked> Continue current cycle</label>
<label><input type="radio" name="plMode" value="fresh"> Fresh cycle</label>
</div>
<div class="hpc-note" style="0">Continue = new plan inherits old end date. Fresh = new full cycle from change date.</div>
</div>
<button class="hpc-adv-btn" data-for="plAdv">▾ Advanced</button>
<div class="hpc-adv" id="plAdv">
<div class="hpc-f"><label>Old plan divisor</label><input type="number" min="1" id="plOldDiv" placeholder="auto"><span class="auto" id="plOldDivA"></span></div>
<div class="hpc-f"><label>New plan divisor</label><input type="number" min="1" id="plNewDiv" placeholder="auto"><span class="auto" id="plNewDivA"></span></div>
</div>
<div class="hpc-res" id="plR"><span class="d">Fill in the fields above.</span></div>
<div class="hpc-res-btns"><button data-copy="plR">Copy</button><button data-note="plan">Draft Adjustment Note</button></div>
</div>
<!-- ═══════════ AUDIT ═══════════ -->
<div class="hpc-pane" id="paneAudit">
<div class="hpc-section">
<div class="hpc-stitle">Service period</div>
<div class="hpc-f"><label>Period start</label><input type="date" id="audStart"></div>
<div class="hpc-f"><label>Billing end</label><input type="date" id="audEnd"></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Payment</div>
<div class="hpc-tmpl-row"><label>Template</label><select id="audTmplSel"><option value="">— load template —</option></select><button id="audTmplSave">Save as…</button></div>
<div class="hpc-f"><label>Payment amount</label><input type="number" step="0.01" min="0" id="audPay"></div>
<div class="hpc-f"><label>Credit applied</label><input type="number" step="0.01" min="0" id="audCred" value="0"></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Cutoff</div>
<div class="hpc-f"><label>Cutoff date</label><input type="date" id="audCut"></div>
<div class="hpc-chk"><input type="checkbox" id="audCutInc" checked><label for="audCutInc">Include cutoff date in unused days</label></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Actual refund received</div>
<div class="hpc-f"><label>Amount received</label><input type="number" step="0.01" min="0" id="audActual" placeholder="3.35"></div>
</div>
<button class="hpc-adv-btn" data-for="audAdv">▾ Advanced</button>
<div class="hpc-adv" id="audAdv">
<div class="hpc-f"><label>Proration divisor</label><input type="number" min="1" id="audDiv" placeholder="auto"><span class="auto" id="audDivA"></span></div>
<div class="hpc-f"><label>Processor fee override</label><input type="number" step="0.01" min="0" id="audFee" placeholder="auto"><span class="auto" id="audFeeA"></span></div>
<div class="hpc-stitle" style="8px">Early termination penalty</div>
<div class="hpc-f"><label>Penalty type</label><select id="audPenT">${PEN_OPTS}</select></div>
<div class="hpc-f" id="audPenR" style="display:none"><label>Penalty amount/%</label><input type="number" step="0.01" min="0" id="audPenV" value="0"></div>
</div>
<div class="hpc-res" id="audR"><span class="d">Fill in the fields above.</span></div>
<div class="hpc-res-btns"><button data-copy="audR">Copy</button><button data-note="audit">Draft Discrepancy Report</button></div>
</div>
<!-- ═══════════ BATCH ═══════════ -->
<div class="hpc-pane" id="paneBatch">
<div class="hpc-section">
<div class="hpc-stitle">Common parameters</div>
<div class="hpc-f"><label>Cutoff date</label><input type="date" id="batCut"></div>
<div class="hpc-chk"><input type="checkbox" id="batCutInc" checked><label for="batCutInc">Include cutoff date in unused days</label></div>
</div>
<div class="hpc-section">
<div class="hpc-stitle">Services <button class="hpc-btn-sm" id="batAdd">+ Add</button></div>
<div id="batList"></div>
</div>
<div class="hpc-res" id="batR"><span class="d">Add services and fill in the cutoff date.</span></div>
<div class="hpc-res-btns"><button data-copy="batR">Copy</button><button data-note="batch">Draft Credit Note</button></div>
</div>
<!-- ═══════════ FX (Currency) ═══════════ -->
<div class="hpc-pane" id="paneFx">
<div class="hpc-section">
<div class="hpc-stitle">Currency conversion</div>
<div class="hpc-f"><label>Amount</label><input type="number" step="0.01" id="fxAmt" placeholder="100.00"></div>
<div class="hpc-f"><label>From currency</label><input type="text" id="fxFrom" value="USD" maxlength="5" style="text-transform:uppercase"></div>
<div class="hpc-f"><label>Exchange rate</label><input type="number" step="0.000001" id="fxRate" placeholder="1.00"></div>
<div class="hpc-f"><label>To currency</label><input type="text" id="fxTo" value="EUR" maxlength="5" style="text-transform:uppercase"></div>
</div>
<div style="display:flex;gap:8px;8px">
<button class="hpc-btn-sm" id="fxLock">Lock Rate</button>
<span id="fxLockInfo" style="font-size:11px;color:#6c757d;26px"></span>
</div>
<div class="hpc-res" id="fxR"><span class="d">Enter amount and rate.</span></div>
<div class="hpc-res-btns"><button data-copy="fxR">Copy</button></div>
</div>
</div><!-- .hpc-body -->
</div><!-- .hpc-panel -->
<!-- Template add/edit overlay -->
<div class="hpc-overlay" id="tmplOvr">
<div class="hpc-modal" style="width:360px">
<h4 id="tmplOvrTitle">Add Template</h4>
<div class="hpc-f" style="8px"><label style="min-width:80px;font-size:12px">Name</label><input type="text" id="tmplName" placeholder="e.g. VPS Basic" maxlength="40"></div>
<div class="hpc-f" style="8px"><label style="min-width:80px;font-size:12px">Price</label><input type="number" step="0.01" min="0" id="tmplPrice" placeholder="45.00"></div>
<div class="hpc-f" style="8px"><label style="min-width:80px;font-size:12px">Cycle</label><select id="tmplCycle">${CYCLE_OPTS}</select></div>
<div class="hpc-f" style="8px"><label style="min-width:80px;font-size:12px">Divisor</label><input type="number" min="1" id="tmplDiv" placeholder="auto (leave blank)"></div>
<input type="hidden" id="tmplEditId">
<div class="m-btns">
<button class="m-secondary" id="tmplOvrClose">Cancel</button>
<button class="m-primary" id="tmplOvrSave">Save Template</button>
</div>
</div>
</div>
<!-- History overlay -->
<div class="hpc-overlay" id="histOvr">
<div class="hpc-modal hpc-modal-wide">
<div style="display:flex;align-items:center;justify-content:space-between;10px">
<h4 style="0">Calculation History</h4>
<button class="m-secondary" id="histClear" style="font-size:11px;padding:3px 8px;border:1px solid #ced4da;border-radius:4px;background:#fff;cursor:pointer">Clear all</button>
</div>
<div id="histList"></div>
<div class="m-btns"><button class="m-secondary" id="histClose">Close</button></div>
</div>
</div>
<!-- Invoice overlay -->
<div class="hpc-overlay" id="noteOvr">
<div class="hpc-modal">
<h4 id="noteTitle">Document</h4>
<textarea id="noteTxt"></textarea>
<div class="m-btns">
<button class="m-secondary" id="noteClose">Close</button>
<button class="m-primary" id="noteCopy">Copy to Clipboard</button>
</div>
</div>
</div>
`;
// ═══════════════════════════════════════════════════════════════════════════
// Init UI (Shadow DOM)
// ═══════════════════════════════════════════════════════════════════════════
const host = document.createElement("div");
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: "closed" });
const styleEl = document.createElement("style");
styleEl.textContent = CSS;
shadow.appendChild(styleEl);
const wrap = document.createElement("div");
wrap.innerHTML = HTML;
shadow.appendChild(wrap);
const $ = (id) => shadow.getElementById(id);
const $v = (id) => { const e = $(id); return e ? e.value : ""; };
const $f = (id) => parseFloat($v(id)) || 0;
const $d = (id) => toDate($v(id));
const $ck = (id) => { const e = $(id); return e ? e.checked : false; };
// ═══════════════════════════════════════════════════════════════════════════
// Load config into UI
// ═══════════════════════════════════════════════════════════════════════════
let cfg = loadCfg();
function cfgToUI() {
$("cfgPct").value = cfg.feePct;
$("cfgFlat").value = cfg.feeFlat;
$("cfgCur").value = cfg.cur;
$("cfgPenType").value = cfg.penType || "none";
$("cfgPenVal").value = cfg.penVal || 0;
$("cfgPenRow").style.display = cfg.penType !== "none" ? "" : "none";
$("refPenT").value = cfg.penType || "none";
$("refPenV").value = cfg.penVal || 0;
$("refPenR").style.display = cfg.penType !== "none" ? "" : "none";
$("audPenT").value = cfg.penType || "none";
$("audPenV").value = cfg.penVal || 0;
$("audPenR").style.display = cfg.penType !== "none" ? "" : "none";
}
cfgToUI();
function readCfg() {
cfg.feePct = parseFloat($("cfgPct").value) || 0;
cfg.feeFlat = parseFloat($("cfgFlat").value) || 0;
cfg.cur = $("cfgCur").value || "$";
cfg.penType = $v("cfgPenType");
cfg.penVal = parseFloat($("cfgPenVal").value) || 0;
saveCfg(cfg);
}
function C(n) { return cfg.cur + n.toFixed(2); }
// ═══════════════════════════════════════════════════════════════════════════
// Config export / import
// ═══════════════════════════════════════════════════════════════════════════
$("btnExport").addEventListener("click", () => {
readCfg();
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "hpc-config.json"; a.click();
URL.revokeObjectURL(url);
});
$("btnImport").addEventListener("click", () => $("fileImport").click());
$("fileImport").addEventListener("change", (e) => {
const f = e.target.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
cfg = { ...DEFAULTS, ...data };
saveCfg(cfg);
cfgToUI();
recalcActive();
} catch { alert("Invalid config file."); }
};
reader.readAsText(f);
e.target.value = "";
});
// Penalty type toggles visibility of value field
["cfgPenType"].forEach(id => {
$(id).addEventListener("change", () => {
$("cfgPenRow").style.display = $v(id) !== "none" ? "" : "none";
readCfg();
});
});
["refPenT"].forEach(id => {
$(id).addEventListener("change", () => {
$("refPenR").style.display = $v(id) !== "none" ? "" : "none";
calcRef();
});
});
["audPenT"].forEach(id => {
$(id).addEventListener("change", () => {
$("audPenR").style.display = $v(id) !== "none" ? "" : "none";
calcAudit();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Panel toggle, drag, tabs
// ═══════════════════════════════════════════════════════════════════════════
$("hpcBtn").addEventListener("click", () => $("hpcPanel").classList.toggle("open"));
$("btnX").addEventListener("click", () => $("hpcPanel").classList.remove("open"));
$("btnCfg").addEventListener("click", () => $("secCfg").classList.toggle("open"));
// Drag
{
const hdr = $("hpcHdr"), panel = $("hpcPanel");
let dx, dy, dragging = false;
hdr.addEventListener("mousedown", (e) => {
if (e.target.tagName === "BUTTON") return;
dragging = true;
const r = panel.getBoundingClientRect();
dx = e.clientX - r.left; dy = e.clientY - r.top;
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!dragging) return;
panel.style.left = (e.clientX - dx) + "px";
panel.style.top = (e.clientY - dy) + "px";
panel.style.right = "auto"; panel.style.bottom = "auto";
});
document.addEventListener("mouseup", () => dragging = false);
}
// Tabs
let activeTab = "ext";
const PANE_MAP = { ext:"paneExt", ref:"paneRef", plan:"panePlan", audit:"paneAudit", batch:"paneBatch", fx:"paneFx" };
shadow.querySelectorAll(".hpc-tab").forEach(tab => {
tab.addEventListener("click", () => {
shadow.querySelectorAll(".hpc-tab").forEach(t => t.classList.remove("active"));
shadow.querySelectorAll(".hpc-pane").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
activeTab = tab.dataset.tab;
$(PANE_MAP[activeTab]).classList.add("active");
});
});
// Advanced toggles
shadow.querySelectorAll(".hpc-adv-btn").forEach(btn => {
btn.addEventListener("click", () => {
const tgt = $(btn.dataset.for);
const open = tgt.classList.toggle("open");
btn.innerHTML = (open ? "▴ " : "▾ ") + "Advanced";
});
});
// Copy buttons
shadow.querySelectorAll("[data-copy]").forEach(btn => {
btn.addEventListener("click", () => {
const txt = $(btn.dataset.copy).textContent;
navigator.clipboard.writeText(txt).then(() => {
const orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => btn.textContent = orig, 1200);
});
});
});
// Escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if ($("noteOvr").classList.contains("open")) $("noteOvr").classList.remove("open");
else $("hpcPanel").classList.remove("open");
}
});
// ═══════════════════════════════════════════════════════════════════════════
// EXTENSION calculation
// ═══════════════════════════════════════════════════════════════════════════
function calcExt() {
readCfg();
const out = $("extR");
const price = $f("extPrice"), endDate = $d("extEnd"), target = $d("extTarget");
if (!price || !endDate || !target) { out.innerHTML = '<span class="d">Fill in the fields above.</span>'; state.lastCalc = null; return; }
const daysAdded = diffDays(endDate, target);
if (daysAdded <= 0) { out.innerHTML = '<span class="w">Target must be after billing end.</span>'; state.lastCalc = null; return; }
const cycle = $v("extCycle");
const natEnd = cycleEnd(endDate, cycle);
const natDays = diffDays(endDate, natEnd);
$("extDivA").textContent = `(cycle: ${natDays}d)`;
const divisor = $f("extDiv") || natDays;
const credit = $f("extCred");
const gross = (daysAdded / divisor) * price;
const toCharge = Math.max(0, gross - credit);
const fee = toCharge > 0 ? calcFee(toCharge, cfg) : 0;
const hostNet = toCharge - fee;
const daily = price / divisor;
let s = "";
const SEP = (c, n) => `<span class="s">${c.repeat(n)}</span>\n`;
s += SEP("=", 42);
s += ` Extension: <span class="hl">${daysAdded} days</span>\n`;
s += ` ${fmtShort(endDate)} --> ${fmtShort(target)}\n`;
s += SEP("-", 42);
s += ` Cycle price: ${C(price)} / ${divisor} days\n`;
s += ` Daily rate: ${cfg.cur}${daily.toFixed(4)}/day\n`;
s += SEP("-", 42);
s += ` <span class="d">(${daysAdded} / ${divisor}) x ${C(price)}</span>\n`;
s += ` Gross cost: <span class="hl">${C(gross)}</span>\n`;
if (credit > 0) {
s += ` Credit: -${C(credit)}\n`;
if (toCharge <= 0) {
s += ` <span class="ok">Fully covered by credit.</span>\n`;
const leftover = credit - gross;
if (leftover > 0) s += ` Remaining: ${C(leftover)}\n`;
out.innerHTML = s;
state.lastCalc = { type:"ext", daysAdded, divisor, price, gross, credit, toCharge:0, fee:0, hostNet:0, endDate, target, cycle };
return;
}
s += ` To charge: ${C(toCharge)}\n`;
}
if (cfg.feePct > 0 || cfg.feeFlat > 0) {
const fd = feeDesc();
s += ` Proc. fee: ${C(fee)} <span class="d">(${fd})</span>\n`;
s += ` Host gets: <span class="ok">${C(hostNet)}</span>\n`;
}
s += ` Customer pays: <span class="hl">${C(toCharge)}</span>\n`;
out.innerHTML = s;
state.lastCalc = { type:"ext", daysAdded, divisor, price, gross, credit, toCharge, fee, hostNet, endDate, target, cycle };
}
// ═══════════════════════════════════════════════════════════════════════════
// REFUND calculation
// ═══════════════════════════════════════════════════════════════════════════
function calcRef() {
readCfg();
const out = $("refR");
const start = $d("refStart"), end = $d("refEnd"), payment = $f("refPay"), credit = $f("refCred"), cutoff = $d("refCut");
if (!start || !end || !cutoff || (!payment && !credit)) { out.innerHTML = '<span class="d">Fill in the fields above.</span>'; state.lastCalc = null; return; }
const totalDays = diffDays(start, end);
if (totalDays <= 0) { out.innerHTML = '<span class="w">End must be after start.</span>'; state.lastCalc = null; return; }
$("refDivA").textContent = `(period: ${totalDays}d)`;
const divisor = $f("refDiv") || totalDays;
const countCut = $ck("refCutInc");
const remFrom = countCut ? cutoff : addDays(cutoff, 1);
const remaining = diffDays(remFrom, end);
if (remaining <= 0) { out.innerHTML = '<span class="d">No remaining days — no refund due.</span>'; state.lastCalc = null; return; }
const feeOvr = $("refFee").value ? $f("refFee") : calcFee(payment, cfg);
$("refFeeA").textContent = `(auto: ${C(calcFee(payment, cfg))})`;
const totalGross = credit + payment;
const totalNet = credit + (payment - feeOvr);
const refGross = (remaining / divisor) * totalGross;
const refNet = (remaining / divisor) * totalNet;
const pct = ((remaining / divisor) * 100).toFixed(2);
const penT = $v("refPenT"), penV = $f("refPenV");
const penGross = calcPenalty(penT === "pct_total" ? totalGross : refGross, penT, penV);
const penNet = calcPenalty(penT === "pct_total" ? totalNet : refNet, penT, penV);
const finalGross = Math.max(0, refGross - penGross);
const finalNet = Math.max(0, refNet - penNet);
const rfFee = $ck("refRfFee");
const s = renderRefundResult({ start, end, divisor, payment, credit, totalGross, totalNet, feeOvr, cutoff, countCut, remFrom, remaining, pct, refGross, refNet, penT, penV, penGross, penNet, finalGross, finalNet, rfFee });
out.innerHTML = s;
state.lastCalc = { type:"ref", start, end, divisor, payment, credit, totalGross, totalNet, cutoff, remaining, remFrom, refGross, refNet, penT, penGross, penNet, finalGross, finalNet, pct };
}
function renderRefundResult(d) {
let s = "";
const SEP = (c, n) => `<span class="s">${c.repeat(n)}</span>\n`;
s += SEP("=", 42);
s += ` Period: ${fmtShort(d.start)} --> ${fmtShort(d.end)}\n`;
s += ` Divisor: ${d.divisor} days\n`;
s += SEP("-", 42);
if (d.credit > 0) {
s += ` Payment: ${C(d.payment)}\n`;
s += ` Credit: ${C(d.credit)}\n`;
s += ` Total paid: ${C(d.totalGross)} <span class="d">(gross)</span>\n`;
s += ` Proc. fee: ${C(d.feeOvr)} <span class="d">(on payment only)</span>\n`;
s += ` Host net: ${C(d.totalNet)}\n`;
} else {
s += ` Total paid: ${C(d.totalGross)} <span class="d">(gross)</span>\n`;
if (d.feeOvr > 0) { s += ` Proc. fee: ${C(d.feeOvr)}\n`; s += ` Host net: ${C(d.totalNet)}\n`; }
}
s += SEP("-", 42);
s += ` Cutoff: ${fmtShort(d.cutoff)}`;
s += d.countCut ? ` <span class="d">(counted as unused)</span>\n` : ` <span class="d">(last used day)</span>\n`;
s += ` Remaining: <span class="hl">${d.remaining} days</span> <span class="d">(${fmtShort(d.remFrom)} --> ${fmtShort(d.end)})</span>\n`;
s += ` Unused: ${d.pct}%\n`;
s += SEP("=", 42);
s += ` <span class="d">(${d.remaining}/${d.divisor}) x ${C(d.totalGross)}</span>\n`;
s += ` Refund (gross): <span class="hl">${C(d.refGross)}</span>\n`;
if (d.totalGross !== d.totalNet) {
s += `\n <span class="d">(${d.remaining}/${d.divisor}) x ${C(d.totalNet)}</span>\n`;
s += ` Refund (net): <span class="ok">${C(d.refNet)}</span>\n`;
}
if (d.penT !== "none" && (d.penGross > 0 || d.penNet > 0)) {
s += SEP("-", 42);
const pLabel = d.penT === "flat"
? `flat ${C(d.penV)}`
: `${d.penV}% of ${d.penT === "pct_total" ? "total paid" : "refund"}`;
s += ` Penalty: -${C(d.penGross)} <span class="d">(${pLabel})</span>\n`;
s += ` After penalty (gross): <span class="hl">${C(d.finalGross)}</span>\n`;
if (d.totalGross !== d.totalNet) {
s += ` After penalty (net): <span class="ok">${C(d.finalNet)}</span>\n`;
}
}
if (d.rfFee) {
const rfg = calcFee(d.finalGross, cfg), rfn = calcFee(d.finalNet, cfg);
s += `\n` + SEP("-", 42);
s += ` <span class="d">Refund transaction fee:</span>\n`;
s += ` On gross: ${C(rfg)}`;
if (d.totalGross !== d.totalNet) s += ` | On net: ${C(rfn)}`;
s += `\n`;
s += ` <span class="d">Host subsidizes (customer gets full):</span>\n`;
s += ` Gross: gets ${C(d.finalGross)}, host pays ${C(d.finalGross + rfg)}\n`;
if (d.totalGross !== d.totalNet)
s += ` Net: gets ${C(d.finalNet)}, host pays ${C(d.finalNet + rfn)}\n`;
s += ` <span class="d">Customer absorbs fee:</span>\n`;
s += ` Gross: gets ${C(d.finalGross - rfg)}, host pays ${C(d.finalGross)}\n`;
if (d.totalGross !== d.totalNet)
s += ` Net: gets ${C(d.finalNet - rfn)}, host pays ${C(d.finalNet)}\n`;
}
return s;
}
// ═══════════════════════════════════════════════════════════════════════════
// PLAN CHANGE calculation
// ═══════════════════════════════════════════════════════════════════════════
function calcPlan() {
readCfg();
const out = $("plR");
const oldP = $f("plOldP"), oldS = $d("plOldS"), oldE = $d("plOldE"), chgDate = $d("plChg");
const newP = $f("plNewP"), newCycle = $v("plNewC");
const mode = shadow.querySelector('input[name="plMode"]:checked');
if (!oldP || !oldS || !oldE || !chgDate || !newP || !mode) { out.innerHTML = '<span class="d">Fill in the fields above.</span>'; state.lastCalc = null; return; }
const pm = mode.value;
const oldTotal = diffDays(oldS, oldE);
if (oldTotal <= 0) { out.innerHTML = '<span class="w">Old plan end must be after start.</span>'; state.lastCalc = null; return; }
const oldRemaining = diffDays(chgDate, oldE);
if (oldRemaining <= 0) { out.innerHTML = '<span class="w">Change date must be before period end.</span>'; state.lastCalc = null; return; }
$("plOldDivA").textContent = `(period: ${oldTotal}d)`;
const oldDiv = $f("plOldDiv") || oldTotal;
const oldCredit = (oldRemaining / oldDiv) * oldP;
let newCharge, newEnd, newDiv, newDays;
if (pm === "continue") {
newDays = oldRemaining;
newEnd = oldE;
const natNewCycle = diffDays(chgDate, cycleEnd(chgDate, newCycle));
$("plNewDivA").textContent = `(cycle: ${natNewCycle}d)`;
newDiv = $f("plNewDiv") || natNewCycle;
newCharge = (newDays / newDiv) * newP;
} else {
newEnd = cycleEnd(chgDate, newCycle);
newDays = diffDays(chgDate, newEnd);
$("plNewDivA").textContent = `(full cycle: ${newDays}d)`;
newDiv = $f("plNewDiv") || newDays;
newCharge = newP;
}
const net = newCharge - oldCredit;
const fee = net > 0 ? calcFee(net, cfg) : 0;
const hostNet = net > 0 ? net - fee : net;
let s = "";
const SEP = (c, n) => `<span class="s">${c.repeat(n)}</span>\n`;
s += SEP("=", 42);
s += ` Plan change on ${fmtShort(chgDate)}\n`;
s += SEP("-", 42);
s += ` <span class="d">Old plan: ${C(oldP)} / ${$v("plOldC")}</span>\n`;
s += ` <span class="d">Period: ${fmtShort(oldS)} --> ${fmtShort(oldE)} (${oldDiv}d)</span>\n`;
s += ` Remaining: ${oldRemaining} days\n`;
s += ` <span class="d">(${oldRemaining}/${oldDiv}) x ${C(oldP)}</span>\n`;
s += ` Credit: <span class="ok">${C(oldCredit)}</span>\n`;
s += SEP("-", 42);
s += ` <span class="d">New plan: ${C(newP)} / ${newCycle}</span>\n`;
if (pm === "continue") {
s += ` <span class="d">Continues to ${fmtShort(newEnd)} (${newDays}d remaining)</span>\n`;
s += ` <span class="d">(${newDays}/${newDiv}) x ${C(newP)}</span>\n`;
} else {
s += ` <span class="d">New cycle: ${fmtShort(chgDate)} --> ${fmtShort(newEnd)} (${newDays}d)</span>\n`;
s += ` <span class="d">Full cycle price</span>\n`;
}
s += ` Charge: <span class="hl">${C(newCharge)}</span>\n`;
s += SEP("=", 42);
s += ` Charge: ${C(newCharge)}\n`;
s += ` Credit: -${C(oldCredit)}\n`;
s += SEP("-", 42);
if (net >= 0) {
s += ` Net due: <span class="hl">${C(net)}</span>\n`;
if (fee > 0) {
s += ` Proc. fee: ${C(fee)} <span class="d">(${feeDesc()})</span>\n`;
s += ` Host gets: <span class="ok">${C(hostNet)}</span>\n`;
}
} else {
s += ` Refund owed: <span class="ok">${C(Math.abs(net))}</span>\n`;
}
out.innerHTML = s;
state.lastCalc = { type:"plan", oldP, oldS, oldE, chgDate, newP, newCycle, pm, oldDiv, oldCredit, oldRemaining, newCharge, newEnd, newDays, newDiv, net, fee, hostNet };
}
// ═══════════════════════════════════════════════════════════════════════════
// AUDIT calculation
// ═══════════════════════════════════════════════════════════════════════════
function calcAudit() {
readCfg();
const out = $("audR");
const start = $d("audStart"), end = $d("audEnd"), payment = $f("audPay"), credit = $f("audCred"), cutoff = $d("audCut"), actual = $f("audActual");
if (!start || !end || !cutoff || (!payment && !credit)) { out.innerHTML = '<span class="d">Fill in the fields above.</span>'; state.lastCalc = null; return; }
const totalDays = diffDays(start, end);
if (totalDays <= 0) { out.innerHTML = '<span class="w">End must be after start.</span>'; state.lastCalc = null; return; }
$("audDivA").textContent = `(period: ${totalDays}d)`;
const divisor = $f("audDiv") || totalDays;
const countCut = $ck("audCutInc");
const remFrom = countCut ? cutoff : addDays(cutoff, 1);
const remaining = diffDays(remFrom, end);
if (remaining <= 0) { out.innerHTML = '<span class="d">No remaining days.</span>'; state.lastCalc = null; return; }
const feeOvr = $("audFee").value ? $f("audFee") : calcFee(payment, cfg);
$("audFeeA").textContent = `(auto: ${C(calcFee(payment, cfg))})`;
const totalGross = credit + payment;
const totalNet = credit + (payment - feeOvr);
const refGross = (remaining / divisor) * totalGross;
const refNet = (remaining / divisor) * totalNet;
const pct = ((remaining / divisor) * 100).toFixed(2);
const penT = $v("audPenT"), penV = $f("audPenV");
const penGross = calcPenalty(penT === "pct_total" ? totalGross : refGross, penT, penV);
const finalGross = Math.max(0, refGross - penGross);
const finalNet = Math.max(0, refNet - calcPenalty(penT === "pct_total" ? totalNet : refNet, penT, penV));
let s = "";
const SEP = (c, n) => `<span class="s">${c.repeat(n)}</span>\n`;
s += SEP("=", 42);
s += ` DISCREPANCY AUDIT\n`;
s += SEP("=", 42);
s += ` Period: ${fmtShort(start)} --> ${fmtShort(end)}\n`;
s += ` Divisor: ${divisor}d | Remaining: ${remaining}d (${pct}%)\n`;
s += SEP("-", 42);
if (credit > 0) {
s += ` Payment: ${C(payment)} + Credit: ${C(credit)}\n`;
s += ` Total gross: ${C(totalGross)}\n`;
} else {
s += ` Total paid: ${C(totalGross)}\n`;
}
if (feeOvr > 0) s += ` Host net: ${C(totalNet)}\n`;
s += SEP("-", 42);
s += ` Correct (gross): <span class="hl">${C(refGross)}</span>\n`;
if (totalGross !== totalNet) s += ` Correct (net): <span class="ok">${C(refNet)}</span>\n`;
if (penT !== "none" && penGross > 0) {
s += ` Penalty: -${C(penGross)}\n`;
s += ` After penalty: <span class="hl">${C(finalGross)}</span>\n`;
}
if (actual > 0) {
s += SEP("=", 42);
s += ` Actual received: ${C(actual)}\n`;
const diffG = finalGross - actual;
const diffN = finalNet - actual;
if (Math.abs(diffG) < 0.005) {
s += ` <span class="ok">Matches gross calculation.</span>\n`;
} else if (Math.abs(diffN) < 0.005) {
s += ` <span class="ok">Matches net calculation.</span>\n`;
} else if (diffG > 0) {
s += ` Shortfall (gross): <span class="w">${C(diffG)}</span>\n`;
if (totalGross !== totalNet) s += ` Shortfall (net): <span class="w">${C(diffN)}</span>\n`;
} else {
s += ` Overpaid by: <span class="w">${C(Math.abs(diffG))}</span>\n`;
}
// Reverse-engineer what base the host likely used
if (remaining > 0 && Math.abs(diffG) >= 0.005) {
const likelyBase = (actual * divisor) / remaining;
s += `\n <span class="d">Likely error: host prorated on</span>\n`;
s += ` <span class="d"> ${C(likelyBase)} instead of ${C(totalGross)}</span>\n`;
if (credit > 0 && Math.abs(likelyBase - payment) < 0.02) {
s += ` <span class="w"> ^ Matches payment amount — credit was excluded!</span>\n`;
}
}
} else {
s += `\n <span class="d">Enter "Amount received" to compare.</span>\n`;
}
out.innerHTML = s;
state.lastCalc = { type:"audit", start, end, divisor, payment, credit, totalGross, totalNet, cutoff, remaining, remFrom, refGross, refNet, actual, finalGross, finalNet, penT, penGross, pct };
}
// ═══════════════════════════════════════════════════════════════════════════
// BATCH calculation
// ═══════════════════════════════════════════════════════════════════════════
let batIdx = 0;
function addBatchCard() {
batIdx++;
const card = document.createElement("div");
card.className = "bc";
card.dataset.idx = batIdx;
card.innerHTML = `
<div class="bc-hdr"><input type="text" class="bc-lbl" value="Service ${batIdx}" placeholder="Label"><button class="bc-rm" title="Remove">×</button></div>
<div class="bc-row"><label>Start</label><input type="date" class="bc-s"><label>End</label><input type="date" class="bc-e"></div>
<div class="bc-row"><label>Paid</label><input type="number" step="0.01" class="bc-pay" placeholder="0.00"><label>Cr.</label><input type="number" step="0.01" class="bc-cr" value="0"><label>Div</label><input type="number" class="bc-div" placeholder="auto" style="width:55px"></div>
<div class="bc-res"></div>`;
$("batList").appendChild(card);
card.querySelector(".bc-rm").addEventListener("click", () => { card.remove(); calcBatch(); });
card.querySelectorAll("input").forEach(inp => { inp.addEventListener("input", calcBatch); inp.addEventListener("change", calcBatch); });
calcBatch();
}
$("batAdd").addEventListener("click", addBatchCard);
function calcBatch() {
readCfg();
const out = $("batR");
const cutoff = $d("batCut");
const countCut = $ck("batCutInc");
const cards = $("batList").querySelectorAll(".bc");
if (!cutoff || cards.length === 0) {
out.innerHTML = '<span class="d">Add services and set cutoff date.</span>';
state.lastCalc = null;
return;
}
let totGross = 0, totNet = 0;
const items = [];
const SEP = (c, n) => `<span class="s">${c.repeat(n)}</span>\n`;
cards.forEach(card => {
const label = card.querySelector(".bc-lbl").value || "?";
const s = toDate(card.querySelector(".bc-s").value);
const e = toDate(card.querySelector(".bc-e").value);
const pay = parseFloat(card.querySelector(".bc-pay").value) || 0;
const cr = parseFloat(card.querySelector(".bc-cr").value) || 0;
const divOvr = parseFloat(card.querySelector(".bc-div").value) || 0;
const resEl = card.querySelector(".bc-res");
if (!s || !e || (!pay && !cr)) { resEl.textContent = ""; return; }
const totalDays = diffDays(s, e);
if (totalDays <= 0) { resEl.textContent = "Invalid dates"; return; }
const divisor = divOvr || totalDays;
const remFrom = countCut ? cutoff : addDays(cutoff, 1);
const remaining = diffDays(remFrom, e);
if (remaining <= 0) { resEl.textContent = "No remaining days"; return; }
const tGross = cr + pay;
const fee = calcFee(pay, cfg);
const tNet = cr + (pay - fee);
const rG = (remaining / divisor) * tGross;
const rN = (remaining / divisor) * tNet;
resEl.textContent = `${remaining}d remaining | Gross: ${C(rG)} | Net: ${C(rN)}`;
totGross += rG;
totNet += rN;
items.push({ label, start: s, end: e, remaining, divisor, totalGross: tGross, totalNet: tNet, refGross: rG, refNet: rN });
});
let s = SEP("=", 42);
s += ` BATCH REFUND SUMMARY\n`;
s += ` Cutoff: ${fmtShort(cutoff)}`;
s += countCut ? ` (included)\n` : ` (excluded)\n`;
s += SEP("=", 42);
items.forEach(it => {
s += ` <span class="hl">${it.label}</span>\n`;
s += ` ${fmtShort(it.start)} --> ${fmtShort(it.end)}\n`;
s += ` ${it.remaining}d remaining, paid ${C(it.totalGross)}\n`;
s += ` Gross: ${C(it.refGross)} | Net: ${C(it.refNet)}\n`;
});
s += SEP("=", 42);
s += ` Total (gross): <span class="hl">${C(totGross)}</span>\n`;
if (Math.abs(totGross - totNet) > 0.005) {
s += ` Total (net): <span class="ok">${C(totNet)}</span>\n`;
}
out.innerHTML = s;
state.lastCalc = { type:"batch", cutoff, countCut, items, totGross, totNet };
}
// ═══════════════════════════════════════════════════════════════════════════
// FX (Currency conversion)
// ═══════════════════════════════════════════════════════════════════════════
let fxLocked = null;
function calcFx() {
const amt = $f("fxAmt"), rate = $f("fxRate");
const from = $v("fxFrom").toUpperCase() || "???";
const to = $v("fxTo").toUpperCase() || "???";
const out = $("fxR");
if (!amt || !rate) { out.innerHTML = '<span class="d">Enter amount and rate.</span>'; return; }
const converted = amt * rate;
let s = "";
s += ` ${amt.toFixed(2)} ${from}\n`;
s += ` x ${rate}\n`;
s += ` <span class="s">${"-".repeat(30)}</span>\n`;
s += ` <span class="hl">${converted.toFixed(2)} ${to}</span>\n`;
if (fxLocked) {
s += `\n <span class="d">Rate locked: ${fxLocked.rate} @ ${fxLocked.time}</span>\n`;
}
out.innerHTML = s;
}
$("fxLock").addEventListener("click", () => {
const rate = $f("fxRate");
if (!rate) return;
const now = new Date();
fxLocked = {
rate: rate,
time: now.toLocaleString("en-US", { month:"short", day:"numeric", year:"numeric", hour:"2-digit", minute:"2-digit" }),
};
$("fxLockInfo").innerHTML = `<span class="fx-lock">Locked @ ${fxLocked.time}</span>`;
calcFx();
});
// ═══════════════════════════════════════════════════════════════════════════
// Invoice / Credit Note / Document generator
// ═══════════════════════════════════════════════════════════════════════════
function feeDesc() {
if (cfg.feePct > 0 && cfg.feeFlat > 0) return `${cfg.feePct}% + ${C(cfg.feeFlat)}`;
if (cfg.feePct > 0) return `${cfg.feePct}%`;
if (cfg.feeFlat > 0) return C(cfg.feeFlat);
return "none";
}
const NOTE_TITLES = {
ext: "INVOICE", ref: "CREDIT NOTE", plan: "ADJUSTMENT NOTE",
audit: "DISCREPANCY REPORT", batch: "CREDIT NOTE (BATCH)",
};
function generateNote(type) {
const lc = state.lastCalc;
if (!lc) return "No calculation to generate from.";
const today = new Date().toLocaleDateString("en-US", { month:"long", day:"numeric", year:"numeric" });
const HR = "=".repeat(46);
const hr = "-".repeat(46);
let t = "";
t += `${HR}\n`;
t += ` ${NOTE_TITLES[type] || "DOCUMENT"}\n`;
t += `${HR}\n`;
t += ` Date: ${today}\n`;
t += ` Ref: [EDIT]\n`;
t += ` Customer: [EDIT]\n`;
t += `${hr}\n\n`;
if (type === "ext" && lc.type === "ext") {
t += ` Service Extension\n`;
t += ` From: ${fmtShort(lc.endDate)}\n`;
t += ` To: ${fmtShort(lc.target)} (${lc.daysAdded} days)\n`;
t += ` Rate: ${C(lc.price)} / ${lc.divisor} days\n\n`;
t += ` Gross cost: ${C(lc.gross)}\n`;
if (lc.credit > 0) t += ` Credit applied: -${C(lc.credit)}\n`;
t += ` Amount due: ${C(lc.toCharge)}\n`;
if (lc.fee > 0) t += ` Processor fee: ${C(lc.fee)} (${feeDesc()})\n`;
t += ` ${hr}\n`;
t += ` TOTAL DUE: ${C(lc.toCharge)}\n`;
}
else if (type === "ref" && lc.type === "ref") {
t += ` Service: [EDIT]\n`;
t += ` Period: ${fmtShort(lc.start)} -> ${fmtShort(lc.end)}\n`;
t += ` Cutoff: ${fmtShort(lc.cutoff)} (${lc.remaining} days remaining)\n\n`;
t += ` Total paid: ${C(lc.totalGross)}\n`;
t += ` Prorated refund: (${lc.remaining}/${lc.divisor}) x ${C(lc.totalGross)}\n\n`;
t += ` Refund (gross): ${C(lc.refGross)}\n`;
if (lc.totalGross !== lc.totalNet) t += ` Refund (net): ${C(lc.refNet)}\n`;
if (lc.penT !== "none" && lc.penGross > 0) {
t += ` Penalty: -${C(lc.penGross)}\n`;
t += ` After penalty: ${C(lc.finalGross)}\n`;
}
t += ` ${hr}\n`;
t += ` TOTAL REFUND: ${C(lc.finalGross)}\n`;
}
else if (type === "plan" && lc.type === "plan") {
t += ` Plan Change on ${fmtShort(lc.chgDate)}\n\n`;
t += ` Old plan: ${C(lc.oldP)} / ${$v("plOldC")}\n`;
t += ` Period: ${fmtShort(lc.oldS)} -> ${fmtShort(lc.oldE)}\n`;
t += ` Remaining: ${lc.oldRemaining} days\n`;
t += ` Credit: ${C(lc.oldCredit)}\n\n`;
t += ` New plan: ${C(lc.newP)} / ${lc.newCycle}\n`;
if (lc.pm === "continue") {
t += ` Continues to: ${fmtShort(lc.newEnd)}\n`;
} else {
t += ` New cycle: ${fmtShort(lc.chgDate)} -> ${fmtShort(lc.newEnd)}\n`;
}
t += ` Charge: ${C(lc.newCharge)}\n\n`;
t += ` ${hr}\n`;
t += ` Charge: ${C(lc.newCharge)}\n`;
t += ` Credit: -${C(lc.oldCredit)}\n`;
t += ` ${hr}\n`;
if (lc.net >= 0) {
t += ` NET DUE: ${C(lc.net)}\n`;
} else {
t += ` REFUND: ${C(Math.abs(lc.net))}\n`;
}
}
else if (type === "audit" && lc.type === "audit") {
t += ` Service: [EDIT]\n`;
t += ` Period: ${fmtShort(lc.start)} -> ${fmtShort(lc.end)}\n`;
t += ` Cutoff: ${fmtShort(lc.cutoff)} (${lc.remaining}d remaining)\n\n`;
t += ` Total paid (gross): ${C(lc.totalGross)}\n`;
if (lc.totalGross !== lc.totalNet) t += ` Total host net: ${C(lc.totalNet)}\n`;
t += `\n Correct refund (gross): ${C(lc.refGross)}\n`;
if (lc.totalGross !== lc.totalNet) t += ` Correct refund (net): ${C(lc.refNet)}\n`;
if (lc.actual > 0) {
t += `\n Actual received: ${C(lc.actual)}\n`;
const diff = lc.finalGross - lc.actual;
if (Math.abs(diff) >= 0.005) {
t += ` Shortfall (gross): ${C(diff)}\n`;
} else {
t += ` (Matches gross calculation)\n`;
}
}
t += ` ${hr}\n`;
if (lc.actual > 0 && lc.finalGross - lc.actual >= 0.005) {
t += ` AMOUNT OWED: ${C(lc.finalGross - lc.actual)}\n`;
}
}
else if (type === "batch" && lc.type === "batch") {
t += ` Service retirement: ${fmtShort(lc.cutoff)}\n\n`;
let i = 0;
lc.items.forEach(it => {
i++;
t += ` ${i}. ${it.label}\n`;
t += ` Period: ${fmtShort(it.start)} -> ${fmtShort(it.end)}\n`;
t += ` Remaining: ${it.remaining}d | Paid: ${C(it.totalGross)}\n`;
t += ` Refund (gross): ${C(it.refGross)}\n`;
if (Math.abs(it.refGross - it.refNet) > 0.005) t += ` Refund (net): ${C(it.refNet)}\n`;
t += `\n`;
});
t += ` ${hr}\n`;
t += ` TOTAL REFUND (gross): ${C(lc.totGross)}\n`;
if (Math.abs(lc.totGross - lc.totNet) > 0.005) {
t += ` TOTAL REFUND (net): ${C(lc.totNet)}\n`;
}
}
t += `\n Notes: [EDIT]\n`;
t += `${HR}\n`;
return t;
}
// Note buttons
shadow.querySelectorAll("[data-note]").forEach(btn => {
btn.addEventListener("click", () => {
const type = btn.dataset.note;
if (!state.lastCalc) { alert("Run a calculation first."); return; }
const txt = generateNote(type);
$("noteTitle").textContent = NOTE_TITLES[type] || "Document";
$("noteTxt").value = txt;
$("noteOvr").classList.add("open");
});
});
$("noteClose").addEventListener("click", () => $("noteOvr").classList.remove("open"));
$("noteCopy").addEventListener("click", () => {
navigator.clipboard.writeText($("noteTxt").value).then(() => {
$("noteCopy").textContent = "Copied!";
setTimeout(() => $("noteCopy").textContent = "Copy to Clipboard", 1200);
});
});
$("noteOvr").addEventListener("click", (e) => {
if (e.target === $("noteOvr")) $("noteOvr").classList.remove("open");
});
// ═══════════════════════════════════════════════════════════════════════════
// Event wiring — recalculate on input changes
// ═══════════════════════════════════════════════════════════════════════════
function recalcActive() {
switch (activeTab) {
case "ext": calcExt(); break;
case "ref": calcRef(); break;
case "plan": calcPlan(); break;
case "audit": calcAudit(); break;
case "batch": calcBatch(); break;
case "fx": calcFx(); break;
}
}
const cfgIds = ["cfgPct", "cfgFlat", "cfgCur", "cfgPenVal"];
cfgIds.forEach(id => {
$(id).addEventListener("input", recalcActive);
$(id).addEventListener("change", recalcActive);
});
function wirePane(ids, fn) {
ids.forEach(id => {
const el = $(id);
if (!el) return;
el.addEventListener("input", fn);
el.addEventListener("change", fn);
});
}
wirePane(["extPrice","extCycle","extEnd","extTarget","extDiv","extCred"], calcExt);
wirePane(["refStart","refEnd","refPay","refCred","refCut","refCutInc","refDiv","refFee","refRfFee","refPenT","refPenV"], calcRef);
wirePane(["plOldP","plOldC","plOldS","plOldE","plChg","plNewP","plNewC","plOldDiv","plNewDiv"], calcPlan);
wirePane(["audStart","audEnd","audPay","audCred","audCut","audCutInc","audDiv","audFee","audActual","audPenT","audPenV"], calcAudit);
wirePane(["batCut","batCutInc"], calcBatch);
wirePane(["fxAmt","fxRate","fxFrom","fxTo"], calcFx);
// Plan mode radios
shadow.querySelectorAll('input[name="plMode"]').forEach(r => r.addEventListener("change", calcPlan));
// ═══════════════════════════════════════════════════════════════════════════
// Case context
// ═══════════════════════════════════════════════════════════════════════════
const ctx = { ticketId: "", customerName: "" };
$("ctxTicket").addEventListener("input", () => { ctx.ticketId = $v("ctxTicket"); });
$("ctxCustomer").addEventListener("input", () => { ctx.customerName = $v("ctxCustomer"); });
// Patch generateNote to use ctx values
const _origGenNote = generateNote;
// (ctx is already in scope when generateNote runs — the function reads ctx directly)
// ═══════════════════════════════════════════════════════════════════════════
// Templates
// ═══════════════════════════════════════════════════════════════════════════
if (!cfg.templates) cfg.templates = [];
function refreshTemplateSelectors() {
const selIds = ["extTmplSel","refTmplSel","plOldTmplSel","plNewTmplSel","audTmplSel"];
selIds.forEach(id => {
const sel = $(id);
if (!sel) return;
const prev = sel.value;
sel.innerHTML = '<option value="">— load template —</option>';
cfg.templates.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(t => {
const opt = document.createElement("option");
opt.value = t.id;
opt.textContent = `${t.name} (${C(t.price)}/${t.cycle})`;
sel.appendChild(opt);
});
if (prev) sel.value = prev;
});
renderTmplList();
}
function renderTmplList() {
const list = $("tmplList");
if (!list) return;
if (cfg.templates.length === 0) {
list.innerHTML = '<div class="hpc-hist-empty">No templates yet.</div>';
return;
}
list.innerHTML = "";
cfg.templates.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(t => {
const item = document.createElement("div");
item.className = "hpc-tmpl-item";
item.innerHTML = `
<span title="Click to edit">${t.name}</span>
<span class="td">${C(t.price)}/${t.cycle}${t.divisor ? " div:"+t.divisor : ""}</span>
<button title="Delete">×</button>`;
item.querySelector("span").addEventListener("click", () => openTmplModal(t));
item.querySelector("button").addEventListener("click", () => {
cfg.templates = cfg.templates.filter(x => x.id !== t.id);
saveCfg(cfg);
refreshTemplateSelectors();
});
list.appendChild(item);
});
}
function openTmplModal(t) {
$("tmplOvrTitle").textContent = t ? "Edit Template" : "Add Template";
$("tmplName").value = t ? t.name : "";
$("tmplPrice").value = t ? t.price : "";
$("tmplCycle").value = t ? t.cycle : "yearly";
$("tmplDiv").value = t ? (t.divisor || "") : "";
$("tmplEditId").value = t ? t.id : "";
$("tmplOvr").classList.add("open");
setTimeout(() => $("tmplName").focus(), 50);
}
$("tmplAddBtn").addEventListener("click", () => openTmplModal(null));
$("tmplOvrClose").addEventListener("click", () => $("tmplOvr").classList.remove("open"));
$("tmplOvr").addEventListener("click", e => { if (e.target === $("tmplOvr")) $("tmplOvr").classList.remove("open"); });
$("tmplOvrSave").addEventListener("click", () => {
const name = $v("tmplName").trim();
const price = parseFloat($v("tmplPrice"));
const cycle = $v("tmplCycle");
const divisor = parseInt($v("tmplDiv")) || 0;
if (!name || !price) { $("tmplName").focus(); return; }
const editId = $v("tmplEditId");
if (editId) {
const t = cfg.templates.find(x => x.id === editId);
if (t) { t.name = name; t.price = price; t.cycle = cycle; t.divisor = divisor; }
} else {
cfg.templates.push({ id: Date.now().toString(36), name, price, cycle, divisor });
}
saveCfg(cfg);
refreshTemplateSelectors();
$("tmplOvr").classList.remove("open");
});
// Load template into a tab's fields
function applyTemplate(t, priceId, cycleId, divId) {
if (!t) return;
$(priceId).value = t.price;
$(cycleId).value = t.cycle;
if (divId && t.divisor) $(divId).value = t.divisor;
else if (divId) $(divId).value = "";
}
function buildSaveHandler(priceId, cycleId, divId) {
return () => {
const price = parseFloat($v(priceId));
const cycle = $v(cycleId);
const divisor = parseInt($v(divId)) || 0;
openTmplModal(null);
$("tmplPrice").value = price || "";
$("tmplCycle").value = cycle;
$("tmplDiv").value = divisor || "";
};
}
// Wire template selectors
const tmplSelMap = [
{ sel:"extTmplSel", p:"extPrice", c:"extCycle", d:"extDiv", fn: calcExt },
{ sel:"refTmplSel", p:"refPay", c:null, d:"refDiv", fn: calcRef },
{ sel:"plOldTmplSel", p:"plOldP", c:"plOldC", d:"plOldDiv", fn: calcPlan },
{ sel:"plNewTmplSel", p:"plNewP", c:"plNewC", d:"plNewDiv", fn: calcPlan },
{ sel:"audTmplSel", p:"audPay", c:null, d:"audDiv", fn: calcAudit},
];
tmplSelMap.forEach(({ sel, p, c, d, fn }) => {
$(sel).addEventListener("change", () => {
const id = $v(sel);
if (!id) return;
const t = cfg.templates.find(x => x.id === id);
if (t) applyTemplate(t, p, c, d);
$(sel).value = "";
fn();
});
});
$("extTmplSave").addEventListener("click", buildSaveHandler("extPrice","extCycle","extDiv"));
$("refTmplSave").addEventListener("click", buildSaveHandler("refPay","extCycle","refDiv"));
$("plOldTmplSave").addEventListener("click", buildSaveHandler("plOldP","plOldC","plOldDiv"));
$("plNewTmplSave").addEventListener("click", buildSaveHandler("plNewP","plNewC","plNewDiv"));
$("audTmplSave").addEventListener("click", buildSaveHandler("audPay","extCycle","audDiv"));
refreshTemplateSelectors();
// ═══════════════════════════════════════════════════════════════════════════
// History
// ═══════════════════════════════════════════════════════════════════════════
const HKEY = "hpc_hist";
const HIST_MAX = 20;
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HKEY)) || []; }
catch { return []; }
}
function saveHistory(h) { localStorage.setItem(HKEY, JSON.stringify(h)); }
function addToHistory(tab, label, inputs) {
const h = loadHistory();
h.unshift({
id: Date.now(),
ts: new Date().toLocaleString("en-US",{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}),
tab,
label,
ticketId: ctx.ticketId,
customerName: ctx.customerName,
inputs,
});
saveHistory(h.slice(0, HIST_MAX));
}
function captureInputs(ids) {
const map = {};
ids.forEach(id => {
const el = $(id);
if (!el) return;
map[id] = el.type === "checkbox" ? el.checked : el.value;
});
return map;
}
function restoreInputs(inputs) {
Object.entries(inputs).forEach(([id, val]) => {
const el = $(id);
if (!el) return;
if (el.type === "checkbox") el.checked = !!val;
else if (el.type === "radio") {
shadow.querySelectorAll(`input[name="${el.name}"]`).forEach(r => r.checked = (r.value === val));
}
else el.value = val;
});
}
function renderHistoryModal() {
const h = loadHistory();
const list = $("histList");
if (h.length === 0) {
list.innerHTML = '<div class="hpc-hist-empty">No calculations yet.</div>';
return;
}
list.innerHTML = "";
h.forEach(entry => {
const row = document.createElement("div");
row.className = "hpc-hist-entry";
const ctxLine = [entry.ticketId, entry.customerName].filter(Boolean).join(" · ");
row.innerHTML = `
<span class="hpc-hist-badge badge-${entry.tab}">${entry.tab}</span>
<div class="hpc-hist-info">
<div class="hpc-hist-ts">${entry.ts}</div>
${ctxLine ? `<div class="hpc-hist-ctx">${ctxLine}</div>` : ""}
<div class="hpc-hist-lbl">${entry.label}</div>
</div>
<button class="hpc-hist-restore">Restore</button>`;
row.querySelector(".hpc-hist-restore").addEventListener("click", () => {
// Switch to the right tab
shadow.querySelectorAll(".hpc-tab").forEach(t => t.classList.remove("active"));
shadow.querySelectorAll(".hpc-pane").forEach(p => p.classList.remove("active"));
const PANE_MAP2 = { ext:"paneExt",ref:"paneRef",plan:"panePlan",audit:"paneAudit",batch:"paneBatch",fx:"paneFx" };
shadow.querySelector(`[data-tab="${entry.tab}"]`).classList.add("active");
activeTab = entry.tab;
$(PANE_MAP2[entry.tab]).classList.add("active");
// Restore inputs
restoreInputs(entry.inputs);
// Recalc
if (entry.inputs._batCards) restoreBatchCards(entry.inputs._batCards);
recalcActive();
$("histOvr").classList.remove("open");
});
list.appendChild(row);
});
}
// Batch card restoration helper
function restoreBatchCards(cards) {
$("batList").innerHTML = "";
batIdx = 0;
cards.forEach(cd => {
addBatchCard();
const card = $("batList").lastElementChild;
card.querySelector(".bc-lbl").value = cd.label;
card.querySelector(".bc-s").value = cd.start;
card.querySelector(".bc-e").value = cd.end;
card.querySelector(".bc-pay").value = cd.pay;
card.querySelector(".bc-cr").value = cd.cr;
card.querySelector(".bc-div").value = cd.div;
});
}
// History button
$("btnHist").addEventListener("click", () => {
renderHistoryModal();
$("histOvr").classList.add("open");
});
$("histClose").addEventListener("click", () => $("histOvr").classList.remove("open"));
$("histOvr").addEventListener("click", e => { if (e.target === $("histOvr")) $("histOvr").classList.remove("open"); });
$("histClear").addEventListener("click", () => {
if (confirm("Clear all history?")) { saveHistory([]); renderHistoryModal(); }
});
// ═══════════════════════════════════════════════════════════════════════════
// Auto-save hooks — patch each calc fn to save history on success
// ═══════════════════════════════════════════════════════════════════════════
const EXT_IDS = ["extPrice","extCycle","extEnd","extTarget","extDiv","extCred"];
const REF_IDS = ["refStart","refEnd","refPay","refCred","refCut","refCutInc","refDiv","refFee","refRfFee","refPenT","refPenV"];
const PLAN_IDS = ["plOldP","plOldC","plOldS","plOldE","plChg","plNewP","plNewC","plOldDiv","plNewDiv"];
const AUD_IDS = ["audStart","audEnd","audPay","audCred","audCut","audCutInc","audDiv","audFee","audActual","audPenT","audPenV"];
const BAT_IDS = ["batCut","batCutInc"];
const FX_IDS = ["fxAmt","fxFrom","fxRate","fxTo"];
function histLabel(tab) {
const lc = state.lastCalc;
if (!lc) return "";
switch (tab) {
case "ext": return `Extend ${lc.daysAdded}d → ${C(lc.toCharge)}`;
case "ref": return `Refund: ${C(lc.finalGross)} gross${lc.totalGross!==lc.totalNet?" / "+C(lc.finalNet)+" net":""}`;
case "plan": return `Plan change: ${lc.net>=0?"due "+C(lc.net):"refund "+C(Math.abs(lc.net))}`;
case "audit": return `Audit: correct ${C(lc.refGross)}${lc.actual>0?", actual "+C(lc.actual):""}`;
case "batch": return `Batch ${lc.items.length} services: ${C(lc.totGross)} gross`;
case "fx": {
const amt=$f("fxAmt"),rate=$f("fxRate"),from=$v("fxFrom"),to=$v("fxTo");
return `${amt.toFixed(2)} ${from} = ${(amt*rate).toFixed(2)} ${to}`;
}
}
return "";
}
function maybeSaveHistory(tab) {
if (!state.lastCalc) return;
let ids, extra = {};
switch (tab) {
case "ext": ids = EXT_IDS; break;
case "ref": ids = REF_IDS; break;
case "plan": ids = PLAN_IDS; extra._plMode = shadow.querySelector('input[name="plMode"]:checked')?.value; break;
case "audit": ids = AUD_IDS; break;
case "batch":
ids = BAT_IDS;
extra._batCards = [...$("batList").querySelectorAll(".bc")].map(card => ({
label: card.querySelector(".bc-lbl").value,
start: card.querySelector(".bc-s").value,
end: card.querySelector(".bc-e").value,
pay: card.querySelector(".bc-pay").value,
cr: card.querySelector(".bc-cr").value,
div: card.querySelector(".bc-div").value,
}));
break;
case "fx": ids = FX_IDS; break;
default: return;
}
addToHistory(tab, histLabel(tab), { ...captureInputs(ids), ...extra });
}
// Debounced history save (only save after 2s of inactivity, not on every keystroke)
const histTimers = {};
function scheduleHistSave(tab) {
clearTimeout(histTimers[tab]);
histTimers[tab] = setTimeout(() => maybeSaveHistory(tab), 2000);
}
// Patch the calc functions to also trigger history save
const _calcExt = calcExt, _calcRef = calcRef, _calcPlan = calcPlan,
_calcAudit = calcAudit, _calcBatch = calcBatch, _calcFx = calcFx;
// Override by wrapping — use the existing references already bound to events
// Instead, hook into recalcActive which is called after every input
const _recalcActive = recalcActive;
// Re-assign the tab-specific wires to also schedule history
["extPrice","extCycle","extEnd","extTarget","extDiv","extCred"].forEach(id => {
const el = $(id); if (!el) return;
el.addEventListener("input", () => scheduleHistSave("ext"));
el.addEventListener("change", () => scheduleHistSave("ext"));
});
REF_IDS.forEach(id => { const el=$(id); if(!el)return; el.addEventListener("input",()=>scheduleHistSave("ref")); el.addEventListener("change",()=>scheduleHistSave("ref")); });
PLAN_IDS.forEach(id => { const el=$(id); if(!el)return; el.addEventListener("input",()=>scheduleHistSave("plan")); el.addEventListener("change",()=>scheduleHistSave("plan")); });
AUD_IDS.forEach(id => { const el=$(id); if(!el)return; el.addEventListener("input",()=>scheduleHistSave("audit")); el.addEventListener("change",()=>scheduleHistSave("audit")); });
BAT_IDS.forEach(id => { const el=$(id); if(!el)return; el.addEventListener("input",()=>scheduleHistSave("batch")); el.addEventListener("change",()=>scheduleHistSave("batch")); });
FX_IDS.forEach(id => { const el=$(id); if(!el)return; el.addEventListener("input",()=>scheduleHistSave("fx")); el.addEventListener("change",()=>scheduleHistSave("fx")); });
shadow.querySelectorAll('input[name="plMode"]').forEach(r => r.addEventListener("change",()=>scheduleHistSave("plan")));
// ═══════════════════════════════════════════════════════════════════════════
// Patch generateNote to use case context
// ═══════════════════════════════════════════════════════════════════════════
// generateNote already runs after state.lastCalc is set; patch the header lines
const origGenNote = generateNote;
// Shadow the function so ctx flows in:
/* generateNote is a plain function in scope — we monkey-patch by redefining the
reference used by the note buttons. Since data-note buttons call origGenNote
via the event listener already added, we update the listener instead. */
shadow.querySelectorAll("[data-note]").forEach(btn => {
// The original listener was added in the earlier block; add a second listener
// that patches the output before showing it.
btn.addEventListener("click", () => {
// Short-circuit: the first listener already set noteTxt.value.
// We just need to patch the Ref/Customer lines.
const ta = $("noteTxt");
if (!ta) return;
if (ctx.ticketId) ta.value = ta.value.replace(/Ref:\s+\[EDIT\]/, `Ref: ${ctx.ticketId}`);
if (ctx.customerName) ta.value = ta.value.replace(/Customer:\s+\[EDIT\]/, `Customer: ${ctx.customerName}`);
});
});
})();