@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,107 @@
|
|
|
1
|
+
// MCP search criteria → query AST. Accepted forms: a simple object of equalities, an array of
|
|
2
|
+
// { field, value, operator? } (or legacy { key, value }), or { filters | criteria: [...], asc, desc, limit, offset,
|
|
3
|
+
// count, fetchAll }. Option names inside the array form are options, not conditions. Values never become query text.
|
|
4
|
+
import { dayNumber } from "./common.mjs";
|
|
5
|
+
import { isObject, own } from "./fields.mjs";
|
|
6
|
+
|
|
7
|
+
const OPTIONS = new Set(["limit", "offset", "asc", "desc", "count", "fetchAll"]);
|
|
8
|
+
const OPERATORS = new Map([["=", "="], ["<", "<"], [">", ">"], ["<=", "<="], [">=", ">="], ["like", "like"], ["in", "in"]]);
|
|
9
|
+
|
|
10
|
+
function bad(s, detail) {
|
|
11
|
+
return s.fail("QUERY_VALIDATION_ERROR", `QueryValidationError: ${detail}`, "criteria");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function literal(s, field, value) {
|
|
15
|
+
if (typeof value === "string") return { kind: "string", value };
|
|
16
|
+
if (typeof value === "number" && Number.isFinite(value)) return { kind: "number", value: String(value) };
|
|
17
|
+
if (typeof value === "boolean") return { kind: "bool", value };
|
|
18
|
+
return bad(s, `value for '${String(field).slice(0, 64)}' must be a string, number or boolean`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function condition(s, field, value, operator) {
|
|
22
|
+
if (typeof field !== "string" || field.length === 0 || field.length > 64) return bad(s, "each criterion needs a field name");
|
|
23
|
+
const op = operator === undefined || operator === null ? (Array.isArray(value) ? "in" : "=") : typeof operator === "string" ? OPERATORS.get(operator.toLowerCase()) : undefined;
|
|
24
|
+
if (op === undefined) return bad(s, `operator ${JSON.stringify(String(operator)).slice(0, 40)} is not supported`);
|
|
25
|
+
if (op === "in") {
|
|
26
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) return bad(s, `IN for '${field}' needs 1 to 100 values`);
|
|
27
|
+
return { prop: field, op, values: value.map((item) => literal(s, field, item)) };
|
|
28
|
+
}
|
|
29
|
+
return { prop: field, op, values: [literal(s, field, value)] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function applyOption(s, options, name, value) {
|
|
33
|
+
if (name === "limit") {
|
|
34
|
+
if (!Number.isInteger(value) || value < 1 || value > 1000) bad(s, "limit must be an integer between 1 and 1000");
|
|
35
|
+
options.limit = value;
|
|
36
|
+
} else if (name === "offset") {
|
|
37
|
+
if (!Number.isInteger(value) || value < 0 || value > 1_000_000) bad(s, "offset must be a non-negative integer");
|
|
38
|
+
options.offset = value;
|
|
39
|
+
} else if (name === "asc" || name === "desc") {
|
|
40
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 64) bad(s, `${name} must name a sortable property`);
|
|
41
|
+
options.order.push({ prop: value, dir: name });
|
|
42
|
+
if (options.order.length > 3) bad(s, "at most 3 sort keys are supported");
|
|
43
|
+
} else {
|
|
44
|
+
if (typeof value !== "boolean") bad(s, `${name} must be true or false`);
|
|
45
|
+
options[name] = value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fromArray(s, entries, options, where) {
|
|
50
|
+
if (entries.length > 50) bad(s, "at most 50 criteria entries are supported");
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!isObject(entry)) bad(s, "each criterion must be an object");
|
|
53
|
+
const field = own(entry, "field") ?? own(entry, "key");
|
|
54
|
+
const value = own(entry, "value");
|
|
55
|
+
if (typeof field === "string" && OPTIONS.has(field)) applyOption(s, options, field, value);
|
|
56
|
+
else where.push(condition(s, field, value, own(entry, "operator")));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Compile criteria plus optional top-level options (search_customers) into { ast, all, count }. */
|
|
61
|
+
export function compileCriteria(s, entity, criteria, topLevel = {}) {
|
|
62
|
+
const options = { limit: undefined, offset: undefined, order: [], count: false, fetchAll: false };
|
|
63
|
+
const where = [];
|
|
64
|
+
if (Array.isArray(criteria)) fromArray(s, criteria, options, where);
|
|
65
|
+
else if (isObject(criteria)) {
|
|
66
|
+
const list = own(criteria, "filters") ?? own(criteria, "criteria");
|
|
67
|
+
if (Array.isArray(list)) {
|
|
68
|
+
fromArray(s, list, options, where);
|
|
69
|
+
for (const name of OPTIONS) if (own(criteria, name) !== undefined) applyOption(s, options, name, criteria[name]);
|
|
70
|
+
} else {
|
|
71
|
+
const keys = Object.keys(criteria);
|
|
72
|
+
if (keys.length > 50) bad(s, "at most 50 criteria entries are supported");
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
if (key === "filters" || key === "criteria") bad(s, `${key} must be an array`);
|
|
75
|
+
if (OPTIONS.has(key)) applyOption(s, options, key, criteria[key]);
|
|
76
|
+
else where.push(condition(s, key, criteria[key], undefined));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else if (criteria !== undefined && criteria !== null) bad(s, "criteria must be an object or an array");
|
|
80
|
+
for (const name of OPTIONS) if (topLevel[name] !== undefined) applyOption(s, options, name, topLevel[name]);
|
|
81
|
+
if (where.length > 20) bad(s, "at most 20 conditions are supported");
|
|
82
|
+
const ast = {
|
|
83
|
+
entity,
|
|
84
|
+
select: { kind: options.count ? "count" : "all" },
|
|
85
|
+
where,
|
|
86
|
+
order: options.order,
|
|
87
|
+
start: (options.offset ?? 0) + 1,
|
|
88
|
+
max: options.limit ?? 100,
|
|
89
|
+
};
|
|
90
|
+
return { ast, all: options.fetchAll, count: options.count };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** search_payments { customer_ref, txn_date_from, txn_date_to, limit } → AST (dates inclusive). */
|
|
94
|
+
export function paymentSearchAst(s, input) {
|
|
95
|
+
const where = [];
|
|
96
|
+
if (input.customer_ref !== undefined) where.push({ prop: "CustomerRef", op: "=", values: [{ kind: "string", value: input.customer_ref }] });
|
|
97
|
+
const from = input.txn_date_from === undefined ? null : dayNumber(input.txn_date_from);
|
|
98
|
+
const to = input.txn_date_to === undefined ? null : dayNumber(input.txn_date_to);
|
|
99
|
+
if (input.txn_date_from !== undefined && from === null) bad(s, "txn_date_from must be a valid YYYY-MM-DD date");
|
|
100
|
+
if (input.txn_date_to !== undefined && to === null) bad(s, "txn_date_to must be a valid YYYY-MM-DD date");
|
|
101
|
+
if (from !== null && to !== null && from > to) bad(s, "txn_date_from is after txn_date_to");
|
|
102
|
+
if (from !== null) where.push({ prop: "TxnDate", op: ">=", values: [{ kind: "string", value: input.txn_date_from }] });
|
|
103
|
+
if (to !== null) where.push({ prop: "TxnDate", op: "<=", values: [{ kind: "string", value: input.txn_date_to }] });
|
|
104
|
+
const limit = input.limit ?? 100;
|
|
105
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) bad(s, "limit must be an integer between 1 and 1000");
|
|
106
|
+
return { entity: "Payment", select: { kind: "all" }, where, order: [{ prop: "TxnDate", dir: "desc" }], start: 1, max: limit };
|
|
107
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Customer entity: rendering (computed Balance) and create / full update / sparse update / make inactive.
|
|
2
|
+
import { bumpToken, load, staleCheck } from "./access.mjs";
|
|
3
|
+
import { TERMS, USD, fromCents, toCents } from "./common.mjs";
|
|
4
|
+
import { checkKeys, has, isObject, optAddr, optBool, optEmail, optEnum, optPhone, optRef, optString, own } from "./fields.mjs";
|
|
5
|
+
|
|
6
|
+
const TEXT_FIELDS = [["DisplayName", 500], ["Title", 16], ["GivenName", 100], ["MiddleName", 100], ["FamilyName", 100], ["Suffix", 16], ["CompanyName", 100], ["PrintOnCheckName", 110], ["Notes", 2000]];
|
|
7
|
+
const NULLABLE = ["Title", "GivenName", "MiddleName", "FamilyName", "Suffix", "CompanyName", "PrimaryEmailAddr", "PrimaryPhone", "Mobile", "BillAddr", "ShipAddr", "Notes", "SalesTermRef", "PaymentMethodRef"];
|
|
8
|
+
const WRITABLE = new Set([...TEXT_FIELDS.map(([name]) => name), "PrimaryEmailAddr", "PrimaryPhone", "Mobile", "BillAddr", "ShipAddr", "Taxable", "PreferredDeliveryMethod", "SalesTermRef", "PaymentMethodRef", "Active"]);
|
|
9
|
+
const READONLY = new Set(["Id", "SyncToken", "sparse", "domain", "MetaData", "FullyQualifiedName", "Balance", "BalanceWithJobs", "Job", "BillWithParent", "CurrencyRef"]);
|
|
10
|
+
|
|
11
|
+
export function renderCustomer(s, row) {
|
|
12
|
+
const balance = fromCents(s.balances().get(row.Id) ?? 0);
|
|
13
|
+
const out = {
|
|
14
|
+
Taxable: row.Taxable,
|
|
15
|
+
Job: false,
|
|
16
|
+
BillWithParent: false,
|
|
17
|
+
Balance: balance,
|
|
18
|
+
BalanceWithJobs: balance,
|
|
19
|
+
CurrencyRef: USD,
|
|
20
|
+
PreferredDeliveryMethod: row.PreferredDeliveryMethod,
|
|
21
|
+
domain: "QBO",
|
|
22
|
+
sparse: false,
|
|
23
|
+
Id: row.Id,
|
|
24
|
+
SyncToken: row.SyncToken,
|
|
25
|
+
MetaData: row.MetaData,
|
|
26
|
+
FullyQualifiedName: row.DisplayName,
|
|
27
|
+
DisplayName: row.DisplayName,
|
|
28
|
+
PrintOnCheckName: row.PrintOnCheckName ?? row.DisplayName,
|
|
29
|
+
Active: row.Active,
|
|
30
|
+
};
|
|
31
|
+
for (const key of NULLABLE) if (row[key] !== null) out[key] = row[key];
|
|
32
|
+
if (row.SalesTermRef !== null) out.SalesTermRef = { value: row.SalesTermRef.value, name: TERMS.get(row.SalesTermRef.value)?.name ?? row.SalesTermRef.name };
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function blank() {
|
|
37
|
+
const row = { Id: "", SyncToken: "0", DisplayName: null, PrintOnCheckName: null, Taxable: false, PreferredDeliveryMethod: "Print", Active: true, MetaData: null };
|
|
38
|
+
for (const key of NULLABLE) row[key] = null;
|
|
39
|
+
return row;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Id + SyncToken of an update body, loaded and stale-checked. */
|
|
43
|
+
export function updateTarget(s, namespace, body, element) {
|
|
44
|
+
if (!has(body, "Id") || body.Id === null) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Id", "Id");
|
|
45
|
+
if (!has(body, "SyncToken") || body.SyncToken === null) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: SyncToken", "SyncToken");
|
|
46
|
+
const id = typeof body.Id === "string" || typeof body.Id === "number" ? String(body.Id) : "";
|
|
47
|
+
const row = load(s, namespace, id, element);
|
|
48
|
+
staleCheck(s, row, typeof body.SyncToken === "string" || typeof body.SyncToken === "number" ? String(body.SyncToken) : null);
|
|
49
|
+
return row;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Create (no Id) or update (Id + SyncToken). `intent` "create" refuses an Id, "update" requires one. */
|
|
53
|
+
export function saveCustomer(s, body, intent) {
|
|
54
|
+
if (!isObject(body)) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Customer object", "Customer");
|
|
55
|
+
checkKeys(s, body, WRITABLE, READONLY);
|
|
56
|
+
const hasId = has(body, "Id") && body.Id !== null;
|
|
57
|
+
if (intent === "create" && hasId) s.fail("BUSINESS_VALIDATION", "Id is assigned by QuickBooks and cannot be supplied when creating a customer.", "Id");
|
|
58
|
+
const current = intent === "update" || hasId ? updateTarget(s, "customers", body, "Id") : null;
|
|
59
|
+
if (has(body, "sparse") && typeof body.sparse !== "boolean") s.fail("BUSINESS_VALIDATION", "Invalid value for sparse: expected true or false", "sparse");
|
|
60
|
+
const sparse = current !== null && body.sparse === true;
|
|
61
|
+
const next = { ...(current ?? blank()) };
|
|
62
|
+
const assign = (key, value, reset) => {
|
|
63
|
+
if (value !== undefined) next[key] = value;
|
|
64
|
+
else if (current !== null && !sparse) next[key] = reset;
|
|
65
|
+
};
|
|
66
|
+
for (const [key, max] of TEXT_FIELDS) assign(key, optString(s, body, key, max), null);
|
|
67
|
+
assign("PrimaryEmailAddr", optEmail(s, body, "PrimaryEmailAddr"), null);
|
|
68
|
+
assign("PrimaryPhone", optPhone(s, body, "PrimaryPhone"), null);
|
|
69
|
+
assign("Mobile", optPhone(s, body, "Mobile"), null);
|
|
70
|
+
assign("BillAddr", optAddr(s, body, "BillAddr"), null);
|
|
71
|
+
assign("ShipAddr", optAddr(s, body, "ShipAddr"), null);
|
|
72
|
+
assign("Taxable", optBool(s, body, "Taxable"), false);
|
|
73
|
+
assign("PreferredDeliveryMethod", optEnum(s, body, "PreferredDeliveryMethod", ["Print", "Email", "None"]), "Print");
|
|
74
|
+
assign("Active", optBool(s, body, "Active"), true);
|
|
75
|
+
const term = optRef(s, body, "SalesTermRef");
|
|
76
|
+
if (typeof term === "string" && !TERMS.has(term)) s.fail("INVALID_REFERENCE", `Invalid Reference Id : Term assigned to this customer does not exist: ${term}`, "SalesTermRef");
|
|
77
|
+
assign("SalesTermRef", typeof term === "string" ? { value: term, name: TERMS.get(term).name } : term, null);
|
|
78
|
+
const method = optRef(s, body, "PaymentMethodRef");
|
|
79
|
+
const methodName = own(body.PaymentMethodRef, "name");
|
|
80
|
+
assign("PaymentMethodRef", typeof method === "string" ? { value: method, name: typeof methodName === "string" ? methodName.slice(0, 100) : null } : method, null);
|
|
81
|
+
const currency = own(body, "CurrencyRef");
|
|
82
|
+
if (currency !== undefined && currency !== null && own(currency, "value") !== "USD") {
|
|
83
|
+
s.fail("BUSINESS_VALIDATION", "Multicurrency is not enabled for this company: CurrencyRef must be USD.", "CurrencyRef");
|
|
84
|
+
}
|
|
85
|
+
let display = typeof next.DisplayName === "string" ? next.DisplayName.trim() : "";
|
|
86
|
+
if (display.length === 0) {
|
|
87
|
+
display = ["Title", "GivenName", "MiddleName", "FamilyName", "Suffix"].map((key) => next[key]).filter((part) => typeof part === "string" && part.trim().length > 0).map((part) => part.trim()).join(" ");
|
|
88
|
+
if (display.length === 0 && typeof next.CompanyName === "string") display = next.CompanyName.trim();
|
|
89
|
+
}
|
|
90
|
+
if (display.length === 0) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: DisplayName or a name part", "DisplayName");
|
|
91
|
+
if (display.includes(":")) s.fail("BUSINESS_VALIDATION", "Invalid character in name : A name can't contain a colon (:).", "DisplayName");
|
|
92
|
+
next.DisplayName = display.slice(0, 500);
|
|
93
|
+
const fold = next.DisplayName.toLowerCase();
|
|
94
|
+
for (const other of s.rows("customers")) {
|
|
95
|
+
if (other.Id !== next.Id && other.DisplayName.trim().toLowerCase() === fold) {
|
|
96
|
+
s.fail("DUPLICATE_NAME", "The name supplied already exists. : Another customer is already using this name. Please use a different name.", "DisplayName");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const openCents = current === null ? 0 : (s.balances().get(current.Id) ?? 0);
|
|
100
|
+
if (has(body, "Balance") && toCents(body.Balance, { allowNegative: true }) !== openCents) {
|
|
101
|
+
s.fail("BUSINESS_VALIDATION", "Balance is read-only: it is computed from open invoices and cannot be set on a customer.", "Balance");
|
|
102
|
+
}
|
|
103
|
+
if (current !== null && current.Active && next.Active === false && openCents > 0) {
|
|
104
|
+
s.fail("BUSINESS_VALIDATION", "This customer has an open balance. Receive payment or void the open invoices before making the customer inactive.", "Active");
|
|
105
|
+
}
|
|
106
|
+
return commitCustomer(s, current, next);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function commitCustomer(s, current, next) {
|
|
110
|
+
if (current === null) {
|
|
111
|
+
next.Id = s.nextId("customer", "customers");
|
|
112
|
+
next.SyncToken = "0";
|
|
113
|
+
next.MetaData = { CreateTime: s.clock.meta, LastUpdatedTime: s.clock.meta };
|
|
114
|
+
} else {
|
|
115
|
+
next.SyncToken = bumpToken(current);
|
|
116
|
+
next.MetaData = { CreateTime: current.MetaData.CreateTime, LastUpdatedTime: s.clock.meta };
|
|
117
|
+
}
|
|
118
|
+
s.put("customers", next);
|
|
119
|
+
s.emit("customer.changed", "Customer", next.Id, current === null ? "Create" : "Update");
|
|
120
|
+
return { row: next, action: current === null ? "create" : "update" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** delete_customer: QuickBooks has no customer delete, so this makes the customer inactive. */
|
|
124
|
+
export function deactivateCustomer(s, idOrEntity) {
|
|
125
|
+
let id = "";
|
|
126
|
+
let token;
|
|
127
|
+
if (typeof idOrEntity === "string") id = idOrEntity;
|
|
128
|
+
else if (isObject(idOrEntity)) {
|
|
129
|
+
const rawId = own(idOrEntity, "Id");
|
|
130
|
+
id = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : "";
|
|
131
|
+
const rawToken = own(idOrEntity, "SyncToken");
|
|
132
|
+
if (rawToken !== undefined) token = typeof rawToken === "string" || typeof rawToken === "number" ? String(rawToken) : null;
|
|
133
|
+
}
|
|
134
|
+
const current = load(s, "customers", id);
|
|
135
|
+
if (token !== undefined) staleCheck(s, current, token);
|
|
136
|
+
if ((s.balances().get(current.Id) ?? 0) > 0) {
|
|
137
|
+
s.fail("BUSINESS_VALIDATION", "This customer has an open balance. Receive payment or void the open invoices before making the customer inactive.", "Active");
|
|
138
|
+
}
|
|
139
|
+
return commitCustomer(s, current, { ...current, Active: false });
|
|
140
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Validation of caller-supplied QuickBooks entity JSON. Every helper reads own properties only and answers the
|
|
2
|
+
// provider's business-validation fault for a wrong shape, so no caller value can crash a handler.
|
|
3
|
+
import { RESERVED_KEYS, clip, dayNumber, isId, toCents } from "./common.mjs";
|
|
4
|
+
|
|
5
|
+
export function isObject(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function has(object, key) {
|
|
10
|
+
return isObject(object) && Object.hasOwn(object, key);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function own(object, key) {
|
|
14
|
+
return has(object, key) ? object[key] : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Refuse unknown or reserved top-level properties. `readonly` names are accepted and ignored. */
|
|
18
|
+
export function checkKeys(s, body, writable, readonly) {
|
|
19
|
+
for (const key of Object.keys(body)) {
|
|
20
|
+
if (RESERVED_KEYS.has(key) || (!writable.has(key) && !readonly.has(key))) {
|
|
21
|
+
s.fail("BUSINESS_VALIDATION", `Request has invalid or unsupported property : Property Name:${clip(key, 80)} specified is unsupported or invalid`, clip(key, 80));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function invalid(s, key, what) {
|
|
27
|
+
return s.fail("BUSINESS_VALIDATION", `Invalid value for ${key}: ${what}`, key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** undefined when absent, null when null or empty, otherwise a string no longer than max. */
|
|
31
|
+
export function optString(s, body, key, max) {
|
|
32
|
+
if (!has(body, key)) return undefined;
|
|
33
|
+
const value = body[key];
|
|
34
|
+
if (value === null) return null;
|
|
35
|
+
if (typeof value !== "string") return invalid(s, key, "expected a string");
|
|
36
|
+
if (value.length > max) return s.fail("BUSINESS_VALIDATION", `String length is either shorter or longer than supported by specification. Min:0 Max:${max} supported. Supplied length:${value.length}`, key);
|
|
37
|
+
return value.length === 0 ? null : value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function optBool(s, body, key) {
|
|
41
|
+
if (!has(body, key)) return undefined;
|
|
42
|
+
const value = body[key];
|
|
43
|
+
if (typeof value !== "boolean") return invalid(s, key, "expected true or false");
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function optEnum(s, body, key, allowed) {
|
|
48
|
+
if (!has(body, key)) return undefined;
|
|
49
|
+
const value = body[key];
|
|
50
|
+
if (typeof value !== "string" || !allowed.includes(value)) return invalid(s, key, `expected one of ${allowed.join(", ")}`);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function optDate(s, body, key) {
|
|
55
|
+
if (!has(body, key)) return undefined;
|
|
56
|
+
const value = body[key];
|
|
57
|
+
if (value === null) return null;
|
|
58
|
+
if (dayNumber(value) === null) return invalid(s, key, "expected a date formatted YYYY-MM-DD");
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reference `{ value, name? }`; returns undefined / null / the id string. */
|
|
63
|
+
export function optRef(s, body, key) {
|
|
64
|
+
if (!has(body, key)) return undefined;
|
|
65
|
+
const value = body[key];
|
|
66
|
+
if (value === null) return null;
|
|
67
|
+
if (!isObject(value)) return invalid(s, key, "expected an object with a value");
|
|
68
|
+
for (const inner of Object.keys(value)) {
|
|
69
|
+
if (inner !== "value" && inner !== "name" && inner !== "type") return invalid(s, key, `unsupported property ${clip(inner, 40)}`);
|
|
70
|
+
}
|
|
71
|
+
const id = value.value;
|
|
72
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 64) return invalid(s, key, "value must be a non-empty string");
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validEmail(text) {
|
|
77
|
+
if (typeof text !== "string" || text.length < 3 || text.length > 100) return false;
|
|
78
|
+
const at = text.indexOf("@");
|
|
79
|
+
if (at <= 0 || at !== text.lastIndexOf("@") || at === text.length - 1) return false;
|
|
80
|
+
for (const char of text) {
|
|
81
|
+
const code = char.charCodeAt(0);
|
|
82
|
+
if (code <= 32 || char === "," || char === ";" || char === "<" || char === ">" || char === "\"") return false;
|
|
83
|
+
}
|
|
84
|
+
const domain = text.slice(at + 1);
|
|
85
|
+
return domain.includes(".") && !domain.startsWith(".") && !domain.endsWith(".");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function optEmail(s, body, key) {
|
|
89
|
+
if (!has(body, key)) return undefined;
|
|
90
|
+
const value = body[key];
|
|
91
|
+
if (value === null) return null;
|
|
92
|
+
if (!isObject(value) || Object.keys(value).some((inner) => inner !== "Address")) return invalid(s, key, "expected { Address }");
|
|
93
|
+
if (value.Address === null || value.Address === undefined || value.Address === "") return null;
|
|
94
|
+
if (!validEmail(value.Address)) return s.fail("BUSINESS_VALIDATION", "Invalid Email Address format : Please enter a valid email address.", `${key}.Address`);
|
|
95
|
+
return { Address: value.Address };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function optPhone(s, body, key) {
|
|
99
|
+
if (!has(body, key)) return undefined;
|
|
100
|
+
const value = body[key];
|
|
101
|
+
if (value === null) return null;
|
|
102
|
+
if (!isObject(value) || Object.keys(value).some((inner) => inner !== "FreeFormNumber")) return invalid(s, key, "expected { FreeFormNumber }");
|
|
103
|
+
const number = value.FreeFormNumber;
|
|
104
|
+
if (number === null || number === undefined || number === "") return null;
|
|
105
|
+
if (typeof number !== "string" || number.length > 30) return invalid(s, `${key}.FreeFormNumber`, "expected a string of at most 30 characters");
|
|
106
|
+
return { FreeFormNumber: number };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ADDR_FIELDS = [["Line1", 500], ["Line2", 500], ["City", 255], ["CountrySubDivisionCode", 255], ["PostalCode", 30], ["Country", 255]];
|
|
110
|
+
|
|
111
|
+
export function optAddr(s, body, key) {
|
|
112
|
+
if (!has(body, key)) return undefined;
|
|
113
|
+
const value = body[key];
|
|
114
|
+
if (value === null) return null;
|
|
115
|
+
if (!isObject(value)) return invalid(s, key, "expected an address object");
|
|
116
|
+
const out = { Id: null, Line1: null, Line2: null, City: null, CountrySubDivisionCode: null, PostalCode: null, Country: null };
|
|
117
|
+
for (const inner of Object.keys(value)) {
|
|
118
|
+
if (inner === "Id") {
|
|
119
|
+
if (value.Id !== null && !isId(value.Id)) return invalid(s, `${key}.Id`, "expected a numeric id");
|
|
120
|
+
out.Id = value.Id;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const spec = ADDR_FIELDS.find(([name]) => name === inner);
|
|
124
|
+
if (spec === undefined) return invalid(s, key, `unsupported property ${clip(inner, 40)}`);
|
|
125
|
+
const text = optString(s, value, inner, spec[1]);
|
|
126
|
+
out[inner] = text === undefined ? null : text;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Money: number with at most two decimals. */
|
|
132
|
+
export function optMoney(s, body, key, { allowNegative = false } = {}) {
|
|
133
|
+
if (!has(body, key)) return undefined;
|
|
134
|
+
const cents = toCents(body[key], { allowNegative });
|
|
135
|
+
if (cents === null) return invalid(s, key, "expected an amount with at most two decimals");
|
|
136
|
+
return cents;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Deep-check a caller object for reserved keys (iterative; bounded by the framework body size). */
|
|
140
|
+
export function reservedKeyIn(value) {
|
|
141
|
+
const stack = [value];
|
|
142
|
+
let visited = 0;
|
|
143
|
+
while (stack.length > 0) {
|
|
144
|
+
const current = stack.pop();
|
|
145
|
+
if (++visited > 100_000) return "the request body has too many values";
|
|
146
|
+
if (Array.isArray(current)) {
|
|
147
|
+
for (const item of current) if (item !== null && typeof item === "object") stack.push(item);
|
|
148
|
+
} else if (current !== null && typeof current === "object") {
|
|
149
|
+
for (const key of Object.keys(current)) {
|
|
150
|
+
if (RESERVED_KEYS.has(key)) return `the property name ${key} is not allowed`;
|
|
151
|
+
const inner = current[key];
|
|
152
|
+
if (inner !== null && typeof inner === "object") stack.push(inner);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Read operations: company info, the query endpoint, entity reads, MCP-shaped searches and the invoice PDF.
|
|
2
|
+
import { NOT_FOUND_DETAIL, load, open } from "./access.mjs";
|
|
3
|
+
import { compileCriteria, paymentSearchAst } from "./criteria.mjs";
|
|
4
|
+
import { renderCustomer } from "./customers.mjs";
|
|
5
|
+
import { renderInvoice } from "./invoices.mjs";
|
|
6
|
+
import { renderItem } from "./items.mjs";
|
|
7
|
+
import { base64FromBinary, invoicePdf } from "./pdf.mjs";
|
|
8
|
+
import { renderPayment } from "./payments.mjs";
|
|
9
|
+
import { parseQuery } from "./query-parse.mjs";
|
|
10
|
+
import { evaluate } from "./query-eval.mjs";
|
|
11
|
+
import { renderAccount, renderCompany } from "./views.mjs";
|
|
12
|
+
|
|
13
|
+
function search(s, entity, criteria, topLevel) {
|
|
14
|
+
const { ast, all, count } = compileCriteria(s, entity, criteria, topLevel);
|
|
15
|
+
const { entities, total } = evaluate(s, ast, { all });
|
|
16
|
+
return count ? { results: [], totalCount: total } : { results: entities };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const readOperations = {
|
|
20
|
+
"company-info.get": (input, context) => {
|
|
21
|
+
const s = open(context, input, "company.read");
|
|
22
|
+
if (input.company_id !== undefined && input.company_id !== s.company.Id && input.company_id !== s.realmId) {
|
|
23
|
+
s.fail("OBJECT_NOT_FOUND", NOT_FOUND_DETAIL, "Id");
|
|
24
|
+
}
|
|
25
|
+
return { CompanyInfo: renderCompany(s.company), time: s.clock.time };
|
|
26
|
+
},
|
|
27
|
+
"query.run": (input, context) => {
|
|
28
|
+
const s = open(context, input, "query");
|
|
29
|
+
const ast = parseQuery(input.query, (detail) => s.fail("QUERY_PARSE_ERROR", detail, "query"));
|
|
30
|
+
const { meta, entities, total } = evaluate(s, ast);
|
|
31
|
+
let response;
|
|
32
|
+
if (ast.select.kind === "count") response = { totalCount: total };
|
|
33
|
+
else if (entities.length === 0) response = {};
|
|
34
|
+
else {
|
|
35
|
+
response = { startPosition: ast.start, maxResults: entities.length };
|
|
36
|
+
response[meta.name] = entities;
|
|
37
|
+
}
|
|
38
|
+
return { QueryResponse: response, time: s.clock.time };
|
|
39
|
+
},
|
|
40
|
+
"customers.get": (input, context) => {
|
|
41
|
+
const s = open(context, input, "customers.read");
|
|
42
|
+
return { Customer: renderCustomer(s, load(s, "customers", input.id)), time: s.clock.time };
|
|
43
|
+
},
|
|
44
|
+
"customers.search": (input, context) => {
|
|
45
|
+
const s = open(context, input, "customers.read");
|
|
46
|
+
const topLevel = { limit: input.limit, offset: input.offset, asc: input.asc, desc: input.desc, fetchAll: input.fetchAll, count: input.count };
|
|
47
|
+
return search(s, "Customer", input.criteria, topLevel);
|
|
48
|
+
},
|
|
49
|
+
"items.get": (input, context) => {
|
|
50
|
+
const s = open(context, input, "items.read");
|
|
51
|
+
return { Item: renderItem(s, load(s, "items", input.item_id, "item_id")), time: s.clock.time };
|
|
52
|
+
},
|
|
53
|
+
"items.search": (input, context) => search(open(context, input, "items.read"), "Item", input.criteria, {}),
|
|
54
|
+
"invoices.get": (input, context) => {
|
|
55
|
+
const s = open(context, input, "invoices.read");
|
|
56
|
+
return { Invoice: renderInvoice(s, load(s, "invoices", input.invoice_id, "invoice_id")), time: s.clock.time };
|
|
57
|
+
},
|
|
58
|
+
"invoices.search": (input, context) => search(open(context, input, "invoices.read"), "Invoice", input.criteria, {}),
|
|
59
|
+
"invoices.pdf": (input, context) => {
|
|
60
|
+
const s = open(context, input, "invoices.read");
|
|
61
|
+
const row = load(s, "invoices", input.invoice_id, "invoice_id");
|
|
62
|
+
if (input.output_path !== undefined) {
|
|
63
|
+
s.fail("BUSINESS_VALIDATION", "output_path is not supported: a Firedrill Tool cannot write files; use the returned base64 content.", "output_path");
|
|
64
|
+
}
|
|
65
|
+
const file = invoicePdf(s.company, renderInvoice(s, row));
|
|
66
|
+
return { invoice_id: row.Id, content_type: "application/pdf", size_bytes: file.length, base64: base64FromBinary(file) };
|
|
67
|
+
},
|
|
68
|
+
"payments.get": (input, context) => {
|
|
69
|
+
const s = open(context, input, "payments.read");
|
|
70
|
+
return { Payment: renderPayment(s, load(s, "payments", input.id)), time: s.clock.time };
|
|
71
|
+
},
|
|
72
|
+
"payments.search": (input, context) => {
|
|
73
|
+
const s = open(context, input, "payments.read");
|
|
74
|
+
return { results: evaluate(s, paymentSearchAst(s, input)).entities };
|
|
75
|
+
},
|
|
76
|
+
"accounts.get": (input, context) => {
|
|
77
|
+
const s = open(context, input, "accounts.read");
|
|
78
|
+
return { Account: renderAccount(s, load(s, "accounts", input.id)), time: s.clock.time };
|
|
79
|
+
},
|
|
80
|
+
"accounts.search": (input, context) => search(open(context, input, "accounts.read"), "Account", input.criteria, {}),
|
|
81
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Write operations: REST dispatchers (`*.post`) and the MCP-shaped create / update / delete / send variants.
|
|
2
|
+
import { open } from "./access.mjs";
|
|
3
|
+
import { clip } from "./common.mjs";
|
|
4
|
+
import { deactivateCustomer, renderCustomer, saveCustomer } from "./customers.mjs";
|
|
5
|
+
import { has, reservedKeyIn } from "./fields.mjs";
|
|
6
|
+
import { deleteInvoice, entityRef, invoiceBodyFromMcp, invoicesPost, renderInvoice, saveInvoice, sendInvoice, updateInvoiceFromMcp } from "./invoices.mjs";
|
|
7
|
+
import { renderItem, saveItem } from "./items.mjs";
|
|
8
|
+
import { paymentBodyFromMcp, paymentsPost, renderPayment, savePayment } from "./payments.mjs";
|
|
9
|
+
|
|
10
|
+
function guardBody(s, value) {
|
|
11
|
+
const problem = reservedKeyIn(value);
|
|
12
|
+
if (problem !== null) s.fail("BUSINESS_VALIDATION", `Request has invalid or unsupported property : ${problem}`, "body");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function onlyUpdate(s, input, entity) {
|
|
16
|
+
if (input.operation !== undefined && input.operation !== "update") {
|
|
17
|
+
s.fail("BUSINESS_VALIDATION", `Operation ${clip(String(input.operation), 32)} is not supported for ${entity}.`, "operation");
|
|
18
|
+
}
|
|
19
|
+
if (input.operation === "update" && !(has(input.body, "Id") && input.body.Id !== null)) {
|
|
20
|
+
s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Id", "Id");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const deleted = (row) => ({ Id: row.Id, status: "Deleted", domain: "QBO" });
|
|
25
|
+
|
|
26
|
+
export const writeOperations = {
|
|
27
|
+
"customers.post": (input, context) => {
|
|
28
|
+
const s = open(context, input, "customers.write");
|
|
29
|
+
guardBody(s, input.body);
|
|
30
|
+
onlyUpdate(s, input, "Customer");
|
|
31
|
+
const { row, action } = saveCustomer(s, input.body, input.operation === "update" ? "update" : undefined);
|
|
32
|
+
return { Customer: renderCustomer(s, row), action, time: s.clock.time };
|
|
33
|
+
},
|
|
34
|
+
"customers.create": (input, context) => {
|
|
35
|
+
const s = open(context, input, "customers.write");
|
|
36
|
+
guardBody(s, input.customer);
|
|
37
|
+
return renderCustomer(s, saveCustomer(s, input.customer, "create").row);
|
|
38
|
+
},
|
|
39
|
+
"customers.update": (input, context) => {
|
|
40
|
+
const s = open(context, input, "customers.write");
|
|
41
|
+
guardBody(s, input.customer);
|
|
42
|
+
return renderCustomer(s, saveCustomer(s, input.customer, "update").row);
|
|
43
|
+
},
|
|
44
|
+
"customers.delete": (input, context) => {
|
|
45
|
+
const s = open(context, input, "customers.write");
|
|
46
|
+
return renderCustomer(s, deactivateCustomer(s, input.idOrEntity).row);
|
|
47
|
+
},
|
|
48
|
+
"items.post": (input, context) => {
|
|
49
|
+
const s = open(context, input, "items.write");
|
|
50
|
+
guardBody(s, input.body);
|
|
51
|
+
onlyUpdate(s, input, "Item");
|
|
52
|
+
const { row, action } = saveItem(s, input.body);
|
|
53
|
+
return { Item: renderItem(s, row), action, time: s.clock.time };
|
|
54
|
+
},
|
|
55
|
+
"invoices.post": (input, context) => {
|
|
56
|
+
const s = open(context, input, "invoices.write");
|
|
57
|
+
guardBody(s, input.body);
|
|
58
|
+
const { row, action } = invoicesPost(s, input);
|
|
59
|
+
return { Invoice: action === "delete" ? deleted(row) : renderInvoice(s, row), action, time: s.clock.time };
|
|
60
|
+
},
|
|
61
|
+
"invoices.create": (input, context) => {
|
|
62
|
+
const s = open(context, input, "invoices.write");
|
|
63
|
+
if (input.linked_txn !== undefined && input.linked_txn.length > 0) {
|
|
64
|
+
s.fail("BUSINESS_VALIDATION", "linked_txn is not supported: estimates and other linkable transactions are not modelled by this company.", "linked_txn");
|
|
65
|
+
}
|
|
66
|
+
const body = invoiceBodyFromMcp(input);
|
|
67
|
+
guardBody(s, body);
|
|
68
|
+
return renderInvoice(s, saveInvoice(s, body, null, false, false).row);
|
|
69
|
+
},
|
|
70
|
+
"invoices.update": (input, context) => {
|
|
71
|
+
const s = open(context, input, "invoices.write");
|
|
72
|
+
guardBody(s, input.patch);
|
|
73
|
+
return renderInvoice(s, updateInvoiceFromMcp(s, input.invoice_id, input.patch).row);
|
|
74
|
+
},
|
|
75
|
+
"invoices.delete": (input, context) => {
|
|
76
|
+
const s = open(context, input, "invoices.write");
|
|
77
|
+
return deleted(deleteInvoice(s, entityRef(s, "invoices", input.idOrEntity)).row);
|
|
78
|
+
},
|
|
79
|
+
"invoices.send": (input, context) => {
|
|
80
|
+
const s = open(context, input, "invoices.write");
|
|
81
|
+
return { Invoice: renderInvoice(s, sendInvoice(s, input.invoice_id, input.sendTo)), time: s.clock.time };
|
|
82
|
+
},
|
|
83
|
+
"payments.post": (input, context) => {
|
|
84
|
+
const s = open(context, input, "payments.write");
|
|
85
|
+
guardBody(s, input.body);
|
|
86
|
+
const { row, action } = paymentsPost(s, input);
|
|
87
|
+
return { Payment: action === "delete" ? deleted(row) : renderPayment(s, row), action, time: s.clock.time };
|
|
88
|
+
},
|
|
89
|
+
"payments.create": (input, context) => {
|
|
90
|
+
const s = open(context, input, "payments.write");
|
|
91
|
+
const body = paymentBodyFromMcp(input);
|
|
92
|
+
guardBody(s, body);
|
|
93
|
+
return renderPayment(s, savePayment(s, body, null, false).row);
|
|
94
|
+
},
|
|
95
|
+
};
|