@firedrill-tools/trolley 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 (78) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +219 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +2109 -0
  5. package/firedrill/conformance.suite.json +19 -0
  6. package/firedrill/partner-outage.scenario.json +11 -0
  7. package/firedrill/processing-response-lost.scenario.json +11 -0
  8. package/firedrill/rate-limited.scenario.json +11 -0
  9. package/firedrill/tight-limits.scenario.json +16 -0
  10. package/firedrill/tools/trolley/app/assets/ATTRIBUTION.md +35 -0
  11. package/firedrill/tools/trolley/app/assets/cropped-Site-Icon-512x512-px-1-192x192.png +0 -0
  12. package/firedrill/tools/trolley/app/assets/fonts/OFL.txt +93 -0
  13. package/firedrill/tools/trolley/app/assets/full-logo.svg +1 -0
  14. package/firedrill/tools/trolley/app/assets/logo-black.svg +18 -0
  15. package/firedrill/tools/trolley/app/site/app.js +139 -0
  16. package/firedrill/tools/trolley/app/site/assets/fonts/inter-latin.woff2 +0 -0
  17. package/firedrill/tools/trolley/app/site/assets/trolley-icon.png +0 -0
  18. package/firedrill/tools/trolley/app/site/assets/trolley-logo.svg +1 -0
  19. package/firedrill/tools/trolley/app/site/base.css +99 -0
  20. package/firedrill/tools/trolley/app/site/batch-actions.js +139 -0
  21. package/firedrill/tools/trolley/app/site/components.css +149 -0
  22. package/firedrill/tools/trolley/app/site/icons.js +50 -0
  23. package/firedrill/tools/trolley/app/site/index.html +53 -0
  24. package/firedrill/tools/trolley/app/site/list.js +87 -0
  25. package/firedrill/tools/trolley/app/site/ui.js +181 -0
  26. package/firedrill/tools/trolley/app/site/view-balances.js +30 -0
  27. package/firedrill/tools/trolley/app/site/view-batch.js +86 -0
  28. package/firedrill/tools/trolley/app/site/view-batches.js +78 -0
  29. package/firedrill/tools/trolley/app/site/view-dashboard.js +79 -0
  30. package/firedrill/tools/trolley/app/site/view-methods.js +80 -0
  31. package/firedrill/tools/trolley/app/site/view-recipient.js +124 -0
  32. package/firedrill/tools/trolley/app/site/view-recipients.js +115 -0
  33. package/firedrill/tools/trolley/behavior.mjs +78 -0
  34. package/firedrill/tools/trolley/lib/access.mjs +21 -0
  35. package/firedrill/tools/trolley/lib/ids.mjs +28 -0
  36. package/firedrill/tools/trolley/lib/money.mjs +55 -0
  37. package/firedrill/tools/trolley/lib/paging.mjs +65 -0
  38. package/firedrill/tools/trolley/lib/pricing.mjs +88 -0
  39. package/firedrill/tools/trolley/lib/records.mjs +87 -0
  40. package/firedrill/tools/trolley/lib/serialize.mjs +152 -0
  41. package/firedrill/tools/trolley/lib/store.mjs +61 -0
  42. package/firedrill/tools/trolley/lib/time.mjs +60 -0
  43. package/firedrill/tools/trolley/lib/validate.mjs +111 -0
  44. package/firedrill/tools/trolley/lib/wire.mjs +145 -0
  45. package/firedrill/tools/trolley/ops/accounts-write.mjs +117 -0
  46. package/firedrill/tools/trolley/ops/accounts.mjs +129 -0
  47. package/firedrill/tools/trolley/ops/balances.mjs +23 -0
  48. package/firedrill/tools/trolley/ops/batches.mjs +139 -0
  49. package/firedrill/tools/trolley/ops/payment-build.mjs +79 -0
  50. package/firedrill/tools/trolley/ops/payments.mjs +112 -0
  51. package/firedrill/tools/trolley/ops/processing.mjs +104 -0
  52. package/firedrill/tools/trolley/ops/recipients-list.mjs +58 -0
  53. package/firedrill/tools/trolley/ops/recipients.mjs +127 -0
  54. package/firedrill/tools/trolley/trolley.tool.json +10095 -0
  55. package/firedrill/trolley-accounts.drill.json +205 -0
  56. package/firedrill/trolley-batch-lifecycle.drill.json +522 -0
  57. package/firedrill/trolley-denied.drill.json +74 -0
  58. package/firedrill/trolley-fresh-install.drill.json +68 -0
  59. package/firedrill/trolley-invalid-key.drill.json +743 -0
  60. package/firedrill/trolley-partner-outage.drill.json +97 -0
  61. package/firedrill/trolley-processing-rules.drill.json +138 -0
  62. package/firedrill/trolley-rate-limited.drill.json +118 -0
  63. package/firedrill/trolley-read-only.drill.json +468 -0
  64. package/firedrill/trolley-recipients.drill.json +183 -0
  65. package/firedrill/trolley-response-lost.drill.json +127 -0
  66. package/firedrill/trolley-tight-limits.drill.json +348 -0
  67. package/firedrill/world.json +2539 -0
  68. package/firedrill.json +5 -0
  69. package/package.json +64 -0
  70. package/starter.json +2108 -0
  71. package/test/conformance.mjs +12 -0
  72. package/test/flow-access.mjs +54 -0
  73. package/test/flow-accounts.mjs +63 -0
  74. package/test/flow-batches.mjs +107 -0
  75. package/test/flow-faults.mjs +58 -0
  76. package/test/flow-processing.mjs +41 -0
  77. package/test/flow-recipients.mjs +93 -0
  78. package/test/harness.mjs +87 -0
@@ -0,0 +1,181 @@
1
+ // DOM helpers, operation calls, toasts, modals and formatting. All record text goes through textContent.
2
+ import { getContext, invoke } from "/_firedrill/client.js";
3
+ import { icon } from "./icons.js";
4
+
5
+ export const $ = (selector, root = document) => root.querySelector(selector);
6
+
7
+ export function el(tag, options = {}, children = []) {
8
+ const node = document.createElement(tag);
9
+ if (options.class) node.className = options.class;
10
+ if (options.text !== undefined && options.text !== null) node.textContent = String(options.text);
11
+ if (options.title) node.title = options.title;
12
+ if (options.attrs) for (const [name, value] of Object.entries(options.attrs)) if (value !== undefined && value !== null && value !== false) node.setAttribute(name, value === true ? "" : String(value));
13
+ if (options.on) for (const [event, handler] of Object.entries(options.on)) node.addEventListener(event, handler);
14
+ for (const child of Array.isArray(children) ? children : [children]) if (child !== undefined && child !== null && child !== false) node.append(child);
15
+ return node;
16
+ }
17
+
18
+ export function btn(label, kind = "secondary", options = {}) {
19
+ const children = [];
20
+ if (options.icon) children.push(icon(options.icon));
21
+ if (label) children.push(el("span", { text: label }));
22
+ return el("button", { class: `btn btn-${kind}${options.small ? " btn-sm" : ""}`, title: options.title, attrs: { type: "button", disabled: options.disabled, "aria-label": options.label }, on: options.onClick ? { click: options.onClick } : {} }, children);
23
+ }
24
+
25
+ export class ToolError extends Error {
26
+ constructor(status, code, message, details) {
27
+ super(message);
28
+ this.status = status;
29
+ this.code = code;
30
+ this.details = details ?? {};
31
+ }
32
+ is(code) {
33
+ return this.code === code || this.code === `tool.${code}` || String(this.code).endsWith(`.${code}`);
34
+ }
35
+ get field() {
36
+ return typeof this.details.field === "string" ? this.details.field : undefined;
37
+ }
38
+ get denied() {
39
+ return this.status === "denied" || this.is("NOT_AUTHORIZED");
40
+ }
41
+ }
42
+
43
+ let pending = 0;
44
+ export async function call(operationId, args = {}, idempotencyKey) {
45
+ pending += 1;
46
+ try {
47
+ const result = await invoke(operationId, args, idempotencyKey ? { idempotencyKey } : {});
48
+ const outcome = result.outcome;
49
+ if (outcome.status !== "ok") {
50
+ const error = outcome.error ?? {};
51
+ throw new ToolError(outcome.status, error.code ?? outcome.status, error.message ?? `The request was ${outcome.status}.`, error.details);
52
+ }
53
+ return outcome.value;
54
+ } finally {
55
+ pending -= 1;
56
+ }
57
+ }
58
+ export const newKey = () => crypto.randomUUID();
59
+
60
+ export function describe(error) {
61
+ if (!(error instanceof ToolError)) return error?.message ?? "Something went wrong.";
62
+ if (error.status === "denied") return "Your API key is not permitted to do this (not_authorized).";
63
+ if (error.is("NOT_AUTHORIZED")) return "This key has read-only access. Ask an administrator for full access.";
64
+ if (error.is("INVALID_API_KEY")) return "API key is invalid for this merchant.";
65
+ if (error.is("RATE_LIMIT_EXCEEDED")) return "Too many requests. Wait a moment, then try again.";
66
+ if (error.is("INTERNAL_SERVER_ERROR")) return `${error.message} The result may be unknown: refresh before retrying.`;
67
+ return error.field ? `${error.message} (${error.field})` : error.message;
68
+ }
69
+
70
+ export function toast(message, { error = false, timeout = 5000, actionLabel, onAction } = {}) {
71
+ const host = $("#toasts");
72
+ const node = el("div", { class: `toast${error ? " toast-error" : ""}`, attrs: { role: error ? "alert" : "status" } }, [
73
+ el("span", { class: "toast-icon" }, icon(error ? "info" : "check")),
74
+ el("span", { class: "toast-text", text: message }),
75
+ ]);
76
+ if (actionLabel) node.append(btn(actionLabel, "link", { onClick: () => { node.remove(); onAction?.(); } }));
77
+ node.append(el("button", { class: "toast-close", attrs: { type: "button", "aria-label": "Dismiss" }, on: { click: () => node.remove() } }, icon("close")));
78
+ host.append(node);
79
+ if (timeout) setTimeout(() => node.remove(), timeout);
80
+ }
81
+
82
+ export function openModal({ title, body, actions = [], wide = false, onClose }) {
83
+ const previous = document.activeElement;
84
+ const titleId = `m-${Math.random().toString(36).slice(2)}`;
85
+ const close = () => { overlay.remove(); document.removeEventListener("keydown", onKey); previous?.focus?.(); onClose?.(); };
86
+ const onKey = (event) => { if (event.key === "Escape") close(); };
87
+ const dialog = el("div", { class: `modal${wide ? " modal-wide" : ""}`, attrs: { role: "dialog", "aria-modal": "true", "aria-labelledby": titleId } }, [
88
+ el("div", { class: "modal-head" }, [el("h2", { text: title, attrs: { id: titleId } }), el("button", { class: "icon-btn", attrs: { type: "button", "aria-label": "Close" }, on: { click: close } }, icon("close"))]),
89
+ el("div", { class: "modal-body" }, body),
90
+ actions.length ? el("div", { class: "modal-foot" }, actions) : null,
91
+ ]);
92
+ const overlay = el("div", { class: "overlay", on: { mousedown: (event) => { if (event.target === overlay) close(); } } }, dialog);
93
+ document.body.append(overlay);
94
+ document.addEventListener("keydown", onKey);
95
+ queueMicrotask(() => (dialog.querySelector("input, select, textarea, .btn-primary") ?? dialog).focus?.());
96
+ return { close, dialog };
97
+ }
98
+
99
+ export function confirmDialog(title, message, okLabel = "Confirm", { danger = false } = {}) {
100
+ return new Promise((resolve) => {
101
+ let done = false;
102
+ const finish = (value) => { if (!done) { done = true; resolve(value); } };
103
+ const cancel = btn("Cancel", "secondary", { onClick: () => { modal.close(); } });
104
+ const ok = btn(okLabel, danger ? "danger" : "primary", { onClick: () => { finish(true); modal.close(); } });
105
+ const modal = openModal({ title, body: typeof message === "string" ? el("p", { text: message }) : message, actions: [cancel, ok], onClose: () => finish(false) });
106
+ });
107
+ }
108
+
109
+ let fieldSeq = 0;
110
+ export function field(label, control, { hint, full = false } = {}) {
111
+ fieldSeq += 1;
112
+ const id = `fld-${fieldSeq}`;
113
+ control.id = id;
114
+ return el("div", { class: `field${full ? " field-full" : ""}`, attrs: { "data-field": control.name || undefined } }, [
115
+ el("label", { text: label, attrs: { for: id } }),
116
+ control,
117
+ hint ? el("div", { class: "field-hint", text: hint }) : null,
118
+ el("div", { class: "field-error", attrs: { "aria-live": "polite" } }),
119
+ ]);
120
+ }
121
+ export function input(name, value = "", attrs = {}) {
122
+ return el("input", { class: "input", attrs: { name, value: value ?? "", type: "text", ...attrs } });
123
+ }
124
+ export function select(name, options, value) {
125
+ const node = el("select", { class: "input", attrs: { name } }, options.map(([v, label]) => el("option", { text: label, attrs: { value: v, selected: v === value } })));
126
+ return node;
127
+ }
128
+ export function showFieldError(form, error) {
129
+ for (const slot of form.querySelectorAll(".field-error")) slot.textContent = "";
130
+ const name = error instanceof ToolError ? error.field : undefined;
131
+ const leaf = name?.split(/[.[\]]/).filter(Boolean).pop();
132
+ const target = name && (form.querySelector(`[data-field="${CSS.escape(name)}"] .field-error`) ?? (leaf && form.querySelector(`[data-field="${CSS.escape(leaf)}"] .field-error`)));
133
+ if (target) target.textContent = error.message;
134
+ return Boolean(target);
135
+ }
136
+
137
+ const SYMBOL = { USD: "$", CAD: "CA$", EUR: "€", GBP: "£", AUD: "A$", MXN: "MX$" };
138
+ export function money(amount, currency = "USD") {
139
+ if (amount === "" || amount === null || amount === undefined) return "—";
140
+ const [whole, frac = "00"] = String(amount).replace("-", "").split(".");
141
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
142
+ const sign = String(amount).startsWith("-") ? "-" : "";
143
+ return `${sign}${Object.hasOwn(SYMBOL, currency) ? SYMBOL[currency] : ""}${grouped}.${frac.padEnd(2, "0").slice(0, 2)}`;
144
+ }
145
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
146
+ export function date(iso, withTime = false) {
147
+ if (!iso) return "—";
148
+ const d = new Date(iso);
149
+ if (Number.isNaN(d.getTime())) return "—";
150
+ const base = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
151
+ return withTime ? `${base} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")} UTC` : base;
152
+ }
153
+ const LABELS = { paypal: "PayPal", ach: "ACH", sepa: "SEPA", "bank-transfer": "Bank transfer", paymentrails: "Trolley" };
154
+ export const human = (value) => (Object.hasOwn(LABELS, String(value)) ? LABELS[value] : String(value ?? "").replace(/[-_]/g, " ").replace(/^\w/, (c) => c.toUpperCase()));
155
+
156
+ const TONES = { active: "green", processed: "green", complete: "green", verified: "green", primary: "blue", open: "blue", pending: "amber", processing: "amber", incomplete: "amber", review: "amber", failed: "red", blocked: "red", suspended: "red", disabled: "grey", archived: "grey" };
157
+ export function pill(status) {
158
+ const tone = Object.hasOwn(TONES, status) ? TONES[status] : "grey";
159
+ return el("span", { class: `pill pill-${tone}`, text: human(status) });
160
+ }
161
+ export function initials(name) {
162
+ return String(name ?? "?").split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0].toUpperCase()).join("") || "?";
163
+ }
164
+
165
+ export function watchWorld(refresh, mayRefresh) {
166
+ let revision;
167
+ const check = async () => {
168
+ if (pending > 0 || document.visibilityState !== "visible") return;
169
+ try {
170
+ const context = await getContext();
171
+ const stamp = JSON.stringify(context.revision);
172
+ if (revision !== undefined && stamp !== revision && mayRefresh()) { revision = stamp; await refresh(); }
173
+ revision = stamp;
174
+ } catch (error) {
175
+ toast(error?.message ?? "The local environment is unavailable.", { error: true });
176
+ }
177
+ };
178
+ setInterval(() => void check(), 2000);
179
+ void check();
180
+ }
181
+ export { getContext };
@@ -0,0 +1,30 @@
1
+ // Balances: funding balances by kind (Trolley and PayPal).
2
+ import { go, setTitle } from "./app.js";
3
+ import { dataTable, stateBlock, tabs } from "./list.js";
4
+ import { el, human, money } from "./ui.js";
5
+ import { call } from "./ui.js";
6
+
7
+ export async function renderBalances(host, route, view) {
8
+ setTitle("Balances");
9
+ const kind = route.params.kind ?? "";
10
+ const table = dataTable([
11
+ { label: "Account", cell: (b) => el("div", {}, [el("div", { text: b.type === "paymentrails" ? "Trolley balance" : "PayPal balance" }), el("div", { class: "sub mono", text: b.accountNumber })]) },
12
+ { label: "Currency", cell: (b) => b.currency },
13
+ { label: "Primary", cell: (b) => (b.primary ? "Yes" : "—") },
14
+ { label: "Pending", num: true, cell: (b) => money(b.pendingAmount, b.currency) },
15
+ { label: "Available", num: true, cell: (b) => el("strong", { text: money(b.amount, b.currency) }) },
16
+ ]);
17
+ host.replaceChildren(el("div", {}, [
18
+ tabs([["", "All balances"], ["paymentrails", "Trolley"], ["paypal", "PayPal"]], kind, (value) => go("balances", undefined, { kind: value })),
19
+ el("div", { class: "banner banner-info" }, el("span", { text: "Deposits and funding transfers are not simulated. Processing a batch debits the Trolley balance in the batch currency." })),
20
+ el("section", { class: "card" }, table.node),
21
+ ]));
22
+ try {
23
+ const value = await call("balances.list", kind ? { kind } : {});
24
+ if (!view.isCurrent()) return;
25
+ table.setRows(value.balances, stateBlock("coins", "No balances", `There are no ${kind ? human(kind) : ""} balances for this merchant.`));
26
+ } catch (error) {
27
+ if (!view.isCurrent()) return;
28
+ table.setError(error, () => go("balances", undefined, { kind }));
29
+ }
30
+ }
@@ -0,0 +1,86 @@
1
+ // Batch detail: summary strip, quote expiry (virtual time), payments table with search and paging, batch actions.
2
+ import { app, go, setTitle } from "./app.js";
3
+ import { deleteBatch, generateQuote, openPaymentForm, processBatch, removePayment, reviewSummary } from "./batch-actions.js";
4
+ import { dataTable, errorBlock, pager, searchBox, stateBlock } from "./list.js";
5
+ import { btn, call, date, el, human, initials, money, pill } from "./ui.js";
6
+
7
+ function quoteText(batch) {
8
+ if (!batch.quoteExpiredAt) return "No quote";
9
+ const ms = Date.parse(batch.quoteExpiredAt) - Date.parse(app.now ?? "");
10
+ if (Number.isNaN(ms)) return `Expires ${date(batch.quoteExpiredAt, true)}`;
11
+ if (ms <= 0) return "Quote expired";
12
+ const minutes = Math.floor(ms / 60000);
13
+ return minutes >= 60 ? `Expires in ${Math.floor(minutes / 60)}h ${minutes % 60}m` : `Expires in ${minutes}m`;
14
+ }
15
+
16
+ export async function renderBatch(host, route, view) {
17
+ setTitle("Payments");
18
+ host.replaceChildren(el("div", { class: "card card-body" }, el("div", { class: "skeleton" })));
19
+ let batch;
20
+ try {
21
+ batch = (await call("batches.get", { batchId: route.id })).batch;
22
+ } catch (error) {
23
+ if (!view.isCurrent()) return;
24
+ host.replaceChildren(crumbs(route.id), el("section", { class: "card" }, error.is?.("NOT_FOUND") ? stateBlock("money", "Batch not found", "It may have been deleted.") : errorBlock(error, () => go("payments", route.id))));
25
+ return;
26
+ }
27
+ if (!view.isCurrent()) return;
28
+ const open = batch.status === "open";
29
+ const reload = () => go("payments", batch.id);
30
+ const state = { page: 1, pageSize: 10, search: "" };
31
+
32
+ const strip = el("section", { class: "card summary-strip" }, [
33
+ ["Status", pill(batch.status)], ["Total amount", money(batch.amount, batch.currency)], ["Payments", String(batch.totalPayments)], ["Quote", quoteText(batch)],
34
+ ].map(([label, value]) => el("div", {}, [el("div", { class: "label", text: label }), el("div", { class: "value" }, value)])));
35
+
36
+ const actions = el("div", { class: "toolbar" }, [
37
+ searchBox("Search recipient, email or memo", "", (value) => { state.search = value; state.page = 1; void load(); }),
38
+ el("span", { class: "spacer" }),
39
+ open ? btn("Add payment", "secondary", { icon: "plus", onClick: () => openPaymentForm(batch, undefined, reload) }) : null,
40
+ open ? btn("Generate quote", "secondary", { onClick: () => generateQuote(batch, reload) }) : null,
41
+ btn("Review", "secondary", { onClick: () => reviewSummary(batch) }),
42
+ open ? btn("Process batch", "primary", { disabled: batch.totalPayments === 0, onClick: () => processBatch(batch, reload) }) : null,
43
+ open ? btn("", "secondary", { icon: "close", label: "Delete batch", title: "Delete batch", onClick: () => deleteBatch(batch) }) : null,
44
+ ]);
45
+
46
+ const table = dataTable([
47
+ { label: "Recipient", cell: (p) => el("div", { class: "who" }, [el("span", { class: "face", text: initials(p.recipient?.name), attrs: { "aria-hidden": "true" } }), el("div", {}, [el("a", { text: p.recipient?.name || p.recipient?.id, attrs: { href: `#/recipients/${encodeURIComponent(p.recipient?.id ?? "")}` } }), el("div", { class: "sub", text: p.recipient?.email ?? "" })])]) },
48
+ { label: "Amount", num: true, cell: (p) => money(p.sourceAmount, p.sourceCurrency) },
49
+ { label: "Recipient gets", num: true, cell: (p) => el("div", {}, [el("div", { text: p.targetAmount ? `${money(p.targetAmount, p.targetCurrency)} ${p.targetCurrency}` : "Quote required" }), p.sourceCurrency !== p.targetCurrency ? el("div", { class: "sub", text: `rate ${p.exchangeRate}` }) : null]) },
50
+ { label: "Fees", num: true, cell: (p) => money(p.fees, p.sourceCurrency) },
51
+ { label: "Method", cell: (p) => (p.payoutMethod ? human(p.payoutMethod) : "—") },
52
+ { label: "Status", cell: (p) => el("div", {}, [pill(p.status), p.failureMessage ? el("div", { class: "sub", text: p.failureMessage }) : null]) },
53
+ { label: "", cell: (p) => (open && p.status === "pending" ? el("div", { class: "row-actions" }, [
54
+ btn("Edit", "link", { small: true, onClick: () => openPaymentForm(batch, p, reload) }),
55
+ btn("Remove", "link", { small: true, onClick: () => removePayment(batch, p, reload) }),
56
+ ]) : "") },
57
+ ]);
58
+ const pagerHost = el("div");
59
+ const meta = el("div", { class: "method-sub", text: `${batch.id} · ${batch.currency} · created ${date(batch.createdAt, true)}${batch.sentAt ? ` · sent ${date(batch.sentAt, true)}` : ""}${batch.completedAt ? ` · completed ${date(batch.completedAt, true)}` : ""}` });
60
+ const heading = el("div", { class: "profile-head" }, [el("div", {}, [el("h2", { text: batch.description || "Untitled batch" }), meta])]);
61
+ const banner = batch.status === "failed" ? el("div", { class: "banner banner-error", attrs: { role: "status" } }, el("span", { text: "This batch failed to process. Payment rows show each failure message." }))
62
+ : !open ? el("div", { class: "banner banner-info" }, el("span", { text: "This batch has been sent. Payments can no longer be edited." })) : "";
63
+
64
+ host.replaceChildren(crumbs(batch.description || batch.id), heading, banner, strip, el("div", { class: "spacer-16" }), actions, el("section", { class: "card" }, [table.node, pagerHost]));
65
+
66
+ async function load() {
67
+ table.setLoading();
68
+ const args = { batchId: batch.id, page: state.page, pageSize: state.pageSize };
69
+ if (state.search) args.search = state.search;
70
+ try {
71
+ const value = await call("payments.list", args);
72
+ if (!view.isCurrent()) return;
73
+ table.setRows(value.payments, state.search ? stateBlock("search", "No payments match", "Try a different search.") : stateBlock("money", "No payments in this batch", open ? "Add a payment to get started." : ""));
74
+ pagerHost.replaceChildren(pager(value.meta, state.pageSize, (next) => { Object.assign(state, next); void load(); }));
75
+ } catch (error) {
76
+ if (!view.isCurrent()) return;
77
+ pagerHost.replaceChildren();
78
+ table.setError(error, () => void load());
79
+ }
80
+ }
81
+ await load();
82
+ }
83
+
84
+ function crumbs(label) {
85
+ return el("nav", { class: "crumbs", attrs: { "aria-label": "Breadcrumb" } }, [el("a", { text: "Payments", attrs: { href: "#/payments" } }), el("span", { text: "›" }), el("span", { text: label })]);
86
+ }
@@ -0,0 +1,78 @@
1
+ // Payments: batch list with status tabs, search, sorting, paging and Create batch.
2
+ import { app, go, setParams, setTitle } from "./app.js";
3
+ import { dataTable, pager, searchBox, stateBlock, tabs } from "./list.js";
4
+ import { btn, call, date, describe, el, field, input, money, newKey, openModal, pill, select, showFieldError, toast } from "./ui.js";
5
+
6
+ const STATUS_TABS = [["", "All"], ["open", "Open"], ["processing", "Processing"], ["complete", "Complete"], ["failed", "Failed"]];
7
+
8
+ export async function renderBatches(host, route, view) {
9
+ setTitle("Payments");
10
+ const state = { page: Number(route.params.page) || 1, pageSize: Number(route.params.pageSize) || 10, status: route.params.status ?? "", search: route.params.search ?? "", orderBy: route.params.orderBy ?? "createdAt", sortBy: route.params.sortBy ?? "desc" };
11
+ const table = dataTable([
12
+ { label: "Batch", cell: (b) => el("div", {}, [el("div", { text: b.description || "Untitled batch" }), el("div", { class: "sub mono", text: b.id })]) },
13
+ { label: "Payments", num: true, cell: (b) => String(b.totalPayments) },
14
+ { label: "Amount", key: "amount", num: true, cell: (b) => money(b.amount, b.currency) },
15
+ { label: "Currency", cell: (b) => b.currency },
16
+ { label: "Status", cell: (b) => pill(b.status) },
17
+ { label: "Created", key: "createdAt", cell: (b) => date(b.createdAt) },
18
+ { label: "Sent", key: "sentAt", cell: (b) => date(b.sentAt) },
19
+ ], { onRowClick: (b) => go("payments", b.id), sort: state, onSort: (key) => { state.sortBy = state.orderBy === key && state.sortBy === "asc" ? "desc" : "asc"; state.orderBy = key; state.page = 1; void load(); } });
20
+ const pagerHost = el("div");
21
+ const tabHost = el("div");
22
+ host.replaceChildren(el("div", {}, [
23
+ tabHost,
24
+ el("div", { class: "toolbar" }, [searchBox("Search batches", state.search, (value) => { state.search = value; state.page = 1; void load(); }), el("span", { class: "spacer" }), btn("Create batch", "primary", { icon: "plus", onClick: openCreateBatch })]),
25
+ el("section", { class: "card" }, [table.node, pagerHost]),
26
+ ]));
27
+ const drawTabs = () => tabHost.replaceChildren(tabs(STATUS_TABS, state.status, (value) => { state.status = value; state.page = 1; drawTabs(); void load(); }));
28
+ drawTabs();
29
+
30
+ async function load() {
31
+ setParams("payments", { status: state.status, search: state.search, page: state.page === 1 ? "" : state.page, pageSize: state.pageSize === 10 ? "" : state.pageSize, orderBy: state.orderBy === "createdAt" ? "" : state.orderBy, sortBy: state.sortBy === "desc" ? "" : state.sortBy });
32
+ table.setLoading();
33
+ const args = { page: state.page, pageSize: state.pageSize, orderBy: state.orderBy, sortBy: state.sortBy };
34
+ if (state.status) args.status = state.status;
35
+ if (state.search) args.search = state.search;
36
+ try {
37
+ const value = await call("batches.list", args);
38
+ if (!view.isCurrent()) return;
39
+ table.setRows(value.batches, state.search || state.status ? stateBlock("search", "No batches match", "Try another tab or search.") : stateBlock("money", "No payment batches yet", "Create a batch to pay recipients."));
40
+ pagerHost.replaceChildren(pager(value.meta, state.pageSize, (next) => { Object.assign(state, next); void load(); }));
41
+ } catch (error) {
42
+ if (!view.isCurrent()) return;
43
+ pagerHost.replaceChildren();
44
+ table.setError(error, () => void load());
45
+ }
46
+ }
47
+ await load();
48
+ }
49
+
50
+ function openCreateBatch() {
51
+ const form = el("form", { class: "form-grid", attrs: { novalidate: true } }, [
52
+ field("Description", input("description", "", { autocomplete: "off" }), { full: true }),
53
+ field("Source currency", select("currency", ["USD", "CAD", "EUR", "GBP", "AUD", "MXN"].map((c) => [c, c]), "USD")),
54
+ field("Tags", input("tags"), { hint: "Comma separated (optional)" }),
55
+ ]);
56
+ const formError = el("div", { class: "form-error", attrs: { role: "alert" } });
57
+ const save = btn("Create batch", "primary");
58
+ const key = newKey();
59
+ app.editing += 1;
60
+ const modal = openModal({ title: "Create payment batch", body: [el("p", { text: "A batch groups payments that are quoted and processed together." }), form, formError], actions: [btn("Cancel", "secondary", { onClick: () => modal.close() }), save], onClose: () => { app.editing -= 1; } });
61
+ save.addEventListener("click", async () => {
62
+ const d = Object.fromEntries(new FormData(form).entries());
63
+ const args = { currency: d.currency };
64
+ if (d.description.trim()) args.description = d.description.trim();
65
+ const tags = d.tags.split(",").map((t) => t.trim()).filter(Boolean);
66
+ if (tags.length) args.tags = tags;
67
+ save.disabled = true;
68
+ try {
69
+ const value = await call("batches.create", args, key);
70
+ modal.close();
71
+ toast("Batch created. Add payments to it.");
72
+ go("payments", value.batch.id);
73
+ } catch (error) {
74
+ if (!showFieldError(form, error)) formError.textContent = describe(error);
75
+ save.disabled = false;
76
+ }
77
+ });
78
+ }
@@ -0,0 +1,79 @@
1
+ // Overview: balance, sent value, recipients needing attention, batches awaiting processing.
2
+ import { app, go, setTitle } from "./app.js";
3
+ import { dataTable, errorBlock, stateBlock } from "./list.js";
4
+ import { icon } from "./icons.js";
5
+ import { btn, call, date, el, money, pill } from "./ui.js";
6
+
7
+ function kpiCard(label) {
8
+ const value = el("div", { class: "kpi-value" }, el("div", { class: "skeleton" }));
9
+ const sub = el("div", { class: "kpi-sub" });
10
+ const node = el("section", { class: "card kpi" }, [el("div", { class: "kpi-label", text: label }), value, sub]);
11
+ return { node, set(v, s) { value.textContent = v; sub.textContent = s ?? ""; }, fail(error) { node.replaceChildren(errorBlock(error)); } };
12
+ }
13
+
14
+ export async function renderDashboard(host, route, view) {
15
+ setTitle("Overview");
16
+ const balanceCard = kpiCard("Available balance");
17
+ const sentCard = kpiCard("Payments Sent (Value)");
18
+ const attentionCard = kpiCard("Recipients");
19
+ const table = dataTable([
20
+ { label: "Amount", cell: (b) => money(b.amount, b.currency) },
21
+ { label: "Batch", cell: (b) => el("div", {}, [el("div", { text: b.description || "Untitled batch" }), el("div", { class: "sub mono", text: b.id })]) },
22
+ { label: "Payments", num: true, cell: (b) => String(b.totalPayments) },
23
+ { label: "Status", cell: (b) => pill(b.status) },
24
+ { label: "Created", cell: (b) => date(b.createdAt) },
25
+ ], { onRowClick: (b) => go("payments", b.id) });
26
+ const openCard = el("section", { class: "card" }, [
27
+ el("div", { class: "card-head" }, [el("h2", { text: "Batches awaiting processing" }), el("span", { class: "spacer" }), btn("View all payments", "link", { onClick: () => go("payments", undefined, { status: "open" }) })]),
28
+ table.node,
29
+ ]);
30
+ const recentCard = el("section", { class: "card" }, [el("div", { class: "card-head" }, el("h2", { text: "Recently completed" })), el("div", { class: "card-body" }, el("div", { class: "skeleton" }))]);
31
+ host.replaceChildren(el("div", { class: "stack" }, [
32
+ el("div", { class: "grid-3" }, [balanceCard.node, sentCard.node, attentionCard.node]),
33
+ el("div", { class: "grid-2" }, [openCard, recentCard]),
34
+ ]));
35
+
36
+ const [balances, open, complete, total, attention] = await Promise.allSettled([
37
+ call("balances.list", { kind: "paymentrails" }),
38
+ call("batches.list", { status: "open", pageSize: 10, orderBy: "createdAt", sortBy: "desc" }),
39
+ call("batches.list", { status: "complete", pageSize: 1000, orderBy: "createdAt", sortBy: "desc" }),
40
+ call("recipients.list", { pageSize: 1 }),
41
+ call("recipients.list", { status: "incomplete", pageSize: 1 }),
42
+ ]);
43
+ if (!view.isCurrent()) return;
44
+
45
+ if (balances.status === "fulfilled") {
46
+ const primary = balances.value.balances.find((b) => b.primary) ?? balances.value.balances[0];
47
+ app.now = balances.value.serverTime;
48
+ if (primary) balanceCard.set(money(primary.amount, primary.currency), `${primary.currency} · pending ${money(primary.pendingAmount, primary.currency)}`);
49
+ else balanceCard.set("—", "No balances");
50
+ } else balanceCard.fail(balances.reason);
51
+
52
+ if (complete.status === "fulfilled") {
53
+ const batches = complete.value.batches;
54
+ const byCurrency = new Map();
55
+ for (const batch of batches) byCurrency.set(batch.currency, (byCurrency.get(batch.currency) ?? 0) + Math.round(Number(batch.amount) * 100));
56
+ const [currency, cents] = [...byCurrency.entries()].sort((a, b) => b[1] - a[1])[0] ?? ["USD", 0];
57
+ const count = batches.reduce((sum, b) => sum + b.totalPayments, 0);
58
+ const capped = complete.value.meta.records > batches.length ? ` (first ${batches.length} batches)` : "";
59
+ sentCard.set(money((cents / 100).toFixed(2), currency), `${count} payments in ${batches.length} completed batches${capped}`);
60
+ const body = recentCard.querySelector(".card-body");
61
+ body.replaceChildren(batches.length === 0 ? stateBlock("money", "No completed batches yet", "Processed batches appear here.") :
62
+ el("div", {}, batches.slice(0, 5).map((b) => el("div", { class: "method" }, [
63
+ el("div", { class: "method-icon done" }, icon("check")),
64
+ el("div", { class: "method-main" }, [el("div", { class: "method-title", text: b.description || b.id }), el("div", { class: "method-sub", text: `${b.totalPayments} payments · ${date(b.completedAt ?? b.updatedAt)}` })]),
65
+ el("strong", { text: money(b.amount, b.currency) }),
66
+ ]))));
67
+ } else {
68
+ sentCard.fail(complete.reason);
69
+ recentCard.querySelector(".card-body").replaceChildren(errorBlock(complete.reason));
70
+ }
71
+
72
+ if (total.status === "fulfilled" && attention.status === "fulfilled") {
73
+ attentionCard.set(String(total.value.meta.records), `${attention.value.meta.records} incomplete profiles need attention`);
74
+ } else attentionCard.fail(total.reason ?? attention.reason);
75
+
76
+ if (open.status === "fulfilled") {
77
+ table.setRows(open.value.batches, stateBlock("check", "Nothing waiting", "All batches have been processed."));
78
+ } else table.setError(open.reason, () => go("dashboard"));
79
+ }
@@ -0,0 +1,80 @@
1
+ // "Select payout method" form, modelled on the dashboard's payout method picker (Bank transfer / Venmo / PayPal / Check).
2
+ import { app } from "./app.js";
3
+ import { icon } from "./icons.js";
4
+ import { btn, call, describe, el, field, input, newKey, openModal, select, showFieldError, toast } from "./ui.js";
5
+
6
+ const TYPES = [["bank-transfer", "Bank transfer", "bank"], ["venmo", "Venmo", "phone"], ["paypal", "PayPal", "paypal"], ["check", "Check", "mail"]];
7
+ const CURRENCIES = ["USD", "CAD", "EUR", "GBP", "AUD", "MXN"].map((c) => [c, c]);
8
+
9
+ export function methodIcon(type) {
10
+ const found = TYPES.find(([t]) => t === type);
11
+ return icon(found ? found[2] : "bank");
12
+ }
13
+ export function methodLine(account) {
14
+ if (account.type === "bank-transfer") return [account.bankName, account.accountNum ?? account.iban, account.country].filter(Boolean).join(" · ");
15
+ if (account.type === "paypal") return account.emailAddress ?? "";
16
+ if (account.type === "venmo") return account.phoneNumber ?? "";
17
+ if (account.type === "check") return [account.mailing?.name, account.mailing?.city, account.mailing?.country].filter(Boolean).join(" · ");
18
+ return "";
19
+ }
20
+
21
+ export function openAddMethod(recipient, done) {
22
+ let type = "bank-transfer";
23
+ const form = el("form", { class: "form-grid", attrs: { novalidate: true } });
24
+ const choices = el("div", { class: "choice-row field-full", attrs: { role: "group", "aria-label": "Payout method" } });
25
+ const draw = () => {
26
+ choices.replaceChildren(...TYPES.map(([value, label, glyph]) => el("button", { class: "choice", attrs: { type: "button", "aria-pressed": String(value === type) }, on: { click: () => { type = value; draw(); } } }, [icon(glyph), el("span", { text: label })])));
27
+ const fields = [];
28
+ if (type === "bank-transfer") {
29
+ fields.push(field("Bank account currency", select("currency", CURRENCIES, recipient.primaryCurrency ?? "USD")), field("Bank country", input("country", recipient.address?.country ?? "US", { maxlength: 2 })),
30
+ field("Account holder name", input("accountHolderName", recipient.name), { full: true }), field("Routing / branch number", input("branchId")), field("Account number", input("accountNum", "", { inputmode: "numeric" })),
31
+ field("Bank ID (Canada)", input("bankId")), field("IBAN (instead of account number)", input("iban")));
32
+ } else if (type === "paypal") {
33
+ fields.push(field("PayPal email", input("emailAddress", recipient.email, { type: "email" }), { full: true }), field("Currency", select("currency", CURRENCIES, "USD")));
34
+ } else if (type === "venmo") {
35
+ fields.push(field("Venmo phone number", input("phoneNumber", recipient.phone ?? ""), { full: true, hint: "US numbers only" }));
36
+ } else {
37
+ const a = recipient.address ?? {};
38
+ fields.push(field("Name on check", input("mailing.name", recipient.name), { full: true }), field("Street", input("mailing.street1", a.street1), { full: true }),
39
+ field("City", input("mailing.city", a.city)), field("State", input("mailing.region", a.region)), field("ZIP", input("mailing.postal", a.postalCode)), field("Country", input("mailing.country", "US", { maxlength: 2 })));
40
+ }
41
+ form.replaceChildren(choices, ...fields);
42
+ };
43
+ draw();
44
+ const formError = el("div", { class: "form-error", attrs: { role: "alert" } });
45
+ const save = btn("Add payout method", "dark");
46
+ let key = newKey();
47
+ let keyType = type;
48
+ app.editing += 1;
49
+ const modal = openModal({
50
+ title: "Select payout method",
51
+ body: [el("p", { text: "Processing time and fees follow this synthetic merchant's schedule. No bank, PayPal, Venmo or mail network is contacted." }), form, formError],
52
+ wide: true,
53
+ actions: [btn("Cancel", "secondary", { onClick: () => modal.close() }), save],
54
+ onClose: () => { app.editing -= 1; },
55
+ });
56
+ save.addEventListener("click", async () => {
57
+ const d = Object.fromEntries(new FormData(form).entries());
58
+ const args = { recipientId: recipient.id, type };
59
+ const mailing = {};
60
+ for (const [name, raw] of Object.entries(d)) {
61
+ const value = String(raw).trim();
62
+ if (!value) continue;
63
+ if (name.startsWith("mailing.")) mailing[name.slice(8)] = name.endsWith("country") ? value.toUpperCase() : value;
64
+ else args[name] = name === "country" ? value.toUpperCase() : value;
65
+ }
66
+ if (type === "check") args.mailing = mailing;
67
+ if (keyType !== type) { key = newKey(); keyType = type; }
68
+ save.disabled = true;
69
+ formError.textContent = "";
70
+ try {
71
+ await call("recipient_accounts.create", args, key);
72
+ modal.close();
73
+ toast("Payout method added.");
74
+ done();
75
+ } catch (error) {
76
+ if (!showFieldError(form, error)) formError.textContent = describe(error);
77
+ save.disabled = false;
78
+ }
79
+ });
80
+ }