@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,1133 @@
|
|
|
1
|
+
// Stripe Tool conformance target. A scripted Tool test, not a model-driven agent.
|
|
2
|
+
// Node built-ins only: fetch against the Stripe-shaped REST routes (form-encoded bodies in Stripe's bracket
|
|
3
|
+
// syntax, Bearer secret key, Idempotency-Key) and raw MCP JSON-RPC (Streamable HTTP) for the agent-toolkit tool
|
|
4
|
+
// names. Every flow fails loudly on an unexpected status, header or body.
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
|
|
7
|
+
let task = "";
|
|
8
|
+
for await (const chunk of process.stdin) task += chunk;
|
|
9
|
+
const invocation = JSON.parse(task);
|
|
10
|
+
const instruction = String(invocation.instruction ?? "");
|
|
11
|
+
|
|
12
|
+
const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
13
|
+
const HTTP_TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
14
|
+
const MCP = process.env.FIREDRILL_MCP_URL;
|
|
15
|
+
const MCP_TOKEN = process.env.FIREDRILL_MCP_TOKEN;
|
|
16
|
+
assert.ok(HTTP && HTTP_TOKEN && MCP && MCP_TOKEN, "HTTP and MCP bindings are required");
|
|
17
|
+
|
|
18
|
+
const T = 1789376400; // 2026-09-14T09:00:00Z, the world's virtual time
|
|
19
|
+
const DAY = 86400;
|
|
20
|
+
const MONTH_LATER = 1791968400; // 2026-10-14T09:00:00Z
|
|
21
|
+
|
|
22
|
+
// Starter ids (deterministic: see firedrill/tools/stripe/lib/ids.mjs and starter.json)
|
|
23
|
+
const CUS = ["cus_0000010zTsvh0y", "cus_0000021MiE2e1N", "cus_00000323bW7j22", "cus_00000406Cqde07", "cus_0000050TRBkb0S", "cus_0000062cN3SK2d", "cus_0000073JGLXP3I", "cus_0000082mCL1S2n", "cus_0000090pV47N0o", "cus_00000A2c4gHd2a", "cus_00000B2EqLAg2F", "cus_00000C0lEasN0k"];
|
|
24
|
+
const cus = (n) => CUS[n - 1];
|
|
25
|
+
const PM = { 20: "pm_00000K4XGX0k4Y", 21: "pm_00000L2ZA26f2Y", 22: "pm_00000M2xnbDc2y", 23: "pm_00000N0iJwhP0h", 24: "pm_00000O3Hbhb43I", 25: "pm_00000P3eq2i13d", 26: "pm_00000Q1hRNDw1i", 27: "pm_00000R1nzxIl1m", 28: "pm_00000S0aCzDG0b", 29: "pm_00000T0yqYKD0x", 30: "pm_00000U1SadEZ1R", 31: "pm_00000V3ilgKm3j", 32: "pm_00000W3Kpwdp3J", 33: "pm_00000X2wCNWs2x" };
|
|
26
|
+
const PROD = { 40: "prod_0000140ivk6Q0k", 41: "prod_0000153SCJoP3R", 42: "prod_0000163qpsvM3r", 43: "prod_0000171bMEP91a", 44: "prod_0000181yaZW61z", 45: "prod_0000192NE8d32M" };
|
|
27
|
+
const PRICE = { 50: "price_00001E2Ddbmb2C", 51: "price_00001F1qPGfe1r", 52: "price_00001G0MnWNL0L", 53: "price_00001H0GEwIW0H", 54: "price_00001I17xbBF16", 55: "price_00001J36465K37", 56: "price_00001K3xmky33w", 57: "price_00001L1WbAIC1X", 58: "price_00001M2NcQav2M", 59: "price_00001N4L16504M" };
|
|
28
|
+
const PI = { 100: "pi_00002S34Rgwz33", 101: "pi_00002T2gVxG22h", 102: "pi_00002U48ZK9147", 103: "pi_00002V3Rg23w3S", 104: "pi_00002W2YOzlt2X", 105: "pi_00002X2BAeew2C", 106: "pi_00002Y0hYuMd0g", 107: "pi_00002Z00fcHY01", 108: "pi_0000302uSs8f2t", 109: "pi_0000310AUtqg0B", 110: "pi_0000323n5M883o", 111: "pi_0000331WuJ1v1V", 112: "pi_00003430W3KE31", 113: "pi_0000353P9cRB3O", 114: "pi_0000360C67XQ0D", 115: "pi_0000372dHiDH2c", 116: "pi_0000381krDuY1l", 117: "pi_0000394U7ncX4S", 118: "pi_00003A1b1wLQ1c", 119: "pi_00003B4cYF1X4b", 120: "pi_00003C2R9wz52Q" };
|
|
29
|
+
const CH = { 200: "ch_00005K1Evk7c1G", 201: "ch_00005L3g7KnT3e", 202: "ch_00005M2oOfuk2p", 206: "ch_00005Q1YjqV41Z", 208: "ch_00005S1OuYvw1Q", 209: "ch_00005T24Od1123" };
|
|
30
|
+
const IN = { 400: "in_0000B42dAyb82e", 401: "in_0000B50gThh30f", 402: "in_0000B63PkHP23Q", 403: "in_0000B73UtdTr3T", 404: "in_0000B81XUxzm1Y", 406: "in_0000BA2JMsDg2K", 409: "in_0000BD0qSWVN0p" };
|
|
31
|
+
const SUB = { 601: "sub_0000GP0DIlzs0E", 602: "sub_0000GQ2AhRTx29", 603: "sub_0000GR1nT6N01o", 604: "sub_0000GS3kANH53j", 605: "sub_0000GT3eJBmG3f", 606: "sub_0000GU0vkRUH0u" };
|
|
32
|
+
const SI_601 = ["si_0000JH4MuvNX4L", "si_0000JI2urYUY2v"];
|
|
33
|
+
|
|
34
|
+
const ALL_OPERATIONS = [
|
|
35
|
+
"balance.retrieve", "dashboard.context", "customers.create", "customers.retrieve", "customers.update", "customers.list",
|
|
36
|
+
"payment_methods.list", "payment_methods.attach", "payment_methods.detach",
|
|
37
|
+
"payment_intents.create", "payment_intents.retrieve", "payment_intents.list", "payment_intents.confirm", "payment_intents.capture", "payment_intents.cancel",
|
|
38
|
+
"charges.retrieve", "charges.list", "refunds.create", "refunds.list",
|
|
39
|
+
"products.create", "products.retrieve", "products.update", "products.list", "prices.create", "prices.retrieve", "prices.list",
|
|
40
|
+
"invoice_items.create", "invoices.create", "invoices.retrieve", "invoices.list", "invoices.finalize", "invoices.pay", "invoices.void",
|
|
41
|
+
"subscriptions.create", "subscriptions.retrieve", "subscriptions.list", "subscriptions.update", "subscriptions.cancel",
|
|
42
|
+
];
|
|
43
|
+
const ALIASES = ["retrieve_balance", "create_customer", "list_customers", "create_product", "list_products", "create_price", "list_prices", "create_invoice", "list_invoices", "create_invoice_item", "finalize_invoice", "create_refund", "list_payment_intents", "list_subscriptions", "cancel_subscription", "update_subscription"];
|
|
44
|
+
|
|
45
|
+
/** One representative, schema-valid request per operation, in manifest order (37 entries). */
|
|
46
|
+
const CALLS = [
|
|
47
|
+
["GET", "/v1/balance"],
|
|
48
|
+
["POST", "/v1/customers", { name: "Probe" }],
|
|
49
|
+
["GET", `/v1/customers/${cus(1)}`],
|
|
50
|
+
["POST", `/v1/customers/${cus(1)}`, { description: "Probe" }],
|
|
51
|
+
["GET", "/v1/customers"],
|
|
52
|
+
["GET", `/v1/customers/${cus(1)}/payment_methods`],
|
|
53
|
+
["POST", `/v1/payment_methods/${PM[29]}/attach`, { customer: cus(1) }],
|
|
54
|
+
["POST", `/v1/payment_methods/${PM[21]}/detach`, {}],
|
|
55
|
+
["POST", "/v1/payment_intents", { amount: 2000, currency: "usd" }],
|
|
56
|
+
["GET", `/v1/payment_intents/${PI[100]}`],
|
|
57
|
+
["GET", "/v1/payment_intents"],
|
|
58
|
+
["POST", `/v1/payment_intents/${PI[112]}/confirm`, {}],
|
|
59
|
+
["POST", `/v1/payment_intents/${PI[108]}/capture`, {}],
|
|
60
|
+
["POST", `/v1/payment_intents/${PI[113]}/cancel`, {}],
|
|
61
|
+
["GET", `/v1/charges/${CH[200]}`],
|
|
62
|
+
["GET", "/v1/charges"],
|
|
63
|
+
["POST", "/v1/refunds", { payment_intent: PI[102] }],
|
|
64
|
+
["GET", "/v1/refunds"],
|
|
65
|
+
["POST", "/v1/products", { name: "Probe" }],
|
|
66
|
+
["GET", `/v1/products/${PROD[40]}`],
|
|
67
|
+
["POST", `/v1/products/${PROD[40]}`, { description: "Probe" }],
|
|
68
|
+
["GET", "/v1/products"],
|
|
69
|
+
["POST", "/v1/prices", { product: PROD[40], currency: "usd", unit_amount: 100 }],
|
|
70
|
+
["GET", `/v1/prices/${PRICE[50]}`],
|
|
71
|
+
["GET", "/v1/prices"],
|
|
72
|
+
["POST", "/v1/invoiceitems", { customer: cus(10), amount: 100, currency: "usd" }],
|
|
73
|
+
["POST", "/v1/invoices", { customer: cus(10) }],
|
|
74
|
+
["GET", `/v1/invoices/${IN[400]}`],
|
|
75
|
+
["GET", "/v1/invoices"],
|
|
76
|
+
["POST", `/v1/invoices/${IN[400]}/finalize`, {}],
|
|
77
|
+
["POST", `/v1/invoices/${IN[402]}/pay`, {}],
|
|
78
|
+
["POST", `/v1/invoices/${IN[402]}/void`, {}],
|
|
79
|
+
["POST", "/v1/subscriptions", { customer: cus(1), items: [{ price: PRICE[54] }] }],
|
|
80
|
+
["GET", `/v1/subscriptions/${SUB[601]}`],
|
|
81
|
+
["GET", "/v1/subscriptions"],
|
|
82
|
+
["POST", `/v1/subscriptions/${SUB[601]}`, { description: "Probe" }],
|
|
83
|
+
["DELETE", `/v1/subscriptions/${SUB[602]}`],
|
|
84
|
+
];
|
|
85
|
+
const WRITE_CALLS = CALLS.filter(([method]) => method !== "GET");
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------------------------
|
|
88
|
+
// Transport helpers
|
|
89
|
+
// ---------------------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/** Stripe's bracket form encoding: `{ items: [{ price }], metadata: { k }, expand: ["a"] }` → `items[0][price]=…`. */
|
|
92
|
+
function encodeForm(value, prefix, params) {
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
value.forEach((entry, index) => encodeForm(entry, typeof entry === "object" && entry !== null ? `${prefix}[${index}]` : `${prefix}[]`, params));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === "object" && value !== null) {
|
|
98
|
+
for (const [key, entry] of Object.entries(value)) encodeForm(entry, prefix === "" ? key : `${prefix}[${key}]`, params);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
params.append(prefix, String(value));
|
|
102
|
+
}
|
|
103
|
+
function form(body) {
|
|
104
|
+
const params = new URLSearchParams();
|
|
105
|
+
encodeForm(body, "", params);
|
|
106
|
+
return params.toString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Stripe-shaped request. `status` is asserted; the JSON body and headers are returned. */
|
|
110
|
+
async function api(method, path, { body, status = 200, headers = {}, rawBody, contentType } = {}) {
|
|
111
|
+
const hasBody = method === "POST" || rawBody !== undefined;
|
|
112
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
113
|
+
method,
|
|
114
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, ...(hasBody ? { "content-type": contentType ?? "application/x-www-form-urlencoded" } : {}), ...headers },
|
|
115
|
+
...(hasBody ? { body: rawBody ?? form(body ?? {}) } : {}),
|
|
116
|
+
});
|
|
117
|
+
const text = await response.text();
|
|
118
|
+
let json;
|
|
119
|
+
try {
|
|
120
|
+
json = text.length > 0 ? JSON.parse(text) : undefined;
|
|
121
|
+
} catch {
|
|
122
|
+
json = undefined;
|
|
123
|
+
}
|
|
124
|
+
assert.equal(response.status, status, `${method} ${path} ${body === undefined ? "" : JSON.stringify(body).slice(0, 200)} -> ${response.status} ${text.slice(0, 500)}`);
|
|
125
|
+
if (json !== undefined && (json.object !== undefined || json.error !== undefined)) {
|
|
126
|
+
assert.ok((response.headers.get("request-id") ?? "").startsWith("req_"), `${method} ${path}: missing Request-Id`);
|
|
127
|
+
assert.equal(response.headers.get("stripe-version"), "2026-08-26.dahlia", `${method} ${path}: missing Stripe-Version`);
|
|
128
|
+
}
|
|
129
|
+
return { json, headers: response.headers, status: response.status };
|
|
130
|
+
}
|
|
131
|
+
const get = (path, options) => api("GET", path, options);
|
|
132
|
+
const post = (path, body, options) => api("POST", path, { ...options, body });
|
|
133
|
+
const del = (path, options) => api("DELETE", path, options);
|
|
134
|
+
|
|
135
|
+
/** Expect Stripe's error envelope: status, `type`, optional `code` and a message fragment. */
|
|
136
|
+
// Ids of Stripe objects named anywhere in an error body (the request log URL is excluded: it names the request).
|
|
137
|
+
function stripeIdsIn(body) {
|
|
138
|
+
const { request_log_url: _log, ...rest } = body;
|
|
139
|
+
void _log;
|
|
140
|
+
return [...new Set(JSON.stringify(rest).match(/\b(?:pi|pm|ch|re|cus|in|inpay|il|ii|sub|si|prod|price|txn)_[A-Za-z0-9]+/g) ?? [])];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A creation-time decline: Stripe's envelope only — no PaymentIntent, PaymentMethod, charge or any other id. */
|
|
144
|
+
function assertCreationDeclineBody(body) {
|
|
145
|
+
assert.equal(body.type, "card_error");
|
|
146
|
+
assert.equal(body.code, "card_declined");
|
|
147
|
+
assert.equal(typeof body.decline_code, "string");
|
|
148
|
+
assert.equal(typeof body.message, "string");
|
|
149
|
+
assert.equal(body.doc_url, "https://stripe.com/docs/error-codes/card-declined");
|
|
150
|
+
assert.match(body.request_log_url, /\/logs\/req_/);
|
|
151
|
+
assert.equal("payment_intent" in body, false, "the PaymentIntent was never stored, so it is not named");
|
|
152
|
+
assert.equal("payment_method" in body, false, "no PaymentMethod is named");
|
|
153
|
+
assert.equal("charge" in body, false, "no charge is named");
|
|
154
|
+
assert.deepEqual(stripeIdsIn(body), [], "a creation-time decline names no object id");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A decline on an existing object names only ids that existed before the call. */
|
|
158
|
+
function assertIdsOnly(body, allowed, label) {
|
|
159
|
+
for (const id of stripeIdsIn(body)) assert.ok(allowed.includes(id), `${label} names ${id}, which did not exist before the call`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function apiError(method, path, status, type, code, text, options = {}) {
|
|
163
|
+
const result = await api(method, path, { ...options, status });
|
|
164
|
+
const error = result.json?.error;
|
|
165
|
+
assert.ok(error && typeof error.message === "string", `${method} ${path}: no Stripe error envelope: ${JSON.stringify(result.json)}`);
|
|
166
|
+
assert.equal(error.type, type, `${method} ${path}: ${JSON.stringify(error)}`);
|
|
167
|
+
if (code !== undefined) assert.equal(error.code, code, `${method} ${path}: ${JSON.stringify(error)}`);
|
|
168
|
+
if (text !== undefined) assert.ok(error.message.includes(text), `${method} ${path}: expected ${JSON.stringify(text)} in ${JSON.stringify(error)}`);
|
|
169
|
+
assert.ok(typeof error.request_log_url === "string", `${method} ${path}: no request_log_url`);
|
|
170
|
+
return error;
|
|
171
|
+
}
|
|
172
|
+
const postError = (path, body, status, type, code, text, options) => apiError("POST", path, status, type, code, text, { ...options, body });
|
|
173
|
+
const getError = (path, status, type, code, text) => apiError("GET", path, status, type, code, text);
|
|
174
|
+
const invalidRequest = (method, path, code, text, body) => apiError(method, path, 400, "invalid_request_error", code, text, body === undefined ? {} : { body });
|
|
175
|
+
const notFound = (method, path, text, body) => apiError(method, path, 404, "invalid_request_error", "resource_missing", text, body === undefined ? {} : { body });
|
|
176
|
+
const call = ([method, path, body], options) => api(method, path, { ...options, ...(body === undefined ? {} : { body }) });
|
|
177
|
+
const callError = ([method, path, body], status, type, code, text) => apiError(method, path, status, type, code, text, body === undefined ? {} : { body });
|
|
178
|
+
|
|
179
|
+
let rpcId = 0;
|
|
180
|
+
async function rpc(method, params) {
|
|
181
|
+
const response = await fetch(MCP, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { authorization: `Bearer ${MCP_TOKEN}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
184
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
|
185
|
+
});
|
|
186
|
+
assert.equal(response.status, 200, `MCP ${method} -> HTTP ${response.status}`);
|
|
187
|
+
const text = await response.text();
|
|
188
|
+
const messages = (response.headers.get("content-type") ?? "").includes("text/event-stream")
|
|
189
|
+
? text.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5).trim()))
|
|
190
|
+
: [JSON.parse(text)];
|
|
191
|
+
const reply = messages.find((message) => message.id === rpcId);
|
|
192
|
+
assert.ok(reply, `MCP ${method}: no JSON-RPC reply`);
|
|
193
|
+
if (reply.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(reply.error)}`);
|
|
194
|
+
return reply.result;
|
|
195
|
+
}
|
|
196
|
+
async function mcpInit() {
|
|
197
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "stripe-conformance", version: "0.1.0" } });
|
|
198
|
+
}
|
|
199
|
+
async function mcp(name, args) {
|
|
200
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
201
|
+
assert.ok(!result.isError, `${name} ${JSON.stringify(args)}: ${JSON.stringify(result).slice(0, 500)}`);
|
|
202
|
+
return result.structuredContent;
|
|
203
|
+
}
|
|
204
|
+
async function mcpError(name, args, code) {
|
|
205
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
206
|
+
assert.ok(result.isError, `${name} ${JSON.stringify(args)} unexpectedly succeeded`);
|
|
207
|
+
if (code !== undefined) {
|
|
208
|
+
const error = result.structuredContent?.error;
|
|
209
|
+
assert.ok(error, `${name}: ${JSON.stringify(result).slice(0, 400)}`);
|
|
210
|
+
assert.equal(error.code, code, JSON.stringify(error));
|
|
211
|
+
}
|
|
212
|
+
return result.structuredContent;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ids = (list) => list.data.map((item) => item.id);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The shared form/query decoder: reserved segments cannot reach a prototype, oversized array indices and caller-chosen
|
|
219
|
+
* nesting depth answer Stripe's 400 quickly, and legitimate bracket forms still decode. Every rejection is followed by a
|
|
220
|
+
* balance read, which would answer 500 if `Object.prototype` had been written.
|
|
221
|
+
*/
|
|
222
|
+
async function formDecoderGuards() {
|
|
223
|
+
const raw = async (method, path, rawBody, code, text) => {
|
|
224
|
+
const started = Date.now();
|
|
225
|
+
const error = await apiError(method, path, 400, "invalid_request_error", code, text, rawBody === undefined ? {} : { rawBody });
|
|
226
|
+
assert.ok(Date.now() - started < 1000, `${method} ${path} took ${Date.now() - started} ms`);
|
|
227
|
+
assert.ok(!/Maximum call stack|Cannot read|is not a function|undefined/.test(error.message), `runtime text leaked: ${error.message}`);
|
|
228
|
+
assert.equal((await get("/v1/balance")).json.object, "balance", "every route keeps answering after a rejected form");
|
|
229
|
+
return error;
|
|
230
|
+
};
|
|
231
|
+
for (const key of ["__proto__[polluted]", "constructor[prototype][polluted]", "prototype[polluted]"]) {
|
|
232
|
+
const body = await raw("POST", "/v1/customers", `${key}=1`, "parameter_unknown", `Received unknown parameter: ${key}`);
|
|
233
|
+
assert.equal(body.param, key);
|
|
234
|
+
await raw("GET", `/v1/customers?${key}=1`, undefined, "parameter_unknown", `Received unknown parameter: ${key}`);
|
|
235
|
+
}
|
|
236
|
+
await raw("POST", "/v1/customers", "metadata[__proto__]=1", "parameter_unknown", "metadata[__proto__]");
|
|
237
|
+
for (const key of ["items[4294967294][price]", "expand[100000000]", "items[999999][price]"]) {
|
|
238
|
+
await raw("POST", "/v1/subscriptions", `customer=${cus(1)}&${key}=x`, "parameter_invalid", "Invalid array");
|
|
239
|
+
}
|
|
240
|
+
await raw("GET", "/v1/customers?expand[100000000]=x", undefined, "parameter_invalid", "Invalid array");
|
|
241
|
+
await raw("POST", "/v1/customers", `a${"[b]".repeat(10000)}=1`, "parameter_invalid", "nested more than 20 levels");
|
|
242
|
+
await raw("POST", "/v1/customers", `a${"[b]".repeat(30000)}=1`, "parameter_invalid", "nested more than 20 levels");
|
|
243
|
+
await raw("POST", "/v1/products", `metadata[a]=1&items${"[0]".repeat(12000)}=1`, "parameter_invalid", "nested more than 20 levels");
|
|
244
|
+
await raw("POST", "/v1/customers", Array.from({ length: 1001 }, () => "expand[]=x").join("&"), "parameter_invalid", "Invalid array");
|
|
245
|
+
// The decoder's error channel cannot be forged by a caller.
|
|
246
|
+
await raw("POST", "/v1/customers", "firedrill:form_error[code]=x&firedrill:form_error[message]=y", "parameter_unknown", "Received unknown parameter");
|
|
247
|
+
|
|
248
|
+
// Canonical JSON input: the framework drops a `__proto__` member from JSON arguments before the handler runs (the
|
|
249
|
+
// handler's own guard in mergeMetadata is defence in depth); nothing is stored and no prototype is written.
|
|
250
|
+
const canonical = await fetch(`${HTTP}/v1/operations/stripe/customers.update`, { method: "POST", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" }, body: `{"arguments":{"customer":"${cus(12)}","metadata":{"__proto__":{"polluted":"1"}}}}` });
|
|
251
|
+
const outcome = (await canonical.json()).outcome;
|
|
252
|
+
assert.ok(outcome.status === "ok" || outcome.status === "tool_error" || outcome.status === "invalid", JSON.stringify(outcome));
|
|
253
|
+
if (outcome.status === "ok") assert.equal(Object.prototype.hasOwnProperty.call(outcome.value.metadata, "__proto__"), false, JSON.stringify(outcome.value.metadata));
|
|
254
|
+
assert.equal((await get("/v1/balance")).json.object, "balance", "a JSON __proto__ member leaves every route answering");
|
|
255
|
+
|
|
256
|
+
// Legitimate bracket forms keep decoding.
|
|
257
|
+
const created = (await api("POST", `/v1/customers/${cus(12)}`, { rawBody: "metadata[a]=1&metadata[constructor_note]=ok&address[line1]=1%20Main%20St&address[country]=us&shipping[name]=Dock&shipping[address][line1]=2%20Pier%20Rd&shipping[address][country]=us&expand[]=invoice_settings.default_payment_method" })).json;
|
|
258
|
+
assert.equal(created.shipping.address.country, "US", "three-level nesting decodes");
|
|
259
|
+
assert.equal(created.metadata.a, "1");
|
|
260
|
+
assert.equal(created.metadata.constructor_note, "ok");
|
|
261
|
+
assert.equal(created.address.country, "US");
|
|
262
|
+
assert.equal(typeof created.invoice_settings.default_payment_method, "object", "expand[] decoded into an array");
|
|
263
|
+
const intent = (await api("POST", "/v1/payment_intents", { rawBody: `amount=2000¤cy=usd&customer=${created.id}&automatic_payment_methods[enabled]=true&metadata[order_id]=7&expand[]=customer` })).json;
|
|
264
|
+
assert.equal(intent.object, "payment_intent", JSON.stringify(intent).slice(0, 300));
|
|
265
|
+
assert.equal(intent.customer.id, created.id);
|
|
266
|
+
assert.equal(intent.metadata.order_id, "7");
|
|
267
|
+
// items[0] and items[1] reach the handler as a two-entry array (its interval check names both prices).
|
|
268
|
+
await raw("POST", "/v1/subscriptions", `customer=${cus(1)}&items[0][price]=${PRICE[52]}&items[1][price]=${PRICE[55]}`, undefined, "must share one");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------------------------
|
|
272
|
+
// Drill: rest-flow (owner, baseline)
|
|
273
|
+
// ---------------------------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
async function restFlow() {
|
|
276
|
+
// Balance ----------------------------------------------------------------------------------
|
|
277
|
+
const balance = (await get("/v1/balance")).json;
|
|
278
|
+
assert.equal(balance.object, "balance");
|
|
279
|
+
assert.equal(balance.livemode, false);
|
|
280
|
+
const row = (rows, currency) => rows.find((entry) => entry.currency === currency);
|
|
281
|
+
assert.equal(row(balance.available, "usd").amount, 51600, "usd available = captured charges older than two days minus their refunds");
|
|
282
|
+
assert.equal(row(balance.pending, "usd").amount, 195550, "usd pending = the three charges of the last two virtual days");
|
|
283
|
+
assert.equal(row(balance.available, "eur").amount, 10000);
|
|
284
|
+
assert.equal(row(balance.available, "gbp").amount, 1500);
|
|
285
|
+
await invalidRequest("GET", "/v1/balance?expand[]=nope", "parameter_invalid", "cannot be expanded");
|
|
286
|
+
|
|
287
|
+
// Dashboard context (canonical operation only; the browser app reads the account, virtual time and permissions from it)
|
|
288
|
+
const contextResponse = await fetch(`${HTTP}/v1/operations/stripe/dashboard.context`, { method: "POST", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ arguments: {} }) });
|
|
289
|
+
assert.equal(contextResponse.status, 200);
|
|
290
|
+
const context = (await contextResponse.json()).outcome;
|
|
291
|
+
assert.equal(context.status, "ok");
|
|
292
|
+
assert.equal(context.value.object, "dashboard.context");
|
|
293
|
+
assert.equal(context.value.account.id, "acct_1S00Q1HxKLumenTr");
|
|
294
|
+
assert.equal(context.value.account.business_name, "Lumen Trail Supply Co.");
|
|
295
|
+
assert.equal(context.value.now, T, "now is the world's virtual time in seconds");
|
|
296
|
+
assert.equal(context.value.livemode, false);
|
|
297
|
+
assert.equal(context.value.permissions.refunds, "write", "a full secret key has write on every group");
|
|
298
|
+
assert.equal(context.value.api_version, "2026-08-26.dahlia");
|
|
299
|
+
const missingRoute = await fetch(`${HTTP}/v1/dashboard/context`, { headers: { authorization: `Bearer ${HTTP_TOKEN}` } });
|
|
300
|
+
assert.equal(missingRoute.status, 404, "no Stripe-shaped route exists for the dashboard context");
|
|
301
|
+
|
|
302
|
+
// Customers: listing ---------------------------------------------------------------------
|
|
303
|
+
const first = (await get("/v1/customers")).json;
|
|
304
|
+
assert.equal(first.object, "list");
|
|
305
|
+
assert.equal(first.data.length, 10);
|
|
306
|
+
assert.equal(first.has_more, true);
|
|
307
|
+
assert.equal(first.url, "/v1/customers");
|
|
308
|
+
assert.equal(first.data[0].id, cus(5), "newest first");
|
|
309
|
+
const pages = [];
|
|
310
|
+
let after;
|
|
311
|
+
for (;;) {
|
|
312
|
+
const page = (await get(`/v1/customers?limit=5${after === undefined ? "" : `&starting_after=${after}`}`)).json;
|
|
313
|
+
pages.push(page.data.length);
|
|
314
|
+
if (!page.has_more) break;
|
|
315
|
+
after = page.data[page.data.length - 1].id;
|
|
316
|
+
}
|
|
317
|
+
assert.deepEqual(pages, [5, 5, 2], "limit=5 walks three pages with starting_after");
|
|
318
|
+
const back = (await get(`/v1/customers?limit=5&ending_before=${after}`)).json;
|
|
319
|
+
assert.equal(back.data.length, 5);
|
|
320
|
+
assert.equal(back.has_more, true);
|
|
321
|
+
assert.equal(back.data[back.data.length - 1].id, cus(6), "ending_before returns the five customers right before the cursor (cus3)");
|
|
322
|
+
await invalidRequest("GET", "/v1/customers?limit=0", "parameter_invalid_integer", "limit");
|
|
323
|
+
await notFound("GET", "/v1/customers?starting_after=cus_nope", "No such customer: 'cus_nope'");
|
|
324
|
+
assert.deepEqual(ids((await get("/v1/customers?email=Zoe.Mueller@example.org")).json), [cus(2)], "exact, case-sensitive e-mail filter");
|
|
325
|
+
assert.deepEqual(ids((await get("/v1/customers?email=zoe.mueller@example.org")).json), []);
|
|
326
|
+
assert.equal((await get("/v1/customers?email=priya.raman@example.com")).json.data.length, 2, "duplicate e-mails are legal");
|
|
327
|
+
assert.deepEqual(ids((await get(`/v1/customers?created[gte]=${T - 3 * DAY}`)).json), [cus(5)]);
|
|
328
|
+
await invalidRequest("GET", "/v1/customers?foo=1", "parameter_invalid", "arguments do not match");
|
|
329
|
+
|
|
330
|
+
// Mangled percent-encoding: the framework decodes `%E0%A4%A` to U+FFFD, so every list filter rejects it instead of
|
|
331
|
+
// running a corrupted filter that silently matches nothing. `%ZZ` stays literal and correct UTF-8 keeps working.
|
|
332
|
+
const MANGLED = "%E0%A4%A";
|
|
333
|
+
for (const [path, param] of [
|
|
334
|
+
[`/v1/customers?email=ana${MANGLED}`, "email"],
|
|
335
|
+
["/v1/customers?email=ana%EF%BF%BD", "email"],
|
|
336
|
+
[`/v1/products?ids[]=prod${MANGLED}`, "ids"],
|
|
337
|
+
[`/v1/prices?lookup_keys[]=trail${MANGLED}`, "lookup_keys"],
|
|
338
|
+
[`/v1/prices?product=prod${MANGLED}`, "product"],
|
|
339
|
+
[`/v1/charges?customer=cus${MANGLED}`, "customer"],
|
|
340
|
+
[`/v1/refunds?charge=ch${MANGLED}`, "charge"],
|
|
341
|
+
[`/v1/payment_intents?customer=cus${MANGLED}`, "customer"],
|
|
342
|
+
[`/v1/invoices?subscription=sub${MANGLED}`, "subscription"],
|
|
343
|
+
[`/v1/subscriptions?price=price${MANGLED}`, "price"],
|
|
344
|
+
[`/v1/payment_methods?customer=${cus(1)}&type=car${MANGLED}`, "type"],
|
|
345
|
+
[`/v1/prices?currency=us${MANGLED}`, "currency"],
|
|
346
|
+
[`/v1/charges?payment_intent=pi${MANGLED}`, "payment_intent"],
|
|
347
|
+
[`/v1/refunds?payment_intent=pi${MANGLED}`, "payment_intent"],
|
|
348
|
+
[`/v1/invoices?customer=cus${MANGLED}`, "customer"],
|
|
349
|
+
[`/v1/subscriptions?customer=cus${MANGLED}`, "customer"],
|
|
350
|
+
]) {
|
|
351
|
+
const error = await invalidRequest("GET", path, "parameter_invalid", "U+FFFD");
|
|
352
|
+
assert.equal(error.param, param, `${path}: the mangled parameter is named`);
|
|
353
|
+
}
|
|
354
|
+
assert.equal((await get(`/v1/customers?email=ana%ZZ`)).json.data.length, 0, "%ZZ stays literal and runs as an ordinary filter");
|
|
355
|
+
// The five closed-enum filters never reach the guard: the declared enum schema rejects the mangled value first, so the
|
|
356
|
+
// response is the generic framework message with no `param`. README documents exactly this split.
|
|
357
|
+
for (const path of [
|
|
358
|
+
`/v1/prices?type=recur${MANGLED}`,
|
|
359
|
+
`/v1/invoices?status=op${MANGLED}`,
|
|
360
|
+
`/v1/invoices?collection_method=charge${MANGLED}`,
|
|
361
|
+
`/v1/subscriptions?status=activ${MANGLED}`,
|
|
362
|
+
`/v1/subscriptions?collection_method=charge${MANGLED}`,
|
|
363
|
+
]) {
|
|
364
|
+
const error = await invalidRequest("GET", path, "parameter_invalid", "arguments do not match");
|
|
365
|
+
assert.equal(error.param, undefined, `${path}: the enum schema rejection names no parameter`);
|
|
366
|
+
}
|
|
367
|
+
const mangledCanonical = await fetch(`${HTTP}/v1/operations/stripe/customers.list`, { method: "POST", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ arguments: { email: "ana\uFFFD" } }) });
|
|
368
|
+
const mangledOutcome = (await mangledCanonical.json()).outcome;
|
|
369
|
+
assert.equal(mangledOutcome.status, "tool_error", "the canonical operation rejects U+FFFD too");
|
|
370
|
+
assert.equal(mangledOutcome.error.code, "tool.INVALID_REQUEST");
|
|
371
|
+
assert.match(mangledOutcome.error.message, /U\+FFFD/);
|
|
372
|
+
assert.equal((await get("/v1/customers?email=caf%C3%A9.%E6%BC%A2@example.com")).json.data.length, 0, "correctly encoded non-ASCII filters still run (no match here)");
|
|
373
|
+
assert.equal((await get("/v1/prices?lookup_keys[]=sommet_%C3%A9t%C3%A9&lookup_keys[]=%F0%9F%94%A5")).json.data.length, 0, "accented and emoji lookup keys are ordinary values");
|
|
374
|
+
const jsonBody = await fetch(`${HTTP}/v1/customers`, { method: "POST", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ name: "x" }) });
|
|
375
|
+
assert.equal(jsonBody.status, 415, "a JSON body on a form route is rejected by the framework");
|
|
376
|
+
await jsonBody.text();
|
|
377
|
+
|
|
378
|
+
// Customers: create / update / retrieve -----------------------------------------------------
|
|
379
|
+
const kai = (await post("/v1/customers", { name: "Kai Nakamura", email: "kai.nakamura@example.com", address: { line1: "1180 NW Glisan St", city: "Portland", state: "OR", postal_code: "97209", country: "us" }, metadata: { tier: "gold", crm_id: "C-2001" }, payment_method: "pm_card_visa" })).json;
|
|
380
|
+
assert.equal(kai.object, "customer");
|
|
381
|
+
assert.ok(kai.id.startsWith("cus_"));
|
|
382
|
+
assert.equal(kai.invoice_prefix.length, 8);
|
|
383
|
+
assert.equal(kai.address.country, "US");
|
|
384
|
+
assert.equal(kai.created, T);
|
|
385
|
+
const kaiDefault = kai.invoice_settings.default_payment_method;
|
|
386
|
+
assert.ok(kaiDefault.startsWith("pm_") && kaiDefault !== "pm_card_visa", "a test card id materialises a real payment method");
|
|
387
|
+
await invalidRequest("POST", "/v1/customers", "email_invalid", "Invalid email address", { email: "not-an-email" });
|
|
388
|
+
await invalidRequest("POST", "/v1/customers", "parameter_invalid", "up to 50 keys", { metadata: Object.fromEntries(Array.from({ length: 51 }, (_, index) => [`k${index}`, "v"])) });
|
|
389
|
+
await notFound("POST", "/v1/customers", "No such PaymentMethod: 'pm_nope'", { name: "x", payment_method: "pm_nope" });
|
|
390
|
+
await invalidRequest("POST", "/v1/customers", "payment_method_already_attached", "already been attached", { name: "x", payment_method: PM[20] });
|
|
391
|
+
const updated = (await post(`/v1/customers/${kai.id}`, { metadata: { tier: "" }, description: "Wholesale account" })).json;
|
|
392
|
+
assert.deepEqual(updated.metadata, { crm_id: "C-2001" }, "an empty metadata value deletes the key");
|
|
393
|
+
assert.equal(updated.description, "Wholesale account");
|
|
394
|
+
await invalidRequest("POST", `/v1/customers/${kai.id}`, "payment_method_unattached", "not attached", { invoice_settings: { default_payment_method: PM[20] } });
|
|
395
|
+
await invalidRequest("POST", `/v1/customers/${kai.id}`, "email_invalid", "Invalid email address", { email: "still-not-an-email" });
|
|
396
|
+
await notFound("POST", "/v1/customers/cus_nope", "No such customer", { description: "x" });
|
|
397
|
+
const expanded = (await get(`/v1/customers/${kai.id}?expand[]=invoice_settings.default_payment_method`)).json;
|
|
398
|
+
assert.equal(expanded.invoice_settings.default_payment_method.object, "payment_method");
|
|
399
|
+
assert.equal(expanded.invoice_settings.default_payment_method.card.last4, "4242");
|
|
400
|
+
await invalidRequest("GET", `/v1/customers/${kai.id}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
401
|
+
await notFound("GET", "/v1/customers/cus_nope", "No such customer: 'cus_nope'");
|
|
402
|
+
|
|
403
|
+
// Payment methods ------------------------------------------------------------------------
|
|
404
|
+
const methods = (await get(`/v1/customers/${cus(1)}/payment_methods?limit=2`)).json;
|
|
405
|
+
assert.equal(methods.data.length, 2);
|
|
406
|
+
assert.equal(methods.has_more, true);
|
|
407
|
+
assert.equal(methods.url, `/v1/customers/${cus(1)}/payment_methods`, "the customer path echoes its url");
|
|
408
|
+
assert.ok(methods.data.every((method) => method.outcome === undefined), "the private card outcome is never returned");
|
|
409
|
+
assert.equal((await get(`/v1/payment_methods?customer=${cus(1)}&type=sepa_debit`)).json.data.length, 0);
|
|
410
|
+
assert.equal((await get(`/v1/payment_methods?customer=${cus(1)}`)).json.url, "/v1/payment_methods");
|
|
411
|
+
await notFound("GET", "/v1/payment_methods?customer=cus_nope", "No such customer");
|
|
412
|
+
await invalidRequest("GET", `/v1/payment_methods?customer=${cus(1)}&limit=0`, "parameter_invalid_integer");
|
|
413
|
+
const master = (await post("/v1/payment_methods/pm_card_mastercard/attach", { customer: kai.id })).json;
|
|
414
|
+
assert.ok(master.id.startsWith("pm_") && master.id !== "pm_card_mastercard");
|
|
415
|
+
assert.equal(master.card.last4, "4444");
|
|
416
|
+
assert.equal(master.customer, kai.id);
|
|
417
|
+
assert.equal(master.billing_details.name, "Kai Nakamura");
|
|
418
|
+
await invalidRequest("POST", `/v1/payment_methods/${master.id}/attach`, "payment_method_already_attached", "already been attached", { customer: cus(1) });
|
|
419
|
+
await notFound("POST", "/v1/payment_methods/pm_nope/attach", "No such PaymentMethod", { customer: kai.id });
|
|
420
|
+
await invalidRequest("POST", `/v1/payment_methods/${PM[29]}/attach`, "parameter_invalid", "cannot be expanded", { customer: kai.id, expand: ["nope"] });
|
|
421
|
+
const detached = (await post(`/v1/payment_methods/${master.id}/detach`, {})).json;
|
|
422
|
+
assert.equal(detached.customer, null);
|
|
423
|
+
await invalidRequest("POST", `/v1/payment_methods/${master.id}/detach`, "payment_method_unattached", "not attached", {});
|
|
424
|
+
await notFound("POST", "/v1/payment_methods/pm_nope/detach", "No such PaymentMethod", {});
|
|
425
|
+
await invalidRequest("POST", `/v1/payment_methods/${PM[21]}/detach`, "parameter_invalid", "cannot be expanded", { expand: ["nope"] });
|
|
426
|
+
|
|
427
|
+
// PaymentIntents ---------------------------------------------------------------------------
|
|
428
|
+
const intent = (await post("/v1/payment_intents", { amount: 2000, currency: "usd", customer: kai.id, payment_method: kaiDefault, description: "Order #LT-5001", metadata: { order_id: "LT-5001" } })).json;
|
|
429
|
+
assert.equal(intent.object, "payment_intent");
|
|
430
|
+
assert.equal(intent.status, "requires_confirmation");
|
|
431
|
+
assert.ok(intent.client_secret.startsWith(`${intent.id}_secret_`));
|
|
432
|
+
await invalidRequest("POST", "/v1/payment_intents", "amount_too_small", "at least", { amount: 10, currency: "usd" });
|
|
433
|
+
await invalidRequest("POST", "/v1/payment_intents", "parameter_invalid", "arguments do not match", { amount: 1.5, currency: "usd" });
|
|
434
|
+
await invalidRequest("POST", "/v1/payment_intents", "parameter_invalid", "Invalid currency", { amount: 2000, currency: "xyz" });
|
|
435
|
+
await invalidRequest("POST", "/v1/payment_intents", "parameter_invalid", "only 'card'", { amount: 2000, currency: "usd", payment_method_types: ["sepa_debit"] });
|
|
436
|
+
await notFound("POST", "/v1/payment_intents", "No such customer", { amount: 2000, currency: "usd", customer: "cus_nope" });
|
|
437
|
+
await invalidRequest("POST", "/v1/payment_intents", "payment_method_customer_mismatch", "belongs to the Customer", { amount: 2000, currency: "usd", customer: kai.id, payment_method: PM[20] });
|
|
438
|
+
const confirmed = (await post(`/v1/payment_intents/${intent.id}/confirm`, {})).json;
|
|
439
|
+
assert.equal(confirmed.status, "succeeded");
|
|
440
|
+
assert.equal(confirmed.amount_received, 2000);
|
|
441
|
+
assert.ok(confirmed.latest_charge.startsWith("ch_"));
|
|
442
|
+
const charge = (await get(`/v1/charges/${confirmed.latest_charge}`)).json;
|
|
443
|
+
assert.equal(charge.object, "charge");
|
|
444
|
+
assert.equal(charge.captured, true);
|
|
445
|
+
assert.equal(charge.paid, true);
|
|
446
|
+
assert.equal(charge.payment_method_details.card.brand, "visa");
|
|
447
|
+
assert.equal(charge.calculated_statement_descriptor, "LUMEN TRAIL");
|
|
448
|
+
assert.ok(charge.balance_transaction.startsWith("txn_"));
|
|
449
|
+
await invalidRequest("POST", `/v1/payment_intents/${intent.id}/confirm`, "payment_intent_unexpected_state", "already succeeded", {});
|
|
450
|
+
await notFound("POST", "/v1/payment_intents/pi_nope/confirm", "No such PaymentIntent", {});
|
|
451
|
+
await invalidRequest("POST", `/v1/payment_intents/${PI[112]}/confirm`, "parameter_invalid", "cannot be expanded", { expand: ["nope"] });
|
|
452
|
+
const manual = (await post("/v1/payment_intents", { amount: 3000, currency: "usd", capture_method: "manual", confirm: true, payment_method: "pm_card_visa" })).json;
|
|
453
|
+
assert.equal(manual.status, "requires_capture");
|
|
454
|
+
assert.equal(manual.amount_capturable, 3000);
|
|
455
|
+
assert.equal((await get(`/v1/charges/${manual.latest_charge}`)).json.captured, false);
|
|
456
|
+
const captured = (await post(`/v1/payment_intents/${manual.id}/capture`, { amount_to_capture: 1500 })).json;
|
|
457
|
+
assert.equal(captured.status, "succeeded");
|
|
458
|
+
assert.equal(captured.amount_received, 1500);
|
|
459
|
+
assert.equal(captured.amount_capturable, 0);
|
|
460
|
+
const capturedCharge = (await get(`/v1/charges/${manual.latest_charge}`)).json;
|
|
461
|
+
assert.equal(capturedCharge.captured, true);
|
|
462
|
+
assert.equal(capturedCharge.amount_captured, 1500);
|
|
463
|
+
await invalidRequest("POST", `/v1/payment_intents/${manual.id}/capture`, "payment_intent_unexpected_state", "cannot capture", {});
|
|
464
|
+
const manual2 = (await post("/v1/payment_intents", { amount: 4000, currency: "usd", capture_method: "manual", confirm: true, payment_method: "pm_card_visa" })).json;
|
|
465
|
+
await invalidRequest("POST", `/v1/payment_intents/${manual2.id}/capture`, "amount_too_large", "greater than the amount capturable", { amount_to_capture: 99999 });
|
|
466
|
+
await notFound("POST", "/v1/payment_intents/pi_nope/capture", "No such PaymentIntent", {});
|
|
467
|
+
const canceled = (await post(`/v1/payment_intents/${manual2.id}/cancel`, { cancellation_reason: "requested_by_customer" })).json;
|
|
468
|
+
assert.equal(canceled.status, "canceled");
|
|
469
|
+
assert.equal(canceled.cancellation_reason, "requested_by_customer");
|
|
470
|
+
assert.equal(canceled.canceled_at, T);
|
|
471
|
+
const released = (await get(`/v1/charges/${manual2.latest_charge}`)).json;
|
|
472
|
+
assert.equal(released.captured, false);
|
|
473
|
+
assert.equal(released.refunded, true, "cancelling an authorisation releases it");
|
|
474
|
+
await invalidRequest("POST", `/v1/payment_intents/${intent.id}/cancel`, "payment_intent_unexpected_state", "already succeeded", {});
|
|
475
|
+
await notFound("POST", "/v1/payment_intents/pi_nope/cancel", "No such PaymentIntent", {});
|
|
476
|
+
await invalidRequest("POST", `/v1/payment_intents/${PI[113]}/cancel`, "parameter_invalid", "cannot be expanded", { expand: ["nope"] });
|
|
477
|
+
|
|
478
|
+
// Declines: a real 402 card_error carrying the intent; the failed attempt is not persisted ---
|
|
479
|
+
// Framework constraint (README Limitations): the declared failure rolls back every write, id counters included.
|
|
480
|
+
// A creation-time decline therefore names no PaymentIntent, PaymentMethod or charge: none was stored, and any id
|
|
481
|
+
// would 404 and then be handed to the next object created.
|
|
482
|
+
const intentCountBefore = (await get("/v1/payment_intents?limit=100")).json.data.length;
|
|
483
|
+
const declined = await postError("/v1/payment_intents", { amount: 2000, currency: "usd", confirm: true, payment_method: "pm_card_chargeDeclined" }, 402, "card_error", "card_declined", "Your card was declined.");
|
|
484
|
+
assert.equal(declined.decline_code, "generic_decline");
|
|
485
|
+
assertCreationDeclineBody(declined);
|
|
486
|
+
assert.equal((await get("/v1/payment_intents?limit=100")).json.data.length, intentCountBefore, "no PaymentIntent was stored");
|
|
487
|
+
const insufficient = await postError("/v1/payment_intents", { amount: 2000, currency: "usd", confirm: true, payment_method: "pm_card_chargeDeclinedInsufficientFunds" }, 402, "card_error", "card_declined", "insufficient funds");
|
|
488
|
+
assert.equal(insufficient.decline_code, "insufficient_funds");
|
|
489
|
+
assertCreationDeclineBody(insufficient);
|
|
490
|
+
// Even a PaymentMethod that already existed is not named on a creation-time decline: the body names no object.
|
|
491
|
+
const existingMethodDecline = await postError("/v1/payment_intents", { amount: 2000, currency: "usd", confirm: true, customer: cus(7), payment_method: PM[26] }, 402, "card_error", "card_declined");
|
|
492
|
+
assertCreationDeclineBody(existingMethodDecline);
|
|
493
|
+
const threeDs = (await post("/v1/payment_intents", { amount: 5000, currency: "usd", confirm: true, payment_method: "pm_card_authenticationRequired" })).json;
|
|
494
|
+
assert.equal(threeDs.status, "requires_action");
|
|
495
|
+
assert.equal(threeDs.next_action.type, "use_stripe_sdk");
|
|
496
|
+
assert.equal(threeDs.latest_charge, null);
|
|
497
|
+
assert.equal(stripeIdsIn(declined).includes(threeDs.id), false, "the next PaymentIntent's id never appeared in a decline body");
|
|
498
|
+
const pendingDecline = (await post("/v1/payment_intents", { amount: 2500, currency: "usd", payment_method: "pm_card_chargeDeclined" })).json;
|
|
499
|
+
assert.equal(pendingDecline.status, "requires_confirmation");
|
|
500
|
+
const confirmDecline = await postError(`/v1/payment_intents/${pendingDecline.id}/confirm`, {}, 402, "card_error", "card_declined");
|
|
501
|
+
assert.equal(confirmDecline.payment_intent.id, pendingDecline.id);
|
|
502
|
+
assert.equal(confirmDecline.payment_intent.status, "requires_payment_method", "the 402 body describes the attempt as Stripe would store it");
|
|
503
|
+
assert.equal(confirmDecline.payment_intent.latest_charge, null);
|
|
504
|
+
assert.equal("charge" in confirmDecline.payment_intent.last_payment_error, false, "the declined charge is not persisted, so none is named");
|
|
505
|
+
assert.equal(confirmDecline.payment_method.id, pendingDecline.payment_method, "the method stored on the intent before the call is named");
|
|
506
|
+
assertIdsOnly(confirmDecline, [pendingDecline.id, pendingDecline.payment_method], "confirm decline");
|
|
507
|
+
// Confirming with a catalogue test card materialises a PaymentMethod inside the rolled-back call: it is not named.
|
|
508
|
+
const confirmCardDecline = await postError(`/v1/payment_intents/${pendingDecline.id}/confirm`, { payment_method: "pm_card_chargeDeclined" }, 402, "card_error", "card_declined");
|
|
509
|
+
assert.equal(confirmCardDecline.payment_intent.id, pendingDecline.id);
|
|
510
|
+
assert.equal("payment_method" in confirmCardDecline, false, "a PaymentMethod created by the rolled-back call is not named");
|
|
511
|
+
assert.equal("payment_method" in confirmCardDecline.payment_intent.last_payment_error, false);
|
|
512
|
+
assertIdsOnly(confirmCardDecline, [pendingDecline.id], "confirm decline with a test card");
|
|
513
|
+
const afterDecline = (await get(`/v1/payment_intents/${pendingDecline.id}`)).json;
|
|
514
|
+
assert.equal(afterDecline.status, "requires_confirmation", "a failed attempt rolls back (framework constraint, documented)");
|
|
515
|
+
assert.equal(afterDecline.last_payment_error, null);
|
|
516
|
+
assert.equal(afterDecline.payment_method, pendingDecline.payment_method);
|
|
517
|
+
assert.equal(afterDecline.latest_charge, null);
|
|
518
|
+
assert.equal((await get(`/v1/charges?payment_intent=${pendingDecline.id}`)).json.data.length, 0, "no failed charge is stored");
|
|
519
|
+
const retried = (await post(`/v1/payment_intents/${pendingDecline.id}/confirm`, { payment_method: "pm_card_visa" })).json;
|
|
520
|
+
assert.equal(retried.status, "succeeded", "retrying the same intent with another card works");
|
|
521
|
+
assert.equal(retried.last_payment_error, null);
|
|
522
|
+
|
|
523
|
+
// PaymentIntent reads --------------------------------------------------------------------
|
|
524
|
+
assert.ok(ids((await get(`/v1/payment_intents?customer=${kai.id}`)).json).includes(intent.id));
|
|
525
|
+
await invalidRequest("GET", "/v1/payment_intents?limit=0", "parameter_invalid_integer");
|
|
526
|
+
await notFound("GET", "/v1/payment_intents?starting_after=pi_nope", "No such PaymentIntent");
|
|
527
|
+
await notFound("GET", "/v1/payment_intents/pi_nope", "No such PaymentIntent: 'pi_nope'");
|
|
528
|
+
await invalidRequest("GET", `/v1/payment_intents/${intent.id}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
529
|
+
const intentExpanded = (await get(`/v1/payment_intents/${intent.id}?expand[]=latest_charge&expand[]=customer&expand[]=payment_method`)).json;
|
|
530
|
+
assert.equal(intentExpanded.latest_charge.object, "charge");
|
|
531
|
+
assert.equal(intentExpanded.customer.object, "customer");
|
|
532
|
+
assert.equal(intentExpanded.payment_method.object, "payment_method");
|
|
533
|
+
const starterDeclined = (await get(`/v1/payment_intents/${PI[109]}`)).json;
|
|
534
|
+
assert.equal(starterDeclined.last_payment_error.payment_method.object, "payment_method", "last_payment_error embeds the payment method");
|
|
535
|
+
|
|
536
|
+
// Charges ----------------------------------------------------------------------------------
|
|
537
|
+
assert.deepEqual(ids((await get(`/v1/charges?payment_intent=${intent.id}`)).json), [confirmed.latest_charge]);
|
|
538
|
+
assert.equal((await get(`/v1/charges?customer=${cus(1)}`)).json.data.length, 3);
|
|
539
|
+
await notFound("GET", "/v1/charges/ch_nope", "No such charge");
|
|
540
|
+
await invalidRequest("GET", `/v1/charges/${CH[200]}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
541
|
+
await invalidRequest("GET", "/v1/charges?limit=0", "parameter_invalid_integer");
|
|
542
|
+
await notFound("GET", "/v1/charges?starting_after=ch_nope", "No such charge");
|
|
543
|
+
const withRefunds = (await get(`/v1/charges/${CH[200]}?expand[]=refunds`)).json;
|
|
544
|
+
assert.equal(withRefunds.refunds.object, "list");
|
|
545
|
+
assert.equal(withRefunds.refunds.total_count, 2);
|
|
546
|
+
assert.equal(withRefunds.amount_refunded, 2000);
|
|
547
|
+
|
|
548
|
+
// Refunds ----------------------------------------------------------------------------------
|
|
549
|
+
const refund = (await post("/v1/refunds", { payment_intent: intent.id, amount: 500, reason: "requested_by_customer", metadata: { ticket: "SUP-300" } })).json;
|
|
550
|
+
assert.equal(refund.object, "refund");
|
|
551
|
+
assert.equal(refund.status, "succeeded");
|
|
552
|
+
assert.equal(refund.charge, confirmed.latest_charge);
|
|
553
|
+
assert.equal((await get(`/v1/charges/${confirmed.latest_charge}`)).json.amount_refunded, 500);
|
|
554
|
+
await invalidRequest("POST", "/v1/refunds", "amount_too_large", "greater than unrefunded amount", { payment_intent: intent.id, amount: 99999 });
|
|
555
|
+
const rest = (await post("/v1/refunds", { charge: confirmed.latest_charge })).json;
|
|
556
|
+
assert.equal(rest.amount, 1500);
|
|
557
|
+
assert.equal((await get(`/v1/charges/${confirmed.latest_charge}`)).json.refunded, true);
|
|
558
|
+
await invalidRequest("POST", "/v1/refunds", "charge_already_refunded", "already been refunded", { payment_intent: intent.id });
|
|
559
|
+
await invalidRequest("POST", "/v1/refunds", "charge_not_captured", "not been captured", { charge: CH[208] });
|
|
560
|
+
await invalidRequest("POST", "/v1/refunds", "charge_not_refundable", "declined", { payment_intent: PI[109] });
|
|
561
|
+
await invalidRequest("POST", "/v1/refunds", "parameter_invalid", "only specify one", { payment_intent: intent.id, charge: confirmed.latest_charge });
|
|
562
|
+
await notFound("POST", "/v1/refunds", "No such PaymentIntent", { payment_intent: "pi_nope" });
|
|
563
|
+
assert.equal((await get(`/v1/refunds?charge=${confirmed.latest_charge}`)).json.data.length, 2);
|
|
564
|
+
assert.equal((await get(`/v1/refunds?payment_intent=${PI[100]}`)).json.data.length, 2);
|
|
565
|
+
await invalidRequest("GET", "/v1/refunds?limit=0", "parameter_invalid_integer");
|
|
566
|
+
await notFound("GET", "/v1/refunds?starting_after=re_nope", "No such refund");
|
|
567
|
+
|
|
568
|
+
// Products and prices ----------------------------------------------------------------------
|
|
569
|
+
const headlamp = (await post("/v1/products", { name: "Headlamp 400", description: "USB-C rechargeable, 400 lm", unit_label: "unit", metadata: { sku: "LT-HL-400" } })).json;
|
|
570
|
+
assert.equal(headlamp.object, "product");
|
|
571
|
+
assert.equal(headlamp.active, true);
|
|
572
|
+
assert.equal(headlamp.default_price, null);
|
|
573
|
+
await invalidRequest("POST", "/v1/products", "parameter_invalid_empty", "empty string", { name: "" });
|
|
574
|
+
await invalidRequest("POST", "/v1/products", "parameter_invalid", "default_price", { name: "x", default_price: PRICE[50] });
|
|
575
|
+
const oneTime = (await post("/v1/prices", { product: headlamp.id, unit_amount: 3500, currency: "usd", nickname: "Headlamp retail" })).json;
|
|
576
|
+
assert.equal(oneTime.object, "price");
|
|
577
|
+
assert.equal(oneTime.type, "one_time");
|
|
578
|
+
assert.equal(oneTime.unit_amount_decimal, "3500");
|
|
579
|
+
await invalidRequest("POST", "/v1/prices", "parameter_invalid", "one year", { product: headlamp.id, unit_amount: 100, currency: "usd", recurring: { interval: "month", interval_count: 13 } });
|
|
580
|
+
await invalidRequest("POST", "/v1/prices", "resource_already_exists", "summit_monthly", { product: headlamp.id, unit_amount: 100, currency: "usd", recurring: { interval: "month" }, lookup_key: "summit_monthly" });
|
|
581
|
+
await notFound("POST", "/v1/prices", "No such product: 'prod_nope'", { product: "prod_nope", unit_amount: 100, currency: "usd" });
|
|
582
|
+
const monthly = (await post("/v1/prices", { product: headlamp.id, unit_amount: 900, currency: "usd", recurring: { interval: "month" }, nickname: "Headlamp rental" })).json;
|
|
583
|
+
assert.equal(monthly.type, "recurring");
|
|
584
|
+
assert.equal(monthly.recurring.interval_count, 1);
|
|
585
|
+
const headlampUpdated = (await post(`/v1/products/${headlamp.id}`, { default_price: oneTime.id, active: false })).json;
|
|
586
|
+
assert.equal(headlampUpdated.default_price, oneTime.id);
|
|
587
|
+
assert.equal(headlampUpdated.active, false);
|
|
588
|
+
await invalidRequest("POST", `/v1/products/${headlamp.id}`, "parameter_invalid", "does not belong", { default_price: PRICE[50] });
|
|
589
|
+
await notFound("POST", "/v1/products/prod_nope", "No such product", { name: "x" });
|
|
590
|
+
assert.equal((await get(`/v1/products/${headlamp.id}?expand[]=default_price`)).json.default_price.object, "price");
|
|
591
|
+
await notFound("GET", "/v1/products/prod_nope", "No such product");
|
|
592
|
+
await invalidRequest("GET", `/v1/products/${PROD[40]}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
593
|
+
assert.equal((await get(`/v1/prices?product=${headlamp.id}&active=true`)).json.data.length, 2);
|
|
594
|
+
assert.deepEqual(ids((await get("/v1/prices?lookup_keys[]=summit_monthly")).json), [PRICE[52]]);
|
|
595
|
+
assert.equal((await get("/v1/prices?type=recurring&limit=100")).json.data.length, 6, "starter recurring prices (inactive included) plus the new one");
|
|
596
|
+
assert.equal((await get("/v1/prices?currency=gbp")).json.data.length, 1);
|
|
597
|
+
await invalidRequest("GET", "/v1/prices?limit=0", "parameter_invalid_integer");
|
|
598
|
+
await notFound("GET", "/v1/prices?starting_after=price_nope", "No such price");
|
|
599
|
+
assert.equal((await get(`/v1/prices/${PRICE[52]}?expand[]=product`)).json.product.name, "Summit Membership");
|
|
600
|
+
await notFound("GET", "/v1/prices/price_nope", "No such price");
|
|
601
|
+
await invalidRequest("GET", `/v1/prices/${PRICE[50]}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
602
|
+
assert.deepEqual(ids((await get(`/v1/products?ids[]=${headlamp.id}&ids[]=prod_nope`)).json), [headlamp.id]);
|
|
603
|
+
assert.equal((await get("/v1/products?active=false")).json.data.length, 2);
|
|
604
|
+
await invalidRequest("GET", "/v1/products?limit=0", "parameter_invalid_integer");
|
|
605
|
+
await notFound("GET", "/v1/products?starting_after=prod_nope", "No such product");
|
|
606
|
+
|
|
607
|
+
// Invoice items and invoices ---------------------------------------------------------------
|
|
608
|
+
const item = (await post("/v1/invoiceitems", { customer: cus(10), price: PRICE[51], quantity: 2 })).json;
|
|
609
|
+
assert.equal(item.object, "invoiceitem");
|
|
610
|
+
assert.equal(item.invoice, null, "pending until the next invoice");
|
|
611
|
+
assert.equal(item.amount, 15800);
|
|
612
|
+
assert.equal(item.description, "Merino Base Layer");
|
|
613
|
+
const viaPricing = (await post("/v1/invoiceitems", { customer: cus(10), pricing: { price: PRICE[51] } })).json;
|
|
614
|
+
assert.equal(viaPricing.pricing.price_details.price, PRICE[51]);
|
|
615
|
+
await invalidRequest("POST", "/v1/invoiceitems", "parameter_invalid", "recurring price", { customer: cus(10), price: PRICE[52] });
|
|
616
|
+
const adHoc = (await post("/v1/invoiceitems", { customer: cus(10), amount: 1200, currency: "usd", description: "Setup fee" })).json;
|
|
617
|
+
assert.equal(adHoc.pricing, null);
|
|
618
|
+
await notFound("POST", "/v1/invoiceitems", "No such customer", { customer: "cus_nope", amount: 100, currency: "usd" });
|
|
619
|
+
await notFound("POST", "/v1/invoiceitems", "No such price: 'price_nope'", { customer: cus(10), price: "price_nope" });
|
|
620
|
+
const draft = (await post("/v1/invoices", { customer: cus(10), description: "September order" })).json;
|
|
621
|
+
assert.equal(draft.object, "invoice");
|
|
622
|
+
assert.equal(draft.status, "draft");
|
|
623
|
+
assert.equal(draft.billing_reason, "manual");
|
|
624
|
+
assert.equal(draft.number, null);
|
|
625
|
+
assert.equal(draft.currency, "usd");
|
|
626
|
+
assert.equal(draft.lines.data.length, 5, "the two starter usd pending items plus the three new ones; the eur item stays pending");
|
|
627
|
+
assert.equal(draft.total, 7900 + 1500 + 15800 + 7900 + 1200);
|
|
628
|
+
assert.equal(draft.amount_due, draft.total);
|
|
629
|
+
assert.equal(draft.lines.data[0].parent.type, "invoice_item_details");
|
|
630
|
+
await invalidRequest("POST", "/v1/invoices", "parameter_missing", "days_until_due", { customer: cus(10), collection_method: "send_invoice" });
|
|
631
|
+
await invalidRequest("POST", "/v1/invoices", "payment_method_unattached", "not attached", { customer: cus(10), default_payment_method: PM[20] });
|
|
632
|
+
await notFound("POST", "/v1/invoices", "No such customer", { customer: "cus_nope" });
|
|
633
|
+
const appended = (await post("/v1/invoiceitems", { customer: cus(10), amount: 500, currency: "usd", description: "Gift wrap", invoice: draft.id })).json;
|
|
634
|
+
assert.equal(appended.invoice, draft.id);
|
|
635
|
+
const draftAfter = (await get(`/v1/invoices/${draft.id}`)).json;
|
|
636
|
+
assert.equal(draftAfter.lines.data.length, 6);
|
|
637
|
+
assert.equal(draftAfter.total, draft.total + 500);
|
|
638
|
+
await invalidRequest("POST", "/v1/invoiceitems", "invoice_not_editable", "no longer editable", { customer: cus(2), amount: 100, currency: "usd", invoice: IN[406] });
|
|
639
|
+
await invalidRequest("POST", `/v1/invoices/${IN[401]}/finalize`, "invoice_no_customer_line_items", "no line items", {});
|
|
640
|
+
const open = (await post(`/v1/invoices/${draft.id}/finalize`, {})).json;
|
|
641
|
+
assert.equal(open.status, "open");
|
|
642
|
+
assert.ok(/^[A-Z0-9]{8}-0001$/.test(open.number), open.number);
|
|
643
|
+
assert.equal(open.hosted_invoice_url, `https://invoice.stripe.test/i/${draft.id}`);
|
|
644
|
+
assert.equal(open.status_transitions.finalized_at, T);
|
|
645
|
+
assert.equal(open.payments.data.length, 1);
|
|
646
|
+
assert.equal(open.payments.data[0].status, "open");
|
|
647
|
+
const invoiceIntentId = open.payments.data[0].payment.payment_intent;
|
|
648
|
+
assert.equal((await get(`/v1/payment_intents/${invoiceIntentId}`)).json.status, "requires_payment_method");
|
|
649
|
+
await invalidRequest("POST", `/v1/invoices/${draft.id}/finalize`, "invoice_not_editable", "already finalized", {});
|
|
650
|
+
await notFound("POST", "/v1/invoices/in_nope/finalize", "No such invoice", {});
|
|
651
|
+
await invalidRequest("POST", `/v1/invoices/${IN[400]}/finalize`, "parameter_invalid", "cannot be expanded", { expand: ["nope"] });
|
|
652
|
+
await invalidRequest("POST", `/v1/payment_intents/${invoiceIntentId}/cancel`, "payment_intent_invoice_managed", "belongs to invoice", {});
|
|
653
|
+
const paid = (await post(`/v1/invoices/${draft.id}/pay`, {})).json;
|
|
654
|
+
assert.equal(paid.status, "paid");
|
|
655
|
+
assert.equal(paid.amount_paid, draftAfter.total);
|
|
656
|
+
assert.equal(paid.amount_remaining, 0);
|
|
657
|
+
assert.equal(paid.attempt_count, 1);
|
|
658
|
+
assert.equal(paid.attempted, true);
|
|
659
|
+
assert.equal(paid.status_transitions.paid_at, T);
|
|
660
|
+
assert.equal(paid.payments.data[0].status, "paid");
|
|
661
|
+
assert.ok(paid.payments.data[0].payment.charge.startsWith("ch_"));
|
|
662
|
+
const invoiceIntent = (await get(`/v1/payment_intents/${invoiceIntentId}`)).json;
|
|
663
|
+
assert.equal(invoiceIntent.status, "succeeded");
|
|
664
|
+
assert.equal(invoiceIntent.description, `Invoice ${open.number}`);
|
|
665
|
+
await invalidRequest("POST", `/v1/invoices/${draft.id}/pay`, "invoice_already_paid", "already paid", {});
|
|
666
|
+
await invalidRequest("POST", `/v1/invoices/${IN[400]}/pay`, "invoice_not_finalized", "finalized", {});
|
|
667
|
+
await notFound("POST", "/v1/invoices/in_nope/pay", "No such invoice", {});
|
|
668
|
+
await invalidRequest("POST", `/v1/invoices/${IN[402]}/pay`, "parameter_invalid", "out of band", { paid_out_of_band: true, payment_method: "pm_card_visa" });
|
|
669
|
+
// A declining default card: 402 and the invoice stays open (the attempt is rolled back)
|
|
670
|
+
await post("/v1/invoiceitems", { customer: cus(7), amount: 2000, currency: "usd", description: "Trail map bundle" });
|
|
671
|
+
const declineDraft = (await post("/v1/invoices", { customer: cus(7) })).json;
|
|
672
|
+
const declineOpen = (await post(`/v1/invoices/${declineDraft.id}/finalize`, {})).json;
|
|
673
|
+
assert.equal(declineOpen.status, "open");
|
|
674
|
+
const invoiceDecline = await postError(`/v1/invoices/${declineDraft.id}/pay`, {}, 402, "card_error", "card_declined");
|
|
675
|
+
assert.equal(invoiceDecline.decline_code, "generic_decline");
|
|
676
|
+
assert.equal(invoiceDecline.payment_method.id, PM[26], "the customer's existing default card is named");
|
|
677
|
+
assert.equal(invoiceDecline.payment_intent.latest_charge, null);
|
|
678
|
+
assertIdsOnly(invoiceDecline, [invoiceDecline.payment_intent.id, cus(7), declineDraft.id, PM[26]], "invoice pay decline");
|
|
679
|
+
const invoiceCardDecline = await postError(`/v1/invoices/${declineDraft.id}/pay`, { payment_method: "pm_card_chargeDeclined" }, 402, "card_error", "card_declined");
|
|
680
|
+
assert.equal(invoiceCardDecline.payment_intent.id, invoiceDecline.payment_intent.id);
|
|
681
|
+
assert.equal("payment_method" in invoiceCardDecline, false, "a PaymentMethod created by the rolled-back call is not named");
|
|
682
|
+
assertIdsOnly(invoiceCardDecline, [invoiceDecline.payment_intent.id, cus(7), declineDraft.id], "invoice pay decline with a test card");
|
|
683
|
+
const stillOpen = (await get(`/v1/invoices/${declineDraft.id}?expand[]=payments.data.payment.payment_intent`)).json;
|
|
684
|
+
assert.equal(stillOpen.status, "open");
|
|
685
|
+
assert.equal(stillOpen.attempt_count, 0, "the declined attempt is not persisted (framework constraint, documented)");
|
|
686
|
+
const stillOpenIntent = stillOpen.payments.data[0].payment.payment_intent;
|
|
687
|
+
assert.equal(invoiceDecline.payment_intent.id, stillOpenIntent.id);
|
|
688
|
+
assert.equal(invoiceDecline.payment_intent.last_payment_error.decline_code, "generic_decline");
|
|
689
|
+
assert.equal(stillOpenIntent.status, "requires_payment_method");
|
|
690
|
+
assert.equal(stillOpenIntent.last_payment_error, null, "unlike Stripe, last_payment_error is not stored");
|
|
691
|
+
const paidWithVisa = (await post(`/v1/invoices/${declineDraft.id}/pay`, { payment_method: "pm_card_visa" })).json;
|
|
692
|
+
assert.equal(paidWithVisa.status, "paid");
|
|
693
|
+
await invalidRequest("POST", `/v1/invoices/${IN[409]}/pay`, "invoice_no_payment_method", "no attached payment source", {});
|
|
694
|
+
// Void
|
|
695
|
+
const voided = (await post(`/v1/invoices/${IN[402]}/void`, {})).json;
|
|
696
|
+
assert.equal(voided.status, "void");
|
|
697
|
+
assert.equal(voided.status_transitions.voided_at, T);
|
|
698
|
+
assert.equal(voided.payments.data[0].status, "canceled");
|
|
699
|
+
const voidedIntent = (await get(`/v1/payment_intents/${PI[114]}`)).json;
|
|
700
|
+
assert.equal(voidedIntent.status, "canceled");
|
|
701
|
+
assert.equal(voidedIntent.cancellation_reason, "void_invoice");
|
|
702
|
+
await invalidRequest("POST", `/v1/invoices/${IN[400]}/void`, "invoice_not_finalized", "Draft invoices", {});
|
|
703
|
+
await invalidRequest("POST", `/v1/invoices/${IN[406]}/void`, "invoice_already_paid", "already paid", {});
|
|
704
|
+
await notFound("POST", "/v1/invoices/in_nope/void", "No such invoice", {});
|
|
705
|
+
await invalidRequest("POST", `/v1/invoices/${IN[403]}/void`, "parameter_invalid", "cannot be expanded", { expand: ["nope"] });
|
|
706
|
+
// Invoice reads
|
|
707
|
+
assert.ok(ids((await get("/v1/invoices?status=open")).json).includes(IN[403]));
|
|
708
|
+
assert.ok(!ids((await get("/v1/invoices?status=open")).json).includes(IN[402]));
|
|
709
|
+
assert.equal((await get(`/v1/invoices?customer=${cus(10)}`)).json.data.length, 1);
|
|
710
|
+
assert.deepEqual(ids((await get(`/v1/invoices?subscription=${SUB[601]}`)).json), [IN[404]]);
|
|
711
|
+
assert.deepEqual(ids((await get(`/v1/invoices?due_date[lt]=${T}&status=open`)).json), [IN[403]], "the past-due open invoice");
|
|
712
|
+
assert.equal((await get(`/v1/invoices?due_date[lt]=${T}`)).json.data.length, 2, "past due: the open one and the uncollectible one");
|
|
713
|
+
assert.equal((await get("/v1/invoices?collection_method=send_invoice&limit=100")).json.data.length, 3);
|
|
714
|
+
await notFound("GET", "/v1/invoices/in_nope", "No such invoice");
|
|
715
|
+
await notFound("GET", "/v1/invoices/upcoming", "No such invoice: 'upcoming'");
|
|
716
|
+
await invalidRequest("GET", `/v1/invoices/${IN[400]}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
717
|
+
await invalidRequest("GET", "/v1/invoices?limit=0", "parameter_invalid_integer");
|
|
718
|
+
await notFound("GET", "/v1/invoices?starting_after=in_nope", "No such invoice");
|
|
719
|
+
const invoiceExpanded = (await get(`/v1/invoices/${IN[404]}?expand[]=customer&expand[]=payments.data.payment.payment_intent&expand[]=parent.subscription_details.subscription`)).json;
|
|
720
|
+
assert.equal(invoiceExpanded.customer.object, "customer");
|
|
721
|
+
assert.equal(invoiceExpanded.payments.data[0].payment.payment_intent.object, "payment_intent");
|
|
722
|
+
assert.equal(invoiceExpanded.parent.subscription_details.subscription.object, "subscription");
|
|
723
|
+
const delinquent = (await get(`/v1/customers/${cus(8)}`)).json;
|
|
724
|
+
assert.equal(delinquent.delinquent, true, "an open invoice past its due date makes the customer delinquent");
|
|
725
|
+
|
|
726
|
+
// Subscriptions -----------------------------------------------------------------------------
|
|
727
|
+
const active = (await post("/v1/subscriptions", { customer: cus(1), items: [{ price: PRICE[54], quantity: 2 }], expand: ["latest_invoice.payments.data.payment.payment_intent"], metadata: { source: "conformance" } })).json;
|
|
728
|
+
assert.equal(active.object, "subscription");
|
|
729
|
+
assert.equal(active.status, "active");
|
|
730
|
+
assert.equal(active.currency, "usd");
|
|
731
|
+
assert.equal(active.items.data[0].current_period_start, T);
|
|
732
|
+
assert.equal(active.items.data[0].current_period_end, MONTH_LATER, "one month later, calendar arithmetic");
|
|
733
|
+
assert.equal(active.items.data[0].plan.amount, 1200);
|
|
734
|
+
assert.equal(active.items.data[0].price.id, PRICE[54]);
|
|
735
|
+
assert.equal(active.latest_invoice.status, "paid");
|
|
736
|
+
assert.equal(active.latest_invoice.billing_reason, "subscription_create");
|
|
737
|
+
assert.equal(active.latest_invoice.total, 2400);
|
|
738
|
+
assert.equal(active.latest_invoice.lines.data[0].parent.type, "subscription_item_details");
|
|
739
|
+
assert.equal(active.latest_invoice.lines.data[0].description, "2 × Basecamp Membership (at $12.00 / month)");
|
|
740
|
+
assert.equal(active.latest_invoice.payments.data[0].payment.payment_intent.status, "succeeded");
|
|
741
|
+
await invalidRequest("POST", "/v1/subscriptions", "parameter_invalid", "one-time price", { customer: cus(1), items: [{ price: PRICE[50] }] });
|
|
742
|
+
await invalidRequest("POST", "/v1/subscriptions", "parameter_invalid", "inactive", { customer: cus(1), items: [{ price: PRICE[58] }] });
|
|
743
|
+
await invalidRequest("POST", "/v1/subscriptions", "parameter_invalid", "billing interval", { customer: cus(1), items: [{ price: PRICE[52] }, { price: PRICE[53] }] });
|
|
744
|
+
await notFound("POST", "/v1/subscriptions", "No such customer", { customer: "cus_nope", items: [{ price: PRICE[54] }] });
|
|
745
|
+
await notFound("POST", "/v1/subscriptions", "No such price: 'price_nope'", { customer: cus(1), items: [{ price: "price_nope" }] });
|
|
746
|
+
const incomplete = (await post("/v1/subscriptions", { customer: cus(5), items: [{ price: PRICE[52] }], expand: ["latest_invoice.payments.data.payment.payment_intent"] })).json;
|
|
747
|
+
assert.equal(incomplete.status, "incomplete", "no payment method: incomplete with an open first invoice");
|
|
748
|
+
assert.equal(incomplete.latest_invoice.status, "open");
|
|
749
|
+
assert.equal(incomplete.latest_invoice.payments.data[0].payment.payment_intent.status, "requires_payment_method");
|
|
750
|
+
const declinedSub = (await post("/v1/subscriptions", { customer: cus(7), items: [{ price: PRICE[52] }], expand: ["latest_invoice.payments.data.payment.payment_intent"] })).json;
|
|
751
|
+
assert.equal(declinedSub.status, "incomplete", "a declining default card: incomplete, the failed attempt persisted");
|
|
752
|
+
const declinedSubIntent = declinedSub.latest_invoice.payments.data[0].payment.payment_intent;
|
|
753
|
+
assert.equal(declinedSubIntent.status, "requires_payment_method");
|
|
754
|
+
assert.equal(declinedSubIntent.last_payment_error.decline_code, "generic_decline");
|
|
755
|
+
assert.equal(declinedSub.latest_invoice.attempt_count, 1);
|
|
756
|
+
assert.equal((await get(`/v1/charges?payment_intent=${declinedSubIntent.id}`)).json.data[0].status, "failed");
|
|
757
|
+
const subDecline = await postError("/v1/subscriptions", { customer: cus(7), items: [{ price: PRICE[52] }], payment_behavior: "error_if_incomplete" }, 402, "card_error", "card_declined");
|
|
758
|
+
assert.equal(subDecline.decline_code, "generic_decline");
|
|
759
|
+
assertCreationDeclineBody(subDecline);
|
|
760
|
+
await invalidRequest("POST", "/v1/subscriptions", "customer_missing_payment_method", "no attached payment source", { customer: cus(5), items: [{ price: PRICE[52] }], payment_behavior: "error_if_incomplete" });
|
|
761
|
+
const trialing = (await post("/v1/subscriptions", { customer: cus(2), items: [{ price: PRICE[52] }], trial_period_days: 14, expand: ["latest_invoice"] })).json;
|
|
762
|
+
assert.equal(trialing.status, "trialing");
|
|
763
|
+
assert.equal(trialing.trial_start, T);
|
|
764
|
+
assert.equal(trialing.trial_end, T + 14 * DAY);
|
|
765
|
+
assert.equal(trialing.latest_invoice.status, "paid");
|
|
766
|
+
assert.equal(trialing.latest_invoice.total, 0);
|
|
767
|
+
assert.equal(trialing.latest_invoice.payments.data.length, 0, "a zero-total invoice needs no PaymentIntent");
|
|
768
|
+
const sendInvoice = (await post("/v1/subscriptions", { customer: cus(3), items: [{ price: PRICE[54] }], collection_method: "send_invoice", days_until_due: 30, expand: ["latest_invoice"] })).json;
|
|
769
|
+
assert.equal(sendInvoice.status, "active");
|
|
770
|
+
assert.equal(sendInvoice.days_until_due, 30);
|
|
771
|
+
assert.equal(sendInvoice.latest_invoice.status, "open");
|
|
772
|
+
assert.equal(sendInvoice.latest_invoice.due_date, T + 30 * DAY);
|
|
773
|
+
// Listing
|
|
774
|
+
assert.equal((await get("/v1/subscriptions?limit=100")).json.data.length, 10, "canceled subscriptions are hidden by default");
|
|
775
|
+
assert.equal((await get("/v1/subscriptions?limit=100&status=all")).json.data.length, 11);
|
|
776
|
+
assert.deepEqual(ids((await get("/v1/subscriptions?status=canceled")).json), [SUB[605]]);
|
|
777
|
+
assert.deepEqual(ids((await get("/v1/subscriptions?status=ended")).json), [SUB[605]]);
|
|
778
|
+
assert.equal((await get(`/v1/subscriptions?customer=${cus(1)}`)).json.data.length, 2);
|
|
779
|
+
assert.equal((await get(`/v1/subscriptions?price=${PRICE[54]}&limit=100`)).json.data.length, 5);
|
|
780
|
+
assert.equal((await get(`/v1/subscriptions?created[gte]=${T}&limit=100`)).json.data.length, 5);
|
|
781
|
+
await invalidRequest("GET", "/v1/subscriptions?limit=0", "parameter_invalid_integer");
|
|
782
|
+
await notFound("GET", "/v1/subscriptions?starting_after=sub_nope", "No such subscription");
|
|
783
|
+
await notFound("GET", "/v1/subscriptions/sub_nope", "No such subscription");
|
|
784
|
+
await invalidRequest("GET", `/v1/subscriptions/${SUB[601]}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
785
|
+
assert.equal((await get(`/v1/subscriptions/${SUB[604]}?expand[]=latest_invoice&expand[]=customer`)).json.latest_invoice.status, "paid");
|
|
786
|
+
// Updates
|
|
787
|
+
const cancelLater = (await post(`/v1/subscriptions/${SUB[601]}`, { cancel_at_period_end: true })).json;
|
|
788
|
+
assert.equal(cancelLater.status, "active");
|
|
789
|
+
assert.equal(cancelLater.cancel_at_period_end, true);
|
|
790
|
+
assert.equal(cancelLater.cancel_at, cancelLater.items.data[0].current_period_end);
|
|
791
|
+
assert.equal(cancelLater.canceled_at, T);
|
|
792
|
+
assert.equal(cancelLater.cancellation_details.reason, "cancellation_requested");
|
|
793
|
+
const resumed = (await post(`/v1/subscriptions/${SUB[601]}`, { cancel_at_period_end: false })).json;
|
|
794
|
+
assert.equal(resumed.cancel_at, null);
|
|
795
|
+
assert.equal(resumed.canceled_at, null);
|
|
796
|
+
const quantity = (await post(`/v1/subscriptions/${SUB[601]}`, { items: [{ id: SI_601[0], quantity: 3 }], proration_behavior: "none" })).json;
|
|
797
|
+
assert.equal(quantity.items.data.find((entry) => entry.id === SI_601[0]).quantity, 3);
|
|
798
|
+
await invalidRequest("POST", `/v1/subscriptions/${SUB[601]}`, "parameter_invalid", "billing interval", { items: [{ price: PRICE[53] }] });
|
|
799
|
+
const added = (await post(`/v1/subscriptions/${SUB[601]}`, { items: [{ price: monthly.id }] })).json;
|
|
800
|
+
assert.equal(added.items.data.length, 3);
|
|
801
|
+
const removed = (await post(`/v1/subscriptions/${SUB[601]}`, { items: [{ id: SI_601[1], deleted: true }] })).json;
|
|
802
|
+
assert.equal(removed.items.data.length, 2);
|
|
803
|
+
await invalidRequest("POST", `/v1/subscriptions/${trialing.id}`, "parameter_invalid", "at least one item", { items: [{ id: trialing.items.data[0].id, deleted: true }] });
|
|
804
|
+
await notFound("POST", `/v1/subscriptions/${SUB[601]}`, "No such subscription_item: 'si_nope'", { items: [{ id: "si_nope", quantity: 1 }] });
|
|
805
|
+
await invalidRequest("POST", `/v1/subscriptions/${SUB[601]}`, "payment_method_unattached", "not attached", { default_payment_method: PM[20] });
|
|
806
|
+
await invalidRequest("POST", `/v1/subscriptions/${SUB[605]}`, "subscription_canceled", "canceled subscription", { description: "x" });
|
|
807
|
+
await notFound("POST", "/v1/subscriptions/sub_nope", "No such subscription", { description: "x" });
|
|
808
|
+
const endedTrial = (await post(`/v1/subscriptions/${trialing.id}`, { trial_end: "now" })).json;
|
|
809
|
+
assert.equal(endedTrial.status, "active");
|
|
810
|
+
assert.equal(endedTrial.trial_end, T);
|
|
811
|
+
const described = (await post(`/v1/subscriptions/${SUB[604]}`, { description: "Summer only", metadata: { note: "keep" }, cancellation_details: { comment: "Season over", feedback: "unused" } })).json;
|
|
812
|
+
assert.equal(described.description, "Summer only");
|
|
813
|
+
assert.equal(described.cancellation_details.comment, "Season over");
|
|
814
|
+
// Cancel
|
|
815
|
+
const canceledSub = (await del(`/v1/subscriptions/${incomplete.id}`)).json;
|
|
816
|
+
assert.equal(canceledSub.status, "canceled");
|
|
817
|
+
assert.equal(canceledSub.canceled_at, T);
|
|
818
|
+
assert.equal(canceledSub.ended_at, T);
|
|
819
|
+
assert.equal((await get(`/v1/invoices/${incomplete.latest_invoice.id}`)).json.status, "void", "cancelling an incomplete subscription voids its first invoice");
|
|
820
|
+
await notFound("DELETE", `/v1/subscriptions/${incomplete.id}`, "No such subscription");
|
|
821
|
+
await notFound("DELETE", "/v1/subscriptions/sub_nope", "No such subscription");
|
|
822
|
+
await invalidRequest("DELETE", `/v1/subscriptions/${SUB[604]}?expand[]=nope`, "parameter_invalid", "cannot be expanded");
|
|
823
|
+
const withBody = await fetch(`${HTTP}/v1/subscriptions/${SUB[604]}`, { method: "DELETE", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/x-www-form-urlencoded" }, body: "invoice_now=true" });
|
|
824
|
+
assert.equal(withBody.status, 400, "DELETE accepts no body (framework HTTP_BODY_NOT_ALLOWED)");
|
|
825
|
+
assert.equal((await withBody.json()).code, "framework.HTTP_BODY_NOT_ALLOWED");
|
|
826
|
+
|
|
827
|
+
// Idempotency, unknown routes, auth ------------------------------------------------------
|
|
828
|
+
const idem1 = (await post("/v1/customers", { name: "Idem Potent", email: "idem@example.org" }, { headers: { "idempotency-key": "conformance-key-1" } }));
|
|
829
|
+
const idem2 = (await post("/v1/customers", { name: "Idem Potent", email: "idem@example.org" }, { headers: { "idempotency-key": "conformance-key-1" } }));
|
|
830
|
+
assert.deepEqual(idem2.json, idem1.json, "the same Idempotency-Key replays the original response");
|
|
831
|
+
assert.equal(idem1.headers.get("idempotency-key"), "conformance-key-1");
|
|
832
|
+
assert.equal((await get("/v1/customers?email=idem@example.org")).json.data.length, 1);
|
|
833
|
+
const unknown = await fetch(`${HTTP}/v1/checkout/sessions`, { headers: { authorization: `Bearer ${HTTP_TOKEN}` } });
|
|
834
|
+
assert.equal(unknown.status, 404);
|
|
835
|
+
await unknown.text();
|
|
836
|
+
const basic = await fetch(`${HTTP}/v1/customers`, { headers: { authorization: `Basic ${Buffer.from(`${HTTP_TOKEN}:`).toString("base64")}` } });
|
|
837
|
+
assert.equal(basic.status, 401, "HTTP Basic (curl -u sk_test_x:) is not accepted");
|
|
838
|
+
await basic.text();
|
|
839
|
+
assert.equal((await get("/v1/customers?limit=100")).json.data.length, 14);
|
|
840
|
+
await formDecoderGuards();
|
|
841
|
+
assert.equal((await get("/v1/customers?limit=100")).json.data.length, 14, "rejected forms created no customer");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ---------------------------------------------------------------------------------------------
|
|
845
|
+
// Drill: mcp-aliases (owner, baseline)
|
|
846
|
+
// ---------------------------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
async function mcpAliases() {
|
|
849
|
+
await mcpInit();
|
|
850
|
+
const tools = (await rpc("tools/list", {})).tools.map((tool) => tool.name);
|
|
851
|
+
for (const alias of ALIASES) assert.ok(tools.includes(alias), `alias ${alias} missing`);
|
|
852
|
+
for (const operationId of ALL_OPERATIONS) assert.ok(tools.includes(`stripe.${operationId}`), `canonical stripe.${operationId} missing`);
|
|
853
|
+
|
|
854
|
+
const balance = await mcp("retrieve_balance", {});
|
|
855
|
+
assert.equal(balance.object, "balance");
|
|
856
|
+
const customer = await mcp("create_customer", { name: "Alias Co", email: "alias@example.org" });
|
|
857
|
+
assert.equal(customer.object, "customer");
|
|
858
|
+
assert.equal(customer.email, "alias@example.org");
|
|
859
|
+
assert.deepEqual(ids(await mcp("list_customers", { limit: 3, email: "alias@example.org" })), [customer.id]);
|
|
860
|
+
const product = await mcp("create_product", { name: "Alias Widget", description: "Created over MCP" });
|
|
861
|
+
assert.equal(product.object, "product");
|
|
862
|
+
const products = await mcp("list_products", { limit: 2 });
|
|
863
|
+
assert.equal(products.data.length, 2);
|
|
864
|
+
assert.equal(products.has_more, true);
|
|
865
|
+
const price = await mcp("create_price", { product: product.id, unit_amount: 1200, currency: "usd" });
|
|
866
|
+
assert.equal(price.type, "one_time");
|
|
867
|
+
assert.deepEqual(ids(await mcp("list_prices", { product: product.id })), [price.id]);
|
|
868
|
+
const invoice = await mcp("create_invoice", { customer: customer.id, days_until_due: 7 });
|
|
869
|
+
assert.equal(invoice.status, "draft");
|
|
870
|
+
assert.equal(invoice.collection_method, "send_invoice", "days_until_due implies send_invoice");
|
|
871
|
+
assert.equal(invoice.due_date, T + 7 * DAY);
|
|
872
|
+
const item = await mcp("create_invoice_item", { customer: customer.id, price: price.id, invoice: invoice.id });
|
|
873
|
+
assert.equal(item.invoice, invoice.id);
|
|
874
|
+
const finalized = await mcp("finalize_invoice", { invoice: invoice.id });
|
|
875
|
+
assert.equal(finalized.status, "open");
|
|
876
|
+
assert.equal(finalized.total, 1200);
|
|
877
|
+
assert.deepEqual(ids(await mcp("list_invoices", { customer: customer.id })), [invoice.id]);
|
|
878
|
+
const intents = await mcp("list_payment_intents", { customer: cus(1), limit: 2 });
|
|
879
|
+
assert.equal(intents.data.length, 2);
|
|
880
|
+
assert.equal(intents.has_more, true);
|
|
881
|
+
const refund = await mcp("create_refund", { payment_intent: PI[102], amount: 100 });
|
|
882
|
+
assert.equal(refund.status, "succeeded");
|
|
883
|
+
assert.equal(refund.charge, CH[202]);
|
|
884
|
+
const subscriptions = await mcp("list_subscriptions", { status: "all", limit: 3 });
|
|
885
|
+
assert.equal(subscriptions.data.length, 3);
|
|
886
|
+
assert.equal(subscriptions.has_more, true);
|
|
887
|
+
const updated = await mcp("update_subscription", { subscription: SUB[601], proration_behavior: "none", items: [{ id: SI_601[0], quantity: 4 }] });
|
|
888
|
+
assert.equal(updated.items.data.find((entry) => entry.id === SI_601[0]).quantity, 4);
|
|
889
|
+
const canceled = await mcp("cancel_subscription", { subscription: SUB[602] });
|
|
890
|
+
assert.equal(canceled.status, "canceled");
|
|
891
|
+
|
|
892
|
+
await mcpError("create_refund", { payment_intent: "pi_nope" }, "tool.RESOURCE_MISSING");
|
|
893
|
+
await mcpError("list_prices", { limit: 500 }, "tool.INVALID_REQUEST");
|
|
894
|
+
await mcpError("finalize_invoice", { invoice: invoice.id }, "tool.INVALID_STATE");
|
|
895
|
+
await mcpError("create_customer", { name: "x", bogus: 1 });
|
|
896
|
+
|
|
897
|
+
assert.equal((await get(`/v1/customers/${customer.id}`)).json.name, "Alias Co", "the MCP-created customer is visible over REST");
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ---------------------------------------------------------------------------------------------
|
|
901
|
+
// Drill: restricted-key (readonly-key, baseline)
|
|
902
|
+
// ---------------------------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
async function restrictedKey() {
|
|
905
|
+
assert.equal((await get("/v1/balance")).json.object, "balance");
|
|
906
|
+
assert.equal((await get("/v1/customers")).json.data.length, 10);
|
|
907
|
+
const denied = await postError("/v1/customers", { name: "x" }, 403, "invalid_request_error", undefined, "'customers: write'");
|
|
908
|
+
assert.ok(denied.message.includes("acct_1S00Q1HxKLumenTr"));
|
|
909
|
+
await postError("/v1/refunds", { payment_intent: PI[102] }, 403, "invalid_request_error", undefined, "'refunds: write'");
|
|
910
|
+
await postError(`/v1/payment_intents/${PI[112]}/confirm`, {}, 403, "invalid_request_error", undefined, "'payment_intents: write'");
|
|
911
|
+
await postError(`/v1/invoices/${IN[400]}/finalize`, {}, 403, "invalid_request_error", undefined, "'invoices: write'");
|
|
912
|
+
await postError("/v1/subscriptions", { customer: cus(1), items: [{ price: PRICE[54] }] }, 403, "invalid_request_error", undefined, "'subscriptions: write'");
|
|
913
|
+
await postError("/v1/products", { name: "x" }, 403, "invalid_request_error", undefined, "'products: write'");
|
|
914
|
+
await mcpInit();
|
|
915
|
+
await mcpError("create_customer", { name: "x" }, "tool.PERMISSION_DENIED");
|
|
916
|
+
const context = await mcp("stripe.dashboard.context", {});
|
|
917
|
+
assert.equal(context.permissions.customers, "read", "the dashboard context reports the restricted key's levels");
|
|
918
|
+
assert.equal(context.permissions.refunds, "read");
|
|
919
|
+
assert.equal((await get("/v1/customers?limit=100")).json.data.length, 12, "nothing was written");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ---------------------------------------------------------------------------------------------
|
|
923
|
+
// Drill: no-permissions (noperm-key, baseline)
|
|
924
|
+
// ---------------------------------------------------------------------------------------------
|
|
925
|
+
|
|
926
|
+
async function noPermissions() {
|
|
927
|
+
for (const entry of CALLS) await callError(entry, 403, "invalid_request_error", undefined, "does not have the required permissions");
|
|
928
|
+
await mcpInit();
|
|
929
|
+
await mcpError("list_customers", {}, "tool.PERMISSION_DENIED");
|
|
930
|
+
assert.equal((await mcp("stripe.dashboard.context", {})).permissions.customers, "none", "the dashboard context never fails, it reports 'none'");
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ---------------------------------------------------------------------------------------------
|
|
934
|
+
// Drill: live-mode (live-key, baseline)
|
|
935
|
+
// ---------------------------------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
async function liveMode() {
|
|
938
|
+
await notFound("POST", "/v1/payment_methods/pm_card_visa/attach", "No such PaymentMethod: 'pm_card_visa'", { customer: cus(1) });
|
|
939
|
+
await notFound("POST", "/v1/payment_intents", "No such PaymentMethod: 'pm_card_visa'", { amount: 2000, currency: "usd", payment_method: "pm_card_visa", confirm: true });
|
|
940
|
+
const intent = (await post("/v1/payment_intents", { amount: 2000, currency: "usd" })).json;
|
|
941
|
+
assert.equal(intent.livemode, true);
|
|
942
|
+
assert.equal(intent.status, "requires_payment_method");
|
|
943
|
+
await notFound("POST", `/v1/payment_intents/${intent.id}/confirm`, "No such PaymentMethod: 'pm_card_visa'", { payment_method: "pm_card_visa" });
|
|
944
|
+
assert.equal((await get(`/v1/customers/${cus(1)}`)).json.livemode, true, "objects are stamped with the key's mode");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---------------------------------------------------------------------------------------------
|
|
948
|
+
// Drill: denied (auditor, baseline)
|
|
949
|
+
// ---------------------------------------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
async function denied() {
|
|
952
|
+
const text = "not granted";
|
|
953
|
+
await getError("/v1/balance", 403, "invalid_request_error", undefined, text);
|
|
954
|
+
await getError("/v1/customers", 403, "invalid_request_error", undefined, text);
|
|
955
|
+
await postError("/v1/customers", { name: "x" }, 403, "invalid_request_error", undefined, text);
|
|
956
|
+
await mcpInit();
|
|
957
|
+
const result = await rpc("tools/call", { name: "list_customers", arguments: {} });
|
|
958
|
+
assert.ok(result.isError);
|
|
959
|
+
assert.equal(result.structuredContent.status, "denied");
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ---------------------------------------------------------------------------------------------
|
|
963
|
+
// Drill: rate-limited (owner, rate-limited)
|
|
964
|
+
// ---------------------------------------------------------------------------------------------
|
|
965
|
+
|
|
966
|
+
async function rateLimited() {
|
|
967
|
+
for (const entry of CALLS) {
|
|
968
|
+
const error = await callError(entry, 429, "invalid_request_error", "rate_limit", "rate limit");
|
|
969
|
+
assert.equal(error.doc_url, "https://stripe.com/docs/error-codes/rate-limit");
|
|
970
|
+
}
|
|
971
|
+
await mcpInit();
|
|
972
|
+
const error = (await mcpError("retrieve_balance", {}, "tool.RATE_LIMITED")).error;
|
|
973
|
+
assert.ok(error.message.includes("rate limit"));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------------------------
|
|
977
|
+
// Drill: api-unavailable (owner, api-unavailable)
|
|
978
|
+
// ---------------------------------------------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
async function apiUnavailable() {
|
|
981
|
+
for (const entry of WRITE_CALLS) await callError(entry, 503, "api_error", undefined, "Please retry");
|
|
982
|
+
assert.equal((await get("/v1/customers")).json.data.length, 10, "reads keep working");
|
|
983
|
+
assert.equal((await get("/v1/balance")).json.object, "balance");
|
|
984
|
+
const draft = (await get(`/v1/invoices/${IN[400]}`)).json;
|
|
985
|
+
assert.equal(draft.status, "draft", "nothing was finalized");
|
|
986
|
+
assert.equal((await get(`/v1/invoices/${IN[402]}`)).json.status, "open", "nothing was voided");
|
|
987
|
+
assert.equal((await get(`/v1/subscriptions/${SUB[602]}`)).json.status, "trialing", "nothing was canceled");
|
|
988
|
+
assert.equal((await get(`/v1/customers/${cus(1)}`)).json.description, "Repeat customer since spring 2026", "nothing was updated");
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ---------------------------------------------------------------------------------------------
|
|
992
|
+
// Drill: refund-committed-lost (owner, refund-committed-lost)
|
|
993
|
+
// ---------------------------------------------------------------------------------------------
|
|
994
|
+
|
|
995
|
+
async function refundCommittedLost() {
|
|
996
|
+
const body = { payment_intent: PI[106], amount: 10000, reason: "requested_by_customer" };
|
|
997
|
+
await postError("/v1/refunds", body, 503, "api_error", undefined, "Please retry", { headers: { "idempotency-key": "lost-refund-1" } });
|
|
998
|
+
assert.equal((await get(`/v1/refunds?payment_intent=${PI[106]}`)).json.data.length, 1, "the refund committed although the caller saw 503");
|
|
999
|
+
assert.equal((await get(`/v1/charges/${CH[206]}`)).json.amount_refunded, 10000);
|
|
1000
|
+
await postError("/v1/refunds", body, 503, "api_error", undefined, "Please retry", { headers: { "idempotency-key": "lost-refund-1" } });
|
|
1001
|
+
assert.equal((await get(`/v1/refunds?payment_intent=${PI[106]}`)).json.data.length, 1, "the same Idempotency-Key replays the original 503 without a second refund");
|
|
1002
|
+
await postError("/v1/refunds", body, 503, "api_error", undefined, "Please retry", { headers: { "idempotency-key": "lost-refund-2" } });
|
|
1003
|
+
assert.equal((await get(`/v1/refunds?payment_intent=${PI[106]}`)).json.data.length, 2, "a new key refunds again: the double-refund trap");
|
|
1004
|
+
assert.equal((await get(`/v1/charges/${CH[206]}`)).json.amount_refunded, 20000);
|
|
1005
|
+
await postError("/v1/refunds", { payment_intent: PI[106] }, 503, "api_error", undefined, "Please retry", { headers: { "idempotency-key": "lost-refund-3" } });
|
|
1006
|
+
const charge = (await get(`/v1/charges/${CH[206]}`)).json;
|
|
1007
|
+
assert.equal(charge.refunded, true);
|
|
1008
|
+
assert.equal(charge.amount_refunded, 189000);
|
|
1009
|
+
await invalidRequest("POST", "/v1/refunds", "charge_already_refunded", "already been refunded", { payment_intent: PI[106], amount: 100 });
|
|
1010
|
+
assert.equal((await get(`/v1/refunds?payment_intent=${PI[106]}`)).json.data.length, 3);
|
|
1011
|
+
assert.equal((await post("/v1/customers", { name: "Not affected" })).json.object, "customer", "customer creates are not covered by the fault");
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ---------------------------------------------------------------------------------------------
|
|
1015
|
+
// Drill: large-pages (owner, baseline) — pages filled by encoded bytes, never over the 1 MiB response cap
|
|
1016
|
+
// ---------------------------------------------------------------------------------------------
|
|
1017
|
+
|
|
1018
|
+
const MIB = 1048576;
|
|
1019
|
+
|
|
1020
|
+
/** GET that measures the encoded body in bytes and asserts 200 below the framework's 1 MiB response cap. */
|
|
1021
|
+
async function sized(path) {
|
|
1022
|
+
const response = await fetch(`${HTTP}${path}`, { headers: { authorization: `Bearer ${HTTP_TOKEN}` } });
|
|
1023
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
1024
|
+
const text = new TextDecoder().decode(bytes);
|
|
1025
|
+
assert.equal(response.status, 200, `GET ${path} -> ${response.status} ${text.slice(0, 300)}`);
|
|
1026
|
+
assert.ok(bytes.length < MIB, `GET ${path} answered ${bytes.length} bytes`);
|
|
1027
|
+
return { json: JSON.parse(text), bytes: bytes.length };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Walk a list forward with starting_after and back with ending_before; every object exactly once, same order. */
|
|
1031
|
+
async function walkBothWays(path) {
|
|
1032
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
1033
|
+
const forward = [];
|
|
1034
|
+
let pages = 0;
|
|
1035
|
+
for (let after; ; pages += 1) {
|
|
1036
|
+
const page = (await sized(`${path}${after === undefined ? "" : `${sep}starting_after=${after}`}`)).json;
|
|
1037
|
+
forward.push(...page.data.map((item) => item.id));
|
|
1038
|
+
if (!page.has_more) break;
|
|
1039
|
+
assert.ok(page.data.length > 0, `${path}: has_more with an empty page`);
|
|
1040
|
+
after = page.data.at(-1).id;
|
|
1041
|
+
}
|
|
1042
|
+
assert.equal(new Set(forward).size, forward.length, `${path}: an object appeared twice going forward`);
|
|
1043
|
+
const backward = [forward.at(-1)];
|
|
1044
|
+
for (let before = forward.at(-1); ; ) {
|
|
1045
|
+
const page = (await sized(`${path}${sep}ending_before=${before}`)).json;
|
|
1046
|
+
backward.unshift(...page.data.map((item) => item.id));
|
|
1047
|
+
if (!page.has_more) break;
|
|
1048
|
+
before = page.data[0].id;
|
|
1049
|
+
}
|
|
1050
|
+
assert.deepEqual(backward, forward, `${path}: backward pages must return exactly the forward objects`);
|
|
1051
|
+
return { ids: forward, pages: pages + 1 };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function largePages() {
|
|
1055
|
+
// Small lists behave as before: count-sized pages.
|
|
1056
|
+
const small = (await sized("/v1/customers?limit=5")).json;
|
|
1057
|
+
assert.equal(small.data.length, 5);
|
|
1058
|
+
assert.equal(small.has_more, true);
|
|
1059
|
+
|
|
1060
|
+
// Twelve customers of about 92 KB of UTF-8 each (CJK name, description and 50 metadata values).
|
|
1061
|
+
const created = [];
|
|
1062
|
+
for (let index = 0; index < 12; index += 1) {
|
|
1063
|
+
const metadata = {};
|
|
1064
|
+
for (let key = 0; key < 50; key += 1) metadata[`k${key}`] = "界".repeat(500);
|
|
1065
|
+
created.push((await post("/v1/customers", { name: "界".repeat(256), description: "界".repeat(5000), metadata })).json.id);
|
|
1066
|
+
}
|
|
1067
|
+
const first = await sized("/v1/customers?limit=100");
|
|
1068
|
+
assert.equal(first.json.has_more, true, "the byte budget ends the page before 24 customers");
|
|
1069
|
+
assert.ok(first.json.data.length < 24 && first.json.data.length > 0);
|
|
1070
|
+
for (const path of ["/v1/customers?limit=100", "/v1/customers?limit=11", "/v1/customers?limit=100&expand[]=data.invoice_settings.default_payment_method"]) {
|
|
1071
|
+
const { ids, pages } = await walkBothWays(path);
|
|
1072
|
+
assert.equal(ids.length, 24, `${path}: every customer once`);
|
|
1073
|
+
assert.ok(pages >= 2, `${path}: more than one page`);
|
|
1074
|
+
for (const id of created) assert.ok(ids.includes(id));
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// An expanded list: PaymentIntents embedding the large customers.
|
|
1078
|
+
for (const customer of created.slice(0, 12)) await post("/v1/payment_intents", { amount: 2000, currency: "usd", customer, payment_method: "pm_card_visa", confirm: true });
|
|
1079
|
+
const intents = await walkBothWays("/v1/payment_intents?limit=100&expand[]=data.customer");
|
|
1080
|
+
assert.ok(intents.pages >= 2 && intents.ids.length >= 12);
|
|
1081
|
+
|
|
1082
|
+
// Embedded charge.refunds holds the first 10 with has_more and total_count; the rest list through /v1/refunds.
|
|
1083
|
+
const intent = (await post("/v1/payment_intents", { amount: 2000, currency: "usd", payment_method: "pm_card_visa", confirm: true })).json;
|
|
1084
|
+
for (let index = 0; index < 11; index += 1) await post("/v1/refunds", { payment_intent: intent.id, amount: 100 });
|
|
1085
|
+
const charge = (await sized(`/v1/charges/${intent.latest_charge}?expand[]=refunds`)).json;
|
|
1086
|
+
assert.equal(charge.refunds.data.length, 10);
|
|
1087
|
+
assert.equal(charge.refunds.has_more, true);
|
|
1088
|
+
assert.equal(charge.refunds.total_count, 11);
|
|
1089
|
+
assert.equal((await walkBothWays(`/v1/refunds?charge=${intent.latest_charge}&limit=4`)).ids.length, 11);
|
|
1090
|
+
|
|
1091
|
+
// 101 prices on one product list completely.
|
|
1092
|
+
const product = (await post("/v1/products", { name: "Hundred and one prices" })).json;
|
|
1093
|
+
for (let index = 0; index < 101; index += 1) await post("/v1/prices", { product: product.id, unit_amount: 100 + index, currency: "usd" });
|
|
1094
|
+
assert.equal((await walkBothWays(`/v1/prices?product=${product.id}&limit=100`)).ids.length, 101);
|
|
1095
|
+
|
|
1096
|
+
// One object that cannot fit a response answers invalid_request_error, and the failed write is discarded.
|
|
1097
|
+
const invoice = (await post("/v1/invoices", { customer: created[0], pending_invoice_items_behavior: "exclude" })).json;
|
|
1098
|
+
let accepted = 0;
|
|
1099
|
+
for (; accepted < 20; accepted += 1) {
|
|
1100
|
+
const metadata = {};
|
|
1101
|
+
for (let key = 0; key < 50; key += 1) metadata[`k${key}`] = "界".repeat(500);
|
|
1102
|
+
const body = { customer: created[0], invoice: invoice.id, amount: 100, currency: "usd", description: "界".repeat(5000), metadata };
|
|
1103
|
+
const response = await api("POST", "/v1/invoiceitems", { body, status: accepted < 9 ? 200 : 400 });
|
|
1104
|
+
if (response.status === 400) {
|
|
1105
|
+
assert.equal(response.json.error.type, "invalid_request_error");
|
|
1106
|
+
assert.ok(response.json.error.message.includes("too large to return in one response"), JSON.stringify(response.json));
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
assert.equal(accepted, 9, "the tenth 92 KB line would push the invoice past the object budget");
|
|
1111
|
+
assert.equal((await sized(`/v1/invoices/${invoice.id}`)).json.lines.data.length, 9, "the rejected line was not added");
|
|
1112
|
+
await invalidRequest("GET", `/v1/invoices/${invoice.id}?expand[]=customer`, "parameter_invalid", "too large to return in one response");
|
|
1113
|
+
await invalidRequest("GET", "/v1/invoices?limit=100&expand[]=data.customer", "parameter_invalid", "too large to return in one response");
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// ---------------------------------------------------------------------------------------------
|
|
1117
|
+
|
|
1118
|
+
const flows = {
|
|
1119
|
+
"rest-flow": restFlow,
|
|
1120
|
+
"mcp-aliases": mcpAliases,
|
|
1121
|
+
"restricted-key": restrictedKey,
|
|
1122
|
+
"no-permissions": noPermissions,
|
|
1123
|
+
"live-mode": liveMode,
|
|
1124
|
+
denied,
|
|
1125
|
+
"rate-limited": rateLimited,
|
|
1126
|
+
"api-unavailable": apiUnavailable,
|
|
1127
|
+
"refund-committed-lost": refundCommittedLost,
|
|
1128
|
+
"large-pages": largePages,
|
|
1129
|
+
};
|
|
1130
|
+
const selected = Object.keys(flows).find((name) => instruction.includes(`the ${name} conformance flow`));
|
|
1131
|
+
if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
|
|
1132
|
+
await flows[selected]();
|
|
1133
|
+
process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
|