@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.
- package/LICENSE +201 -0
- package/README.md +240 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +2338 -0
- package/firedrill/conformance.suite.json +22 -0
- package/firedrill/payment-response-lost.scenario.json +2344 -0
- package/firedrill/qbo-company-query.drill.json +143 -0
- package/firedrill/qbo-customers-items.drill.json +173 -0
- package/firedrill/qbo-default-identity.drill.json +53 -0
- package/firedrill/qbo-denied.drill.json +53 -0
- package/firedrill/qbo-escape-heavy-invoices.drill.json +160 -0
- package/firedrill/qbo-invalid-auth.drill.json +398 -0
- package/firedrill/qbo-invoice-lifecycle.drill.json +193 -0
- package/firedrill/qbo-large-invoice.drill.json +174 -0
- package/firedrill/qbo-mcp-shapes.drill.json +503 -0
- package/firedrill/qbo-payment-response-lost.drill.json +80 -0
- package/firedrill/qbo-payments.drill.json +110 -0
- package/firedrill/qbo-reports-only.drill.json +398 -0
- package/firedrill/qbo-roles.drill.json +143 -0
- package/firedrill/qbo-throttled.drill.json +398 -0
- package/firedrill/qbo-tight-limits.drill.json +368 -0
- package/firedrill/qbo-write-outage.drill.json +411 -0
- package/firedrill/throttled.scenario.json +2344 -0
- package/firedrill/tight-limits.scenario.json +2338 -0
- package/firedrill/tools/quickbooks/app/assets/ATTRIBUTION.md +33 -0
- package/firedrill/tools/quickbooks/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/quickbooks/app/assets/intuit-quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/assets/quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/site/app.js +157 -0
- package/firedrill/tools/quickbooks/app/site/assets/fonts/figtree-latin.woff2 +0 -0
- package/firedrill/tools/quickbooks/app/site/assets/intuit-quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/site/assets/quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/site/base.css +107 -0
- package/firedrill/tools/quickbooks/app/site/forms.css +85 -0
- package/firedrill/tools/quickbooks/app/site/icons.js +73 -0
- package/firedrill/tools/quickbooks/app/site/index.html +81 -0
- package/firedrill/tools/quickbooks/app/site/nav.css +99 -0
- package/firedrill/tools/quickbooks/app/site/store.js +85 -0
- package/firedrill/tools/quickbooks/app/site/tables.css +114 -0
- package/firedrill/tools/quickbooks/app/site/ui.js +189 -0
- package/firedrill/tools/quickbooks/app/site/views/accounts.js +59 -0
- package/firedrill/tools/quickbooks/app/site/views/batch-actions.js +116 -0
- package/firedrill/tools/quickbooks/app/site/views/batch.js +91 -0
- package/firedrill/tools/quickbooks/app/site/views/common.js +87 -0
- package/firedrill/tools/quickbooks/app/site/views/customer-detail.js +85 -0
- package/firedrill/tools/quickbooks/app/site/views/customer-drawer.js +71 -0
- package/firedrill/tools/quickbooks/app/site/views/customers.js +87 -0
- package/firedrill/tools/quickbooks/app/site/views/home.js +79 -0
- package/firedrill/tools/quickbooks/app/site/views/invoice-actions.js +109 -0
- package/firedrill/tools/quickbooks/app/site/views/invoice-editor.js +126 -0
- package/firedrill/tools/quickbooks/app/site/views/invoice-lines.js +92 -0
- package/firedrill/tools/quickbooks/app/site/views/invoices.js +118 -0
- package/firedrill/tools/quickbooks/app/site/views/items.js +105 -0
- package/firedrill/tools/quickbooks/app/site/views/payments.js +63 -0
- package/firedrill/tools/quickbooks/app/site/views/receive-payment.js +102 -0
- package/firedrill/tools/quickbooks/app/site/views/txn-frame.js +25 -0
- package/firedrill/tools/quickbooks/behavior.mjs +10 -0
- package/firedrill/tools/quickbooks/lib/access.mjs +167 -0
- package/firedrill/tools/quickbooks/lib/common.mjs +204 -0
- package/firedrill/tools/quickbooks/lib/criteria.mjs +107 -0
- package/firedrill/tools/quickbooks/lib/customers.mjs +140 -0
- package/firedrill/tools/quickbooks/lib/fields.mjs +157 -0
- package/firedrill/tools/quickbooks/lib/handlers-read.mjs +81 -0
- package/firedrill/tools/quickbooks/lib/handlers-write.mjs +95 -0
- package/firedrill/tools/quickbooks/lib/invoice-draft.mjs +165 -0
- package/firedrill/tools/quickbooks/lib/invoices.mjs +229 -0
- package/firedrill/tools/quickbooks/lib/items.mjs +117 -0
- package/firedrill/tools/quickbooks/lib/like.mjs +49 -0
- package/firedrill/tools/quickbooks/lib/payments.mjs +200 -0
- package/firedrill/tools/quickbooks/lib/pdf.mjs +96 -0
- package/firedrill/tools/quickbooks/lib/query-eval.mjs +178 -0
- package/firedrill/tools/quickbooks/lib/query-parse.mjs +229 -0
- package/firedrill/tools/quickbooks/lib/views.mjs +88 -0
- package/firedrill/tools/quickbooks/lib/wire.mjs +147 -0
- package/firedrill/tools/quickbooks/quickbooks.tool.json +11014 -0
- package/firedrill/world.json +559 -0
- package/firedrill/write-outage.scenario.json +2344 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +2337 -0
- package/test/conformance.mjs +674 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// QuickBooks query language: hand-written tokenizer and parser (no regular expressions over caller text).
|
|
2
|
+
// select (* | count(*) | prop[, prop]) from Entity [where cond (and cond)*] [orderby prop [asc|desc], …]
|
|
3
|
+
// [startposition n] [maxresults n]. Failures raise QUERY_PARSE_ERROR with QuickBooks' parser wording.
|
|
4
|
+
|
|
5
|
+
export const MAX_QUERY_LENGTH = 20000;
|
|
6
|
+
const MAX_CONDITIONS = 20;
|
|
7
|
+
const MAX_IN_VALUES = 100;
|
|
8
|
+
const MAX_SORT_KEYS = 3;
|
|
9
|
+
|
|
10
|
+
function isLetter(code) {
|
|
11
|
+
return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95;
|
|
12
|
+
}
|
|
13
|
+
function isDigit(code) {
|
|
14
|
+
return code >= 48 && code <= 57;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Tokens: { type: "word" | "string" | "number" | "punct", value, column }. */
|
|
18
|
+
export function tokenize(text, parseFail) {
|
|
19
|
+
const tokens = [];
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i < text.length) {
|
|
22
|
+
const code = text.charCodeAt(i);
|
|
23
|
+
const column = i + 1;
|
|
24
|
+
if (code === 32 || code === 9 || code === 10 || code === 13) {
|
|
25
|
+
i += 1;
|
|
26
|
+
} else if (code === 0xfffd) {
|
|
27
|
+
parseFail(`QueryParserError: Invalid character in query at line 1, column ${column}.`);
|
|
28
|
+
} else if (isLetter(code)) {
|
|
29
|
+
let j = i + 1;
|
|
30
|
+
while (j < text.length) {
|
|
31
|
+
const c = text.charCodeAt(j);
|
|
32
|
+
if (isLetter(c) || isDigit(c) || c === 46) j += 1;
|
|
33
|
+
else break;
|
|
34
|
+
}
|
|
35
|
+
tokens.push({ type: "word", value: text.slice(i, j), column });
|
|
36
|
+
i = j;
|
|
37
|
+
} else if (isDigit(code) || (code === 45 && i + 1 < text.length && isDigit(text.charCodeAt(i + 1)))) {
|
|
38
|
+
let j = i + 1;
|
|
39
|
+
while (j < text.length && (isDigit(text.charCodeAt(j)) || text.charCodeAt(j) === 46)) j += 1;
|
|
40
|
+
tokens.push({ type: "number", value: text.slice(i, j), column });
|
|
41
|
+
i = j;
|
|
42
|
+
} else if (code === 39) {
|
|
43
|
+
let value = "";
|
|
44
|
+
let j = i + 1;
|
|
45
|
+
let closed = false;
|
|
46
|
+
while (j < text.length) {
|
|
47
|
+
const c = text.charCodeAt(j);
|
|
48
|
+
if (c === 92 && j + 1 < text.length) {
|
|
49
|
+
value += text[j + 1];
|
|
50
|
+
j += 2;
|
|
51
|
+
} else if (c === 39) {
|
|
52
|
+
closed = true;
|
|
53
|
+
j += 1;
|
|
54
|
+
break;
|
|
55
|
+
} else {
|
|
56
|
+
value += text[j];
|
|
57
|
+
j += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!closed) parseFail(`QueryParserError: Lexical error at line 1, column ${text.length + 1}. Encountered: <EOF> after : "'"`);
|
|
61
|
+
tokens.push({ type: "string", value, column });
|
|
62
|
+
i = j;
|
|
63
|
+
} else {
|
|
64
|
+
const two = text.slice(i, i + 2);
|
|
65
|
+
if (two === "<=" || two === ">=" || two === "!=" || two === "<>") {
|
|
66
|
+
tokens.push({ type: "punct", value: two, column });
|
|
67
|
+
i += 2;
|
|
68
|
+
} else if ("=<>(),*".includes(text[i])) {
|
|
69
|
+
tokens.push({ type: "punct", value: text[i], column });
|
|
70
|
+
i += 1;
|
|
71
|
+
} else {
|
|
72
|
+
parseFail(`QueryParserError: Lexical error at line 1, column ${column}. Encountered: "${text[i]}"`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return tokens;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const KEYWORDS = new Set(["select", "from", "where", "and", "or", "not", "orderby", "asc", "desc", "startposition", "maxresults", "in", "like", "count"]);
|
|
80
|
+
|
|
81
|
+
/** Parse query text into { entity, select, where, order, start, max }. */
|
|
82
|
+
export function parseQuery(text, parseFail) {
|
|
83
|
+
if (typeof text !== "string" || text.trim().length === 0) parseFail("QueryParserError: Encountered \"<EOF>\" at line 1, column 1.");
|
|
84
|
+
if (text.length > MAX_QUERY_LENGTH) parseFail(`QueryParserError: Query exceeds the supported length of ${MAX_QUERY_LENGTH} characters.`);
|
|
85
|
+
const mangled = text.indexOf("\uFFFD");
|
|
86
|
+
if (mangled >= 0) parseFail(`QueryParserError: Invalid character in query at line 1, column ${mangled + 1}.`);
|
|
87
|
+
const tokens = tokenize(text, parseFail);
|
|
88
|
+
let at = 0;
|
|
89
|
+
const peek = () => tokens[at];
|
|
90
|
+
const lower = (token) => (token !== undefined && token.type === "word" ? token.value.toLowerCase() : null);
|
|
91
|
+
const unexpected = (token) => {
|
|
92
|
+
if (token === undefined) return parseFail(`QueryParserError: Encountered " <EOF> "" at line 1, column ${text.length + 1}.`);
|
|
93
|
+
const kind = token.type === "string" ? "<STRING>" : token.type === "number" ? "<INTEGER>" : token.type === "word" ? "<IDENTIFIER>" : token.value;
|
|
94
|
+
return parseFail(`QueryParserError: Encountered " ${kind} "${token.value.slice(0, 64)} "" at line 1, column ${token.column}.`);
|
|
95
|
+
};
|
|
96
|
+
const expectWord = (word) => {
|
|
97
|
+
const token = peek();
|
|
98
|
+
if (lower(token) !== word) unexpected(token);
|
|
99
|
+
at += 1;
|
|
100
|
+
};
|
|
101
|
+
const expectPunct = (value) => {
|
|
102
|
+
const token = peek();
|
|
103
|
+
if (token === undefined || token.type !== "punct" || token.value !== value) unexpected(token);
|
|
104
|
+
at += 1;
|
|
105
|
+
};
|
|
106
|
+
const property = () => {
|
|
107
|
+
const token = peek();
|
|
108
|
+
if (token === undefined || token.type !== "word" || KEYWORDS.has(token.value.toLowerCase())) unexpected(token);
|
|
109
|
+
at += 1;
|
|
110
|
+
return token.value;
|
|
111
|
+
};
|
|
112
|
+
const integer = () => {
|
|
113
|
+
const token = peek();
|
|
114
|
+
if (token === undefined || token.type !== "number" || !isAllDigits(token.value) || token.value.length > 9) unexpected(token);
|
|
115
|
+
at += 1;
|
|
116
|
+
return Number(token.value);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
expectWord("select");
|
|
120
|
+
let select;
|
|
121
|
+
const first = peek();
|
|
122
|
+
if (first !== undefined && first.type === "punct" && first.value === "*") {
|
|
123
|
+
at += 1;
|
|
124
|
+
select = { kind: "all" };
|
|
125
|
+
} else if (lower(first) === "count") {
|
|
126
|
+
at += 1;
|
|
127
|
+
expectPunct("(");
|
|
128
|
+
expectPunct("*");
|
|
129
|
+
expectPunct(")");
|
|
130
|
+
select = { kind: "count" };
|
|
131
|
+
} else {
|
|
132
|
+
const fields = [property()];
|
|
133
|
+
while (peek() !== undefined && peek().type === "punct" && peek().value === ",") {
|
|
134
|
+
at += 1;
|
|
135
|
+
fields.push(property());
|
|
136
|
+
if (fields.length > 100) unexpected(peek());
|
|
137
|
+
}
|
|
138
|
+
select = { kind: "fields", fields };
|
|
139
|
+
}
|
|
140
|
+
expectWord("from");
|
|
141
|
+
const entityToken = peek();
|
|
142
|
+
if (entityToken === undefined || entityToken.type !== "word" || KEYWORDS.has(entityToken.value.toLowerCase())) unexpected(entityToken);
|
|
143
|
+
at += 1;
|
|
144
|
+
const where = [];
|
|
145
|
+
if (lower(peek()) === "where") {
|
|
146
|
+
at += 1;
|
|
147
|
+
where.push(condition());
|
|
148
|
+
while (lower(peek()) === "and") {
|
|
149
|
+
at += 1;
|
|
150
|
+
if (where.length >= MAX_CONDITIONS) parseFail(`QueryParserError: A query supports at most ${MAX_CONDITIONS} conditions.`);
|
|
151
|
+
where.push(condition());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const order = [];
|
|
155
|
+
if (lower(peek()) === "orderby") {
|
|
156
|
+
at += 1;
|
|
157
|
+
for (;;) {
|
|
158
|
+
const prop = property();
|
|
159
|
+
let dir = "asc";
|
|
160
|
+
if (lower(peek()) === "asc" || lower(peek()) === "desc") {
|
|
161
|
+
dir = lower(peek());
|
|
162
|
+
at += 1;
|
|
163
|
+
}
|
|
164
|
+
order.push({ prop, dir });
|
|
165
|
+
if (peek() === undefined || peek().type !== "punct" || peek().value !== ",") break;
|
|
166
|
+
at += 1;
|
|
167
|
+
if (order.length >= MAX_SORT_KEYS) parseFail(`QueryParserError: A query supports at most ${MAX_SORT_KEYS} sort keys.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
let start = 1;
|
|
171
|
+
let max = 100;
|
|
172
|
+
if (lower(peek()) === "startposition") {
|
|
173
|
+
at += 1;
|
|
174
|
+
start = integer();
|
|
175
|
+
if (start < 1) parseFail("QueryParserError: STARTPOSITION must be 1 or greater.");
|
|
176
|
+
}
|
|
177
|
+
if (lower(peek()) === "maxresults") {
|
|
178
|
+
at += 1;
|
|
179
|
+
max = integer();
|
|
180
|
+
if (max < 1 || max > 1000) parseFail("QueryParserError: MAXRESULTS must be between 1 and 1000.");
|
|
181
|
+
}
|
|
182
|
+
if (at < tokens.length) unexpected(peek());
|
|
183
|
+
return { entity: entityToken.value, select, where, order, start, max };
|
|
184
|
+
|
|
185
|
+
function condition() {
|
|
186
|
+
const prop = property();
|
|
187
|
+
const token = peek();
|
|
188
|
+
const word = lower(token);
|
|
189
|
+
if (word === "in") {
|
|
190
|
+
at += 1;
|
|
191
|
+
expectPunct("(");
|
|
192
|
+
const values = [literal()];
|
|
193
|
+
while (peek() !== undefined && peek().type === "punct" && peek().value === ",") {
|
|
194
|
+
at += 1;
|
|
195
|
+
if (values.length >= MAX_IN_VALUES) parseFail(`QueryParserError: IN supports at most ${MAX_IN_VALUES} values.`);
|
|
196
|
+
values.push(literal());
|
|
197
|
+
}
|
|
198
|
+
expectPunct(")");
|
|
199
|
+
return { prop, op: "in", values };
|
|
200
|
+
}
|
|
201
|
+
if (word === "like") {
|
|
202
|
+
at += 1;
|
|
203
|
+
return { prop, op: "like", values: [literal()] };
|
|
204
|
+
}
|
|
205
|
+
if (token === undefined || token.type !== "punct" || !["=", "<", ">", "<=", ">="].includes(token.value)) unexpected(token);
|
|
206
|
+
at += 1;
|
|
207
|
+
return { prop, op: token.value, values: [literal()] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function literal() {
|
|
211
|
+
const token = peek();
|
|
212
|
+
if (token === undefined) unexpected(token);
|
|
213
|
+
if (token.type === "string" || token.type === "number") {
|
|
214
|
+
at += 1;
|
|
215
|
+
return { kind: token.type, value: token.value };
|
|
216
|
+
}
|
|
217
|
+
const word = lower(token);
|
|
218
|
+
if (word === "true" || word === "false") {
|
|
219
|
+
at += 1;
|
|
220
|
+
return { kind: "bool", value: word === "true" };
|
|
221
|
+
}
|
|
222
|
+
return unexpected(token);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isAllDigits(text) {
|
|
227
|
+
for (let i = 0; i < text.length; i += 1) if (!isDigit(text.charCodeAt(i))) return false;
|
|
228
|
+
return text.length > 0;
|
|
229
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Read-only entities: CompanyInfo and Account (with CurrentBalance computed from the ledger on read).
|
|
2
|
+
import { USD, fromCents, toCents } from "./common.mjs";
|
|
3
|
+
|
|
4
|
+
export function renderCompany(company) {
|
|
5
|
+
const out = {
|
|
6
|
+
CompanyName: company.CompanyName,
|
|
7
|
+
LegalName: company.LegalName,
|
|
8
|
+
CompanyAddr: compactAddr(company.CompanyAddr),
|
|
9
|
+
Email: company.Email,
|
|
10
|
+
PrimaryPhone: company.PrimaryPhone,
|
|
11
|
+
WebAddr: company.WebAddr,
|
|
12
|
+
Country: company.Country,
|
|
13
|
+
FiscalYearStartMonth: company.FiscalYearStartMonth,
|
|
14
|
+
CompanyStartDate: company.CompanyStartDate,
|
|
15
|
+
SupportedLanguages: company.SupportedLanguages,
|
|
16
|
+
DefaultTimeZone: company.DefaultTimeZone,
|
|
17
|
+
domain: "QBO",
|
|
18
|
+
sparse: false,
|
|
19
|
+
Id: company.Id,
|
|
20
|
+
SyncToken: company.SyncToken,
|
|
21
|
+
MetaData: company.MetaData,
|
|
22
|
+
};
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function compactAddr(addr) {
|
|
27
|
+
if (addr === null) return null;
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const key of ["Id", "Line1", "Line2", "City", "CountrySubDivisionCode", "PostalCode", "Country"]) if (addr[key] !== null) out[key] = addr[key];
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Per-request ledger totals for account balances (one pass over invoices, payments and items). */
|
|
34
|
+
function accountTotals(s) {
|
|
35
|
+
const key = "#accountTotals";
|
|
36
|
+
if (s.memo.has(key)) return s.memo.get(key);
|
|
37
|
+
const totals = { receivable: 0, deposits: new Map(), income: new Map() };
|
|
38
|
+
const items = new Map();
|
|
39
|
+
for (const item of s.rows("items")) items.set(item.Id, item);
|
|
40
|
+
for (const invoice of s.rows("invoices")) {
|
|
41
|
+
totals.receivable += toCents(invoice.Balance) ?? 0;
|
|
42
|
+
if (invoice.voided) continue;
|
|
43
|
+
for (const line of invoice.Line) {
|
|
44
|
+
const item = items.get(line.SalesItemLineDetail.ItemRef.value);
|
|
45
|
+
if (item === undefined) continue;
|
|
46
|
+
const account = item.IncomeAccountRef.value;
|
|
47
|
+
totals.income.set(account, (totals.income.get(account) ?? 0) + (toCents(line.Amount) ?? 0));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const payment of s.rows("payments")) {
|
|
51
|
+
const account = payment.DepositToAccountRef.value;
|
|
52
|
+
totals.deposits.set(account, (totals.deposits.get(account) ?? 0) + (toCents(payment.TotalAmt) ?? 0));
|
|
53
|
+
}
|
|
54
|
+
s.memo.set(key, totals);
|
|
55
|
+
return totals;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function accountBalanceCents(s, row) {
|
|
59
|
+
const opening = toCents(row.openingBalance, { allowNegative: true }) ?? 0;
|
|
60
|
+
if (row.AccountType === "Accounts Receivable") return opening + accountTotals(s).receivable;
|
|
61
|
+
if (row.AccountType === "Bank" || row.AccountSubType === "UndepositedFunds") return opening + (accountTotals(s).deposits.get(row.Id) ?? 0);
|
|
62
|
+
if (row.AccountType === "Income") return opening + (accountTotals(s).income.get(row.Id) ?? 0);
|
|
63
|
+
return opening;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderAccount(s, row) {
|
|
67
|
+
const balance = fromCents(accountBalanceCents(s, row));
|
|
68
|
+
const out = {
|
|
69
|
+
Name: row.Name,
|
|
70
|
+
SubAccount: false,
|
|
71
|
+
FullyQualifiedName: row.FullyQualifiedName,
|
|
72
|
+
Active: row.Active,
|
|
73
|
+
Classification: row.Classification,
|
|
74
|
+
AccountType: row.AccountType,
|
|
75
|
+
AccountSubType: row.AccountSubType,
|
|
76
|
+
CurrentBalance: balance,
|
|
77
|
+
CurrentBalanceWithSubAccounts: balance,
|
|
78
|
+
CurrencyRef: USD,
|
|
79
|
+
domain: "QBO",
|
|
80
|
+
sparse: false,
|
|
81
|
+
Id: row.Id,
|
|
82
|
+
SyncToken: row.SyncToken,
|
|
83
|
+
MetaData: row.MetaData,
|
|
84
|
+
};
|
|
85
|
+
if (row.AcctNum !== null) out.AcctNum = row.AcctNum;
|
|
86
|
+
if (row.Description !== null) out.Description = row.Description;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// QuickBooks Online Accounting API v3 wire codecs: pure decode (path, query, body → operation arguments) and
|
|
2
|
+
// encode (outcome → QuickBooks body or Fault envelope). No state, no clock; `time` comes from handler outputs.
|
|
3
|
+
import { faultInfo } from "./common.mjs";
|
|
4
|
+
import { bytesFromBase64 } from "./pdf.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_DEPTH = 512;
|
|
7
|
+
|
|
8
|
+
function last(query, name) {
|
|
9
|
+
const values = Object.hasOwn(query, name) ? query[name] : undefined;
|
|
10
|
+
return values === undefined || values.length === 0 ? undefined : values[values.length - 1];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const cap = (value, max) => (typeof value === "string" && value.length > max ? value.slice(0, max) : value);
|
|
14
|
+
|
|
15
|
+
function checkDepth(value) {
|
|
16
|
+
const stack = [[value, 1]];
|
|
17
|
+
while (stack.length > 0) {
|
|
18
|
+
const [current, depth] = stack.pop();
|
|
19
|
+
if (current === null || typeof current !== "object") continue;
|
|
20
|
+
if (depth > MAX_DEPTH) throw new Error(`Request body nesting exceeds the supported depth of ${MAX_DEPTH} levels.`);
|
|
21
|
+
for (const inner of Array.isArray(current) ? current : Object.values(current)) {
|
|
22
|
+
if (inner !== null && typeof inner === "object") stack.push([inner, depth + 1]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function args(entries) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [key, value] of entries) if (value !== undefined) out[key] = value;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withKey(request, argumentsObject) {
|
|
34
|
+
const key = last(request.query, "requestid");
|
|
35
|
+
if (key === undefined || key.length === 0) return { arguments: argumentsObject };
|
|
36
|
+
if (key.length > 255) throw new Error("requestid must be at most 255 characters.");
|
|
37
|
+
return { arguments: argumentsObject, idempotencyKey: key };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const realm = (request) => cap(request.path.realmId, 64);
|
|
41
|
+
|
|
42
|
+
function jsonBody(request) {
|
|
43
|
+
if (request.body.kind !== "json") return undefined;
|
|
44
|
+
checkDepth(request.body.value);
|
|
45
|
+
return request.body.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ERROR_TEXT = new Map([
|
|
49
|
+
["THROTTLE_EXCEEDED", "Throttling limits exceeded."],
|
|
50
|
+
["SERVICE_UNAVAILABLE", "Service Unavailable"],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** QuickBooks Fault envelope for any unsuccessful outcome. */
|
|
54
|
+
export function faultBody(outcome) {
|
|
55
|
+
const error = outcome.error ?? {};
|
|
56
|
+
const message = typeof error.message === "string" ? error.message : "";
|
|
57
|
+
if (outcome.status === "denied") {
|
|
58
|
+
return envelope("message=ApplicationAuthorizationFailed; errorCode=003100; statusCode=403", "This operation is not granted to the calling actor in this Firedrill world.", "3100", "", "AuthorizationFault");
|
|
59
|
+
}
|
|
60
|
+
if (outcome.status === "unsupported") return envelope("Unsupported Operation", "Operation is not supported by this Tool.", "500", "", "ValidationFault");
|
|
61
|
+
if (outcome.status === "invalid") {
|
|
62
|
+
return envelope("A business validation error has occurred while processing your request", `Business Validation Error: ${message.slice(0, 500)}`, "6000", "", "ValidationFault");
|
|
63
|
+
}
|
|
64
|
+
const code = String(error.code ?? "").replace(/^tool\./, "");
|
|
65
|
+
const info = faultInfo(code) ?? ["10000", "An application error has occurred while processing your request", "SystemFault"];
|
|
66
|
+
const details = error.details !== null && typeof error.details === "object" ? error.details : {};
|
|
67
|
+
const body = envelope(
|
|
68
|
+
code === "SERVICE_UNAVAILABLE" ? info[1] : message.length > 0 ? message : info[1],
|
|
69
|
+
typeof details.detail === "string" ? details.detail : (ERROR_TEXT.get(code) ?? message),
|
|
70
|
+
typeof details.qboCode === "string" ? details.qboCode : info[0],
|
|
71
|
+
typeof details.element === "string" ? details.element : "",
|
|
72
|
+
typeof details.faultType === "string" ? details.faultType : info[2],
|
|
73
|
+
);
|
|
74
|
+
if (typeof details.time === "string") body.time = details.time;
|
|
75
|
+
return body;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function envelope(Message, Detail, code, element, type) {
|
|
79
|
+
return { Fault: { Error: [{ Message, Detail, code, element }], type } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function encodeJson({ invocation, outcome }) {
|
|
83
|
+
const headers = { intuit_tid: invocation.callId };
|
|
84
|
+
if (outcome.status !== "ok") return { headers, body: { kind: "json", value: faultBody(outcome) } };
|
|
85
|
+
const value = {};
|
|
86
|
+
for (const [key, inner] of Object.entries(outcome.value)) if (key !== "action") value[key] = inner;
|
|
87
|
+
return { headers, body: { kind: "json", value } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function encodePdf(result) {
|
|
91
|
+
const { outcome, invocation } = result;
|
|
92
|
+
if (outcome.status !== "ok") return encodeJson(result);
|
|
93
|
+
const bytes = bytesFromBase64(outcome.value.base64);
|
|
94
|
+
if (bytes === null) return { headers: { intuit_tid: invocation.callId }, body: { kind: "json", value: envelope("An application error has occurred while processing your request", "PDF rendering failed.", "10000", "", "SystemFault") } };
|
|
95
|
+
return { headers: { intuit_tid: invocation.callId }, body: { kind: "bytes", value: bytes, contentType: "application/pdf" } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const getRoute = (idName) => ({
|
|
99
|
+
decode: (request) => ({ arguments: args([[idName, cap(request.path.id, 64)], ["realmId", realm(request)]]) }),
|
|
100
|
+
encode: encodeJson,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const postRoute = (withInclude) => ({
|
|
104
|
+
decode: (request) =>
|
|
105
|
+
withKey(request, args([
|
|
106
|
+
["body", jsonBody(request)],
|
|
107
|
+
["operation", cap(last(request.query, "operation"), 64)],
|
|
108
|
+
["include", withInclude ? cap(last(request.query, "include"), 64) : undefined],
|
|
109
|
+
["realmId", realm(request)],
|
|
110
|
+
])),
|
|
111
|
+
encode: encodeJson,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const routes = {
|
|
115
|
+
"companyinfo-get": {
|
|
116
|
+
decode: (request) => ({ arguments: args([["company_id", cap(request.path.companyId, 64)], ["realmId", realm(request)]]) }),
|
|
117
|
+
encode: encodeJson,
|
|
118
|
+
},
|
|
119
|
+
"query-get": {
|
|
120
|
+
decode: (request) => ({ arguments: args([["query", cap(last(request.query, "query") ?? "", 20001)], ["realmId", realm(request)]]) }),
|
|
121
|
+
encode: encodeJson,
|
|
122
|
+
},
|
|
123
|
+
"query-post": {
|
|
124
|
+
decode: (request) => {
|
|
125
|
+
const text = request.body.kind === "text" ? request.body.value : (last(request.query, "query") ?? "");
|
|
126
|
+
return { arguments: args([["query", cap(text, 20001)], ["realmId", realm(request)]]) };
|
|
127
|
+
},
|
|
128
|
+
encode: encodeJson,
|
|
129
|
+
},
|
|
130
|
+
"customer-get": getRoute("id"),
|
|
131
|
+
"customer-post": postRoute(false),
|
|
132
|
+
"item-get": getRoute("item_id"),
|
|
133
|
+
"item-post": postRoute(false),
|
|
134
|
+
"invoice-get": getRoute("invoice_id"),
|
|
135
|
+
"invoice-post": postRoute(true),
|
|
136
|
+
"invoice-send": {
|
|
137
|
+
decode: (request) => withKey(request, args([["invoice_id", cap(request.path.id, 64)], ["sendTo", cap(last(request.query, "sendTo"), 320)], ["realmId", realm(request)]])),
|
|
138
|
+
encode: encodeJson,
|
|
139
|
+
},
|
|
140
|
+
"invoice-pdf": {
|
|
141
|
+
decode: (request) => ({ arguments: args([["invoice_id", cap(request.path.id, 64)], ["realmId", realm(request)]]) }),
|
|
142
|
+
encode: encodePdf,
|
|
143
|
+
},
|
|
144
|
+
"payment-get": getRoute("id"),
|
|
145
|
+
"payment-post": postRoute(false),
|
|
146
|
+
"account-get": getRoute("id"),
|
|
147
|
+
};
|