@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,502 @@
1
+ // Payments list, payment detail, "Create a payment" page and the Refunds list. Every action is a Tool operation.
2
+ import { icon } from "./icons.js";
3
+ import { app, canRead, canWrite, indexAll, invalidateCache, navigate, now, registerPage, routeHash, setTitle } from "./store.js";
4
+ import { ToolError, action, amountString, badge, button, call, cardChip, confirmDialog, dateTime, el, field, humanize, idChip, input, intentStatus, key, link, money, moneyCell, openMenu, openModal, refundStatus, select, statusBadge, text, toast } from "./ui.js";
5
+ import { alert, amountInput, amountWithStatus, applyError, chip, customerCell, customerPicker, dateCell, deniedPanel, empty, errorPanel, boundNote, indexedList, kv, load, metaBar, methodPicker, listTools, notSimulated, pageHeader, pageInner, pagedList, section, statTabs, submitModal, table, timeline } from "./widgets.js";
6
+
7
+ const PAYMENT_TABS = [
8
+ { key: "all", label: "All" },
9
+ { key: "succeeded", label: "Succeeded" },
10
+ { key: "refunded", label: "Refunded" },
11
+ { key: "uncaptured", label: "Uncaptured" },
12
+ { key: "failed", label: "Failed" },
13
+ { key: "incomplete", label: "Incomplete" },
14
+ { key: "canceled", label: "Canceled" },
15
+ ];
16
+
17
+ const LIST_EXPAND = ["data.latest_charge", "data.customer"];
18
+
19
+ function chargeOf(intent) {
20
+ return typeof intent.latest_charge === "object" && intent.latest_charge !== null ? intent.latest_charge : undefined;
21
+ }
22
+
23
+ function paymentRows(intents) {
24
+ return table({
25
+ select: { label: "payments", rowLabel: (intent) => `payment ${intent.id}` },
26
+ columns: [
27
+ { label: "Amount", class: "nowrap", render: (intent) => amountWithStatus(intent.amount, intent.currency, statusBadge(intentStatus(intent, chargeOf(intent)))) },
28
+ { label: "Payment method", class: "nowrap", render: (intent) => methodCell(intent) },
29
+ { label: "Description", class: "truncate", render: (intent) => intent.description ?? el("span", { class: "muted", text: "—" }) },
30
+ { label: "Customer", class: "truncate", render: (intent) => customerCell(intent.customer) },
31
+ { label: "Date", class: "nowrap", render: (intent) => dateCell(intent.created) },
32
+ { label: "", class: "actions", render: (intent) => rowMenu(intent) },
33
+ ],
34
+ rows: intents,
35
+ href: (intent) => `#/payments/${intent.id}`,
36
+ onOpen: (intent) => navigate(`#/payments/${intent.id}`),
37
+ });
38
+ }
39
+
40
+ function methodCell(intent) {
41
+ const charge = chargeOf(intent);
42
+ const card = charge?.payment_method_details?.card;
43
+ if (card) return cardChip({ card });
44
+ if (intent.last_payment_error?.payment_method?.card) return cardChip(intent.last_payment_error.payment_method);
45
+ return el("span", { class: "muted", text: "—" });
46
+ }
47
+
48
+ function rowMenu(intent) {
49
+ const trigger = button(undefined, "ghost", { icon: "more", size: "sm", ariaLabel: `Actions for ${intent.id}`, class: "btn-icon-only" });
50
+ trigger.addEventListener("click", (event) => {
51
+ event.stopPropagation();
52
+ openMenu(trigger, [
53
+ { label: "View payment details", icon: "external", onSelect: () => navigate(`#/payments/${intent.id}`) },
54
+ { label: "Copy payment ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(intent.id).then(() => toast("Copied to clipboard")) },
55
+ ...(intent.customer ? [{ label: "View customer", icon: "customers", onSelect: () => navigate(`#/customers/${typeof intent.customer === "string" ? intent.customer : intent.customer.id}`) }] : []),
56
+ ], { align: "end" });
57
+ });
58
+ return trigger;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------------------------
62
+ // Payments list
63
+ // ---------------------------------------------------------------------------------------------
64
+
65
+ registerPage("payments", async (host, route) => {
66
+ setTitle("Payments");
67
+ const tab = route.params.tab ?? "all";
68
+ const actions = [];
69
+ if (canWrite("payment_intents")) actions.push(button("Create payment", "primary", { icon: "plus", onClick: () => navigate("#/payments/new") }));
70
+ const headerNode = pageHeader("Payments", { actions });
71
+ const tabsHost = el("div");
72
+ const filtersHost = el("div", { class: "filters" });
73
+ const listHost = el("div");
74
+ host.replaceChildren(pageInner(headerNode, tabsHost, filtersHost, listHost));
75
+ if (!canRead("payment_intents")) {
76
+ listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "payment_intents" }), "payment_intents"));
77
+ return;
78
+ }
79
+
80
+ const dateFilter = route.params.since;
81
+ const created = dateFilter ? { gte: Number(dateFilter) } : undefined;
82
+ const baseArgs = { expand: LIST_EXPAND, ...(created ? { created } : {}) };
83
+
84
+ // Status counts come from a bounded index of the account (PaymentIntents carry no server-side status filter).
85
+ tabsHost.replaceChildren(statTabs(PAYMENT_TABS.map((entry) => ({ ...entry })), tab, (next) => navigate(routeHash("payments", undefined, { ...route.params, tab: next === "all" ? undefined : next }))));
86
+ const ranges = [
87
+ { label: "All time", value: undefined },
88
+ { label: "Last 7 days", value: now() - 7 * 86400 },
89
+ { label: "Last 30 days", value: now() - 30 * 86400 },
90
+ { label: "Last 90 days", value: now() - 90 * 86400 },
91
+ ];
92
+ const activeRange = ranges.find((range) => String(range.value ?? "") === String(dateFilter ?? "")) ?? ranges[0];
93
+ const dateChip = chip("Date and time", {
94
+ active: activeRange.value !== undefined,
95
+ value: activeRange.value === undefined ? undefined : activeRange.label,
96
+ onClear: () => navigate(routeHash("payments", undefined, { ...route.params, since: undefined })),
97
+ onClick: () => openMenu(dateChip, ranges.map((range) => ({ label: range.label, onSelect: () => navigate(routeHash("payments", undefined, { ...route.params, since: range.value })) }))),
98
+ });
99
+ filtersHost.append(
100
+ dateChip,
101
+ chip("Amount", { onClick: () => notSimulated("Amount filter", "PaymentIntents have no amount filter in this Tool's list operation, and the app does not fake one.") }),
102
+ chip("Currency", { onClick: () => notSimulated("Currency filter", "PaymentIntents have no currency filter in this Tool's list operation, and the app does not fake one.") }),
103
+ chip("Status", { onClick: () => notSimulated("Status filter", "Use the status cards above; they count and filter a bounded index of this account's payments.") }),
104
+ chip("Payment method", { onClick: () => notSimulated("Payment method filter", "Cards are the only payment method type in this synthetic account.") }),
105
+ chip("More filters", { onClick: () => notSimulated("More filters") }),
106
+ listTools(),
107
+ );
108
+
109
+ const emptyNode = () => empty(tab === "all" ? "No payments yet" : `No ${PAYMENT_TABS.find((entry) => entry.key === tab)?.label.toLowerCase()} payments`, tab === "all" ? "Payments you create or that your integration creates appear here." : "Try another status or a wider date range.", canWrite("payment_intents") && tab === "all" ? button("Create payment", "primary", { icon: "plus", onClick: () => navigate("#/payments/new") }) : undefined);
110
+
111
+ if (tab === "all") {
112
+ const list = pagedList({ operation: "payment_intents.list", args: baseArgs, limit: 20, render: paymentRows, emptyNode });
113
+ listHost.append(list.element);
114
+ } else {
115
+ listHost.append(el("div", { class: "list-loading" }, [icon("spinner", "spin"), text("Loading…")]));
116
+ }
117
+ try {
118
+ const index = await indexAll("payment_intents.list", baseArgs);
119
+ const counts = Object.fromEntries(PAYMENT_TABS.map((entry) => [entry.key, 0]));
120
+ for (const intent of index.items) {
121
+ counts.all += 1;
122
+ counts[intentStatus(intent, chargeOf(intent)).key] += 1;
123
+ }
124
+ tabsHost.replaceChildren(statTabs(PAYMENT_TABS.map((entry) => ({ ...entry, count: `${counts[entry.key]}${index.complete ? "" : "+"}` })), tab, (next) => navigate(routeHash("payments", undefined, { ...route.params, tab: next === "all" ? undefined : next }))));
125
+ if (tab !== "all") {
126
+ const filtered = index.items.filter((intent) => intentStatus(intent, chargeOf(intent)).key === tab);
127
+ listHost.replaceChildren(indexedList({ items: filtered, complete: index.complete, render: paymentRows, emptyNode, note: "status filtered client-side" }));
128
+ }
129
+ } catch (error) {
130
+ if (tab !== "all") listHost.replaceChildren(errorPanel(error, () => navigate(location.hash)));
131
+ }
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------------------------
135
+ // Payment detail
136
+ // ---------------------------------------------------------------------------------------------
137
+
138
+ registerPage("payment", async (host, route) => {
139
+ const id = route.id;
140
+ setTitle(id);
141
+ const inner = pageInner();
142
+ host.replaceChildren(inner);
143
+ await load(inner, async () => {
144
+ const intent = await call("payment_intents.retrieve", { payment_intent: id, expand: ["customer", "payment_method", "latest_charge"] });
145
+ const charge = chargeOf(intent);
146
+ let refunds = [];
147
+ let refundsComplete = true;
148
+ if (charge && canRead("refunds")) {
149
+ try {
150
+ const refundIndex = await indexAll("refunds.list", { payment_intent: intent.id });
151
+ refunds = refundIndex.items;
152
+ refundsComplete = refundIndex.complete;
153
+ } catch {
154
+ refunds = [];
155
+ }
156
+ }
157
+ return renderPayment(intent, charge, refunds, refundsComplete);
158
+ }, { retry: () => navigate(location.hash) });
159
+ });
160
+
161
+ function renderPayment(intent, charge, refunds, refundsComplete = true) {
162
+ const status = intentStatus(intent, charge);
163
+ const customer = typeof intent.customer === "object" ? intent.customer : undefined;
164
+ const method = typeof intent.payment_method === "object" && intent.payment_method !== null ? intent.payment_method : charge?.payment_method_details?.card ? { card: charge.payment_method_details.card, id: charge.payment_method } : intent.last_payment_error?.payment_method ?? undefined;
165
+ const refundable = intent.status === "succeeded" && charge && charge.captured && charge.status === "succeeded" ? charge.amount_captured - charge.amount_refunded : 0;
166
+ const actions = [];
167
+ const canPay = canWrite("payment_intents");
168
+ if (canWrite("refunds") && refundable > 0) actions.push(button("Refund", "secondary", { icon: "arrow-return", onClick: () => openRefund(intent, charge, refundable) }));
169
+ if (canPay && intent.status === "requires_capture") actions.push(button("Capture", "primary", { onClick: () => openCapture(intent) }));
170
+ if (canPay && (intent.status === "requires_confirmation" || intent.status === "requires_payment_method")) actions.push(button("Confirm payment", "primary", { onClick: () => openConfirm(intent, customer) }));
171
+ if (canPay && ["requires_payment_method", "requires_confirmation", "requires_action", "requires_capture"].includes(intent.status)) actions.push(button("Cancel payment", "secondary", { onClick: () => cancelIntent(intent) }));
172
+ const more = button(undefined, "secondary", { icon: "more", ariaLabel: "More actions", class: "btn-icon-only" });
173
+ more.addEventListener("click", () =>
174
+ openMenu(more, [
175
+ { label: "Copy payment ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(intent.id).then(() => toast("Copied to clipboard")) },
176
+ ...(customer ? [{ label: "View customer", icon: "customers", onSelect: () => navigate(`#/customers/${customer.id}`) }] : []),
177
+ ...(charge ? [{ label: "Copy charge ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(charge.id).then(() => toast("Copied to clipboard")) }] : []),
178
+ "divider",
179
+ { label: "Receipt (synthetic, not served)", icon: "receipt", disabled: true },
180
+ ], { align: "end" }),
181
+ );
182
+ actions.push(more);
183
+
184
+ const title = el("div", { class: "detail-title" }, [el("h1", { class: "page-title" }, moneyCell(intent.amount, intent.currency)), statusBadge(status)]);
185
+ const header = pageHeader(title, { kind: "Payment", actions, breadcrumbs: [{ label: "Payments", href: "#/payments" }, { label: intent.id }] });
186
+
187
+ const meta = metaBar([
188
+ ["Last update", dateTime(lastUpdate(intent, charge), now())],
189
+ ["Customer", customer ? link(`#/customers/${customer.id}`, customer.name || customer.email || customer.id) : intent.customer ? link(`#/customers/${intent.customer}`, intent.customer) : el("span", { class: "muted", text: "Guest" })],
190
+ ["Payment method", method ? cardChip(method) : el("span", { class: "muted", text: "No payment method" })],
191
+ charge?.outcome ? ["Risk evaluation", riskCell(charge.outcome)] : undefined,
192
+ ]);
193
+
194
+ const banners = [];
195
+ if (intent.last_payment_error) banners.push(alert(`${intent.last_payment_error.message} (${humanize(intent.last_payment_error.decline_code ?? intent.last_payment_error.code)})`, "danger", { title: "The last payment attempt failed" }));
196
+ if (intent.status === "requires_action") banners.push(alert("The customer must complete 3D Secure authentication. This synthetic account cannot complete it; cancel the payment or retry with another card.", "warning", { title: "Requires action" }));
197
+ if (intent.status === "requires_capture") banners.push(alert(`${money(intent.amount_capturable, intent.currency)} is authorised and will be released if it is not captured.`, "warning", { title: "Uncaptured", iconName: "clock" }));
198
+
199
+ const details = section("Payment details", kv([
200
+ ["Amount", moneyCell(intent.amount, intent.currency)],
201
+ ["Amount received", money(intent.amount_received, intent.currency)],
202
+ ...(charge ? [["Net (fees not modelled)", money(charge.amount_captured - charge.amount_refunded, intent.currency)]] : []),
203
+ ["Status", statusBadge(status)],
204
+ ["Description", intent.description],
205
+ ["Statement descriptor", charge?.calculated_statement_descriptor ?? intent.statement_descriptor_suffix],
206
+ ["Capture method", humanize(intent.capture_method)],
207
+ ["Receipt email", intent.receipt_email],
208
+ ["ID", idChip(intent.id)],
209
+ ["Client secret", el("span", { class: "muted", text: "Hidden — usable nowhere" })],
210
+ ]));
211
+
212
+ const methodSection = section("Payment method", method?.card ? kv([
213
+ ["ID", method.id ? idChip(method.id) : null],
214
+ ["Number", cardChip(method)],
215
+ ["Fingerprint", el("code", { class: "id-code", text: method.card.fingerprint ?? "—" })],
216
+ ["Expires", `${String(method.card.exp_month).padStart(2, "0")} / ${method.card.exp_year}`],
217
+ ["Type", `${humanize(method.card.funding)} card`],
218
+ ["Issuer country", method.card.country],
219
+ ["CVC check", humanize(method.card.checks?.cvc_check ?? charge?.payment_method_details?.card?.checks?.cvc_check ?? "unavailable")],
220
+ ["Owner", method.billing_details?.name ?? charge?.billing_details?.name],
221
+ ["Owner email", method.billing_details?.email ?? charge?.billing_details?.email],
222
+ ]) : el("p", { class: "muted", text: "No payment method has been attached to this payment yet." }));
223
+
224
+ const events = [];
225
+ if (intent.status === "succeeded") events.push({ title: "Payment succeeded", at: lastUpdate(intent, charge), tone: "green", icon: "check-circle" });
226
+ if (intent.status === "canceled") events.push({ title: "Payment canceled", at: intent.canceled_at ?? undefined, text: intent.cancellation_reason ? humanize(intent.cancellation_reason) : undefined, tone: "", icon: "minus-circle" });
227
+ if (intent.status === "requires_capture") events.push({ title: "Payment authorised", at: charge?.created, text: "Awaiting capture", tone: "yellow", icon: "clock" });
228
+ if (intent.last_payment_error) events.push({ title: "Payment failed", at: charge?.created, text: intent.last_payment_error.message, tone: "red", icon: "x-circle" });
229
+ if (intent.status === "requires_action") events.push({ title: "Customer authentication required", at: charge?.created ?? intent.created, tone: "yellow", icon: "alert-circle" });
230
+ for (const refund of refunds) events.push({ title: `${money(refund.amount, refund.currency)} refunded`, at: refund.created, text: refund.reason ? humanize(refund.reason) : undefined, tone: "", icon: "arrow-return" });
231
+ events.push({ title: "Payment started", at: intent.created, tone: "blue", icon: "dot" });
232
+ events.sort((left, right) => (right.at ?? 0) - (left.at ?? 0));
233
+ const timelineSection = section("Timeline", timeline(events), { actions: [button("Add note", "secondary", { size: "sm", icon: "plus", onClick: () => notSimulated("Notes", "Dashboard notes are not stored by this synthetic account.") })] });
234
+ const logsSection = section("Events and logs", el("div", { class: "empty" }, [el("div", { class: "empty-title", text: "Events and request logs are not simulated" }), el("div", { text: "The Tool emits Firedrill events (payment_intent.succeeded, …) to drills, but keeps no Stripe event or request-log objects to list here." })]));
235
+
236
+ const refundsNote = boundNote({ items: refunds, complete: refundsComplete }, "refunds of this payment", "#/refunds");
237
+ const refundsSection = refunds.length > 0 ? section("Refunds", [...(refundsNote ? [refundsNote] : []), table({
238
+ columns: [
239
+ { label: "Amount", class: "nowrap", render: (refund) => amountWithStatus(refund.amount, refund.currency, statusBadge(refundStatus(refund))) },
240
+ { label: "Reason", render: (refund) => (refund.reason ? humanize(refund.reason) : el("span", { class: "muted", text: "—" })) },
241
+ { label: "ID", render: (refund) => idChip(refund.id) },
242
+ { label: "Date", class: "nowrap", render: (refund) => dateCell(refund.created) },
243
+ ],
244
+ rows: refunds,
245
+ })], { count: `${refunds.length}${refundsComplete ? "" : "+"}` }) : null;
246
+
247
+ const metadataRows = Object.entries(intent.metadata ?? {});
248
+ const metadataSection = section("Metadata", metadataRows.length > 0 ? kv(metadataRows) : el("p", { class: "muted", text: "No metadata" }));
249
+
250
+ return [header, meta, ...banners, timelineSection, details, methodSection, refundsSection, metadataSection, logsSection].filter(Boolean);
251
+ }
252
+
253
+ function lastUpdate(intent, charge) {
254
+ return Math.max(intent.created, charge?.created ?? 0, intent.canceled_at ?? 0);
255
+ }
256
+
257
+ function riskCell(outcome) {
258
+ const level = outcome.risk_level ?? "normal";
259
+ const tone = level === "elevated" ? "yellow" : level === "highest" ? "red" : "green";
260
+ return el("span", { class: "meta-value" }, [badge(`${outcome.risk_score ?? "–"}`, tone), el("span", { class: "muted", text: humanize(level) })]);
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------------------------
264
+ // Refund / capture / confirm / cancel
265
+ // ---------------------------------------------------------------------------------------------
266
+
267
+ function openRefund(intent, charge, refundable) {
268
+ const amount = amountInput({ amount: amountString(refundable, intent.currency), currency: intent.currency, currencies: [intent.currency] });
269
+ amount.select.disabled = true;
270
+ const reason = select([{ value: "", label: "Select a reason" }, { value: "duplicate", label: "Duplicate" }, { value: "fraudulent", label: "Fraudulent" }, { value: "requested_by_customer", label: "Requested by customer" }], "");
271
+ const fields = { amount: field("Refund", amount, { hint: `Up to ${money(refundable, intent.currency)} can be refunded.` }), reason: field("Reason", reason, { optional: true }) };
272
+ // The key is fixed for the life of the dialog so a retry after an outage replays instead of refunding twice.
273
+ const idempotencyKey = key();
274
+ openModal({
275
+ title: "Refund payment",
276
+ describedBy: "Refunds take 5–10 days to appear on a customer's statement. This synthetic account credits the balance immediately.",
277
+ body: [fields.amount, fields.reason],
278
+ actions: [
279
+ { label: "Cancel" },
280
+ {
281
+ label: "Refund",
282
+ kind: "primary",
283
+ submit: true,
284
+ onClick: (api) => {
285
+ const minor = amount.amount();
286
+ if (minor === undefined || minor <= 0) {
287
+ fields.amount.setError("Enter a valid amount.");
288
+ return;
289
+ }
290
+ return submitModal(api, fields, () => call("refunds.create", { payment_intent: intent.id, amount: minor, ...(reason.value ? { reason: reason.value } : {}) }, idempotencyKey), () => {
291
+ toast(`${money(minor, intent.currency)} refunded`);
292
+ invalidateCache();
293
+ navigate(location.hash);
294
+ });
295
+ },
296
+ },
297
+ ],
298
+ });
299
+ }
300
+
301
+ function openCapture(intent) {
302
+ const amount = amountInput({ amount: amountString(intent.amount_capturable, intent.currency), currency: intent.currency, currencies: [intent.currency] });
303
+ amount.select.disabled = true;
304
+ const fields = { amount_to_capture: field("Amount to capture", amount, { hint: `Up to ${money(intent.amount_capturable, intent.currency)}. Capturing less releases the remainder.` }) };
305
+ const idempotencyKey = key();
306
+ openModal({
307
+ title: "Capture payment",
308
+ body: [fields.amount_to_capture],
309
+ actions: [
310
+ { label: "Cancel" },
311
+ {
312
+ label: "Capture",
313
+ kind: "primary",
314
+ submit: true,
315
+ onClick: (api) => {
316
+ const minor = amount.amount();
317
+ if (minor === undefined || minor <= 0) {
318
+ fields.amount_to_capture.setError("Enter a valid amount.");
319
+ return;
320
+ }
321
+ return submitModal(api, fields, () => call("payment_intents.capture", { payment_intent: intent.id, amount_to_capture: minor }, idempotencyKey), () => {
322
+ toast(`Captured ${money(minor, intent.currency)}`);
323
+ invalidateCache();
324
+ navigate(location.hash);
325
+ });
326
+ },
327
+ },
328
+ ],
329
+ });
330
+ }
331
+
332
+ function openConfirm(intent, customer) {
333
+ const picker = methodPicker({ customer: customer?.id ?? intent.customer ?? undefined, value: typeof intent.payment_method === "string" ? intent.payment_method : intent.payment_method?.id ?? "" });
334
+ if (customer) void picker.setCustomer(customer.id, customer.invoice_settings?.default_payment_method);
335
+ const fields = { payment_method: field("Payment method", picker) };
336
+ const idempotencyKey = key();
337
+ openModal({
338
+ title: "Confirm payment",
339
+ describedBy: `Confirm ${money(intent.amount, intent.currency)} with a saved card or one of Stripe's test cards.`,
340
+ body: [fields.payment_method],
341
+ actions: [
342
+ { label: "Cancel" },
343
+ {
344
+ label: "Confirm",
345
+ kind: "primary",
346
+ submit: true,
347
+ onClick: (api) => {
348
+ if (!picker.value) {
349
+ fields.payment_method.setError("Choose a payment method.");
350
+ return;
351
+ }
352
+ return submitModal(api, fields, () => call("payment_intents.confirm", { payment_intent: intent.id, payment_method: picker.value }, idempotencyKey), (value) => {
353
+ toast(value.status === "succeeded" ? "Payment succeeded" : `Payment is now ${humanize(value.status)}`);
354
+ invalidateCache();
355
+ navigate(location.hash);
356
+ });
357
+ },
358
+ },
359
+ ],
360
+ });
361
+ }
362
+
363
+ async function cancelIntent(intent) {
364
+ const ok = await confirmDialog("Cancel payment?", `${money(intent.amount, intent.currency)} will not be collected. ${intent.status === "requires_capture" ? "The authorisation is released." : ""}`.trim(), "Cancel payment", { danger: true });
365
+ if (!ok) return;
366
+ await action(async () => {
367
+ await call("payment_intents.cancel", { payment_intent: intent.id, cancellation_reason: "requested_by_customer" }, key());
368
+ toast("Payment canceled");
369
+ invalidateCache();
370
+ navigate(location.hash);
371
+ });
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------------------------
375
+ // Create a payment (full page, like the Dashboard's "Create payment")
376
+ // ---------------------------------------------------------------------------------------------
377
+
378
+ registerPage("payment-new", async (host, route) => {
379
+ setTitle("Create a payment");
380
+ if (!canWrite("payment_intents")) {
381
+ host.replaceChildren(pageInner(pageHeader("Create a payment", { breadcrumbs: [{ label: "Payments", href: "#/payments" }] }), deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "payment_intents", level: "write" }), "payment_intents")));
382
+ return;
383
+ }
384
+ app.editing += 1;
385
+ const amount = amountInput({ currency: app.context?.account?.default_currency ?? "usd" });
386
+ const customer = customerPicker({ value: route.params.customer, onChange: (record) => void picker.setCustomer(record?.id, record?.invoice_settings?.default_payment_method) });
387
+ const description = input({ placeholder: "Order #1234 — what the customer bought" });
388
+ const picker = methodPicker({ customer: route.params.customer, allowNone: true, noneLabel: "Create without a payment method (requires_payment_method)" });
389
+ const capture = select([{ value: "automatic", label: "Automatically (charge now)" }, { value: "manual", label: "Manually (authorise now, capture later)" }], "automatic");
390
+ const receipt = input({ type: "email", placeholder: "customer@example.com" });
391
+ const statement = input({ placeholder: "ORDER 1234", maxlength: 22 });
392
+ const confirmNow = el("input", { attrs: { type: "checkbox" } });
393
+ confirmNow.checked = true;
394
+ const fields = {
395
+ amount: field("Amount", amount),
396
+ customer: field("Customer", customer, { optional: true }),
397
+ description: field("Description", description, { optional: true }),
398
+ payment_method: field("Payment method", picker),
399
+ capture_method: field("Capture method", capture),
400
+ receipt_email: field("Receipt email", receipt, { optional: true }),
401
+ statement_descriptor_suffix: field("Statement descriptor suffix", statement, { optional: true, hint: "Up to 22 characters shown after the account's descriptor." }),
402
+ };
403
+ const errorHost = el("div", { class: "form-error", attrs: { role: "alert" } });
404
+ errorHost.hidden = true;
405
+ const api = { setError: (message) => { errorHost.textContent = message ?? ""; errorHost.hidden = !message; }, setBusy() {}, close() {} };
406
+ let idempotencyKey = key();
407
+ const summary = el("div", { class: "editor-summary" }, [el("div", { class: "editor-summary-title", text: "Summary" }), el("div", { class: "muted", text: "The payment is created in this synthetic account; no card network is contacted." })]);
408
+ const submit = button("Create payment", "primary", {
409
+ onClick: () =>
410
+ action(async () => {
411
+ errorHost.hidden = true;
412
+ for (const entry of Object.values(fields)) entry.setError(undefined);
413
+ const minor = amount.amount();
414
+ if (minor === undefined) {
415
+ fields.amount.setError("Enter a valid amount, for example 18.90.");
416
+ return;
417
+ }
418
+ const args = { amount: minor, currency: amount.currency(), capture_method: capture.value };
419
+ if (customer.value) args.customer = customer.value;
420
+ if (description.value.trim()) args.description = description.value.trim();
421
+ if (picker.value) args.payment_method = picker.value;
422
+ if (receipt.value.trim()) args.receipt_email = receipt.value.trim();
423
+ if (statement.value.trim()) args.statement_descriptor_suffix = statement.value.trim();
424
+ if (confirmNow.checked && picker.value) args.confirm = true;
425
+ submit.classList.add("is-busy");
426
+ submit.disabled = true;
427
+ try {
428
+ const intent = await call("payment_intents.create", args, idempotencyKey);
429
+ toast(intent.status === "succeeded" ? `Payment of ${money(intent.amount, intent.currency)} succeeded` : `Payment created (${humanize(intent.status)})`);
430
+ invalidateCache();
431
+ app.editing -= 1;
432
+ navigate(`#/payments/${intent.id}`);
433
+ } catch (error) {
434
+ if (error instanceof ToolError && error.is("CARD_DECLINED")) {
435
+ idempotencyKey = key();
436
+ // A creation-time decline names no PaymentIntent: none was created, so there is no id to show or link.
437
+ errorHost.hidden = false;
438
+ errorHost.replaceChildren(el("strong", { text: `Card declined: ` }), text(`${error.message} (${humanize(error.details.decline_code ?? "generic_decline")}). No payment was created — choose another card and try again.`));
439
+ } else {
440
+ idempotencyKey = key();
441
+ applyError(error, fields, api);
442
+ }
443
+ } finally {
444
+ submit.classList.remove("is-busy");
445
+ submit.disabled = false;
446
+ }
447
+ }),
448
+ });
449
+ const form = el("div", { class: "editor-form" }, [
450
+ fields.amount,
451
+ fields.customer,
452
+ fields.description,
453
+ fields.payment_method,
454
+ el("label", { class: "check-field" }, [confirmNow, el("span", { class: "check-label", text: "Confirm immediately" }, [el("span", { class: "check-hint", text: "Uncheck to create a PaymentIntent in requires_confirmation and confirm it later." })])]),
455
+ fields.capture_method,
456
+ fields.receipt_email,
457
+ fields.statement_descriptor_suffix,
458
+ errorHost,
459
+ el("div", { class: "sticky-footer" }, [button("Cancel", "secondary", { onClick: () => { app.editing -= 1; navigate("#/payments"); } }), submit]),
460
+ ]);
461
+ host.replaceChildren(pageInner(pageHeader("Create a payment", { breadcrumbs: [{ label: "Payments", href: "#/payments" }, { label: "Create" }] }), el("div", { class: "editor" }, [form, summary])));
462
+ if (route.params.customer) void picker.setCustomer(route.params.customer);
463
+ amount.input.focus();
464
+ host.addEventListener("page-leave", () => { app.editing = Math.max(0, app.editing - 1); }, { once: true });
465
+ });
466
+
467
+ // ---------------------------------------------------------------------------------------------
468
+ // Refunds list
469
+ // ---------------------------------------------------------------------------------------------
470
+
471
+ registerPage("refunds", async (host) => {
472
+ setTitle("Refunds");
473
+ const header = pageHeader("Refunds");
474
+ const listHost = el("div");
475
+ host.replaceChildren(pageInner(header, listHost));
476
+ if (!canRead("refunds")) {
477
+ listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "refunds" }), "refunds"));
478
+ return;
479
+ }
480
+ const list = pagedList({
481
+ operation: "refunds.list",
482
+ args: {},
483
+ limit: 20,
484
+ emptyNode: () => empty("No refunds yet", "Refunds you issue from a payment appear here."),
485
+ render: (refunds) =>
486
+ table({
487
+ columns: [
488
+ { label: "Amount", class: "nowrap", render: (refund) => amountWithStatus(refund.amount, refund.currency, statusBadge(refundStatus(refund))) },
489
+ { label: "Payment", render: (refund) => link(`#/payments/${refund.payment_intent}`, refund.payment_intent ?? refund.charge, { class: "link-quiet" }) },
490
+ { label: "Reason", render: (refund) => (refund.reason ? humanize(refund.reason) : null) },
491
+ { label: "Reference", render: (refund) => el("code", { class: "id-code", text: refund.destination_details?.card?.reference ?? "—" }) },
492
+ { label: "Date", class: "nowrap", render: (refund) => dateCell(refund.created) },
493
+ ],
494
+ rows: refunds,
495
+ href: (refund) => `#/payments/${refund.payment_intent}`,
496
+ onOpen: (refund) => navigate(`#/payments/${refund.payment_intent}`),
497
+ }),
498
+ });
499
+ listHost.append(list.element);
500
+ });
501
+
502
+ export { chargeOf, paymentRows, openRefund };
@@ -0,0 +1,99 @@
1
+ // Shared app state, hash routing and page registry. Pages register themselves; app.js boots the shell.
2
+ import { $, call } from "./ui.js";
3
+
4
+ export const app = {
5
+ /** The Tool's `dashboard.context` value: account, virtual `now`, mode, permission levels. */
6
+ context: undefined,
7
+ connection: undefined,
8
+ route: { page: "home", id: undefined, params: {} },
9
+ pages: {},
10
+ /** Bounded client-side indexes (status tabs, search) keyed by operation+args; cleared when the world revision moves. */
11
+ cache: new Map(),
12
+ revisionStamp: undefined,
13
+ /** Set by a page while a form is being edited so a revision refresh does not discard typed values. */
14
+ editing: 0,
15
+ /** Elements to re-render when the world changes. */
16
+ refresh: undefined,
17
+ };
18
+
19
+ /** World virtual "now" in Unix seconds — the app never reads the browser clock for dates. */
20
+ export const now = () => app.context?.now;
21
+
22
+ export function registerPage(name, render) {
23
+ app.pages[name] = render;
24
+ }
25
+
26
+ export function navigate(hash) {
27
+ if (location.hash === hash) window.dispatchEvent(new HashChangeEvent("hashchange"));
28
+ else location.hash = hash;
29
+ }
30
+
31
+ /** `#/payments/pi_x?tab=refunded` → { page: "payments", id: "pi_x", params: { tab: "refunded" } }. */
32
+ export function parseRoute(hash) {
33
+ const raw = (hash ?? "").replace(/^#\/?/, "");
34
+ const [pathPart, queryPart = ""] = raw.split("?");
35
+ const segments = pathPart.split("/").filter(Boolean);
36
+ const params = Object.fromEntries(new URLSearchParams(queryPart).entries());
37
+ const page = segments[0] || "home";
38
+ const id = segments[1];
39
+ const sub = segments[2];
40
+ return { page, id, sub, params };
41
+ }
42
+
43
+ export function routeHash(page, id, params = {}) {
44
+ const query = new URLSearchParams(Object.entries(params).filter(([, value]) => value !== undefined && value !== "" && value !== null)).toString();
45
+ return `#/${page}${id ? `/${id}` : ""}${query ? `?${query}` : ""}`;
46
+ }
47
+
48
+ /** Permission level of the calling key for a resource group ("none" | "read" | "write"). */
49
+ export function level(group) {
50
+ return app.context?.permissions?.[group] ?? "write";
51
+ }
52
+ export function canRead(group) {
53
+ return level(group) !== "none";
54
+ }
55
+ export function canWrite(group) {
56
+ return level(group) === "write";
57
+ }
58
+
59
+ /** Route id prefixes to their pages. */
60
+ export const PREFIX_PAGES = Object.freeze({ pi: "payments", cus: "customers", in: "invoices", sub: "subscriptions", prod: "products", ch: "charges", re: "refunds", price: "prices", pm: "payment_methods", ii: "invoice_items" });
61
+
62
+ /** Re-read the dashboard context (account, now, permissions). */
63
+ export async function loadContext() {
64
+ app.context = await call("dashboard.context", {});
65
+ return app.context;
66
+ }
67
+
68
+ /** Bounded walk over a list operation, cached until the world revision changes. Returns { items, complete }. */
69
+ export async function indexAll(operation, args = {}, { max = 1000, pageSize = 100 } = {}) {
70
+ const cacheKey = `${operation}:${JSON.stringify(args)}`;
71
+ const hit = app.cache.get(cacheKey);
72
+ if (hit) return hit;
73
+ const items = [];
74
+ let after;
75
+ let complete = true;
76
+ for (;;) {
77
+ const page = await call(operation, { ...args, limit: pageSize, ...(after === undefined ? {} : { starting_after: after }) });
78
+ items.push(...page.data);
79
+ if (!page.has_more) break;
80
+ if (items.length >= max) {
81
+ complete = false;
82
+ break;
83
+ }
84
+ after = page.data[page.data.length - 1].id;
85
+ }
86
+ const value = { items, complete };
87
+ app.cache.set(cacheKey, value);
88
+ return value;
89
+ }
90
+
91
+ export function invalidateCache() {
92
+ app.cache.clear();
93
+ }
94
+
95
+ export function setTitle(title) {
96
+ document.title = `${title} – Stripe Dashboard (synthetic)`;
97
+ }
98
+
99
+ export const pageHost = () => $("#page");