@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,200 @@
1
+ // Payment entity (receive payment): rendering, create / update / void / delete, with invoice balances kept in sync.
2
+ import { bumpToken } from "./access.mjs";
3
+ import { USD, clip, fromCents, isId, toCents } from "./common.mjs";
4
+ import { updateTarget } from "./customers.mjs";
5
+ import { INACTIVE_DETAIL } from "./invoice-draft.mjs";
6
+ import { refName } from "./items.mjs";
7
+ import { checkKeys, has, isObject, optDate, optMoney, optRef, optString, own } from "./fields.mjs";
8
+
9
+ const WRITABLE = new Set(["CustomerRef", "TotalAmt", "TxnDate", "PaymentRefNum", "PaymentMethodRef", "DepositToAccountRef", "PrivateNote", "Line", "ProcessPayment", "CurrencyRef", "ExchangeRate"]);
10
+ const READONLY = new Set(["Id", "SyncToken", "sparse", "domain", "MetaData", "UnappliedAmt"]);
11
+
12
+ export function renderPayment(s, row) {
13
+ const applied = row.Line.reduce((sum, line) => sum + (toCents(line.Amount) ?? 0), 0);
14
+ const out = {
15
+ CustomerRef: refName(s, "customers", row.CustomerRef, "DisplayName"),
16
+ DepositToAccountRef: refName(s, "accounts", row.DepositToAccountRef, "Name"),
17
+ TotalAmt: row.TotalAmt,
18
+ UnappliedAmt: fromCents((toCents(row.TotalAmt) ?? 0) - applied),
19
+ ProcessPayment: false,
20
+ domain: "QBO",
21
+ sparse: false,
22
+ Id: row.Id,
23
+ SyncToken: row.SyncToken,
24
+ MetaData: row.MetaData,
25
+ TxnDate: row.TxnDate,
26
+ CurrencyRef: USD,
27
+ Line: row.Line.map((line) => ({ Amount: line.Amount, LinkedTxn: line.LinkedTxn.map((txn) => ({ TxnId: txn.TxnId, TxnType: txn.TxnType })) })),
28
+ };
29
+ for (const key of ["PaymentRefNum", "PaymentMethodRef", "PrivateNote"]) if (row[key] !== null) out[key] = row[key];
30
+ return out;
31
+ }
32
+
33
+ function perInvoice(lines) {
34
+ const map = new Map();
35
+ for (const line of lines) map.set(line.LinkedTxn[0].TxnId, (map.get(line.LinkedTxn[0].TxnId) ?? 0) + (toCents(line.Amount) ?? 0));
36
+ return map;
37
+ }
38
+
39
+ /** Move invoice balances by (old − new) applied cents; one invoice.changed Update per touched invoice. */
40
+ function rebalance(s, oldLines, newLines) {
41
+ const before = perInvoice(oldLines);
42
+ const after = perInvoice(newLines);
43
+ const ids = [...new Set([...before.keys(), ...after.keys()])].sort((a, b) => Number(a) - Number(b));
44
+ for (const id of ids) {
45
+ const delta = (before.get(id) ?? 0) - (after.get(id) ?? 0);
46
+ if (delta === 0) continue;
47
+ const invoice = s.get("invoices", id);
48
+ if (invoice === null) continue;
49
+ const balance = (toCents(invoice.Balance) ?? 0) + delta;
50
+ s.put("invoices", { ...invoice, Balance: fromCents(Math.max(0, balance)), SyncToken: bumpToken(invoice), MetaData: { ...invoice.MetaData, LastUpdatedTime: s.clock.meta } });
51
+ s.emit("invoice.changed", "Invoice", id, "Update");
52
+ }
53
+ }
54
+
55
+ function parsePaymentLines(s, raw, customerId, currentLines, totalCents) {
56
+ if (!Array.isArray(raw)) s.fail("BUSINESS_VALIDATION", "Invalid value for Line: expected an array", "Line");
57
+ if (raw.length > 250) s.fail("BUSINESS_VALIDATION", "A payment can be applied to at most 250 transactions.", "Line");
58
+ const previous = perInvoice(currentLines);
59
+ const seen = new Set();
60
+ const lines = [];
61
+ let applied = 0;
62
+ for (const [index, line] of raw.entries()) {
63
+ const at = `Line[${index}]`;
64
+ if (!isObject(line) || Object.keys(line).some((key) => key !== "Amount" && key !== "LinkedTxn" && key !== "LineEx")) s.fail("BUSINESS_VALIDATION", `Invalid value for ${at}: expected { Amount, LinkedTxn }`, at);
65
+ const amount = toCents(line.Amount);
66
+ if (amount === null || amount === 0) s.fail("BUSINESS_VALIDATION", "A payment line Amount must be greater than zero with at most two decimals.", `${at}.Amount`);
67
+ const links = own(line, "LinkedTxn");
68
+ if (!Array.isArray(links) || links.length !== 1 || !isObject(links[0])) s.fail("BUSINESS_VALIDATION", "Each payment line must link exactly one transaction.", `${at}.LinkedTxn`);
69
+ if (own(links[0], "TxnType") !== "Invoice") s.fail("BUSINESS_VALIDATION", "Only Invoice transactions can be linked to a payment in this company.", `${at}.LinkedTxn.TxnType`);
70
+ const rawId = own(links[0], "TxnId");
71
+ const txnId = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : "";
72
+ const invoice = isId(txnId) ? s.get("invoices", txnId) : null;
73
+ if (invoice === null) s.fail("INVALID_REFERENCE", `Invalid Reference Id : Invoice ${clip(txnId, 64)} linked to this payment does not exist`, `${at}.LinkedTxn.TxnId`);
74
+ if (seen.has(invoice.Id)) s.fail("BUSINESS_VALIDATION", `Invoice ${invoice.Id} is linked by more than one line of this payment.`, `${at}.LinkedTxn.TxnId`);
75
+ seen.add(invoice.Id);
76
+ if (invoice.CustomerRef.value !== customerId) s.fail("BUSINESS_VALIDATION", `Invoice ${invoice.Id} belongs to another customer.`, `${at}.LinkedTxn.TxnId`);
77
+ if (invoice.voided) s.fail("BUSINESS_VALIDATION", `Invoice ${invoice.Id} is voided and can't receive a payment.`, `${at}.LinkedTxn.TxnId`);
78
+ const open = (toCents(invoice.Balance) ?? 0) + (previous.get(invoice.Id) ?? 0);
79
+ if (amount > open) s.fail("BUSINESS_VALIDATION", `The payment amount applied to invoice ${invoice.Id} exceeds its open balance of ${fromCents(open).toFixed(2)}.`, `${at}.Amount`);
80
+ applied += amount;
81
+ lines.push({ Amount: fromCents(amount), LinkedTxn: [{ TxnId: invoice.Id, TxnType: "Invoice" }] });
82
+ }
83
+ if (applied > totalCents) s.fail("BUSINESS_VALIDATION", "The amount applied to transactions exceeds the payment amount (TotalAmt).", "Line");
84
+ return lines;
85
+ }
86
+
87
+ export function savePayment(s, body, current, sparse) {
88
+ if (!isObject(body)) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Payment object", "Payment");
89
+ checkKeys(s, body, WRITABLE, READONLY);
90
+ if (current !== null && current.voided) s.fail("BUSINESS_VALIDATION", "A voided payment can't be changed.", "Id");
91
+ const keep = (key) => current !== null && sparse && !has(body, key);
92
+ const next = current === null ? { voided: false } : { ...current };
93
+ if (!keep("CustomerRef")) {
94
+ const customerId = optRef(s, body, "CustomerRef");
95
+ if (typeof customerId !== "string") s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: CustomerRef", "CustomerRef");
96
+ const customer = s.get("customers", customerId);
97
+ if (customer === null) s.fail("INVALID_REFERENCE", `Invalid Reference Id : Customer assigned to this transaction does not exist: ${clip(customerId, 64)}`, "CustomerRef");
98
+ if (!customer.Active && (current === null || current.CustomerRef.value !== customer.Id)) s.fail("INVALID_REFERENCE", INACTIVE_DETAIL, "CustomerRef");
99
+ next.CustomerRef = { value: customer.Id, name: customer.DisplayName };
100
+ }
101
+ if (!keep("TotalAmt")) {
102
+ if (!has(body, "TotalAmt") || body.TotalAmt === null) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: TotalAmt", "TotalAmt");
103
+ next.TotalAmt = fromCents(optMoney(s, body, "TotalAmt"));
104
+ }
105
+ const txnDate = optDate(s, body, "TxnDate");
106
+ if (txnDate !== undefined && txnDate !== null) next.TxnDate = txnDate;
107
+ else if (current === null) next.TxnDate = s.clock.date;
108
+ for (const [key, max] of [["PaymentRefNum", 21], ["PrivateNote", 4000]]) {
109
+ const value = optString(s, body, key, max);
110
+ if (value !== undefined) next[key] = value;
111
+ else if (current === null || !sparse) next[key] = null;
112
+ }
113
+ const method = optRef(s, body, "PaymentMethodRef");
114
+ if (method !== undefined) next.PaymentMethodRef = method === null ? null : { value: method, name: typeof own(body.PaymentMethodRef, "name") === "string" ? body.PaymentMethodRef.name.slice(0, 100) : null };
115
+ else if (current === null || !sparse) next.PaymentMethodRef = null;
116
+ const depositId = optRef(s, body, "DepositToAccountRef");
117
+ if (depositId !== undefined || current === null || !sparse) {
118
+ const id = typeof depositId === "string" ? depositId : "4";
119
+ const account = s.get("accounts", id);
120
+ if (account === null || !account.Active || (account.AccountType !== "Bank" && account.AccountSubType !== "UndepositedFunds")) {
121
+ s.fail("INVALID_REFERENCE", `Invalid Reference Id : Deposit account ${clip(id, 64)} does not exist or is not a bank or Undeposited Funds account.`, "DepositToAccountRef");
122
+ }
123
+ next.DepositToAccountRef = { value: account.Id, name: account.Name };
124
+ }
125
+ if (has(body, "ProcessPayment") && body.ProcessPayment !== false) s.fail("BUSINESS_VALIDATION", "Payment processing (ProcessPayment) is not available in this company.", "ProcessPayment");
126
+ const currency = own(body, "CurrencyRef");
127
+ if (currency !== undefined && currency !== null && own(currency, "value") !== "USD") s.fail("BUSINESS_VALIDATION", "Multicurrency is not enabled for this company: CurrencyRef must be USD.", "CurrencyRef");
128
+ if (has(body, "ExchangeRate") && body.ExchangeRate !== 1) s.fail("BUSINESS_VALIDATION", "Multicurrency is not enabled for this company: ExchangeRate is not accepted.", "ExchangeRate");
129
+ const oldLines = current === null ? [] : current.Line;
130
+ if (!keep("Line") || !keep("CustomerRef") || !keep("TotalAmt")) {
131
+ const raw = keep("Line") ? oldLines : has(body, "Line") && body.Line !== null ? body.Line : [];
132
+ next.Line = parsePaymentLines(s, raw, next.CustomerRef.value, oldLines, toCents(next.TotalAmt));
133
+ }
134
+ if (current === null) {
135
+ next.Id = s.nextId("payment", "payments");
136
+ next.SyncToken = "0";
137
+ next.MetaData = { CreateTime: s.clock.meta, LastUpdatedTime: s.clock.meta };
138
+ } else {
139
+ next.SyncToken = bumpToken(current);
140
+ next.MetaData = { CreateTime: current.MetaData.CreateTime, LastUpdatedTime: s.clock.meta };
141
+ }
142
+ s.put("payments", next);
143
+ s.emit("payment.changed", "Payment", next.Id, current === null ? "Create" : "Update");
144
+ rebalance(s, oldLines, next.Line);
145
+ return { row: next, action: current === null ? "create" : "update" };
146
+ }
147
+
148
+ export function voidPayment(s, current) {
149
+ if (current.voided) s.fail("BUSINESS_VALIDATION", "This payment is already voided.", "Id");
150
+ const note = current.PrivateNote === null ? "Voided" : `Voided - ${current.PrivateNote}`.slice(0, 4000);
151
+ const next = { ...current, TotalAmt: 0, Line: [], voided: true, PrivateNote: note, SyncToken: bumpToken(current), MetaData: { ...current.MetaData, LastUpdatedTime: s.clock.meta } };
152
+ s.put("payments", next);
153
+ s.emit("payment.changed", "Payment", current.Id, "Void");
154
+ rebalance(s, current.Line, []);
155
+ return { row: next, action: "void" };
156
+ }
157
+
158
+ export function deletePayment(s, current) {
159
+ s.remove("payments", current.Id);
160
+ s.emit("payment.changed", "Payment", current.Id, "Delete");
161
+ rebalance(s, current.Line, []);
162
+ return { row: current, action: "delete" };
163
+ }
164
+
165
+ export function paymentsPost(s, input) {
166
+ const operation = input.operation;
167
+ if (operation === undefined || operation === "update") {
168
+ const body = input.body;
169
+ const hasId = has(body, "Id") && body.Id !== null;
170
+ if (operation === "update" && !hasId) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Id", "Id");
171
+ const current = hasId ? updateTarget(s, "payments", body, "Id") : null;
172
+ if (has(body, "sparse") && typeof body.sparse !== "boolean") s.fail("BUSINESS_VALIDATION", "Invalid value for sparse: expected true or false", "sparse");
173
+ return savePayment(s, body, current, current !== null && body.sparse === true);
174
+ }
175
+ if (operation === "void" || operation === "delete") {
176
+ if (!isObject(input.body)) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Payment object", "Payment");
177
+ const current = updateTarget(s, "payments", input.body, "Id");
178
+ return operation === "void" ? voidPayment(s, current) : deletePayment(s, current);
179
+ }
180
+ return s.fail("BUSINESS_VALIDATION", `Operation ${clip(String(operation), 32)} is not supported for Payment.`, "operation");
181
+ }
182
+
183
+ /** create_payment (MCP argument shape) → Payment body. */
184
+ export function paymentBodyFromMcp(input) {
185
+ const body = { CustomerRef: { value: input.customer_ref }, TotalAmt: input.total_amt };
186
+ if (input.payment_method_ref !== undefined) body.PaymentMethodRef = { value: input.payment_method_ref };
187
+ if (input.deposit_to_account_ref !== undefined) body.DepositToAccountRef = { value: input.deposit_to_account_ref };
188
+ if (input.txn_date !== undefined) body.TxnDate = input.txn_date;
189
+ if (input.private_note !== undefined) body.PrivateNote = input.private_note;
190
+ if (input.payment_ref_num !== undefined) body.PaymentRefNum = input.payment_ref_num;
191
+ if (input.currency_ref !== undefined) body.CurrencyRef = { value: input.currency_ref };
192
+ if (input.exchange_rate !== undefined) body.ExchangeRate = input.exchange_rate;
193
+ if (Array.isArray(input.line)) {
194
+ body.Line = input.line.map((line) => ({
195
+ Amount: own(line, "amount"),
196
+ LinkedTxn: (Array.isArray(own(line, "linked_txn")) ? line.linked_txn : []).map((txn) => ({ TxnId: own(txn, "txn_id"), TxnType: own(txn, "txn_type") })),
197
+ }));
198
+ }
199
+ return body;
200
+ }
@@ -0,0 +1,96 @@
1
+ // Deterministic one-page invoice PDF (PDF 1.4, Helvetica, ASCII text) and a hand-written base64 codec.
2
+ // The document derives only from state, so repeated runs produce identical bytes.
3
+
4
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5
+
6
+ /** base64 of a string whose char codes are all < 256. */
7
+ export function base64FromBinary(binary) {
8
+ let out = "";
9
+ for (let i = 0; i < binary.length; i += 3) {
10
+ const a = binary.charCodeAt(i);
11
+ const b = i + 1 < binary.length ? binary.charCodeAt(i + 1) : 0;
12
+ const c = i + 2 < binary.length ? binary.charCodeAt(i + 2) : 0;
13
+ out += B64[a >> 2] + B64[((a & 3) << 4) | (b >> 4)];
14
+ out += i + 1 < binary.length ? B64[((b & 15) << 2) | (c >> 6)] : "=";
15
+ out += i + 2 < binary.length ? B64[c & 63] : "=";
16
+ }
17
+ return out;
18
+ }
19
+
20
+ /** Strict base64 → Uint8Array, or null when the text is not canonical base64. */
21
+ export function bytesFromBase64(text) {
22
+ if (typeof text !== "string" || text.length % 4 !== 0) return null;
23
+ const pad = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
24
+ const bytes = new Uint8Array((text.length / 4) * 3 - pad);
25
+ let k = 0;
26
+ for (let i = 0; i < text.length; i += 4) {
27
+ const values = [];
28
+ for (let j = 0; j < 4; j += 1) {
29
+ const char = text[i + j];
30
+ if (char === "=" && i + 4 === text.length && j >= 4 - pad) values.push(0);
31
+ else {
32
+ const index = B64.indexOf(char);
33
+ if (index < 0) return null;
34
+ values.push(index);
35
+ }
36
+ }
37
+ const n = (values[0] << 18) | (values[1] << 12) | (values[2] << 6) | values[3];
38
+ if (k < bytes.length) bytes[k++] = (n >> 16) & 255;
39
+ if (k < bytes.length) bytes[k++] = (n >> 8) & 255;
40
+ if (k < bytes.length) bytes[k++] = n & 255;
41
+ }
42
+ return bytes;
43
+ }
44
+
45
+ function pdfText(value) {
46
+ let out = "";
47
+ for (const char of String(value)) {
48
+ const code = char.codePointAt(0);
49
+ if (code < 32 || code > 126) out += "?";
50
+ else if (char === "(" || char === ")" || char === "\\") out += `\\${char}`;
51
+ else out += char;
52
+ }
53
+ return out.length > 120 ? `${out.slice(0, 117)}...` : out;
54
+ }
55
+
56
+ const money = (value) => `$${Number(value).toFixed(2)}`;
57
+
58
+ /** Text lines of the invoice document. */
59
+ export function invoiceLines(company, invoice) {
60
+ const lines = [company.CompanyName, "INVOICE", `Invoice no. ${invoice.DocNumber}`, `Invoice date: ${invoice.TxnDate}`, `Due date: ${invoice.DueDate}`, `Bill to: ${invoice.CustomerRef.name ?? invoice.CustomerRef.value}`];
61
+ if (invoice.BillEmail) lines.push(`Email: ${invoice.BillEmail.Address}`);
62
+ lines.push("");
63
+ for (const line of invoice.Line) {
64
+ if (line.DetailType !== "SalesItemLineDetail") continue;
65
+ const detail = line.SalesItemLineDetail;
66
+ const label = line.Description ?? detail.ItemRef.name ?? detail.ItemRef.value;
67
+ lines.push(`${line.LineNum}. ${label} ${detail.Qty} x ${money(detail.UnitPrice)} = ${money(line.Amount)}`);
68
+ }
69
+ lines.push("", `Total: ${money(invoice.TotalAmt)}`, `Balance due: ${money(invoice.Balance)}`);
70
+ if (invoice.CustomerMemo) lines.push(`Message: ${invoice.CustomerMemo.value}`);
71
+ return lines;
72
+ }
73
+
74
+ /** Complete PDF file as a binary (Latin-1) string. */
75
+ export function invoicePdf(company, invoice) {
76
+ const text = invoiceLines(company, invoice).slice(0, 60);
77
+ const stream = `BT /F1 10 Tf 13 TL 50 790 Td ${text.map((line) => `(${pdfText(line)}) Tj T*`).join(" ")} ET`;
78
+ const objects = [
79
+ "<< /Type /Catalog /Pages 2 0 R >>",
80
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
81
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
82
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
83
+ `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
84
+ ];
85
+ let file = "%PDF-1.4\n";
86
+ const offsets = [];
87
+ objects.forEach((body, index) => {
88
+ offsets.push(file.length);
89
+ file += `${index + 1} 0 obj\n${body}\nendobj\n`;
90
+ });
91
+ const xref = file.length;
92
+ file += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
93
+ for (const offset of offsets) file += `${String(offset).padStart(10, "0")} 00000 n \n`;
94
+ file += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
95
+ return file;
96
+ }
@@ -0,0 +1,178 @@
1
+ // Query evaluation over package state: entity metadata (filterable / sortable properties), typed comparisons,
2
+ // QuickBooks' Active default, ordering, 1-based paging and a response byte budget. Shared by query.run and the
3
+ // MCP search operations (criteria compile to the same AST).
4
+ import { dayNumber, instantOf, utf8Bytes } from "./common.mjs";
5
+ import { renderCustomer } from "./customers.mjs";
6
+ import { renderInvoice } from "./invoices.mjs";
7
+ import { renderItem } from "./items.mjs";
8
+ import { compileLike, likeMatches } from "./like.mjs";
9
+ import { renderPayment } from "./payments.mjs";
10
+ import { renderAccount, renderCompany } from "./views.mjs";
11
+
12
+ const META_TIMES = [["MetaData.CreateTime", "datetime"], ["MetaData.LastUpdatedTime", "datetime"]];
13
+ const ENTITIES = new Map([
14
+ ["customer", { name: "Customer", namespace: "customers", capability: "customers.read", activeDefault: true, render: renderCustomer, props: new Map([["Id", "id"], ["DisplayName", "string"], ["GivenName", "string"], ["FamilyName", "string"], ["CompanyName", "string"], ["PrimaryEmailAddr", "email"], ["Active", "bool"], ["Balance", "number"], ...META_TIMES]) }],
15
+ ["item", { name: "Item", namespace: "items", capability: "items.read", activeDefault: true, render: renderItem, props: new Map([["Id", "id"], ["Name", "string"], ["Active", "bool"], ["Type", "string"], ["Sku", "string"], ...META_TIMES]) }],
16
+ ["invoice", { name: "Invoice", namespace: "invoices", capability: "invoices.read", activeDefault: false, render: renderInvoice, props: new Map([["Id", "id"], ["DocNumber", "string"], ["TxnDate", "date"], ["DueDate", "date"], ["CustomerRef", "ref"], ["Balance", "number"], ["TotalAmt", "number"], ["EmailStatus", "string"], ...META_TIMES]) }],
17
+ ["payment", { name: "Payment", namespace: "payments", capability: "payments.read", activeDefault: false, render: renderPayment, props: new Map([["Id", "id"], ["TxnDate", "date"], ["CustomerRef", "ref"], ["TotalAmt", "number"], ["PaymentRefNum", "string"], ...META_TIMES]) }],
18
+ ["account", { name: "Account", namespace: "accounts", capability: "accounts.read", activeDefault: true, render: renderAccount, props: new Map([["Id", "id"], ["Name", "string"], ["AccountType", "string"], ["Classification", "string"], ["Active", "bool"], ["CurrentBalance", "number"], ...META_TIMES]) }],
19
+ ["companyinfo", { name: "CompanyInfo", namespace: null, capability: "company.read", activeDefault: false, render: null, props: new Map([["Id", "id"], ...META_TIMES]) }],
20
+ ]);
21
+
22
+ // Encoded-page budget (the framework refuses HTTP responses over 1 MiB). A stored invoice is bounded by
23
+ // INVOICE_DESCRIPTION_BYTES (invoice-draft.mjs) so that any single rendered row plus the QueryResponse envelope
24
+ // fits under this budget: the first row of a page is therefore always returned, and a page stops before the row
25
+ // that would pass the budget (maxResults = rows returned, so STARTPOSITION + maxResults is the real next position).
26
+ export const RESPONSE_BYTE_BUDGET = 900_000;
27
+ export { utf8Bytes };
28
+
29
+ export function entityMeta(s, name) {
30
+ const meta = typeof name === "string" ? ENTITIES.get(name.toLowerCase()) : undefined;
31
+ if (meta === undefined) s.fail("QUERY_VALIDATION_ERROR", `QueryValidationError: Invalid entity name: ${String(name).slice(0, 64)}`, "query");
32
+ return meta;
33
+ }
34
+
35
+ function invalidProp(s, prop, why = "is not queryable") {
36
+ return s.fail("QUERY_VALIDATION_ERROR", `QueryValidationError: property '${String(prop).slice(0, 64)}' ${why}`, "query");
37
+ }
38
+
39
+ /** Typed value of a rendered entity property (null when absent). */
40
+ function valueOf(entity, prop, type) {
41
+ if (prop === "MetaData.CreateTime" || prop === "MetaData.LastUpdatedTime") {
42
+ const text = entity.MetaData?.[prop === "MetaData.CreateTime" ? "CreateTime" : "LastUpdatedTime"];
43
+ return typeof text === "string" ? instantOf(text) : null;
44
+ }
45
+ if (!Object.hasOwn(entity, prop)) return null;
46
+ const raw = entity[prop];
47
+ if (raw === null || raw === undefined) return null;
48
+ switch (type) {
49
+ case "id": return Number(raw);
50
+ case "email": return typeof raw.Address === "string" ? raw.Address.toLowerCase() : null;
51
+ case "ref": return typeof raw.value === "string" ? raw.value : null;
52
+ case "string": return typeof raw === "string" ? raw.toLowerCase() : null;
53
+ case "date": return dayNumber(raw);
54
+ default: return raw;
55
+ }
56
+ }
57
+
58
+ /** Literal → comparable value for the property type, or a QUERY_VALIDATION_ERROR. */
59
+ function coerce(s, prop, type, literal) {
60
+ const bad = () => s.fail("QUERY_VALIDATION_ERROR", `QueryValidationError: value ${JSON.stringify(String(literal.value)).slice(0, 80)} is not valid for property '${prop}'`, "query");
61
+ const text = literal.kind === "bool" ? null : String(literal.value);
62
+ switch (type) {
63
+ case "id": {
64
+ if (text === null || text.length === 0 || text.length > 10) return bad();
65
+ for (let i = 0; i < text.length; i += 1) if (text.charCodeAt(i) < 48 || text.charCodeAt(i) > 57) return bad();
66
+ return Number(text);
67
+ }
68
+ case "string":
69
+ case "email":
70
+ return text === null ? bad() : text.toLowerCase();
71
+ case "ref":
72
+ return text === null ? bad() : text;
73
+ case "number": {
74
+ const n = text === null || text.trim().length === 0 || text.length > 32 ? Number.NaN : Number(text);
75
+ return Number.isFinite(n) ? n : bad();
76
+ }
77
+ case "bool":
78
+ if (literal.kind === "bool") return literal.value;
79
+ if (text === "true" || text === "false") return text === "true";
80
+ return bad();
81
+ case "date": {
82
+ const day = text === null ? null : dayNumber(text.slice(0, 10));
83
+ return day === null ? bad() : day;
84
+ }
85
+ default: {
86
+ const instant = text === null ? null : instantOf(text);
87
+ return instant === null ? bad() : instant;
88
+ }
89
+ }
90
+ }
91
+
92
+ function compileCondition(s, meta, condition) {
93
+ const type = meta.props.get(condition.prop);
94
+ if (type === undefined) invalidProp(s, condition.prop);
95
+ if (condition.op === "like") {
96
+ if (type !== "string" && type !== "email") invalidProp(s, condition.prop, "does not support LIKE");
97
+ const pattern = coerce(s, condition.prop, type, condition.values[0]);
98
+ const compiled = compileLike(pattern);
99
+ return (entity) => {
100
+ const value = valueOf(entity, condition.prop, type);
101
+ return typeof value === "string" && likeMatches(compiled, value);
102
+ };
103
+ }
104
+ const values = condition.values.map((literal) => coerce(s, condition.prop, type, literal));
105
+ return (entity) => {
106
+ const value = valueOf(entity, condition.prop, type);
107
+ if (value === null) return false;
108
+ switch (condition.op) {
109
+ case "in": return values.includes(value);
110
+ case "=": return value === values[0];
111
+ case "<": return value < values[0];
112
+ case ">": return value > values[0];
113
+ case "<=": return value <= values[0];
114
+ default: return value >= values[0];
115
+ }
116
+ };
117
+ }
118
+
119
+ function compare(a, b) {
120
+ if (a === b) return 0;
121
+ if (a === null) return -1;
122
+ if (b === null) return 1;
123
+ return a < b ? -1 : 1;
124
+ }
125
+
126
+ /** Evaluate an AST. `all` ignores start/max (fetchAll). Returns { meta, entities (page), total }. */
127
+ export function evaluate(s, ast, { all = false } = {}) {
128
+ const meta = entityMeta(s, ast.entity);
129
+ s.permit(meta.capability);
130
+ const tests = ast.where.map((condition) => compileCondition(s, meta, condition));
131
+ const order = ast.order.map(({ prop, dir }) => {
132
+ const type = meta.props.get(prop);
133
+ if (type === undefined) invalidProp(s, prop, "is not sortable");
134
+ return { prop, type, sign: dir === "desc" ? -1 : 1 };
135
+ });
136
+ const activeDefault = meta.activeDefault && !ast.where.some((condition) => condition.prop === "Active");
137
+ const source = meta.namespace === null ? [s.company] : s.rows(meta.namespace);
138
+ const matched = [];
139
+ for (const row of source) {
140
+ const entity = meta.namespace === null ? renderCompany(row) : meta.render(s, row);
141
+ if (activeDefault && entity.Active !== true) continue;
142
+ if (tests.every((test) => test(entity))) matched.push(entity);
143
+ }
144
+ matched.sort((a, b) => {
145
+ for (const key of order) {
146
+ const result = compare(valueOf(a, key.prop, key.type), valueOf(b, key.prop, key.type));
147
+ if (result !== 0) return result * key.sign;
148
+ }
149
+ return Number(a.Id) - Number(b.Id);
150
+ });
151
+ if (ast.select.kind === "count") return { meta, entities: [], total: matched.length };
152
+ const page = all ? matched : matched.slice(ast.start - 1, ast.start - 1 + ast.max);
153
+ const entities = [];
154
+ let bytes = 0;
155
+ for (const entity of page) {
156
+ const projected = ast.select.kind === "fields" ? project(entity, ast.select.fields) : entity;
157
+ const size = utf8Bytes(JSON.stringify(projected)) + 1;
158
+ if (size > RESPONSE_BYTE_BUDGET) {
159
+ // Last line of defence: a single row that can never fit a response answers the declared error instead of an opaque
160
+ // framework 500 (invoice-draft.mjs bounds stored invoices so this is unreachable through the public write surface).
161
+ s.fail("STATE_BOUND_EXCEEDED", `Entity ${projected.Id ?? ""} renders to ${size} bytes, over the response bound of ${RESPONSE_BYTE_BUDGET}.`, "query");
162
+ }
163
+ if (entities.length > 0 && bytes + size > RESPONSE_BYTE_BUDGET) {
164
+ // fetchAll has no next position to hand back; a paged query returns the rows that fit.
165
+ if (all) s.fail("STATE_BOUND_EXCEEDED", "Response too large; page with limit and offset instead of fetchAll.", "query");
166
+ break;
167
+ }
168
+ bytes += size;
169
+ entities.push(projected);
170
+ }
171
+ return { meta, entities, total: matched.length };
172
+ }
173
+
174
+ function project(entity, fields) {
175
+ const out = { Id: entity.Id, SyncToken: entity.SyncToken, domain: "QBO", sparse: true };
176
+ for (const field of fields) if (Object.hasOwn(entity, field)) out[field] = entity[field];
177
+ return out;
178
+ }