@firedrill-tools/stripe 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +433 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/api-unavailable.scenario.json +11 -0
  5. package/firedrill/baseline.scenario.json +5 -0
  6. package/firedrill/conformance.suite.json +17 -0
  7. package/firedrill/rate-limited.scenario.json +11 -0
  8. package/firedrill/refund-committed-lost.scenario.json +11 -0
  9. package/firedrill/stripe-api-unavailable.drill.json +463 -0
  10. package/firedrill/stripe-denied.drill.json +74 -0
  11. package/firedrill/stripe-large-pages.drill.json +153 -0
  12. package/firedrill/stripe-live-mode.drill.json +136 -0
  13. package/firedrill/stripe-mcp-aliases.drill.json +406 -0
  14. package/firedrill/stripe-no-permissions.drill.json +1143 -0
  15. package/firedrill/stripe-rate-limited.drill.json +616 -0
  16. package/firedrill/stripe-refund-committed-lost.drill.json +139 -0
  17. package/firedrill/stripe-rest-flow.drill.json +1401 -0
  18. package/firedrill/stripe-restricted-key.drill.json +171 -0
  19. package/firedrill/tools/stripe/app/assets/ATTRIBUTION.md +36 -0
  20. package/firedrill/tools/stripe/app/assets/fonts/OFL.txt +93 -0
  21. package/firedrill/tools/stripe/app/assets/stripe-s.svg +1 -0
  22. package/firedrill/tools/stripe/app/assets/stripe.svg +1 -0
  23. package/firedrill/tools/stripe/app/site/app.js +456 -0
  24. package/firedrill/tools/stripe/app/site/assets/fonts/inter-latin.woff2 +0 -0
  25. package/firedrill/tools/stripe/app/site/assets/stripe-s.svg +1 -0
  26. package/firedrill/tools/stripe/app/site/assets/stripe.svg +1 -0
  27. package/firedrill/tools/stripe/app/site/icons.js +90 -0
  28. package/firedrill/tools/stripe/app/site/index.html +137 -0
  29. package/firedrill/tools/stripe/app/site/pages-billing.js +902 -0
  30. package/firedrill/tools/stripe/app/site/pages-catalog.js +314 -0
  31. package/firedrill/tools/stripe/app/site/pages-customers.js +416 -0
  32. package/firedrill/tools/stripe/app/site/pages-home.js +373 -0
  33. package/firedrill/tools/stripe/app/site/pages-payments.js +502 -0
  34. package/firedrill/tools/stripe/app/site/store.js +99 -0
  35. package/firedrill/tools/stripe/app/site/styles.css +2512 -0
  36. package/firedrill/tools/stripe/app/site/ui.js +767 -0
  37. package/firedrill/tools/stripe/app/site/widgets.js +707 -0
  38. package/firedrill/tools/stripe/behavior.mjs +148 -0
  39. package/firedrill/tools/stripe/lib/cards.mjs +53 -0
  40. package/firedrill/tools/stripe/lib/form.mjs +204 -0
  41. package/firedrill/tools/stripe/lib/ids.mjs +85 -0
  42. package/firedrill/tools/stripe/lib/money.mjs +35 -0
  43. package/firedrill/tools/stripe/lib/objects.mjs +229 -0
  44. package/firedrill/tools/stripe/lib/periods.mjs +41 -0
  45. package/firedrill/tools/stripe/lib/size.mjs +55 -0
  46. package/firedrill/tools/stripe/lib/state.mjs +230 -0
  47. package/firedrill/tools/stripe/lib/validate.mjs +184 -0
  48. package/firedrill/tools/stripe/lib/wire.mjs +98 -0
  49. package/firedrill/tools/stripe/ops/billing.mjs +914 -0
  50. package/firedrill/tools/stripe/ops/catalog.mjs +203 -0
  51. package/firedrill/tools/stripe/ops/customers.mjs +241 -0
  52. package/firedrill/tools/stripe/ops/dashboard.mjs +29 -0
  53. package/firedrill/tools/stripe/ops/payments.mjs +608 -0
  54. package/firedrill/tools/stripe/stripe.tool.json +28833 -0
  55. package/firedrill/world.json +8527 -0
  56. package/firedrill.json +5 -0
  57. package/package.json +64 -0
  58. package/starter.json +7999 -0
  59. package/test/conformance.mjs +1133 -0
@@ -0,0 +1,767 @@
1
+ // DOM, Firedrill-client, formatting and interaction helpers for the Stripe Tool app. Every piece of record text
2
+ // is rendered through textContent; nothing here keeps authoritative data.
3
+ import { getContext, invoke } from "/_firedrill/client.js";
4
+ import { icon } from "./icons.js";
5
+
6
+ export const $ = (selector, root = document) => root.querySelector(selector);
7
+ export const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
8
+
9
+ /** Create an element. `options`: { class, text, title, attrs: {…}, on: {event: handler} }. */
10
+ export function el(tag, options = {}, children = []) {
11
+ const element = document.createElement(tag);
12
+ if (options.class) element.className = options.class;
13
+ if (options.text !== undefined) element.textContent = options.text;
14
+ if (options.title !== undefined) element.title = options.title;
15
+ if (options.attrs) for (const [name, value] of Object.entries(options.attrs)) if (value !== undefined && value !== null && value !== false) element.setAttribute(name, value === true ? "" : String(value));
16
+ if (options.on) for (const [event, handler] of Object.entries(options.on)) element.addEventListener(event, handler);
17
+ for (const child of Array.isArray(children) ? children : [children]) if (child !== undefined && child !== null && child !== false) element.append(child);
18
+ return element;
19
+ }
20
+
21
+ export const text = (value) => document.createTextNode(String(value));
22
+
23
+ /** Internal link (hash route). */
24
+ export function link(href, label, options = {}) {
25
+ return el("a", { class: `link ${options.class ?? ""}`.trim(), text: label, attrs: { href }, title: options.title });
26
+ }
27
+
28
+ /**
29
+ * Stripe-style button. `kind`: primary | secondary | danger | ghost | link. `options`: { icon, iconAfter, size, class, title, disabled, onClick }.
30
+ */
31
+ export function button(label, kind = "secondary", options = {}) {
32
+ const element = el("button", { class: `btn btn-${kind} ${options.size ? `btn-${options.size}` : ""} ${options.class ?? ""}`.trim(), title: options.title, attrs: { type: options.type ?? "button", "aria-label": options.ariaLabel } });
33
+ if (options.icon) element.append(icon(options.icon, "btn-icon"));
34
+ if (label !== undefined && label !== null && label !== "") element.append(el("span", { class: "btn-label", text: label }));
35
+ if (options.iconAfter) element.append(icon(options.iconAfter, "btn-icon btn-icon-after"));
36
+ if (options.disabled) element.disabled = true;
37
+ if (options.onClick) element.addEventListener("click", options.onClick);
38
+ return element;
39
+ }
40
+
41
+ export function iconButton(name, label, options = {}) {
42
+ return button(undefined, options.kind ?? "ghost", { ...options, icon: name, ariaLabel: label, title: options.title ?? label, class: `btn-icon-only ${options.class ?? ""}`.trim() });
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------------------------
46
+ // Tool calls
47
+ // ---------------------------------------------------------------------------------------------
48
+
49
+ export class ToolError extends Error {
50
+ constructor(status, code, message, details) {
51
+ super(message);
52
+ this.status = status;
53
+ this.code = code;
54
+ this.details = details ?? {};
55
+ }
56
+ get denied() {
57
+ return this.status === "denied";
58
+ }
59
+ is(code) {
60
+ return this.code === `tool.${code}` || this.code === code;
61
+ }
62
+ /** Stripe's `error.code` (`resource_missing`, `card_declined`, …) when the Tool provided one. */
63
+ get stripeCode() {
64
+ return typeof this.details.code === "string" ? this.details.code : undefined;
65
+ }
66
+ get param() {
67
+ return typeof this.details.param === "string" ? this.details.param : undefined;
68
+ }
69
+ }
70
+
71
+ /** Invoke one Tool operation; throws ToolError for every non-ok outcome. */
72
+ export async function call(operationId, args = {}, idempotencyKey) {
73
+ const result = await invoke(operationId, args, idempotencyKey ? { idempotencyKey } : {});
74
+ if (result.outcome.status !== "ok") {
75
+ const error = result.outcome.error ?? {};
76
+ throw new ToolError(result.outcome.status, error.code ?? result.outcome.status, error.message ?? `The operation was ${result.outcome.status}.`, error.details);
77
+ }
78
+ return result.outcome.value;
79
+ }
80
+
81
+ export const key = () => crypto.randomUUID();
82
+
83
+ export function describe(error) {
84
+ if (error instanceof ToolError) {
85
+ if (error.denied) return "Your key is not granted this operation in this Firedrill world.";
86
+ if (error.is("PERMISSION_DENIED")) return error.message;
87
+ if (error.is("API_ERROR")) return "Stripe is temporarily unavailable (503). Nothing was changed; try again in a moment.";
88
+ if (error.is("RATE_LIMITED")) return "Too many requests (429). Wait a moment and try again.";
89
+ if (error.status === "invalid") return `Invalid request: ${error.message}`;
90
+ return error.message;
91
+ }
92
+ return error?.message ?? "Something went wrong. Refresh and try again.";
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------------------------
96
+ // One user action at a time
97
+ // ---------------------------------------------------------------------------------------------
98
+
99
+ let pending = 0;
100
+ const busyListeners = new Set();
101
+ export const isPending = () => pending > 0;
102
+ export function onBusy(listener) {
103
+ busyListeners.add(listener);
104
+ }
105
+ function setBusy(delta) {
106
+ pending += delta;
107
+ document.documentElement.toggleAttribute("data-busy", pending > 0);
108
+ for (const listener of busyListeners) listener(pending > 0);
109
+ }
110
+
111
+ /** Run a user action; a second action is ignored while one is in flight so a write cannot be submitted twice. */
112
+ export async function action(task, { exclusive = true, onError } = {}) {
113
+ if (exclusive && pending > 0) return undefined;
114
+ setBusy(1);
115
+ try {
116
+ return await task();
117
+ } catch (error) {
118
+ if (onError) onError(error);
119
+ else toast(describe(error), { error: true });
120
+ return undefined;
121
+ } finally {
122
+ setBusy(-1);
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------------------------
127
+ // Toasts (bottom-centre, dark, the Dashboard's notification style)
128
+ // ---------------------------------------------------------------------------------------------
129
+
130
+ let toastTimer;
131
+ export function toast(message, { error = false, timeout = 5000, actionLabel, onAction } = {}) {
132
+ const host = $("#toast");
133
+ host.replaceChildren(icon(error ? "alert-circle" : "check-circle", "toast-icon"), el("span", { class: "toast-text", text: message }));
134
+ if (actionLabel) {
135
+ host.append(
136
+ button(actionLabel, "link", {
137
+ class: "toast-action",
138
+ onClick: () => {
139
+ hideToast();
140
+ if (onAction) void action(onAction);
141
+ },
142
+ }),
143
+ );
144
+ }
145
+ host.dataset.error = String(error);
146
+ host.hidden = false;
147
+ clearTimeout(toastTimer);
148
+ toastTimer = setTimeout(hideToast, timeout);
149
+ }
150
+ export function hideToast() {
151
+ clearTimeout(toastTimer);
152
+ const host = $("#toast");
153
+ if (host) host.hidden = true;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------------------------
157
+ // Popover menus
158
+ // ---------------------------------------------------------------------------------------------
159
+
160
+ let openMenuElement;
161
+ export function closeMenus() {
162
+ if (openMenuElement) {
163
+ const anchor = openMenuElement.anchor;
164
+ openMenuElement.remove();
165
+ openMenuElement = undefined;
166
+ if (anchor?.isConnected) {
167
+ anchor.setAttribute("aria-expanded", "false");
168
+ if (!document.activeElement || document.activeElement === document.body) anchor.focus();
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Open a Dashboard-style menu below `anchor`. `content` is an array of items
175
+ * ({ label, icon?, danger?, disabled?, description?, onSelect }, "divider", or { header }) or a prebuilt element.
176
+ */
177
+ export function openMenu(anchor, content, { align = "start", className = "", width } = {}) {
178
+ closeMenus();
179
+ const menu = el("div", { class: `menu ${className}`.trim(), attrs: { role: "menu" } });
180
+ menu.anchor = anchor;
181
+ if (width) menu.style.width = `${width}px`;
182
+ if (Array.isArray(content)) {
183
+ for (const item of content) {
184
+ if (item === "divider") {
185
+ menu.append(el("div", { class: "menu-divider", attrs: { role: "separator" } }));
186
+ continue;
187
+ }
188
+ if (item.header !== undefined) {
189
+ menu.append(el("div", { class: "menu-header", text: item.header }));
190
+ continue;
191
+ }
192
+ const entry = el("button", { class: `menu-item ${item.danger ? "danger" : ""}`.trim(), attrs: { type: "button", role: "menuitem" } });
193
+ if (item.icon) entry.append(icon(item.icon, "menu-icon"));
194
+ const labels = el("span", { class: "menu-labels" }, [el("span", { class: "menu-label", text: item.label })]);
195
+ if (item.description) labels.append(el("span", { class: "menu-description", text: item.description }));
196
+ entry.append(labels);
197
+ if (item.shortcut) entry.append(el("kbd", { class: "menu-kbd", text: item.shortcut }));
198
+ if (item.disabled) entry.disabled = true;
199
+ entry.addEventListener("click", () => {
200
+ closeMenus();
201
+ item.onSelect?.();
202
+ });
203
+ menu.append(entry);
204
+ }
205
+ } else menu.append(content);
206
+ document.body.append(menu);
207
+ openMenuElement = menu;
208
+ anchor.setAttribute("aria-expanded", "true");
209
+ const rect = anchor.getBoundingClientRect();
210
+ const menuWidth = menu.offsetWidth;
211
+ const height = menu.offsetHeight;
212
+ let left = align === "end" ? rect.right - menuWidth : rect.left;
213
+ left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
214
+ let top = rect.bottom + 6;
215
+ if (top + height > window.innerHeight - 8) top = Math.max(8, rect.top - height - 6);
216
+ menu.style.left = `${left}px`;
217
+ menu.style.top = `${top}px`;
218
+ menu.querySelector("input, button:not(:disabled)")?.focus();
219
+ return menu;
220
+ }
221
+
222
+ document.addEventListener("pointerdown", (event) => {
223
+ if (openMenuElement && !openMenuElement.contains(event.target) && !openMenuElement.anchor?.contains(event.target)) closeMenus();
224
+ });
225
+ document.addEventListener("keydown", (event) => {
226
+ if (!openMenuElement) return;
227
+ if (event.key === "Escape") {
228
+ event.preventDefault();
229
+ closeMenus();
230
+ } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
231
+ const items = $$(".menu-item:not(:disabled)", openMenuElement);
232
+ if (items.length === 0) return;
233
+ const index = items.indexOf(document.activeElement);
234
+ const next = event.key === "ArrowDown" ? (index + 1) % items.length : (index - 1 + items.length) % items.length;
235
+ items[next].focus();
236
+ event.preventDefault();
237
+ }
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------------------------
241
+ // Modal dialogs
242
+ // ---------------------------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Open a modal. `options`: { title, body (element), footer (elements) | actions: [{label, kind, onClick, submit}], size, onClose }.
246
+ * Returns { dialog, close, setError, form }.
247
+ */
248
+ export function openModal({ title, body, actions = [], size = "", onClose, describedBy }) {
249
+ const dialog = el("dialog", { class: `modal ${size}`.trim(), attrs: { "aria-labelledby": "modal-title" } });
250
+ const form = el("form", { class: "modal-form", attrs: { method: "dialog", novalidate: true } });
251
+ const header = el("header", { class: "modal-header" }, [el("h2", { class: "modal-title", text: title, attrs: { id: "modal-title" } }), iconButton("close", "Close", { onClick: () => close("cancel") })]);
252
+ const bodyHost = el("div", { class: "modal-body" }, body);
253
+ if (describedBy) bodyHost.prepend(el("p", { class: "modal-lede", text: describedBy }));
254
+ const errorHost = el("div", { class: "form-error", attrs: { role: "alert" } });
255
+ errorHost.hidden = true;
256
+ const footer = el("footer", { class: "modal-footer" });
257
+ let primary;
258
+ for (const item of actions) {
259
+ const control = button(item.label, item.kind ?? "secondary", { icon: item.icon, danger: item.danger, disabled: item.disabled, type: item.submit ? "submit" : "button" });
260
+ if (item.submit) primary = control;
261
+ if (!item.submit) control.addEventListener("click", () => (item.onClick ? item.onClick(api) : close("cancel")));
262
+ footer.append(control);
263
+ }
264
+ bodyHost.append(errorHost);
265
+ form.append(header, bodyHost, footer);
266
+ dialog.append(form);
267
+ document.body.append(dialog);
268
+ let result = "cancel";
269
+ const close = (value = "cancel") => {
270
+ result = value;
271
+ if (dialog.open) dialog.close();
272
+ };
273
+ const api = {
274
+ dialog,
275
+ form,
276
+ close,
277
+ primary,
278
+ setError(message) {
279
+ errorHost.textContent = message ?? "";
280
+ errorHost.hidden = !message;
281
+ },
282
+ setBusy(flag) {
283
+ if (primary) {
284
+ primary.disabled = flag;
285
+ primary.classList.toggle("is-busy", flag);
286
+ }
287
+ },
288
+ };
289
+ form.addEventListener("submit", (event) => {
290
+ event.preventDefault();
291
+ const submitAction = actions.find((item) => item.submit);
292
+ if (submitAction?.onClick) void submitAction.onClick(api);
293
+ });
294
+ dialog.addEventListener("cancel", (event) => {
295
+ event.preventDefault();
296
+ close("cancel");
297
+ });
298
+ dialog.addEventListener("close", () => {
299
+ dialog.remove();
300
+ onClose?.(result);
301
+ });
302
+ dialog.addEventListener("click", (event) => {
303
+ if (event.target === dialog) close("cancel");
304
+ });
305
+ dialog.showModal();
306
+ const first = form.querySelector("input:not([type=hidden]), select, textarea, button.btn-primary, button.btn-danger");
307
+ first?.focus();
308
+ return api;
309
+ }
310
+
311
+ /** Confirmation: title, one sentence, buttons. Resolves true when confirmed. */
312
+ export function confirmDialog(title, message, okLabel = "Confirm", { danger = false } = {}) {
313
+ return new Promise((resolve) => {
314
+ openModal({
315
+ title,
316
+ size: "modal-sm",
317
+ body: [el("p", { class: "modal-text", text: message })],
318
+ actions: [
319
+ { label: "Cancel" },
320
+ { label: okLabel, kind: danger ? "danger" : "primary", submit: true, onClick: (api) => api.close("ok") },
321
+ ],
322
+ onClose: (result) => resolve(result === "ok"),
323
+ });
324
+ });
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------------------------
328
+ // Forms
329
+ // ---------------------------------------------------------------------------------------------
330
+
331
+ let idCounter = 0;
332
+ /** Deterministic unique DOM id (a counter, never random). */
333
+ export function nextId(prefix = "f") {
334
+ idCounter += 1;
335
+ return `${prefix}-${idCounter}`;
336
+ }
337
+
338
+ const LABELABLE = "input:not([type=hidden]):not([type=radio]):not([type=checkbox]), select, textarea";
339
+
340
+ /**
341
+ * Labelled field. `control` is an input/select/textarea, or a composite (amount + currency, customer combobox,
342
+ * payment-method radio list). A native control is linked with `for`; a composite's first text control is linked
343
+ * with `for` and the composite itself becomes a labelled group (radiogroup for radio lists).
344
+ */
345
+ export function field(label, control, { hint, optional = false, inline = false, id } = {}) {
346
+ const native = control.matches?.(LABELABLE) ? control : undefined;
347
+ const hasRadios = !native && Boolean(control.querySelector?.("input[type=radio]"));
348
+ const inner = native ?? (hasRadios ? undefined : control.querySelector?.(LABELABLE));
349
+ const target = native ?? inner ?? control;
350
+ const controlId = id || target.id || nextId("f");
351
+ target.id = controlId;
352
+ const labelId = `${controlId}-label`;
353
+ const labelElement = el("label", { class: "field-label", attrs: { id: labelId, for: native || inner ? controlId : undefined } }, [text(label)]);
354
+ if (optional) labelElement.append(el("span", { class: "field-optional", text: "Optional" }));
355
+ if (!native) {
356
+ if (!control.getAttribute("role")) control.setAttribute("role", hasRadios ? "radiogroup" : "group");
357
+ control.setAttribute("aria-labelledby", labelId);
358
+ }
359
+ const wrapper = el("div", { class: `field ${inline ? "field-inline" : ""}`.trim() });
360
+ wrapper.append(labelElement, control);
361
+ const describedBy = [];
362
+ if (hint) {
363
+ const hintId = `${controlId}-hint`;
364
+ wrapper.append(el("p", { class: "field-hint", text: hint, attrs: { id: hintId } }));
365
+ describedBy.push(hintId);
366
+ }
367
+ const errorId = `${controlId}-error`;
368
+ const error = el("p", { class: "field-error", attrs: { id: errorId } });
369
+ error.hidden = true;
370
+ wrapper.append(error);
371
+ const setDescribedBy = (withError) => {
372
+ const ids = withError ? [...describedBy, errorId] : describedBy;
373
+ if (ids.length > 0) target.setAttribute("aria-describedby", ids.join(" "));
374
+ else target.removeAttribute("aria-describedby");
375
+ };
376
+ setDescribedBy(false);
377
+ wrapper.control = target;
378
+ wrapper.setError = (message) => {
379
+ error.textContent = message ?? "";
380
+ error.hidden = !message;
381
+ control.classList.toggle("is-invalid", Boolean(message));
382
+ target.setAttribute("aria-invalid", message ? "true" : "false");
383
+ setDescribedBy(Boolean(message));
384
+ };
385
+ return wrapper;
386
+ }
387
+
388
+ export function input(options = {}) {
389
+ const element = el("input", { class: `input ${options.class ?? ""}`.trim(), attrs: { type: options.type ?? "text", placeholder: options.placeholder, name: options.name, autocomplete: "off", spellcheck: "false", inputmode: options.inputmode, maxlength: options.maxlength, min: options.min, max: options.max, step: options.step, required: options.required } });
390
+ if (options.value !== undefined && options.value !== null) element.value = String(options.value);
391
+ return element;
392
+ }
393
+
394
+ export function textarea(options = {}) {
395
+ const element = el("textarea", { class: `input textarea ${options.class ?? ""}`.trim(), attrs: { placeholder: options.placeholder, name: options.name, rows: options.rows ?? 3, maxlength: options.maxlength } });
396
+ if (options.value !== undefined && options.value !== null) element.value = String(options.value);
397
+ return element;
398
+ }
399
+
400
+ /** `<select>` with `options` [{ value, label, disabled }] or plain strings. */
401
+ export function select(options, value, extra = {}) {
402
+ const element = el("select", { class: `input select ${extra.class ?? ""}`.trim(), attrs: { name: extra.name } });
403
+ for (const option of options) {
404
+ const item = typeof option === "string" ? { value: option, label: option } : option;
405
+ const node = el("option", { text: item.label, attrs: { value: item.value, disabled: item.disabled } });
406
+ element.append(node);
407
+ }
408
+ if (value !== undefined && value !== null) element.value = String(value);
409
+ return element;
410
+ }
411
+
412
+ export function checkbox(label, checked = false, extra = {}) {
413
+ const box = el("input", { class: "checkbox", attrs: { type: "checkbox", name: extra.name } });
414
+ box.checked = checked;
415
+ const wrapper = el("label", { class: "check-field" }, [box, el("span", { class: "check-label", text: label })]);
416
+ if (extra.hint) wrapper.append(el("span", { class: "check-hint", text: extra.hint }));
417
+ wrapper.input = box;
418
+ return wrapper;
419
+ }
420
+
421
+ /** Radio group; `items` [{ value, label, description }]. */
422
+ export function radioGroup(name, items, value) {
423
+ const group = el("div", { class: "radio-group", attrs: { role: "radiogroup" } });
424
+ for (const item of items) {
425
+ const radio = el("input", { class: "radio", attrs: { type: "radio", name, value: item.value } });
426
+ radio.checked = item.value === value;
427
+ const label = el("label", { class: "radio-field" }, [radio, el("span", { class: "radio-labels" }, [el("span", { class: "radio-label", text: item.label }), item.description ? el("span", { class: "radio-description", text: item.description }) : null])]);
428
+ group.append(label);
429
+ }
430
+ group.value = () => group.querySelector("input:checked")?.value;
431
+ return group;
432
+ }
433
+
434
+ /** Apply a Tool error to a form: field-level when the error names a `param`, otherwise on the summary. */
435
+ export function applyFormError(error, fields, api) {
436
+ const message = describe(error);
437
+ const param = error instanceof ToolError ? error.param : undefined;
438
+ const base = param ? param.replace(/\[.*$/, "") : undefined;
439
+ if (base && fields[base]) {
440
+ fields[base].setError(message);
441
+ fields[base].querySelector("input, select, textarea")?.focus();
442
+ api?.setError(undefined);
443
+ } else api?.setError(message);
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------------------------
447
+ // Formatting (money, dates from the world's virtual clock, ids)
448
+ // ---------------------------------------------------------------------------------------------
449
+
450
+ const ZERO_DECIMAL = new Set(["jpy"]);
451
+ const SYMBOLS = { usd: "$", eur: "€", gbp: "£", cad: "CA$", aud: "A$", chf: "CHF ", sek: "kr ", nok: "kr ", dkk: "kr ", jpy: "¥", nzd: "NZ$", sgd: "S$" };
452
+ export const CURRENCIES = Object.freeze(["usd", "eur", "gbp", "cad", "aud", "chf", "sek", "nok", "dkk", "jpy", "nzd", "sgd"]);
453
+
454
+ export function decimals(currency) {
455
+ return ZERO_DECIMAL.has(currency) ? 0 : 2;
456
+ }
457
+
458
+ /** `$1,890.00`, `-€15.00`, `¥500`. */
459
+ export function money(amount, currency = "usd") {
460
+ const symbol = SYMBOLS[currency] ?? `${currency.toUpperCase()} `;
461
+ const places = decimals(currency);
462
+ const sign = amount < 0 ? "-" : "";
463
+ const absolute = Math.abs(amount);
464
+ const major = places === 0 ? absolute : Math.floor(absolute / 100);
465
+ const minor = places === 0 ? "" : `.${String(absolute % 100).padStart(2, "0")}`;
466
+ return `${sign}${symbol}${major.toLocaleString("en-US")}${minor}`;
467
+ }
468
+
469
+ /** Amount cell: "$1,890.00" + a muted "USD". */
470
+ export function moneyCell(amount, currency, options = {}) {
471
+ return el("span", { class: `money ${options.class ?? ""}`.trim() }, [el("span", { class: "money-value", text: money(amount, currency) }), el("span", { class: "money-currency", text: currency.toUpperCase() })]);
472
+ }
473
+
474
+ /** Parse a decimal amount typed by the user into minor units; undefined when not a valid number. */
475
+ export function parseAmount(textValue, currency) {
476
+ const cleaned = String(textValue ?? "").replace(/[,\s]/g, "");
477
+ if (!/^-?\d+(\.\d{0,2})?$/.test(cleaned)) return undefined;
478
+ const places = decimals(currency);
479
+ const [whole, fraction = ""] = cleaned.split(".");
480
+ if (places === 0) return fraction.length > 0 && Number(fraction) !== 0 ? undefined : Number(whole);
481
+ return Number(whole) * 100 + (whole.startsWith("-") ? -1 : 1) * Number(fraction.padEnd(2, "0"));
482
+ }
483
+
484
+ /** Minor units → editable decimal string ("18.90"). */
485
+ export function amountString(amount, currency) {
486
+ return decimals(currency) === 0 ? String(amount) : (amount / 100).toFixed(2);
487
+ }
488
+
489
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
490
+
491
+ function utc(seconds) {
492
+ const date = new Date(seconds * 1000);
493
+ return { y: date.getUTCFullYear(), m: date.getUTCMonth(), d: date.getUTCDate(), h: date.getUTCHours(), min: date.getUTCMinutes() };
494
+ }
495
+
496
+ function clock(parts) {
497
+ const hour12 = parts.h % 12 === 0 ? 12 : parts.h % 12;
498
+ return `${hour12}:${String(parts.min).padStart(2, "0")} ${parts.h < 12 ? "AM" : "PM"}`;
499
+ }
500
+
501
+ /** List date the way the Dashboard shows it: "Sep 14, 9:00 AM" this year, "Sep 14, 2025, 9:00 AM" otherwise. Rendered in UTC so a replay is identical anywhere. */
502
+ export function dateTime(seconds, nowSeconds) {
503
+ if (typeof seconds !== "number") return "—";
504
+ const parts = utc(seconds);
505
+ const sameYear = nowSeconds !== undefined && utc(nowSeconds).y === parts.y;
506
+ return `${MONTHS[parts.m]} ${parts.d}${sameYear ? "" : `, ${parts.y}`}, ${clock(parts)}`;
507
+ }
508
+
509
+ export function dateOnly(seconds, nowSeconds) {
510
+ if (typeof seconds !== "number") return "—";
511
+ const parts = utc(seconds);
512
+ const sameYear = nowSeconds !== undefined && utc(nowSeconds).y === parts.y;
513
+ return `${MONTHS[parts.m]} ${parts.d}${sameYear ? "" : `, ${parts.y}`}`;
514
+ }
515
+
516
+ export function fullDate(seconds) {
517
+ if (typeof seconds !== "number") return "—";
518
+ const parts = utc(seconds);
519
+ return `${MONTHS[parts.m]} ${parts.d}, ${parts.y}, ${clock(parts)} UTC`;
520
+ }
521
+
522
+ /** "in 3 days" / "2 hours ago" relative to the world's virtual now. */
523
+ export function relative(seconds, nowSeconds) {
524
+ const diff = seconds - nowSeconds;
525
+ const abs = Math.abs(diff);
526
+ const unit = abs < 3600 ? [Math.max(1, Math.round(abs / 60)), "minute"] : abs < 86400 ? [Math.round(abs / 3600), "hour"] : abs < 86400 * 31 ? [Math.round(abs / 86400), "day"] : abs < 86400 * 365 ? [Math.round(abs / (86400 * 30)), "month"] : [Math.round(abs / (86400 * 365)), "year"];
527
+ const label = `${unit[0]} ${unit[1]}${unit[0] === 1 ? "" : "s"}`;
528
+ return diff >= 0 ? `in ${label}` : `${label} ago`;
529
+ }
530
+
531
+ /** Unix seconds → "2026-09-14" for date inputs; and back. */
532
+ export function isoDay(seconds) {
533
+ const parts = utc(seconds);
534
+ return `${parts.y}-${String(parts.m + 1).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
535
+ }
536
+ export function fromIsoDay(value, hour = 9) {
537
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? "");
538
+ if (!match) return undefined;
539
+ return Math.floor(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), hour) / 1000);
540
+ }
541
+
542
+ export function capitalize(value) {
543
+ const stringValue = String(value ?? "");
544
+ return stringValue.charAt(0).toUpperCase() + stringValue.slice(1);
545
+ }
546
+
547
+ export function humanize(value) {
548
+ return capitalize(String(value ?? "").replace(/_/g, " "));
549
+ }
550
+
551
+ const BRANDS = { visa: "Visa", mastercard: "Mastercard", amex: "American Express", discover: "Discover", jcb: "JCB", diners: "Diners Club", unionpay: "UnionPay" };
552
+ export function brandName(brand) {
553
+ return BRANDS[brand] ?? capitalize(brand ?? "Card");
554
+ }
555
+
556
+ /** "Visa •••• 4242" the way the Dashboard labels a card. */
557
+ export function cardLabel(method) {
558
+ const card = method?.card;
559
+ if (!card) return method?.type ? humanize(method.type) : "—";
560
+ return `${brandName(card.brand)} •••• ${card.last4}`;
561
+ }
562
+
563
+ /** Card row: a brand chip plus "•••• 4242". */
564
+ export function cardChip(method, options = {}) {
565
+ const card = method?.card;
566
+ const wrapper = el("span", { class: `card-chip ${options.class ?? ""}`.trim() });
567
+ if (!card) {
568
+ wrapper.append(icon("card", "card-brand-icon"), el("span", { text: method?.type ? humanize(method.type) : "—" }));
569
+ return wrapper;
570
+ }
571
+ wrapper.append(el("span", { class: `card-brand card-brand-${card.brand}`, text: brandName(card.brand).replace("American Express", "Amex") }), el("span", { class: "card-last4", text: `•••• ${card.last4}` }));
572
+ if (options.expiry) wrapper.append(el("span", { class: "card-expiry", text: `Expires ${String(card.exp_month).padStart(2, "0")}/${card.exp_year}` }));
573
+ return wrapper;
574
+ }
575
+
576
+ /** Copy to clipboard with a toast; never throws. */
577
+ export async function copyText(value, label = "Copied") {
578
+ try {
579
+ await navigator.clipboard.writeText(value);
580
+ toast(label);
581
+ } catch {
582
+ toast("Could not access the clipboard", { error: true });
583
+ }
584
+ }
585
+
586
+ /** A monospaced id with a copy affordance. */
587
+ export function idChip(id) {
588
+ return el("span", { class: "id-chip" }, [
589
+ el("code", { class: "id-code", text: id }),
590
+ iconButton("copy", `Copy ${id}`, { size: "xs", class: "id-copy", onClick: () => void copyText(id, "Copied to clipboard") }),
591
+ ]);
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------------------------
595
+ // Status badges (Sail colours: green / red / yellow / blue / gray)
596
+ // ---------------------------------------------------------------------------------------------
597
+
598
+ export function badge(label, tone = "gray", glyph) {
599
+ const element = el("span", { class: `badge badge-${tone}`, text: label });
600
+ if (glyph) element.append(icon(glyph, "badge-icon"));
601
+ return element;
602
+ }
603
+
604
+ /** PaymentIntent display status ("Succeeded", "Refunded", "Uncaptured", …) given the intent and its latest charge. */
605
+ export function intentStatus(intent, charge) {
606
+ const refunded = charge && charge.amount_refunded > 0 && charge.status === "succeeded";
607
+ if (intent.status === "succeeded" && refunded) {
608
+ return charge.refunded || charge.amount_refunded >= charge.amount_captured ? { label: "Refunded", tone: "gray", icon: "arrow-return", key: "refunded" } : { label: "Partial refund", tone: "gray", icon: "arrow-return", key: "refunded" };
609
+ }
610
+ switch (intent.status) {
611
+ case "succeeded":
612
+ return { label: "Succeeded", tone: "green", icon: "check-circle", key: "succeeded" };
613
+ case "requires_capture":
614
+ return { label: "Uncaptured", tone: "yellow", icon: "clock", key: "uncaptured" };
615
+ case "canceled":
616
+ return { label: "Canceled", tone: "gray", icon: "minus-circle", key: "canceled" };
617
+ case "processing":
618
+ return { label: "Processing", tone: "gray", icon: "clock", key: "incomplete" };
619
+ case "requires_payment_method":
620
+ return intent.last_payment_error ? { label: "Failed", tone: "red", icon: "x-circle", key: "failed" } : { label: "Incomplete", tone: "gray", icon: "clock", key: "incomplete" };
621
+ default:
622
+ return { label: "Incomplete", tone: "gray", icon: "clock", key: "incomplete" };
623
+ }
624
+ }
625
+
626
+ export function invoiceStatus(invoice, nowSeconds) {
627
+ switch (invoice.status) {
628
+ case "draft":
629
+ return { label: "Draft", tone: "gray", icon: "edit", key: "draft" };
630
+ case "open":
631
+ return typeof invoice.due_date === "number" && invoice.due_date < nowSeconds ? { label: "Past due", tone: "yellow", icon: "alert-circle", key: "past_due" } : { label: "Open", tone: "blue", icon: "clock", key: "open" };
632
+ case "paid":
633
+ return { label: "Paid", tone: "green", icon: "check-circle", key: "paid" };
634
+ case "void":
635
+ return { label: "Void", tone: "gray", icon: "minus-circle", key: "void" };
636
+ case "uncollectible":
637
+ return { label: "Uncollectible", tone: "red", icon: "x-circle", key: "uncollectible" };
638
+ default:
639
+ return { label: humanize(invoice.status), tone: "gray", key: invoice.status };
640
+ }
641
+ }
642
+
643
+ export function subscriptionStatus(subscription, nowSeconds) {
644
+ if (subscription.cancel_at_period_end && subscription.status !== "canceled") return { label: `Cancels ${dateOnly(subscription.cancel_at, nowSeconds)}`, tone: "gray", icon: "clock", key: "cancels" };
645
+ switch (subscription.status) {
646
+ case "active":
647
+ return { label: "Active", tone: "green", icon: "check-circle", key: "active" };
648
+ case "trialing":
649
+ return { label: "Trialing", tone: "blue", icon: "clock", key: "trialing" };
650
+ case "past_due":
651
+ return { label: "Past due", tone: "yellow", icon: "alert-circle", key: "past_due" };
652
+ case "canceled":
653
+ return { label: "Canceled", tone: "gray", icon: "minus-circle", key: "canceled" };
654
+ case "incomplete":
655
+ return { label: "Incomplete", tone: "yellow", icon: "clock", key: "incomplete" };
656
+ case "incomplete_expired":
657
+ return { label: "Incomplete expired", tone: "gray", icon: "x-circle", key: "incomplete_expired" };
658
+ case "unpaid":
659
+ return { label: "Unpaid", tone: "red", icon: "x-circle", key: "unpaid" };
660
+ case "paused":
661
+ return { label: "Paused", tone: "gray", icon: "clock", key: "paused" };
662
+ default:
663
+ return { label: humanize(subscription.status), tone: "gray", key: subscription.status };
664
+ }
665
+ }
666
+
667
+ export function refundStatus(refund) {
668
+ switch (refund.status) {
669
+ case "succeeded":
670
+ return { label: "Succeeded", tone: "green", icon: "check-circle" };
671
+ case "pending":
672
+ return { label: "Pending", tone: "gray", icon: "clock" };
673
+ case "failed":
674
+ return { label: "Failed", tone: "red", icon: "x-circle" };
675
+ case "canceled":
676
+ return { label: "Canceled", tone: "gray", icon: "minus-circle" };
677
+ default:
678
+ return { label: humanize(refund.status), tone: "gray" };
679
+ }
680
+ }
681
+
682
+ export function statusBadge(status) {
683
+ return badge(status.label, status.tone, status.icon);
684
+ }
685
+
686
+ /** Price summary: "$29.00 / month", "$189.00", "$4.00 every 2 weeks". */
687
+ export function priceLabel(price) {
688
+ if (!price) return "—";
689
+ const base = money(price.unit_amount ?? 0, price.currency);
690
+ const recurring = price.recurring;
691
+ if (!recurring) return base;
692
+ const count = recurring.interval_count ?? 1;
693
+ return count === 1 ? `${base} / ${recurring.interval}` : `${base} every ${count} ${recurring.interval}s`;
694
+ }
695
+
696
+ export function intervalLabel(recurring) {
697
+ if (!recurring) return "One time";
698
+ const count = recurring.interval_count ?? 1;
699
+ return count === 1 ? capitalize(`${recurring.interval}ly`).replace("Dayly", "Daily") : `Every ${count} ${recurring.interval}s`;
700
+ }
701
+
702
+ /** Letter avatar (customer initials) in the Dashboard's muted style. */
703
+ export function avatar(name, seed = "", size = "") {
704
+ const initials = String(name ?? "")
705
+ .split(/\s+/)
706
+ .filter(Boolean)
707
+ .slice(0, 2)
708
+ .map((part) => part.charAt(0).toUpperCase())
709
+ .join("");
710
+ const element = el("span", { class: `avatar ${size}`.trim(), text: initials || "?", attrs: { "aria-hidden": "true" } });
711
+ let hash = 0;
712
+ for (const character of String(seed || name)) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
713
+ element.dataset.tone = String(hash % 6);
714
+ return element;
715
+ }
716
+
717
+ // ---------------------------------------------------------------------------------------------
718
+ // Per-viewer preferences (never authority over records)
719
+ // ---------------------------------------------------------------------------------------------
720
+
721
+ export function readPreference(name, fallback) {
722
+ try {
723
+ return localStorage.getItem(`stripe-tool:${name}`) ?? fallback;
724
+ } catch {
725
+ return fallback;
726
+ }
727
+ }
728
+ export function writePreference(name, value) {
729
+ try {
730
+ localStorage.setItem(`stripe-tool:${name}`, value);
731
+ } catch {
732
+ /* blocked storage: the preference just does not persist */
733
+ }
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------------------------
737
+ // World revision polling
738
+ // ---------------------------------------------------------------------------------------------
739
+
740
+ /** Poll the world revision and refresh when something changed (agent activity, reset, other tabs). */
741
+ export function watchWorld(refresh, mayRefresh = () => true, onContext) {
742
+ let revision;
743
+ let checking = false;
744
+ const check = async () => {
745
+ if (checking || pending > 0 || document.visibilityState !== "visible") return;
746
+ checking = true;
747
+ try {
748
+ const context = await getContext();
749
+ onContext?.(context);
750
+ const stamp = JSON.stringify(context.revision);
751
+ if (revision !== stamp) {
752
+ const first = revision === undefined;
753
+ revision = stamp;
754
+ if (first || mayRefresh()) await refresh(first);
755
+ }
756
+ } catch (error) {
757
+ toast(error?.message ?? "The local environment is unavailable.", { error: true });
758
+ } finally {
759
+ checking = false;
760
+ }
761
+ };
762
+ const timer = setInterval(() => void check(), 2000);
763
+ window.addEventListener("pagehide", () => clearInterval(timer), { once: true });
764
+ void check();
765
+ }
766
+
767
+ export { getContext };