crates/monetize-site-buy/assets/paid.js
// **Back from the vendor's checkout** (`/paid/{reference}`, `/cancel/{reference}`, A12, owner
// 2026-10-23; ONE copy for every product site, TASKS-4-v2 T16, A28 M11): gunnar-ui-web's
// PaidReturn and CancelledReturn panes and their FollowOrder (`pages/subscription.rs`,
// `purchase.rs`: the steps, `order_title` / `order_detail` by what the order does,
// `change_line`, `pay_area`, "Cancel order"), their words as Gunnar wrote them. It follows the
// order through the site's own buy doors (the shared monetize-site-buy relay):
// `GET /api/account/subscription/status?reference=` until it is over, and
// `POST /api/account/subscription/cancel` for "Cancel order" (A16), signed in as this tab's
// person (the site's cookie, or the bearer its login page keeps). The site's page draws the
// look and carries the elements this script fills (`monetize_site_buy::assets::PAID_IDS`).
const BUY = { product: '@@PRODUCT@@', bearerKey: '@@BEARER_KEY@@', again: '@@AGAIN@@', account: '@@ACCOUNT@@', login: '@@LOGIN@@' };
const SLOW_NOTICE = 'Usually within a minute. If Stripe\'s notice is slow, our check picks the payment up within 5 minutes — you can leave this page; the room is added either way.';
const CANCELLED_TITLE = 'Payment cancelled';
const CANCELLED_DETAIL = 'Nothing was charged. Your order is kept for a while: try again below, or cancel it.';
const STEPS = ['Choose', 'Pay at Stripe', 'Room added'];
const RESUME_LABEL = 'Resume payment';
const CREATING_LINK = 'Creating your payment link…';
const NO_LINK = 'The payment link could not be created. Nothing has been charged — cancel the order and choose again, or contact support if it happens again.';
// How many follow-ups a pending order may go without a link before the page says it failed.
const LINK_PATIENCE = 20;
const POLL_MS = 3000;
const $ = id => document.getElementById(id);
const headline = $('headline');
const detail = $('detail');
const progress = $('progress');
const foot = $('foot');
function bearer() {
try { return sessionStorage.getItem(BUY.bearerKey) || ''; } catch (e) { return ''; }
}
function auth() { const t = bearer(); return t ? { Authorization: 'Bearer ' + t } : {}; }
function say(head, text, cls) { headline.textContent = head; detail.textContent = text; detail.className = cls || ''; detail.hidden = !text; }
function link(href, words) { const a = document.createElement('a'); a.href = href; a.textContent = words; return a; }
// gunnar-ui-web `fmt::bytes`: one decimal from KiB up.
function bytes(n) {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let v = Number(n || 0), i = 0;
if (v < 1024) return v + ' B';
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(1) + ' ' + units[i];
}
// gunnar-ui-web `fmt::date`: the fact's day, YYYY-MM-DD (UTC).
function date(ms) { return new Date(Number(ms)).toISOString().slice(0, 10); }
// The reference: the rest of the path (monetize's may carry slashes), never empty segments.
const PATH = location.pathname;
const CANCELLED = PATH.startsWith('/cancel/');
const REFERENCE = (() => {
const rest = PATH.replace(/^\/(paid|cancel)\//, '').replace(/\/+$/, '');
return (rest && !rest.split('/').some(s => s === '')) ? decodeURIComponent(rest) : '';
})();
// gunnar-ui-web `Phase`, from the attempt's phase word and state.
function phaseOf(a) {
const p = a.phase || '';
if (p === 'waiting_for_payment') return 'waiting';
if (p === 'paid_adding') return 'adding';
if (p === 'done') return 'done';
if (p === 'cancelled' || p === 'declined' || p === 'refunded') return 'cancelled';
// gunnar-ui-web `phase`: monetize's ladder word, and whether the ledger holds a capture.
if (a.state === 'pending') return a.captured ? 'adding' : 'waiting';
if (a.state === 'paid') return 'done';
if (a.state === 'declined' || a.state === 'released' || a.state === 'released_with_orphans') return 'cancelled';
return 'unknown';
}
// `kind_of`: what the order does to the account, by monetize's verb.
function kindOf(action) {
return { prolong: 'prolong', renew: 'prolong', upgrade: 'upgrade', bump: 'bump' }[action || ''] || 'room';
}
// `steps(kind)`.
function stepsOf(kind) {
const last = { prolong: 'Prolonged', upgrade: 'Upgraded', bump: 'Raised' }[kind];
return last ? [STEPS[0], STEPS[1], last] : STEPS;
}
// `step_of`.
function stepOf(phase) { return { waiting: 1, adding: 2, done: 2 }[phase] || 0; }
// `now_holds`: the room held after a granted order, from the server's `after`.
function nowHolds(a) {
const after = a.after || {};
const k = Object.keys(after)[0];
return k === undefined ? undefined : after[k];
}
// `phase_title`.
function phaseTitle(phase, now) {
return {
waiting: 'Waiting for payment',
adding: 'Paid – adding room',
done: (now !== undefined) ? 'Done – you now have ' + bytes(now) + ' storage' : 'Done – the room is yours',
cancelled: 'Cancelled or expired',
unknown: 'We could not find this order',
}[phase];
}
// `phase_detail`.
function phaseDetail(phase) {
return {
waiting: 'Nothing has been charged yet. Pay with Stripe to continue, or cancel the order.',
adding: SLOW_NOTICE,
done: 'The extra room is available now.',
cancelled: 'Nothing was charged. You can choose again whenever you like.',
unknown: 'If you have just paid, give it a minute and reload this page.',
}[phase];
}
function periodWords(period) { return { year: '12 months', month: '1 month' }[period]; }
// `order_title`.
function orderTitle(phase, kind, now, until, period) {
const d = until ? date(until) : undefined;
if (kind === 'prolong' && phase === 'adding') return 'Paid – prolonging';
if (phase === 'done' && kind === 'prolong') return d ? 'Prolonged until ' + d : 'Prolonged';
if (phase === 'done' && kind === 'upgrade') {
const w = periodWords(period);
if (w && d) return 'Upgraded to ' + w + ' until ' + d;
if (w) return 'Upgraded to ' + w;
return d ? 'Upgraded until ' + d : 'Upgraded';
}
if (phase === 'done' && kind === 'bump') return now !== undefined ? 'Raised to ' + bytes(now) + ' storage' : 'Raised';
return phaseTitle(phase, now);
}
// `order_detail`.
function orderDetail(phase, kind, now, until) {
if (phase === 'done' && kind === 'prolong') return '';
if (phase === 'done' && kind === 'upgrade') return now !== undefined ? 'You now have ' + bytes(now) + ' storage.' : '';
if (phase === 'done' && kind === 'bump') return until ? 'Your plan runs until ' + date(until) + ', as before.' : '';
return phaseDetail(phase);
}
// `change_line`: "You have X storage → Y after payment", only when both halves are sent.
function changeLine(a) {
const after = a.after || {}, before = a.before || {};
const k = Object.keys(after)[0];
if (k === undefined) return '';
return 'You have ' + bytes(before[k] || 0) + ' storage → ' + bytes(after[k]) + ' after payment';
}
// `pay_url`: a link the browser may be sent to pay at: `https://`, a host, no credentials,
// nothing but printable ASCII (the server's own rule, kept here too so the page never trusts a
// link it was handed: no `javascript:`, no plain http, no `//host`).
function payUrl(url) {
if (typeof url !== 'string' || url.length > 4096 || !/^[\x21-\x7e]*$/.test(url)) return false;
if (!url.startsWith('https://')) return false;
const authority = url.slice('https://'.length).split(/[\/?#]/)[0];
const colon = authority.lastIndexOf(':');
const host = colon < 0 ? authority : authority.slice(0, colon);
return host !== '' && !authority.includes('@');
}
// `pay_area`: never an order waiting for payment with no way to pay.
function payArea(phase, url, asks) {
if (phase !== 'waiting') return 'nothing';
if (payUrl(url)) return 'button';
if (typeof url === 'string' && url.trim() !== '') return 'failed';
return asks < LINK_PATIENCE ? 'creating' : 'failed';
}
let asks = 0;
let cancelling = false;
let over = false;
function steps(kind, phase) {
const ol = $('steps');
ol.textContent = '';
const at = stepOf(phase);
stepsOf(kind).forEach((name, i) => {
const li = document.createElement('li');
li.textContent = (i + 1) + '. ' + name;
if (i < at || (i === at && phase === 'done')) li.className = 'done';
else if (i === at) li.className = 'current';
ol.append(li);
});
}
// `FollowOrder`: one attempt drawn.
function draw(a) {
const phase = phaseOf(a);
const kind = kindOf(a.action);
const now = nowHolds(a);
steps(kind, phase);
const cls = phase === 'done' ? 'ok' : '';
say(orderTitle(phase, kind, now, a.paid_until_unix_ms, a.period), orderDetail(phase, kind, now, a.paid_until_unix_ms), cls);
const change = (phase === 'waiting' || phase === 'adding') ? changeLine(a) : '';
$('change').textContent = change;
$('change').hidden = !change;
const room = (kind !== 'prolong' && a.room && a.room.words) || '';
$('room').textContent = room;
$('room').hidden = !room;
const pay = $('pay');
pay.textContent = '';
const area = payArea(phase, a.url, asks);
if (area === 'button') {
const b = document.createElement('button');
b.type = 'button'; b.className = 'primary'; b.textContent = RESUME_LABEL;
b.onclick = () => location.assign(a.url);
pay.append(b);
} else if (area === 'creating') {
const p = document.createElement('p'); p.className = 'muted'; p.textContent = CREATING_LINK; pay.append(p);
} else if (area === 'failed') {
const p = document.createElement('p'); p.className = 'bad'; p.textContent = NO_LINK; pay.append(p);
}
if (a.cancellable) {
const c = document.createElement('button');
c.type = 'button'; c.textContent = 'Cancel order'; c.disabled = cancelling;
c.onclick = () => cancel(a.reference || REFERENCE);
pay.append(c);
}
pay.hidden = !pay.childElementCount;
over = phase === 'done' || phase === 'cancelled' || phase === 'unknown';
progress.hidden = over;
}
function back(tryAgain) {
foot.textContent = '';
if (tryAgain) foot.append(link(BUY.again, 'Try again'), ' ');
foot.append(link(BUY.account, 'Back to your account'));
foot.hidden = false;
}
// The customer's own "Cancel order" (gunnar-ui-web FollowOrder `cancel`): monetize decides.
async function cancel(reference) {
cancelling = true;
$('fault').hidden = true;
let res, a = null;
try {
res = await fetch('/api/account/subscription/cancel', { method: 'POST', headers: Object.assign({ 'content-type': 'application/json' }, auth()), body: JSON.stringify({ reference }) });
a = await res.json().catch(() => null);
} catch (e) {
res = null;
}
cancelling = false;
if (res && res.ok && a) {
draw(a);
return;
}
$('fault').textContent = (a && a.error) || BUY.product + ' is not answering. Try again in a moment.';
$('fault').hidden = false;
const c = [...$('pay').querySelectorAll('button')].find(b => b.textContent === 'Cancel order');
if (c) c.disabled = false;
}
async function tick(first) {
let res, a = null;
try {
res = await fetch('/api/account/subscription/status?reference=' + encodeURIComponent(REFERENCE), { headers: auth() });
a = await res.json().catch(() => null);
} catch (e) {
$('fault').textContent = BUY.product + ' is not answering. Try again in a moment.';
$('fault').hidden = false;
return setTimeout(() => tick(first), POLL_MS);
}
if (res.status === 401) {
progress.hidden = true;
say('Sign in to follow this order', 'This order is your account\'s: sign in, and come back to this address.', 'err');
foot.textContent = '';
foot.append(link(BUY.login + '?next=' + encodeURIComponent(location.pathname), 'Sign in at ' + location.hostname));
foot.hidden = false;
return;
}
if (!res.ok || !a) {
if (first && CANCELLED) {
// An order this reader cannot see still gets the sentence and a way back to buy again.
progress.hidden = true;
$('steps').hidden = true;
headline.hidden = true;
detail.hidden = true;
return back(true);
}
$('fault').textContent = (a && a.error) || BUY.product + ' is not answering yet. Try again in a moment.';
$('fault').hidden = false;
return setTimeout(() => tick(first), POLL_MS);
}
$('fault').hidden = true;
asks += 1;
if (!cancelling) draw(a);
back(false);
if (!over) setTimeout(() => tick(false), POLL_MS);
}
if (!REFERENCE) {
progress.hidden = true;
say('We could not find this order', 'If you have just paid, give it a minute and reload this page.', 'err');
back(true);
} else {
if (CANCELLED) $('cancelled').hidden = false;
tick(true);
}