@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,416 @@
1
+ // Customers list, customer detail (payment methods, subscriptions, invoices, payments) and the add/edit modals.
2
+ import { icon } from "./icons.js";
3
+ import { canRead, canWrite, indexAll, invalidateCache, navigate, now, registerPage, routeHash, setTitle } from "./store.js";
4
+ import { ToolError, action, avatar, badge, button, call, cardChip, confirmDialog, dateTime, el, field, humanize, idChip, input, intentStatus, invoiceStatus, key, link, money, moneyCell, openMenu, openModal, select, statusBadge, subscriptionStatus, textarea, toast } from "./ui.js";
5
+ import { alert, amountWithStatus, boundNote, chip, dateCell, deniedPanel, empty, kv, listTools, load, metaBar, methodPicker, notSimulated, pageHeader, pageInner, pagedList, section, statTabs, submitModal, table } from "./widgets.js";
6
+ import { chargeOf } from "./pages-payments.js";
7
+
8
+ function customerRows(customers) {
9
+ return table({
10
+ select: { label: "customers", rowLabel: (customer) => `customer ${customer.name ?? customer.id}` },
11
+ columns: [
12
+ { label: "Name", class: "truncate", render: (customer) => el("span", { class: "customer-cell" }, [avatar(customer.name || customer.email || "?", customer.id, "sm"), el("span", { class: "truncate", text: customer.name ?? el("span", { class: "muted", text: "No name" }) })]) },
13
+ { label: "Email", class: "truncate", render: (customer) => customer.email ?? el("span", { class: "muted", text: "—" }) },
14
+ { label: "Default payment method", class: "nowrap", render: (customer) => (customer.invoice_settings?.default_payment_method && typeof customer.invoice_settings.default_payment_method === "object" ? cardChip(customer.invoice_settings.default_payment_method) : el("span", { class: "muted", text: "—" })) },
15
+ { label: "Status", class: "nowrap", render: (customer) => (customer.delinquent ? badge("Delinquent", "red", "alert-circle") : el("span", { class: "muted", text: "—" })) },
16
+ { label: "Created", class: "nowrap", render: (customer) => dateCell(customer.created) },
17
+ { label: "", class: "actions", render: (customer) => customerMenu(customer) },
18
+ ],
19
+ rows: customers,
20
+ href: (customer) => `#/customers/${customer.id}`,
21
+ onOpen: (customer) => navigate(`#/customers/${customer.id}`),
22
+ });
23
+ }
24
+
25
+ function customerMenu(customer) {
26
+ const trigger = button(undefined, "ghost", { icon: "more", size: "sm", ariaLabel: `Actions for ${customer.name ?? customer.id}`, class: "btn-icon-only" });
27
+ trigger.addEventListener("click", (event) => {
28
+ event.stopPropagation();
29
+ openMenu(trigger, [
30
+ { label: "View customer", icon: "external", onSelect: () => navigate(`#/customers/${customer.id}`) },
31
+ { label: "Copy customer ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(customer.id).then(() => toast("Copied to clipboard")) },
32
+ ...(canWrite("payment_intents") ? [{ label: "Create payment", icon: "payments", onSelect: () => navigate(routeHash("payments", "new", { customer: customer.id })) }] : []),
33
+ ...(canWrite("invoices") ? [{ label: "Create invoice", icon: "invoices", onSelect: () => navigate(routeHash("invoices", "new", { customer: customer.id })) }] : []),
34
+ ...(canWrite("subscriptions") ? [{ label: "Create subscription", icon: "subscriptions", onSelect: () => navigate(routeHash("subscriptions", "new", { customer: customer.id })) }] : []),
35
+ ], { align: "end" });
36
+ });
37
+ return trigger;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------------------------
41
+ // List
42
+ // ---------------------------------------------------------------------------------------------
43
+
44
+ registerPage("customers", async (host, route) => {
45
+ setTitle("Customers");
46
+ const actions = [];
47
+ if (canWrite("customers")) actions.push(button("Add customer", "primary", { icon: "plus", onClick: () => openCustomerForm() }));
48
+ const header = pageHeader("Customers", { actions });
49
+ const segmentsHost = el("div");
50
+ const drawSegments = (allCount) => segmentsHost.replaceChildren(statTabs(CUSTOMER_SEGMENTS.map((segment) => (segment.key === "all" ? { ...segment, count: allCount } : segment)), "all", () => navigate("#/customers")));
51
+ drawSegments(undefined);
52
+ const filters = el("div", { class: "filters" });
53
+ const listHost = el("div");
54
+ host.replaceChildren(pageInner(header, segmentsHost, filters, listHost));
55
+ if (!canRead("customers")) {
56
+ listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "customers" }), "customers"));
57
+ return;
58
+ }
59
+ const email = route.params.email ?? "";
60
+ const emailInput = input({ placeholder: "Filter by exact email", value: email, type: "search" });
61
+ emailInput.setAttribute("aria-label", "Filter customers by exact email");
62
+ const emailChip = el("form", { class: "chip-input", attrs: { role: "search" } }, [icon("search"), emailInput]);
63
+ emailChip.addEventListener("submit", (event) => {
64
+ event.preventDefault();
65
+ navigate(routeHash("customers", undefined, { email: emailInput.value.trim() || undefined }));
66
+ });
67
+ filters.append(
68
+ emailChip,
69
+ chip("Card", { onClick: () => notSimulated("Card filter", "customers.list filters by exact email only.") }),
70
+ chip("Created date", { onClick: () => notSimulated("Created date filter", "customers.list filters by exact email only.") }),
71
+ chip("Type", { onClick: () => notSimulated("Type filter", "All customers in this synthetic account are individuals.") }),
72
+ chip("More filters", { onClick: () => notSimulated("More filters") }),
73
+ listTools([["Analyze", "sparkline", "Customer analytics are not simulated."]]),
74
+ );
75
+ if (email) filters.append(button("Clear", "link", { onClick: () => navigate("#/customers") }));
76
+ filters.append(el("span", { class: "filters-note", text: "Email filter is exact and case-sensitive, as in the API" }));
77
+ const list = pagedList({
78
+ operation: "customers.list",
79
+ args: { expand: ["data.invoice_settings.default_payment_method"], ...(email ? { email } : {}) },
80
+ limit: 20,
81
+ render: customerRows,
82
+ emptyNode: () => (email ? empty("No customers match", `No customer has the email ${email}.`) : empty("No customers yet", "Customers are created when a payment is made or when you add one.", canWrite("customers") ? button("Add customer", "primary", { icon: "plus", onClick: () => openCustomerForm() }) : undefined)),
83
+ });
84
+ listHost.append(list.element);
85
+ try {
86
+ const index = await indexAll("customers.list", {});
87
+ drawSegments(`${index.items.length}${index.complete ? "" : "+"}`);
88
+ } catch {
89
+ drawSegments(undefined);
90
+ }
91
+ });
92
+
93
+ const SEGMENT_NOTE = "Customer segments are computed by Stripe's analytics, which this synthetic account does not run. Only the All count comes from customers.list.";
94
+ const CUSTOMER_SEGMENTS = Object.freeze([
95
+ { key: "all", label: "All" },
96
+ { key: "top", label: "Top customers", notSimulated: SEGMENT_NOTE },
97
+ { key: "first_time", label: "First-time customers", notSimulated: SEGMENT_NOTE },
98
+ { key: "repeat", label: "Repeat customers", notSimulated: SEGMENT_NOTE },
99
+ { key: "recent", label: "Recent customers", notSimulated: SEGMENT_NOTE },
100
+ { key: "high_refunds", label: "High refunds", notSimulated: SEGMENT_NOTE },
101
+ { key: "high_disputes", label: "High disputes", notSimulated: SEGMENT_NOTE },
102
+ ]);
103
+
104
+ // ---------------------------------------------------------------------------------------------
105
+ // Detail
106
+ // ---------------------------------------------------------------------------------------------
107
+
108
+ registerPage("customer", async (host, route) => {
109
+ const id = route.id;
110
+ setTitle(id);
111
+ const inner = pageInner();
112
+ host.replaceChildren(inner);
113
+ await load(inner, async () => {
114
+ const customer = await call("customers.retrieve", { customer: id, expand: ["invoice_settings.default_payment_method"] });
115
+ return renderCustomer(customer);
116
+ }, { retry: () => navigate(location.hash) });
117
+ });
118
+
119
+ function renderCustomer(customer) {
120
+ const name = customer.name || customer.email || customer.id;
121
+ const defaultMethod = customer.invoice_settings?.default_payment_method;
122
+ const defaultId = typeof defaultMethod === "object" && defaultMethod !== null ? defaultMethod.id : defaultMethod;
123
+ const actions = [];
124
+ if (canWrite("payment_intents")) actions.push(button("Create payment", "secondary", { onClick: () => navigate(routeHash("payments", "new", { customer: customer.id })) }));
125
+ if (canWrite("invoices")) actions.push(button("Create invoice", "secondary", { onClick: () => navigate(routeHash("invoices", "new", { customer: customer.id })) }));
126
+ if (canWrite("subscriptions")) actions.push(button("Create subscription", "secondary", { onClick: () => navigate(routeHash("subscriptions", "new", { customer: customer.id })) }));
127
+ const more = button(undefined, "secondary", { icon: "more", ariaLabel: "More actions", class: "btn-icon-only" });
128
+ more.addEventListener("click", () =>
129
+ openMenu(more, [
130
+ ...(canWrite("customers") ? [{ label: "Edit information", icon: "edit", onSelect: () => openCustomerForm(customer) }] : []),
131
+ { label: "Copy customer ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(customer.id).then(() => toast("Copied to clipboard")) },
132
+ "divider",
133
+ { label: "Delete customer", icon: "trash", danger: true, disabled: true, description: "Not offered by this synthetic account" },
134
+ ], { align: "end" }),
135
+ );
136
+ actions.push(more);
137
+ const title = el("div", { class: "detail-title" }, [avatar(name, customer.id, "lg"), el("div", {}, [el("h1", { class: "page-title", text: name }), customer.email && customer.name ? el("div", { class: "page-subtitle", text: customer.email }) : null]), customer.delinquent ? badge("Delinquent", "red", "alert-circle") : null]);
138
+ const header = pageHeader(title, { kind: "Customer", actions, breadcrumbs: [{ label: "Customers", href: "#/customers" }, { label: customer.id }] });
139
+ const meta = metaBar([
140
+ ["Account balance", el("span", {}, [moneyCell(customer.balance, customer.currency ?? "usd"), customer.balance < 0 ? el("span", { class: "muted", text: " credit" }) : null])],
141
+ ["Default payment method", typeof defaultMethod === "object" && defaultMethod !== null ? cardChip(defaultMethod) : el("span", { class: "muted", text: "None" })],
142
+ ["Created", dateTime(customer.created, now())],
143
+ ["Customer since", relativeDays(customer.created)],
144
+ ]);
145
+
146
+ const details = section("Details", [
147
+ kv([
148
+ ["Customer ID", idChip(customer.id)],
149
+ ["Customer since", dateTime(customer.created, now())],
150
+ ["Name", customer.name],
151
+ ["Email", customer.email],
152
+ ["Phone", customer.phone],
153
+ ["Description", customer.description],
154
+ ["Billing address", addressText(customer.address)],
155
+ ["Shipping", customer.shipping ? `${customer.shipping.name ?? ""}${customer.shipping.name ? ", " : ""}${addressText(customer.shipping.address) ?? ""}` : null],
156
+ ["Preferred locales", customer.preferred_locales?.length ? customer.preferred_locales.join(", ") : null],
157
+ ["Tax status", customer.tax_exempt === "none" ? "Taxable" : humanize(customer.tax_exempt)],
158
+ ["Invoice prefix", el("code", { class: "id-code", text: customer.invoice_prefix })],
159
+ ["Next invoice number", `${customer.invoice_prefix}-${String(customer.next_invoice_sequence).padStart(4, "0")}`],
160
+ ["Currency", customer.currency ? customer.currency.toUpperCase() : null],
161
+ ["Delinquent", customer.delinquent ? "Yes — an open invoice is past due" : "No"],
162
+ ]),
163
+ ], { actions: canWrite("customers") ? [button("Edit", "secondary", { size: "sm", icon: "edit", onClick: () => openCustomerForm(customer) })] : [] });
164
+ details.querySelector(".kv").classList.add("kv-stacked");
165
+
166
+ const methodsHost = el("div");
167
+ const methodsSection = section("Payment methods", methodsHost, { actions: canWrite("payment_methods") ? [button("Add payment method", "secondary", { size: "sm", icon: "plus", onClick: () => openAttach(customer) })] : [] });
168
+ void loadMethods(methodsHost, customer, defaultId);
169
+
170
+ const subscriptionsHost = el("div");
171
+ const subscriptionsSection = section("Subscriptions", subscriptionsHost, { actions: [link("#/subscriptions", "View all", { class: "btn btn-link" })] });
172
+ void loadSubscriptions(subscriptionsHost, customer);
173
+
174
+ const invoicesHost = el("div");
175
+ const invoicesSection = section("Invoices", invoicesHost, { actions: [link(routeHash("invoices", undefined, { customer: customer.id }), "View all", { class: "btn btn-link" })] });
176
+ void loadInvoices(invoicesHost, customer);
177
+
178
+ const paymentsHost = el("div");
179
+ const paymentsSection = section("Payments", paymentsHost, { actions: [link("#/payments", "View all", { class: "btn btn-link" })] });
180
+ void loadPayments(paymentsHost, customer);
181
+
182
+ const metadataRows = Object.entries(customer.metadata ?? {});
183
+ const metadataSection = section("Metadata", metadataRows.length > 0 ? kv(metadataRows) : el("p", { class: "muted", text: "No metadata" }));
184
+ if (metadataRows.length > 0) metadataSection.querySelector(".kv").classList.add("kv-stacked");
185
+ const eventsSection = section("Events", el("p", { class: "muted", text: "Stripe event objects are not simulated for this account." }));
186
+
187
+ return [header, meta, el("div", { class: "detail-layout" }, [el("div", { class: "detail-main" }, [paymentsSection, subscriptionsSection, methodsSection, invoicesSection, eventsSection]), el("aside", { class: "detail-rail", attrs: { "aria-label": "Customer details" } }, [details, metadataSection])])];
188
+ }
189
+
190
+ function relativeDays(created) {
191
+ const days = Math.max(0, Math.floor((now() - created) / 86400));
192
+ if (days < 1) return "Today";
193
+ if (days < 31) return `${days} day${days === 1 ? "" : "s"}`;
194
+ if (days < 365) return `${Math.floor(days / 30)} month${Math.floor(days / 30) === 1 ? "" : "s"}`;
195
+ return `${Math.floor(days / 365)} year${Math.floor(days / 365) === 1 ? "" : "s"}`;
196
+ }
197
+
198
+ export function addressText(address) {
199
+ if (!address) return null;
200
+ const parts = [address.line1, address.line2, [address.city, address.state].filter(Boolean).join(", "), address.postal_code, address.country].filter(Boolean);
201
+ return parts.length > 0 ? parts.join(" · ") : null;
202
+ }
203
+
204
+ async function loadMethods(host, customer, defaultId) {
205
+ if (!canRead("payment_methods")) {
206
+ host.replaceChildren(el("p", { class: "muted", text: "This key cannot read payment methods." }));
207
+ return;
208
+ }
209
+ await load(host, async () => {
210
+ const methodIndex = await indexAll("payment_methods.list", { customer: customer.id });
211
+ const methods = methodIndex.items;
212
+ if (methods.length === 0) return el("p", { class: "muted", text: "No payment methods. Add one of Stripe's test cards to charge this customer." });
213
+ const list = el("div", { class: "pm-list" });
214
+ for (const method of methods) {
215
+ const row = el("div", { class: "pm-row" }, [cardChip(method, { expiry: true }), method.id === defaultId ? badge("Default", "purple") : null]);
216
+ const actions = el("div", { class: "btn-group" });
217
+ if (canWrite("customers") && method.id !== defaultId) actions.append(button("Set as default", "ghost", { size: "sm", onClick: () => setDefault(customer, method) }));
218
+ if (canWrite("payment_methods")) actions.append(button("Detach", "ghost", { size: "sm", onClick: () => detach(customer, method) }));
219
+ row.append(actions);
220
+ list.append(row);
221
+ }
222
+ const note = boundNote(methodIndex, "payment methods of this customer");
223
+ return note ? el("div", {}, [note, list]) : list;
224
+ });
225
+ }
226
+
227
+ async function loadSubscriptions(host, customer) {
228
+ if (!canRead("subscriptions")) {
229
+ host.replaceChildren(el("p", { class: "muted", text: "This key cannot read subscriptions." }));
230
+ return;
231
+ }
232
+ host.replaceChildren(pagedList({
233
+ operation: "subscriptions.list",
234
+ args: { customer: customer.id, status: "all" },
235
+ limit: 10,
236
+ emptyNode: () => el("p", { class: "muted", text: "No subscriptions." }),
237
+ render: (rows) => table({
238
+ columns: [
239
+ { label: "Subscription", render: (subscription) => el("span", { text: subscription.items.data.map((item) => item.price.nickname ?? item.price.id).join(", ") }) },
240
+ { label: "Status", class: "nowrap", render: (subscription) => statusBadge(subscriptionStatus(subscription, now())) },
241
+ { label: "Amount", class: "nowrap", render: (subscription) => subscriptionAmount(subscription) },
242
+ { label: "Current period", class: "nowrap muted", render: (subscription) => `${dateTime(subscription.items.data[0]?.current_period_start, now())} – ${dateTime(subscription.items.data[0]?.current_period_end, now())}` },
243
+ { label: "Created", class: "nowrap", render: (subscription) => dateCell(subscription.created) },
244
+ ],
245
+ rows,
246
+ href: (subscription) => `#/subscriptions/${subscription.id}`,
247
+ onOpen: (subscription) => navigate(`#/subscriptions/${subscription.id}`),
248
+ }),
249
+ }).element);
250
+ }
251
+
252
+ export function subscriptionAmount(subscription) {
253
+ const items = subscription.items.data;
254
+ const total = items.reduce((sum, item) => sum + (item.price.unit_amount ?? 0) * (item.quantity ?? 1), 0);
255
+ const recurring = items[0]?.price.recurring;
256
+ const suffix = recurring ? (recurring.interval_count === 1 ? ` / ${recurring.interval}` : ` every ${recurring.interval_count} ${recurring.interval}s`) : "";
257
+ return el("span", { class: "money" }, [el("span", { class: "money-value", text: money(total, subscription.currency) }), el("span", { class: "money-currency", text: suffix.trim() })]);
258
+ }
259
+
260
+ async function loadInvoices(host, customer) {
261
+ if (!canRead("invoices")) {
262
+ host.replaceChildren(el("p", { class: "muted", text: "This key cannot read invoices." }));
263
+ return;
264
+ }
265
+ host.replaceChildren(pagedList({
266
+ operation: "invoices.list",
267
+ args: { customer: customer.id },
268
+ limit: 10,
269
+ emptyNode: () => el("p", { class: "muted", text: "No invoices." }),
270
+ render: (rows) => table({
271
+ columns: [
272
+ { label: "Amount", class: "nowrap", render: (invoice) => amountWithStatus(invoice.total, invoice.currency, statusBadge(invoiceStatus(invoice, now()))) },
273
+ { label: "Invoice number", render: (invoice) => invoice.number ?? el("span", { class: "muted", text: "Draft" }) },
274
+ { label: "Due", class: "nowrap muted", render: (invoice) => (invoice.due_date ? dateTime(invoice.due_date, now()) : "—") },
275
+ { label: "Created", class: "nowrap", render: (invoice) => dateCell(invoice.created) },
276
+ ],
277
+ rows,
278
+ href: (invoice) => `#/invoices/${invoice.id}`,
279
+ onOpen: (invoice) => navigate(`#/invoices/${invoice.id}`),
280
+ }),
281
+ }).element);
282
+ }
283
+
284
+ async function loadPayments(host, customer) {
285
+ if (!canRead("payment_intents")) {
286
+ host.replaceChildren(el("p", { class: "muted", text: "This key cannot read payments." }));
287
+ return;
288
+ }
289
+ host.replaceChildren(pagedList({
290
+ operation: "payment_intents.list",
291
+ args: { customer: customer.id, expand: ["data.latest_charge"] },
292
+ limit: 10,
293
+ emptyNode: () => el("p", { class: "muted", text: "No payments." }),
294
+ render: (rows) => table({
295
+ columns: [
296
+ { label: "Amount", class: "nowrap", render: (intent) => amountWithStatus(intent.amount, intent.currency, statusBadge(intentStatus(intent, chargeOf(intent)))) },
297
+ { label: "Description", class: "truncate", render: (intent) => intent.description },
298
+ { label: "Payment method", class: "nowrap", render: (intent) => (chargeOf(intent)?.payment_method_details?.card ? cardChip({ card: chargeOf(intent).payment_method_details.card }) : null) },
299
+ { label: "Date", class: "nowrap", render: (intent) => dateCell(intent.created) },
300
+ ],
301
+ rows,
302
+ href: (intent) => `#/payments/${intent.id}`,
303
+ onOpen: (intent) => navigate(`#/payments/${intent.id}`),
304
+ }),
305
+ }).element);
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------------------------
309
+ // Mutations
310
+ // ---------------------------------------------------------------------------------------------
311
+
312
+ async function setDefault(customer, method) {
313
+ await action(async () => {
314
+ await call("customers.update", { customer: customer.id, invoice_settings: { default_payment_method: method.id } }, key());
315
+ toast("Default payment method updated");
316
+ invalidateCache();
317
+ navigate(location.hash);
318
+ });
319
+ }
320
+
321
+ async function detach(customer, method) {
322
+ const ok = await confirmDialog("Detach payment method?", `${method.card ? `${humanize(method.card.brand)} •••• ${method.card.last4}` : method.id} will no longer be usable for this customer's payments, invoices or subscriptions.`, "Detach", { danger: true });
323
+ if (!ok) return;
324
+ await action(async () => {
325
+ await call("payment_methods.detach", { payment_method: method.id }, key());
326
+ toast("Payment method detached");
327
+ invalidateCache();
328
+ navigate(location.hash);
329
+ });
330
+ }
331
+
332
+ function openAttach(customer) {
333
+ const picker = methodPicker({ includeTestCards: true });
334
+ const makeDefault = el("input", { attrs: { type: "checkbox" } });
335
+ makeDefault.checked = !customer.invoice_settings?.default_payment_method;
336
+ const fields = { payment_method: field("Card", picker, { hint: "Test cards materialise as real pm_… objects when attached, as in Stripe's test mode." }) };
337
+ const idempotencyKey = key();
338
+ openModal({
339
+ title: "Add payment method",
340
+ body: [fields.payment_method, el("label", { class: "check-field" }, [makeDefault, el("span", { class: "check-label", text: "Use as default for invoices and subscriptions" })])],
341
+ actions: [
342
+ { label: "Cancel" },
343
+ {
344
+ label: "Add",
345
+ kind: "primary",
346
+ submit: true,
347
+ onClick: (api) => {
348
+ if (!picker.value) {
349
+ fields.payment_method.setError("Choose a card.");
350
+ return;
351
+ }
352
+ return submitModal(api, fields, async () => {
353
+ const method = await call("payment_methods.attach", { payment_method: picker.value, customer: customer.id }, idempotencyKey);
354
+ if (makeDefault.checked && canWrite("customers")) await call("customers.update", { customer: customer.id, invoice_settings: { default_payment_method: method.id } }, key());
355
+ return method;
356
+ }, () => {
357
+ toast("Payment method added");
358
+ invalidateCache();
359
+ navigate(location.hash);
360
+ });
361
+ },
362
+ },
363
+ ],
364
+ });
365
+ }
366
+
367
+ /** Add (no `existing`) or edit a customer. */
368
+ export function openCustomerForm(existing, onCreated) {
369
+ const name = input({ placeholder: "Jane Diaz", value: existing?.name ?? "" });
370
+ const email = input({ type: "email", placeholder: "jane.diaz@example.com", value: existing?.email ?? "" });
371
+ const description = textarea({ placeholder: "Internal note about this customer", value: existing?.description ?? "", rows: 2 });
372
+ const phone = input({ placeholder: "+1 555 0100", value: existing?.phone ?? "" });
373
+ const line1 = input({ placeholder: "Address line 1", value: existing?.address?.line1 ?? "" });
374
+ const line2 = input({ placeholder: "Address line 2", value: existing?.address?.line2 ?? "" });
375
+ const city = input({ placeholder: "City", value: existing?.address?.city ?? "" });
376
+ const state = input({ placeholder: "State / province", value: existing?.address?.state ?? "" });
377
+ const postal = input({ placeholder: "Postal code", value: existing?.address?.postal_code ?? "" });
378
+ const country = input({ placeholder: "Country (ISO, e.g. US)", value: existing?.address?.country ?? "", maxlength: 2 });
379
+ for (const [control, label] of [[line1, "Address line 1"], [line2, "Address line 2"], [city, "City"], [state, "State or province"], [postal, "Postal code"], [country, "Country (two-letter ISO code)"]]) control.setAttribute("aria-label", label);
380
+ const taxExempt = select([{ value: "none", label: "Taxable" }, { value: "exempt", label: "Exempt" }, { value: "reverse", label: "Reverse charge" }], existing?.tax_exempt ?? "none");
381
+ const fields = {
382
+ name: field("Name", name),
383
+ email: field("Email", email, { optional: true }),
384
+ description: field("Description", description, { optional: true }),
385
+ phone: field("Phone", phone, { optional: true }),
386
+ address: field("Billing address", el("div", { class: "inline-list" }, [line1, line2, el("div", { class: "field-row" }, [city, state]), el("div", { class: "field-row" }, [postal, country])]), { optional: true }),
387
+ tax_exempt: field("Tax status", taxExempt),
388
+ };
389
+ const idempotencyKey = key();
390
+ openModal({
391
+ title: existing ? "Update customer" : "Add customer",
392
+ size: "modal-lg",
393
+ body: [fields.name, fields.email, fields.description, fields.phone, fields.address, fields.tax_exempt],
394
+ actions: [
395
+ { label: "Cancel" },
396
+ {
397
+ label: existing ? "Update customer" : "Add customer",
398
+ kind: "primary",
399
+ submit: true,
400
+ onClick: (api) => {
401
+ const args = { name: name.value.trim(), email: email.value.trim(), description: description.value.trim(), phone: phone.value.trim(), tax_exempt: taxExempt.value };
402
+ const hasAddress = [line1, line2, city, state, postal, country].some((control) => control.value.trim());
403
+ if (hasAddress) args.address = { line1: line1.value.trim() || null, line2: line2.value.trim() || null, city: city.value.trim() || null, state: state.value.trim() || null, postal_code: postal.value.trim() || null, country: country.value.trim().toUpperCase() || null };
404
+ if (!existing) for (const [name_, value] of Object.entries(args)) if (value === "") delete args[name_];
405
+ return submitModal(api, fields, () => (existing ? call("customers.update", { customer: existing.id, ...args }, idempotencyKey) : call("customers.create", args, idempotencyKey)), (customer) => {
406
+ toast(existing ? "Customer updated" : "Customer added");
407
+ invalidateCache();
408
+ if (onCreated) onCreated(customer);
409
+ else navigate(`#/customers/${customer.id}`);
410
+ });
411
+ },
412
+ },
413
+ ],
414
+ });
415
+ }
416
+