@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.
Files changed (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +214 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +6905 -0
  5. package/firedrill/concurrency-limited.scenario.json +11 -0
  6. package/firedrill/conformance.suite.json +21 -0
  7. package/firedrill/ns-analytics.drill.json +73 -0
  8. package/firedrill/ns-concurrency.drill.json +53 -0
  9. package/firedrill/ns-default-identity.drill.json +43 -0
  10. package/firedrill/ns-denied.drill.json +38 -0
  11. package/firedrill/ns-insufficient-permission.drill.json +53 -0
  12. package/firedrill/ns-invalid-login.drill.json +53 -0
  13. package/firedrill/ns-order-to-cash.drill.json +93 -0
  14. package/firedrill/ns-record-locked.drill.json +53 -0
  15. package/firedrill/ns-records.drill.json +68 -0
  16. package/firedrill/ns-role-ar-clerk.drill.json +58 -0
  17. package/firedrill/ns-role-sales-rep.drill.json +58 -0
  18. package/firedrill/ns-subsidiary-scope.drill.json +58 -0
  19. package/firedrill/ns-tight-limits.drill.json +58 -0
  20. package/firedrill/ns-transform-lost.drill.json +63 -0
  21. package/firedrill/ns-write-outage.drill.json +63 -0
  22. package/firedrill/record-locked.scenario.json +11 -0
  23. package/firedrill/tight-limits.scenario.json +17 -0
  24. package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
  25. package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
  26. package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
  27. package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
  28. package/firedrill/tools/netsuite/app/site/app.js +182 -0
  29. package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
  30. package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
  31. package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
  32. package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
  33. package/firedrill/tools/netsuite/app/site/icons.js +59 -0
  34. package/firedrill/tools/netsuite/app/site/index.html +68 -0
  35. package/firedrill/tools/netsuite/app/site/menus.js +111 -0
  36. package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
  37. package/firedrill/tools/netsuite/app/site/store.js +95 -0
  38. package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
  39. package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
  40. package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
  41. package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
  42. package/firedrill/tools/netsuite/app/site/ui.js +119 -0
  43. package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
  44. package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
  45. package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
  46. package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
  47. package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
  48. package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
  49. package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
  50. package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
  51. package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
  52. package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
  53. package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
  54. package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
  55. package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
  56. package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
  57. package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
  58. package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
  59. package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
  60. package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
  61. package/firedrill/tools/netsuite/behavior.mjs +13 -0
  62. package/firedrill/tools/netsuite/lib/body.mjs +185 -0
  63. package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
  64. package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
  65. package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
  66. package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
  67. package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
  68. package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
  69. package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
  70. package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
  71. package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
  72. package/firedrill/tools/netsuite/lib/project.mjs +178 -0
  73. package/firedrill/tools/netsuite/lib/q.mjs +280 -0
  74. package/firedrill/tools/netsuite/lib/session.mjs +188 -0
  75. package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
  76. package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
  77. package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
  78. package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
  79. package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
  80. package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
  81. package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
  82. package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
  83. package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
  84. package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
  85. package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
  86. package/firedrill/transform-response-lost.scenario.json +11 -0
  87. package/firedrill/world.json +7711 -0
  88. package/firedrill/write-outage.scenario.json +11 -0
  89. package/firedrill.json +5 -0
  90. package/package.json +52 -0
  91. package/starter.json +6904 -0
  92. package/test/conformance.mjs +701 -0
@@ -0,0 +1,107 @@
1
+ // Pure decoder helpers. Every property access is guarded, JSON bodies are depth-checked with an explicit stack,
2
+ // and reserved property names are reported to the handler so the NetSuite envelope survives.
3
+ const MAX_DEPTH = 512;
4
+ const UNSAFE = new Set(["__proto__", "constructor", "prototype"]);
5
+
6
+ export function firstQuery(request, name) {
7
+ const values = Object.hasOwn(request.query, name) ? request.query[name] : undefined;
8
+ return Array.isArray(values) && values.length > 0 ? values[0] : undefined;
9
+ }
10
+
11
+ export function firstHeader(request, name) {
12
+ const values = Object.hasOwn(request.headers, name) ? request.headers[name] : undefined;
13
+ return Array.isArray(values) && values.length > 0 ? values[0] : undefined;
14
+ }
15
+
16
+ export function pathValue(request, name) {
17
+ return Object.hasOwn(request.path, name) ? request.path[name] : undefined;
18
+ }
19
+
20
+ /** Decode one path segment; a mangled percent-encoding is returned unchanged so the handler can reject it. */
21
+ export function decodeSegment(value) {
22
+ if (typeof value !== "string") return value;
23
+ try {
24
+ return decodeURIComponent(value);
25
+ } catch {
26
+ return value;
27
+ }
28
+ }
29
+
30
+ const list = (value) => (typeof value === "string" && value.length > 0
31
+ ? value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0)
32
+ : undefined);
33
+
34
+ /** Query parameters shared by the read routes. */
35
+ export function readArguments(request) {
36
+ const args = {};
37
+ for (const name of ["q", "limit", "offset", "expandSubResources", "simpleEnumFormat"]) {
38
+ const value = firstQuery(request, name);
39
+ if (value !== undefined) args[name] = value;
40
+ }
41
+ const fields = list(firstQuery(request, "fields"));
42
+ if (fields !== undefined) args.fields = fields;
43
+ return args;
44
+ }
45
+
46
+ /** Headers shared by the write routes. */
47
+ export function writeArguments(request) {
48
+ const args = {};
49
+ const key = firstHeader(request, "x-netsuite-idempotency-key");
50
+ if (key !== undefined) args.idempotencyKeyHeader = key;
51
+ const validation = firstHeader(request, "x-netsuite-propertynamevalidation");
52
+ if (validation === "ignore" || validation === "warning" || validation === "error") {
53
+ args.propertyNameValidation = validation;
54
+ }
55
+ const ifMatch = firstHeader(request, "if-match");
56
+ if (ifMatch !== undefined) args.ifMatchVersion = ifMatch;
57
+ const replace = list(firstQuery(request, "replace"));
58
+ if (replace !== undefined) args.replace = replace;
59
+ return args;
60
+ }
61
+
62
+ /** Depth and reserved-name check over a decoded JSON value, with an explicit stack. */
63
+ function inspect(value) {
64
+ const stack = [{ node: value, depth: 1 }];
65
+ while (stack.length > 0) {
66
+ const entry = stack.pop();
67
+ if (entry.depth > MAX_DEPTH) return "depth";
68
+ const node = entry.node;
69
+ if (Array.isArray(node)) {
70
+ for (const child of node) {
71
+ if (typeof child === "object" && child !== null) stack.push({ node: child, depth: entry.depth + 1 });
72
+ }
73
+ continue;
74
+ }
75
+ if (typeof node !== "object" || node === null) continue;
76
+ for (const key of Object.keys(node)) {
77
+ if (UNSAFE.has(key)) return "prototype";
78
+ const child = node[key];
79
+ if (typeof child === "object" && child !== null) stack.push({ node: child, depth: entry.depth + 1 });
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Decode a JSON request body. `body` is always present so the operation's strict input schema is satisfied;
87
+ * a shape problem travels as `bodyError`, which the handler turns into INVALID_REQUEST with NetSuite's envelope.
88
+ */
89
+ export function bodyArguments(request, { optional = true } = {}) {
90
+ const body = request.body;
91
+ if (body === undefined || body.kind === "none") {
92
+ return optional ? { body: {} } : { body: {}, bodyError: "not-json" };
93
+ }
94
+ if (body.kind !== "json") return { body: {}, bodyError: "not-json" };
95
+ const value = body.value;
96
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
97
+ return { body: {}, bodyError: "not-json" };
98
+ }
99
+ const problem = inspect(value);
100
+ if (problem !== null) return { body: {}, bodyError: problem };
101
+ return { body: value };
102
+ }
103
+
104
+ export const ACCOUNT_CONTENT_TYPE = "application/vnd.oracle.resource+json; type=singular";
105
+ export const COLLECTION_CONTENT_TYPE = "application/vnd.oracle.resource+json; type=collection";
106
+ export const ERROR_CONTENT_TYPE = "application/vnd.oracle.resource+json; type=error";
107
+ export const SCHEMA_CONTENT_TYPE = "application/schema+json";
@@ -0,0 +1,228 @@
1
+ // Provider-shaped HTTP codecs. Pure: no state is read here and every response body is assembled from the
2
+ // operation outcome alone.
3
+ import { problemBody } from "./errors.mjs";
4
+ import { selfLinks } from "./project.mjs";
5
+ import {
6
+ ACCOUNT_CONTENT_TYPE, bodyArguments, COLLECTION_CONTENT_TYPE, decodeSegment, ERROR_CONTENT_TYPE,
7
+ firstHeader, firstQuery, pathValue, readArguments, SCHEMA_CONTENT_TYPE, writeArguments,
8
+ } from "./wire-decode.mjs";
9
+
10
+ // Framework-raised codes (they never reach a handler) that still have an exact NetSuite equivalent.
11
+ // Keyed by the outcome status the framework reports, so the NetSuite code always agrees with the HTTP status
12
+ // the framework puts on the wire (invalid -> 400, denied -> 403, unsupported -> 404) whatever the internal code is.
13
+ const FRAMEWORK_CODES = new Map([
14
+ ["invalid", "INVALID_PARAMETER"],
15
+ ["denied", "INSUFFICIENT_PERMISSION"],
16
+ ["unsupported", "NONEXISTENT_ID"],
17
+ ]);
18
+
19
+ const DENIED_DETAIL =
20
+ "Permission Violation: You need a higher level of permission to access this record. Please contact your account administrator.";
21
+
22
+ // Caller-safe wording for each framework code; framework messages name internal identifiers, so none is quoted.
23
+ const FRAMEWORK_DETAIL = new Map([
24
+ ["INSUFFICIENT_PERMISSION", DENIED_DETAIL],
25
+ ["INVALID_PARAMETER", "Invalid request parameter value. One of the supplied parameters is not valid for this request."],
26
+ ["NONEXISTENT_ID", "The requested record operation is not available in this account."],
27
+ ["UNEXPECTED_ERROR", "Unexpected error."],
28
+ ]);
29
+
30
+ const codeOf = (outcome) => {
31
+ const code = outcome?.error?.code;
32
+ if (typeof code === "string" && code.startsWith("tool.")) return code.slice(5);
33
+ return FRAMEWORK_CODES.get(outcome?.status) ?? "UNEXPECTED_ERROR";
34
+ };
35
+
36
+ /** True when the outcome carries a framework code, whose message is internal wording we must not quote. */
37
+ const isFrameworkError = (outcome) => {
38
+ const code = outcome?.error?.code;
39
+ return typeof code !== "string" || !code.startsWith("tool.");
40
+ };
41
+
42
+ const detailsOf = (outcome) => {
43
+ const details = outcome?.error?.details;
44
+ return typeof details === "object" && details !== null && !Array.isArray(details) ? details : {};
45
+ };
46
+
47
+ function errorBody(outcome) {
48
+ const code = codeOf(outcome);
49
+ if (isFrameworkError(outcome)) {
50
+ // Framework messages name internal identifiers, so they are never quoted into a provider-shaped body.
51
+ return problemBody(code, FRAMEWORK_DETAIL.get(code) ?? "Unexpected error.", {});
52
+ }
53
+ const details = detailsOf(outcome);
54
+ const detail = typeof details.detail === "string" ? details.detail : (outcome?.error?.message ?? "Request failed.");
55
+ return problemBody(code, detail, details);
56
+ }
57
+
58
+ const errorResponse = (outcome) => ({
59
+ headers: {
60
+ "content-type": ERROR_CONTENT_TYPE,
61
+ ...(codeOf(outcome) === "CONCURRENCY_LIMIT_EXCEEDED" ? { "retry-after": "5" } : {}),
62
+ },
63
+ body: { kind: "json", value: errorBody(outcome) },
64
+ });
65
+
66
+ const okObject = (outcome) => {
67
+ const value = outcome?.value;
68
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
69
+ };
70
+
71
+ /** 200 responses carrying a record or a collection. */
72
+ function jsonCodec(contentType, transform = (value) => value) {
73
+ return ({ outcome }) => {
74
+ const value = okObject(outcome);
75
+ if (value === null) return errorResponse(outcome);
76
+ return { headers: { "content-type": contentType }, body: { kind: "json", value: transform(value) } };
77
+ };
78
+ }
79
+
80
+ /** 204 responses: no body, a Location header naming the record that was written. */
81
+ const writeCodec = ({ outcome }) => {
82
+ const value = okObject(outcome);
83
+ if (value === null) return errorResponse(outcome);
84
+ const location = typeof value.location === "string" ? value.location : undefined;
85
+ return {
86
+ headers: location === undefined ? {} : { location },
87
+ body: { kind: "empty" },
88
+ };
89
+ };
90
+
91
+ const recordCodec = jsonCodec(ACCOUNT_CONTENT_TYPE);
92
+ const collectionCodec = jsonCodec(COLLECTION_CONTENT_TYPE);
93
+
94
+ /** The REST subsidiary route answers the record-collection shape, not the MCP shape. */
95
+ const subsidiaryCollectionCodec = jsonCodec(COLLECTION_CONTENT_TYPE, (value) => ({
96
+ ...value,
97
+ items: Array.isArray(value.items)
98
+ ? value.items.map((entry) => ({ links: selfLinks("subsidiary", entry?.id ?? ""), id: entry?.id ?? "" }))
99
+ : [],
100
+ }));
101
+
102
+ const idArguments = (request) => ({ recordId: decodeSegment(pathValue(request, "id")) ?? "" });
103
+
104
+ const listRoute = (id) => [id, { decode: (request) => ({ arguments: readArguments(request) }), encode: collectionCodec }];
105
+
106
+ const getRoute = (id) => [id, {
107
+ decode: (request) => ({ arguments: { ...readArguments(request), ...idArguments(request) } }),
108
+ encode: recordCodec,
109
+ }];
110
+
111
+ const createRoute = (id) => [id, {
112
+ decode: (request) => {
113
+ const write = writeArguments(request);
114
+ return {
115
+ arguments: { ...write, ...bodyArguments(request, { optional: false }) },
116
+ ...(write.idempotencyKeyHeader === undefined ? {} : { idempotencyKey: write.idempotencyKeyHeader }),
117
+ };
118
+ },
119
+ encode: writeCodec,
120
+ }];
121
+
122
+ const updateRoute = (id) => [id, {
123
+ decode: (request) => {
124
+ const write = writeArguments(request);
125
+ return {
126
+ arguments: { ...write, ...idArguments(request), ...bodyArguments(request, { optional: false }) },
127
+ ...(write.idempotencyKeyHeader === undefined ? {} : { idempotencyKey: write.idempotencyKeyHeader }),
128
+ };
129
+ },
130
+ encode: writeCodec,
131
+ }];
132
+
133
+ const transformRoute = (id, target) => [id, {
134
+ decode: (request) => {
135
+ const write = writeArguments(request);
136
+ return {
137
+ arguments: {
138
+ ...write,
139
+ ...idArguments(request),
140
+ target,
141
+ transformMarker: decodeSegment(pathValue(request, "transformMarker")) ?? "",
142
+ ...bodyArguments(request, { optional: true }),
143
+ },
144
+ ...(write.idempotencyKeyHeader === undefined ? {} : { idempotencyKey: write.idempotencyKeyHeader }),
145
+ };
146
+ },
147
+ encode: writeCodec,
148
+ }];
149
+
150
+ export const routes = Object.fromEntries([
151
+ listRoute("customer-list"),
152
+ getRoute("customer-get"),
153
+ createRoute("customer-create"),
154
+ updateRoute("customer-update"),
155
+ ["customer-delete", {
156
+ decode: (request) => {
157
+ const write = writeArguments(request);
158
+ return {
159
+ arguments: { ...idArguments(request), ...(write.idempotencyKeyHeader === undefined ? {} : { idempotencyKeyHeader: write.idempotencyKeyHeader }) },
160
+ ...(write.idempotencyKeyHeader === undefined ? {} : { idempotencyKey: write.idempotencyKeyHeader }),
161
+ };
162
+ },
163
+ encode: writeCodec,
164
+ }],
165
+ listRoute("sales-order-list"),
166
+ getRoute("sales-order-get"),
167
+ createRoute("sales-order-create"),
168
+ updateRoute("sales-order-update"),
169
+ ["sales-order-items", {
170
+ decode: (request) => ({
171
+ arguments: {
172
+ ...idArguments(request),
173
+ ...(firstQuery(request, "limit") === undefined ? {} : { limit: firstQuery(request, "limit") }),
174
+ ...(firstQuery(request, "offset") === undefined ? {} : { offset: firstQuery(request, "offset") }),
175
+ },
176
+ }),
177
+ encode: collectionCodec,
178
+ }],
179
+ transformRoute("sales-order-transform", "invoice"),
180
+ listRoute("invoice-list"),
181
+ getRoute("invoice-get"),
182
+ createRoute("invoice-create"),
183
+ updateRoute("invoice-update"),
184
+ transformRoute("invoice-transform", "customerPayment"),
185
+ listRoute("customer-payment-list"),
186
+ getRoute("customer-payment-get"),
187
+ listRoute("inventory-item-list"),
188
+ getRoute("inventory-item-get"),
189
+ ["subsidiary-list", {
190
+ decode: (request) => ({
191
+ arguments: {
192
+ ...(firstQuery(request, "limit") === undefined ? {} : { limit: firstQuery(request, "limit") }),
193
+ ...(firstQuery(request, "offset") === undefined ? {} : { offset: firstQuery(request, "offset") }),
194
+ },
195
+ }),
196
+ encode: subsidiaryCollectionCodec,
197
+ }],
198
+ ["metadata-catalog-list", {
199
+ decode: () => ({ arguments: {} }),
200
+ encode: jsonCodec(COLLECTION_CONTENT_TYPE),
201
+ }],
202
+ ["metadata-catalog-type", {
203
+ decode: (request) => ({ arguments: { recordType: decodeSegment(pathValue(request, "recordType")) ?? "" } }),
204
+ encode: jsonCodec(SCHEMA_CONTENT_TYPE),
205
+ }],
206
+ ["suiteql", {
207
+ decode: (request) => {
208
+ const decoded = bodyArguments(request, { optional: false });
209
+ const args = { ...decoded };
210
+ if (decoded.body !== undefined) {
211
+ const body = decoded.body;
212
+ delete args.body;
213
+ args.q = typeof body.q === "string" ? body.q : "";
214
+ if (Array.isArray(body.params)) args.params = body.params;
215
+ } else if (decoded.bodyError === undefined) {
216
+ args.q = "";
217
+ }
218
+ const prefer = firstHeader(request, "prefer");
219
+ if (prefer !== undefined) args.prefer = prefer;
220
+ const limit = firstQuery(request, "limit");
221
+ if (limit !== undefined) args.limit = limit;
222
+ const offset = firstQuery(request, "offset");
223
+ if (offset !== undefined) args.offset = offset;
224
+ return { arguments: args };
225
+ },
226
+ encode: collectionCodec,
227
+ }],
228
+ ]);
@@ -0,0 +1,181 @@
1
+ // Customer writes: create, merge-patch update and the referential delete.
2
+ import { quote } from "./errors.mjs";
3
+ import { open } from "./session.mjs";
4
+ import { loadRecord } from "./handlers-read.mjs";
5
+ import {
6
+ booleanField, checkPropertyNames, checkVersion, idempotencyKey, numberField,
7
+ referenceField, replaceList, requestBody, stringField,
8
+ } from "./body.mjs";
9
+
10
+ const KNOWN = new Set([
11
+ "entityId", "companyName", "isPerson", "firstName", "middleName", "lastName", "email", "phone",
12
+ "altPhone", "subsidiary", "currency", "terms", "creditLimit", "comments", "isInactive", "categoryId",
13
+ ]);
14
+ const REPLACEABLE = new Set(["companyName", "email", "phone", "altPhone", "comments", "creditLimit", "terms", "addressBook"]);
15
+ const CURRENCY_FOR = new Map([["1", "1"], ["2", "3"]]);
16
+
17
+ function readFields(session, input, body) {
18
+ checkPropertyNames(session, input, body, KNOWN);
19
+ return {
20
+ entityId: stringField(session, body, "entityId", 83),
21
+ companyName: stringField(session, body, "companyName", 83),
22
+ isPerson: booleanField(session, body, "isPerson"),
23
+ firstName: stringField(session, body, "firstName", 32),
24
+ middleName: stringField(session, body, "middleName", 32),
25
+ lastName: stringField(session, body, "lastName", 32),
26
+ email: stringField(session, body, "email", 254),
27
+ phone: stringField(session, body, "phone", 22),
28
+ altPhone: stringField(session, body, "altPhone", 22),
29
+ comments: stringField(session, body, "comments", 999),
30
+ creditLimit: numberField(session, body, "creditLimit", { min: 0, max: 1000000000 }),
31
+ isInactive: booleanField(session, body, "isInactive"),
32
+ subsidiaryId: referenceField(session, body, "subsidiary"),
33
+ termsId: referenceField(session, body, "terms"),
34
+ };
35
+ }
36
+
37
+ function resolveSubsidiary(session, subsidiaryId) {
38
+ const id = subsidiaryId ?? session.account.defaultSubsidiaryId;
39
+ if (session.get("subsidiaries", id) === null) {
40
+ session.fail("INVALID_KEY_OR_REF", `Invalid subsidiary reference key ${quote(String(id))}.`, { errorPath: "subsidiary" });
41
+ }
42
+ if (!session.inScope(id)) {
43
+ session.fail("INVALID_KEY_OR_REF", `Subsidiary ${quote(String(id))} is outside the subsidiary restriction of this role.`, {
44
+ errorPath: "subsidiary",
45
+ });
46
+ }
47
+ return id;
48
+ }
49
+
50
+ function checkTerms(session, termsId) {
51
+ if (termsId === undefined || termsId === null) return termsId ?? null;
52
+ if (!["1", "2", "3"].includes(termsId)) {
53
+ session.fail("INVALID_KEY_OR_REF", `Invalid terms reference key ${quote(termsId)}.`, { errorPath: "terms" });
54
+ }
55
+ return termsId;
56
+ }
57
+
58
+ function checkDuplicate(session, entityId, ownId) {
59
+ for (const row of session.rows("customers")) {
60
+ if (row.id !== ownId && row.entityId.toLowerCase() === entityId.toLowerCase()) {
61
+ session.fail("USER_ERROR", `This entity already exists. The name ${quote(entityId)} is already in use by another customer.`, {
62
+ errorPath: "entityId",
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ export const customerWrites = {
69
+ "customer.create": (input, context) => {
70
+ const session = open(context);
71
+ session.permit("LIST_CUSTJOB", "create");
72
+ idempotencyKey(session, input);
73
+ const body = requestBody(session, input);
74
+ const fields = readFields(session, input, body);
75
+ session.rows("customers");
76
+ const subsidiaryId = resolveSubsidiary(session, fields.subsidiaryId);
77
+ const termsId = checkTerms(session, fields.termsId);
78
+ const isPerson = fields.isPerson === true;
79
+ const displayName = isPerson
80
+ ? `${fields.lastName ?? ""}${fields.lastName !== undefined && fields.firstName !== undefined ? ", " : ""}${fields.firstName ?? ""}`.trim()
81
+ : (fields.companyName ?? "").trim();
82
+ if (displayName.length === 0) {
83
+ session.fail("USER_ERROR", "Please enter a value for Company Name, or a first and last name for an individual customer.", {
84
+ errorPath: isPerson ? "lastName" : "companyName",
85
+ });
86
+ }
87
+ const id = session.nextId("nextEntityId", "customers");
88
+ const entityId = (fields.entityId ?? `C${id} ${displayName}`).slice(0, 83);
89
+ checkDuplicate(session, entityId, null);
90
+ const row = {
91
+ id,
92
+ entityId,
93
+ companyName: isPerson ? (fields.companyName ?? null) : displayName,
94
+ isPerson,
95
+ firstName: fields.firstName ?? null,
96
+ lastName: fields.lastName ?? null,
97
+ middleName: fields.middleName ?? null,
98
+ email: fields.email ?? null,
99
+ phone: fields.phone ?? null,
100
+ altPhone: fields.altPhone ?? null,
101
+ subsidiaryId,
102
+ currencyId: CURRENCY_FOR.get(subsidiaryId) ?? "1",
103
+ termsId,
104
+ creditLimit: fields.creditLimit ?? null,
105
+ comments: fields.comments ?? null,
106
+ isInactive: fields.isInactive === true,
107
+ categoryId: null,
108
+ defaultAddress: null,
109
+ addressBook: [],
110
+ dateCreated: session.now,
111
+ createdDate: session.now,
112
+ lastModifiedDate: session.now,
113
+ createdById: session.employee.id,
114
+ lastModifiedById: session.employee.id,
115
+ version: 1,
116
+ };
117
+ session.put("customers", id, row);
118
+ session.recordChanged("customer", id, "CREATE", subsidiaryId);
119
+ return { id, location: `/services/rest/record/v1/customer/${id}` };
120
+ },
121
+
122
+ "customer.update": (input, context) => {
123
+ const session = open(context);
124
+ session.permit("LIST_CUSTJOB", "edit");
125
+ idempotencyKey(session, input);
126
+ const body = requestBody(session, input);
127
+ const replace = replaceList(session, input, REPLACEABLE);
128
+ const current = loadRecord(session, "customer", input.recordId);
129
+ checkVersion(session, input, current);
130
+ const fields = readFields(session, input, body);
131
+ session.rows("customers");
132
+ const updated = { ...current };
133
+ for (const name of replace) {
134
+ if (name === "addressBook") {
135
+ updated.addressBook = [];
136
+ updated.defaultAddress = null;
137
+ } else if (name === "terms") updated.termsId = null;
138
+ else updated[name] = null;
139
+ }
140
+ for (const [key, value] of [
141
+ ["companyName", fields.companyName], ["firstName", fields.firstName], ["middleName", fields.middleName],
142
+ ["lastName", fields.lastName], ["email", fields.email], ["phone", fields.phone], ["altPhone", fields.altPhone],
143
+ ["comments", fields.comments], ["creditLimit", fields.creditLimit],
144
+ ]) {
145
+ if (value !== undefined) updated[key] = value;
146
+ }
147
+ if (fields.isInactive !== undefined && fields.isInactive !== null) updated.isInactive = fields.isInactive;
148
+ if (fields.isPerson !== undefined && fields.isPerson !== null) updated.isPerson = fields.isPerson;
149
+ if (fields.termsId !== undefined) updated.termsId = checkTerms(session, fields.termsId);
150
+ if (fields.subsidiaryId !== undefined && fields.subsidiaryId !== null) {
151
+ updated.subsidiaryId = resolveSubsidiary(session, fields.subsidiaryId);
152
+ updated.currencyId = CURRENCY_FOR.get(updated.subsidiaryId) ?? "1";
153
+ }
154
+ if (fields.entityId !== undefined && fields.entityId !== null) updated.entityId = fields.entityId;
155
+ checkDuplicate(session, updated.entityId, current.id);
156
+ updated.lastModifiedDate = session.now;
157
+ updated.lastModifiedById = session.employee.id;
158
+ updated.version = current.version + 1;
159
+ session.put("customers", current.id, updated);
160
+ session.recordChanged("customer", current.id, "UPDATE", updated.subsidiaryId);
161
+ return { id: current.id, location: `/services/rest/record/v1/customer/${current.id}` };
162
+ },
163
+
164
+ "customer.delete": (input, context) => {
165
+ const session = open(context);
166
+ session.permit("LIST_CUSTJOB", "full");
167
+ idempotencyKey(session, input);
168
+ const current = loadRecord(session, "customer", input.recordId);
169
+ const referenced = [
170
+ ...session.rows("sales-orders"),
171
+ ...session.rows("invoices"),
172
+ ...session.rows("customer-payments"),
173
+ ].some((row) => row.entityId === current.id);
174
+ if (referenced) {
175
+ session.fail("USER_ERROR", "This record cannot be deleted because it is referenced by one or more transactions.");
176
+ }
177
+ session.remove("customers", current.id);
178
+ session.recordChanged("customer", current.id, "DELETE", current.subsidiaryId);
179
+ return { id: current.id };
180
+ },
181
+ };