Oregami
Repositories/oxedyne/daimond

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})();