@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,148 @@
1
+ // Synthetic Stripe account (test mode) for Firedrill. Every operation computes from `context.state`: ids come
2
+ // from the `meta/counters` row, timestamps from the virtual clock, card outcomes from a fixed test-card
3
+ // catalogue. Nothing here contacts Stripe or any card network; no e-mail, receipt or hosted page is served.
4
+ import { FORM_ERROR_KEY, requestArguments } from "./lib/form.mjs";
5
+ import { requireFits } from "./lib/size.mjs";
6
+ import { formError, formErrorEnvelope, responseHeaders, stripeError } from "./lib/wire.mjs";
7
+ import { invoiceItemsCreate, invoicesCreate, invoicesFinalize, invoicesList, invoicesPay, invoicesRetrieve, invoicesVoid, subscriptionsCancel, subscriptionsCreate, subscriptionsList, subscriptionsRetrieve, subscriptionsUpdate } from "./ops/billing.mjs";
8
+ import { pricesCreate, pricesList, pricesRetrieve, productsCreate, productsList, productsRetrieve, productsUpdate } from "./ops/catalog.mjs";
9
+ import { dashboardContext } from "./ops/dashboard.mjs";
10
+ import { customersCreate, customersList, customersRetrieve, customersUpdate, paymentMethodsAttach, paymentMethodsDetach, paymentMethodsList } from "./ops/customers.mjs";
11
+ import { balanceRetrieve, chargesList, chargesRetrieve, paymentIntentsCancel, paymentIntentsCapture, paymentIntentsConfirm, paymentIntentsCreate, paymentIntentsList, paymentIntentsRetrieve, refundsCreate, refundsList } from "./ops/payments.mjs";
12
+
13
+ const handlers = {
14
+ "balance.retrieve": balanceRetrieve,
15
+ "dashboard.context": dashboardContext,
16
+ "customers.create": customersCreate,
17
+ "customers.retrieve": customersRetrieve,
18
+ "customers.update": customersUpdate,
19
+ "customers.list": customersList,
20
+ "payment_methods.list": paymentMethodsList,
21
+ "payment_methods.attach": paymentMethodsAttach,
22
+ "payment_methods.detach": paymentMethodsDetach,
23
+ "payment_intents.create": paymentIntentsCreate,
24
+ "payment_intents.retrieve": paymentIntentsRetrieve,
25
+ "payment_intents.list": paymentIntentsList,
26
+ "payment_intents.confirm": paymentIntentsConfirm,
27
+ "payment_intents.capture": paymentIntentsCapture,
28
+ "payment_intents.cancel": paymentIntentsCancel,
29
+ "charges.retrieve": chargesRetrieve,
30
+ "charges.list": chargesList,
31
+ "refunds.create": refundsCreate,
32
+ "refunds.list": refundsList,
33
+ "products.create": productsCreate,
34
+ "products.retrieve": productsRetrieve,
35
+ "products.update": productsUpdate,
36
+ "products.list": productsList,
37
+ "prices.create": pricesCreate,
38
+ "prices.retrieve": pricesRetrieve,
39
+ "prices.list": pricesList,
40
+ "invoice_items.create": invoiceItemsCreate,
41
+ "invoices.create": invoicesCreate,
42
+ "invoices.retrieve": invoicesRetrieve,
43
+ "invoices.list": invoicesList,
44
+ "invoices.finalize": invoicesFinalize,
45
+ "invoices.pay": invoicesPay,
46
+ "invoices.void": invoicesVoid,
47
+ "subscriptions.create": subscriptionsCreate,
48
+ "subscriptions.retrieve": subscriptionsRetrieve,
49
+ "subscriptions.list": subscriptionsList,
50
+ "subscriptions.update": subscriptionsUpdate,
51
+ "subscriptions.cancel": subscriptionsCancel,
52
+ };
53
+
54
+ /**
55
+ * Every Stripe operation's response must fit one HTTP response (the framework refuses bodies over 1 MiB with an
56
+ * opaque 500 after committing). Lists already fill pages by bytes; this bound covers single objects and their
57
+ * expansions, answering `invalid_request_error` and discarding the operation's writes. `dashboard.context` is a small
58
+ * fixed-shape app read with no declared errors.
59
+ */
60
+ function fitResponse(context, value) {
61
+ // List pages are already filled by bytes in `paginate` (up to PAGE_BYTE_BUDGET); every other value is one object.
62
+ return value !== null && typeof value === "object" && value.object === "list" ? value : requireFits(context, value, "This object");
63
+ }
64
+
65
+ const operations = Object.fromEntries(
66
+ Object.entries(handlers).map(([id, handler]) => [id, id === "dashboard.context" ? handler : (input, context) => fitResponse(context, handler(input, context))]),
67
+ );
68
+
69
+ // ---------------------------------------------------------------------------------------------
70
+ // HTTP codecs: Stripe REST v1 (form-encoded requests, JSON responses, Stripe error envelope)
71
+ // ---------------------------------------------------------------------------------------------
72
+
73
+ function idempotencyKey(request) {
74
+ const values = request.headers["idempotency-key"];
75
+ const key = values === undefined || values.length === 0 ? undefined : values[values.length - 1];
76
+ return key === undefined || key.length === 0 ? {} : { idempotencyKey: key };
77
+ }
78
+
79
+ /**
80
+ * Path parameters + bracket-encoded query/form arguments; `Idempotency-Key` becomes the framework key.
81
+ * A request the form decoder cannot map (reserved segment, nesting or array index over the cap) must still answer in
82
+ * Stripe's envelope, but a throwing `decode` becomes the framework's `HTTP_REQUEST_MAPPING_FAILED`. The decoder's error
83
+ * therefore travels as the only argument under `FORM_ERROR_KEY`: every route's input schema is closed
84
+ * (`additionalProperties: false`), so the framework rejects the call before any handler runs, and `encode` renders the
85
+ * carried error as Stripe's 400 `invalid_request_error`. No idempotency key is forwarded for such a request.
86
+ */
87
+ function decode(request) {
88
+ const decoded = requestArguments(request);
89
+ if (decoded.error !== undefined) return { arguments: { [FORM_ERROR_KEY]: decoded.error } };
90
+ return { arguments: decoded.value, ...idempotencyKey(request) };
91
+ }
92
+
93
+ function route(options = {}) {
94
+ return {
95
+ decode,
96
+ encode({ invocation, outcome }) {
97
+ const headers = responseHeaders(invocation);
98
+ const carried = outcome.status === "invalid" ? formError(invocation.arguments, FORM_ERROR_KEY) : undefined;
99
+ if (carried !== undefined) return { headers, body: { kind: "json", value: formErrorEnvelope(carried, invocation.correlationId) } };
100
+ if (outcome.status !== "ok") return { headers, body: { kind: "json", value: stripeError(outcome, invocation.correlationId) } };
101
+ const value = options.select === undefined ? outcome.value : options.select(outcome.value, invocation);
102
+ return { headers, body: { kind: "json", value } };
103
+ },
104
+ };
105
+ }
106
+
107
+ const http = {
108
+ "retrieve-balance": route(),
109
+ "create-customer": route(),
110
+ "retrieve-customer": route(),
111
+ "update-customer": route(),
112
+ "list-customers": route(),
113
+ "list-payment-methods": route(),
114
+ "list-customer-payment-methods": route({ select: (value, invocation) => ({ ...value, url: `/v1/customers/${invocation.arguments.customer}/payment_methods` }) }),
115
+ "attach-payment-method": route(),
116
+ "detach-payment-method": route(),
117
+ "create-payment-intent": route(),
118
+ "retrieve-payment-intent": route(),
119
+ "list-payment-intents": route(),
120
+ "confirm-payment-intent": route(),
121
+ "capture-payment-intent": route(),
122
+ "cancel-payment-intent": route(),
123
+ "retrieve-charge": route(),
124
+ "list-charges": route(),
125
+ "create-refund": route(),
126
+ "list-refunds": route(),
127
+ "create-product": route(),
128
+ "retrieve-product": route(),
129
+ "update-product": route(),
130
+ "list-products": route(),
131
+ "create-price": route(),
132
+ "retrieve-price": route(),
133
+ "list-prices": route(),
134
+ "create-invoice-item": route(),
135
+ "create-invoice": route(),
136
+ "retrieve-invoice": route(),
137
+ "list-invoices": route(),
138
+ "finalize-invoice": route(),
139
+ "pay-invoice": route(),
140
+ "void-invoice": route(),
141
+ "create-subscription": route(),
142
+ "retrieve-subscription": route(),
143
+ "list-subscriptions": route(),
144
+ "update-subscription": route(),
145
+ "cancel-subscription": route(),
146
+ };
147
+
148
+ export default { operations, http };
@@ -0,0 +1,53 @@
1
+ // The synthetic test-card catalogue: Stripe's published test payment method ids and the outcome each one
2
+ // produces when it is confirmed. Constants only; materialised cards live in the `payment_methods` namespace.
3
+
4
+ const CARDS = Object.freeze({
5
+ pm_card_visa: { brand: "visa", last4: "4242", funding: "credit", country: "US", outcome: "succeed", fingerprint: "Xt5EWLLDS7FJjR1c" },
6
+ pm_card_visa_debit: { brand: "visa", last4: "5556", funding: "debit", country: "US", outcome: "succeed", fingerprint: "k9tPmQ2vR4sX7bLn" },
7
+ pm_card_mastercard: { brand: "mastercard", last4: "4444", funding: "credit", country: "US", outcome: "succeed", fingerprint: "Q7mVdR2sK9pLxT4w" },
8
+ pm_card_amex: { brand: "amex", last4: "8431", funding: "credit", country: "US", outcome: "succeed", fingerprint: "Bv3NcY6hJ8qWzP1r" },
9
+ pm_card_chargeDeclined: { brand: "visa", last4: "0002", funding: "credit", country: "US", outcome: "decline_generic", fingerprint: "Dc0LnX4tG7vKrM2s" },
10
+ pm_card_chargeDeclinedInsufficientFunds: { brand: "visa", last4: "9995", funding: "credit", country: "US", outcome: "decline_insufficient_funds", fingerprint: "If9Pq2Ws5RtY7uB3" },
11
+ pm_card_authenticationRequired: { brand: "visa", last4: "3155", funding: "credit", country: "US", outcome: "requires_action", fingerprint: "Au3Rq8Zt1Vx6NmC5" },
12
+ });
13
+
14
+ export const TEST_CARD_IDS = Object.freeze(Object.keys(CARDS));
15
+
16
+ export function testCard(id) {
17
+ return typeof id === "string" && Object.hasOwn(CARDS, id) ? CARDS[id] : undefined;
18
+ }
19
+
20
+ export function isTestCardId(id) {
21
+ return testCard(id) !== undefined;
22
+ }
23
+
24
+ /** The `card` block of a PaymentMethod built from a catalogue entry. */
25
+ export function cardDetails(card) {
26
+ return {
27
+ brand: card.brand,
28
+ checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: "pass" },
29
+ country: card.country,
30
+ display_brand: card.brand,
31
+ exp_month: 12,
32
+ exp_year: 2034,
33
+ fingerprint: card.fingerprint,
34
+ funding: card.funding,
35
+ generated_from: null,
36
+ last4: card.last4,
37
+ networks: { available: [card.brand], preferred: null },
38
+ regulated_status: "unregulated",
39
+ three_d_secure_usage: { supported: true },
40
+ wallet: null,
41
+ };
42
+ }
43
+
44
+ /** Decline details for the two synthetic decline outcomes; undefined for outcomes that do not decline. */
45
+ export function declineFor(outcome) {
46
+ if (outcome === "decline_generic") {
47
+ return { decline_code: "generic_decline", message: "Your card was declined." };
48
+ }
49
+ if (outcome === "decline_insufficient_funds") {
50
+ return { decline_code: "insufficient_funds", message: "Your card has insufficient funds." };
51
+ }
52
+ return undefined;
53
+ }
@@ -0,0 +1,204 @@
1
+ // Stripe's form encoding: `metadata[order_id]=42`, `items[0][price]=price_x`, `expand[]=customer`,
2
+ // `created[gte]=1700000000`. The framework hands codecs the raw `URLSearchParams` pairs; this module un-flattens
3
+ // them into nested objects/arrays and coerces the integer/boolean parameters this Tool knows. Pure functions only.
4
+ //
5
+ // Tool modules run in the serve process's own JavaScript realm and codecs run inline in its request handler, so the
6
+ // decoder is defensive by construction:
7
+ // - maps are built from `Object.create(null)` and copied out with `Object.fromEntries` (own data properties only), and
8
+ // the segments `__proto__`, `constructor` and `prototype` are rejected, so no caller key can reach a prototype;
9
+ // - nesting depth and array indices are capped, and the walk is iterative, so no request causes deep recursion, a
10
+ // sparse array of length 2^32-1, or other work that is not proportional to the request's own size;
11
+ // - it never throws: a request that cannot be mapped yields `{ error: { code, message, param } }`, which the codec
12
+ // turns into Stripe's 400 `invalid_request_error` envelope (see `behavior.mjs`).
13
+
14
+ /** Bracket segments allowed after the parameter name (`a[b][c]` has two). This Tool's deepest parameter has four. */
15
+ export const MAX_NESTING_DEPTH = 20;
16
+ /** Largest accepted array index; `expand[]`/`items[]` hold at most `MAX_ARRAY_INDEX + 1` entries. */
17
+ export const MAX_ARRAY_INDEX = 999;
18
+ /** Argument key the codec uses to carry a decoding error past schema validation to `encode`. */
19
+ export const FORM_ERROR_KEY = "firedrill:form_error";
20
+
21
+ const RESERVED_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
22
+ const DISPLAY_LIMIT = 120;
23
+
24
+ const INTEGER_KEYS = new Set([
25
+ "amount",
26
+ "amount_to_capture",
27
+ "limit",
28
+ "quantity",
29
+ "unit_amount",
30
+ "days_until_due",
31
+ "trial_period_days",
32
+ "interval_count",
33
+ "cancel_at",
34
+ "billing_cycle_anchor",
35
+ "start",
36
+ "end",
37
+ "gt",
38
+ "gte",
39
+ "lt",
40
+ "lte",
41
+ ]);
42
+ const INTEGER_OR_OBJECT_KEYS = new Set(["created", "due_date", "current_period_start", "current_period_end"]);
43
+ const INTEGER_OR_STRING_KEYS = new Set(["trial_end"]);
44
+ const BOOLEAN_KEYS = new Set(["confirm", "active", "cancel_at_period_end", "auto_advance", "paid_out_of_band", "off_session", "deleted", "enabled"]);
45
+ const OPAQUE_PARENTS = new Set(["metadata"]);
46
+
47
+ class FormError {
48
+ constructor(code, message, param) {
49
+ this.error = { code, message, param };
50
+ }
51
+ }
52
+
53
+ function display(key) {
54
+ return key.length > DISPLAY_LIMIT ? `${key.slice(0, DISPLAY_LIMIT)}...` : key;
55
+ }
56
+
57
+ /**
58
+ * `items[0][price]` → `["items", "0", "price"]`. A key that is not of the form `name[seg]...[seg]` stays one opaque
59
+ * parameter name (the canonical schema rejects it as unknown). Linear scan; stops as soon as the depth cap is exceeded.
60
+ */
61
+ function segments(key) {
62
+ const open = key.indexOf("[");
63
+ if (open <= 0 || key.indexOf("]") < open) return [key];
64
+ const parts = [key.slice(0, open)];
65
+ let position = open;
66
+ while (position < key.length) {
67
+ if (key[position] !== "[") return [key];
68
+ const close = key.indexOf("]", position + 1);
69
+ if (close === -1) return [key];
70
+ const segment = key.slice(position + 1, close);
71
+ if (segment.includes("[")) return [key];
72
+ if (parts.length > MAX_NESTING_DEPTH) {
73
+ throw new FormError("parameter_invalid", `Invalid parameter: ${display(key)} is nested more than ${MAX_NESTING_DEPTH} levels deep.`, display(key));
74
+ }
75
+ parts.push(segment);
76
+ position = close + 1;
77
+ }
78
+ return parts;
79
+ }
80
+
81
+ function coerce(parent, key, value) {
82
+ if (OPAQUE_PARENTS.has(parent)) return value;
83
+ if (INTEGER_KEYS.has(key) || INTEGER_OR_OBJECT_KEYS.has(key)) {
84
+ if (/^-?[0-9]{1,15}$/.test(value)) return Number(value);
85
+ if (/^-?[0-9]{1,15}\.[0-9]{1,15}$/.test(value)) return Number(value);
86
+ return value;
87
+ }
88
+ if (INTEGER_OR_STRING_KEYS.has(key)) return /^[0-9]{1,15}$/.test(value) ? Number(value) : value;
89
+ if (BOOLEAN_KEYS.has(key)) {
90
+ if (value === "true") return true;
91
+ if (value === "false") return false;
92
+ return value;
93
+ }
94
+ return value;
95
+ }
96
+
97
+ function isMap(value) {
98
+ return typeof value === "object" && value !== null && !Array.isArray(value);
99
+ }
100
+
101
+ function arrayIndex(key, name, array, segment) {
102
+ // Leading zeros are not significant (`items[0001]` is index 1, as Stripe reads it); a digit run too long to be a
103
+ // safe integer is simply over the cap and gets the same "0 to MAX_ARRAY_INDEX" message.
104
+ const digits = segment === "" ? null : segment.replace(/^0+(?=[0-9])/, "");
105
+ const index = digits === null ? array.length : digits.length <= 15 ? Number(digits) : Number.POSITIVE_INFINITY;
106
+ if (index > MAX_ARRAY_INDEX) {
107
+ throw new FormError(
108
+ "parameter_invalid",
109
+ `Invalid array: ${display(key)}. The ${name} parameter accepts at most ${MAX_ARRAY_INDEX + 1} entries, indexed from 0 to ${MAX_ARRAY_INDEX}.`,
110
+ display(key),
111
+ );
112
+ }
113
+ return index;
114
+ }
115
+
116
+ /** Iterative write of one `parts → value` pair into a null-prototype tree. */
117
+ function assign(root, key, parts, value) {
118
+ let target = root;
119
+ let parentKey = "";
120
+ let position = 0;
121
+ while (position < parts.length) {
122
+ const head = parts[position];
123
+ if (position === parts.length - 1) {
124
+ target[head] = coerce(parentKey, head, value);
125
+ return;
126
+ }
127
+ const next = parts[position + 1];
128
+ if (next === "" || /^[0-9]+$/.test(next)) {
129
+ if (!Array.isArray(target[head])) target[head] = [];
130
+ const array = target[head];
131
+ const index = arrayIndex(key, head, array, next);
132
+ if (position + 1 === parts.length - 1) {
133
+ array[index] = coerce(head, head, value);
134
+ return;
135
+ }
136
+ if (next === "" || !isMap(array[index])) array[index] = Object.create(null);
137
+ target = array[index];
138
+ parentKey = head;
139
+ position += 2;
140
+ continue;
141
+ }
142
+ if (!isMap(target[head])) target[head] = Object.create(null);
143
+ target = target[head];
144
+ parentKey = head;
145
+ position += 1;
146
+ }
147
+ }
148
+
149
+ /** Null-prototype tree → plain JSON value. Depth is bounded by `MAX_NESTING_DEPTH`, array length by the index cap. */
150
+ function compact(value) {
151
+ if (Array.isArray(value)) {
152
+ // Walk the entries the request actually set (`Object.keys` of an array yields its present indices in ascending
153
+ // order), so a sparse `x[999]=1` costs one step rather than a thousand: the cost stays proportional to the
154
+ // request's own size however many sparse arrays it builds.
155
+ const out = [];
156
+ for (const index of Object.keys(value)) if (value[index] !== undefined) out.push(compact(value[index]));
157
+ return out;
158
+ }
159
+ if (isMap(value)) return Object.fromEntries(Object.keys(value).map((key) => [key, compact(value[key])]));
160
+ return value;
161
+ }
162
+
163
+ function unflattenOrThrow(record) {
164
+ const target = Object.create(null);
165
+ for (const key of Object.keys(record)) {
166
+ const values = record[key];
167
+ const parts = segments(key);
168
+ const reserved = parts.find((part) => RESERVED_SEGMENTS.has(part));
169
+ if (reserved !== undefined || parts[0] === FORM_ERROR_KEY) {
170
+ throw new FormError("parameter_unknown", `Received unknown parameter: ${display(key)}`, display(key));
171
+ }
172
+ for (const value of values) assign(target, key, parts, typeof value === "string" ? value : String(value));
173
+ }
174
+ return compact(target);
175
+ }
176
+
177
+ function guarded(run) {
178
+ try {
179
+ return { value: run() };
180
+ } catch (error) {
181
+ if (error instanceof FormError) return { error: error.error };
182
+ return { error: { code: "parameter_invalid", message: "Invalid request: the form parameters could not be decoded.", param: undefined } };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * `Record<string, string[]>` (form body or query string) → `{ value }` with nested arguments, or `{ error }` when the
188
+ * request cannot be mapped. Repeated plain keys keep the last value (Stripe's behaviour for duplicated scalar
189
+ * parameters); `expand[]`, `ids[]`, `items[0][price]` build arrays. Integer-looking values of known integer parameters
190
+ * become numbers, `true`/`false` of known boolean parameters become booleans; anything else stays a string for the
191
+ * canonical schema to judge.
192
+ */
193
+ export function unflatten(record) {
194
+ return guarded(() => unflattenOrThrow(record));
195
+ }
196
+
197
+ /** Path parameters plus query and form arguments merged into one canonical argument object, or `{ error }`. */
198
+ export function requestArguments(request) {
199
+ return guarded(() => {
200
+ const query = unflattenOrThrow(request.query);
201
+ const body = request.body.kind === "form" ? unflattenOrThrow(request.body.value) : {};
202
+ return { ...query, ...body, ...request.path };
203
+ });
204
+ }
@@ -0,0 +1,85 @@
1
+ // Deterministic Stripe-style identifiers. Ids are `<prefix>_<6 base-36 sequence chars><8 hash chars>`: the
2
+ // zero-padded, upper-case base-36 object sequence keeps a namespace scan in creation order, the 8-character
3
+ // FNV-1a-derived suffix is a checksum only and is never parsed. Nothing here keeps state or reads a clock.
4
+
5
+ const B36 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
6
+ const B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7
+
8
+ /** 32-bit FNV-1a of a string, as an unsigned integer. */
9
+ export function fnv1a(text, seed = 0x811c9dc5) {
10
+ let hash = seed >>> 0;
11
+ for (let index = 0; index < text.length; index += 1) {
12
+ hash ^= text.charCodeAt(index);
13
+ hash = Math.imul(hash, 0x01000193) >>> 0;
14
+ }
15
+ return hash >>> 0;
16
+ }
17
+
18
+ function encode(number, alphabet, width) {
19
+ let out = "";
20
+ let value = number;
21
+ do {
22
+ out = alphabet[value % alphabet.length] + out;
23
+ value = Math.floor(value / alphabet.length);
24
+ } while (value > 0);
25
+ return out.padStart(width, alphabet[0]).slice(-width);
26
+ }
27
+
28
+ /** A deterministic base-62 token of `length` characters derived from `seed`. */
29
+ export function token(seed, length) {
30
+ let out = "";
31
+ let round = 0;
32
+ while (out.length < length) {
33
+ out += encode(fnv1a(`${seed}#${round}`), B62, 6);
34
+ round += 1;
35
+ }
36
+ return out.slice(0, length);
37
+ }
38
+
39
+ /** Render the id of the `sequence`-th object with a Stripe prefix (`cus`, `pi`, `ch`, ...). */
40
+ export function renderId(prefix, sequence) {
41
+ return `${prefix}_${encode(sequence, B36, 6)}${token(`${prefix}_${sequence}`, 8)}`;
42
+ }
43
+
44
+ /** Sequence number encoded in an id rendered by `renderId`, or undefined for foreign ids. */
45
+ export function sequenceOf(id) {
46
+ const match = /^[a-z]+_([0-9A-Z]{6})[0-9A-Za-z]{8}$/.exec(id);
47
+ if (match === null) return undefined;
48
+ let value = 0;
49
+ for (const character of match[1]) value = value * 36 + B36.indexOf(character);
50
+ return value;
51
+ }
52
+
53
+ const COUNTER_START = 1000;
54
+
55
+ /** Allocate the next object sequence from the `meta/counters` row (created lazily at 1000). */
56
+ export function nextSequence(context) {
57
+ const stored = context.state.get("meta", "counters");
58
+ const next = stored === null || typeof stored.next !== "number" ? COUNTER_START : stored.next;
59
+ context.state.put("meta", "counters", { next: next + 1 });
60
+ return next;
61
+ }
62
+
63
+ export function nextId(context, prefix) {
64
+ return renderId(prefix, nextSequence(context));
65
+ }
66
+
67
+ /** Customer invoice prefix: eight upper-case alphanumerics derived from the customer sequence. */
68
+ export function invoicePrefix(sequence) {
69
+ return token(`invoice_prefix_${sequence}`, 8).toUpperCase();
70
+ }
71
+
72
+ /** A PaymentIntent client secret: never usable anywhere, deterministic per intent. */
73
+ export function clientSecret(intentId) {
74
+ return `${intentId}_secret_${token(`secret_${intentId}`, 24)}`;
75
+ }
76
+
77
+ /** Acquirer reference number shown on refunds' destination details. */
78
+ export function acquirerReference(refundId) {
79
+ return token(`arn_${refundId}`, 24).replace(/[a-z]/g, (character) => character.toUpperCase());
80
+ }
81
+
82
+ /** Deterministic 0–99 risk score for a charge outcome. */
83
+ export function riskScore(chargeId) {
84
+ return fnv1a(`risk_${chargeId}`) % 100;
85
+ }
@@ -0,0 +1,35 @@
1
+ // Amounts are integers in minor units; currencies are a fixed lower-case ISO-4217 list. Pure functions only.
2
+
3
+ export const CURRENCIES = Object.freeze(["usd", "eur", "gbp", "cad", "aud", "chf", "sek", "nok", "dkk", "jpy", "nzd", "sgd"]);
4
+ export const MIN_CHARGE_AMOUNT = 50;
5
+ export const MAX_CHARGE_AMOUNT = 99_999_999;
6
+
7
+ const SYMBOLS = { usd: "$", eur: "€", gbp: "£", cad: "CA$", aud: "A$", chf: "CHF ", sek: "kr ", nok: "kr ", dkk: "kr ", jpy: "¥", nzd: "NZ$", sgd: "S$" };
8
+
9
+ export function isCurrency(value) {
10
+ return typeof value === "string" && CURRENCIES.includes(value.toLowerCase());
11
+ }
12
+
13
+ export function normalizeCurrency(value) {
14
+ return value.toLowerCase();
15
+ }
16
+
17
+ export function isInteger(value) {
18
+ return typeof value === "number" && Number.isInteger(value);
19
+ }
20
+
21
+ /** `$12.50` / `€3.00` / `¥500` formatting used in Stripe's error messages. */
22
+ export function formatAmount(amount, currency) {
23
+ const symbol = SYMBOLS[currency] ?? `${currency.toUpperCase()} `;
24
+ if (currency === "jpy") return `${symbol}${amount}`;
25
+ const sign = amount < 0 ? "-" : "";
26
+ const absolute = Math.abs(amount);
27
+ const major = Math.floor(absolute / 100);
28
+ const minor = String(absolute % 100).padStart(2, "0");
29
+ return `${sign}${symbol}${major}.${minor}`;
30
+ }
31
+
32
+ /** Legacy `unit_amount_decimal` / `amount_decimal` strings (Stripe renders integers as decimal strings). */
33
+ export function decimalString(amount) {
34
+ return String(amount);
35
+ }