@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,165 @@
|
|
|
1
|
+
// Parsing and validation of an invoice body (create, full update, sparse update) into a stored invoice row.
|
|
2
|
+
import { TERMS, clip, dateFromDay, dayNumber, fromCents, lineCents, toCents, toScaled4, utf8Bytes } from "./common.mjs";
|
|
3
|
+
import { checkKeys, has, isObject, optAddr, optDate, optEmail, optEnum, optRef, optString, own } from "./fields.mjs";
|
|
4
|
+
|
|
5
|
+
const WRITABLE = new Set(["DocNumber", "TxnDate", "DueDate", "CustomerRef", "SalesTermRef", "BillEmail", "BillAddr", "Line", "CustomerMemo", "PrivateNote", "EmailStatus", "PrintStatus", "GlobalTaxCalculation"]);
|
|
6
|
+
const READONLY = new Set(["Id", "SyncToken", "sparse", "domain", "MetaData", "TotalAmt", "Balance", "LinkedTxn", "TxnTaxDetail", "CurrencyRef", "ApplyTaxAfterDiscount", "DeliveryInfo"]);
|
|
7
|
+
const LINE_KEYS = new Set(["Id", "LineNum", "Description", "Amount", "DetailType", "SalesItemLineDetail", "SubTotalLineDetail", "LineEx"]);
|
|
8
|
+
const DETAIL_KEYS = new Set(["ItemRef", "Qty", "UnitPrice", "ServiceDate", "TaxCodeRef"]);
|
|
9
|
+
/**
|
|
10
|
+
* Upper bound on the JSON-encoded UTF-8 bytes of all line Descriptions on one invoice, measured as the text is returned
|
|
11
|
+
* (`JSON.stringify` without the surrounding quotes, so `\n`, `"` and `\\` count 2 bytes and control characters 6, while
|
|
12
|
+
* CJK counts its 3 UTF-8 bytes). Each Description may be 4,000 characters and an invoice 250 lines, so without this bound a
|
|
13
|
+
* stored invoice could render to 3-6 MB — over the framework's 1 MiB response cap for GET, PDF and query alike. With it, a
|
|
14
|
+
* maximal invoice (600 KB of encoded descriptions plus at most about 250 KB of other line and header fields) always fits
|
|
15
|
+
* one query page; `evaluate` in query-eval.mjs stays the last line of defence for any single row over RESPONSE_BYTE_BUDGET.
|
|
16
|
+
*/
|
|
17
|
+
export const INVOICE_DESCRIPTION_BYTES = 600_000;
|
|
18
|
+
/** Bytes a string occupies inside a JSON response, without its two quotes: the size that counts against the response cap. */
|
|
19
|
+
export function encodedBytes(text) {
|
|
20
|
+
return utf8Bytes(JSON.stringify(text)) - 2;
|
|
21
|
+
}
|
|
22
|
+
export const INACTIVE_DETAIL = "Invalid Reference Id : Something you're trying to use has been made inactive. Check the fields with accounts, customers, items, vendors or employees.";
|
|
23
|
+
|
|
24
|
+
/** Sales lines → stored lines and total cents. SubTotalLineDetail lines echoed back by clients are ignored. */
|
|
25
|
+
export function parseLines(s, raw, currentLines) {
|
|
26
|
+
if (!Array.isArray(raw)) s.fail("BUSINESS_VALIDATION", "Invalid value for Line: expected an array of lines", "Line");
|
|
27
|
+
if (raw.length > 251) s.fail("BUSINESS_VALIDATION", "An invoice can have at most 250 lines.", "Line");
|
|
28
|
+
const existing = new Set((currentLines ?? []).map((line) => line.SalesItemLineDetail.ItemRef.value));
|
|
29
|
+
const lines = [];
|
|
30
|
+
let total = 0;
|
|
31
|
+
let descriptionBytes = 0;
|
|
32
|
+
for (const [index, line] of raw.entries()) {
|
|
33
|
+
const at = `Line[${index}]`;
|
|
34
|
+
if (!isObject(line)) s.fail("BUSINESS_VALIDATION", `Invalid value for ${at}: expected a line object`, at);
|
|
35
|
+
for (const key of Object.keys(line)) if (!LINE_KEYS.has(key)) s.fail("BUSINESS_VALIDATION", `Request has invalid or unsupported property : Property Name:${clip(key, 80)} specified is unsupported or invalid`, at);
|
|
36
|
+
if (line.DetailType === "SubTotalLineDetail") continue;
|
|
37
|
+
if (!has(line, "DetailType") || line.DetailType === null) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Line.DetailType", `${at}.DetailType`);
|
|
38
|
+
if (line.DetailType !== "SalesItemLineDetail") s.fail("BUSINESS_VALIDATION", "Only SalesItemLineDetail lines are supported by this company (no discount, group or description-only lines).", `${at}.DetailType`);
|
|
39
|
+
const detail = own(line, "SalesItemLineDetail");
|
|
40
|
+
if (!isObject(detail)) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: SalesItemLineDetail", `${at}.SalesItemLineDetail`);
|
|
41
|
+
for (const key of Object.keys(detail)) if (!DETAIL_KEYS.has(key)) s.fail("BUSINESS_VALIDATION", `Request has invalid or unsupported property : Property Name:${clip(key, 80)} specified is unsupported or invalid`, at);
|
|
42
|
+
const itemId = optRef(s, detail, "ItemRef");
|
|
43
|
+
if (typeof itemId !== "string") s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: ItemRef", `${at}.SalesItemLineDetail.ItemRef`);
|
|
44
|
+
const item = s.get("items", itemId);
|
|
45
|
+
if (item === null) s.fail("INVALID_REFERENCE", `Invalid Reference Id : Item assigned to line ${index + 1} does not exist: ${clip(itemId, 64)}`, `${at}.SalesItemLineDetail.ItemRef`);
|
|
46
|
+
if (!item.Active && !existing.has(item.Id)) s.fail("INVALID_REFERENCE", INACTIVE_DETAIL, `${at}.SalesItemLineDetail.ItemRef`);
|
|
47
|
+
const qty = has(detail, "Qty") && detail.Qty !== null ? detail.Qty : 1;
|
|
48
|
+
const qtyScaled = toScaled4(qty, 999_999);
|
|
49
|
+
if (qtyScaled === null || qtyScaled === 0) s.fail("BUSINESS_VALIDATION", "Qty must be a number greater than zero with at most four decimals.", `${at}.SalesItemLineDetail.Qty`);
|
|
50
|
+
const price = has(detail, "UnitPrice") && detail.UnitPrice !== null ? detail.UnitPrice : item.UnitPrice;
|
|
51
|
+
const priceScaled = toScaled4(price, 999_999_999);
|
|
52
|
+
if (priceScaled === null) s.fail("BUSINESS_VALIDATION", "UnitPrice must be a non-negative number with at most four decimals.", `${at}.SalesItemLineDetail.UnitPrice`);
|
|
53
|
+
const cents = lineCents(qtyScaled, priceScaled);
|
|
54
|
+
if (has(line, "Amount") && line.Amount !== null && toCents(line.Amount) !== cents) {
|
|
55
|
+
s.fail("BUSINESS_VALIDATION", `Amount is not equal to UnitPrice * Qty. Supplied value:${clip(String(line.Amount), 40)}`, `${at}.Amount`);
|
|
56
|
+
}
|
|
57
|
+
const description = optString(s, line, "Description", 4000);
|
|
58
|
+
const serviceDate = optDate(s, detail, "ServiceDate");
|
|
59
|
+
const taxCode = optRef(s, detail, "TaxCodeRef");
|
|
60
|
+
if (typeof taxCode === "string" && taxCode !== "TAX" && taxCode !== "NON") s.fail("BUSINESS_VALIDATION", "TaxCodeRef must be TAX or NON for this US company.", `${at}.SalesItemLineDetail.TaxCodeRef`);
|
|
61
|
+
total += cents;
|
|
62
|
+
const storedDescription = description === undefined ? item.Description : description;
|
|
63
|
+
if (typeof storedDescription === "string") descriptionBytes += encodedBytes(storedDescription);
|
|
64
|
+
if (descriptionBytes > INVOICE_DESCRIPTION_BYTES) {
|
|
65
|
+
s.fail("BUSINESS_VALIDATION", `The line descriptions on this invoice total more than 600,000 bytes of text as returned (JSON-encoded, including descriptions inherited from items); shorten the descriptions or split the invoice.`, `${at}.Description`);
|
|
66
|
+
}
|
|
67
|
+
lines.push({
|
|
68
|
+
Id: String(lines.length + 1),
|
|
69
|
+
LineNum: lines.length + 1,
|
|
70
|
+
Description: storedDescription,
|
|
71
|
+
Amount: fromCents(cents),
|
|
72
|
+
DetailType: "SalesItemLineDetail",
|
|
73
|
+
SalesItemLineDetail: {
|
|
74
|
+
ItemRef: { value: item.Id, name: item.Name },
|
|
75
|
+
Qty: qty,
|
|
76
|
+
UnitPrice: price,
|
|
77
|
+
ServiceDate: serviceDate ?? null,
|
|
78
|
+
TaxCodeRef: { value: typeof taxCode === "string" ? taxCode : item.Taxable ? "TAX" : "NON" },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (lines.length === 0) s.fail("BUSINESS_VALIDATION", "You must fill out at least one split line.", "Line");
|
|
83
|
+
if (lines.length > 250) s.fail("BUSINESS_VALIDATION", "An invoice can have at most 250 lines.", "Line");
|
|
84
|
+
if (total > 99_999_999_999) s.fail("BUSINESS_VALIDATION", "The invoice total exceeds the supported maximum.", "Line");
|
|
85
|
+
return { lines, total };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Body → next invoice row fields (Id, SyncToken, MetaData, DocNumber, balances are completed by the caller). */
|
|
89
|
+
export function draftInvoice(s, body, current, sparse) {
|
|
90
|
+
if (!isObject(body)) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Invoice object", "Invoice");
|
|
91
|
+
checkKeys(s, body, WRITABLE, READONLY);
|
|
92
|
+
if (current !== null && current.voided) s.fail("BUSINESS_VALIDATION", "A voided invoice can't be changed.", "Id");
|
|
93
|
+
const keep = (key) => current !== null && sparse && !has(body, key);
|
|
94
|
+
const next = current === null ? { voided: false, DeliveryInfo: null, EmailStatus: "NotSet", PrintStatus: "NotSet" } : { ...current };
|
|
95
|
+
|
|
96
|
+
let customer;
|
|
97
|
+
if (keep("CustomerRef")) customer = s.get("customers", current.CustomerRef.value);
|
|
98
|
+
else {
|
|
99
|
+
const customerId = optRef(s, body, "CustomerRef");
|
|
100
|
+
if (typeof customerId !== "string") s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: CustomerRef", "CustomerRef");
|
|
101
|
+
customer = s.get("customers", customerId);
|
|
102
|
+
if (customer === null) s.fail("INVALID_REFERENCE", `Invalid Reference Id : Customer assigned to this transaction does not exist: ${clip(customerId, 64)}`, "CustomerRef");
|
|
103
|
+
const changed = current === null || current.CustomerRef.value !== customer.Id;
|
|
104
|
+
if (!customer.Active && changed) s.fail("INVALID_REFERENCE", INACTIVE_DETAIL, "CustomerRef");
|
|
105
|
+
next.CustomerRef = { value: customer.Id, name: customer.DisplayName };
|
|
106
|
+
}
|
|
107
|
+
if (!keep("Line")) {
|
|
108
|
+
if (!has(body, "Line") || body.Line === null) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Line", "Line");
|
|
109
|
+
const parsed = parseLines(s, body.Line, current === null ? null : current.Line);
|
|
110
|
+
next.Line = parsed.lines;
|
|
111
|
+
next.TotalAmt = fromCents(parsed.total);
|
|
112
|
+
}
|
|
113
|
+
const txnDate = optDate(s, body, "TxnDate");
|
|
114
|
+
if (txnDate !== undefined && txnDate !== null) next.TxnDate = txnDate;
|
|
115
|
+
else if (current === null) next.TxnDate = s.clock.date;
|
|
116
|
+
|
|
117
|
+
const term = optRef(s, body, "SalesTermRef");
|
|
118
|
+
if (typeof term === "string" && !TERMS.has(term)) s.fail("INVALID_REFERENCE", `Invalid Reference Id : Term assigned to this transaction does not exist: ${clip(term, 64)}`, "SalesTermRef");
|
|
119
|
+
if (term !== undefined) next.SalesTermRef = typeof term === "string" ? { value: term, name: TERMS.get(term).name } : null;
|
|
120
|
+
else if (current === null) next.SalesTermRef = customer !== null && customer.SalesTermRef !== null ? customer.SalesTermRef : null;
|
|
121
|
+
else if (!sparse) next.SalesTermRef = null;
|
|
122
|
+
|
|
123
|
+
const due = optDate(s, body, "DueDate");
|
|
124
|
+
if (due !== undefined && due !== null) next.DueDate = due;
|
|
125
|
+
else if (current === null || !sparse || due === null) {
|
|
126
|
+
const days = next.SalesTermRef === null ? 30 : (TERMS.get(next.SalesTermRef.value)?.days ?? 30);
|
|
127
|
+
next.DueDate = dateFromDay(dayNumber(next.TxnDate) + days);
|
|
128
|
+
}
|
|
129
|
+
if (dayNumber(next.DueDate) < dayNumber(next.TxnDate)) s.fail("BUSINESS_VALIDATION", "The due date can't be earlier than the invoice date.", "DueDate");
|
|
130
|
+
|
|
131
|
+
const email = optEmail(s, body, "BillEmail");
|
|
132
|
+
if (email !== undefined) next.BillEmail = email;
|
|
133
|
+
else if (current === null) next.BillEmail = customer !== null ? customer.PrimaryEmailAddr : null;
|
|
134
|
+
else if (!sparse) next.BillEmail = null;
|
|
135
|
+
const addr = optAddr(s, body, "BillAddr");
|
|
136
|
+
if (addr !== undefined) next.BillAddr = addr;
|
|
137
|
+
else if (current === null) next.BillAddr = customer !== null ? customer.BillAddr : null;
|
|
138
|
+
else if (!sparse) next.BillAddr = null;
|
|
139
|
+
|
|
140
|
+
if (has(body, "CustomerMemo")) {
|
|
141
|
+
const memo = body.CustomerMemo;
|
|
142
|
+
if (memo === null) next.CustomerMemo = null;
|
|
143
|
+
else {
|
|
144
|
+
if (!isObject(memo) || Object.keys(memo).some((key) => key !== "value")) s.fail("BUSINESS_VALIDATION", "Invalid value for CustomerMemo: expected { value }", "CustomerMemo");
|
|
145
|
+
const text = optString(s, memo, "value", 1000);
|
|
146
|
+
next.CustomerMemo = typeof text === "string" ? { value: text } : null;
|
|
147
|
+
}
|
|
148
|
+
} else if (current === null || !sparse) next.CustomerMemo = null;
|
|
149
|
+
const note = optString(s, body, "PrivateNote", 4000);
|
|
150
|
+
if (note !== undefined) next.PrivateNote = note;
|
|
151
|
+
else if (current === null || !sparse) next.PrivateNote = null;
|
|
152
|
+
|
|
153
|
+
const emailStatus = optEnum(s, body, "EmailStatus", ["NotSet", "NeedToSend", "EmailSent"]);
|
|
154
|
+
if (emailStatus === "EmailSent" && next.EmailStatus !== "EmailSent") s.fail("BUSINESS_VALIDATION", "EmailStatus EmailSent is set by sending the invoice.", "EmailStatus");
|
|
155
|
+
if (emailStatus !== undefined) next.EmailStatus = emailStatus;
|
|
156
|
+
const printStatus = optEnum(s, body, "PrintStatus", ["NotSet", "NeedToPrint", "PrintComplete"]);
|
|
157
|
+
if (printStatus !== undefined) next.PrintStatus = printStatus;
|
|
158
|
+
if (has(body, "GlobalTaxCalculation") && body.GlobalTaxCalculation !== "NotApplicable") {
|
|
159
|
+
s.fail("BUSINESS_VALIDATION", "GlobalTaxCalculation is not applicable to this US company; only NotApplicable is accepted.", "GlobalTaxCalculation");
|
|
160
|
+
}
|
|
161
|
+
const currency = own(body, "CurrencyRef");
|
|
162
|
+
if (currency !== undefined && currency !== null && own(currency, "value") !== "USD") s.fail("BUSINESS_VALIDATION", "Multicurrency is not enabled for this company: CurrencyRef must be USD.", "CurrencyRef");
|
|
163
|
+
const docNumber = optString(s, body, "DocNumber", 21);
|
|
164
|
+
return { next, docNumber, customer };
|
|
165
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Invoice entity: rendering (computed LinkedTxn and subtotal line), create / update / void / delete / send, and the
|
|
2
|
+
// MCP argument mappings. Balances follow linked payments; every write bumps SyncToken and emits invoice.changed.
|
|
3
|
+
import { NOT_FOUND_DETAIL, bumpToken, load, staleCheck } from "./access.mjs";
|
|
4
|
+
import { RESERVED_KEYS, USD, clip, fromCents, padId, toCents } from "./common.mjs";
|
|
5
|
+
import { updateTarget } from "./customers.mjs";
|
|
6
|
+
import { has, isObject, own, validEmail } from "./fields.mjs";
|
|
7
|
+
import { draftInvoice } from "./invoice-draft.mjs";
|
|
8
|
+
import { refName } from "./items.mjs";
|
|
9
|
+
|
|
10
|
+
export function renderInvoice(s, row) {
|
|
11
|
+
const links = s.applied().get(row.Id)?.paymentIds ?? [];
|
|
12
|
+
let subtotal = 0;
|
|
13
|
+
const lines = row.Line.map((line) => {
|
|
14
|
+
subtotal += toCents(line.Amount) ?? 0;
|
|
15
|
+
const detail = line.SalesItemLineDetail;
|
|
16
|
+
const out = {
|
|
17
|
+
Id: line.Id,
|
|
18
|
+
LineNum: line.LineNum,
|
|
19
|
+
Amount: line.Amount,
|
|
20
|
+
DetailType: "SalesItemLineDetail",
|
|
21
|
+
SalesItemLineDetail: { ItemRef: refName(s, "items", detail.ItemRef, "Name"), Qty: detail.Qty, UnitPrice: detail.UnitPrice, TaxCodeRef: detail.TaxCodeRef },
|
|
22
|
+
};
|
|
23
|
+
if (line.Description !== null) out.Description = line.Description;
|
|
24
|
+
if (detail.ServiceDate !== null) out.SalesItemLineDetail.ServiceDate = detail.ServiceDate;
|
|
25
|
+
return out;
|
|
26
|
+
});
|
|
27
|
+
lines.push({ Amount: fromCents(subtotal), DetailType: "SubTotalLineDetail", SubTotalLineDetail: {} });
|
|
28
|
+
const out = {
|
|
29
|
+
Id: row.Id,
|
|
30
|
+
SyncToken: row.SyncToken,
|
|
31
|
+
domain: "QBO",
|
|
32
|
+
sparse: false,
|
|
33
|
+
MetaData: row.MetaData,
|
|
34
|
+
DocNumber: row.DocNumber,
|
|
35
|
+
TxnDate: row.TxnDate,
|
|
36
|
+
DueDate: row.DueDate,
|
|
37
|
+
CurrencyRef: USD,
|
|
38
|
+
LinkedTxn: links.map((id) => ({ TxnId: id, TxnType: "Payment" })),
|
|
39
|
+
Line: lines,
|
|
40
|
+
TxnTaxDetail: { TotalTax: 0 },
|
|
41
|
+
CustomerRef: refName(s, "customers", row.CustomerRef, "DisplayName"),
|
|
42
|
+
TotalAmt: row.TotalAmt,
|
|
43
|
+
ApplyTaxAfterDiscount: false,
|
|
44
|
+
PrintStatus: row.PrintStatus,
|
|
45
|
+
EmailStatus: row.EmailStatus,
|
|
46
|
+
Balance: row.Balance,
|
|
47
|
+
};
|
|
48
|
+
for (const key of ["SalesTermRef", "BillEmail", "BillAddr", "CustomerMemo", "PrivateNote", "DeliveryInfo"]) if (row[key] !== null) out[key] = row[key];
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function saveInvoice(s, body, current, sparse, allowDuplicate) {
|
|
53
|
+
const { next, docNumber } = draftInvoice(s, body, current, sparse);
|
|
54
|
+
const applied = current === null ? 0 : (s.applied().get(current.Id)?.cents ?? 0);
|
|
55
|
+
if (current !== null && applied > 0 && next.CustomerRef.value !== current.CustomerRef.value) {
|
|
56
|
+
s.fail("BUSINESS_VALIDATION", "The customer can't be changed while payments are applied to this invoice.", "CustomerRef");
|
|
57
|
+
}
|
|
58
|
+
const total = toCents(next.TotalAmt) ?? 0;
|
|
59
|
+
if (total < applied) s.fail("BUSINESS_VALIDATION", `The invoice total can't be less than the ${fromCents(applied).toFixed(2)} already received for it.`, "Line");
|
|
60
|
+
next.Balance = fromCents(total - applied);
|
|
61
|
+
const invoices = s.rows("invoices");
|
|
62
|
+
if (typeof docNumber === "string") {
|
|
63
|
+
if (!allowDuplicate && (current === null || current.DocNumber !== docNumber)) {
|
|
64
|
+
const other = invoices.find((row) => row.DocNumber === docNumber && (current === null || row.Id !== current.Id));
|
|
65
|
+
if (other !== undefined) {
|
|
66
|
+
s.fail("DUPLICATE_DOC_NUMBER", `Duplicate Document Number Error : You must specify a different number. This number has already been used. DocNumber=${clip(docNumber, 21)} is assigned to TxnType=Invoice with TxnId=${other.Id}`, "DocNumber");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
next.DocNumber = docNumber;
|
|
70
|
+
} else if (current === null) {
|
|
71
|
+
const used = new Set(invoices.map((row) => row.DocNumber));
|
|
72
|
+
let n = s.counter("docNumber");
|
|
73
|
+
while (used.has(String(n))) n += 1;
|
|
74
|
+
next.DocNumber = String(n);
|
|
75
|
+
s.setCounter("docNumber", n + 1);
|
|
76
|
+
}
|
|
77
|
+
if (current === null) {
|
|
78
|
+
next.Id = s.nextId("invoice", "invoices");
|
|
79
|
+
next.SyncToken = "0";
|
|
80
|
+
next.MetaData = { CreateTime: s.clock.meta, LastUpdatedTime: s.clock.meta };
|
|
81
|
+
} else {
|
|
82
|
+
next.SyncToken = bumpToken(current);
|
|
83
|
+
next.MetaData = { CreateTime: current.MetaData.CreateTime, LastUpdatedTime: s.clock.meta };
|
|
84
|
+
}
|
|
85
|
+
s.put("invoices", next);
|
|
86
|
+
s.emit("invoice.changed", "Invoice", next.Id, current === null ? "Create" : "Update");
|
|
87
|
+
return { row: next, action: current === null ? "create" : "update" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Remove this invoice's lines from every payment linked to it (payments keep TotalAmt and become unapplied). */
|
|
91
|
+
function unlinkPayments(s, invoiceId) {
|
|
92
|
+
const paymentIds = [...(s.applied().get(invoiceId)?.paymentIds ?? [])];
|
|
93
|
+
for (const paymentId of paymentIds) {
|
|
94
|
+
const payment = s.get("payments", paymentId);
|
|
95
|
+
if (payment === null) continue;
|
|
96
|
+
const lines = payment.Line.filter((line) => line.LinkedTxn[0].TxnId !== invoiceId);
|
|
97
|
+
s.put("payments", { ...payment, Line: lines, SyncToken: bumpToken(payment), MetaData: { ...payment.MetaData, LastUpdatedTime: s.clock.meta } });
|
|
98
|
+
s.emit("payment.changed", "Payment", paymentId, "Update");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function voidInvoice(s, current) {
|
|
103
|
+
if (current.voided) s.fail("BUSINESS_VALIDATION", "This invoice is already voided.", "Id");
|
|
104
|
+
unlinkPayments(s, current.Id);
|
|
105
|
+
const next = {
|
|
106
|
+
...current,
|
|
107
|
+
Line: current.Line.map((line) => ({ ...line, Amount: 0 })),
|
|
108
|
+
TotalAmt: 0,
|
|
109
|
+
Balance: 0,
|
|
110
|
+
voided: true,
|
|
111
|
+
PrivateNote: current.PrivateNote === null ? "Voided" : `Voided - ${current.PrivateNote}`.slice(0, 4000),
|
|
112
|
+
SyncToken: bumpToken(current),
|
|
113
|
+
MetaData: { ...current.MetaData, LastUpdatedTime: s.clock.meta },
|
|
114
|
+
};
|
|
115
|
+
s.put("invoices", next);
|
|
116
|
+
s.emit("invoice.changed", "Invoice", current.Id, "Void");
|
|
117
|
+
return { row: next, action: "void" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function deleteInvoice(s, current) {
|
|
121
|
+
unlinkPayments(s, current.Id);
|
|
122
|
+
s.remove("invoices", current.Id);
|
|
123
|
+
s.emit("invoice.changed", "Invoice", current.Id, "Delete");
|
|
124
|
+
return { row: current, action: "delete" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function sendInvoice(s, id, sendTo) {
|
|
128
|
+
const current = load(s, "invoices", id);
|
|
129
|
+
if (sendTo !== undefined) {
|
|
130
|
+
if (sendTo.includes(",") || sendTo.includes(";")) s.fail("BUSINESS_VALIDATION", "Only one e-mail address can be specified in sendTo.", "sendTo");
|
|
131
|
+
if (sendTo.includes("\uFFFD")) s.fail("BUSINESS_VALIDATION", "Invalid Email Address format : Please enter a valid email address.", "sendTo");
|
|
132
|
+
if (!validEmail(sendTo)) s.fail("BUSINESS_VALIDATION", "Invalid Email Address format : Please enter a valid email address.", "sendTo");
|
|
133
|
+
}
|
|
134
|
+
const address = sendTo ?? current.BillEmail?.Address;
|
|
135
|
+
if (address === undefined) s.fail("BUSINESS_VALIDATION", "Email address is missing: supply sendTo or set BillEmail on the invoice before sending it.", "BillEmail");
|
|
136
|
+
const prefix = `${padId(current.Id)}/`;
|
|
137
|
+
let count = 0;
|
|
138
|
+
for (const record of s.context.state.scan("deliveries", { afterRowId: prefix, limit: 10000 })) {
|
|
139
|
+
if (!record.rowId.startsWith(prefix)) break;
|
|
140
|
+
count += 1;
|
|
141
|
+
}
|
|
142
|
+
if (count >= 9999) s.fail("BUSINESS_VALIDATION", "This invoice has reached the supported number of sends.", "Id");
|
|
143
|
+
const next = {
|
|
144
|
+
...current,
|
|
145
|
+
BillEmail: { Address: address },
|
|
146
|
+
EmailStatus: "EmailSent",
|
|
147
|
+
DeliveryInfo: { DeliveryType: "Email", DeliveryTime: s.clock.meta },
|
|
148
|
+
SyncToken: bumpToken(current),
|
|
149
|
+
MetaData: { ...current.MetaData, LastUpdatedTime: s.clock.meta },
|
|
150
|
+
};
|
|
151
|
+
s.put("invoices", next);
|
|
152
|
+
s.context.state.put("deliveries", `${prefix}${String(count + 1).padStart(4, "0")}`, {
|
|
153
|
+
invoiceId: current.Id,
|
|
154
|
+
sendTo: address,
|
|
155
|
+
deliveryTime: s.clock.meta,
|
|
156
|
+
actorId: s.context.actor.id,
|
|
157
|
+
source: "invoices.send",
|
|
158
|
+
});
|
|
159
|
+
s.emit("invoice.changed", "Invoice", current.Id, "Emailed");
|
|
160
|
+
return next;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function invoicesPost(s, input) {
|
|
164
|
+
const operation = input.operation;
|
|
165
|
+
const body = input.body;
|
|
166
|
+
if (operation === undefined || operation === "update") {
|
|
167
|
+
const hasId = has(body, "Id") && body.Id !== null;
|
|
168
|
+
if (operation === "update" && !hasId) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Id", "Id");
|
|
169
|
+
const current = hasId ? updateTarget(s, "invoices", body, "Id") : null;
|
|
170
|
+
if (has(body, "sparse") && typeof body.sparse !== "boolean") s.fail("BUSINESS_VALIDATION", "Invalid value for sparse: expected true or false", "sparse");
|
|
171
|
+
return saveInvoice(s, body, current, current !== null && body.sparse === true, input.include === "allowduplicatedocnum");
|
|
172
|
+
}
|
|
173
|
+
if (operation === "void" || operation === "delete") {
|
|
174
|
+
if (!isObject(body)) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Invoice object", "Invoice");
|
|
175
|
+
const current = updateTarget(s, "invoices", body, "Id");
|
|
176
|
+
return operation === "void" ? voidInvoice(s, current) : deleteInvoice(s, current);
|
|
177
|
+
}
|
|
178
|
+
return s.fail("BUSINESS_VALIDATION", `Operation ${clip(String(operation), 32)} is not supported for Invoice.`, "operation");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** create_invoice (MCP argument shape) → Invoice body. */
|
|
182
|
+
export function invoiceBodyFromMcp(input) {
|
|
183
|
+
const body = {
|
|
184
|
+
CustomerRef: { value: input.customer_ref },
|
|
185
|
+
Line: (Array.isArray(input.line_items) ? input.line_items : []).map((item) => {
|
|
186
|
+
const detail = { ItemRef: { value: own(item, "item_ref") }, Qty: own(item, "qty"), UnitPrice: own(item, "unit_price") };
|
|
187
|
+
if (own(item, "tax_code_ref") !== undefined) detail.TaxCodeRef = { value: item.tax_code_ref };
|
|
188
|
+
if (own(item, "service_date") !== undefined) detail.ServiceDate = item.service_date;
|
|
189
|
+
const line = { DetailType: "SalesItemLineDetail", SalesItemLineDetail: detail };
|
|
190
|
+
if (own(item, "description") !== undefined) line.Description = item.description;
|
|
191
|
+
return line;
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
if (input.doc_number !== undefined) body.DocNumber = input.doc_number;
|
|
195
|
+
if (input.txn_date !== undefined) body.TxnDate = input.txn_date;
|
|
196
|
+
if (input.customer_memo !== undefined) body.CustomerMemo = { value: input.customer_memo };
|
|
197
|
+
if (input.sales_term_ref !== undefined) body.SalesTermRef = { value: input.sales_term_ref };
|
|
198
|
+
if (input.bill_email !== undefined) body.BillEmail = { Address: input.bill_email };
|
|
199
|
+
if (input.global_tax_calculation !== undefined) body.GlobalTaxCalculation = input.global_tax_calculation;
|
|
200
|
+
return body;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** update_invoice: sparse update of `patch`; a missing SyncToken means the current one (the MCP server reads it first). */
|
|
204
|
+
export function updateInvoiceFromMcp(s, invoiceId, patch) {
|
|
205
|
+
const current = load(s, "invoices", invoiceId, "invoice_id");
|
|
206
|
+
if (!isObject(patch)) s.fail("BUSINESS_VALIDATION", "Invalid value for patch: expected an object", "patch");
|
|
207
|
+
for (const key of Object.keys(patch)) if (RESERVED_KEYS.has(key)) s.fail("BUSINESS_VALIDATION", `Request has invalid or unsupported property : Property Name:${key} specified is unsupported or invalid`, key);
|
|
208
|
+
const body = { ...patch, Id: current.Id, sparse: true };
|
|
209
|
+
const token = own(patch, "SyncToken");
|
|
210
|
+
body.SyncToken = token === undefined || token === null ? current.SyncToken : typeof token === "string" || typeof token === "number" ? String(token) : null;
|
|
211
|
+
staleCheck(s, current, body.SyncToken);
|
|
212
|
+
return saveInvoice(s, body, current, true, false);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function entityRef(s, namespace, idOrEntity) {
|
|
216
|
+
let id = "";
|
|
217
|
+
let token;
|
|
218
|
+
if (typeof idOrEntity === "string") id = idOrEntity;
|
|
219
|
+
else if (isObject(idOrEntity)) {
|
|
220
|
+
const rawId = own(idOrEntity, "Id");
|
|
221
|
+
id = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : "";
|
|
222
|
+
const rawToken = own(idOrEntity, "SyncToken");
|
|
223
|
+
if (rawToken !== undefined) token = typeof rawToken === "string" || typeof rawToken === "number" ? String(rawToken) : null;
|
|
224
|
+
}
|
|
225
|
+
const row = s.get(namespace, id);
|
|
226
|
+
if (row === null) s.fail("OBJECT_NOT_FOUND", NOT_FOUND_DETAIL, "Id");
|
|
227
|
+
if (token !== undefined) staleCheck(s, row, token);
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Item entity (Service and NonInventory only): rendering and create / full update / sparse update.
|
|
2
|
+
import { bumpToken } from "./access.mjs";
|
|
3
|
+
import { updateTarget } from "./customers.mjs";
|
|
4
|
+
import { toScaled4 } from "./common.mjs";
|
|
5
|
+
import { checkKeys, has, isObject, optBool, optRef, optString } from "./fields.mjs";
|
|
6
|
+
|
|
7
|
+
const WRITABLE = new Set(["Name", "Sku", "Description", "Type", "UnitPrice", "IncomeAccountRef", "ExpenseAccountRef", "Taxable", "Active", "TrackQtyOnHand"]);
|
|
8
|
+
const READONLY = new Set(["Id", "SyncToken", "sparse", "domain", "MetaData", "FullyQualifiedName", "PurchaseCost", "Level", "SubItem"]);
|
|
9
|
+
|
|
10
|
+
export function refName(s, namespace, ref, field) {
|
|
11
|
+
if (ref === null) return null;
|
|
12
|
+
const target = s.get(namespace, ref.value);
|
|
13
|
+
return { value: ref.value, name: target === null ? ref.name : target[field] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderItem(s, row) {
|
|
17
|
+
const out = {
|
|
18
|
+
Name: row.Name,
|
|
19
|
+
Active: row.Active,
|
|
20
|
+
FullyQualifiedName: row.Name,
|
|
21
|
+
Taxable: row.Taxable,
|
|
22
|
+
UnitPrice: row.UnitPrice,
|
|
23
|
+
Type: row.Type,
|
|
24
|
+
IncomeAccountRef: refName(s, "accounts", row.IncomeAccountRef, "Name"),
|
|
25
|
+
PurchaseCost: 0,
|
|
26
|
+
TrackQtyOnHand: false,
|
|
27
|
+
domain: "QBO",
|
|
28
|
+
sparse: false,
|
|
29
|
+
Id: row.Id,
|
|
30
|
+
SyncToken: row.SyncToken,
|
|
31
|
+
MetaData: row.MetaData,
|
|
32
|
+
};
|
|
33
|
+
if (row.Description !== null) out.Description = row.Description;
|
|
34
|
+
if (row.Sku !== null) out.Sku = row.Sku;
|
|
35
|
+
if (row.ExpenseAccountRef !== null) out.ExpenseAccountRef = refName(s, "accounts", row.ExpenseAccountRef, "Name");
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function accountRef(s, id, types, element) {
|
|
40
|
+
const account = s.get("accounts", id);
|
|
41
|
+
if (account === null || !account.Active || !types.includes(account.AccountType)) {
|
|
42
|
+
s.fail("INVALID_REFERENCE", `Invalid Reference Id : Account ${id} does not exist, is inactive or is not a ${types.join(" or ")} account.`, element);
|
|
43
|
+
}
|
|
44
|
+
return { value: account.Id, name: account.Name };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function saveItem(s, body) {
|
|
48
|
+
if (!isObject(body)) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Item object", "Item");
|
|
49
|
+
checkKeys(s, body, WRITABLE, READONLY);
|
|
50
|
+
const hasId = has(body, "Id") && body.Id !== null;
|
|
51
|
+
const current = hasId ? updateTarget(s, "items", body, "Id") : null;
|
|
52
|
+
if (has(body, "sparse") && typeof body.sparse !== "boolean") s.fail("BUSINESS_VALIDATION", "Invalid value for sparse: expected true or false", "sparse");
|
|
53
|
+
const sparse = current !== null && body.sparse === true;
|
|
54
|
+
const keep = (key) => sparse && !has(body, key);
|
|
55
|
+
const next = current === null ? { Id: "", SyncToken: "0", MetaData: null } : { ...current };
|
|
56
|
+
|
|
57
|
+
const name = optString(s, body, "Name", 100);
|
|
58
|
+
if (!keep("Name")) {
|
|
59
|
+
if (typeof name !== "string" || name.trim().length === 0) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Name", "Name");
|
|
60
|
+
if (name.includes(":")) s.fail("BUSINESS_VALIDATION", "Invalid character in name : A name can't contain a colon (:).", "Name");
|
|
61
|
+
next.Name = name.trim();
|
|
62
|
+
}
|
|
63
|
+
if (!keep("Type")) {
|
|
64
|
+
const type = has(body, "Type") ? body.Type : null;
|
|
65
|
+
if (type === null || type === undefined) s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: Type", "Type");
|
|
66
|
+
if (type === "Inventory" || type === "Group" || type === "Category") {
|
|
67
|
+
s.fail("BUSINESS_VALIDATION", `Item Type ${type} is not supported by this company. Use Service or NonInventory.`, "Type");
|
|
68
|
+
}
|
|
69
|
+
if (type !== "Service" && type !== "NonInventory") s.fail("BUSINESS_VALIDATION", "Invalid value for Type: expected Service or NonInventory", "Type");
|
|
70
|
+
if (current !== null && current.Type !== type) s.fail("BUSINESS_VALIDATION", "You can't change the type of an existing product or service.", "Type");
|
|
71
|
+
next.Type = type;
|
|
72
|
+
}
|
|
73
|
+
if (!keep("UnitPrice")) {
|
|
74
|
+
const price = has(body, "UnitPrice") && body.UnitPrice !== null ? body.UnitPrice : 0;
|
|
75
|
+
if (toScaled4(price, 999_999_999) === null) s.fail("BUSINESS_VALIDATION", "Invalid value for UnitPrice: expected a non-negative amount with at most four decimals", "UnitPrice");
|
|
76
|
+
next.UnitPrice = price;
|
|
77
|
+
}
|
|
78
|
+
if (!keep("IncomeAccountRef")) {
|
|
79
|
+
const income = optRef(s, body, "IncomeAccountRef");
|
|
80
|
+
if (typeof income !== "string") s.fail("REQUIRED_PARAM_MISSING", "Required param missing, need to supply the required value for the API: IncomeAccountRef", "IncomeAccountRef");
|
|
81
|
+
next.IncomeAccountRef = accountRef(s, income, ["Income"], "IncomeAccountRef");
|
|
82
|
+
}
|
|
83
|
+
if (!keep("ExpenseAccountRef")) {
|
|
84
|
+
const expense = optRef(s, body, "ExpenseAccountRef");
|
|
85
|
+
next.ExpenseAccountRef = typeof expense === "string" ? accountRef(s, expense, ["Cost of Goods Sold", "Expense"], "ExpenseAccountRef") : null;
|
|
86
|
+
}
|
|
87
|
+
for (const [key, max] of [["Sku", 100], ["Description", 4000]]) {
|
|
88
|
+
if (!keep(key)) {
|
|
89
|
+
const value = optString(s, body, key, max);
|
|
90
|
+
next[key] = value === undefined ? null : value;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [key, fallback] of [["Taxable", false], ["Active", true]]) {
|
|
94
|
+
if (!keep(key)) {
|
|
95
|
+
const value = optBool(s, body, key);
|
|
96
|
+
next[key] = value === undefined ? fallback : value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (optBool(s, body, "TrackQtyOnHand") === true) {
|
|
100
|
+
s.fail("BUSINESS_VALIDATION", "Quantity on hand can only be tracked for Inventory items, which this company does not support.", "TrackQtyOnHand");
|
|
101
|
+
}
|
|
102
|
+
const fold = next.Name.toLowerCase();
|
|
103
|
+
for (const other of s.rows("items")) {
|
|
104
|
+
if (other.Id !== next.Id && other.Name.toLowerCase() === fold) {
|
|
105
|
+
s.fail("DUPLICATE_NAME", "The name supplied already exists. : Another product or service is already using this name. Please use a different name.", "Name");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (current === null) {
|
|
109
|
+
next.Id = s.nextId("item", "items");
|
|
110
|
+
next.MetaData = { CreateTime: s.clock.meta, LastUpdatedTime: s.clock.meta };
|
|
111
|
+
} else {
|
|
112
|
+
next.SyncToken = bumpToken(current);
|
|
113
|
+
next.MetaData = { CreateTime: current.MetaData.CreateTime, LastUpdatedTime: s.clock.meta };
|
|
114
|
+
}
|
|
115
|
+
s.put("items", next);
|
|
116
|
+
return { row: next, action: current === null ? "create" : "update" };
|
|
117
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// LIKE matching with `%` wildcards in time linear in the value length: the pattern is split into
|
|
2
|
+
// wildcard-free segments and each segment is located with a Knuth-Morris-Pratt search.
|
|
3
|
+
|
|
4
|
+
function failureTable(segment) {
|
|
5
|
+
const table = new Array(segment.length).fill(0);
|
|
6
|
+
let k = 0;
|
|
7
|
+
for (let i = 1; i < segment.length; i += 1) {
|
|
8
|
+
while (k > 0 && segment[i] !== segment[k]) k = table[k - 1];
|
|
9
|
+
if (segment[i] === segment[k]) k += 1;
|
|
10
|
+
table[i] = k;
|
|
11
|
+
}
|
|
12
|
+
return table;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** First index >= from where segment occurs in text, or -1. */
|
|
16
|
+
function kmpFind(text, segment, from) {
|
|
17
|
+
if (segment.length === 0) return from;
|
|
18
|
+
const table = failureTable(segment);
|
|
19
|
+
let k = 0;
|
|
20
|
+
for (let i = from; i < text.length; i += 1) {
|
|
21
|
+
while (k > 0 && text[i] !== segment[k]) k = table[k - 1];
|
|
22
|
+
if (text[i] === segment[k]) k += 1;
|
|
23
|
+
if (k === segment.length) return i - segment.length + 1;
|
|
24
|
+
}
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Compile a LIKE pattern once (case-folded). */
|
|
29
|
+
export function compileLike(pattern) {
|
|
30
|
+
const folded = pattern.toLowerCase();
|
|
31
|
+
const segments = folded.split("%");
|
|
32
|
+
return { segments, anchoredStart: !folded.startsWith("%"), anchoredEnd: !folded.endsWith("%") };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function likeMatches(compiled, value) {
|
|
36
|
+
const text = value.toLowerCase();
|
|
37
|
+
const { segments } = compiled;
|
|
38
|
+
if (segments.length === 1) return text === segments[0];
|
|
39
|
+
const head = segments[0];
|
|
40
|
+
const tail = segments[segments.length - 1];
|
|
41
|
+
if (!text.startsWith(head)) return false;
|
|
42
|
+
let cursor = head.length;
|
|
43
|
+
for (let i = 1; i < segments.length - 1; i += 1) {
|
|
44
|
+
const found = kmpFind(text, segments[i], cursor);
|
|
45
|
+
if (found < 0) return false;
|
|
46
|
+
cursor = found + segments[i].length;
|
|
47
|
+
}
|
|
48
|
+
return text.length - tail.length >= cursor && text.endsWith(tail);
|
|
49
|
+
}
|