@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,373 @@
1
+ // Home, Balances, Transactions, Developers and Settings screens.
2
+ import { icon } from "./icons.js";
3
+ import { app, canRead, canWrite, indexAll, navigate, now, registerPage, setTitle } from "./store.js";
4
+ import { ToolError, button, call, cardChip, dateOnly, dateTime, el, humanize, idChip, intentStatus, link, money, refundStatus, statusBadge, text } from "./ui.js";
5
+ import { alert, amountWithStatus, chip, customerCell, dateCell, deniedPanel, empty, errorPanel, boundNote, kv, mergedList, listTools, load, notSimulated, pageHeader, pageInner, pagedList, section, table, tabs } from "./widgets.js";
6
+ import { chargeOf } from "./pages-payments.js";
7
+
8
+ const DAY = 86400;
9
+
10
+ function dayStart(seconds) {
11
+ return seconds - (seconds % DAY);
12
+ }
13
+
14
+ /** Line chart drawn with SVG DOM nodes (no markup strings), the Dashboard's purple line over a dotted baseline. */
15
+ function lineChart(points, { height = 110, format }) {
16
+ const svgNs = "http://www.w3.org/2000/svg";
17
+ const width = 600;
18
+ const pad = 6;
19
+ const svg = document.createElementNS(svgNs, "svg");
20
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
21
+ svg.setAttribute("preserveAspectRatio", "none");
22
+ svg.setAttribute("class", "chart");
23
+ svg.setAttribute("role", "img");
24
+ svg.setAttribute("aria-label", points.map((point) => `${point.label}: ${format ? format(point.value) : point.value}`).join(", "));
25
+ const max = Math.max(1, ...points.map((point) => point.value));
26
+ const step = points.length > 1 ? (width - pad * 2) / (points.length - 1) : 0;
27
+ const coords = points.map((point, index) => [pad + index * step, height - pad - (point.value / max) * (height - pad * 2 - 4)]);
28
+ const grid = document.createElementNS(svgNs, "line");
29
+ grid.setAttribute("x1", "0");
30
+ grid.setAttribute("x2", String(width));
31
+ grid.setAttribute("y1", String(height - pad));
32
+ grid.setAttribute("y2", String(height - pad));
33
+ grid.setAttribute("class", "chart-line");
34
+ svg.append(grid);
35
+ const area = document.createElementNS(svgNs, "path");
36
+ area.setAttribute("d", `M ${coords[0][0]} ${height - pad} ${coords.map(([x, y]) => `L ${x} ${y}`).join(" ")} L ${coords[coords.length - 1][0]} ${height - pad} Z`);
37
+ area.setAttribute("class", "chart-area");
38
+ svg.append(area);
39
+ const line = document.createElementNS(svgNs, "polyline");
40
+ line.setAttribute("points", coords.map(([x, y]) => `${x},${y}`).join(" "));
41
+ line.setAttribute("class", "chart-stroke");
42
+ line.setAttribute("vector-effect", "non-scaling-stroke");
43
+ svg.append(line);
44
+ coords.forEach(([x, y], index) => {
45
+ const dot = document.createElementNS(svgNs, "circle");
46
+ dot.setAttribute("cx", String(x));
47
+ dot.setAttribute("cy", String(y));
48
+ dot.setAttribute("r", "3");
49
+ dot.setAttribute("class", "chart-dot");
50
+ dot.setAttribute("vector-effect", "non-scaling-stroke");
51
+ const title = document.createElementNS(svgNs, "title");
52
+ title.textContent = `${points[index].label}: ${format ? format(points[index].value) : points[index].value}`;
53
+ dot.append(title);
54
+ svg.append(dot);
55
+ });
56
+ const axis = el("div", { class: "chart-axis" }, [el("span", { text: points[0]?.label ?? "" }), el("span", { text: points[points.length - 1]?.label ?? "" })]);
57
+ return el("div", {}, [svg, axis]);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------------------------
61
+ // Home
62
+ // ---------------------------------------------------------------------------------------------
63
+
64
+ registerPage("home", async (host) => {
65
+ setTitle("Home");
66
+ const account = app.context?.account;
67
+ const currency = account?.default_currency ?? "usd";
68
+ const updated = `Updated ${dateTime(now(), now())}`;
69
+ const header = pageHeader("Today");
70
+ const todayMain = el("div", { class: "card today-main" });
71
+ const balanceCard = el("div", { class: "card" }, [el("div", { class: "home-card-title" }, [text(`${currency.toUpperCase()} balance`), link("#/balances", "View", { class: "link" })])]);
72
+ const payoutsCard = el("div", { class: "card" }, [el("div", { class: "home-card-title" }, [text("Payouts")]), el("div", { class: "card-note", text: "Payouts are not simulated by this synthetic account." })]);
73
+ const todayHost = el("div", { class: "today-grid" }, [todayMain, el("div", { class: "today-side" }, [balanceCard, payoutsCard])]);
74
+ const overviewHost = el("div");
75
+ const recentHost = el("div");
76
+ const addWidget = button("Add", "secondary", { size: "sm", icon: "plus" });
77
+ const editWidgets = button("Edit", "secondary", { size: "sm", icon: "edit" });
78
+ const widgetNote = "The overview shows a fixed set of widgets computed from charges, refunds and customers in this Tool.";
79
+ addWidget.addEventListener("click", () => notSimulated("Add widgets", widgetNote));
80
+ editWidgets.addEventListener("click", () => notSimulated("Edit widgets", widgetNote));
81
+ const controls = el("div", { class: "overview-controls" }, [
82
+ chip("Date range", { active: true, value: "Last 7 days", onClick: () => notSimulated("Date range", "The overview always covers the last 7 virtual days.") }),
83
+ chip("Daily", { active: true, value: "Daily", onClick: () => notSimulated("Granularity", "The overview charts one point per virtual day.") }),
84
+ chip("Compare", { onClick: () => notSimulated("Comparison period", "Period comparisons are not simulated.") }),
85
+ ]);
86
+ host.replaceChildren(pageInner(header, todayHost, section("Your overview", [controls, overviewHost], { actions: [addWidget, editWidgets] }), section("Recent payments", recentHost, { actions: [link("#/payments", "View all", { class: "btn btn-link" })] })));
87
+
88
+ if (!canRead("balance")) balanceCard.append(el("div", { class: "muted", text: "This key cannot read the balance." }));
89
+ else {
90
+ try {
91
+ const balance = await call("balance.retrieve", {});
92
+ const available = balance.available.find((row) => row.currency === currency)?.amount ?? 0;
93
+ const pending = balance.pending.find((row) => row.currency === currency)?.amount ?? 0;
94
+ balanceCard.append(el("div", { class: "card-value", text: money(available + pending, currency) }), el("div", { class: "card-note", text: `${money(available, currency)} available · ${money(pending, currency)} pending` }));
95
+ const others = balance.available.filter((row) => row.currency !== currency && row.amount !== 0);
96
+ if (others.length > 0) balanceCard.append(el("div", { class: "card-note", text: `Also ${others.map((row) => money(row.amount, row.currency)).join(", ")} available` }));
97
+ } catch (error) {
98
+ balanceCard.append(el("div", { class: "muted", text: error instanceof ToolError && error.is("PERMISSION_DENIED") ? "This key cannot read the balance." : "Balance unavailable right now." }));
99
+ }
100
+ }
101
+
102
+ const overview = el("div", { class: "overview-row" });
103
+ overviewHost.append(overview);
104
+ if (!canRead("charges")) {
105
+ todayMain.append(el("div", { class: "home-card-title" }, [text("Gross volume")]), el("div", { class: "muted", text: "This key cannot read charges." }));
106
+ overview.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "charges" }), "charges"));
107
+ } else {
108
+ try {
109
+ const since = dayStart(now()) - 6 * DAY;
110
+ const chargeIndex = await indexAll("charges.list", { created: { gte: since - DAY } });
111
+ const charges = chargeIndex.items;
112
+ // The index is bounded (store.indexAll); past the bound every figure is a lower bound and says so.
113
+ const plus = chargeIndex.complete ? "" : "+";
114
+ const partial = chargeIndex.complete ? undefined : `Partial: first ${charges.length.toLocaleString("en-US")} charges of this window`;
115
+ const counted = (charge) => charge.status === "succeeded" && charge.captured;
116
+ const days = Array.from({ length: 7 }, (_, index) => since + index * DAY);
117
+ const volume = days.map((day) => ({ label: dateOnly(day, now()), value: charges.filter((charge) => counted(charge) && charge.currency === currency && dayStart(charge.created) === day).reduce((sum, charge) => sum + charge.amount_captured, 0) }));
118
+ const counts = days.map((day) => ({ label: dateOnly(day, now()), value: charges.filter((charge) => counted(charge) && dayStart(charge.created) === day).length }));
119
+ const failed = charges.filter((charge) => charge.status === "failed" && charge.created >= since).length;
120
+ const gross = volume.reduce((sum, point) => sum + point.value, 0);
121
+ const successful = counts.reduce((sum, point) => sum + point.value, 0);
122
+ // Today: cumulative gross volume per hour from 12:00 AM (UTC) up to the world's virtual now, beside yesterday's total.
123
+ const today = dayStart(now());
124
+ const hours = Math.max(1, Math.floor((now() - today) / 3600) + 1);
125
+ let running = 0;
126
+ const todaySeries = Array.from({ length: hours + 1 }, (_, index) => {
127
+ const end = Math.min(now(), today + index * 3600);
128
+ running = charges.filter((charge) => counted(charge) && charge.currency === currency && charge.created >= today && charge.created <= end).reduce((sum, charge) => sum + charge.amount_captured, 0);
129
+ return { label: index === 0 ? "12:00 AM" : dateTime(end, now()).split(", ").pop(), value: running };
130
+ });
131
+ const yesterday = charges.filter((charge) => counted(charge) && charge.currency === currency && dayStart(charge.created) === today - DAY).reduce((sum, charge) => sum + charge.amount_captured, 0);
132
+ todayMain.append(
133
+ el("div", { class: "today-stats" }, [
134
+ el("div", {}, [el("div", { class: "today-stat-label", text: "Gross volume" }), el("div", { class: "today-stat-value", text: money(running, currency) + plus })]),
135
+ el("div", {}, [el("div", { class: "today-stat-label", text: "Yesterday" }), el("div", { class: "today-stat-value muted", text: money(yesterday, currency) })]),
136
+ ]),
137
+ lineChart(todaySeries, { height: 160, format: (value) => money(value, currency) }),
138
+ ...[boundNote(chargeIndex, "charges of the last 8 days", "#/transactions?tab=charges")].filter(Boolean),
139
+ );
140
+ const footer = (href, note) => el("div", { class: "card-footer" }, [el("span", { text: note ?? updated }), href ? link(href, "More details", { class: "link" }) : null]);
141
+ const widget = (title, big, delta, series, href, format, note) => el("div", { class: "card" }, [el("div", { class: "home-card-title" }, [text(title)]), el("div", { class: "stat-row" }, [el("span", { class: "stat-big", text: big }), el("span", { class: "stat-delta", text: delta })]), lineChart(series, { format }), footer(href, note)]);
142
+ const moneyFormat = (value) => money(value, currency);
143
+ overview.append(
144
+ widget("Gross volume", money(gross, currency) + plus, currency.toUpperCase(), volume, "#/transactions", moneyFormat, partial),
145
+ widget("Successful payments", String(successful) + plus, failed > 0 ? `${failed}${plus} failed` : plus ? "failures not fully counted" : "no failures", counts, "#/payments?tab=succeeded", undefined, partial),
146
+ );
147
+ // Failed payments per day, from the same charges.
148
+ const failedSeries = days.map((day) => ({ label: dateOnly(day, now()), value: charges.filter((charge) => charge.status === "failed" && dayStart(charge.created) === day).length }));
149
+ overview.append(widget("Failed payments", String(failedSeries.reduce((sum, point) => sum + point.value, 0)) + plus, "declined charges", failedSeries, "#/payments?tab=failed", undefined, partial));
150
+ // Net volume: captured charges minus succeeded refunds per day (Stripe fees are not modelled, so no fee is subtracted).
151
+ const netCard = el("div");
152
+ overview.append(netCard);
153
+ if (!canRead("refunds")) netCard.replaceWith(el("div", { class: "card" }, [el("div", { class: "home-card-title" }, [text("Net volume")]), el("div", { class: "muted", text: "This key cannot read refunds." })]));
154
+ else {
155
+ try {
156
+ const refundIndex = await indexAll("refunds.list", { created: { gte: since } });
157
+ const refunds = refundIndex.items.filter((refund) => refund.status === "succeeded" && refund.currency === currency);
158
+ const net = volume.map((point, index) => ({ label: point.label, value: point.value - refunds.filter((refund) => dayStart(refund.created) === days[index]).reduce((sum, refund) => sum + refund.amount, 0) }));
159
+ const refunded = refunds.reduce((sum, refund) => sum + refund.amount, 0);
160
+ netCard.replaceWith(widget("Net volume", money(net.reduce((sum, point) => sum + point.value, 0), currency), `${money(refunded, currency)}${refundIndex.complete ? "" : "+"} refunded · no fees modelled`, net, "#/balances", moneyFormat, refundIndex.complete && chargeIndex.complete ? undefined : `Partial: first ${refundIndex.items.length.toLocaleString("en-US")} refunds and ${charges.length.toLocaleString("en-US")} charges of this window`));
161
+ } catch (error) {
162
+ netCard.replaceWith(el("div", { class: "card" }, [el("div", { class: "home-card-title" }, [text("Net volume")]), el("div", { class: "muted", text: error instanceof ToolError && error.is("PERMISSION_DENIED") ? "This key cannot read refunds." : "Unavailable right now." })]));
163
+ }
164
+ }
165
+ // New customers per day.
166
+ const customersCard = el("div");
167
+ overview.append(customersCard);
168
+ if (!canRead("customers")) customersCard.replaceWith(el("div", { class: "card" }, [el("div", { class: "home-card-title" }, [text("New customers")]), el("div", { class: "muted", text: "This key cannot read customers." })]));
169
+ else {
170
+ try {
171
+ const customerIndex = await indexAll("customers.list", { created: { gte: since } });
172
+ const created = customerIndex.items;
173
+ const series = days.map((day) => ({ label: dateOnly(day, now()), value: created.filter((customer) => dayStart(customer.created) === day).length }));
174
+ customersCard.replaceWith(widget("New customers", String(created.length) + (customerIndex.complete ? "" : "+"), "created in the last 7 days", series, "#/customers", undefined, customerIndex.complete ? undefined : `Partial: first ${created.length.toLocaleString("en-US")} customers of this window`));
175
+ } catch (error) {
176
+ customersCard.replaceWith(el("div", { class: "card" }, [el("div", { class: "home-card-title" }, [text("New customers")]), el("div", { class: "muted", text: error instanceof ToolError && error.is("PERMISSION_DENIED") ? "This key cannot read customers." : "Unavailable right now." })]));
177
+ }
178
+ }
179
+ // Widgets that need data the Tool does not model.
180
+ for (const [title, detail] of [["Dispute activity", "Disputes are not modelled by this synthetic account."], ["MRR", "Billing analytics (MRR, churn, trials) are not computed by this Tool."]]) {
181
+ const card = el("button", { class: "card card-not-simulated", attrs: { type: "button", "aria-label": `${title} (not simulated)` }, title: `${title} (not simulated)` }, [el("div", { class: "home-card-title" }, [text(title)]), el("div", { class: "card-placeholder" }, [icon("flask"), el("span", { text: "Not simulated by this Tool" })])]);
182
+ card.addEventListener("click", () => notSimulated(title, detail));
183
+ overview.append(card);
184
+ }
185
+ } catch (error) {
186
+ todayMain.append(el("div", { class: "muted", text: "Unavailable right now." }));
187
+ overview.append(errorPanel(error, () => navigate(location.hash)));
188
+ }
189
+ }
190
+
191
+ // Recent payments
192
+ if (!canRead("payment_intents")) recentHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "payment_intents" }), "payment_intents"));
193
+ else {
194
+ await load(recentHost, async () => {
195
+ const page = await call("payment_intents.list", { limit: 5, expand: ["data.latest_charge", "data.customer"] });
196
+ if (page.data.length === 0) return empty("No payments yet", "Payments appear here as soon as your integration or the Create button makes one.", canWrite("payment_intents") ? button("Create payment", "primary", { icon: "plus", onClick: () => navigate("#/payments/new") }) : undefined);
197
+ return table({
198
+ columns: [
199
+ { label: "Amount", class: "nowrap", render: (intent) => amountWithStatus(intent.amount, intent.currency, statusBadge(intentStatus(intent, chargeOf(intent)))) },
200
+ { label: "Description", class: "truncate", render: (intent) => intent.description },
201
+ { label: "Customer", class: "truncate", render: (intent) => customerCell(intent.customer) },
202
+ { label: "Date", class: "nowrap", render: (intent) => dateCell(intent.created) },
203
+ ],
204
+ rows: page.data,
205
+ href: (intent) => `#/payments/${intent.id}`,
206
+ onOpen: (intent) => navigate(`#/payments/${intent.id}`),
207
+ });
208
+ });
209
+ }
210
+ });
211
+
212
+ // ---------------------------------------------------------------------------------------------
213
+ // Balances
214
+ // ---------------------------------------------------------------------------------------------
215
+
216
+ registerPage("balances", async (host) => {
217
+ setTitle("Balances");
218
+ const header = pageHeader("Balances", { subtitle: "Available funds are captured charges older than two virtual days, minus their refunds; newer captures are pending." });
219
+ const balanceHost = el("div");
220
+ const activityHost = el("div");
221
+ host.replaceChildren(pageInner(header, balanceHost, section("Recent activity", activityHost, { actions: [link("#/transactions", "View all transactions", { class: "btn btn-link" })] })));
222
+ if (!canRead("balance")) balanceHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "balance" }), "balance"));
223
+ else {
224
+ await load(balanceHost, async () => {
225
+ const balance = await call("balance.retrieve", {});
226
+ const currencies = [...new Set([...balance.available.map((row) => row.currency), ...balance.pending.map((row) => row.currency)])];
227
+ if (currencies.length === 0) return empty("No balance yet", "Captured payments build up a balance per currency.");
228
+ const cards = el("div", { class: "cards" });
229
+ for (const currency of currencies) {
230
+ const available = balance.available.find((row) => row.currency === currency)?.amount ?? 0;
231
+ const pending = balance.pending.find((row) => row.currency === currency)?.amount ?? 0;
232
+ cards.append(el("div", { class: "card" }, [el("div", { class: "card-label", text: `${currency.toUpperCase()} balance` }), el("div", { class: "card-value", text: money(available + pending, currency) }), el("div", { class: "balance-row" }, [el("span", { class: "muted", text: "Available" }), el("span", { text: money(available, currency) })]), el("div", { class: "balance-row" }, [el("span", { class: "muted", text: "Pending" }), el("span", { text: money(pending, currency) })]), el("div", { class: "balance-row" }, [el("span", { class: "muted", text: "Reserved" }), el("span", { text: money(balance.connect_reserved.find((row) => row.currency === currency)?.amount ?? 0, currency) })])]));
233
+ }
234
+ return [cards, alert("Payouts are not modelled by this synthetic account; the balance only grows and shrinks with captures and refunds.", "neutral", { iconName: "info" })];
235
+ });
236
+ }
237
+ await renderActivity(activityHost, 10);
238
+ });
239
+
240
+ async function renderActivity(host, limit) {
241
+ if (!canRead("charges")) {
242
+ host.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "charges" }), "charges"));
243
+ return;
244
+ }
245
+ await load(host, async () => {
246
+ const charges = (await call("charges.list", { limit })).data;
247
+ let refunds = [];
248
+ if (canRead("refunds")) {
249
+ try {
250
+ refunds = (await call("refunds.list", { limit })).data;
251
+ } catch {
252
+ refunds = [];
253
+ }
254
+ }
255
+ const rows = [...charges.map((charge) => ({ kind: "charge", at: charge.created, record: charge })), ...refunds.map((refund) => ({ kind: "refund", at: refund.created, record: refund }))].sort((left, right) => right.at - left.at).slice(0, limit);
256
+ if (rows.length === 0) return empty("No activity yet");
257
+ return transactionsTable(rows);
258
+ });
259
+ }
260
+
261
+ function transactionsTable(rows) {
262
+ return table({
263
+ columns: [
264
+ { label: "Type", class: "nowrap", render: (row) => (row.kind === "charge" ? el("span", { class: "amount-status" }, [icon("payments"), text(row.record.captured ? "Charge" : row.record.status === "failed" ? "Failed charge" : "Authorization")]) : el("span", { class: "amount-status" }, [icon("arrow-return"), text("Refund")])) },
265
+ { label: "Amount", class: "nowrap", render: (row) => (row.kind === "charge" ? amountWithStatus(row.record.amount, row.record.currency, statusBadge(chargeStatus(row.record))) : amountWithStatus(-row.record.amount, row.record.currency, statusBadge(refundStatus(row.record)))) },
266
+ { label: "Description", class: "truncate", render: (row) => (row.kind === "charge" ? row.record.description : row.record.reason ? humanize(row.record.reason) : "Refund") },
267
+ { label: "Payment", render: (row) => link(`#/payments/${row.record.payment_intent}`, row.record.payment_intent ?? "—", { class: "link-quiet" }) },
268
+ { label: "Method", class: "nowrap", render: (row) => (row.kind === "charge" && row.record.payment_method_details?.card ? cardChip({ card: row.record.payment_method_details.card }) : null) },
269
+ { label: "Date", class: "nowrap", render: (row) => dateCell(row.at) },
270
+ ],
271
+ rows,
272
+ href: (row) => `#/payments/${row.record.payment_intent}`,
273
+ onOpen: (row) => navigate(`#/payments/${row.record.payment_intent}`),
274
+ });
275
+ }
276
+
277
+ function chargeStatus(charge) {
278
+ if (charge.status === "failed") return { label: "Failed", tone: "red", icon: "x-circle" };
279
+ if (charge.refunded) return { label: "Refunded", tone: "gray", icon: "arrow-return" };
280
+ if (charge.amount_refunded > 0) return { label: "Partial refund", tone: "gray", icon: "arrow-return" };
281
+ if (!charge.captured) return { label: "Uncaptured", tone: "yellow", icon: "clock" };
282
+ return { label: "Succeeded", tone: "green", icon: "check-circle" };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------------------------
286
+ // Transactions (charges + refunds)
287
+ // ---------------------------------------------------------------------------------------------
288
+
289
+ registerPage("transactions", async (host, route) => {
290
+ setTitle("Transactions");
291
+ const tab = route.params.tab ?? "all";
292
+ const header = pageHeader("Transactions");
293
+ const tabsHost = tabs([{ key: "all", label: "All activity" }, { key: "charges", label: "Charges" }, { key: "refunds", label: "Refunds" }], tab, (next) => navigate(`#/transactions${next === "all" ? "" : `?tab=${next}`}`));
294
+ const listHost = el("div");
295
+ const filters = el("div", { class: "filters" }, [chip("Date and time", { onClick: () => notSimulated("Date filter") }), chip("Amount", { onClick: () => notSimulated("Amount filter") }), chip("Type", { onClick: () => notSimulated("Type filter", "Use the tabs above to separate charges from refunds.") }), chip("More filters", { onClick: () => notSimulated("More filters") }), listTools()]);
296
+ host.replaceChildren(pageInner(header, tabsHost, filters, listHost));
297
+ if (tab === "refunds") {
298
+ if (!canRead("refunds")) {
299
+ listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "refunds" }), "refunds"));
300
+ return;
301
+ }
302
+ listHost.append(pagedList({ operation: "refunds.list", args: {}, limit: 20, render: (refunds) => transactionsTable(refunds.map((refund) => ({ kind: "refund", at: refund.created, record: refund }))), emptyNode: () => empty("No refunds yet") }).element);
303
+ return;
304
+ }
305
+ if (!canRead("charges")) {
306
+ listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "charges" }), "charges"));
307
+ return;
308
+ }
309
+ if (tab === "charges") {
310
+ listHost.append(pagedList({ operation: "charges.list", args: {}, limit: 20, render: (charges) => transactionsTable(charges.map((charge) => ({ kind: "charge", at: charge.created, record: charge }))), emptyNode: () => empty("No charges yet") }).element);
311
+ return;
312
+ }
313
+ const streams = [{ operation: "charges.list", args: {}, map: (charge) => ({ kind: "charge", at: charge.created, record: charge }) }];
314
+ if (canRead("refunds")) streams.push({ operation: "refunds.list", args: {}, map: (refund) => ({ kind: "refund", at: refund.created, record: refund }) });
315
+ listHost.append(mergedList({ streams, render: transactionsTable, emptyNode: () => empty("No transactions yet", "Charges and refunds appear here."), note: "charges and refunds, newest first" }).element);
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------------------------
319
+ // Developers
320
+ // ---------------------------------------------------------------------------------------------
321
+
322
+ const ROUTES = [
323
+ ["GET", "/v1/balance"],
324
+ ["POST", "/v1/customers"], ["GET", "/v1/customers"], ["GET", "/v1/customers/{id}"], ["POST", "/v1/customers/{id}"], ["GET", "/v1/customers/{id}/payment_methods"],
325
+ ["GET", "/v1/payment_methods"], ["POST", "/v1/payment_methods/{id}/attach"], ["POST", "/v1/payment_methods/{id}/detach"],
326
+ ["POST", "/v1/payment_intents"], ["GET", "/v1/payment_intents"], ["GET", "/v1/payment_intents/{id}"], ["POST", "/v1/payment_intents/{id}/confirm"], ["POST", "/v1/payment_intents/{id}/capture"], ["POST", "/v1/payment_intents/{id}/cancel"],
327
+ ["GET", "/v1/charges"], ["GET", "/v1/charges/{id}"], ["POST", "/v1/refunds"], ["GET", "/v1/refunds"],
328
+ ["POST", "/v1/products"], ["GET", "/v1/products"], ["GET", "/v1/products/{id}"], ["POST", "/v1/products/{id}"], ["POST", "/v1/prices"], ["GET", "/v1/prices"], ["GET", "/v1/prices/{id}"],
329
+ ["POST", "/v1/invoiceitems"], ["POST", "/v1/invoices"], ["GET", "/v1/invoices"], ["GET", "/v1/invoices/{id}"], ["POST", "/v1/invoices/{id}/finalize"], ["POST", "/v1/invoices/{id}/pay"], ["POST", "/v1/invoices/{id}/void"],
330
+ ["POST", "/v1/subscriptions"], ["GET", "/v1/subscriptions"], ["GET", "/v1/subscriptions/{id}"], ["POST", "/v1/subscriptions/{id}"], ["DELETE", "/v1/subscriptions/{id}"],
331
+ ];
332
+
333
+ registerPage("developers", async (host) => {
334
+ setTitle("Developers");
335
+ const context = app.context;
336
+ const header = pageHeader("Developers", { subtitle: "How to point an integration at this synthetic account." });
337
+ const overview = section("Overview", kv([
338
+ ["API version", el("code", { class: "id-code", text: context?.api_version ?? "—" })],
339
+ ["Mode", context?.livemode ? "Live" : "Test (sandbox)"],
340
+ ["Account", context?.account ? idChip(context.account.id) : null],
341
+ ["Virtual time", context ? dateTime(context.now, undefined) : null],
342
+ ["Secret key", el("span", { class: "muted", text: "The HTTP token printed by `firedrill serve` — never shown in this app." })],
343
+ ]));
344
+ const snippet = section("Connect an SDK", [
345
+ el("p", { class: "muted", text: "Point the official SDK at the base URL and token that `firedrill serve` prints. Requests are form-encoded /v1 calls with a Bearer secret key and an optional Idempotency-Key header, answered with Stripe-shaped objects and error envelopes." }),
346
+ el("pre", { class: "code-block", text: "# stripe-node\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { host, port, protocol }); // parsed from STRIPE_API_BASE\n\n# stripe-python\nstripe.api_base = os.environ[\"STRIPE_API_BASE\"]\n\n# curl\ncurl \"$STRIPE_API_BASE/v1/customers\" -H \"Authorization: Bearer $STRIPE_SECRET_KEY\" -d name=\"Jane Diaz\"" }),
347
+ ]);
348
+ const routes = section("Supported endpoints", el("ul", { class: "route-list inline-list" }, ROUTES.map(([method, path]) => el("li", {}, [el("span", { class: `method method-${method.toLowerCase()}`, text: method }), el("code", { text: path })]))), { count: ROUTES.length });
349
+ const mcp = section("MCP", el("p", { class: "muted", text: "The same operations are exposed as MCP tools under their canonical names (stripe.<operation>) and as the per-resource tool names of the Stripe agent toolkit (create_customer, list_invoices, create_refund, …)." }));
350
+ host.replaceChildren(pageInner(header, alert("Everything on this page is served by Firedrill: no request leaves the machine, no card network is contacted and no e-mail is sent.", "info"), overview, snippet, routes, mcp));
351
+ });
352
+
353
+ // ---------------------------------------------------------------------------------------------
354
+ // Settings (business details)
355
+ // ---------------------------------------------------------------------------------------------
356
+
357
+ registerPage("settings", async (host) => {
358
+ setTitle("Settings");
359
+ const account = app.context?.account;
360
+ const header = pageHeader("Settings", { subtitle: "Business details of the simulated account (read-only in this synthetic account)." });
361
+ host.replaceChildren(pageInner(header, section("Business details", kv([
362
+ ["Business name", account?.business_name],
363
+ ["Account ID", account ? idChip(account.id) : null],
364
+ ["Country", account?.country],
365
+ ["Default currency", account?.default_currency?.toUpperCase()],
366
+ ["Statement descriptor", account?.statement_descriptor],
367
+ ["Support email", account?.support_email],
368
+ ])), section("Your key", kv([
369
+ ["Actor", app.connection?.actorId],
370
+ ["Permissions", el("div", { class: "inline-list" }, Object.entries(app.context?.permissions ?? {}).map(([group, level]) => el("div", { class: "balance-row" }, [el("span", { text: humanize(group) }), el("span", { class: level === "none" ? "muted" : "", text: level })])))],
371
+ ]))));
372
+ });
373
+