@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,63 @@
1
+ // Sales & Get paid > Payments: received payments with paging, customer filter, void and delete.
2
+ import { app, go, query, queryAll, register } from "../store.js";
3
+ import { call, confirmDialog, describe, el, errorBanner, icon, isAccessDenied, loadingRows, mdY, money, newKey, openMenu, quote, toast } from "../ui.js";
4
+ import { emptyRow, pageShell, pager, salesTabs, select } from "./common.js";
5
+ import { openReceivePayment } from "./receive-payment.js";
6
+
7
+ const SIZE = 25;
8
+ const isVoidedPayment = (p) => typeof p.PrivateNote === "string" && p.PrivateNote.startsWith("Voided");
9
+
10
+ register("payments", async (host, route) => {
11
+ const p = route.params;
12
+ const customer = p.get("customer") ?? "";
13
+ const start = Math.max(1, Number(p.get("start")) || 1);
14
+ const nav = (patch) => { const n = new URLSearchParams(p); for (const [k, v] of Object.entries(patch)) v ? n.set(k, v) : n.delete(k); go(`#/payments?${n}`); };
15
+ const refresh = () => { if (host.isConnected) app.pages.payments(host, route); };
16
+ const { root, body } = pageShell("Payments", {
17
+ crumb: "Sales & Get paid",
18
+ actions: [el("button", { type: "button", class: "btn primary", text: "Receive payment", onclick: () => openReceivePayment({ onSaved: refresh }) })],
19
+ tabs: salesTabs("payments"),
20
+ });
21
+ const custSel = select([["", "All customers"]], "");
22
+ custSel.setAttribute("aria-label", "Customer");
23
+ custSel.addEventListener("change", () => nav({ customer: custSel.value, start: "" }));
24
+ const tbody = el("tbody", {}, loadingRows(8));
25
+ const foot = el("div");
26
+ const card = el("div", { class: "card" }, [
27
+ el("div", { class: "toolbar" }, [el("label", { class: "filter-label" }, ["Customer", custSel]), el("div", { class: "grow" })]),
28
+ el("div", { class: "table-wrap" }, el("table", { class: "grid" }, [el("thead", {}, el("tr", {}, ["Date", "Type", "No.", "Customer", "Method", "Deposit to", "Amount", "Status", "Action"].map((h, i) => el("th", { class: i === 6 ? "num" : i === 8 ? "act" : "", text: h })))), tbody])),
29
+ foot,
30
+ ]);
31
+ body.append(card);
32
+ host.replaceChildren(root);
33
+ queryAll("select * from Customer where Active IN (true, false) orderby DisplayName").then((cs) => { for (const c of cs) custSel.append(el("option", { value: c.Id, text: c.DisplayName })); custSel.value = customer; }).catch((error) => { if (!isAccessDenied(error)) toast(`Customer filter: ${describe(error)}`, { error: true }); });
34
+ try {
35
+ const clause = customer ? ` where CustomerRef = ${quote(customer)}` : "";
36
+ const total = (await call("query.run", { query: `select count(*) from Payment${clause}` })).QueryResponse.totalCount ?? 0;
37
+ const rows = await query(`select * from Payment${clause} orderby TxnDate desc STARTPOSITION ${start} MAXRESULTS ${SIZE}`);
38
+ tbody.replaceChildren(...(rows.length ? rows.map((pay) => {
39
+ const voided = isVoidedPayment(pay);
40
+ const unapplied = Number(pay.UnappliedAmt ?? 0);
41
+ const caret = el("button", { type: "button", class: "caret", "aria-label": `More actions for payment ${pay.Id}`, "aria-haspopup": "menu" }, icon("chevronDown"));
42
+ caret.addEventListener("click", () => openMenu(caret, [
43
+ { label: "Void", disabled: voided, run: async () => {
44
+ if (!(await confirmDialog("Void payment?", `Voiding sets this ${money(pay.TotalAmt)} payment to zero and reopens the invoices it paid.`, "Yes, void"))) return;
45
+ try { await call("payments.post", { operation: "void", body: { Id: pay.Id, SyncToken: pay.SyncToken } }, newKey()); toast("Payment voided"); refresh(); } catch (error) { toast(describe(error), { error: true }); }
46
+ } },
47
+ { label: "Delete", run: async () => {
48
+ if (!(await confirmDialog("Delete payment?", "Deleting this payment reopens the invoices it paid. This can't be undone.", "Yes, delete", "danger"))) return;
49
+ try { await call("payments.post", { operation: "delete", body: { Id: pay.Id, SyncToken: pay.SyncToken } }, newKey()); toast("Payment deleted"); refresh(); } catch (error) { toast(describe(error), { error: true }); }
50
+ } },
51
+ ]));
52
+ const status = voided ? "Voided" : unapplied > 0 ? `Unapplied ${money(unapplied)}` : "Closed";
53
+ return el("tr", {}, [el("td", { text: mdY(pay.TxnDate) }), el("td", { text: "Payment" }), el("td", { text: pay.PaymentRefNum ?? "" }),
54
+ el("td", {}, el("a", { href: `#/customers/${encodeURIComponent(pay.CustomerRef?.value ?? "")}`, text: pay.CustomerRef?.name ?? "" })),
55
+ el("td", { text: pay.PaymentMethodRef?.name ?? "" }), el("td", { text: pay.DepositToAccountRef?.name ?? "" }),
56
+ el("td", { class: "num", text: money(pay.TotalAmt) }), el("td", { class: voided ? "muted" : "", text: status }),
57
+ el("td", { class: "act" }, el("div", { class: "row-actions" }, [el("span", { class: "muted", text: "" }), caret]))]);
58
+ }) : [emptyRow(9, "No payments yet", "Record a payment when a customer pays an invoice.")]));
59
+ foot.replaceChildren(pager({ start, size: SIZE, total, onChange: (s) => nav({ start: String(s) }) }));
60
+ } catch (error) {
61
+ card.replaceWith(errorBanner(error, isAccessDenied(error) ? "You don't have access to payments" : "We couldn't load payments"));
62
+ }
63
+ });
@@ -0,0 +1,102 @@
1
+ // Receive payment form: customer, outstanding invoices with per-line payment amounts, deposit account → payments.post.
2
+ import { app, queryAll } from "../store.js";
3
+ import { ToolError, amount, call, confirmDialog, describe, el, errorBanner, mdY, money, newKey, quote, toast } from "../ui.js";
4
+ import { field, input, select } from "./common.js";
5
+ import { txnFrame } from "./txn-frame.js";
6
+
7
+ const METHODS = [["", ""], ["1", "Cash"], ["2", "Check"], ["3", "Credit card"], ["4", "ACH / bank transfer"]];
8
+
9
+ export async function openReceivePayment({ customerId = "", invoiceId = "", onSaved } = {}) {
10
+ let touched = false;
11
+ const t = txnFrame("Receive payment", { onClose: async (close) => { if (!touched || (await confirmDialog("Leave without saving?", "Do you want to leave without saving? Your changes will be lost.", "Yes, leave"))) close(); } });
12
+ t.canvas.append(el("div", { class: "loading" }, [el("span", { class: "spinner" }), el("span", { text: "Loading…" })]));
13
+ let customers; let deposit;
14
+ try {
15
+ [customers, deposit] = await Promise.all([
16
+ queryAll("select * from Customer orderby DisplayName"),
17
+ queryAll("select * from Account").then((a) => a.filter((x) => x.AccountType === "Bank" || x.AccountSubType === "UndepositedFunds")),
18
+ ]);
19
+ } catch (error) { t.canvas.replaceChildren(errorBanner(error, "We couldn't open Receive payment")); return; }
20
+ const undeposited = deposit.find((a) => a.AccountSubType === "UndepositedFunds");
21
+ const cust = field("Customer", select([["", "Choose a customer"], ...customers.map((c) => [c.Id, c.DisplayName])], customerId));
22
+ const email = field("Email", input({ type: "email", readonly: true, value: "" }));
23
+ const date = field("Payment date", input({ type: "date", value: app.today }));
24
+ const method = field("Payment method", select(METHODS, ""));
25
+ const ref = field("Reference no.", input({ maxlength: "21" }));
26
+ const dep = field("Deposit to", select(deposit.map((a) => [a.Id, a.Name]), undeposited?.Id ?? deposit[0]?.Id ?? ""));
27
+ const received = field("Amount received", input({ type: "number", step: "0.01", min: "0", class: "num" }));
28
+ const memo = field("Memo", el("textarea"));
29
+ const big = el("div", { class: "val", text: money(0) });
30
+ const formError = el("div");
31
+ const tbody = el("tbody");
32
+ let rows = [];
33
+ const sum = () => rows.reduce((s, r) => s + (r.check.checked ? Math.round(Number(r.pay.value || 0) * 100) : 0), 0);
34
+ const syncTotal = () => { const c = sum(); received.input.value = (c / 100).toFixed(2); big.textContent = money(c / 100); };
35
+ received.input.addEventListener("input", () => { touched = true; big.textContent = money(Number(received.input.value || 0)); });
36
+
37
+ async function loadOpen() {
38
+ rows = [];
39
+ const c = customers.find((x) => x.Id === cust.input.value);
40
+ email.input.value = c?.PrimaryEmailAddr?.Address ?? "";
41
+ if (!c) { tbody.replaceChildren(el("tr", {}, el("td", { class: "empty", colspan: 6, text: "Select a customer to see outstanding transactions." }))); syncTotal(); return; }
42
+ tbody.replaceChildren(el("tr", {}, el("td", { colspan: 6, class: "empty", text: "Loading outstanding transactions…" })));
43
+ try {
44
+ const open = await queryAll(`select * from Invoice where CustomerRef = ${quote(c.Id)} AND Balance > '0'`);
45
+ open.sort((a, b) => (a.DueDate < b.DueDate ? -1 : 1));
46
+ rows = open.map((inv) => {
47
+ const check = el("input", { type: "checkbox", "aria-label": `Apply payment to invoice ${inv.DocNumber}`, checked: invoiceId ? inv.Id === invoiceId : false });
48
+ const pay = el("input", { class: "cell-input num", type: "number", step: "0.01", min: "0", "aria-label": `Payment for invoice ${inv.DocNumber}`, value: check.checked ? String(inv.Balance) : "" });
49
+ check.addEventListener("change", () => { touched = true; pay.value = check.checked ? String(inv.Balance) : ""; pay.classList.remove("invalid"); syncTotal(); });
50
+ pay.addEventListener("input", () => { touched = true; check.checked = Number(pay.value) > 0; syncTotal(); });
51
+ const tr = el("tr", {}, [el("td", {}, check), el("td", { text: `Invoice # ${inv.DocNumber ?? inv.Id} (${mdY(inv.TxnDate)})` }), el("td", { text: mdY(inv.DueDate) }), el("td", { class: "amt", text: amount(inv.TotalAmt) }), el("td", { class: "amt", text: amount(inv.Balance) }), el("td", {}, pay)]);
52
+ return { inv, check, pay, tr };
53
+ });
54
+ tbody.replaceChildren(...(rows.length ? rows.map((r) => r.tr) : [el("tr", {}, el("td", { class: "empty", colspan: 6, text: "There are no outstanding transactions for this customer." }))]));
55
+ syncTotal();
56
+ } catch (error) { tbody.replaceChildren(el("tr", {}, el("td", { colspan: 6 }, errorBanner(error)))); }
57
+ }
58
+ cust.input.addEventListener("change", () => { touched = true; invoiceId = ""; loadOpen(); });
59
+
60
+ t.canvas.replaceChildren(el("div", { class: "sheet" }, [formError,
61
+ el("div", { class: "sheet-top" }, [el("div", { class: "row2" }, [cust.wrap, email.wrap]), el("div", { class: "sheet-balance" }, [el("div", { class: "lbl", text: "Amount received" }), big])]),
62
+ el("div", { class: "row3" }, [date.wrap, method.wrap, ref.wrap]),
63
+ el("div", { class: "row3" }, [dep.wrap, el("div"), received.wrap]),
64
+ el("div", { class: "section-title", text: "Outstanding transactions" }),
65
+ el("div", { class: "table-wrap" }, el("table", { class: "lines outstanding" }, [el("thead", {}, el("tr", {}, [el("th", {}, el("span", { class: "visually-hidden", text: "Apply" })), el("th", { text: "Description" }), el("th", { text: "Due date" }), el("th", { class: "amt", text: "Original amount" }), el("th", { class: "amt", text: "Open balance" }), el("th", { class: "amt", text: "Payment" })])), tbody])),
66
+ el("div", { class: "sheet-bottom" }, [el("div", { class: "notes" }, memo.wrap)]),
67
+ ]));
68
+ await loadOpen();
69
+
70
+ let key = newKey();
71
+ const save = el("button", { type: "button", class: "btn primary", text: "Save and close" });
72
+ save.addEventListener("click", async () => {
73
+ formError.replaceChildren(); for (const f of [cust, received, dep]) f.setError("");
74
+ if (!cust.input.value) { cust.setError("Choose a customer."); return cust.input.focus(); }
75
+ const total = Math.round(Number(received.input.value || 0) * 100);
76
+ const applied = sum();
77
+ if (total <= 0) { received.setError("Enter the amount received."); return received.input.focus(); }
78
+ if (applied > total) { received.setError("The amount received is less than the payments applied."); return; }
79
+ const body = { CustomerRef: { value: cust.input.value }, TotalAmt: total / 100, TxnDate: date.input.value || app.today, DepositToAccountRef: { value: dep.input.value },
80
+ Line: rows.filter((r) => r.check.checked && Number(r.pay.value) > 0).map((r) => ({ Amount: Math.round(Number(r.pay.value) * 100) / 100, LinkedTxn: [{ TxnId: r.inv.Id, TxnType: "Invoice" }] })) };
81
+ if (method.input.value) body.PaymentMethodRef = { value: method.input.value, name: METHODS.find((m) => m[0] === method.input.value)[1] };
82
+ if (ref.input.value.trim()) body.PaymentRefNum = ref.input.value.trim();
83
+ if (memo.input.value.trim()) body.PrivateNote = memo.input.value.trim();
84
+ save.disabled = true;
85
+ try {
86
+ const out = await call("payments.post", { body }, key);
87
+ key = newKey();
88
+ t.close();
89
+ toast(`Payment of ${money(out.Payment.TotalAmt)} saved${Number(out.Payment.UnappliedAmt) > 0 ? ` (${money(out.Payment.UnappliedAmt)} unapplied)` : ""}`);
90
+ onSaved?.();
91
+ } catch (error) {
92
+ save.disabled = false;
93
+ if (error instanceof ToolError && /Line|LinkedTxn|Amount/.test(error.element)) rows.forEach((r) => { if (r.check.checked) r.pay.classList.add("invalid"); });
94
+ if (error instanceof ToolError && error.element === "DepositToAccountRef") dep.setError(describe(error));
95
+ formError.replaceChildren(errorBanner(error, "We couldn't save this payment"));
96
+ if (!(error instanceof ToolError && error.is("THROTTLE_EXCEEDED"))) key = newKey();
97
+ }
98
+ });
99
+ t.foot.append(el("button", { type: "button", class: "btn", text: "Cancel", onclick: t.requestClose }),
100
+ el("button", { type: "button", class: "foot-link", text: "Clear payment", onclick: () => { rows.forEach((r) => { r.check.checked = false; r.pay.value = ""; }); syncTotal(); } }),
101
+ el("div", { class: "grow" }), save);
102
+ }
@@ -0,0 +1,25 @@
1
+ // Full-screen transaction frame shared by the invoice and receive-payment forms (header, grey canvas, dark footer).
2
+ import { app } from "../store.js";
3
+ import { el, icon, notSimulated } from "../ui.js";
4
+
5
+ export function txnFrame(title, { onClose }) {
6
+ const previous = document.activeElement;
7
+ const titleNode = el("span", { text: title });
8
+ const canvas = el("div", { class: "txn-body" });
9
+ const foot = el("div", { class: "txn-foot" });
10
+ const close = () => { frame.remove(); document.removeEventListener("keydown", onKey); app.dirty = false; previous?.focus?.(); };
11
+ const requestClose = () => onClose(close);
12
+ const onKey = (e) => { if (e.key === "Escape" && !document.querySelector(".scrim, .menu-pop")) requestClose(); };
13
+ const head = el("div", { class: "txn-head" }, [
14
+ el("h1", {}, [el("img", { src: "./assets/quickbooks.svg", alt: "" }), titleNode]),
15
+ el("button", { type: "button", class: "icon-btn", "aria-label": "Recent transactions", title: "Recent transactions", onclick: () => notSimulated("Recent transactions") }, icon("history")),
16
+ el("button", { type: "button", class: "icon-btn", "aria-label": "Form settings", title: "Settings", onclick: () => notSimulated("Form settings") }, icon("gear")),
17
+ el("button", { type: "button", class: "icon-btn", "aria-label": "Help", title: "Help", onclick: () => notSimulated("Help") }, icon("help")),
18
+ el("button", { type: "button", class: "icon-btn", "aria-label": "Close", title: "Close", onclick: requestClose }, icon("close")),
19
+ ]);
20
+ const frame = el("div", { class: "txn", role: "dialog", "aria-modal": "true", "aria-label": title }, [head, canvas, foot]);
21
+ document.getElementById("overlay-root").append(frame);
22
+ document.addEventListener("keydown", onKey);
23
+ app.dirty = true;
24
+ return { frame, canvas, foot, close, requestClose, setTitle: (t) => { titleNode.textContent = t; frame.setAttribute("aria-label", t); } };
25
+ }
@@ -0,0 +1,10 @@
1
+ // QuickBooks Online (Accounting API v3 subset) Tool behavior: synchronous handlers over context.state plus pure
2
+ // provider-shaped wire codecs. Read handlers live in lib/handlers-read.mjs, write handlers in lib/handlers-write.mjs.
3
+ import { readOperations } from "./lib/handlers-read.mjs";
4
+ import { writeOperations } from "./lib/handlers-write.mjs";
5
+ import { routes } from "./lib/wire.mjs";
6
+
7
+ export default {
8
+ operations: { ...readOperations, ...writeOperations },
9
+ http: routes,
10
+ };
@@ -0,0 +1,167 @@
1
+ // Caller identity, QuickBooks user-role model and the per-operation session (clock, caches, id counters, events).
2
+ import { DEFAULT_OFFSET_MINUTES, clockOf, fail, isId, padId, scanAll, scanBound, toCents } from "./common.mjs";
3
+
4
+ const ROLES = new Set(["company_admin", "standard_limited_customers", "reports_only"]);
5
+ const LIMITED = new Set([
6
+ "company.read",
7
+ "query",
8
+ "customers.read",
9
+ "customers.write",
10
+ "invoices.read",
11
+ "invoices.write",
12
+ "payments.read",
13
+ "payments.write",
14
+ "items.read",
15
+ ]);
16
+
17
+ export const NOT_FOUND_DETAIL =
18
+ "Object Not Found : Something you're trying to use has been made inactive or deleted. Check the fields with accounts, customers, items, vendors or employees.";
19
+
20
+ /**
21
+ * Open a session for one operation call. Order: company present → actor realm claim → actor role → path/argument
22
+ * realm → role capability. Actors without attributes act as the company admin of the seeded realm.
23
+ */
24
+ export function open(context, input, capability) {
25
+ const company = context.state.get("company", "company");
26
+ const offset = company !== null && Number.isInteger(company.utcOffsetMinutes) ? company.utcOffsetMinutes : DEFAULT_OFFSET_MINUTES;
27
+ const clock = clockOf(context, offset);
28
+ if (company === null) {
29
+ return fail(context, "AUTHENTICATION_FAILED", "This Firedrill world has no QuickBooks company, so no token can be valid for it.", "", clock);
30
+ }
31
+ const attributes = context.actor.attributes ?? {};
32
+ if (Object.hasOwn(attributes, "realmId") && attributes.realmId !== company.realmId) {
33
+ return fail(context, "AUTHENTICATION_FAILED", "Token is not valid for this company: the realmId it was issued for does not match.", "", clock);
34
+ }
35
+ let role = "company_admin";
36
+ if (Object.hasOwn(attributes, "role")) {
37
+ if (typeof attributes.role !== "string" || !ROLES.has(attributes.role)) {
38
+ return fail(context, "AUTHENTICATION_FAILED", "Token carries an unknown QuickBooks user role.", "", clock);
39
+ }
40
+ role = attributes.role;
41
+ }
42
+ if (input.realmId !== undefined && input.realmId !== company.realmId) {
43
+ return fail(context, "AUTHORIZATION_FAILED", "The application is not authorized to access the requested company (realmId).", "", clock);
44
+ }
45
+ const session = createSession(context, company, clock, role);
46
+ session.permit(capability);
47
+ return session;
48
+ }
49
+
50
+ function createSession(context, company, clock, role) {
51
+ const cache = new Map();
52
+ const bound = scanBound(context);
53
+ const s = {
54
+ context,
55
+ memo: new Map(),
56
+ company,
57
+ clock,
58
+ role,
59
+ realmId: company.realmId,
60
+ fail: (code, detail, element = "") => fail(context, code, detail, element, clock),
61
+ permit(capability) {
62
+ if (role === "company_admin") return;
63
+ if (role === "standard_limited_customers" && LIMITED.has(capability)) return;
64
+ if (role === "reports_only" && capability === "company.read") return;
65
+ s.fail("AUTHORIZATION_FAILED", `Your QuickBooks user role (${role}) does not allow this request (${capability}).`);
66
+ },
67
+ get(namespace, id) {
68
+ return isId(id) ? context.state.get(namespace, padId(id)) : null;
69
+ },
70
+ rows(namespace) {
71
+ if (!cache.has(namespace)) cache.set(namespace, scanAll(context, namespace, bound, clock));
72
+ return cache.get(namespace);
73
+ },
74
+ put(namespace, row) {
75
+ context.state.put(namespace, padId(row.Id), row);
76
+ invalidate(namespace);
77
+ },
78
+ remove(namespace, id) {
79
+ context.state.delete(namespace, padId(id));
80
+ invalidate(namespace);
81
+ },
82
+ emit(eventId, name, id, operation) {
83
+ context.events.emit(eventId, { realmId: company.realmId, name, id, operation, lastUpdated: clock.meta });
84
+ },
85
+ /** Next free id for an entity counter; skips ids already present (consumer-authored rows). */
86
+ nextId(key, namespace) {
87
+ const counters = context.state.get("meta", "counters") ?? {};
88
+ let next = Number.isInteger(counters[key]) && counters[key] >= 1 ? counters[key] : 1;
89
+ for (let guard = 0; context.state.get(namespace, padId(next)) !== null; guard += 1) {
90
+ if (guard >= bound) s.fail("STATE_BOUND_EXCEEDED", `No free ${key} id within the supported bound of ${bound} rows.`);
91
+ next += 1;
92
+ }
93
+ if (next > 9_999_999_999) s.fail("BUSINESS_VALIDATION", `The ${key} id space is exhausted.`);
94
+ context.state.put("meta", "counters", { ...defaultCounters(), ...counters, [key]: next + 1 });
95
+ return String(next);
96
+ },
97
+ setCounter(key, value) {
98
+ const counters = context.state.get("meta", "counters") ?? {};
99
+ context.state.put("meta", "counters", { ...defaultCounters(), ...counters, [key]: value });
100
+ },
101
+ counter(key) {
102
+ const counters = context.state.get("meta", "counters") ?? {};
103
+ return Number.isInteger(counters[key]) && counters[key] >= 1 ? counters[key] : 1;
104
+ },
105
+ /** Map invoice id → { cents, paymentIds[] } from non-voided payment lines. */
106
+ applied() {
107
+ if (!cache.has("#applied")) {
108
+ const map = new Map();
109
+ for (const payment of s.rows("payments")) {
110
+ for (const line of payment.Line) {
111
+ const invoiceId = line.LinkedTxn[0].TxnId;
112
+ const entry = map.get(invoiceId) ?? { cents: 0, paymentIds: [] };
113
+ entry.cents += toCents(line.Amount) ?? 0;
114
+ if (!entry.paymentIds.includes(payment.Id)) entry.paymentIds.push(payment.Id);
115
+ map.set(invoiceId, entry);
116
+ }
117
+ }
118
+ cache.set("#applied", map);
119
+ }
120
+ return cache.get("#applied");
121
+ },
122
+ /** Map customer id → open balance cents (Σ invoice Balance). */
123
+ balances() {
124
+ if (!cache.has("#balances")) {
125
+ const map = new Map();
126
+ for (const invoice of s.rows("invoices")) {
127
+ const id = invoice.CustomerRef.value;
128
+ map.set(id, (map.get(id) ?? 0) + (toCents(invoice.Balance) ?? 0));
129
+ }
130
+ cache.set("#balances", map);
131
+ }
132
+ return cache.get("#balances");
133
+ },
134
+ };
135
+ function invalidate(namespace) {
136
+ cache.delete(namespace);
137
+ if (namespace === "payments") cache.delete("#applied");
138
+ if (namespace === "invoices") cache.delete("#balances");
139
+ }
140
+ return s;
141
+ }
142
+
143
+ export function defaultCounters() {
144
+ return { customer: 1, item: 1, invoice: 1, payment: 1, docNumber: 1001 };
145
+ }
146
+
147
+ /** Load an entity row by caller id, or fail OBJECT_NOT_FOUND (ids are validated before any lookup). */
148
+ export function load(s, namespace, id, element = "Id") {
149
+ const row = s.get(namespace, id);
150
+ if (row === null) s.fail("OBJECT_NOT_FOUND", NOT_FOUND_DETAIL, element);
151
+ return row;
152
+ }
153
+
154
+ export function staleCheck(s, row, syncToken) {
155
+ if (syncToken !== row.SyncToken) {
156
+ s.fail(
157
+ "STALE_OBJECT",
158
+ "Stale Object Error : You and another user were working on this at the same time. The other user finished before you did, so your work was not saved.",
159
+ "SyncToken",
160
+ );
161
+ }
162
+ }
163
+
164
+ export function bumpToken(row) {
165
+ const n = Number(row.SyncToken);
166
+ return String(Number.isSafeInteger(n) ? n + 1 : 1);
167
+ }
@@ -0,0 +1,204 @@
1
+ // Shared pure helpers: QuickBooks fault codes, company-local time, ids, money in integer cents and bounded scans.
2
+ // No module state, no wall clock: every time value derives from context.clock.nowUs().
3
+
4
+ const FAULTS = new Map([
5
+ ["OBJECT_NOT_FOUND", ["610", "Object Not Found", "ValidationFault"]],
6
+ ["REQUIRED_PARAM_MISSING", ["2020", "Required param missing, need to supply the required value for the API", "ValidationFault"]],
7
+ ["INVALID_REFERENCE", ["2500", "Invalid Reference Id", "ValidationFault"]],
8
+ ["QUERY_PARSE_ERROR", ["4000", "Error parsing query", "ValidationFault"]],
9
+ ["QUERY_VALIDATION_ERROR", ["4001", "Invalid query", "ValidationFault"]],
10
+ ["STALE_OBJECT", ["5010", "Stale Object Error", "ValidationFault"]],
11
+ ["BUSINESS_VALIDATION", ["6000", "A business validation error has occurred while processing your request", "ValidationFault"]],
12
+ ["DUPLICATE_DOC_NUMBER", ["6140", "Duplicate Document Number Error", "ValidationFault"]],
13
+ ["DUPLICATE_NAME", ["6240", "Duplicate Name Exists Error", "ValidationFault"]],
14
+ ["AUTHENTICATION_FAILED", ["3200", "message=AuthenticationFailed; errorCode=003200; statusCode=401", "AuthenticationFault"]],
15
+ ["AUTHORIZATION_FAILED", ["3100", "message=ApplicationAuthorizationFailed; errorCode=003100; statusCode=403", "AuthorizationFault"]],
16
+ ["STATE_BOUND_EXCEEDED", ["10000", "An application error has occurred while processing your request", "SystemFault"]],
17
+ ["THROTTLE_EXCEEDED", ["3001", "message=ThrottleExceeded; errorCode=003001; statusCode=429", "SERVICE"]],
18
+ ["SERVICE_UNAVAILABLE", ["10000", "An application error has occurred while processing your request", "SystemFault"]],
19
+ ]);
20
+
21
+ export function faultInfo(code) {
22
+ return FAULTS.get(code);
23
+ }
24
+
25
+ export const DEFAULT_OFFSET_MINUTES = -420;
26
+ export const MAX_DETAIL = 2000;
27
+ export const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
28
+
29
+ export function clip(text, max = 200) {
30
+ const value = String(text);
31
+ return value.length > max ? `${value.slice(0, max)}…` : value;
32
+ }
33
+
34
+ /** UTF-8 length of a string, counted per code point (1, 2, 3 or 4 bytes; lone surrogates count as 3). */
35
+ export function utf8Bytes(text) {
36
+ let bytes = 0;
37
+ for (let i = 0; i < text.length; i += 1) {
38
+ const unit = text.charCodeAt(i);
39
+ if (unit < 0x80) bytes += 1;
40
+ else if (unit < 0x800) bytes += 2;
41
+ else if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < text.length) { const next = text.charCodeAt(i + 1); if (next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i += 1; } else bytes += 3; }
42
+ else bytes += 3;
43
+ }
44
+ return bytes;
45
+ }
46
+
47
+ const pad2 = (n) => String(n).padStart(2, "0");
48
+
49
+ function offsetText(minutes) {
50
+ const sign = minutes < 0 ? "-" : "+";
51
+ const abs = Math.abs(minutes);
52
+ return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
53
+ }
54
+
55
+ /** Company-local parts of an epoch-millisecond instant (fixed offset, host-independent). */
56
+ function localParts(ms, offsetMinutes) {
57
+ const d = new Date(ms + offsetMinutes * 60000);
58
+ return {
59
+ date: `${String(d.getUTCFullYear()).padStart(4, "0")}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`,
60
+ clock: `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`,
61
+ millis: String(d.getUTCMilliseconds()).padStart(3, "0"),
62
+ };
63
+ }
64
+
65
+ export function clockOf(context, offsetMinutes = DEFAULT_OFFSET_MINUTES) {
66
+ const ms = Math.floor(context.clock.nowUs() / 1000);
67
+ const parts = localParts(ms, offsetMinutes);
68
+ const zone = offsetText(offsetMinutes);
69
+ return {
70
+ ms,
71
+ date: parts.date,
72
+ meta: `${parts.date}T${parts.clock}${zone}`,
73
+ time: `${parts.date}T${parts.clock}.${parts.millis}${zone}`,
74
+ };
75
+ }
76
+
77
+ /** Raise a declared error with the QuickBooks fault fields the wire encoder copies. */
78
+ export function fail(context, code, detail, element = "", clock = clockOf(context)) {
79
+ const info = FAULTS.get(code);
80
+ return context.fail({
81
+ code,
82
+ message: info[1],
83
+ details: { qboCode: info[0], faultType: info[2], detail: clip(detail, MAX_DETAIL), element: clip(element, 200), time: clock.time },
84
+ });
85
+ }
86
+
87
+ export const ID_PATTERN = /^(0|[1-9][0-9]{0,9})$/;
88
+
89
+ export function isId(value) {
90
+ return typeof value === "string" && ID_PATTERN.test(value);
91
+ }
92
+
93
+ export function padId(id) {
94
+ return String(id).padStart(10, "0");
95
+ }
96
+
97
+ const DATE_PATTERN = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/;
98
+
99
+ /** Day number (days since 1970-01-01) of a strict YYYY-MM-DD calendar date, or null. */
100
+ export function dayNumber(text) {
101
+ if (typeof text !== "string") return null;
102
+ const match = DATE_PATTERN.exec(text);
103
+ if (match === null) return null;
104
+ const year = Number(match[1]);
105
+ const month = Number(match[2]);
106
+ const day = Number(match[3]);
107
+ if (year < 1900 || month < 1 || month > 12 || day < 1) return null;
108
+ const ms = Date.UTC(year, month - 1, day);
109
+ const check = new Date(ms);
110
+ if (check.getUTCDate() !== day || check.getUTCMonth() !== month - 1) return null;
111
+ return Math.round(ms / 86400000);
112
+ }
113
+
114
+ export function dateFromDay(dayNum) {
115
+ const d = new Date(dayNum * 86400000);
116
+ return `${String(d.getUTCFullYear()).padStart(4, "0")}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
117
+ }
118
+
119
+ const DATETIME_PATTERN = /^([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.([0-9]{1,6}))?)?(Z|[+-][0-9]{2}:[0-9]{2})?)?$/;
120
+
121
+ /** Epoch milliseconds of a date or ISO date-time; zone-less values use the company offset. Null when invalid. */
122
+ export function instantOf(text, offsetMinutes = DEFAULT_OFFSET_MINUTES) {
123
+ if (typeof text !== "string" || text.length > 40) return null;
124
+ const match = DATETIME_PATTERN.exec(text);
125
+ if (match === null) return null;
126
+ const day = dayNumber(match[1]);
127
+ if (day === null) return null;
128
+ const hours = match[2] === undefined ? 0 : Number(match[2]);
129
+ const minutes = match[3] === undefined ? 0 : Number(match[3]);
130
+ const seconds = match[4] === undefined ? 0 : Number(match[4]);
131
+ if (hours > 23 || minutes > 59 || seconds > 59) return null;
132
+ const millis = match[5] === undefined ? 0 : Math.floor(Number(`0.${match[5]}`) * 1000);
133
+ let zone = offsetMinutes;
134
+ if (match[6] === "Z") zone = 0;
135
+ else if (match[6] !== undefined) {
136
+ const zh = Number(match[6].slice(1, 3));
137
+ const zm = Number(match[6].slice(4, 6));
138
+ if (zh > 14 || zm > 59) return null;
139
+ zone = (match[6][0] === "-" ? -1 : 1) * (zh * 60 + zm);
140
+ }
141
+ return day * 86400000 + ((hours * 60 + minutes - zone) * 60 + seconds) * 1000 + millis;
142
+ }
143
+
144
+ /** Integer cents of a non-negative-or-any number with at most two decimals, or null. */
145
+ export function toCents(value, { allowNegative = false, max = 99_999_999_999 } = {}) {
146
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
147
+ const scaled = value * 100;
148
+ const cents = Math.round(scaled);
149
+ if (Math.abs(scaled - cents) > 1e-6) return null;
150
+ if (!allowNegative && cents < 0) return null;
151
+ if (Math.abs(cents) > max) return null;
152
+ return cents;
153
+ }
154
+
155
+ export function fromCents(cents) {
156
+ return cents / 100;
157
+ }
158
+
159
+ /** Value scaled by 10^4 when it has at most four decimals and lies within [0, max]; otherwise null. */
160
+ export function toScaled4(value, max) {
161
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > max) return null;
162
+ const scaled = value * 10000;
163
+ const rounded = Math.round(scaled);
164
+ return Math.abs(scaled - rounded) > 1e-4 ? null : rounded;
165
+ }
166
+
167
+ /** Cents of qty x unitPrice (both scaled by 10^4), rounded half up, without floating error. */
168
+ export function lineCents(qtyScaled, priceScaled) {
169
+ const product = BigInt(qtyScaled) * BigInt(priceScaled);
170
+ return Number((product + 500_000n) / 1_000_000n);
171
+ }
172
+
173
+ export const DEFAULT_SCAN_BOUND = 5000;
174
+
175
+ export function scanBound(context) {
176
+ const limits = context.state.get("meta", "limits");
177
+ const value = limits === null ? undefined : limits.maxScanRows;
178
+ return Number.isInteger(value) && value >= 1 && value <= 10000 ? value : DEFAULT_SCAN_BOUND;
179
+ }
180
+
181
+ /** Every row of a namespace in row-id order; fails STATE_BOUND_EXCEEDED instead of truncating. */
182
+ export function scanAll(context, namespace, bound, clock) {
183
+ const rows = [];
184
+ let after;
185
+ for (;;) {
186
+ const page = context.state.scan(namespace, after === undefined ? { limit: 1000 } : { afterRowId: after, limit: 1000 });
187
+ for (const record of page) {
188
+ rows.push(record.value);
189
+ if (rows.length > bound) {
190
+ return fail(context, "STATE_BOUND_EXCEEDED", `State exceeds the supported bound of ${bound} rows for ${namespace}.`, "", clock);
191
+ }
192
+ }
193
+ if (page.length < 1000) return rows;
194
+ after = page[page.length - 1].rowId;
195
+ }
196
+ }
197
+
198
+ export const TERMS = new Map([
199
+ ["1", { name: "Due on receipt", days: 0 }],
200
+ ["2", { name: "Net 15", days: 15 }],
201
+ ["3", { name: "Net 30", days: 30 }],
202
+ ]);
203
+
204
+ export const USD = Object.freeze({ value: "USD", name: "United States Dollar" });