@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,314 @@
1
+ // Product catalog: products list, product detail with its prices, add/edit product and add price.
2
+ import { canRead, canWrite, indexAll, invalidateCache, navigate, now, registerPage, routeHash, setTitle } from "./store.js";
3
+ import { ToolError, action, badge, button, call, confirmDialog, dateTime, el, field, humanize, idChip, input, intervalLabel, key, moneyCell, openMenu, openModal, priceLabel, select, textarea, toast } from "./ui.js";
4
+ import { amountInput, chip, dateCell, deniedPanel, empty, kv, listTools, load, metaBar, notSimulated, pageHeader, pageInner, pagedList, section, statTabs, submitModal, table, tabs } from "./widgets.js";
5
+
6
+ function productRows(products) {
7
+ return table({
8
+ select: { label: "products", rowLabel: (product) => `product ${product.name}` },
9
+ columns: [
10
+ { label: "Name", class: "truncate", render: (product) => el("span", { class: "customer-cell" }, [el("span", { class: "customer-cell-text" }, [el("span", { text: product.name }), product.description ? el("span", { class: "customer-cell-sub", text: product.description }) : null])]) },
11
+ { label: "Pricing", class: "nowrap", render: (product) => (product.default_price && typeof product.default_price === "object" ? priceLabel(product.default_price) : el("span", { class: "muted", text: product.default_price ? product.default_price : "No default price" })) },
12
+ { label: "Status", class: "nowrap", render: (product) => (product.active ? badge("Active", "green", "check-circle") : badge("Archived", "gray", "minus-circle")) },
13
+ { label: "Updated", class: "nowrap", render: (product) => dateCell(product.updated ?? product.created) },
14
+ { label: "Created", class: "nowrap", render: (product) => dateCell(product.created) },
15
+ { label: "", class: "actions", render: (product) => productMenu(product) },
16
+ ],
17
+ rows: products,
18
+ href: (product) => `#/products/${product.id}`,
19
+ onOpen: (product) => navigate(`#/products/${product.id}`),
20
+ });
21
+ }
22
+
23
+ function productMenu(product) {
24
+ const trigger = button(undefined, "ghost", { icon: "more", size: "sm", ariaLabel: `Actions for ${product.name}`, class: "btn-icon-only" });
25
+ trigger.addEventListener("click", (event) => {
26
+ event.stopPropagation();
27
+ openMenu(trigger, [
28
+ { label: "View product", icon: "external", onSelect: () => navigate(`#/products/${product.id}`) },
29
+ ...(canWrite("products") ? [{ label: "Edit product", icon: "edit", onSelect: () => openProductForm(product) }, { label: "Add another price", icon: "plus", onSelect: () => openPriceForm(product) }, { label: product.active ? "Archive product" : "Unarchive product", icon: product.active ? "trash" : "check", onSelect: () => toggleActive(product) }] : []),
30
+ { label: "Copy product ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(product.id).then(() => toast("Copied to clipboard")) },
31
+ ], { align: "end" });
32
+ });
33
+ return trigger;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------------------------
37
+ // List
38
+ // ---------------------------------------------------------------------------------------------
39
+
40
+ registerPage("products", async (host, route) => {
41
+ setTitle("Product catalog");
42
+ const tab = route.params.tab ?? "available";
43
+ const actions = [];
44
+ if (canWrite("products")) actions.push(button("Add product", "primary", { icon: "plus", onClick: () => openProductForm() }));
45
+ const header = pageHeader("Product catalog", { actions });
46
+ const sectionTabs = tabs(CATALOG_SECTIONS, "products", () => navigate("#/products"));
47
+ const selectStatus = (next) => navigate(routeHash("products", undefined, { tab: next === "available" ? undefined : next }));
48
+ const tabsHost = el("div");
49
+ const drawStatus = (counts) => tabsHost.replaceChildren(statTabs(PRODUCT_STATUS.map((entry) => ({ ...entry, count: counts?.[entry.key] })), tab, selectStatus));
50
+ drawStatus(undefined);
51
+ const listHost = el("div");
52
+ const filters = el("div", { class: "filters" }, [chip("Created date", { onClick: () => notSimulated("Created date filter") }), chip("Price", { onClick: () => notSimulated("Price filter") }), chip("More filters", { onClick: () => notSimulated("More filters") }), listTools([["Export prices", "download", "Price exports are not produced by this synthetic account."]])]);
53
+ host.replaceChildren(pageInner(header, sectionTabs, tabsHost, filters, listHost));
54
+ if (!canRead("products")) {
55
+ listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "products" }), "products"));
56
+ return;
57
+ }
58
+ const args = { expand: ["data.default_price"] };
59
+ if (tab === "available") args.active = true;
60
+ if (tab === "archived") args.active = false;
61
+ const list = pagedList({
62
+ operation: "products.list",
63
+ args,
64
+ limit: 20,
65
+ render: productRows,
66
+ emptyNode: () => (tab === "archived" ? empty("No archived products", "Archived products stop appearing in pickers but keep their history.") : empty("Add your first product", "Products describe what you sell; prices say how much and how often.", canWrite("products") ? button("Add product", "primary", { icon: "plus", onClick: () => openProductForm() }) : undefined)),
67
+ });
68
+ listHost.append(list.element);
69
+ try {
70
+ const index = await indexAll("products.list", {});
71
+ const suffix = index.complete ? "" : "+";
72
+ const active = index.items.filter((product) => product.active).length;
73
+ drawStatus({ all: `${index.items.length}${suffix}`, available: `${active}${suffix}`, archived: `${index.items.length - active}${suffix}` });
74
+ } catch {
75
+ drawStatus(undefined);
76
+ }
77
+ });
78
+
79
+ const CATALOG_SECTIONS = Object.freeze([
80
+ { key: "products", label: "Products" },
81
+ { key: "coupons", label: "Coupons", notSimulated: "Coupons and promotion codes are not modelled; invoices and subscriptions carry no discounts." },
82
+ { key: "shipping_rates", label: "Shipping rates", notSimulated: "Shipping rates are not modelled by this synthetic account." },
83
+ { key: "tax_rates", label: "Tax rates", notSimulated: "Tax rates are not modelled; invoices carry no tax lines." },
84
+ { key: "pricing_tables", label: "Pricing tables", notSimulated: "Pricing tables are hosted embeds and are not simulated." },
85
+ ]);
86
+ const PRODUCT_STATUS = Object.freeze([
87
+ { key: "all", label: "All" },
88
+ { key: "available", label: "Active" },
89
+ { key: "archived", label: "Archived" },
90
+ ]);
91
+
92
+ // ---------------------------------------------------------------------------------------------
93
+ // Detail
94
+ // ---------------------------------------------------------------------------------------------
95
+
96
+ registerPage("product", async (host, route) => {
97
+ setTitle(route.id);
98
+ const inner = pageInner();
99
+ host.replaceChildren(inner);
100
+ await load(inner, async () => {
101
+ const product = await call("products.retrieve", { product: route.id, expand: ["default_price"] });
102
+ // Every price of the product, paged with starting_after (bounded index; the UI says when it stops early).
103
+ const index = await indexAll("prices.list", { product: product.id });
104
+ return renderProduct(product, index.items, index.complete);
105
+ }, { retry: () => navigate(location.hash) });
106
+ });
107
+
108
+ function renderProduct(product, prices, complete = true) {
109
+ const defaultId = typeof product.default_price === "object" && product.default_price !== null ? product.default_price.id : product.default_price;
110
+ const actions = [];
111
+ if (canWrite("products")) {
112
+ actions.push(button("Add another price", "secondary", { icon: "plus", onClick: () => openPriceForm(product) }));
113
+ actions.push(button("Edit product", "secondary", { icon: "edit", onClick: () => openProductForm(product) }));
114
+ }
115
+ const more = button(undefined, "secondary", { icon: "more", ariaLabel: "More actions", class: "btn-icon-only" });
116
+ more.addEventListener("click", () =>
117
+ openMenu(more, [
118
+ { label: "Copy product ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(product.id).then(() => toast("Copied to clipboard")) },
119
+ ...(canWrite("products") ? ["divider", { label: product.active ? "Archive product" : "Unarchive product", icon: product.active ? "trash" : "check", danger: product.active, onSelect: () => toggleActive(product) }] : []),
120
+ ], { align: "end" }),
121
+ );
122
+ actions.push(more);
123
+ const title = el("div", { class: "detail-title" }, [el("h1", { class: "page-title", text: product.name }), product.active ? badge("Active", "green", "check-circle") : badge("Archived", "gray", "minus-circle")]);
124
+ const header = pageHeader(title, { kind: "Product", actions, breadcrumbs: [{ label: "Product catalog", href: "#/products" }, { label: product.id }] });
125
+ const meta = metaBar([
126
+ ["Default price", defaultId ? priceLabel(prices.find((price) => price.id === defaultId) ?? product.default_price) : el("span", { class: "muted", text: "None" })],
127
+ ["Prices", `${prices.length}${complete ? "" : "+"} price${prices.length === 1 ? "" : "s"}`],
128
+ ["Updated", dateTime(product.updated ?? product.created, now())],
129
+ ["Created", dateTime(product.created, now())],
130
+ ]);
131
+ const details = section("Details", kv([
132
+ ["Name", product.name],
133
+ ["Description", product.description],
134
+ ["Unit label", product.unit_label],
135
+ ["Type", humanize(product.type)],
136
+ ["Statement descriptor", product.statement_descriptor],
137
+ ["Images", product.images?.length ? product.images.join(", ") : null],
138
+ ["ID", idChip(product.id)],
139
+ ]));
140
+ const pricesTable = prices.length === 0 ? empty("No prices yet", "Add a price so this product can be sold.", canWrite("products") ? button("Add a price", "primary", { icon: "plus", onClick: () => openPriceForm(product) }) : undefined) : table({
141
+ columns: [
142
+ { label: "Price", class: "nowrap", render: (price) => el("span", { class: "amount-status" }, [moneyCell(price.unit_amount, price.currency), price.id === defaultId ? badge("Default", "purple") : null]) },
143
+ { label: "Nickname", class: "truncate", render: (price) => price.nickname },
144
+ { label: "Billing", class: "nowrap", render: (price) => intervalLabel(price.recurring) },
145
+ { label: "Lookup key", render: (price) => (price.lookup_key ? el("code", { class: "id-code", text: price.lookup_key }) : null) },
146
+ { label: "Status", class: "nowrap", render: (price) => (price.active ? badge("Active", "green") : badge("Inactive", "gray")) },
147
+ { label: "ID", render: (price) => idChip(price.id) },
148
+ { label: "Created", class: "nowrap", render: (price) => dateCell(price.created) },
149
+ { label: "", class: "actions", render: (price) => priceMenu(product, price, defaultId) },
150
+ ],
151
+ rows: prices,
152
+ });
153
+ const pricesBody = complete ? pricesTable : el("div", {}, [el("p", { class: "muted", text: `Showing the first ${prices.length.toLocaleString("en-US")} prices of this product.` }), pricesTable]);
154
+ const pricesSection = section("Pricing", pricesBody, { count: `${prices.length}${complete ? "" : "+"}`, actions: canWrite("products") ? [button("Add another price", "secondary", { size: "sm", icon: "plus", onClick: () => openPriceForm(product) })] : [] });
155
+ const metadataRows = Object.entries(product.metadata ?? {});
156
+ const metadataSection = section("Metadata", metadataRows.length > 0 ? kv(metadataRows) : el("p", { class: "muted", text: "No metadata" }));
157
+ return [header, meta, pricesSection, details, metadataSection];
158
+ }
159
+
160
+ function priceMenu(product, price, defaultId) {
161
+ const trigger = button(undefined, "ghost", { icon: "more", size: "sm", ariaLabel: `Actions for ${price.id}`, class: "btn-icon-only" });
162
+ trigger.addEventListener("click", () =>
163
+ openMenu(trigger, [
164
+ ...(canWrite("products") && price.id !== defaultId && price.active ? [{ label: "Set as default price", icon: "check", onSelect: () => action(async () => { await call("products.update", { product: product.id, default_price: price.id }, key()); toast("Default price updated"); invalidateCache(); navigate(location.hash); }) }] : []),
165
+ { label: "Copy price ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(price.id).then(() => toast("Copied to clipboard")) },
166
+ ...(canWrite("subscriptions") && price.recurring ? [{ label: "Create subscription with this price", icon: "subscriptions", onSelect: () => navigate(routeHash("subscriptions", "new", { price: price.id })) }] : []),
167
+ { label: "Edit price", icon: "edit", disabled: true, description: "Prices are immutable" },
168
+ ], { align: "end" }),
169
+ );
170
+ return trigger;
171
+ }
172
+
173
+ async function toggleActive(product) {
174
+ const archive = product.active;
175
+ const ok = await confirmDialog(archive ? "Archive product?" : "Unarchive product?", archive ? `${product.name} will be hidden from new invoices and subscriptions. Existing subscriptions keep working.` : `${product.name} becomes available again.`, archive ? "Archive" : "Unarchive", { danger: archive });
176
+ if (!ok) return;
177
+ await action(async () => {
178
+ await call("products.update", { product: product.id, active: !archive }, key());
179
+ toast(archive ? "Product archived" : "Product unarchived");
180
+ invalidateCache();
181
+ navigate(location.hash);
182
+ });
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------------------------
186
+ // Forms
187
+ // ---------------------------------------------------------------------------------------------
188
+
189
+ /** Add (no `existing`) or edit a product; a new product gets an inline first price. */
190
+ export function openProductForm(existing) {
191
+ const name = input({ placeholder: "Premium plan, sunglasses, etc.", value: existing?.name ?? "" });
192
+ const description = textarea({ placeholder: "Appears at checkout, on the customer portal and in quotes", value: existing?.description ?? "", rows: 2 });
193
+ const unitLabel = input({ placeholder: "e.g. seat, unit, hour", value: existing?.unit_label ?? "" });
194
+ const active = el("input", { attrs: { type: "checkbox" } });
195
+ active.checked = existing ? existing.active : true;
196
+ const fields = { name: field("Name", name), description: field("Description", description, { optional: true }), unit_label: field("Unit label", unitLabel, { optional: true }) };
197
+ const body = [fields.name, fields.description, fields.unit_label];
198
+ let priceFields;
199
+ if (!existing) {
200
+ priceFields = priceFieldset();
201
+ body.push(el("div", { class: "form-section-title", text: "Price information" }), ...priceFields.nodes);
202
+ } else body.push(el("label", { class: "check-field" }, [active, el("span", { class: "check-label", text: "Active" }, [el("span", { class: "check-hint", text: "Inactive (archived) products are hidden from new invoices and subscriptions." })])]));
203
+ const idempotencyKey = key();
204
+ openModal({
205
+ title: existing ? "Edit product" : "Add a product",
206
+ size: "modal-lg",
207
+ body,
208
+ actions: [
209
+ { label: "Cancel" },
210
+ {
211
+ label: existing ? "Save product" : "Add product",
212
+ kind: "primary",
213
+ submit: true,
214
+ onClick: (api) => {
215
+ if (!name.value.trim()) {
216
+ fields.name.setError("Name is required.");
217
+ return;
218
+ }
219
+ const args = { name: name.value.trim(), description: description.value.trim() || undefined, unit_label: unitLabel.value.trim() || undefined };
220
+ if (existing) args.active = active.checked;
221
+ let priceArgs;
222
+ if (priceFields) {
223
+ priceArgs = priceFields.read();
224
+ if (priceArgs === undefined) return;
225
+ }
226
+ return submitModal(api, { ...fields, ...(priceFields?.fields ?? {}) }, async () => {
227
+ if (existing) return call("products.update", { product: existing.id, ...args }, idempotencyKey);
228
+ const product = await call("products.create", args, idempotencyKey);
229
+ if (priceArgs && priceArgs.unit_amount !== undefined) {
230
+ const price = await call("prices.create", { product: product.id, ...priceArgs }, key());
231
+ await call("products.update", { product: product.id, default_price: price.id }, key());
232
+ }
233
+ return product;
234
+ }, (product) => {
235
+ toast(existing ? "Product saved" : "Product added");
236
+ invalidateCache();
237
+ navigate(`#/products/${product.id}`);
238
+ });
239
+ },
240
+ },
241
+ ],
242
+ });
243
+ }
244
+
245
+ function priceFieldset(product) {
246
+ const amount = amountInput({ currency: "usd" });
247
+ const model = select([{ value: "one_time", label: "One-off" }, { value: "recurring", label: "Recurring" }], product ? "recurring" : "one_time");
248
+ const interval = select([{ value: "day", label: "Daily" }, { value: "week", label: "Weekly" }, { value: "month", label: "Monthly" }, { value: "year", label: "Yearly" }], "month");
249
+ const intervalCount = input({ type: "number", value: "1", min: 1, max: 12 });
250
+ const nickname = input({ placeholder: "Shown in lists, not to customers" });
251
+ const lookupKey = input({ placeholder: "e.g. summit_monthly (unique among active prices)" });
252
+ const trialDays = input({ type: "number", placeholder: "0", min: 1, max: 730 });
253
+ const fields = {
254
+ unit_amount: field("Amount", amount),
255
+ recurring: field("Billing period", el("div", { class: "field-row" }, [interval, intervalCount])),
256
+ nickname: field("Price description", nickname, { optional: true }),
257
+ lookup_key: field("Lookup key", lookupKey, { optional: true }),
258
+ trial_period_days: field("Free trial days", trialDays, { optional: true }),
259
+ };
260
+ const modelField = field("Pricing model", model);
261
+ const toggle = () => {
262
+ const recurring = model.value === "recurring";
263
+ fields.recurring.hidden = !recurring;
264
+ fields.trial_period_days.hidden = !recurring;
265
+ };
266
+ model.addEventListener("change", toggle);
267
+ toggle();
268
+ return {
269
+ nodes: [fields.unit_amount, modelField, fields.recurring, fields.trial_period_days, fields.nickname, fields.lookup_key],
270
+ fields,
271
+ read() {
272
+ const minor = amount.amount();
273
+ if (minor === undefined || minor < 0) {
274
+ fields.unit_amount.setError("Enter a valid amount, for example 29.00.");
275
+ return undefined;
276
+ }
277
+ const args = { currency: amount.currency(), unit_amount: minor };
278
+ if (nickname.value.trim()) args.nickname = nickname.value.trim();
279
+ if (lookupKey.value.trim()) args.lookup_key = lookupKey.value.trim();
280
+ if (model.value === "recurring") {
281
+ args.recurring = { interval: interval.value, interval_count: Number(intervalCount.value || 1) };
282
+ if (trialDays.value) args.recurring.trial_period_days = Number(trialDays.value);
283
+ }
284
+ return args;
285
+ },
286
+ };
287
+ }
288
+
289
+ export function openPriceForm(product) {
290
+ const set = priceFieldset(product);
291
+ const idempotencyKey = key();
292
+ openModal({
293
+ title: `Add a price to ${product.name}`,
294
+ body: set.nodes,
295
+ actions: [
296
+ { label: "Cancel" },
297
+ {
298
+ label: "Add price",
299
+ kind: "primary",
300
+ submit: true,
301
+ onClick: (api) => {
302
+ const args = set.read();
303
+ if (!args) return;
304
+ return submitModal(api, set.fields, () => call("prices.create", { product: product.id, ...args }, idempotencyKey), () => {
305
+ toast("Price added");
306
+ invalidateCache();
307
+ navigate(location.hash);
308
+ });
309
+ },
310
+ },
311
+ ],
312
+ });
313
+ }
314
+