@firedrill-tools/netsuite 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 +214 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +6905 -0
- package/firedrill/concurrency-limited.scenario.json +11 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/ns-analytics.drill.json +73 -0
- package/firedrill/ns-concurrency.drill.json +53 -0
- package/firedrill/ns-default-identity.drill.json +43 -0
- package/firedrill/ns-denied.drill.json +38 -0
- package/firedrill/ns-insufficient-permission.drill.json +53 -0
- package/firedrill/ns-invalid-login.drill.json +53 -0
- package/firedrill/ns-order-to-cash.drill.json +93 -0
- package/firedrill/ns-record-locked.drill.json +53 -0
- package/firedrill/ns-records.drill.json +68 -0
- package/firedrill/ns-role-ar-clerk.drill.json +58 -0
- package/firedrill/ns-role-sales-rep.drill.json +58 -0
- package/firedrill/ns-subsidiary-scope.drill.json +58 -0
- package/firedrill/ns-tight-limits.drill.json +58 -0
- package/firedrill/ns-transform-lost.drill.json +63 -0
- package/firedrill/ns-write-outage.drill.json +63 -0
- package/firedrill/record-locked.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +17 -0
- package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
- package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
- package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/app.js +182 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/icons.js +59 -0
- package/firedrill/tools/netsuite/app/site/index.html +68 -0
- package/firedrill/tools/netsuite/app/site/menus.js +111 -0
- package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
- package/firedrill/tools/netsuite/app/site/store.js +95 -0
- package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
- package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
- package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
- package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
- package/firedrill/tools/netsuite/app/site/ui.js +119 -0
- package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
- package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
- package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
- package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
- package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
- package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
- package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
- package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
- package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
- package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
- package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
- package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
- package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
- package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
- package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
- package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
- package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
- package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
- package/firedrill/tools/netsuite/behavior.mjs +13 -0
- package/firedrill/tools/netsuite/lib/body.mjs +185 -0
- package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
- package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
- package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
- package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
- package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
- package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
- package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
- package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
- package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
- package/firedrill/tools/netsuite/lib/project.mjs +178 -0
- package/firedrill/tools/netsuite/lib/q.mjs +280 -0
- package/firedrill/tools/netsuite/lib/session.mjs +188 -0
- package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
- package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
- package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
- package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
- package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
- package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
- package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
- package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
- package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
- package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
- package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
- package/firedrill/transform-response-lost.scenario.json +11 -0
- package/firedrill/world.json +7711 -0
- package/firedrill/write-outage.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +6904 -0
- package/test/conformance.mjs +701 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// NetSuite problem-envelope helpers. Handlers raise declared Tool errors carrying the envelope fields in
|
|
2
|
+
// `details`; the route codecs turn those into the documented `o:errorDetails` body.
|
|
3
|
+
|
|
4
|
+
/** Longest piece of caller text ever quoted back in a `detail` string. */
|
|
5
|
+
export const QUOTE_LIMIT = 200;
|
|
6
|
+
|
|
7
|
+
export function quote(value) {
|
|
8
|
+
const text = typeof value === "string" ? value : String(value ?? "");
|
|
9
|
+
return text.length > QUOTE_LIMIT ? `${text.slice(0, QUOTE_LIMIT)}…` : text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TITLES = new Map([
|
|
13
|
+
[400, "Bad Request"],
|
|
14
|
+
[401, "Unauthorized"],
|
|
15
|
+
[403, "Forbidden"],
|
|
16
|
+
[404, "Not Found"],
|
|
17
|
+
[409, "Conflict"],
|
|
18
|
+
[429, "Bad Request"],
|
|
19
|
+
[500, "Internal Server Error"],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const STATUS = new Map([
|
|
23
|
+
["INVALID_LOGIN", 401],
|
|
24
|
+
["INSUFFICIENT_PERMISSION", 403],
|
|
25
|
+
["NONEXISTENT_ID", 404],
|
|
26
|
+
["INVALID_ID", 400],
|
|
27
|
+
["INVALID_CONTENT", 400],
|
|
28
|
+
["INVALID_KEY_OR_REF", 400],
|
|
29
|
+
["INVALID_PARAMETER", 400],
|
|
30
|
+
["INVALID_REQUEST", 400],
|
|
31
|
+
["USER_ERROR", 400],
|
|
32
|
+
["RCRD_HAS_BEEN_CHANGED", 409],
|
|
33
|
+
["CONCURRENCY_LIMIT_EXCEEDED", 429],
|
|
34
|
+
["UNEXPECTED_ERROR", 500],
|
|
35
|
+
["RESULT_SET_TOO_LARGE", 400],
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const TYPES = new Map([
|
|
39
|
+
[400, "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.1"],
|
|
40
|
+
[401, "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.2"],
|
|
41
|
+
[403, "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.4"],
|
|
42
|
+
[404, "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.5"],
|
|
43
|
+
[409, "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.10"],
|
|
44
|
+
[429, "https://www.rfc-editor.org/rfc/rfc6585#section-4"],
|
|
45
|
+
[500, "https://www.rfc-editor.org/rfc/rfc9110#section-15.6.1"],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export function statusFor(code) {
|
|
49
|
+
return STATUS.get(code) ?? 400;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build the documented NetSuite problem body for one Tool error code. */
|
|
53
|
+
export function problemBody(code, detail, extra = {}) {
|
|
54
|
+
const status = statusFor(code);
|
|
55
|
+
const entry = { detail, "o:errorCode": code };
|
|
56
|
+
for (const [key, value] of [
|
|
57
|
+
["o:errorPath", extra.errorPath],
|
|
58
|
+
["o:urlPath", extra.urlPath],
|
|
59
|
+
["o:errorQueryParam", extra.errorQueryParam],
|
|
60
|
+
["o:errorHeader", extra.errorHeader],
|
|
61
|
+
]) {
|
|
62
|
+
if (typeof value === "string" && value.length > 0) entry[key] = value;
|
|
63
|
+
}
|
|
64
|
+
const body = {
|
|
65
|
+
type: TYPES.get(status) ?? TYPES.get(400),
|
|
66
|
+
title: TITLES.get(status) ?? "Bad Request",
|
|
67
|
+
status,
|
|
68
|
+
"o:errorDetails": [entry],
|
|
69
|
+
};
|
|
70
|
+
if (code === "CONCURRENCY_LIMIT_EXCEEDED") body["o:errorCode"] = "USER_ERROR";
|
|
71
|
+
return body;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Raise one of the declared errors with the envelope fields the codec needs. */
|
|
75
|
+
export function fail(context, code, detail, extra = {}) {
|
|
76
|
+
context.fail({
|
|
77
|
+
code,
|
|
78
|
+
message: detail.length > 3900 ? `${detail.slice(0, 3900)}…` : detail,
|
|
79
|
+
retryable: code === "CONCURRENCY_LIMIT_EXCEEDED" || code === "UNEXPECTED_ERROR",
|
|
80
|
+
details: {
|
|
81
|
+
detail,
|
|
82
|
+
...(typeof extra.errorPath === "string" ? { errorPath: extra.errorPath } : {}),
|
|
83
|
+
...(typeof extra.urlPath === "string" ? { urlPath: extra.urlPath } : {}),
|
|
84
|
+
...(typeof extra.errorQueryParam === "string" ? { errorQueryParam: extra.errorQueryParam } : {}),
|
|
85
|
+
...(typeof extra.errorHeader === "string" ? { errorHeader: extra.errorHeader } : {}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const LOGIN_DETAIL =
|
|
91
|
+
"Invalid login attempt. For more details, see the Login Audit Trail in the NetSuite UI at Setup > Users/Roles > User Management > View Login Audit Trail.";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The MCP-shaped record reads, the record-type metadata catalog and the two SuiteQL operations.
|
|
2
|
+
import { quote } from "./errors.mjs";
|
|
3
|
+
import { requireId } from "./primitives.mjs";
|
|
4
|
+
import { open } from "./session.mjs";
|
|
5
|
+
import { collection, paging } from "./collections.mjs";
|
|
6
|
+
import { projectSubsidiary } from "./project-tran.mjs";
|
|
7
|
+
import { KINDS, loadRecord, projectOne } from "./handlers-read.mjs";
|
|
8
|
+
import { catalog, describe, RECORD_TYPES } from "./metadata.mjs";
|
|
9
|
+
import { executeSql } from "./suiteql-select.mjs";
|
|
10
|
+
import { SqlError } from "./suiteql-parse.mjs";
|
|
11
|
+
|
|
12
|
+
const BY_RESOURCE = new Map(Object.entries(KINDS).map(([kind, definition]) => [definition.resource, kind]));
|
|
13
|
+
const SQL_MAX_ROWS = 1000;
|
|
14
|
+
const SUITEQL_PATH = "/services/rest/query/v1/suiteql";
|
|
15
|
+
|
|
16
|
+
function runQuery(session, statement, params) {
|
|
17
|
+
if (typeof statement !== "string" || statement.trim().length === 0) {
|
|
18
|
+
session.fail("INVALID_REQUEST", "A SuiteQL request must carry a non-empty q value.");
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return executeSql(session, statement, params);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof SqlError) {
|
|
24
|
+
session.fail("INVALID_REQUEST", `Invalid SuiteQL statement: ${error.message}.`);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function boundParams(session, params) {
|
|
31
|
+
if (params === undefined) return [];
|
|
32
|
+
if (!Array.isArray(params)) {
|
|
33
|
+
session.fail("INVALID_PARAMETER", "The params value must be an array of bind values.", { errorPath: "params" });
|
|
34
|
+
}
|
|
35
|
+
return params;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const metaOperations = {
|
|
39
|
+
"record.get": (input, context) => {
|
|
40
|
+
const session = open(context);
|
|
41
|
+
const recordType = input.recordType;
|
|
42
|
+
if (typeof recordType !== "string" || !RECORD_TYPES.includes(recordType)) {
|
|
43
|
+
session.fail("INVALID_CONTENT", `Record type ${quote(String(recordType))} is not supported by this service.`, {
|
|
44
|
+
errorPath: "recordType",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (recordType === "subsidiary") {
|
|
48
|
+
session.permit("LIST_SUBSIDIARY", "view");
|
|
49
|
+
requireId(context, input.recordId, "id");
|
|
50
|
+
const row = session.get("subsidiaries", input.recordId);
|
|
51
|
+
if (row === null || !session.inScope(row.id)) {
|
|
52
|
+
session.fail("NONEXISTENT_ID", `Invalid subsidiary reference key ${quote(String(input.recordId))}.`);
|
|
53
|
+
}
|
|
54
|
+
const index = new Map(session.rows("subsidiaries").map((entry) => [entry.id, entry]));
|
|
55
|
+
return projectSubsidiary(row, index);
|
|
56
|
+
}
|
|
57
|
+
const kind = BY_RESOURCE.get(recordType);
|
|
58
|
+
session.permit(KINDS[kind].permission, "view");
|
|
59
|
+
const row = loadRecord(session, kind, input.recordId);
|
|
60
|
+
return projectOne(session, kind, row, true);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
"record.metadata": (input, context) => {
|
|
64
|
+
const session = open(context);
|
|
65
|
+
session.permit("SETUP_RECORD_METADATA", "view");
|
|
66
|
+
if (input.recordType === undefined) return catalog(session.now);
|
|
67
|
+
if (typeof input.recordType !== "string" || !RECORD_TYPES.includes(input.recordType)) {
|
|
68
|
+
session.fail("INVALID_CONTENT", `Record type ${quote(String(input.recordType))} is not supported by this service.`, {
|
|
69
|
+
errorPath: "recordType",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return describe(input.recordType, session.now);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
"suiteql.query": (input, context) => {
|
|
76
|
+
const session = open(context);
|
|
77
|
+
session.permit("REPO_ANALYTICS", "view");
|
|
78
|
+
const prefer = typeof input.prefer === "string" ? input.prefer.toLowerCase() : "";
|
|
79
|
+
if (!prefer.split(",").map((entry) => entry.trim()).includes("transient")) {
|
|
80
|
+
session.fail("INVALID_REQUEST", "The Prefer: transient header is required for SuiteQL requests.", {
|
|
81
|
+
errorHeader: "Prefer",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const { limit, offset } = paging(session, input, 10);
|
|
85
|
+
const rows = runQuery(session, input.q, boundParams(session, input.params));
|
|
86
|
+
const entries = rows.slice(offset, offset + limit).map((row) => ({
|
|
87
|
+
links: [{ rel: "self", href: SUITEQL_PATH }],
|
|
88
|
+
...row,
|
|
89
|
+
}));
|
|
90
|
+
return collection(session, SUITEQL_PATH, entries, { limit, offset, total: rows.length });
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
"suiteql.run": (input, context) => {
|
|
94
|
+
const session = open(context);
|
|
95
|
+
session.permit("REPO_ANALYTICS", "view");
|
|
96
|
+
const rows = runQuery(session, input.query, []);
|
|
97
|
+
const items = rows.slice(0, SQL_MAX_ROWS);
|
|
98
|
+
return { items, count: items.length, hasMore: rows.length > items.length, totalResults: rows.length };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Read operations: the five record collections, their singular reads, the sales-order item sublist and subsidiaries.
|
|
2
|
+
import { quote } from "./errors.mjs";
|
|
3
|
+
import { requireId } from "./primitives.mjs";
|
|
4
|
+
import { open } from "./session.mjs";
|
|
5
|
+
import { applyFilter, booleanOf, collection, paging, projectionContext } from "./collections.mjs";
|
|
6
|
+
import { projectCustomer, projectLine, selfLinks } from "./project.mjs";
|
|
7
|
+
import { projectInvoice, projectItem, projectPayment, projectSalesOrder, projectSubsidiary } from "./project-tran.mjs";
|
|
8
|
+
|
|
9
|
+
const REC = "/services/rest/record/v1";
|
|
10
|
+
|
|
11
|
+
export const KINDS = {
|
|
12
|
+
customer: { namespace: "customers", resource: "customer", permission: "LIST_CUSTJOB", label: "customer" },
|
|
13
|
+
"sales-order": { namespace: "sales-orders", resource: "salesOrder", permission: "TRAN_SALESORD", label: "sales order" },
|
|
14
|
+
invoice: { namespace: "invoices", resource: "invoice", permission: "TRAN_CUSTINVC", label: "invoice" },
|
|
15
|
+
"customer-payment": { namespace: "customer-payments", resource: "customerPayment", permission: "TRAN_CUSTPYMT", label: "customer payment" },
|
|
16
|
+
"inventory-item": { namespace: "items", resource: "inventoryItem", permission: "LIST_ITEM", label: "inventory item" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const visible = (session, kind, row) => (kind === "inventory-item"
|
|
20
|
+
? row.subsidiaryIds.some((id) => session.inScope(id)) && row.itemType === "inventoryItem"
|
|
21
|
+
: session.inScope(row.subsidiaryId));
|
|
22
|
+
|
|
23
|
+
export function loadRecord(session, kind, recordId) {
|
|
24
|
+
const definition = KINDS[kind];
|
|
25
|
+
requireId(session.context, recordId, "id");
|
|
26
|
+
const row = session.get(definition.namespace, recordId);
|
|
27
|
+
if (row === null || !visible(session, kind, row)) {
|
|
28
|
+
session.fail("NONEXISTENT_ID", `Invalid ${definition.resource} reference key ${quote(recordId)}.`, {
|
|
29
|
+
urlPath: `${REC}/${definition.resource}/${recordId}`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return row;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Most field names one `fields` selection may name, matching the documented REST record-service bound. */
|
|
36
|
+
const MAX_FIELDS = 40;
|
|
37
|
+
/** Longest single field name accepted before it can only be an unknown field. */
|
|
38
|
+
const MAX_FIELD_NAME = 120;
|
|
39
|
+
|
|
40
|
+
/** Restrict a projected record to the caller's `fields` selection. */
|
|
41
|
+
function selectFields(session, record, fields) {
|
|
42
|
+
if (fields === undefined) return record;
|
|
43
|
+
if (!Array.isArray(fields)) {
|
|
44
|
+
session.fail("INVALID_PARAMETER", "The fields parameter must be a comma-separated list of field names.", { errorQueryParam: "fields" });
|
|
45
|
+
}
|
|
46
|
+
if (fields.length > MAX_FIELDS) {
|
|
47
|
+
session.fail("INVALID_PARAMETER", `The fields parameter is limited to ${MAX_FIELDS} field names.`, { errorQueryParam: "fields" });
|
|
48
|
+
}
|
|
49
|
+
const picked = { links: record.links, id: record.id };
|
|
50
|
+
for (const name of fields) {
|
|
51
|
+
if (typeof name !== "string" || name.length === 0 || name.length > MAX_FIELD_NAME) {
|
|
52
|
+
session.fail("INVALID_PARAMETER", "The fields parameter must be a comma-separated list of field names.", { errorQueryParam: "fields" });
|
|
53
|
+
}
|
|
54
|
+
if (!Object.hasOwn(record, name)) {
|
|
55
|
+
session.fail("INVALID_PARAMETER", `Unknown field ${quote(name)} in the fields parameter.`, { errorQueryParam: "fields" });
|
|
56
|
+
}
|
|
57
|
+
picked[name] = record[name];
|
|
58
|
+
}
|
|
59
|
+
return picked;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function projectOne(session, kind, row, expand) {
|
|
63
|
+
if (kind === "inventory-item") {
|
|
64
|
+
const subsidiaries = new Map();
|
|
65
|
+
for (const id of row.subsidiaryIds) {
|
|
66
|
+
const subsidiary = session.get("subsidiaries", id);
|
|
67
|
+
if (subsidiary !== null) subsidiaries.set(id, subsidiary);
|
|
68
|
+
}
|
|
69
|
+
return projectItem(row, subsidiaries);
|
|
70
|
+
}
|
|
71
|
+
const context = projectionContext(session, expand);
|
|
72
|
+
if (kind === "customer") return projectCustomer(row, context);
|
|
73
|
+
if (kind === "sales-order") return projectSalesOrder(row, context);
|
|
74
|
+
if (kind === "invoice") return projectInvoice(row, context);
|
|
75
|
+
return projectPayment(row, context);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function listHandler(kind) {
|
|
79
|
+
const definition = KINDS[kind];
|
|
80
|
+
return (input, context) => {
|
|
81
|
+
const session = open(context);
|
|
82
|
+
session.permit(definition.permission, "view");
|
|
83
|
+
const { limit, offset } = paging(session, input);
|
|
84
|
+
const rows = session.rows(definition.namespace).filter((row) => visible(session, kind, row));
|
|
85
|
+
const filtered = applyFilter(session, definition.resource, rows, input.q);
|
|
86
|
+
const page = filtered.slice(offset, offset + limit);
|
|
87
|
+
const entries = page.map((row) => ({ links: selfLinks(definition.resource, row.id), id: row.id }));
|
|
88
|
+
return collection(session, `${REC}/${definition.resource}`, entries, {
|
|
89
|
+
limit, offset, total: filtered.length, q: input.q,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getHandler(kind) {
|
|
95
|
+
const definition = KINDS[kind];
|
|
96
|
+
return (input, context) => {
|
|
97
|
+
const session = open(context);
|
|
98
|
+
session.permit(definition.permission, "view");
|
|
99
|
+
const expand = booleanOf(session, input.expandSubResources, "expandSubResources");
|
|
100
|
+
booleanOf(session, input.simpleEnumFormat, "simpleEnumFormat");
|
|
101
|
+
const row = loadRecord(session, kind, input.recordId);
|
|
102
|
+
return selectFields(session, projectOne(session, kind, row, expand), input.fields);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const readOperations = {
|
|
107
|
+
"customer.list": listHandler("customer"),
|
|
108
|
+
"customer.get": getHandler("customer"),
|
|
109
|
+
"sales-order.list": listHandler("sales-order"),
|
|
110
|
+
"sales-order.get": getHandler("sales-order"),
|
|
111
|
+
"invoice.list": listHandler("invoice"),
|
|
112
|
+
"invoice.get": getHandler("invoice"),
|
|
113
|
+
"customer-payment.list": listHandler("customer-payment"),
|
|
114
|
+
"customer-payment.get": getHandler("customer-payment"),
|
|
115
|
+
"inventory-item.list": listHandler("inventory-item"),
|
|
116
|
+
"inventory-item.get": getHandler("inventory-item"),
|
|
117
|
+
|
|
118
|
+
"sales-order.items.list": (input, context) => {
|
|
119
|
+
const session = open(context);
|
|
120
|
+
session.permit("TRAN_SALESORD", "view");
|
|
121
|
+
const { limit, offset } = paging(session, input);
|
|
122
|
+
const order = loadRecord(session, "sales-order", input.recordId);
|
|
123
|
+
const projection = projectionContext(session, true);
|
|
124
|
+
const entries = order.lines
|
|
125
|
+
.slice(offset, offset + limit)
|
|
126
|
+
.map((line) => projectLine("salesOrder", order.id, line, projection.items));
|
|
127
|
+
return collection(session, `${REC}/salesOrder/${order.id}/item`, entries, {
|
|
128
|
+
limit, offset, total: order.lines.length,
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
"subsidiary.list": (input, context) => {
|
|
133
|
+
const session = open(context);
|
|
134
|
+
session.permit("LIST_SUBSIDIARY", "view");
|
|
135
|
+
const { limit, offset } = paging(session, input);
|
|
136
|
+
const rows = session.rows("subsidiaries").filter((row) => session.inScope(row.id));
|
|
137
|
+
const index = new Map(rows.map((row) => [row.id, row]));
|
|
138
|
+
const entries = rows.slice(offset, offset + limit).map((row) => projectSubsidiary(row, index));
|
|
139
|
+
return collection(session, `${REC}/subsidiary`, entries, { limit, offset, total: rows.length });
|
|
140
|
+
},
|
|
141
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The signed-in context of one SuiteTalk session: account, employee, role, subsidiary scope and server time.
|
|
2
|
+
// NetSuite exposes this to a browser session through the application shell rather than a REST resource, so this
|
|
3
|
+
// operation carries no HTTP route; the browser app reads it to render the account/role chrome and the world clock.
|
|
4
|
+
import { open, PERMISSION_LABELS } from "./session.mjs";
|
|
5
|
+
|
|
6
|
+
const LEVELS = ["none", "view", "create", "edit", "full"];
|
|
7
|
+
|
|
8
|
+
function permissionList(role) {
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const [name, label] of PERMISSION_LABELS) {
|
|
11
|
+
const granted = role.isAdmin === true
|
|
12
|
+
? "full"
|
|
13
|
+
: (role.permissions !== undefined && Object.hasOwn(role.permissions, name) ? role.permissions[name] : "none");
|
|
14
|
+
entries.push({ name, label, level: LEVELS.includes(granted) ? granted : "none" });
|
|
15
|
+
}
|
|
16
|
+
return entries;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const sessionOperations = {
|
|
20
|
+
"session.get": (input, context) => {
|
|
21
|
+
const session = open(context);
|
|
22
|
+
session.permit("SETUP_RECORD_METADATA", "view");
|
|
23
|
+
const account = session.account;
|
|
24
|
+
const employee = session.employee;
|
|
25
|
+
const scopeRow = session.scope === "0" ? null : session.get("subsidiaries", session.scope);
|
|
26
|
+
return {
|
|
27
|
+
account: {
|
|
28
|
+
accountId: account.accountId,
|
|
29
|
+
companyName: account.companyName,
|
|
30
|
+
baseCurrency: account.baseCurrency,
|
|
31
|
+
dateFormat: account.dateFormat,
|
|
32
|
+
isOneWorld: account.isOneWorld === true,
|
|
33
|
+
},
|
|
34
|
+
user: {
|
|
35
|
+
id: employee.id,
|
|
36
|
+
entityId: employee.entityId,
|
|
37
|
+
name: `${employee.firstName} ${employee.lastName}`,
|
|
38
|
+
email: employee.email,
|
|
39
|
+
title: employee.title,
|
|
40
|
+
},
|
|
41
|
+
role: {
|
|
42
|
+
id: session.role.id,
|
|
43
|
+
name: session.role.name,
|
|
44
|
+
isAdmin: session.role.isAdmin === true,
|
|
45
|
+
permissions: permissionList(session.role),
|
|
46
|
+
},
|
|
47
|
+
subsidiaryScope: session.scope === "0" ? { id: "0", refName: "All" } : { id: session.scope, refName: scopeRow === null ? session.scope : scopeRow.name },
|
|
48
|
+
serverTime: session.now,
|
|
49
|
+
accountDate: session.today,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The nine write operations, grouped by record.
|
|
2
|
+
import { customerWrites } from "./write-customer.mjs";
|
|
3
|
+
import { orderWrites } from "./write-order.mjs";
|
|
4
|
+
import { invoiceWrites } from "./write-invoice.mjs";
|
|
5
|
+
|
|
6
|
+
export const writeOperations = { ...customerWrites, ...orderWrites, ...invoiceWrites };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Record-type metadata. The field table below mirrors this Tool's own state schemas and the `q` filter tables;
|
|
2
|
+
// the response body is assembled from it on every call, never stored as a blob.
|
|
3
|
+
import { FILTER_FIELDS } from "./collections.mjs";
|
|
4
|
+
|
|
5
|
+
const string = (maxLength, extra = {}) => ({ type: "string", maxLength, ...extra });
|
|
6
|
+
const number = (extra = {}) => ({ type: "number", ...extra });
|
|
7
|
+
const boolean = () => ({ type: "boolean" });
|
|
8
|
+
const reference = (recordType) => ({ type: "object", "x-ns-reference": recordType });
|
|
9
|
+
const date = () => ({ type: "string", format: "date" });
|
|
10
|
+
const timestamp = () => ({ type: "string", format: "date-time" });
|
|
11
|
+
|
|
12
|
+
export const RECORD_TYPES = ["customer", "salesOrder", "invoice", "customerPayment", "inventoryItem", "subsidiary"];
|
|
13
|
+
|
|
14
|
+
const FIELDS = {
|
|
15
|
+
customer: {
|
|
16
|
+
id: [string(15), "Internal ID", true],
|
|
17
|
+
entityId: [string(83), "Name", true],
|
|
18
|
+
companyName: [string(83), "Company Name", false],
|
|
19
|
+
isPerson: [boolean(), "Individual", true],
|
|
20
|
+
firstName: [string(32), "First Name", false],
|
|
21
|
+
middleName: [string(32), "Middle Name", false],
|
|
22
|
+
lastName: [string(32), "Last Name", false],
|
|
23
|
+
email: [string(254), "Email", false],
|
|
24
|
+
phone: [string(22), "Phone", false],
|
|
25
|
+
altPhone: [string(22), "Alt. Phone", false],
|
|
26
|
+
subsidiary: [reference("subsidiary"), "Subsidiary", true],
|
|
27
|
+
currency: [reference("currency"), "Primary Currency", false],
|
|
28
|
+
terms: [reference("term"), "Terms", false],
|
|
29
|
+
creditLimit: [number({ minimum: 0 }), "Credit Limit", false],
|
|
30
|
+
comments: [string(999), "Comments", false],
|
|
31
|
+
isInactive: [boolean(), "Inactive", true],
|
|
32
|
+
balance: [number(), "Balance", false],
|
|
33
|
+
unbilledOrders: [number(), "Unbilled Orders", false],
|
|
34
|
+
overdueBalance: [number(), "Overdue Balance", false],
|
|
35
|
+
dateCreated: [timestamp(), "Date Created", true],
|
|
36
|
+
lastModifiedDate: [timestamp(), "Last Modified", true],
|
|
37
|
+
},
|
|
38
|
+
salesOrder: {
|
|
39
|
+
id: [string(15), "Internal ID", true],
|
|
40
|
+
tranId: [string(45), "Document Number", true],
|
|
41
|
+
tranDate: [date(), "Date", true],
|
|
42
|
+
entity: [reference("customer"), "Customer", true],
|
|
43
|
+
subsidiary: [reference("subsidiary"), "Subsidiary", true],
|
|
44
|
+
status: [string(40, { enum: ["A", "B", "D", "E", "F", "G", "H"] }), "Status", true],
|
|
45
|
+
memo: [string(999), "Memo", false],
|
|
46
|
+
otherRefNum: [string(45), "PO/Check Number", false],
|
|
47
|
+
currency: [reference("currency"), "Currency", false],
|
|
48
|
+
terms: [reference("term"), "Terms", false],
|
|
49
|
+
subtotal: [number(), "Subtotal", false],
|
|
50
|
+
taxTotal: [number(), "Tax Total", false],
|
|
51
|
+
total: [number(), "Amount", true],
|
|
52
|
+
item: [{ type: "array", "x-ns-sublist": "item" }, "Items", false],
|
|
53
|
+
},
|
|
54
|
+
invoice: {
|
|
55
|
+
id: [string(15), "Internal ID", true],
|
|
56
|
+
tranId: [string(45), "Document Number", true],
|
|
57
|
+
tranDate: [date(), "Date", true],
|
|
58
|
+
dueDate: [date(), "Due Date", true],
|
|
59
|
+
entity: [reference("customer"), "Customer", true],
|
|
60
|
+
subsidiary: [reference("subsidiary"), "Subsidiary", true],
|
|
61
|
+
createdFrom: [reference("salesOrder"), "Created From", true],
|
|
62
|
+
status: [string(40, { enum: ["A", "B", "C", "V"] }), "Status", true],
|
|
63
|
+
memo: [string(999), "Memo", false],
|
|
64
|
+
currency: [reference("currency"), "Currency", false],
|
|
65
|
+
terms: [reference("term"), "Terms", false],
|
|
66
|
+
subtotal: [number(), "Subtotal", false],
|
|
67
|
+
taxTotal: [number(), "Tax Total", false],
|
|
68
|
+
total: [number(), "Amount", true],
|
|
69
|
+
amountPaid: [number(), "Amount Paid", false],
|
|
70
|
+
amountRemaining: [number(), "Amount Remaining", true],
|
|
71
|
+
item: [{ type: "array", "x-ns-sublist": "item" }, "Items", false],
|
|
72
|
+
},
|
|
73
|
+
customerPayment: {
|
|
74
|
+
id: [string(15), "Internal ID", true],
|
|
75
|
+
tranId: [string(45), "Document Number", true],
|
|
76
|
+
tranDate: [date(), "Date", true],
|
|
77
|
+
customer: [reference("customer"), "Customer", true],
|
|
78
|
+
subsidiary: [reference("subsidiary"), "Subsidiary", false],
|
|
79
|
+
payment: [number(), "Payment Amount", true],
|
|
80
|
+
unapplied: [number(), "Unapplied", false],
|
|
81
|
+
account: [reference("account"), "Account", false],
|
|
82
|
+
currency: [reference("currency"), "Currency", false],
|
|
83
|
+
status: [string(40, { enum: ["A", "B"] }), "Status", true],
|
|
84
|
+
apply: [{ type: "array", "x-ns-sublist": "apply" }, "Apply", false],
|
|
85
|
+
},
|
|
86
|
+
inventoryItem: {
|
|
87
|
+
id: [string(15), "Internal ID", true],
|
|
88
|
+
itemId: [string(60), "Item Name/Number", true],
|
|
89
|
+
displayName: [string(60), "Display Name", true],
|
|
90
|
+
description: [string(999), "Description", false],
|
|
91
|
+
basePrice: [number({ minimum: 0 }), "Base Price", true],
|
|
92
|
+
cost: [number({ minimum: 0 }), "Purchase Price", false],
|
|
93
|
+
quantityOnHand: [number({ minimum: 0 }), "Quantity On Hand", false],
|
|
94
|
+
incomeAccount: [reference("account"), "Income Account", false],
|
|
95
|
+
taxSchedule: [reference("taxSchedule"), "Tax Schedule", false],
|
|
96
|
+
subsidiary: [{ type: "array", "x-ns-sublist": "subsidiary" }, "Subsidiary", false],
|
|
97
|
+
isInactive: [boolean(), "Inactive", true],
|
|
98
|
+
},
|
|
99
|
+
subsidiary: {
|
|
100
|
+
id: [string(15), "Internal ID", true],
|
|
101
|
+
name: [string(120), "Name", true],
|
|
102
|
+
parent: [reference("subsidiary"), "Parent Subsidiary", false],
|
|
103
|
+
country: [string(2), "Country", false],
|
|
104
|
+
currency: [reference("currency"), "Currency", false],
|
|
105
|
+
isElimination: [boolean(), "Elimination", false],
|
|
106
|
+
isInactive: [boolean(), "Inactive", false],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const FILTER_KEY = {
|
|
111
|
+
customer: "customer",
|
|
112
|
+
salesOrder: "salesOrder",
|
|
113
|
+
invoice: "invoice",
|
|
114
|
+
customerPayment: "customerPayment",
|
|
115
|
+
inventoryItem: "inventoryItem",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The `application/schema+json`-shaped description of one record type. */
|
|
119
|
+
export function describe(recordType, serverTime) {
|
|
120
|
+
const fields = FIELDS[recordType];
|
|
121
|
+
const filterable = FILTER_KEY[recordType] === undefined ? {} : FILTER_FIELDS[FILTER_KEY[recordType]];
|
|
122
|
+
const properties = {};
|
|
123
|
+
const required = [];
|
|
124
|
+
for (const [name, [schema, title, isRequired]] of Object.entries(fields)) {
|
|
125
|
+
properties[name] = { ...schema, title };
|
|
126
|
+
if (Object.hasOwn(filterable, name)) properties[name]["x-ns-filterable"] = true;
|
|
127
|
+
if (isRequired === true) required.push(name);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
"x-ns-type": recordType,
|
|
131
|
+
"x-ns-serverTime": serverTime,
|
|
132
|
+
type: "object",
|
|
133
|
+
properties,
|
|
134
|
+
required,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function catalog(serverTime) {
|
|
139
|
+
return {
|
|
140
|
+
links: [{ rel: "self", href: "/services/rest/record/v1/metadata-catalog" }],
|
|
141
|
+
"x-ns-serverTime": serverTime,
|
|
142
|
+
items: RECORD_TYPES.map((name) => ({
|
|
143
|
+
name,
|
|
144
|
+
links: [{ rel: "canonical", href: `/services/rest/record/v1/metadata-catalog/${name}` }],
|
|
145
|
+
})),
|
|
146
|
+
totalResults: RECORD_TYPES.length,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Deterministic primitives: internal ids, money, account-local dates and safe caller-key maps.
|
|
2
|
+
import { fail } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
const ID_PATTERN = /^[1-9][0-9]{0,14}$/;
|
|
5
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
6
|
+
|
|
7
|
+
export const isInternalId = (value) => typeof value === "string" && ID_PATTERN.test(value);
|
|
8
|
+
export const isUnsafeKey = (key) => UNSAFE_KEYS.has(key);
|
|
9
|
+
|
|
10
|
+
/** A prototype-free map built from caller-chosen keys. */
|
|
11
|
+
export function safeMap(entries) {
|
|
12
|
+
const map = new Map();
|
|
13
|
+
for (const [key, value] of entries) map.set(key, value);
|
|
14
|
+
return map;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Validate a caller-supplied path id before any state lookup. */
|
|
18
|
+
export function requireId(context, value, label = "id") {
|
|
19
|
+
if (!isInternalId(value)) {
|
|
20
|
+
fail(context, "INVALID_ID", `Invalid value for the ${label} parameter. A NetSuite internal id is a positive integer.`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const round2 = (value) => Math.round((value + Number.EPSILON) * 100) / 100;
|
|
26
|
+
export const round4 = (value) => Math.round((value + Number.EPSILON) * 10000) / 10000;
|
|
27
|
+
export const cents = (value) => Math.round(value * 100);
|
|
28
|
+
export const fromCents = (value) => round2(value / 100);
|
|
29
|
+
|
|
30
|
+
const DAY_MS = 86400000;
|
|
31
|
+
const pad = (value, width) => String(value).padStart(width, "0");
|
|
32
|
+
|
|
33
|
+
/** Days since the Unix epoch for an account-local civil date. */
|
|
34
|
+
export function dayNumber(year, month, day) {
|
|
35
|
+
return Math.floor(Date.UTC(year, month - 1, day) / DAY_MS);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function civilFromDay(days) {
|
|
39
|
+
const date = new Date(days * DAY_MS);
|
|
40
|
+
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Account-local ISO date (YYYY-MM-DD) for a virtual-time microsecond value at a fixed UTC offset. */
|
|
44
|
+
export function accountDate(nowUs, offsetMinutes) {
|
|
45
|
+
const localMs = Math.floor(nowUs / 1000) + offsetMinutes * 60000;
|
|
46
|
+
const date = new Date(localMs);
|
|
47
|
+
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ISO-8601 UTC timestamp with a trailing Z, truncated to whole seconds. */
|
|
51
|
+
export function timestamp(nowUs) {
|
|
52
|
+
const date = new Date(Math.floor(nowUs / 1000000) * 1000);
|
|
53
|
+
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}T${pad(date.getUTCHours(), 2)}:${pad(date.getUTCMinutes(), 2)}:${pad(date.getUTCSeconds(), 2)}Z`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ISO_DATE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/;
|
|
57
|
+
const US_DATE = /^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/;
|
|
58
|
+
|
|
59
|
+
/** Parse an account-local date literal (YYYY-MM-DD or M/D/YYYY). Returns null when unparsable. */
|
|
60
|
+
export function parseDate(value) {
|
|
61
|
+
if (typeof value !== "string") return null;
|
|
62
|
+
const text = value.trim();
|
|
63
|
+
let year;
|
|
64
|
+
let month;
|
|
65
|
+
let day;
|
|
66
|
+
const iso = ISO_DATE.exec(text);
|
|
67
|
+
if (iso !== null) {
|
|
68
|
+
year = Number(iso[1]); month = Number(iso[2]); day = Number(iso[3]);
|
|
69
|
+
} else {
|
|
70
|
+
const us = US_DATE.exec(text);
|
|
71
|
+
if (us === null) return null;
|
|
72
|
+
month = Number(us[1]); day = Number(us[2]); year = Number(us[3]);
|
|
73
|
+
}
|
|
74
|
+
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900 || year > 2999) return null;
|
|
75
|
+
const civil = civilFromDay(dayNumber(year, month, day));
|
|
76
|
+
if (civil.year !== year || civil.month !== month || civil.day !== day) return null;
|
|
77
|
+
return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const dateToDays = (iso) => {
|
|
81
|
+
const parts = ISO_DATE.exec(iso);
|
|
82
|
+
return parts === null ? null : dayNumber(Number(parts[1]), Number(parts[2]), Number(parts[3]));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function addDays(iso, days) {
|
|
86
|
+
const base = dateToDays(iso);
|
|
87
|
+
if (base === null) return iso;
|
|
88
|
+
const civil = civilFromDay(base + days);
|
|
89
|
+
return `${pad(civil.year, 4)}-${pad(civil.month, 2)}-${pad(civil.day, 2)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** UTF-8 byte length of a string, computed from its code points (no Buffer in behavior modules). */
|
|
93
|
+
export function utf8Length(text) {
|
|
94
|
+
let total = 0;
|
|
95
|
+
for (const character of text) {
|
|
96
|
+
const code = character.codePointAt(0);
|
|
97
|
+
if (code < 0x80) total += 1;
|
|
98
|
+
else if (code < 0x800) total += 2;
|
|
99
|
+
else if (code < 0x10000) total += 3;
|
|
100
|
+
else total += 4;
|
|
101
|
+
}
|
|
102
|
+
return total;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const jsonBytes = (value) => utf8Length(JSON.stringify(value));
|