@firedrill-tools/quickbooks 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +240 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +2338 -0
  5. package/firedrill/conformance.suite.json +22 -0
  6. package/firedrill/payment-response-lost.scenario.json +2344 -0
  7. package/firedrill/qbo-company-query.drill.json +143 -0
  8. package/firedrill/qbo-customers-items.drill.json +173 -0
  9. package/firedrill/qbo-default-identity.drill.json +53 -0
  10. package/firedrill/qbo-denied.drill.json +53 -0
  11. package/firedrill/qbo-escape-heavy-invoices.drill.json +160 -0
  12. package/firedrill/qbo-invalid-auth.drill.json +398 -0
  13. package/firedrill/qbo-invoice-lifecycle.drill.json +193 -0
  14. package/firedrill/qbo-large-invoice.drill.json +174 -0
  15. package/firedrill/qbo-mcp-shapes.drill.json +503 -0
  16. package/firedrill/qbo-payment-response-lost.drill.json +80 -0
  17. package/firedrill/qbo-payments.drill.json +110 -0
  18. package/firedrill/qbo-reports-only.drill.json +398 -0
  19. package/firedrill/qbo-roles.drill.json +143 -0
  20. package/firedrill/qbo-throttled.drill.json +398 -0
  21. package/firedrill/qbo-tight-limits.drill.json +368 -0
  22. package/firedrill/qbo-write-outage.drill.json +411 -0
  23. package/firedrill/throttled.scenario.json +2344 -0
  24. package/firedrill/tight-limits.scenario.json +2338 -0
  25. package/firedrill/tools/quickbooks/app/assets/ATTRIBUTION.md +33 -0
  26. package/firedrill/tools/quickbooks/app/assets/fonts/OFL.txt +93 -0
  27. package/firedrill/tools/quickbooks/app/assets/intuit-quickbooks.svg +1 -0
  28. package/firedrill/tools/quickbooks/app/assets/quickbooks.svg +1 -0
  29. package/firedrill/tools/quickbooks/app/site/app.js +157 -0
  30. package/firedrill/tools/quickbooks/app/site/assets/fonts/figtree-latin.woff2 +0 -0
  31. package/firedrill/tools/quickbooks/app/site/assets/intuit-quickbooks.svg +1 -0
  32. package/firedrill/tools/quickbooks/app/site/assets/quickbooks.svg +1 -0
  33. package/firedrill/tools/quickbooks/app/site/base.css +107 -0
  34. package/firedrill/tools/quickbooks/app/site/forms.css +85 -0
  35. package/firedrill/tools/quickbooks/app/site/icons.js +73 -0
  36. package/firedrill/tools/quickbooks/app/site/index.html +81 -0
  37. package/firedrill/tools/quickbooks/app/site/nav.css +99 -0
  38. package/firedrill/tools/quickbooks/app/site/store.js +85 -0
  39. package/firedrill/tools/quickbooks/app/site/tables.css +114 -0
  40. package/firedrill/tools/quickbooks/app/site/ui.js +189 -0
  41. package/firedrill/tools/quickbooks/app/site/views/accounts.js +59 -0
  42. package/firedrill/tools/quickbooks/app/site/views/batch-actions.js +116 -0
  43. package/firedrill/tools/quickbooks/app/site/views/batch.js +91 -0
  44. package/firedrill/tools/quickbooks/app/site/views/common.js +87 -0
  45. package/firedrill/tools/quickbooks/app/site/views/customer-detail.js +85 -0
  46. package/firedrill/tools/quickbooks/app/site/views/customer-drawer.js +71 -0
  47. package/firedrill/tools/quickbooks/app/site/views/customers.js +87 -0
  48. package/firedrill/tools/quickbooks/app/site/views/home.js +79 -0
  49. package/firedrill/tools/quickbooks/app/site/views/invoice-actions.js +109 -0
  50. package/firedrill/tools/quickbooks/app/site/views/invoice-editor.js +126 -0
  51. package/firedrill/tools/quickbooks/app/site/views/invoice-lines.js +92 -0
  52. package/firedrill/tools/quickbooks/app/site/views/invoices.js +118 -0
  53. package/firedrill/tools/quickbooks/app/site/views/items.js +105 -0
  54. package/firedrill/tools/quickbooks/app/site/views/payments.js +63 -0
  55. package/firedrill/tools/quickbooks/app/site/views/receive-payment.js +102 -0
  56. package/firedrill/tools/quickbooks/app/site/views/txn-frame.js +25 -0
  57. package/firedrill/tools/quickbooks/behavior.mjs +10 -0
  58. package/firedrill/tools/quickbooks/lib/access.mjs +167 -0
  59. package/firedrill/tools/quickbooks/lib/common.mjs +204 -0
  60. package/firedrill/tools/quickbooks/lib/criteria.mjs +107 -0
  61. package/firedrill/tools/quickbooks/lib/customers.mjs +140 -0
  62. package/firedrill/tools/quickbooks/lib/fields.mjs +157 -0
  63. package/firedrill/tools/quickbooks/lib/handlers-read.mjs +81 -0
  64. package/firedrill/tools/quickbooks/lib/handlers-write.mjs +95 -0
  65. package/firedrill/tools/quickbooks/lib/invoice-draft.mjs +165 -0
  66. package/firedrill/tools/quickbooks/lib/invoices.mjs +229 -0
  67. package/firedrill/tools/quickbooks/lib/items.mjs +117 -0
  68. package/firedrill/tools/quickbooks/lib/like.mjs +49 -0
  69. package/firedrill/tools/quickbooks/lib/payments.mjs +200 -0
  70. package/firedrill/tools/quickbooks/lib/pdf.mjs +96 -0
  71. package/firedrill/tools/quickbooks/lib/query-eval.mjs +178 -0
  72. package/firedrill/tools/quickbooks/lib/query-parse.mjs +229 -0
  73. package/firedrill/tools/quickbooks/lib/views.mjs +88 -0
  74. package/firedrill/tools/quickbooks/lib/wire.mjs +147 -0
  75. package/firedrill/tools/quickbooks/quickbooks.tool.json +11014 -0
  76. package/firedrill/world.json +559 -0
  77. package/firedrill/write-outage.scenario.json +2344 -0
  78. package/firedrill.json +5 -0
  79. package/package.json +52 -0
  80. package/starter.json +2337 -0
  81. package/test/conformance.mjs +674 -0
@@ -0,0 +1,674 @@
1
+ // QuickBooks Online Tool conformance target. A scripted Tool test, not a model-driven agent.
2
+ // Node built-ins only: fetch against the QuickBooks-shaped /v3/company/{realmId} routes and raw MCP JSON-RPC
3
+ // (Streamable HTTP) for the MCP tool-name aliases. Every flow fails loudly on an unexpected status or body.
4
+ import assert from "node:assert/strict";
5
+
6
+ let task = "";
7
+ for await (const chunk of process.stdin) task += chunk;
8
+ const instruction = String(JSON.parse(task).instruction ?? "");
9
+
10
+ const HTTP = process.env.FIREDRILL_HTTP_URL;
11
+ const HTTP_TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
12
+ const MCP = process.env.FIREDRILL_MCP_URL;
13
+ const MCP_TOKEN = process.env.FIREDRILL_MCP_TOKEN;
14
+ assert.ok(HTTP && HTTP_TOKEN && MCP && MCP_TOKEN, "HTTP and MCP bindings are required");
15
+
16
+ const REALM = "9341453172839201";
17
+ const B = `/v3/company/${REALM}`;
18
+ const q = (text) => `${B}/query?query=${encodeURIComponent(text)}&minorversion=75`;
19
+ const NOW_TIME = "2026-09-15T02:00:00.000-07:00";
20
+
21
+ // ---------------------------------------------------------------------------------------------
22
+ // Transport helpers
23
+ // ---------------------------------------------------------------------------------------------
24
+
25
+ /** QuickBooks-shaped request; asserts the status and returns { status, json, bytes, headers }. */
26
+ async function api(method, path, { body, text, status = 200, contentType } = {}) {
27
+ const headers = { authorization: `Bearer ${HTTP_TOKEN}`, accept: "application/json" };
28
+ let payload;
29
+ if (body !== undefined) {
30
+ headers["content-type"] = contentType ?? "application/json";
31
+ payload = typeof body === "string" ? body : JSON.stringify(body);
32
+ } else if (text !== undefined) {
33
+ headers["content-type"] = "application/text";
34
+ payload = text;
35
+ }
36
+ const response = await fetch(`${HTTP}${path}`, { method, headers, body: payload });
37
+ const bytes = new Uint8Array(await response.arrayBuffer());
38
+ const type = response.headers.get("content-type") ?? "";
39
+ let json;
40
+ if (type.includes("json") && bytes.length > 0) json = JSON.parse(new TextDecoder().decode(bytes));
41
+ const preview = json === undefined ? `${bytes.length} bytes ${type}` : JSON.stringify(json).slice(0, 600);
42
+ assert.equal(response.status, status, `${method} ${path} ${payload === undefined ? "" : String(payload).slice(0, 200)} -> ${response.status} ${preview}`);
43
+ return { status: response.status, json, bytes, headers: response.headers };
44
+ }
45
+ const get = (path, options) => api("GET", path, options);
46
+ const post = (path, body, options) => api("POST", path, { ...options, body });
47
+
48
+ /** Expect a QuickBooks Fault envelope with the given HTTP status and QuickBooks error code. */
49
+ async function fault(method, path, status, code, { body, text, type, detail } = {}) {
50
+ const result = await api(method, path, { body, text, status });
51
+ const envelope = result.json?.Fault;
52
+ assert.ok(envelope && Array.isArray(envelope.Error) && envelope.Error.length === 1, `${method} ${path}: no Fault envelope: ${JSON.stringify(result.json)}`);
53
+ const error = envelope.Error[0];
54
+ assert.equal(error.code, code, `${method} ${path}: ${JSON.stringify(result.json)}`);
55
+ assert.equal(typeof error.Message, "string");
56
+ assert.equal(typeof error.Detail, "string");
57
+ if (type !== undefined) assert.equal(envelope.type, type, JSON.stringify(result.json));
58
+ if (detail !== undefined) assert.ok(error.Detail.includes(detail), `${method} ${path}: Detail ${JSON.stringify(error.Detail)} lacks ${JSON.stringify(detail)}`);
59
+ return result.json;
60
+ }
61
+
62
+ let rpcId = 0;
63
+ async function rpc(method, params) {
64
+ const response = await fetch(MCP, {
65
+ method: "POST",
66
+ headers: { authorization: `Bearer ${MCP_TOKEN}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
67
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
68
+ });
69
+ assert.equal(response.status, 200, `MCP ${method} -> HTTP ${response.status}`);
70
+ const raw = await response.text();
71
+ const type = response.headers.get("content-type") ?? "";
72
+ const messages = type.includes("text/event-stream")
73
+ ? raw.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5).trim()))
74
+ : [JSON.parse(raw)];
75
+ const reply = messages.find((message) => message.id === rpcId);
76
+ assert.ok(reply, `MCP ${method}: no JSON-RPC reply`);
77
+ if (reply.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(reply.error)}`);
78
+ return reply.result;
79
+ }
80
+ async function mcpInit() {
81
+ await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "quickbooks-conformance", version: "0.1.0" } });
82
+ }
83
+ async function mcp(name, args) {
84
+ const result = await rpc("tools/call", { name, arguments: args });
85
+ assert.ok(!result.isError, `${name} ${JSON.stringify(args).slice(0, 300)}: ${JSON.stringify(result).slice(0, 600)}`);
86
+ return result.structuredContent;
87
+ }
88
+ async function mcpError(name, args, code) {
89
+ const result = await rpc("tools/call", { name, arguments: args });
90
+ assert.ok(result.isError, `${name} ${JSON.stringify(args).slice(0, 300)} unexpectedly succeeded`);
91
+ const error = result.structuredContent?.error;
92
+ assert.ok(error, `${name}: ${JSON.stringify(result).slice(0, 400)}`);
93
+ assert.equal(error.code, `tool.${code}`, `${name}: ${JSON.stringify(error).slice(0, 400)}`);
94
+ return error;
95
+ }
96
+
97
+ const INVOICE_BODY = { CustomerRef: { value: "58" }, Line: [{ DetailType: "SalesItemLineDetail", Amount: 150, SalesItemLineDetail: { ItemRef: { value: "20" }, Qty: 1 } }] };
98
+
99
+ /** One representative, schema-valid call per operation (used by the auth, role, fault and bound drills). */
100
+ const CALLS = [
101
+ ["company-info.get", ["GET", `${B}/companyinfo/1`]],
102
+ ["query.run", ["GET", q("select * from Customer maxresults 1")]],
103
+ ["customers.get", ["GET", `${B}/customer/58`]],
104
+ ["customers.search", ["mcp", "search_customers", { criteria: [{ field: "DisplayName", value: "Harbor%", operator: "LIKE" }] }]],
105
+ ["customers.post", ["POST", `${B}/customer`, { DisplayName: "Probe Customer" }]],
106
+ ["customers.create", ["mcp", "create_customer", { customer: { DisplayName: "Probe MCP Customer" } }]],
107
+ ["customers.update", ["mcp", "update_customer", { customer: { Id: "67", SyncToken: "0", sparse: true, Notes: "probe" } }]],
108
+ ["customers.delete", ["mcp", "delete_customer", { idOrEntity: "67" }]],
109
+ ["items.get", ["GET", `${B}/item/20`]],
110
+ ["items.search", ["mcp", "search_items", { criteria: { Type: "Service" } }]],
111
+ ["items.post", ["POST", `${B}/item`, { Name: "Probe item", Type: "Service", IncomeAccountRef: { value: "45" } }]],
112
+ ["invoices.get", ["GET", `${B}/invoice/1036`]],
113
+ ["invoices.search", ["mcp", "search_invoices", { criteria: [{ field: "Balance", value: 0, operator: ">" }] }]],
114
+ ["invoices.pdf", ["GET", `${B}/invoice/1046/pdf`]],
115
+ ["invoices.post", ["POST", `${B}/invoice`, INVOICE_BODY]],
116
+ ["invoices.create", ["mcp", "create_invoice", { customer_ref: "58", line_items: [{ item_ref: "20", qty: 1, unit_price: 150 }] }]],
117
+ ["invoices.update", ["mcp", "update_invoice", { invoice_id: "1046", patch: { PrivateNote: "probe" } }]],
118
+ ["invoices.delete", ["mcp", "delete_invoice", { idOrEntity: "1049" }]],
119
+ ["invoices.send", ["POST", `${B}/invoice/1049/send`]],
120
+ ["payments.get", ["GET", `${B}/payment/190`]],
121
+ ["payments.search", ["mcp", "search_payments", { customer_ref: "63" }]],
122
+ ["payments.post", ["POST", `${B}/payment`, { CustomerRef: { value: "61" }, TotalAmt: 10 }]],
123
+ ["payments.create", ["mcp", "create_payment", { customer_ref: "61", total_amt: 10 }]],
124
+ ["accounts.get", ["GET", `${B}/account/84`]],
125
+ ["accounts.search", ["mcp", "search_accounts", { criteria: [{ field: "AccountType", value: "Income" }] }]],
126
+ ];
127
+ const WRITES = new Set(["customers.post", "customers.create", "customers.update", "customers.delete", "items.post", "invoices.post", "invoices.create", "invoices.update", "invoices.delete", "invoices.send", "payments.post", "payments.create"]);
128
+ const QBO_CODE = { THROTTLE_EXCEEDED: "3001", SERVICE_UNAVAILABLE: "10000", AUTHENTICATION_FAILED: "3200", AUTHORIZATION_FAILED: "3100", STATE_BOUND_EXCEEDED: "10000" };
129
+ const STATUS = { THROTTLE_EXCEEDED: 429, SERVICE_UNAVAILABLE: 503, AUTHENTICATION_FAILED: 401, AUTHORIZATION_FAILED: 403, STATE_BOUND_EXCEEDED: 500 };
130
+
131
+ /** Run a CALLS entry expecting the declared error `code`. */
132
+ async function callError([, call], code) {
133
+ if (call[0] === "mcp") return mcpError(call[1], call[2], code);
134
+ return fault(call[0], call[1], STATUS[code], QBO_CODE[code], { body: call[2] });
135
+ }
136
+ async function callOk([, call]) {
137
+ if (call[0] === "mcp") return mcp(call[1], call[2]);
138
+ return api(call[0], call[1], { body: call[2] });
139
+ }
140
+ const byOp = (id) => CALLS.find(([op]) => op === id);
141
+
142
+ // ---------------------------------------------------------------------------------------------
143
+ // Sweep flows: auth, roles, faults, bounds
144
+ // ---------------------------------------------------------------------------------------------
145
+
146
+ async function invalidAuth() {
147
+ await mcpInit();
148
+ for (const entry of CALLS) await callError(entry, "AUTHENTICATION_FAILED");
149
+ const body = await fault("GET", `${B}/companyinfo/1`, 401, "3200", { type: "AuthenticationFault" });
150
+ assert.equal(body.Fault.Error[0].Message, "message=AuthenticationFailed; errorCode=003200; statusCode=401");
151
+ assert.equal(body.time, NOW_TIME, "declared errors carry the company time");
152
+ }
153
+
154
+ async function reportsOnly() {
155
+ await mcpInit();
156
+ const info = (await get(`${B}/companyinfo/1`)).json;
157
+ assert.equal(info.CompanyInfo.CompanyName, "Brightwater Studio LLC");
158
+ for (const entry of CALLS) if (entry[0] !== "company-info.get") await callError(entry, "AUTHORIZATION_FAILED");
159
+ await fault("POST", `${B}/invoice/1040/send`, 403, "3100", { type: "AuthorizationFault" });
160
+ }
161
+
162
+ async function throttled() {
163
+ await mcpInit();
164
+ for (const entry of CALLS) await callError(entry, "THROTTLE_EXCEEDED");
165
+ const body = await fault("GET", `${B}/invoice/1036`, 429, "3001");
166
+ assert.equal(body.Fault.Error[0].Message, "message=ThrottleExceeded; errorCode=003001; statusCode=429");
167
+ assert.equal(body.Fault.Error[0].Detail, "Throttling limits exceeded.");
168
+ assert.equal(body.time, undefined, "a fault outcome has no handler time");
169
+ }
170
+
171
+ async function writeOutage() {
172
+ await mcpInit();
173
+ for (const entry of CALLS) {
174
+ if (WRITES.has(entry[0])) await callError(entry, "SERVICE_UNAVAILABLE");
175
+ else await callOk(entry);
176
+ }
177
+ const body = await fault("POST", `${B}/customer`, 503, "10000", { body: { DisplayName: "Outage" }, type: "SystemFault" });
178
+ assert.equal(body.Fault.Error[0].Message, "An application error has occurred while processing your request");
179
+ assert.equal((await get(q("select count(*) from Customer"))).json.QueryResponse.totalCount, 13, "nothing was written");
180
+ }
181
+
182
+ async function tightLimits() {
183
+ await mcpInit();
184
+ const unbounded = new Set(["company-info.get", "items.get", "payments.get", "payments.post", "payments.create"]);
185
+ for (const entry of CALLS) {
186
+ if (unbounded.has(entry[0])) {
187
+ if (!WRITES.has(entry[0])) await callOk(entry);
188
+ } else await callError(entry, "STATE_BOUND_EXCEEDED");
189
+ }
190
+ const body = await fault("GET", q("select * from Invoice"), 500, "10000", { type: "SystemFault", detail: "supported bound of 5 rows" });
191
+ assert.equal(body.Fault.Error[0].Message, "An application error has occurred while processing your request");
192
+ }
193
+
194
+ async function denied() {
195
+ await get(`${B}/invoice/1036`, { status: 403 });
196
+ await post(`${B}/payment`, { CustomerRef: { value: "61" }, TotalAmt: 5 }, { status: 403 });
197
+ }
198
+
199
+ async function roles() {
200
+ await mcpInit();
201
+ const created = (await post(`${B}/customer`, { DisplayName: "Clerk Created LLC" })).json.Customer;
202
+ assert.equal(created.Id, "72");
203
+ const invoice = (await post(`${B}/invoice`, { CustomerRef: { value: created.Id }, Line: [{ DetailType: "SalesItemLineDetail", SalesItemLineDetail: { ItemRef: { value: "21" }, Qty: 1 } }] })).json.Invoice;
204
+ assert.equal(invoice.TotalAmt, 1200);
205
+ const payment = (await post(`${B}/payment`, { CustomerRef: { value: created.Id }, TotalAmt: 200, Line: [{ Amount: 200, LinkedTxn: [{ TxnId: invoice.Id, TxnType: "Invoice" }] }] })).json.Payment;
206
+ assert.equal(payment.UnappliedAmt, 0);
207
+ assert.equal((await get(`${B}/invoice/${invoice.Id}`)).json.Invoice.Balance, 1000);
208
+ assert.equal((await get(`${B}/item/20`)).json.Item.Name, "Design consulting");
209
+ await fault("POST", `${B}/item`, 403, "3100", { body: { Name: "Clerk item", Type: "Service", IncomeAccountRef: { value: "45" } }, type: "AuthorizationFault" });
210
+ await fault("GET", `${B}/account/1`, 403, "3100");
211
+ await fault("GET", q("select * from Account"), 403, "3100");
212
+ await mcpError("search_accounts", {}, "AUTHORIZATION_FAILED");
213
+ const embedded = (await get(`${B}/payment/${payment.Id}`)).json.Payment;
214
+ assert.deepEqual(embedded.DepositToAccountRef, { value: "4", name: "Undeposited Funds" }, "embedded account refs stay readable");
215
+ }
216
+
217
+ async function defaultIdentity() {
218
+ const info = (await get(`${B}/companyinfo/1`)).json;
219
+ assert.equal(info.CompanyInfo.CompanyName, "Brightwater Studio LLC");
220
+ assert.equal(info.time, NOW_TIME);
221
+ const created = (await post(`${B}/customer`, { GivenName: "Rae", FamilyName: "Okafor" })).json.Customer;
222
+ assert.equal(created.DisplayName, "Rae Okafor", "DisplayName defaults to the name parts");
223
+ assert.equal(created.MetaData.CreateTime, "2026-09-15T02:00:00-07:00", "fresh worlds use starter virtual time");
224
+ }
225
+
226
+ async function paymentResponseLost() {
227
+ await mcpInit();
228
+ const full = { CustomerRef: { value: "68" }, TotalAmt: 1200, Line: [{ Amount: 1200, LinkedTxn: [{ TxnId: "1043", TxnType: "Invoice" }] }] };
229
+ await fault("POST", `${B}/payment`, 503, "10000", { body: full });
230
+ assert.equal((await get(`${B}/invoice/1043`)).json.Invoice.Balance, 0, "the lost payment committed");
231
+ await fault("POST", `${B}/payment`, 400, "6000", { body: full, detail: "exceeds its open balance" });
232
+ await mcpError("create_payment", { customer_ref: "61", total_amt: 250 }, "SERVICE_UNAVAILABLE");
233
+ await mcpError("create_payment", { customer_ref: "61", total_amt: 250 }, "SERVICE_UNAVAILABLE");
234
+ assert.equal((await get(q("select count(*) from Payment where CustomerRef = '61'"))).json.QueryResponse.totalCount, 3, "the naive retry duplicated the unapplied payment");
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------------------------
238
+ // Admin flows: company + query, customers + items
239
+ // ---------------------------------------------------------------------------------------------
240
+
241
+ const rows = async (text, entity) => (await get(q(text))).json.QueryResponse[entity] ?? [];
242
+
243
+ async function companyQuery() {
244
+ const info = await get(`${B}/companyinfo/1`);
245
+ assert.equal(info.json.CompanyInfo.CompanyName, "Brightwater Studio LLC");
246
+ assert.equal(info.json.time, NOW_TIME, "time is company virtual time");
247
+ assert.ok(info.headers.get("intuit_tid"), "intuit_tid header");
248
+ await fault("GET", `${B}/companyinfo/2`, 400, "610", { type: "ValidationFault" });
249
+ await fault("GET", `/v3/company/123/companyinfo/1`, 403, "3100", { type: "AuthorizationFault" });
250
+
251
+ const all = (await get(q("select * from Customer"))).json.QueryResponse;
252
+ assert.equal(all.Customer.length, 13, "inactive customers are hidden by default");
253
+ assert.equal(all.startPosition, 1);
254
+ const pages = [];
255
+ for (const start of [1, 6, 11]) pages.push((await rows(`select * from Customer startposition ${start} maxresults 5`, "Customer")).length);
256
+ assert.deepEqual(pages, [5, 5, 3]);
257
+ assert.deepEqual((await get(q("select * from Customer startposition 16 maxresults 5"))).json.QueryResponse, {});
258
+ assert.equal((await rows("select * from Customer where Active IN (true, false)", "Customer")).length, 14);
259
+ assert.deepEqual((await rows("select * from Customer where DisplayName LIKE 'harbor%'", "Customer")).map((c) => c.Id), ["58", "59"]);
260
+ assert.equal((await rows("select * from Customer where DisplayName = 'O\\'Neill Bakery'", "Customer"))[0].Id, "60");
261
+ assert.equal((await rows("select * from Customer orderby DisplayName desc maxresults 1", "Customer"))[0].DisplayName, "Summit Outfitters");
262
+ const sparse = (await rows("select Id, DisplayName from Customer where Id = '58'", "Customer"))[0];
263
+ assert.equal(sparse.sparse, true);
264
+ assert.equal(sparse.Balance, undefined);
265
+ assert.equal((await get(q("select count(*) from Invoice where Balance > '0'"))).json.QueryResponse.totalCount, 9);
266
+ assert.deepEqual((await rows("select * from Invoice where CustomerRef = '64' and DueDate < '2026-09-15'", "Invoice")).map((i) => i.Id), ["1041", "1044"]);
267
+ assert.equal((await rows("select * from Invoice where Id IN ('1036', '1042')", "Invoice")).length, 2);
268
+ const items = (await api("POST", `${B}/query?minorversion=75`, { text: "select * from Item" })).json.QueryResponse.Item;
269
+ assert.equal(items.length, 7, "POST /query with an application/text body");
270
+
271
+ const parse = (text) => fault("GET", q(text), 400, "4000");
272
+ await parse("select * from Customer where Active = true OR Taxable = false");
273
+ await parse("selec * from Invoice");
274
+ await parse("select * from Invoice maxresults 1001");
275
+ await fault("GET", `${B}/query?query=select%20*%20from%20Customer%20where%20DisplayName%20%3D%20'%C3'`, 400, "4000");
276
+ // Malformed percent-encoding arrives as U+FFFD: a correctly encoded U+FFFD (%EF%BF%BD) is rejected too; %ZZ stays literal.
277
+ await fault("GET", `${B}/query?query=select%20*%20from%20Customer%20where%20DisplayName%20like%20'%25Ac%E0%A4%A%25'`, 400, "4000");
278
+ await fault("GET", `${B}/query?query=select%20*%20from%20Customer%20where%20DisplayName%20%3D%20'x%EF%BF%BD'`, 400, "4000");
279
+ await fault("POST", `${B}/query`, 400, "4000", { text: "select * from Customer where DisplayName = 'x\uFFFD'" });
280
+ assert.deepEqual((await get(`${B}/query?query=select%20*%20from%20Customer%20where%20DisplayName%20%3D%20'a%ZZ'`)).json.QueryResponse, {});
281
+ // A malformed escape in a PATH segment never reaches the Tool: the framework refuses the path before routing (documented in
282
+ // README "Compatibility"), so the body is the framework 404 envelope, not a Fault. A well-formed U+FFFD id does reach the handler.
283
+ for (const path of [`${B}/customer/%E0%A4%A`, `${B}/customer/%ZZ`, `${B}/invoice/%E0%A4%A/pdf`, `/v3/company/%E0%A4%A/customer/58`]) {
284
+ const refused = await get(path, { status: 404 });
285
+ assert.equal(refused.json?.code, "framework.HTTP_ROUTE_NOT_FOUND", `${path}: ${JSON.stringify(refused.json)}`);
286
+ assert.equal(refused.json?.Fault, undefined);
287
+ }
288
+ await fault("GET", `${B}/customer/%EF%BF%BD`, 400, "610", { type: "ValidationFault" });
289
+ assert.equal((await rows("select * from Customer where DisplayName like '%café%'", "Customer")).length, 1);
290
+ assert.equal((await rows("select * from Customer where DisplayName like '%漢字🔥%'", "Customer")).length, 0);
291
+ await fault("GET", q("select * from Widget"), 400, "4001");
292
+ await fault("GET", q("select * from Customer where Notes = 'x'"), 400, "4001");
293
+ await fault("GET", q("select * from Customer where Balance LIKE '1%'"), 400, "4001");
294
+
295
+ assert.equal((await get(`${B}/account/84`)).json.Account.CurrentBalance, 9805, "A/R equals the open invoice balances");
296
+ assert.equal((await get(`${B}/account/1`)).json.Account.CurrentBalance, 16655);
297
+ assert.equal((await rows("select * from Account", "Account")).length, 9);
298
+ await fault("GET", `${B}/account/999`, 400, "610");
299
+ assert.equal((await get(`${B}/customer/64`)).json.Customer.Balance, 2450);
300
+ await fault("GET", `${B}/customer/abc`, 400, "610");
301
+ await fault("GET", `${B}/customer/99999999999`, 400, "610");
302
+ }
303
+
304
+ async function customersItems() {
305
+ const kelp = { DisplayName: "Kelp Street Bikes", PrimaryEmailAddr: { Address: "ride@kelpstreet.example.com" }, Notes: "Cargo bikes" };
306
+ const created = (await post(`${B}/customer?requestid=kelp-1`, kelp)).json.Customer;
307
+ assert.equal(created.Id, "72");
308
+ assert.equal(created.SyncToken, "0");
309
+ assert.equal((await post(`${B}/customer?requestid=kelp-1`, kelp)).json.Customer.Id, "72", "requestid replay returns the same customer");
310
+ await fault("POST", `${B}/customer`, 400, "6240", { body: { DisplayName: "kelp street bikes" } });
311
+ await fault("POST", `${B}/customer`, 400, "2020", { body: { Notes: "no name" } });
312
+ await fault("POST", `${B}/customer`, 400, "6000", { body: { DisplayName: "Balance Try", Balance: 10 } });
313
+ await fault("POST", `${B}/customer`, 400, "2500", { body: { DisplayName: "Term Try", SalesTermRef: { value: "9" } } });
314
+ const phone = { FreeFormNumber: "(619) 555-0199" };
315
+ assert.equal((await post(`${B}/customer`, { Id: "72", SyncToken: "0", sparse: true, PrimaryPhone: phone })).json.Customer.SyncToken, "1");
316
+ await fault("POST", `${B}/customer`, 400, "5010", { body: { Id: "72", SyncToken: "0", sparse: true, Notes: "stale" } });
317
+ const full = (await post(`${B}/customer`, { Id: "72", SyncToken: "1", DisplayName: "Kelp Street Bikes" })).json.Customer;
318
+ assert.equal(full.Notes, undefined, "a full update clears omitted fields");
319
+ assert.equal(full.PrimaryPhone, undefined);
320
+ assert.equal((await post(`${B}/customer?operation=update`, { Id: "72", SyncToken: "2", sparse: true, Notes: "Back" })).json.Customer.SyncToken, "3");
321
+ await fault("POST", `${B}/customer`, 400, "610", { body: { Id: "999", SyncToken: "0", DisplayName: "Ghost" } });
322
+ await fault("POST", `${B}/customer?operation=update`, 400, "2020", { body: { DisplayName: "No Id" } });
323
+ assert.equal((await post(`${B}/customer`, { Id: "67", SyncToken: "0", sparse: true, Active: false })).json.Customer.Active, false);
324
+ await fault("POST", `${B}/customer`, 400, "6000", { body: { Id: "68", SyncToken: "0", sparse: true, Active: false }, detail: "open balance" });
325
+ assert.equal((await get(`${B}/customer/72`)).json.Customer.Notes, "Back");
326
+
327
+ assert.equal((await get(`${B}/item/20`)).json.Item.UnitPrice, 150);
328
+ await fault("GET", `${B}/item/999`, 400, "610");
329
+ const item = { Name: "Color grading", Type: "Service", UnitPrice: 95, IncomeAccountRef: { value: "46" } };
330
+ const grading = (await post(`${B}/item`, item)).json.Item;
331
+ assert.equal(grading.Id, "28");
332
+ assert.equal(grading.IncomeAccountRef.name, "Photography Income");
333
+ await fault("POST", `${B}/item`, 400, "6240", { body: { ...item, Name: "color grading" } });
334
+ await fault("POST", `${B}/item`, 400, "6000", { body: { ...item, Name: "Stock", Type: "Inventory" } });
335
+ await fault("POST", `${B}/item`, 400, "2500", { body: { ...item, Name: "Expense income", IncomeAccountRef: { value: "61" } } });
336
+ await fault("POST", `${B}/item`, 400, "2500", { body: { ...item, Name: "Inactive income", IncomeAccountRef: { value: "48" } } });
337
+ await fault("POST", `${B}/item`, 400, "2020", { body: { Name: "No account", Type: "Service" } });
338
+ assert.equal((await post(`${B}/item`, { Id: "27", SyncToken: "0", sparse: true, Active: false })).json.Item.Active, false);
339
+ await fault("POST", `${B}/item`, 400, "5010", { body: { Id: "27", SyncToken: "0", sparse: true, Active: true } });
340
+ await fault("POST", `${B}/item`, 400, "610", { body: { Id: "999", SyncToken: "0", sparse: true, Active: true } });
341
+ assert.equal((await rows("select * from Item", "Item")).length, 7);
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------------------------
345
+ // Admin flows: invoice lifecycle, payments
346
+ // ---------------------------------------------------------------------------------------------
347
+
348
+ const salesLine = (itemId, Qty, extra = {}) => ({ DetailType: "SalesItemLineDetail", ...extra, SalesItemLineDetail: { ItemRef: { value: itemId }, Qty, ...(extra.UnitPrice === undefined ? {} : { UnitPrice: extra.UnitPrice }) } });
349
+
350
+ async function invoiceLifecycle() {
351
+ const body = { CustomerRef: { value: "58" }, Line: [salesLine("20", 3, { Amount: 450 }), salesLine("25", 2)] };
352
+ const created = (await post(`${B}/invoice`, body)).json.Invoice;
353
+ assert.equal(created.Id, "1050");
354
+ assert.equal(created.DocNumber, "1050");
355
+ assert.equal(created.TxnDate, "2026-09-15");
356
+ assert.equal(created.DueDate, "2026-10-15", "Net 30 from the customer's terms");
357
+ assert.equal(created.TotalAmt, 486);
358
+ assert.equal(created.Balance, 486);
359
+ assert.equal(created.BillEmail.Address, "ap@harborcoffee.example.com");
360
+ assert.equal(created.Line.at(-1).DetailType, "SubTotalLineDetail");
361
+ await fault("POST", `${B}/invoice`, 400, "6140", { body: { ...body, DocNumber: "1042" }, detail: "TxnId=1042" });
362
+ assert.equal((await post(`${B}/invoice?include=allowduplicatedocnum`, { ...body, DocNumber: "1042" })).json.Invoice.Id, "1051");
363
+ await fault("POST", `${B}/invoice`, 400, "2500", { body: { ...body, Line: [salesLine("26", 1)] }, detail: "made inactive" });
364
+ await fault("POST", `${B}/invoice`, 400, "2500", { body: { ...body, CustomerRef: { value: "999" } } });
365
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: { ...body, Line: [] } });
366
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: { ...body, Line: [salesLine("20", 0)] } });
367
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: { ...body, Line: [salesLine("20", 2, { Amount: 100 })] } });
368
+ await fault("POST", `${B}/invoice`, 400, "2020", { body: { Line: body.Line } });
369
+ const updated = (await post(`${B}/invoice`, { Id: "1050", SyncToken: "0", sparse: true, Line: [...body.Line, salesLine("27", 1)] })).json.Invoice;
370
+ assert.equal(updated.SyncToken, "1");
371
+ assert.equal(updated.TotalAmt, 561);
372
+ await fault("POST", `${B}/invoice`, 400, "5010", { body: { Id: "1050", SyncToken: "0", sparse: true, PrivateNote: "stale" } });
373
+ await fault("POST", `${B}/invoice?operation=update`, 400, "610", { body: { Id: "9999", SyncToken: "0", sparse: true } });
374
+
375
+ const sent = (await api("POST", `${B}/invoice/1050/send?sendTo=${encodeURIComponent("ap@harbor-coffee.example.com")}`)).json.Invoice;
376
+ assert.equal(sent.EmailStatus, "EmailSent");
377
+ assert.equal(sent.DeliveryInfo.DeliveryTime, "2026-09-15T02:00:00-07:00");
378
+ assert.equal(sent.BillEmail.Address, "ap@harbor-coffee.example.com");
379
+ await fault("POST", `${B}/invoice/1048/send`, 400, "6000", { detail: "Email address is missing" });
380
+ await fault("POST", `${B}/invoice/1048/send?sendTo=a@example.com,b@example.com`, 400, "6000");
381
+ await fault("POST", `${B}/invoice/1048/send?sendTo=a%E0%A4%A@example.com`, 400, "6000");
382
+ await fault("POST", `${B}/invoice/9999/send`, 400, "610");
383
+
384
+ const pdf = await get(`${B}/invoice/1046/pdf`);
385
+ assert.equal(pdf.headers.get("content-type"), "application/pdf");
386
+ const text = new TextDecoder("latin1").decode(pdf.bytes);
387
+ assert.ok(text.startsWith("%PDF-1.4"), "a PDF document");
388
+ assert.ok(text.includes("1046"), "the PDF names the invoice");
389
+ await fault("GET", `${B}/invoice/9999/pdf`, 400, "610");
390
+
391
+ const partial = (await get(`${B}/invoice/1042`)).json.Invoice;
392
+ assert.deepEqual(partial.LinkedTxn, [{ TxnId: "192", TxnType: "Payment" }]);
393
+ const voided = (await post(`${B}/invoice?operation=void`, { Id: "1042", SyncToken: partial.SyncToken })).json.Invoice;
394
+ assert.equal(voided.TotalAmt, 0);
395
+ assert.equal(voided.Balance, 0);
396
+ assert.equal(voided.PrivateNote, "Voided");
397
+ const unapplied = (await get(`${B}/payment/192`)).json.Payment;
398
+ assert.equal(unapplied.UnappliedAmt, 1500, "the payment stays with its amount, now unapplied");
399
+ await fault("POST", `${B}/invoice?operation=void`, 400, "6000", { body: { Id: "1042", SyncToken: voided.SyncToken } });
400
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: { Id: "1042", SyncToken: voided.SyncToken, sparse: true, PrivateNote: "x" } });
401
+ const gone = (await post(`${B}/invoice?operation=delete`, { Id: "1049", SyncToken: "0" })).json.Invoice;
402
+ assert.deepEqual(gone, { Id: "1049", status: "Deleted", domain: "QBO" });
403
+ await fault("GET", `${B}/invoice/1049`, 400, "610");
404
+ }
405
+
406
+ async function payments() {
407
+ const quartz = { CustomerRef: { value: "64" }, TotalAmt: 1000, Line: [{ Amount: 700, LinkedTxn: [{ TxnId: "1041", TxnType: "Invoice" }] }, { Amount: 300, LinkedTxn: [{ TxnId: "1044", TxnType: "Invoice" }] }] };
408
+ const paid = (await post(`${B}/payment`, quartz)).json.Payment;
409
+ assert.equal(paid.Id, "196");
410
+ assert.equal(paid.UnappliedAmt, 0);
411
+ assert.equal(paid.DepositToAccountRef.name, "Undeposited Funds");
412
+ const i1041 = (await get(`${B}/invoice/1041`)).json.Invoice;
413
+ assert.equal(i1041.Balance, 1100);
414
+ assert.equal(i1041.SyncToken, "1", "a balance change bumps the invoice SyncToken");
415
+ assert.equal((await get(`${B}/invoice/1044`)).json.Invoice.Balance, 350);
416
+ const link = (TxnId, Amount) => ({ Amount, LinkedTxn: [{ TxnId, TxnType: "Invoice" }] });
417
+ await fault("POST", `${B}/payment`, 400, "6000", { body: { CustomerRef: { value: "68" }, TotalAmt: 1500, Line: [link("1043", 1500)] }, detail: "exceeds its open balance" });
418
+ await fault("POST", `${B}/payment`, 400, "6000", { body: { CustomerRef: { value: "64" }, TotalAmt: 100, Line: [link("1043", 100)] }, detail: "another customer" });
419
+ await fault("POST", `${B}/payment`, 400, "6000", { body: { CustomerRef: { value: "64" }, TotalAmt: 100, Line: [link("1041", 200)] } });
420
+ await fault("POST", `${B}/payment`, 400, "2500", { body: { CustomerRef: { value: "64" }, TotalAmt: 10, DepositToAccountRef: { value: "45" } } });
421
+ await fault("POST", `${B}/payment`, 400, "2020", { body: { CustomerRef: { value: "64" } } });
422
+ const loose = (await post(`${B}/payment`, { CustomerRef: { value: "61" }, TotalAmt: 250, PaymentRefNum: "WIRE-2201", DepositToAccountRef: { value: "1" } })).json.Payment;
423
+ assert.equal(loose.Id, "197");
424
+ assert.equal(loose.UnappliedAmt, 250);
425
+ await fault("POST", `${B}/payment`, 400, "5010", { body: { ...quartz, Id: "196", SyncToken: "5" } });
426
+ const moved = (await post(`${B}/payment?operation=update`, { ...quartz, Id: "196", SyncToken: "0", Line: [link("1041", 400), link("1044", 600)] })).json.Payment;
427
+ assert.equal(moved.SyncToken, "1");
428
+ assert.equal((await get(`${B}/invoice/1044`)).json.Invoice.Balance, 50);
429
+ await fault("POST", `${B}/payment`, 400, "610", { body: { ...quartz, Id: "9999", SyncToken: "0" } });
430
+ const voided = (await post(`${B}/payment?operation=void`, { Id: "196", SyncToken: "1" })).json.Payment;
431
+ assert.equal(voided.TotalAmt, 0);
432
+ assert.equal((await get(`${B}/invoice/1041`)).json.Invoice.Balance, 1800, "voiding restores the invoice balance");
433
+ assert.equal((await get(`${B}/invoice/1044`)).json.Invoice.Balance, 650);
434
+ assert.equal((await post(`${B}/payment?operation=delete`, { Id: "197", SyncToken: "0" })).json.Payment.status, "Deleted");
435
+ await fault("GET", `${B}/payment/197`, 400, "610");
436
+ assert.equal((await get(`${B}/payment/192`)).json.Payment.PaymentRefNum, "CHK-4471");
437
+ assert.equal((await get(`${B}/account/4`)).json.Account.CurrentBalance, 2460);
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------------------------
441
+ // MCP tool-name aliases
442
+ // ---------------------------------------------------------------------------------------------
443
+
444
+ async function mcpShapes() {
445
+ await mcpInit();
446
+ const listed = new Set((await rpc("tools/list", {})).tools.map((tool) => tool.name));
447
+ for (const name of ["get_company_info", "get_customer", "search_customers", "create_customer", "update_customer", "delete_customer", "read_item", "search_items", "read_invoice", "search_invoices", "create_invoice", "update_invoice", "delete_invoice", "get_invoice_pdf", "get_payment", "search_payments", "create_payment", "get_account", "search_accounts"]) {
448
+ assert.ok(listed.has(name), `tools/list lacks ${name}`);
449
+ }
450
+ assert.equal((await mcp("get_company_info", {})).CompanyInfo.CompanyName, "Brightwater Studio LLC");
451
+ assert.equal((await mcp("get_customer", { id: "64" })).Customer.Balance, 2450);
452
+
453
+ const names = (result) => result.results.map((row) => row.DisplayName);
454
+ assert.deepEqual(names(await mcp("search_customers", { criteria: [{ field: "DisplayName", value: "Harbor%", operator: "LIKE" }] })), ["Harbor Coffee Co.", "Harbor Lights Dental"]);
455
+ assert.equal((await mcp("search_customers", { criteria: [{ key: "Active", value: true }] })).results.length, 13);
456
+ assert.equal(names(await mcp("search_customers", { limit: 5, offset: 5, asc: "DisplayName" }))[0], "Juniper Yoga");
457
+ assert.equal((await mcp("search_customers", { count: true })).totalCount, 13);
458
+ await mcpError("search_customers", { criteria: [{ field: "Notes", value: "x" }] }, "QUERY_VALIDATION_ERROR");
459
+
460
+ const tide = await mcp("create_customer", { customer: { DisplayName: "Tidepool Apps" } });
461
+ assert.equal(tide.Id, "72");
462
+ await mcpError("create_customer", { customer: { DisplayName: "tidepool apps" } }, "DUPLICATE_NAME");
463
+ await mcpError("create_customer", { customer: {} }, "REQUIRED_PARAM_MISSING");
464
+ await mcpError("create_customer", { customer: { DisplayName: "Term Co", SalesTermRef: { value: "9" } } }, "INVALID_REFERENCE");
465
+ await mcpError("create_customer", { customer: { DisplayName: "Id Co", Id: "5" } }, "BUSINESS_VALIDATION");
466
+ assert.equal((await mcp("update_customer", { customer: { Id: "72", SyncToken: "0", sparse: true, Notes: "MCP" } })).SyncToken, "1");
467
+ await mcpError("update_customer", { customer: { DisplayName: "No Id" } }, "REQUIRED_PARAM_MISSING");
468
+ await mcpError("update_customer", { customer: { Id: "999", SyncToken: "0", sparse: true } }, "OBJECT_NOT_FOUND");
469
+ await mcpError("update_customer", { customer: { Id: "72", SyncToken: "0", sparse: true, Notes: "stale" } }, "STALE_OBJECT");
470
+ await mcpError("update_customer", { customer: { Id: "72", SyncToken: "1", sparse: true, DisplayName: "Harbor Coffee Co." } }, "DUPLICATE_NAME");
471
+ await mcpError("update_customer", { customer: { Id: "72", SyncToken: "1", sparse: true, SalesTermRef: { value: "9" } } }, "INVALID_REFERENCE");
472
+ await mcpError("update_customer", { customer: { Id: "72", SyncToken: "1", sparse: true, Balance: 5 } }, "BUSINESS_VALIDATION");
473
+ assert.equal((await mcp("delete_customer", { idOrEntity: "72" })).Active, false);
474
+ await mcpError("delete_customer", { idOrEntity: "999" }, "OBJECT_NOT_FOUND");
475
+ await mcpError("delete_customer", { idOrEntity: { Id: "67", SyncToken: "9" } }, "STALE_OBJECT");
476
+ await mcpError("delete_customer", { idOrEntity: "68" }, "BUSINESS_VALIDATION");
477
+
478
+ assert.equal((await mcp("read_item", { item_id: "21" })).Item.Name, "Brand workshop");
479
+ assert.equal((await mcp("search_items", { criteria: { Type: "Service" } })).results.length, 5);
480
+ await mcpError("search_items", { criteria: [{ field: "Notes", value: "x" }] }, "QUERY_VALIDATION_ERROR");
481
+
482
+ const i1039 = (await mcp("read_invoice", { invoice_id: "1039" })).Invoice;
483
+ assert.equal(i1039.Line.length, 7);
484
+ assert.deepEqual(i1039.LinkedTxn, [{ TxnId: "194", TxnType: "Payment" }]);
485
+ const open = await mcp("search_invoices", { criteria: { filters: [{ field: "Balance", value: 0, operator: ">" }], desc: "TxnDate", limit: 3 } });
486
+ assert.deepEqual(open.results.map((row) => row.Id), ["1049", "1048", "1047"]);
487
+ await mcpError("search_invoices", { criteria: [{ field: "Notes", value: "x" }] }, "QUERY_VALIDATION_ERROR");
488
+ const line = [{ item_ref: "20", qty: 3, unit_price: 150 }];
489
+ const made = await mcp("create_invoice", { customer_ref: "58", line_items: line, bill_email: "ap@harborcoffee.example.com" });
490
+ assert.equal(made.Id, "1050");
491
+ assert.equal(made.TotalAmt, 450);
492
+ await mcpError("create_invoice", { customer_ref: "58", line_items: [{ item_ref: "26", qty: 1, unit_price: 500 }] }, "INVALID_REFERENCE");
493
+ await mcpError("create_invoice", { customer_ref: "58", line_items: line, doc_number: "1036" }, "DUPLICATE_DOC_NUMBER");
494
+ await mcpError("create_invoice", { customer_ref: "58", line_items: line, linked_txn: [{ TxnId: "1", TxnType: "Estimate" }] }, "BUSINESS_VALIDATION");
495
+ assert.equal((await mcp("update_invoice", { invoice_id: "1050", patch: { CustomerMemo: { value: "Thanks" } } })).CustomerMemo.value, "Thanks");
496
+ await mcpError("update_invoice", { invoice_id: "9999", patch: {} }, "OBJECT_NOT_FOUND");
497
+ await mcpError("update_invoice", { invoice_id: "1050", patch: { SyncToken: "7" } }, "STALE_OBJECT");
498
+ await mcpError("update_invoice", { invoice_id: "1050", patch: { CustomerRef: { value: "999" } } }, "INVALID_REFERENCE");
499
+ await mcpError("update_invoice", { invoice_id: "1050", patch: { DocNumber: "1036" } }, "DUPLICATE_DOC_NUMBER");
500
+ await mcpError("update_invoice", { invoice_id: "1050", patch: { DueDate: "2020-01-01" } }, "BUSINESS_VALIDATION");
501
+ assert.equal((await mcp("delete_invoice", { idOrEntity: { Id: "1050", SyncToken: "1" } })).status, "Deleted");
502
+ await mcpError("delete_invoice", { idOrEntity: "9999" }, "OBJECT_NOT_FOUND");
503
+ await mcpError("delete_invoice", { idOrEntity: { Id: "1049", SyncToken: "5" } }, "STALE_OBJECT");
504
+ const pdf = await mcp("get_invoice_pdf", { invoice_id: "1046" });
505
+ assert.equal(pdf.content_type, "application/pdf");
506
+ assert.ok(atob(pdf.base64).startsWith("%PDF-1.4"));
507
+ assert.equal(atob(pdf.base64).length, pdf.size_bytes);
508
+ await mcpError("get_invoice_pdf", { invoice_id: "1046", output_path: "/tmp/invoice.pdf" }, "BUSINESS_VALIDATION");
509
+ await mcpError("get_invoice_pdf", { invoice_id: "9999" }, "OBJECT_NOT_FOUND");
510
+
511
+ assert.equal((await mcp("get_payment", { id: "192" })).Payment.TotalAmt, 1500);
512
+ assert.deepEqual((await mcp("search_payments", { customer_ref: "63", txn_date_from: "2026-08-01", limit: 10 })).results.map((row) => row.Id), ["191"]);
513
+ await mcpError("search_payments", { txn_date_from: "2026-13-45" }, "QUERY_VALIDATION_ERROR");
514
+ const received = await mcp("create_payment", { customer_ref: "64", total_amt: 100, line: [{ amount: 100, linked_txn: [{ txn_id: "1041", txn_type: "Invoice" }] }] });
515
+ assert.equal(received.UnappliedAmt, 0);
516
+ await mcpError("create_payment", { customer_ref: "64", total_amt: 100, currency_ref: "EUR" }, "BUSINESS_VALIDATION");
517
+ await mcpError("create_payment", { customer_ref: "999", total_amt: 100 }, "INVALID_REFERENCE");
518
+ assert.equal((await mcp("get_account", { id: "1" })).Account.Name, "Checking");
519
+ assert.equal((await mcp("search_accounts", { criteria: [{ field: "AccountType", value: "Income" }] })).results.length, 3);
520
+ await mcpError("search_accounts", { criteria: [{ field: "Notes", value: "x" }] }, "QUERY_VALIDATION_ERROR");
521
+ assert.equal((await get(`${B}/invoice/1041`)).json.Invoice.Balance, 1700, "an MCP payment is visible over REST");
522
+ }
523
+
524
+ /** A maximal invoice (250 lines, 600,000 bytes of CJK descriptions) is readable by GET, PDF and its own query; pages split by bytes. */
525
+ async function largeInvoice() {
526
+ await mcpInit();
527
+ const CJK = "\u754c"; // 界, 3 bytes in UTF-8
528
+ const maximal = (chars) => ({
529
+ CustomerRef: { value: "58" },
530
+ PrivateNote: CJK.repeat(4000),
531
+ CustomerMemo: { value: CJK.repeat(1000) },
532
+ Line: Array.from({ length: 250 }, () => salesLine("20", 1, { Description: CJK.repeat(chars) })),
533
+ });
534
+ const first = (await post(`${B}/invoice`, maximal(800))).json.Invoice; // 250 x 2,400 bytes = exactly 600,000
535
+ assert.equal(first.Id, "1050");
536
+ assert.equal(first.Line.length, 251, "250 sales lines plus the subtotal");
537
+ assert.equal((await post(`${B}/invoice`, maximal(800))).json.Invoice.Id, "1051");
538
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: maximal(801), type: "ValidationFault", detail: "600,000 bytes" });
539
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: { Id: "1050", SyncToken: "0", sparse: true, Line: maximal(801).Line }, detail: "600,000 bytes" });
540
+ const items = (chars) => Array.from({ length: 250 }, () => ({ item_ref: "20", qty: 1, unit_price: 1, description: CJK.repeat(chars) }));
541
+ assert.equal((await mcp("create_invoice", { customer_ref: "58", line_items: items(800) })).Id, "1052");
542
+ await mcpError("create_invoice", { customer_ref: "58", line_items: items(801) }, "BUSINESS_VALIDATION");
543
+
544
+ const read = await get(`${B}/invoice/1050`);
545
+ assert.ok(read.bytes.length > 600_000 && read.bytes.length < 900_000, `GET is ${read.bytes.length} bytes`);
546
+ assert.equal(read.json.Invoice.Line[249].Description, CJK.repeat(800));
547
+ const pdf = await get(`${B}/invoice/1050/pdf`);
548
+ assert.equal(pdf.headers.get("content-type"), "application/pdf");
549
+ for (const text of ["select * from Invoice where Id = '1050'", "select * from Invoice where Id = '1050' maxresults 1", "select * from Invoice where Id = '1050' startposition 1 maxresults 1000"]) {
550
+ const single = await get(q(text));
551
+ assert.ok(single.bytes.length > 600_000 && single.bytes.length < 900_000, `${text}: ${single.bytes.length} bytes`);
552
+ assert.equal(single.json.QueryResponse.maxResults, 1);
553
+ assert.equal(single.json.QueryResponse.startPosition, 1);
554
+ assert.deepEqual(single.json.QueryResponse.Invoice.map((row) => row.Id), ["1050"]);
555
+ }
556
+ assert.equal((await mcp("read_invoice", { invoice_id: "1050" })).Invoice.Id, "1050");
557
+ assert.deepEqual((await mcp("search_invoices", { criteria: [{ field: "Id", value: "1050" }] })).results.map((row) => row.Id), ["1050"]);
558
+
559
+ // Multi-row pages stop by bytes: two maximal invoices never share a page; the small ones fit after the third.
560
+ const total = (await get(q("select count(*) from Invoice"))).json.QueryResponse.totalCount;
561
+ const seen = [];
562
+ let position = 1;
563
+ const pageSizes = [];
564
+ while (true) {
565
+ const page = (await get(q(`select * from Invoice orderby Id desc startposition ${position} maxresults 10`))).json.QueryResponse;
566
+ if (page.Invoice === undefined) break;
567
+ assert.equal(page.startPosition, position);
568
+ assert.equal(page.maxResults, page.Invoice.length, "maxResults counts the rows actually returned");
569
+ pageSizes.push(page.Invoice.length);
570
+ seen.push(...page.Invoice.map((row) => row.Id));
571
+ position += page.maxResults;
572
+ }
573
+ assert.deepEqual(pageSizes.slice(0, 2), [1, 1], `a second maximal invoice never fits the same page: ${JSON.stringify(pageSizes)}`);
574
+ assert.ok(pageSizes[2] > 1, `the third maximal invoice shares its page with the small ones: ${JSON.stringify(pageSizes)}`);
575
+ assert.deepEqual(seen.slice(0, 3), ["1052", "1051", "1050"]);
576
+ assert.equal(new Set(seen).size, seen.length, "every row appears exactly once");
577
+ assert.equal(seen.length, total, "paging by bytes loses nothing");
578
+ const paged = await mcp("search_invoices", { criteria: [{ field: "Id", value: "1049", operator: ">" }, { field: "limit", value: 10 }] });
579
+ assert.equal(paged.results.length, 1, "an MCP search page stops by bytes too");
580
+ await mcpError("search_invoices", { criteria: [{ field: "Id", value: "1049", operator: ">" }, { field: "fetchAll", value: true }] }, "STATE_BOUND_EXCEEDED");
581
+ }
582
+
583
+ /**
584
+ * The description bound counts bytes as returned (JSON-encoded): a newline, quote or backslash is 2 bytes and a control
585
+ * character 6, so escape-heavy text of the same character count must hit the bound sooner — directly and through a
586
+ * description inherited from the item. Every committed row stays readable alone and inside every list page, and the loop
587
+ * the bundled app runs (1,000-row pages until an empty page) loses nothing although every page is cut by bytes.
588
+ * Its own drill (fresh baseline, Ids from 1050): the drill report embeds every body it sees, and these are 600-900 KB each.
589
+ */
590
+ async function escapeHeavyInvoices() {
591
+ await mcpInit();
592
+ const before = (await get(q("select count(*) from Invoice"))).json.QueryResponse.totalCount;
593
+ const body = (Line) => ({ CustomerRef: { value: "58" }, Line });
594
+ const direct = (text, lines = 250) => body(Array.from({ length: lines }, () => salesLine("20", 1, { Description: text })));
595
+ // 250 x 1,200 newlines = 300,000 characters but exactly 600,000 encoded bytes; one more newline per line is over.
596
+ const newline = (await post(`${B}/invoice`, direct("\n".repeat(1200)))).json.Invoice;
597
+ assert.equal(newline.Id, "1050");
598
+ assert.equal(newline.Line[249].Description, "\n".repeat(1200));
599
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: direct("\n".repeat(1201)), type: "ValidationFault", detail: "600,000 bytes" });
600
+ // Quotes and backslashes escape to 2 bytes, a control character to 6: 300 x (`"\\` + U+0001) = 300 x 10 = 3,000 bytes; 200 lines = 600,000.
601
+ const mixed = "\"\\".repeat(300);
602
+ assert.equal((await post(`${B}/invoice`, direct(mixed, 200))).json.Invoice.Id, "1051");
603
+ const overMixed = await fault("POST", `${B}/invoice`, 400, "6000", { body: direct(mixed, 201), detail: "600,000 bytes" });
604
+ assert.equal(overMixed.Fault.Error[0].element, "Line[200].Description");
605
+ // Item-inherited descriptions: an item whose Description is 4,000 newlines (8,000 encoded bytes) inherited by 75 lines
606
+ // with no Description of their own is exactly 600,000 bytes; 76 lines is over, named at the line that inherits it.
607
+ const item = (await post(`${B}/item`, { Name: "Newline item", Type: "Service", IncomeAccountRef: { value: "45" }, Description: "\n".repeat(4000) })).json.Item;
608
+ assert.equal(item.Description, "\n".repeat(4000));
609
+ const inherited = (lines) => body(Array.from({ length: lines }, () => salesLine(item.Id, 1)));
610
+ const fromItem = (await post(`${B}/invoice`, inherited(75))).json.Invoice;
611
+ assert.equal(fromItem.Id, "1052");
612
+ assert.equal(fromItem.Line[74].Description, "\n".repeat(4000), "the item description is inherited");
613
+ const over = await fault("POST", `${B}/invoice`, 400, "6000", { body: inherited(76), detail: "inherited from items" });
614
+ assert.equal(over.Fault.Error[0].element, "Line[75].Description");
615
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: inherited(150), detail: "600,000 bytes" });
616
+ await fault("POST", `${B}/invoice`, 400, "6000", { body: { Id: "1050", SyncToken: "0", sparse: true, Line: inherited(76).Line }, detail: "inherited from items" });
617
+ await mcpError("create_invoice", { customer_ref: "58", line_items: Array.from({ length: 76 }, () => ({ item_ref: item.Id, qty: 1, unit_price: 1 })) }, "BUSINESS_VALIDATION");
618
+
619
+ // Every committed row is readable — one full read per shape (GET, single-row query, MCP read) and the PDF of each.
620
+ const read = await get(`${B}/invoice/1050`);
621
+ assert.ok(read.bytes.length > 600_000 && read.bytes.length < 900_000, `GET 1050 is ${read.bytes.length} bytes`);
622
+ assert.equal(read.json.Invoice.Line[0].Description, "\n".repeat(1200));
623
+ for (const id of ["1050", "1051", "1052"]) assert.equal((await get(`${B}/invoice/${id}/pdf`)).headers.get("content-type"), "application/pdf");
624
+ const single = (await get(q("select * from Invoice where Id = '1051'"))).json.QueryResponse;
625
+ assert.deepEqual([single.maxResults, single.Invoice.map((row) => row.Id)], [1, ["1051"]]);
626
+ assert.equal(single.Invoice[0].Line[199].Description, mixed);
627
+ assert.equal((await mcp("read_invoice", { invoice_id: "1052" })).Invoice.Line[74].Description, "\n".repeat(4000));
628
+ assert.equal((await get(q("select count(*) from Invoice"))).json.QueryResponse.totalCount, before + 3);
629
+ // Every list page that contains one of them is readable, and paging until an empty page loses nothing even though every
630
+ // page is cut by bytes, not by count: a short page is NOT the last page (the app's old loop stopped at the first one).
631
+ const text = "select * from Invoice where CustomerRef = '58'";
632
+ const expected = (await get(q("select count(*) from Invoice where CustomerRef = '58'"))).json.QueryResponse.totalCount;
633
+ const seen = [];
634
+ const pageSizes = [];
635
+ let position = 1;
636
+ while (true) {
637
+ const page = (await get(q(`${text} startposition ${position} maxresults 1000`))).json.QueryResponse;
638
+ if (page.Invoice === undefined) break;
639
+ assert.equal(page.startPosition, position);
640
+ assert.equal(page.maxResults, page.Invoice.length, "maxResults counts the rows actually returned");
641
+ pageSizes.push(page.maxResults);
642
+ seen.push(...page.Invoice.map((row) => row.Id));
643
+ position += page.maxResults;
644
+ }
645
+ assert.ok(pageSizes.length >= 3 && pageSizes[0] < 1000, `the first page is short and is not the last: ${JSON.stringify(pageSizes)}`);
646
+ assert.equal(new Set(seen).size, seen.length, "every row appears exactly once");
647
+ assert.equal(seen.length, expected, `paging until an empty page loses nothing (${seen.length} of ${expected})`);
648
+ assert.ok(seen.includes("1050") && seen.includes("1051") && seen.includes("1052"), "the three escape-heavy invoices are all seen");
649
+ }
650
+
651
+ // ---------------------------------------------------------------------------------------------
652
+
653
+ const flows = {
654
+ "company-query": companyQuery,
655
+ "customers-items": customersItems,
656
+ "invoice-lifecycle": invoiceLifecycle,
657
+ payments,
658
+ "mcp-shapes": mcpShapes,
659
+ roles,
660
+ "reports-only": reportsOnly,
661
+ "default-identity": defaultIdentity,
662
+ "invalid-auth": invalidAuth,
663
+ denied,
664
+ throttled,
665
+ "write-outage": writeOutage,
666
+ "payment-response-lost": paymentResponseLost,
667
+ "tight-limits": tightLimits,
668
+ "large-invoice": largeInvoice,
669
+ "escape-heavy-invoices": escapeHeavyInvoices,
670
+ };
671
+ const selected = Object.keys(flows).find((name) => instruction.includes(`the ${name} conformance flow`));
672
+ if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
673
+ await flows[selected]();
674
+ process.stdout.write(JSON.stringify({ completed: true, flow: selected }));