@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.
- package/LICENSE +201 -0
- package/README.md +433 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/api-unavailable.scenario.json +11 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/refund-committed-lost.scenario.json +11 -0
- package/firedrill/stripe-api-unavailable.drill.json +463 -0
- package/firedrill/stripe-denied.drill.json +74 -0
- package/firedrill/stripe-large-pages.drill.json +153 -0
- package/firedrill/stripe-live-mode.drill.json +136 -0
- package/firedrill/stripe-mcp-aliases.drill.json +406 -0
- package/firedrill/stripe-no-permissions.drill.json +1143 -0
- package/firedrill/stripe-rate-limited.drill.json +616 -0
- package/firedrill/stripe-refund-committed-lost.drill.json +139 -0
- package/firedrill/stripe-rest-flow.drill.json +1401 -0
- package/firedrill/stripe-restricted-key.drill.json +171 -0
- package/firedrill/tools/stripe/app/assets/ATTRIBUTION.md +36 -0
- package/firedrill/tools/stripe/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/stripe/app/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/app.js +456 -0
- package/firedrill/tools/stripe/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/stripe/app/site/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/site/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/icons.js +90 -0
- package/firedrill/tools/stripe/app/site/index.html +137 -0
- package/firedrill/tools/stripe/app/site/pages-billing.js +902 -0
- package/firedrill/tools/stripe/app/site/pages-catalog.js +314 -0
- package/firedrill/tools/stripe/app/site/pages-customers.js +416 -0
- package/firedrill/tools/stripe/app/site/pages-home.js +373 -0
- package/firedrill/tools/stripe/app/site/pages-payments.js +502 -0
- package/firedrill/tools/stripe/app/site/store.js +99 -0
- package/firedrill/tools/stripe/app/site/styles.css +2512 -0
- package/firedrill/tools/stripe/app/site/ui.js +767 -0
- package/firedrill/tools/stripe/app/site/widgets.js +707 -0
- package/firedrill/tools/stripe/behavior.mjs +148 -0
- package/firedrill/tools/stripe/lib/cards.mjs +53 -0
- package/firedrill/tools/stripe/lib/form.mjs +204 -0
- package/firedrill/tools/stripe/lib/ids.mjs +85 -0
- package/firedrill/tools/stripe/lib/money.mjs +35 -0
- package/firedrill/tools/stripe/lib/objects.mjs +229 -0
- package/firedrill/tools/stripe/lib/periods.mjs +41 -0
- package/firedrill/tools/stripe/lib/size.mjs +55 -0
- package/firedrill/tools/stripe/lib/state.mjs +230 -0
- package/firedrill/tools/stripe/lib/validate.mjs +184 -0
- package/firedrill/tools/stripe/lib/wire.mjs +98 -0
- package/firedrill/tools/stripe/ops/billing.mjs +914 -0
- package/firedrill/tools/stripe/ops/catalog.mjs +203 -0
- package/firedrill/tools/stripe/ops/customers.mjs +241 -0
- package/firedrill/tools/stripe/ops/dashboard.mjs +29 -0
- package/firedrill/tools/stripe/ops/payments.mjs +608 -0
- package/firedrill/tools/stripe/stripe.tool.json +28833 -0
- package/firedrill/world.json +8527 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +7999 -0
- package/test/conformance.mjs +1133 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Rendering stored rows as Stripe API objects (private fields stripped, `livemode` stamped from the actor,
|
|
2
|
+
// derived fields recomputed) and `expand[]` handling from a fixed allow-list. Reads state, never writes it.
|
|
3
|
+
|
|
4
|
+
import { allRows, getRow, invalid, livemode, nowSeconds } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
const PREFIX_NAMESPACES = {
|
|
7
|
+
cus: "customers",
|
|
8
|
+
pm: "payment_methods",
|
|
9
|
+
pi: "payment_intents",
|
|
10
|
+
ch: "charges",
|
|
11
|
+
re: "refunds",
|
|
12
|
+
prod: "products",
|
|
13
|
+
price: "prices",
|
|
14
|
+
ii: "invoice_items",
|
|
15
|
+
in: "invoices",
|
|
16
|
+
sub: "subscriptions",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const TYPES = Object.freeze({
|
|
20
|
+
customers: "customer",
|
|
21
|
+
payment_methods: "payment_method",
|
|
22
|
+
payment_intents: "payment_intent",
|
|
23
|
+
charges: "charge",
|
|
24
|
+
refunds: "refund",
|
|
25
|
+
products: "product",
|
|
26
|
+
prices: "price",
|
|
27
|
+
invoice_items: "invoiceitem",
|
|
28
|
+
invoices: "invoice",
|
|
29
|
+
subscriptions: "subscription",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Expandable properties per object type (one level; parents are expanded implicitly). */
|
|
33
|
+
export const EXPANDABLE = Object.freeze({
|
|
34
|
+
balance: [],
|
|
35
|
+
customer: ["invoice_settings.default_payment_method"],
|
|
36
|
+
payment_method: ["customer"],
|
|
37
|
+
payment_intent: ["customer", "payment_method", "latest_charge"],
|
|
38
|
+
charge: ["customer", "payment_intent", "payment_method", "refunds"],
|
|
39
|
+
refund: ["charge", "payment_intent"],
|
|
40
|
+
product: ["default_price"],
|
|
41
|
+
price: ["product"],
|
|
42
|
+
invoiceitem: ["customer", "invoice"],
|
|
43
|
+
invoice: ["customer", "default_payment_method", "payments.data.payment.payment_intent", "lines.data.pricing.price_details.price", "parent.subscription_details.subscription"],
|
|
44
|
+
subscription: ["customer", "default_payment_method", "latest_invoice", "latest_invoice.payments.data.payment.payment_intent"],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Per-call view: the actor's mode plus memoised derived data (past-due customers). */
|
|
48
|
+
export function makeView(context) {
|
|
49
|
+
const view = { context, now: nowSeconds(context), livemode: livemode(context), memo: {} };
|
|
50
|
+
return view;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pastDueCustomers(view) {
|
|
54
|
+
if (view.memo.pastDue === undefined) {
|
|
55
|
+
const set = new Set();
|
|
56
|
+
for (const invoice of allRows(view.context, "invoices")) {
|
|
57
|
+
if (invoice.status === "open" && typeof invoice.due_date === "number" && invoice.due_date < view.now) set.add(invoice.customer);
|
|
58
|
+
}
|
|
59
|
+
view.memo.pastDue = set;
|
|
60
|
+
}
|
|
61
|
+
return view.memo.pastDue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------------------------
|
|
65
|
+
// Renderers
|
|
66
|
+
// ---------------------------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export function renderCustomer(view, row) {
|
|
69
|
+
return { ...row, delinquent: row.delinquent === true || pastDueCustomers(view).has(row.id), livemode: view.livemode };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function renderPaymentMethod(view, row) {
|
|
73
|
+
const { outcome, ...rest } = row;
|
|
74
|
+
void outcome;
|
|
75
|
+
return { ...rest, livemode: view.livemode };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderPaymentIntent(view, row) {
|
|
79
|
+
const { invoice, ...rest } = row;
|
|
80
|
+
void invoice;
|
|
81
|
+
const value = { ...rest, livemode: view.livemode };
|
|
82
|
+
if (value.last_payment_error !== null && typeof value.last_payment_error.payment_method === "string") {
|
|
83
|
+
const method = getRow(view.context, "payment_methods", value.last_payment_error.payment_method);
|
|
84
|
+
value.last_payment_error = { ...value.last_payment_error, payment_method: method === null ? value.last_payment_error.payment_method : renderPaymentMethod(view, method) };
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderCharge(view, row) {
|
|
90
|
+
return { ...row, livemode: view.livemode };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function renderRefund(view, row) {
|
|
94
|
+
void view;
|
|
95
|
+
return { ...row };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function renderProduct(view, row) {
|
|
99
|
+
return { ...row, livemode: view.livemode };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderPrice(view, row) {
|
|
103
|
+
return { ...row, livemode: view.livemode };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function renderInvoiceItem(view, row) {
|
|
107
|
+
return { ...row, livemode: view.livemode };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function renderInvoice(view, row) {
|
|
111
|
+
return {
|
|
112
|
+
...row,
|
|
113
|
+
livemode: view.livemode,
|
|
114
|
+
lines: { ...row.lines, data: row.lines.data.map((line) => ({ ...line, livemode: view.livemode })) },
|
|
115
|
+
payments: { ...row.payments, data: row.payments.data.map((payment) => ({ ...payment, livemode: view.livemode })) },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function renderSubscription(view, row) {
|
|
120
|
+
return {
|
|
121
|
+
...row,
|
|
122
|
+
livemode: view.livemode,
|
|
123
|
+
items: {
|
|
124
|
+
...row.items,
|
|
125
|
+
data: row.items.data.map((item) => ({ ...item, price: { ...item.price, livemode: view.livemode }, plan: { ...item.plan, livemode: view.livemode } })),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const RENDERERS = {
|
|
131
|
+
customers: renderCustomer,
|
|
132
|
+
payment_methods: renderPaymentMethod,
|
|
133
|
+
payment_intents: renderPaymentIntent,
|
|
134
|
+
charges: renderCharge,
|
|
135
|
+
refunds: renderRefund,
|
|
136
|
+
products: renderProduct,
|
|
137
|
+
prices: renderPrice,
|
|
138
|
+
invoice_items: renderInvoiceItem,
|
|
139
|
+
invoices: renderInvoice,
|
|
140
|
+
subscriptions: renderSubscription,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function renderRow(view, namespace, row) {
|
|
144
|
+
return RENDERERS[namespace](view, row);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Render the object an id refers to (by prefix), or return the id when it cannot be resolved. */
|
|
148
|
+
export function renderById(view, id) {
|
|
149
|
+
if (typeof id !== "string") return id;
|
|
150
|
+
const prefix = id.split("_", 1)[0];
|
|
151
|
+
const namespace = Object.hasOwn(PREFIX_NAMESPACES, prefix) ? PREFIX_NAMESPACES[prefix] : undefined;
|
|
152
|
+
if (namespace === undefined) return id;
|
|
153
|
+
const row = getRow(view.context, namespace, id);
|
|
154
|
+
return row === null ? id : renderRow(view, namespace, row);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Refunds shown inline in an expanded `charge.refunds` list; Stripe embeds the first page and sets `has_more`. */
|
|
158
|
+
export const EMBEDDED_REFUNDS = 10;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The expandable `refunds` list of a charge, computed from the `refunds` namespace. Refunds are grouped by charge
|
|
162
|
+
* once per call (a charges list expanding `data.refunds` scans the namespace once, not once per charge), newest
|
|
163
|
+
* first; like Stripe, the embedded list carries the first `EMBEDDED_REFUNDS` with `has_more` and `total_count`, and
|
|
164
|
+
* the rest are listed with `GET /v1/refunds?charge=`.
|
|
165
|
+
*/
|
|
166
|
+
export function chargeRefunds(view, chargeId) {
|
|
167
|
+
if (view.memo.refundsByCharge === undefined) {
|
|
168
|
+
const groups = new Map();
|
|
169
|
+
for (const refund of allRows(view.context, "refunds")) {
|
|
170
|
+
const group = groups.get(refund.charge);
|
|
171
|
+
if (group === undefined) groups.set(refund.charge, [refund]);
|
|
172
|
+
else group.push(refund);
|
|
173
|
+
}
|
|
174
|
+
for (const group of groups.values()) group.sort((left, right) => (right.created - left.created) || (left.id < right.id ? 1 : -1));
|
|
175
|
+
view.memo.refundsByCharge = groups;
|
|
176
|
+
}
|
|
177
|
+
const all = view.memo.refundsByCharge.get(chargeId) ?? [];
|
|
178
|
+
const data = all.slice(0, EMBEDDED_REFUNDS).map((refund) => renderRefund(view, refund));
|
|
179
|
+
return { object: "list", data, has_more: all.length > data.length, total_count: all.length, url: `/v1/charges/${chargeId}/refunds` };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------------------------
|
|
183
|
+
// expand[]
|
|
184
|
+
// ---------------------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** Validate `expand` against the allow-list of `type` (`data.` prefix for lists); returns the normalised paths. */
|
|
187
|
+
export function validateExpand(context, expand, type, isList = false) {
|
|
188
|
+
if (expand === undefined) return [];
|
|
189
|
+
if (!Array.isArray(expand) || expand.some((entry) => typeof entry !== "string")) {
|
|
190
|
+
return invalid(context, "Invalid array: expand must be an array of strings.", "parameter_invalid", "expand");
|
|
191
|
+
}
|
|
192
|
+
const allowed = EXPANDABLE[type] ?? [];
|
|
193
|
+
const paths = [];
|
|
194
|
+
for (const raw of expand) {
|
|
195
|
+
let path = raw;
|
|
196
|
+
if (isList) {
|
|
197
|
+
if (!path.startsWith("data.")) return invalid(context, `This property cannot be expanded (${raw}). Expand list items with the 'data.' prefix.`, "parameter_invalid", "expand");
|
|
198
|
+
path = path.slice("data.".length);
|
|
199
|
+
}
|
|
200
|
+
if (!allowed.includes(path)) return invalid(context, `This property cannot be expanded (${raw}).`, "parameter_invalid", "expand");
|
|
201
|
+
paths.push(path);
|
|
202
|
+
}
|
|
203
|
+
return paths.sort((left, right) => left.split(".").length - right.split(".").length);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function expandInto(view, target, segments, special) {
|
|
207
|
+
if (target === null || typeof target !== "object") return target;
|
|
208
|
+
const [head, ...rest] = segments;
|
|
209
|
+
if (target.object === "list" && head === "data" && Array.isArray(target.data)) {
|
|
210
|
+
return { ...target, data: target.data.map((item) => expandInto(view, item, rest, special)) };
|
|
211
|
+
}
|
|
212
|
+
if (rest.length === 0) {
|
|
213
|
+
if (head === "refunds" && special === "charge") return { ...target, refunds: chargeRefunds(view, target.id) };
|
|
214
|
+
return { ...target, [head]: renderById(view, target[head]) };
|
|
215
|
+
}
|
|
216
|
+
let child = target[head];
|
|
217
|
+
if (typeof child === "string") child = renderById(view, child);
|
|
218
|
+
return { ...target, [head]: expandInto(view, child, rest, special) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Apply validated expand paths to a rendered object or list. */
|
|
222
|
+
export function applyExpand(view, value, paths, type, isList = false) {
|
|
223
|
+
let out = value;
|
|
224
|
+
for (const path of paths) {
|
|
225
|
+
const segments = (isList ? "data." + path : path).split(".");
|
|
226
|
+
out = expandInto(view, out, segments, type);
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Billing-period arithmetic on UTC calendar fields. Only `Date.UTC` and explicit epoch construction are used;
|
|
2
|
+
// nothing here reads the wall clock.
|
|
3
|
+
|
|
4
|
+
export const DAY = 86_400;
|
|
5
|
+
|
|
6
|
+
export const INTERVALS = Object.freeze(["day", "week", "month", "year"]);
|
|
7
|
+
export const MAX_INTERVAL_COUNT = Object.freeze({ day: 365, week: 52, month: 12, year: 1 });
|
|
8
|
+
|
|
9
|
+
function utcFields(seconds) {
|
|
10
|
+
const date = new Date(seconds * 1_000);
|
|
11
|
+
return {
|
|
12
|
+
year: date.getUTCFullYear(),
|
|
13
|
+
month: date.getUTCMonth(),
|
|
14
|
+
day: date.getUTCDate(),
|
|
15
|
+
hour: date.getUTCHours(),
|
|
16
|
+
minute: date.getUTCMinutes(),
|
|
17
|
+
second: date.getUTCSeconds(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function daysInMonth(year, month) {
|
|
22
|
+
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `seconds` advanced by `count` intervals; month/year steps clamp to the last day of the target month. */
|
|
26
|
+
export function addInterval(seconds, interval, count) {
|
|
27
|
+
if (interval === "day") return seconds + count * DAY;
|
|
28
|
+
if (interval === "week") return seconds + count * 7 * DAY;
|
|
29
|
+
const fields = utcFields(seconds);
|
|
30
|
+
const months = interval === "year" ? count * 12 : count;
|
|
31
|
+
const total = fields.year * 12 + fields.month + months;
|
|
32
|
+
const year = Math.floor(total / 12);
|
|
33
|
+
const month = total - year * 12;
|
|
34
|
+
const day = Math.min(fields.day, daysInMonth(year, month));
|
|
35
|
+
return Math.floor(Date.UTC(year, month, day, fields.hour, fields.minute, fields.second) / 1_000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** ISO-8601 (`2026-09-14T09:00:00Z`) for logs and messages. */
|
|
39
|
+
export function isoSeconds(seconds) {
|
|
40
|
+
return new Date(seconds * 1_000).toISOString().replace(/\.000Z$/, "Z");
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Response byte budget. The framework refuses any HTTP route response larger than 1 MiB (answering an opaque 500
|
|
2
|
+
// after the operation committed), so list pages are filled by the encoded UTF-8 size of the objects actually
|
|
3
|
+
// returned, and a single object that cannot fit answers Stripe's own `invalid_request_error` instead.
|
|
4
|
+
// Sizes are UTF-8 bytes of `JSON.stringify` output, computed from code points (no Buffer in behavior modules).
|
|
5
|
+
|
|
6
|
+
import { invalid } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
/** Largest encoded single object (with its expansions) this Tool returns, alone or as one list item. */
|
|
9
|
+
export const OBJECT_BYTE_BUDGET = 900_000;
|
|
10
|
+
/**
|
|
11
|
+
* Bytes of list items (objects plus separators) one page may carry. At least `OBJECT_BYTE_BUDGET`, so every object
|
|
12
|
+
* that can be retrieved alone can also be listed; the list envelope, headers and a route's `url` rewrite fit in the
|
|
13
|
+
* remaining space below the framework's 1 MiB response cap.
|
|
14
|
+
*/
|
|
15
|
+
export const PAGE_BYTE_BUDGET = 950_000;
|
|
16
|
+
|
|
17
|
+
/** UTF-8 byte length of a string: 1-3 bytes per BMP code unit, 4 per surrogate pair, 3 for a lone surrogate. */
|
|
18
|
+
export function utf8Bytes(text) {
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
const length = text.length;
|
|
21
|
+
for (let index = 0; index < length; index += 1) {
|
|
22
|
+
const unit = text.charCodeAt(index);
|
|
23
|
+
if (unit < 0x80) bytes += 1;
|
|
24
|
+
else if (unit < 0x800) bytes += 2;
|
|
25
|
+
else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < length) {
|
|
26
|
+
const next = text.charCodeAt(index + 1);
|
|
27
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
28
|
+
bytes += 4;
|
|
29
|
+
index += 1;
|
|
30
|
+
} else bytes += 3;
|
|
31
|
+
} else bytes += 3;
|
|
32
|
+
}
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Encoded JSON size of a value, in UTF-8 bytes. */
|
|
37
|
+
export function jsonBytes(value) {
|
|
38
|
+
const text = JSON.stringify(value);
|
|
39
|
+
return text === undefined ? 0 : utf8Bytes(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Fail with `invalid_request_error` naming `what` ("The invoice in_…", "This object") and its encoded size. */
|
|
43
|
+
export function tooLargeObject(context, what, bytes) {
|
|
44
|
+
return invalid(
|
|
45
|
+
context,
|
|
46
|
+
`${what} is too large to return in one response (${bytes} bytes; the limit is ${OBJECT_BYTE_BUDGET} bytes). Reduce its metadata or text fields, or request fewer expansions.`,
|
|
47
|
+
"parameter_invalid",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Return `value` when its encoded body fits the object budget, otherwise fail with the declared error. */
|
|
52
|
+
export function requireFits(context, value, what) {
|
|
53
|
+
const bytes = jsonBytes(value);
|
|
54
|
+
return bytes > OBJECT_BYTE_BUDGET ? tooLargeObject(context, what, bytes) : value;
|
|
55
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Declared-error helpers, bounded scans, virtual-time seconds, permissions and list pagination. Every function
|
|
2
|
+
// is pure or reads `context`; nothing keeps module-level state.
|
|
3
|
+
import { OBJECT_BYTE_BUDGET, PAGE_BYTE_BUDGET, jsonBytes, tooLargeObject } from "./size.mjs";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export const SCAN_STEP = 500;
|
|
7
|
+
export const SCAN_CAP = 10_000;
|
|
8
|
+
export const MAX_LIST_LIMIT = 100;
|
|
9
|
+
export const DEFAULT_LIST_LIMIT = 10;
|
|
10
|
+
|
|
11
|
+
const OBJECT_NAMES = {
|
|
12
|
+
customers: "customer",
|
|
13
|
+
payment_methods: "PaymentMethod",
|
|
14
|
+
payment_intents: "PaymentIntent",
|
|
15
|
+
charges: "charge",
|
|
16
|
+
refunds: "refund",
|
|
17
|
+
products: "product",
|
|
18
|
+
prices: "price",
|
|
19
|
+
invoice_items: "invoiceitem",
|
|
20
|
+
invoices: "invoice",
|
|
21
|
+
subscriptions: "subscription",
|
|
22
|
+
subscription_items: "subscription_item",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------------------------
|
|
26
|
+
// Declared errors
|
|
27
|
+
// ---------------------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export function fail(context, code, message, details) {
|
|
30
|
+
return context.fail(details === undefined ? { code, message } : { code, message, details });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 400 `invalid_request_error` for a parameter problem; `stripeCode` is Stripe's `error.code`. */
|
|
34
|
+
export function invalid(context, message, stripeCode = "parameter_invalid", param) {
|
|
35
|
+
return fail(context, "INVALID_REQUEST", message, { code: stripeCode, ...(param === undefined ? {} : { param }) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parameterMissing(context, param) {
|
|
39
|
+
return invalid(context, `Missing required param: ${param}.`, "parameter_missing", param);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parameterUnknown(context, param) {
|
|
43
|
+
return invalid(context, `Received unknown parameter: ${param}`, "parameter_unknown", param);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function invalidInteger(context, param, value) {
|
|
47
|
+
return invalid(context, `Invalid integer: ${String(value)}`, "parameter_invalid_integer", param);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function invalidEmpty(context, param) {
|
|
51
|
+
return invalid(context, `You passed an empty string for '${param}'. We assume empty values are an attempt to unset a parameter; however '${param}' cannot be unset. You should remove '${param}' from your request or supply a non-empty value.`, "parameter_invalid_empty", param);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 404 `resource_missing`: `No such customer: 'cus_x'`. */
|
|
55
|
+
export function resourceMissing(context, namespace, id, param) {
|
|
56
|
+
const name = OBJECT_NAMES[namespace] ?? namespace;
|
|
57
|
+
return fail(context, "RESOURCE_MISSING", `No such ${name}: '${String(id)}'`, { code: "resource_missing", param: param ?? (name === "PaymentMethod" ? "payment_method" : name === "PaymentIntent" ? "payment_intent" : name) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 400 `invalid_request_error` for a status-machine violation. */
|
|
61
|
+
export function invalidState(context, message, stripeCode) {
|
|
62
|
+
return fail(context, "INVALID_STATE", message, stripeCode === undefined ? {} : { code: stripeCode });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function tooLarge(context, namespace) {
|
|
66
|
+
return invalid(context, `This synthetic account holds more than ${SCAN_CAP} ${namespace} rows; the Tool refuses to answer with a truncated list.`, "state_bound_exceeded");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------------------------
|
|
70
|
+
// Permissions and mode (actor attributes)
|
|
71
|
+
// ---------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export const PERMISSION_GROUPS = Object.freeze(["balance", "customers", "payment_methods", "payment_intents", "charges", "refunds", "products", "invoices", "subscriptions"]);
|
|
74
|
+
const LEVELS = { none: 0, read: 1, write: 2 };
|
|
75
|
+
|
|
76
|
+
export function permissionLevel(context, group) {
|
|
77
|
+
const permissions = context.actor.attributes?.permissions;
|
|
78
|
+
if (typeof permissions !== "object" || permissions === null || Array.isArray(permissions)) return "write";
|
|
79
|
+
const level = permissions[group];
|
|
80
|
+
return level === "read" || level === "write" ? level : "none";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function accountId(context) {
|
|
84
|
+
const account = context.state.get("meta", "account");
|
|
85
|
+
return account !== null && typeof account.id === "string" ? account.id : "acct_synthetic";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fail `PERMISSION_DENIED` unless the actor's restricted-key permissions cover `group: level`. */
|
|
89
|
+
export function requirePermission(context, group, level) {
|
|
90
|
+
const granted = permissionLevel(context, group);
|
|
91
|
+
if (LEVELS[granted] >= LEVELS[level]) return;
|
|
92
|
+
return fail(
|
|
93
|
+
context,
|
|
94
|
+
"PERMISSION_DENIED",
|
|
95
|
+
`This API key does not have the required permissions for this endpoint on account ${accountId(context)}. Having the '${group}: ${level}' permission would allow this request to continue.`,
|
|
96
|
+
{ group, level },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function requirePermissions(context, pairs) {
|
|
101
|
+
for (const [group, level] of pairs) requirePermission(context, group, level);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function livemode(context) {
|
|
105
|
+
return context.actor.attributes?.livemode === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------------------------
|
|
109
|
+
// Time
|
|
110
|
+
// ---------------------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export function nowSeconds(context) {
|
|
113
|
+
return Math.floor(context.clock.nowUs() / 1_000_000);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------------------------
|
|
117
|
+
// Scans
|
|
118
|
+
// ---------------------------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** Every row of a namespace in row-id (creation) order; fails when the bound is exceeded. */
|
|
121
|
+
export function allRows(context, namespace) {
|
|
122
|
+
const rows = [];
|
|
123
|
+
let after;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const batch = context.state.scan(namespace, { ...(after === undefined ? {} : { afterRowId: after }), limit: SCAN_STEP });
|
|
126
|
+
if (batch.length === 0) return rows;
|
|
127
|
+
for (const record of batch) {
|
|
128
|
+
after = record.rowId;
|
|
129
|
+
rows.push(record.value);
|
|
130
|
+
if (rows.length >= SCAN_CAP) return tooLarge(context, namespace);
|
|
131
|
+
}
|
|
132
|
+
if (batch.length < SCAN_STEP) return rows;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getRow(context, namespace, id) {
|
|
137
|
+
return typeof id === "string" && id.length > 0 && id.length <= 255 ? context.state.get(namespace, id) : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function requireRow(context, namespace, id, param) {
|
|
141
|
+
if (typeof id !== "string" || id.length === 0) return parameterMissing(context, param ?? OBJECT_NAMES[namespace] ?? namespace);
|
|
142
|
+
const row = getRow(context, namespace, id);
|
|
143
|
+
return row === null ? resourceMissing(context, namespace, id, param) : row;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------------------------
|
|
147
|
+
// Lists (newest first, cursor pagination)
|
|
148
|
+
// ---------------------------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function compareNewestFirst(left, right) {
|
|
151
|
+
if (left.created !== right.created) return right.created - left.created;
|
|
152
|
+
return left.id < right.id ? 1 : left.id > right.id ? -1 : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function validateLimit(context, limit) {
|
|
156
|
+
if (limit === undefined) return DEFAULT_LIST_LIMIT;
|
|
157
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) {
|
|
158
|
+
return invalid(context, `Invalid integer: ${String(limit)}. limit must be between 1 and ${MAX_LIST_LIMIT}.`, "parameter_invalid_integer", "limit");
|
|
159
|
+
}
|
|
160
|
+
return limit;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `created`/`due_date` style range filter: an integer (equality) or `{ gt, gte, lt, lte }`. */
|
|
164
|
+
export function matchesRange(value, filter, context, param) {
|
|
165
|
+
if (filter === undefined) return true;
|
|
166
|
+
if (typeof filter === "number") {
|
|
167
|
+
if (!Number.isInteger(filter)) return invalidInteger(context, param, filter);
|
|
168
|
+
return value === filter;
|
|
169
|
+
}
|
|
170
|
+
if (typeof filter !== "object" || filter === null || Array.isArray(filter)) return invalidInteger(context, param, filter);
|
|
171
|
+
if (value === null || value === undefined) return false;
|
|
172
|
+
for (const [key, bound] of Object.entries(filter)) {
|
|
173
|
+
if (!["gt", "gte", "lt", "lte"].includes(key)) return parameterUnknown(context, `${param}[${key}]`);
|
|
174
|
+
if (!Number.isInteger(bound)) return invalidInteger(context, `${param}[${key}]`, bound);
|
|
175
|
+
if (key === "gt" && !(value > bound)) return false;
|
|
176
|
+
if (key === "gte" && !(value >= bound)) return false;
|
|
177
|
+
if (key === "lt" && !(value < bound)) return false;
|
|
178
|
+
if (key === "lte" && !(value <= bound)) return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Paginate `rows` (already filtered) newest first. Cursors must name an existing row of the namespace
|
|
185
|
+
* (Stripe answers `resource_missing` on the cursor otherwise); the page is everything after/before that row in
|
|
186
|
+
* list order, so a cursor outside the filtered set still positions correctly.
|
|
187
|
+
*
|
|
188
|
+
* `render` returns the object exactly as the response carries it (expansions included). Pages are filled by count
|
|
189
|
+
* (`limit`) and by the encoded UTF-8 size of those objects (`PAGE_BYTE_BUDGET`): a page stops before the object that
|
|
190
|
+
* would pass the budget and answers `has_more: true`, so the next `starting_after` (last object) or `ending_before`
|
|
191
|
+
* (first object) resumes exactly at the first object not returned. Backward pages are filled from the cursor
|
|
192
|
+
* outward. One object larger than `OBJECT_BYTE_BUDGET` answers `invalid_request_error`, never a truncated page.
|
|
193
|
+
*/
|
|
194
|
+
export function paginate(context, namespace, rows, input, url, render) {
|
|
195
|
+
const limit = validateLimit(context, input.limit);
|
|
196
|
+
if (input.starting_after !== undefined && input.ending_before !== undefined) {
|
|
197
|
+
return invalid(context, "You cannot specify both starting_after and ending_before.", "parameter_invalid", "starting_after");
|
|
198
|
+
}
|
|
199
|
+
const sorted = [...rows].sort(compareNewestFirst);
|
|
200
|
+
if (input.ending_before !== undefined) {
|
|
201
|
+
const cursor = requireRow(context, namespace, input.ending_before, "ending_before");
|
|
202
|
+
const before = sorted.filter((row) => compareNewestFirst(row, cursor) < 0);
|
|
203
|
+
const { data, taken } = fillPage(context, namespace, before.length, limit, (step) => before[before.length - 1 - step], render);
|
|
204
|
+
return { object: "list", data: data.reverse(), has_more: taken < before.length, url };
|
|
205
|
+
}
|
|
206
|
+
let candidates = sorted;
|
|
207
|
+
if (input.starting_after !== undefined) {
|
|
208
|
+
const cursor = requireRow(context, namespace, input.starting_after, "starting_after");
|
|
209
|
+
candidates = sorted.filter((row) => compareNewestFirst(row, cursor) > 0);
|
|
210
|
+
}
|
|
211
|
+
const { data, taken } = fillPage(context, namespace, candidates.length, limit, (step) => candidates[step], render);
|
|
212
|
+
return { object: "list", data, has_more: taken < candidates.length, url };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Render up to `limit` of `count` rows (`rowAt(step)`, nearest the cursor first) within the page byte budget. */
|
|
216
|
+
function fillPage(context, namespace, count, limit, rowAt, render) {
|
|
217
|
+
const data = [];
|
|
218
|
+
let bytes = 0;
|
|
219
|
+
while (data.length < limit && data.length < count) {
|
|
220
|
+
const row = rowAt(data.length);
|
|
221
|
+
const item = render(row);
|
|
222
|
+
const itemBytes = jsonBytes(item);
|
|
223
|
+
if (itemBytes > OBJECT_BYTE_BUDGET) return tooLargeObject(context, `The ${OBJECT_NAMES[namespace] ?? namespace} ${row.id}`, itemBytes);
|
|
224
|
+
const size = itemBytes + 1;
|
|
225
|
+
if (bytes + size > PAGE_BYTE_BUDGET) break;
|
|
226
|
+
bytes += size;
|
|
227
|
+
data.push(item);
|
|
228
|
+
}
|
|
229
|
+
return { data, taken: data.length };
|
|
230
|
+
}
|