oxedyne/daimond/www/js/autoreload.js
14.0 KiB, 1 run
created by r2519314175:1343, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* autoreload.js — the standing instruction to buy your own credits. |
| 2 | * |
| 3 | * Daimond will charge a saved card while its owner is asleep. That is a serious thing to be |
| 4 | * allowed to do, and this panel is the only place it is authorised. Three numbers say what it |
| 5 | * may do, and the third is the one that matters: |
| 6 | * |
| 7 | * below WHEN the balance at which a reload fires |
| 8 | * buy HOW MUCH one top-up |
| 9 | * never more than this, in a calendar month -- a hard cap, not a target |
| 10 | * |
| 11 | * The cap is enforced at the gateway, on the same code path as the charge, so it cannot be |
| 12 | * walked around by a client that lies. It is here because the user must be able to state it, |
| 13 | * not because the browser enforces anything. |
| 14 | * |
| 15 | * Two things this panel refuses to do: |
| 16 | * |
| 17 | * - It will not let auto-reload be turned on with no card. The gateway refuses that too (422), |
| 18 | * but a control that can be switched on and then silently fails is a worse lie than one that |
| 19 | * is disabled and says why. |
| 20 | * - It does not touch a card. Saving a card is a redirect to Stripe's own hosted page; no |
| 21 | * card number, expiry or CVC is ever typed into Daimond, so there is nothing here to leak. |
| 22 | */ |
| 23 | (function () { |
| 24 | 'use strict'; |
| 25 | |
| 26 | var G = null; // DaimondGateway, once it exists |
| 27 | var cur = null; // the last settings read back from the gateway |
| 28 | var busy = false; |
| 29 | |
| 30 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 31 | /// Whether figures are being shown in the currency they are billed in. |
| 32 | function usdDisplay() { return !window.DaimondI18n || DaimondI18n.currency() === 'USD'; } |
| 33 | |
| 34 | function money(minor, ccy) { |
| 35 | if (window.DaimondGateway && DaimondGateway.fmtMoney) return DaimondGateway.fmtMoney(minor, ccy); |
| 36 | return ((minor || 0) / 100).toFixed(2); |
| 37 | } |
| 38 | |
| 39 | /// A standing instruction to charge a card is the sharpest end of the |
| 40 | /// billing question, so every amount here is a US dollar amount and the |
| 41 | /// panel says so when the user is reading in something else. |
| 42 | function billed(minor, ccy) { |
| 43 | if (window.DaimondGateway && DaimondGateway.fmtBilled) return DaimondGateway.fmtBilled(minor, ccy); |
| 44 | return money(minor, ccy); |
| 45 | } |
| 46 | |
| 47 | function el(tag, cls, text) { |
| 48 | var e = document.createElement(tag); |
| 49 | if (cls) e.className = cls; |
| 50 | if (text != null) e.textContent = text; |
| 51 | return e; |
| 52 | } |
| 53 | |
| 54 | /// Read the panel back into the shape the gateway takes. Amounts are typed in whole units and |
| 55 | /// held in minor ones, because a price is not a float. |
| 56 | function readForm() { |
| 57 | function minor(id) { |
| 58 | var v = parseFloat((document.getElementById(id) || {}).value); |
| 59 | return isFinite(v) && v > 0 ? Math.round(v * 100) : 0; |
| 60 | } |
| 61 | return { |
| 62 | enabled: !!(document.getElementById('ar-on') || {}).checked, |
| 63 | threshold_minor: minor('ar-threshold'), |
| 64 | topup_minor: minor('ar-topup'), |
| 65 | monthly_budget_minor: minor('ar-budget'), |
| 66 | }; |
| 67 | } |
| 68 | |
| 69 | /// The panel's one message line: a confirmation, or the gateway's own refusal, said as it came. |
| 70 | /// |
| 71 | /// THE TRAP, and it held for a month. The element is made in `render()` by `el('div', 'ar-note', |
| 72 | /// '')`, and `el`'s SECOND ARGUMENT IS A CLASS NAME. So `ar-note` was a class and never an id, |
| 73 | /// this lookup returned null every single time, and `if (!n) return` read exactly like a correct |
| 74 | /// guard -- four call sites about money went quiet and nothing said so. The id is now set as its |
| 75 | /// own statement where the div is built, the way `ar-save` already was eight lines further down. |
| 76 | /// Do not fold it back into the `el()` call. |
| 77 | /// |
| 78 | /// An element that does not exist also reports itself to a browser automation locator as HIDDEN, |
| 79 | /// which is what a working guard looks like, so no check that asserted "no error is shown" could |
| 80 | /// ever have caught this. Every check over this line asserts the element EXISTS and says what. |
| 81 | function note(msg, bad) { |
| 82 | var n = document.getElementById('ar-note'); |
| 83 | if (!n) return; |
| 84 | n.textContent = msg || ''; |
| 85 | n.classList.toggle('bad', !!bad); |
| 86 | } |
| 87 | |
| 88 | async function render() { |
| 89 | var host = document.getElementById('autoreload'); |
| 90 | if (!host) return; |
| 91 | G = window.DaimondGateway; |
| 92 | if (!G || !G.state().authed) { host.innerHTML = ''; return; } |
| 93 | |
| 94 | var s = await G.autoReload(); |
| 95 | // A read that came back with nothing leaves what is on screen ALONE. |
| 96 | // |
| 97 | // It used to blank the host, so one refused or dropped request -- a renewal in |
| 98 | // flight, a gateway restarting -- took the auto-reload controls off the page |
| 99 | // entirely and nothing put them back. An empty space says the feature does not |
| 100 | // exist; the settings already drawn are the last thing the gateway actually |
| 101 | // said, which is nearer the truth. With nothing drawn, nothing appears, which |
| 102 | // is the original intent: no gateway, and the panel is not the place to complain. |
| 103 | if (!s) return; |
| 104 | cur = s; |
| 105 | host.innerHTML = ''; |
| 106 | |
| 107 | host.appendChild(el('div', 'ar-title', t('autoreload.title'))); |
| 108 | host.appendChild(el('p', 'cfg-lead', t('autoreload.lead'))); |
| 109 | |
| 110 | // ── The card ──────────────────────────────────────────────── |
| 111 | var cardRow = el('div', 'ar-card'); |
| 112 | var card = s.card || {}; |
| 113 | if (card.saved) { |
| 114 | cardRow.appendChild(el('span', 'ar-card-has', |
| 115 | '💳 ' + t('autoreload.card_has', { brand: card.brand || t('autoreload.card_word'), last4: card.last4 || '••••' }))); |
| 116 | var replace = el('button', 'ar-card-btn', t('autoreload.replace')); |
| 117 | replace.title = t('autoreload.replace_help'); |
| 118 | replace.addEventListener('click', startCard); |
| 119 | cardRow.appendChild(replace); |
| 120 | } else { |
| 121 | cardRow.appendChild(el('span', 'ar-card-none', t('autoreload.no_card'))); |
| 122 | // `addCard`, and NOT `save`. `var` is function-scoped, so a local `save` here |
| 123 | // shadowed the module's `save()` for the WHOLE of `render()` -- including |
| 124 | // `saveBtn.addEventListener('click', save)` sixty lines below, which was then handed |
| 125 | // a DOM element (or, on the card-saved branch, `undefined`) instead of the handler. |
| 126 | // Neither is an error to `addEventListener`, so the panel's Save button silently did |
| 127 | // NOTHING, on both branches, and no console message said so. Do not rename it back. |
| 128 | var addCard = el('button', 'ar-card-btn accent', t('autoreload.save_card')); |
| 129 | addCard.title = t('autoreload.save_card_help'); |
| 130 | addCard.addEventListener('click', startCard); |
| 131 | cardRow.appendChild(addCard); |
| 132 | } |
| 133 | host.appendChild(cardRow); |
| 134 | |
| 135 | // ── The switch ────────────────────────────────────────────── |
| 136 | // |
| 137 | // Disabled without a card, and it says which. A switch that can be flipped and then does |
| 138 | // nothing teaches the user that the app lies. |
| 139 | var onRow = el('label', 'ar-switch'); |
| 140 | var box = document.createElement('input'); |
| 141 | box.type = 'checkbox'; |
| 142 | box.id = 'ar-on'; |
| 143 | box.checked = !!s.enabled; |
| 144 | box.disabled = !card.saved; |
| 145 | box.addEventListener('change', function () { paintFields(); }); |
| 146 | onRow.appendChild(box); |
| 147 | onRow.appendChild(el('span', null, card.saved |
| 148 | ? t('autoreload.switch_on') |
| 149 | : t('autoreload.switch_no_card'))); |
| 150 | host.appendChild(onRow); |
| 151 | |
| 152 | // ── The three numbers ─────────────────────────────────────── |
| 153 | var ccy = s.currency || 'usd'; |
| 154 | host.appendChild(field('ar-threshold', t('autoreload.when_below'), |
| 155 | s.threshold_minor, ccy, t('autoreload.when_below_hint'))); |
| 156 | host.appendChild(field('ar-topup', t('autoreload.buy_amount'), |
| 157 | s.topup_minor, ccy, t('autoreload.buy_amount_hint', { max: billed(20000, ccy) }))); |
| 158 | host.appendChild(field('ar-budget', t('autoreload.monthly_cap'), |
| 159 | s.monthly_budget_minor, ccy, t('autoreload.monthly_cap_hint'))); |
| 160 | |
| 161 | // What it has spent against that ceiling, this month. The number that answers "is this |
| 162 | // thing running away with my money", which is the only question that matters. |
| 163 | var spent = el('div', 'ar-spent'); |
| 164 | if (s.monthly_budget_minor > 0) { |
| 165 | var pct = Math.min(100, Math.round(100 * (s.spent_this_month_minor || 0) / s.monthly_budget_minor)); |
| 166 | var bar = el('div', 'ar-bar'); |
| 167 | var fill = el('div', 'ar-bar-fill'); |
| 168 | fill.style.width = pct + '%'; |
| 169 | bar.appendChild(fill); |
| 170 | spent.appendChild(bar); |
| 171 | } |
| 172 | spent.appendChild(el('span', 'ar-spent-txt', |
| 173 | s.monthly_budget_minor > 0 |
| 174 | ? t('autoreload.spent_of', { |
| 175 | spent: money(s.spent_this_month_minor || 0, ccy), |
| 176 | cap: money(s.monthly_budget_minor, ccy) }) |
| 177 | : t('autoreload.spent', { spent: money(s.spent_this_month_minor || 0, ccy) }))); |
| 178 | host.appendChild(spent); |
| 179 | |
| 180 | // The gateway's last complaint, said plainly. A card that has expired or been declined is |
| 181 | // something the user must be told BEFORE the balance runs out, not after. |
| 182 | if (s.last_error) { |
| 183 | var err = el('div', 'ar-last-error'); |
| 184 | err.appendChild(el('span', null, '⚠ ' + t('autoreload.last_error', { reason: s.last_error }))); |
| 185 | host.appendChild(err); |
| 186 | } |
| 187 | |
| 188 | // The amounts above are charged, not merely shown, so a user reading in |
| 189 | // another currency is told which currency the card will see. |
| 190 | if (!usdDisplay()) host.appendChild(el('p', 'ar-hint', t('billing.usd_note'))); |
| 191 | |
| 192 | var actions = el('div', 'ar-actions'); |
| 193 | var saveBtn = el('button', 'ar-save accent', t('common.save')); |
| 194 | saveBtn.id = 'ar-save'; |
| 195 | // `save` MUST resolve to the module's handler. See the `addCard` comment above: a |
| 196 | // function-scoped `var save` anywhere in this function binds this listener to the wrong |
| 197 | // thing, and `addEventListener` accepts the wrong thing without a word. |
| 198 | saveBtn.addEventListener('click', save); |
| 199 | actions.appendChild(saveBtn); |
| 200 | host.appendChild(actions); |
| 201 | |
| 202 | // The message line. The id is set as its own statement because `el`'s second argument is a |
| 203 | // CLASS NAME, and passing 'ar-note' there is what silenced `note()` for a month -- see the |
| 204 | // comment on `note()` before changing this back. |
| 205 | // |
| 206 | // Announced as well as drawn: a refusal about a standing instruction to charge a card is |
| 207 | // not something to leave for the user to notice in the corner they were not looking at. |
| 208 | var noteEl = el('div', 'ar-note', ''); |
| 209 | noteEl.id = 'ar-note'; |
| 210 | noteEl.setAttribute('role', 'status'); |
| 211 | noteEl.setAttribute('aria-live', 'polite'); |
| 212 | host.appendChild(noteEl); |
| 213 | |
| 214 | paintFields(); |
| 215 | } |
| 216 | |
| 217 | /// One labelled amount, in whole units. |
| 218 | function field(id, label, minorVal, ccy, hint) { |
| 219 | var wrap = el('div', 'ar-field'); |
| 220 | var lab = el('label', 'ar-label', label); |
| 221 | lab.setAttribute('for', id); |
| 222 | var row = el('div', 'ar-input-row'); |
| 223 | // The unit the number is typed in, which is the unit it is billed in. |
| 224 | // It only spells out "US" when that is not what the user is reading in. |
| 225 | var unit = (ccy || 'usd').toUpperCase(); |
| 226 | row.appendChild(el('span', 'ar-ccy', unit === 'GBP' ? '£' : (usdDisplay() ? '$' : 'US$'))); |
| 227 | var inp = document.createElement('input'); |
| 228 | inp.type = 'number'; |
| 229 | inp.id = id; |
| 230 | inp.min = '0'; |
| 231 | inp.step = '1'; |
| 232 | inp.className = 'ar-input'; |
| 233 | inp.value = minorVal > 0 ? String(minorVal / 100) : ''; |
| 234 | row.appendChild(inp); |
| 235 | wrap.appendChild(lab); |
| 236 | wrap.appendChild(row); |
| 237 | if (hint) wrap.appendChild(el('div', 'ar-hint', hint)); |
| 238 | return wrap; |
| 239 | } |
| 240 | |
| 241 | /// Grey the numbers out when the thing they configure is off, so the panel says at a glance |
| 242 | /// whether it is doing anything. |
| 243 | function paintFields() { |
| 244 | var on = !!(document.getElementById('ar-on') || {}).checked; |
| 245 | var host = document.getElementById('autoreload'); |
| 246 | if (host) host.classList.toggle('ar-off', !on); |
| 247 | } |
| 248 | |
| 249 | async function startCard() { |
| 250 | try { await DaimondGateway.saveCard(); } |
| 251 | catch (e) { note(e && e.message ? e.message : String(e), true); } |
| 252 | } |
| 253 | |
| 254 | async function save() { |
| 255 | if (busy) return; |
| 256 | busy = true; |
| 257 | var btn = document.getElementById('ar-save'); |
| 258 | if (btn) { btn.disabled = true; btn.textContent = t('autoreload.saving'); } |
| 259 | note(''); |
| 260 | try { |
| 261 | var next = await DaimondGateway.setAutoReload(readForm()); |
| 262 | cur = next; |
| 263 | // AFTER the redraw, and this order is the whole fix. `render()` blanks the host and |
| 264 | // re-appends an EMPTY note line, so a confirmation written before it was destroyed in |
| 265 | // the same tick -- a second bug sitting on top of the id one, and fixing either alone |
| 266 | // proves nothing. |
| 267 | // |
| 268 | // Written after rather than carried through the redraw because this sentence answers an |
| 269 | // ACTION. A redraw for any other reason -- Credits opened again, a change of language, |
| 270 | // the card poll after a Stripe return -- should show what the gateway says NOW, not a |
| 271 | // sentence about something the user did earlier. The refusal below needs no such care: |
| 272 | // nothing redraws the panel on a save that failed, so it stays until the next action. |
| 273 | await render(); |
| 274 | note(next.enabled ? t('autoreload.on_note') : t('autoreload.off_note')); |
| 275 | } catch (e) { |
| 276 | // The gateway's refusals are written for a person to read -- "the monthly budget is |
| 277 | // smaller than one top-up, so auto-reload could never buy anything" -- so they are |
| 278 | // shown as they came, not replaced with a generic failure. |
| 279 | note(e && e.message ? e.message : String(e), true); |
| 280 | } finally { |
| 281 | busy = false; |
| 282 | var b = document.getElementById('ar-save'); |
| 283 | if (b) { b.disabled = false; b.textContent = t('common.save'); } |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // ── A session that lands after Credits was opened fills the panel in ── |
| 288 | // |
| 289 | // `render` draws nothing without a gateway session, and that session is taken |
| 290 | // asynchronously at boot: an account POST, a challenge, a signature and a verify, |
| 291 | // then a balance. A user who reached Credits before all that landed was shown an |
| 292 | // empty space where the standing instruction to spend their money should be, and |
| 293 | // nothing redrew it for the life of the page. The panel was not late, it was |
| 294 | // absent, and the only way back was a reload. |
| 295 | // |
| 296 | // `daimond:authed` is raised at the moment there is a session -- a first unlock, |
| 297 | // or a renewal that came good -- so it is the moment the missing panel can be |
| 298 | // drawn. Only when the host is EMPTY, so an ordinary renewal over a panel already |
| 299 | // on screen costs no request. |
| 300 | window.addEventListener('daimond:authed', function () { |
| 301 | var host = document.getElementById('autoreload'); |
| 302 | if (host && !host.firstChild) render(); |
| 303 | }); |
| 304 | |
| 305 | window.DaimondAutoReload = { |
| 306 | render: render, |
| 307 | settings: function () { return cur; }, |
| 308 | }; |
| 309 | })(); |