@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,319 @@
1
+ // Invoice writes: create (optionally from a sales order), merge-patch update and the !transform that
2
+ // accepts a customer payment.
3
+ import { quote } from "./errors.mjs";
4
+ import { addDays, dateToDays, round2, round4 } from "./primitives.mjs";
5
+ import { open } from "./session.mjs";
6
+ import { loadRecord } from "./handlers-read.mjs";
7
+ import {
8
+ checkPropertyNames, checkVersion, dateField, idempotencyKey, numberField,
9
+ referenceField, replaceList, requestBody, stringField, sublistItems,
10
+ } from "./body.mjs";
11
+ import {
12
+ billedTotal, buildLine, checkDuplicateTranId, invoiceStatus, lineCounter, nextTranId,
13
+ orderStatus, resolveCustomer, setLineCounter, totals,
14
+ } from "./tran.mjs";
15
+ import { checkMarker } from "./write-order.mjs";
16
+
17
+ const KNOWN = new Set(["entity", "createdFrom", "tranDate", "dueDate", "tranId", "memo", "otherRefNum", "terms", "item"]);
18
+ const REPLACEABLE = new Set(["item", "memo", "otherRefNum", "terms"]);
19
+ const TERMS = new Set(["1", "2", "3"]);
20
+
21
+ function checkTerms(session, termsId) {
22
+ if (termsId === undefined || termsId === null) return termsId ?? null;
23
+ if (!TERMS.has(termsId)) session.fail("INVALID_KEY_OR_REF", `Invalid terms reference key ${quote(termsId)}.`, { errorPath: "terms" });
24
+ return termsId;
25
+ }
26
+
27
+ function checkDueDate(session, tranDate, dueDate) {
28
+ const from = dateToDays(tranDate);
29
+ const to = dateToDays(dueDate);
30
+ if (from !== null && to !== null && to < from) {
31
+ session.fail("USER_ERROR", "The due date cannot be earlier than the transaction date.", { errorPath: "dueDate" });
32
+ }
33
+ return dueDate;
34
+ }
35
+
36
+ export const invoiceWrites = {
37
+ "invoice.create": (input, context) => {
38
+ const session = open(context);
39
+ session.permit("TRAN_CUSTINVC", "create");
40
+ idempotencyKey(session, input);
41
+ const body = requestBody(session, input);
42
+ checkPropertyNames(session, input, body, KNOWN);
43
+ session.rows("invoices");
44
+ const entityId = referenceField(session, body, "entity");
45
+ if (entityId === undefined || entityId === null) {
46
+ session.fail("INVALID_KEY_OR_REF", "An invoice must carry an entity reference.", { errorPath: "entity" });
47
+ }
48
+ const customer = resolveCustomer(session, entityId);
49
+ const subsidiaryId = customer.subsidiaryId;
50
+ const createdFromId = referenceField(session, body, "createdFrom");
51
+ let order = null;
52
+ if (createdFromId !== undefined && createdFromId !== null) {
53
+ order = session.get("sales-orders", createdFromId);
54
+ if (order === null || !session.inScope(order.subsidiaryId)) {
55
+ session.fail("INVALID_KEY_OR_REF", `Invalid sales order reference key ${quote(createdFromId)}.`, { errorPath: "createdFrom" });
56
+ }
57
+ if (order.entityId !== customer.id) {
58
+ session.fail("USER_ERROR", "The sales order named by createdFrom belongs to a different customer.", { errorPath: "createdFrom" });
59
+ }
60
+ if (order.status === "closed") session.fail("USER_ERROR", "This sales order is closed and cannot be billed.");
61
+ }
62
+
63
+ const entries = sublistItems(session, body, "item", 200);
64
+ let lines;
65
+ let updatedOrder = null;
66
+ if (order !== null) {
67
+ const remaining = new Map();
68
+ for (const line of order.lines) {
69
+ const openQuantity = round4(line.quantity - line.quantityBilled);
70
+ if (openQuantity > 0) remaining.set(line.line, openQuantity);
71
+ }
72
+ if (remaining.size === 0) session.fail("USER_ERROR", "There are no items to be billed on this sales order.");
73
+ const billing = new Map();
74
+ if (entries === undefined || entries.length === 0) {
75
+ for (const [line, quantity] of remaining) billing.set(line, quantity);
76
+ } else {
77
+ for (const [index, entry] of entries.entries()) {
78
+ const prefix = `item.items[${index}].`;
79
+ const orderLine = numberField(session, entry, "orderLine", { min: 1, max: 100000, prefix });
80
+ if (orderLine === undefined || orderLine === null || !remaining.has(orderLine)) {
81
+ session.fail("USER_ERROR", `Order line ${quote(String(orderLine))} cannot be billed on this sales order.`, {
82
+ errorPath: `${prefix}orderLine`,
83
+ });
84
+ }
85
+ const quantity = numberField(session, entry, "quantity", { min: 0, max: 1000000, prefix }) ?? remaining.get(orderLine);
86
+ if (quantity > remaining.get(orderLine)) {
87
+ session.fail("USER_ERROR", `Line ${orderLine} has only ${remaining.get(orderLine)} units left to bill.`, {
88
+ errorPath: `${prefix}quantity`,
89
+ });
90
+ }
91
+ if (quantity > 0) billing.set(orderLine, round4(quantity));
92
+ }
93
+ if (billing.size === 0) session.fail("USER_ERROR", "There are no items to be billed on this sales order.");
94
+ }
95
+ updatedOrder = { ...order, lines: order.lines.map((line) => ({ ...line })) };
96
+ lines = [];
97
+ let number = 1;
98
+ for (const line of updatedOrder.lines) {
99
+ const quantity = billing.get(line.line);
100
+ if (quantity === undefined) continue;
101
+ line.quantityBilled = round4(line.quantityBilled + quantity);
102
+ const amount = round2(quantity * line.rate);
103
+ lines.push({
104
+ line: number, itemId: line.itemId, quantity, rate: line.rate, amount,
105
+ description: line.description, taxRate: line.taxRate, taxAmount: round2(amount * line.taxRate),
106
+ orderLine: line.line, quantityBilled: 0,
107
+ });
108
+ number += 1;
109
+ }
110
+ } else {
111
+ if (entries === undefined || entries.length === 0) {
112
+ session.fail("USER_ERROR", "Please enter at least one line item for this transaction.", { errorPath: "item.items" });
113
+ }
114
+ lines = entries.map((entry, index) => buildLine(session, entry, subsidiaryId, index + 1, `item.items[${index}].`));
115
+ }
116
+
117
+ const tranDate = dateField(session, body, "tranDate") ?? session.today;
118
+ const dueDate = checkDueDate(session, tranDate, dateField(session, body, "dueDate") ?? addDays(tranDate, 30));
119
+ const suppliedTranId = stringField(session, body, "tranId", 45);
120
+ const id = session.nextId("nextTransactionId", "invoices");
121
+ const tranId = suppliedTranId ?? nextTranId(session, "invoices", "INV");
122
+ checkDuplicateTranId(session, ["invoices"], tranId, null);
123
+ const row = {
124
+ id, tranId, tranDate, dueDate,
125
+ entityId: customer.id,
126
+ subsidiaryId,
127
+ createdFromId: order === null ? null : order.id,
128
+ status: "open",
129
+ memo: stringField(session, body, "memo", 999) ?? null,
130
+ otherRefNum: stringField(session, body, "otherRefNum", 45) ?? null,
131
+ currencyId: customer.currencyId,
132
+ termsId: checkTerms(session, referenceField(session, body, "terms")) ?? customer.termsId,
133
+ lines,
134
+ ...totals(lines),
135
+ amountPaid: 0,
136
+ appliedPaymentIds: [],
137
+ createdDate: session.now,
138
+ lastModifiedDate: session.now,
139
+ createdById: session.employee.id,
140
+ lastModifiedById: session.employee.id,
141
+ version: 1,
142
+ };
143
+ session.put("invoices", id, row);
144
+ setLineCounter(session, id, lines.length + 1);
145
+ session.recordChanged("invoice", id, "CREATE", subsidiaryId);
146
+ session.statusChanged("invoice", row, null, row.total);
147
+ if (updatedOrder !== null) {
148
+ updatedOrder.billedTotal = billedTotal(updatedOrder.lines);
149
+ updatedOrder.status = orderStatus(updatedOrder);
150
+ updatedOrder.lastModifiedDate = session.now;
151
+ updatedOrder.lastModifiedById = session.employee.id;
152
+ updatedOrder.version = order.version + 1;
153
+ session.put("sales-orders", order.id, updatedOrder);
154
+ session.recordChanged("salesOrder", order.id, "UPDATE", updatedOrder.subsidiaryId);
155
+ if (updatedOrder.status !== order.status) session.statusChanged("salesOrder", updatedOrder, order.status, null);
156
+ }
157
+ return { id, location: `/services/rest/record/v1/invoice/${id}` };
158
+ },
159
+
160
+ "invoice.update": (input, context) => {
161
+ const session = open(context);
162
+ session.permit("TRAN_CUSTINVC", "edit");
163
+ idempotencyKey(session, input);
164
+ const body = requestBody(session, input);
165
+ checkPropertyNames(session, input, body, KNOWN);
166
+ const replace = replaceList(session, input, REPLACEABLE);
167
+ const current = loadRecord(session, "invoice", input.recordId);
168
+ checkVersion(session, input, current);
169
+ session.rows("invoices");
170
+ if (current.status === "paidInFull") {
171
+ session.fail("USER_ERROR", "This invoice is paid in full and can no longer be edited.");
172
+ }
173
+ const updated = { ...current, lines: current.lines.map((line) => ({ ...line })) };
174
+ if (replace.has("memo")) updated.memo = null;
175
+ if (replace.has("otherRefNum")) updated.otherRefNum = null;
176
+ if (replace.has("terms")) updated.termsId = null;
177
+ if (replace.has("item")) updated.lines = [];
178
+
179
+ const tranDate = dateField(session, body, "tranDate");
180
+ if (tranDate !== undefined && tranDate !== null) updated.tranDate = tranDate;
181
+ const dueDate = dateField(session, body, "dueDate");
182
+ if (dueDate !== undefined && dueDate !== null) updated.dueDate = dueDate;
183
+ checkDueDate(session, updated.tranDate, updated.dueDate);
184
+ const memo = stringField(session, body, "memo", 999);
185
+ if (memo !== undefined) updated.memo = memo;
186
+ const otherRefNum = stringField(session, body, "otherRefNum", 45);
187
+ if (otherRefNum !== undefined) updated.otherRefNum = otherRefNum;
188
+ const termsId = referenceField(session, body, "terms");
189
+ if (termsId !== undefined) updated.termsId = checkTerms(session, termsId);
190
+ const tranId = stringField(session, body, "tranId", 45);
191
+ if (tranId !== undefined && tranId !== null) {
192
+ checkDuplicateTranId(session, ["invoices"], tranId, current.id);
193
+ updated.tranId = tranId;
194
+ }
195
+
196
+ const entries = sublistItems(session, body, "item", 200);
197
+ const touchesLines = entries !== undefined || replace.has("item");
198
+ if (touchesLines && current.amountPaid > 0) {
199
+ session.fail("USER_ERROR", "Lines cannot be changed on an invoice that already has payments applied.", { errorPath: "item.items" });
200
+ }
201
+ if (entries !== undefined) {
202
+ let counter = lineCounter(session, current.id);
203
+ for (const [index, entry] of entries.entries()) {
204
+ const prefix = `item.items[${index}].`;
205
+ const lineNumber = numberField(session, entry, "line", { min: 1, max: 100000, prefix });
206
+ if (lineNumber === undefined || lineNumber === null) {
207
+ updated.lines.push(buildLine(session, entry, updated.subsidiaryId, counter, prefix));
208
+ counter += 1;
209
+ continue;
210
+ }
211
+ const position = updated.lines.findIndex((line) => line.line === lineNumber);
212
+ if (position < 0) {
213
+ session.fail("INVALID_KEY_OR_REF", `Line ${lineNumber} does not exist on this invoice.`, { errorPath: `${prefix}line` });
214
+ }
215
+ updated.lines[position] = buildLine(session, entry, updated.subsidiaryId, lineNumber, prefix, updated.lines[position]);
216
+ }
217
+ setLineCounter(session, current.id, counter);
218
+ }
219
+ Object.assign(updated, totals(updated.lines));
220
+ if (updated.total < current.amountPaid) {
221
+ session.fail("USER_ERROR", "The invoice total cannot be lowered below the amount already paid.", { errorPath: "item.items" });
222
+ }
223
+ updated.status = invoiceStatus(updated);
224
+ updated.lastModifiedDate = session.now;
225
+ updated.lastModifiedById = session.employee.id;
226
+ updated.version = current.version + 1;
227
+ session.put("invoices", current.id, updated);
228
+ session.recordChanged("invoice", current.id, "UPDATE", updated.subsidiaryId);
229
+ if (updated.status !== current.status) {
230
+ session.statusChanged("invoice", updated, current.status, round2(updated.total - updated.amountPaid));
231
+ }
232
+ return { id: current.id, location: `/services/rest/record/v1/invoice/${current.id}` };
233
+ },
234
+
235
+ "invoice.transform": (input, context) => {
236
+ const session = open(context);
237
+ session.permit("TRAN_CUSTINVC", "view");
238
+ session.permit("TRAN_CUSTPYMT", "create");
239
+ checkMarker(session, input.transformMarker);
240
+ idempotencyKey(session, input);
241
+ const body = requestBody(session, input);
242
+ const invoice = loadRecord(session, "invoice", input.recordId);
243
+ checkVersion(session, input, invoice);
244
+ session.rows("customer-payments");
245
+ if (invoice.status === "paidInFull") session.fail("USER_ERROR", "This invoice is already paid in full.");
246
+ if (invoice.status === "voided") session.fail("USER_ERROR", "This invoice is voided and cannot be paid.");
247
+ const remaining = round2(invoice.total - invoice.amountPaid);
248
+ if (remaining <= 0) session.fail("USER_ERROR", "This invoice has no remaining balance to apply a payment to.");
249
+
250
+ const requested = numberField(session, body, "payment", { min: 0, max: 1000000000 });
251
+ const applications = sublistItems(session, body, "apply", 20);
252
+ let applied;
253
+ if (applications !== undefined && applications.length > 0) {
254
+ applied = 0;
255
+ for (const [index, entry] of applications.entries()) {
256
+ const prefix = `apply.items[${index}].`;
257
+ const doc = Object.hasOwn(entry, "doc") ? String(entry.doc) : undefined;
258
+ if (doc !== invoice.id) {
259
+ session.fail("USER_ERROR", `The apply line ${quote(String(doc))} does not belong to this invoice.`, { errorPath: `${prefix}doc` });
260
+ }
261
+ if (Object.hasOwn(entry, "apply") && entry.apply === false) continue;
262
+ const amount = numberField(session, entry, "amount", { min: 0, max: 1000000000, prefix }) ?? remaining;
263
+ applied = round2(applied + amount);
264
+ }
265
+ } else {
266
+ // Without an apply sublist NetSuite applies the payment to this invoice, up to its remaining balance.
267
+ applied = requested === undefined || requested === null ? remaining : Math.min(round2(requested), remaining);
268
+ }
269
+ const payment = requested ?? applied;
270
+ if (payment <= 0) session.fail("USER_ERROR", "The payment amount must be greater than zero.", { errorPath: "payment" });
271
+ if (applied > remaining) {
272
+ session.fail("USER_ERROR", `The applied amount cannot exceed the invoice balance of ${remaining}.`, { errorPath: "apply.items" });
273
+ }
274
+ if (applied > payment) {
275
+ session.fail("USER_ERROR", "The applied amount cannot exceed the payment amount.", { errorPath: "apply.items" });
276
+ }
277
+
278
+ const paymentId = session.nextId("nextTransactionId", "customer-payments");
279
+ const tranDate = dateField(session, body, "tranDate") ?? session.today;
280
+ const accountBody = Object.hasOwn(body, "account") && typeof body.account === "object" && body.account !== null
281
+ ? body.account
282
+ : {};
283
+ const row = {
284
+ id: paymentId,
285
+ tranId: nextTranId(session, "customer-payments", "CP"),
286
+ tranDate,
287
+ entityId: invoice.entityId,
288
+ subsidiaryId: invoice.subsidiaryId,
289
+ payment,
290
+ unapplied: round2(payment - applied),
291
+ accountName: stringField(session, accountBody, "refName", 80) ?? "1000 Operating Bank Account",
292
+ memo: stringField(session, body, "memo", 999) ?? null,
293
+ currencyId: invoice.currencyId,
294
+ applied: [{ invoiceId: invoice.id, amount: applied }],
295
+ status: "notDeposited",
296
+ createdDate: session.now,
297
+ lastModifiedDate: session.now,
298
+ createdById: session.employee.id,
299
+ version: 1,
300
+ };
301
+ const updatedInvoice = {
302
+ ...invoice,
303
+ amountPaid: round2(invoice.amountPaid + applied),
304
+ appliedPaymentIds: [...invoice.appliedPaymentIds, paymentId],
305
+ lastModifiedDate: session.now,
306
+ lastModifiedById: session.employee.id,
307
+ version: invoice.version + 1,
308
+ };
309
+ updatedInvoice.status = invoiceStatus(updatedInvoice);
310
+ session.put("customer-payments", paymentId, row);
311
+ session.put("invoices", invoice.id, updatedInvoice);
312
+ session.recordChanged("customerPayment", paymentId, "CREATE", row.subsidiaryId);
313
+ session.recordChanged("invoice", invoice.id, "UPDATE", updatedInvoice.subsidiaryId);
314
+ if (updatedInvoice.status !== invoice.status) {
315
+ session.statusChanged("invoice", updatedInvoice, invoice.status, round2(updatedInvoice.total - updatedInvoice.amountPaid));
316
+ }
317
+ return { id: paymentId, location: `/services/rest/record/v1/customerPayment/${paymentId}` };
318
+ },
319
+ };
@@ -0,0 +1,269 @@
1
+ // Sales-order writes: create, merge-patch update and the !transform that bills an order into an invoice.
2
+ import { quote } from "./errors.mjs";
3
+ import { addDays, round2, round4 } from "./primitives.mjs";
4
+ import { open } from "./session.mjs";
5
+ import { loadRecord } from "./handlers-read.mjs";
6
+ import {
7
+ checkPropertyNames, checkVersion, dateField, idempotencyKey, numberField,
8
+ referenceField, replaceList, requestBody, stringField, sublistItems,
9
+ } from "./body.mjs";
10
+ import {
11
+ billedTotal, buildLine, checkDuplicateTranId, lineCounter, nextTranId, orderStatus,
12
+ resolveCustomer, setLineCounter, totals,
13
+ } from "./tran.mjs";
14
+
15
+ const KNOWN = new Set(["entity", "tranDate", "tranId", "memo", "otherRefNum", "terms", "item", "subsidiary"]);
16
+ const REPLACEABLE = new Set(["item", "memo", "otherRefNum", "terms"]);
17
+ const TERMS = new Set(["1", "2", "3"]);
18
+ const MARKER = "!transform";
19
+
20
+ export function checkMarker(session, marker) {
21
+ if (marker !== MARKER) {
22
+ session.fail("INVALID_REQUEST", `Invalid transform marker ${quote(String(marker))}. The transform path segment must be !transform.`);
23
+ }
24
+ }
25
+
26
+ function checkTerms(session, termsId) {
27
+ if (termsId === undefined || termsId === null) return termsId ?? null;
28
+ if (!TERMS.has(termsId)) session.fail("INVALID_KEY_OR_REF", `Invalid terms reference key ${quote(termsId)}.`, { errorPath: "terms" });
29
+ return termsId;
30
+ }
31
+
32
+ export const orderWrites = {
33
+ "sales-order.create": (input, context) => {
34
+ const session = open(context);
35
+ session.permit("TRAN_SALESORD", "create");
36
+ idempotencyKey(session, input);
37
+ const body = requestBody(session, input);
38
+ checkPropertyNames(session, input, body, KNOWN);
39
+ session.rows("sales-orders");
40
+ const entityId = referenceField(session, body, "entity");
41
+ if (entityId === undefined || entityId === null) {
42
+ session.fail("INVALID_KEY_OR_REF", "A sales order must carry an entity reference.", { errorPath: "entity" });
43
+ }
44
+ const customer = resolveCustomer(session, entityId);
45
+ const subsidiaryId = customer.subsidiaryId;
46
+ const bodySubsidiary = referenceField(session, body, "subsidiary");
47
+ if (bodySubsidiary !== undefined && bodySubsidiary !== null && bodySubsidiary !== subsidiaryId) {
48
+ session.fail("USER_ERROR", "The subsidiary of this transaction must match the subsidiary of the customer.", {
49
+ errorPath: "subsidiary",
50
+ });
51
+ }
52
+ const entries = sublistItems(session, body, "item", 200);
53
+ if (entries === undefined || entries.length === 0) {
54
+ session.fail("USER_ERROR", "Please enter at least one line item for this transaction.", { errorPath: "item.items" });
55
+ }
56
+ const lines = entries.map((entry, index) =>
57
+ buildLine(session, entry, subsidiaryId, index + 1, `item.items[${index}].`));
58
+ const tranDate = dateField(session, body, "tranDate") ?? session.today;
59
+ const suppliedTranId = stringField(session, body, "tranId", 45);
60
+ const id = session.nextId("nextTransactionId", "sales-orders");
61
+ const tranId = suppliedTranId ?? nextTranId(session, "sales-orders", "SO");
62
+ checkDuplicateTranId(session, ["sales-orders"], tranId, null);
63
+ const amounts = totals(lines);
64
+ const row = {
65
+ id,
66
+ tranId,
67
+ tranDate,
68
+ entityId: customer.id,
69
+ subsidiaryId,
70
+ status: "pendingFulfillment",
71
+ memo: stringField(session, body, "memo", 999) ?? null,
72
+ otherRefNum: stringField(session, body, "otherRefNum", 45) ?? null,
73
+ currencyId: customer.currencyId,
74
+ termsId: checkTerms(session, referenceField(session, body, "terms")) ?? customer.termsId,
75
+ lines,
76
+ ...amounts,
77
+ billedTotal: 0,
78
+ createdFromId: null,
79
+ createdDate: session.now,
80
+ lastModifiedDate: session.now,
81
+ createdById: session.employee.id,
82
+ lastModifiedById: session.employee.id,
83
+ version: 1,
84
+ };
85
+ session.put("sales-orders", id, row);
86
+ setLineCounter(session, id, lines.length + 1);
87
+ session.recordChanged("salesOrder", id, "CREATE", subsidiaryId);
88
+ session.statusChanged("salesOrder", row, null, null);
89
+ return { id, location: `/services/rest/record/v1/salesOrder/${id}` };
90
+ },
91
+
92
+ "sales-order.update": (input, context) => {
93
+ const session = open(context);
94
+ session.permit("TRAN_SALESORD", "edit");
95
+ idempotencyKey(session, input);
96
+ const body = requestBody(session, input);
97
+ checkPropertyNames(session, input, body, KNOWN);
98
+ const replace = replaceList(session, input, REPLACEABLE);
99
+ const current = loadRecord(session, "sales-order", input.recordId);
100
+ checkVersion(session, input, current);
101
+ session.rows("sales-orders");
102
+ if (current.status === "billed" || current.status === "closed") {
103
+ session.fail("USER_ERROR", `This sales order is ${current.status === "billed" ? "fully billed" : "closed"} and can no longer be edited.`);
104
+ }
105
+ const updated = { ...current, lines: current.lines.map((line) => ({ ...line })) };
106
+ if (replace.has("memo")) updated.memo = null;
107
+ if (replace.has("otherRefNum")) updated.otherRefNum = null;
108
+ if (replace.has("terms")) updated.termsId = null;
109
+ if (replace.has("item")) updated.lines = [];
110
+
111
+ const tranDate = dateField(session, body, "tranDate");
112
+ if (tranDate !== undefined && tranDate !== null) updated.tranDate = tranDate;
113
+ const memo = stringField(session, body, "memo", 999);
114
+ if (memo !== undefined) updated.memo = memo;
115
+ const otherRefNum = stringField(session, body, "otherRefNum", 45);
116
+ if (otherRefNum !== undefined) updated.otherRefNum = otherRefNum;
117
+ const termsId = referenceField(session, body, "terms");
118
+ if (termsId !== undefined) updated.termsId = checkTerms(session, termsId);
119
+ const tranId = stringField(session, body, "tranId", 45);
120
+ if (tranId !== undefined && tranId !== null) {
121
+ checkDuplicateTranId(session, ["sales-orders"], tranId, current.id);
122
+ updated.tranId = tranId;
123
+ }
124
+
125
+ const entries = sublistItems(session, body, "item", 200);
126
+ if (entries !== undefined) {
127
+ let counter = lineCounter(session, current.id);
128
+ for (const [index, entry] of entries.entries()) {
129
+ const prefix = `item.items[${index}].`;
130
+ const lineNumber = numberField(session, entry, "line", { min: 1, max: 100000, prefix });
131
+ if (lineNumber === undefined || lineNumber === null) {
132
+ const built = buildLine(session, entry, updated.subsidiaryId, counter, prefix);
133
+ updated.lines.push(built);
134
+ counter += 1;
135
+ continue;
136
+ }
137
+ const position = updated.lines.findIndex((line) => line.line === lineNumber);
138
+ if (position < 0) {
139
+ session.fail("INVALID_KEY_OR_REF", `Line ${lineNumber} does not exist on this sales order.`, { errorPath: `${prefix}line` });
140
+ }
141
+ updated.lines[position] = buildLine(session, entry, updated.subsidiaryId, lineNumber, prefix, updated.lines[position]);
142
+ }
143
+ setLineCounter(session, current.id, counter);
144
+ }
145
+ if (updated.lines.length === 0) {
146
+ session.fail("USER_ERROR", "Please enter at least one line item for this transaction.", { errorPath: "item.items" });
147
+ }
148
+ Object.assign(updated, totals(updated.lines));
149
+ updated.billedTotal = billedTotal(updated.lines);
150
+ updated.status = orderStatus(updated);
151
+ updated.lastModifiedDate = session.now;
152
+ updated.lastModifiedById = session.employee.id;
153
+ updated.version = current.version + 1;
154
+ session.put("sales-orders", current.id, updated);
155
+ session.recordChanged("salesOrder", current.id, "UPDATE", updated.subsidiaryId);
156
+ if (updated.status !== current.status) session.statusChanged("salesOrder", updated, current.status, null);
157
+ return { id: current.id, location: `/services/rest/record/v1/salesOrder/${current.id}` };
158
+ },
159
+
160
+ "sales-order.transform": (input, context) => {
161
+ const session = open(context);
162
+ session.permit("TRAN_SALESORD", "view");
163
+ session.permit("TRAN_CUSTINVC", "create");
164
+ checkMarker(session, input.transformMarker);
165
+ idempotencyKey(session, input);
166
+ const body = requestBody(session, input);
167
+ const order = loadRecord(session, "sales-order", input.recordId);
168
+ checkVersion(session, input, order);
169
+ session.rows("invoices");
170
+ if (order.status === "closed") {
171
+ session.fail("USER_ERROR", "This sales order is closed and cannot be billed.");
172
+ }
173
+ const remaining = new Map();
174
+ for (const line of order.lines) {
175
+ const openQuantity = round4(line.quantity - line.quantityBilled);
176
+ if (openQuantity > 0) remaining.set(line.line, openQuantity);
177
+ }
178
+ if (remaining.size === 0) {
179
+ session.fail("USER_ERROR", "There are no items to be billed on this sales order.");
180
+ }
181
+ const requested = sublistItems(session, body, "item", 200);
182
+ const billing = new Map();
183
+ if (requested === undefined || requested.length === 0) {
184
+ for (const [line, quantity] of remaining) billing.set(line, quantity);
185
+ } else {
186
+ for (const [index, entry] of requested.entries()) {
187
+ const prefix = `item.items[${index}].`;
188
+ const orderLine = numberField(session, entry, "orderLine", { min: 1, max: 100000, prefix });
189
+ if (orderLine === undefined || orderLine === null || !remaining.has(orderLine)) {
190
+ session.fail("USER_ERROR", `Order line ${quote(String(orderLine))} cannot be billed on this sales order.`, {
191
+ errorPath: `${prefix}orderLine`,
192
+ });
193
+ }
194
+ const quantity = numberField(session, entry, "quantity", { min: 0, max: 1000000, prefix }) ?? remaining.get(orderLine);
195
+ if (quantity > remaining.get(orderLine)) {
196
+ session.fail("USER_ERROR", `Line ${orderLine} has only ${remaining.get(orderLine)} units left to bill.`, {
197
+ errorPath: `${prefix}quantity`,
198
+ });
199
+ }
200
+ if (quantity > 0) billing.set(orderLine, round4(quantity));
201
+ }
202
+ if (billing.size === 0) session.fail("USER_ERROR", "There are no items to be billed on this sales order.");
203
+ }
204
+
205
+ const updatedOrder = { ...order, lines: order.lines.map((line) => ({ ...line })) };
206
+ const invoiceLines = [];
207
+ let number = 1;
208
+ for (const line of updatedOrder.lines) {
209
+ const quantity = billing.get(line.line);
210
+ if (quantity === undefined) continue;
211
+ line.quantityBilled = round4(line.quantityBilled + quantity);
212
+ const amount = round2(quantity * line.rate);
213
+ invoiceLines.push({
214
+ line: number,
215
+ itemId: line.itemId,
216
+ quantity,
217
+ rate: line.rate,
218
+ amount,
219
+ description: line.description,
220
+ taxRate: line.taxRate,
221
+ taxAmount: round2(amount * line.taxRate),
222
+ orderLine: line.line,
223
+ quantityBilled: 0,
224
+ });
225
+ number += 1;
226
+ }
227
+ const amounts = totals(invoiceLines);
228
+ const invoiceId = session.nextId("nextTransactionId", "invoices");
229
+ const tranDate = dateField(session, body, "tranDate") ?? session.today;
230
+ const dueDate = dateField(session, body, "dueDate") ?? addDays(tranDate, 30);
231
+ const invoice = {
232
+ id: invoiceId,
233
+ tranId: nextTranId(session, "invoices", "INV"),
234
+ tranDate,
235
+ dueDate,
236
+ entityId: order.entityId,
237
+ subsidiaryId: order.subsidiaryId,
238
+ createdFromId: order.id,
239
+ status: "open",
240
+ memo: stringField(session, body, "memo", 999) ?? order.memo,
241
+ otherRefNum: order.otherRefNum,
242
+ currencyId: order.currencyId,
243
+ termsId: order.termsId,
244
+ lines: invoiceLines,
245
+ ...amounts,
246
+ amountPaid: 0,
247
+ appliedPaymentIds: [],
248
+ createdDate: session.now,
249
+ lastModifiedDate: session.now,
250
+ createdById: session.employee.id,
251
+ lastModifiedById: session.employee.id,
252
+ version: 1,
253
+ };
254
+ updatedOrder.billedTotal = billedTotal(updatedOrder.lines);
255
+ updatedOrder.status = orderStatus(updatedOrder);
256
+ updatedOrder.lastModifiedDate = session.now;
257
+ updatedOrder.lastModifiedById = session.employee.id;
258
+ updatedOrder.version = order.version + 1;
259
+
260
+ session.put("invoices", invoiceId, invoice);
261
+ setLineCounter(session, invoiceId, invoiceLines.length + 1);
262
+ session.put("sales-orders", order.id, updatedOrder);
263
+ session.recordChanged("invoice", invoiceId, "CREATE", invoice.subsidiaryId);
264
+ session.statusChanged("invoice", invoice, null, invoice.total);
265
+ session.recordChanged("salesOrder", order.id, "UPDATE", updatedOrder.subsidiaryId);
266
+ if (updatedOrder.status !== order.status) session.statusChanged("salesOrder", updatedOrder, order.status, null);
267
+ return { id: invoiceId, location: `/services/rest/record/v1/invoice/${invoiceId}` };
268
+ },
269
+ };