@stubbedev/trimit-mcp 0.1.2 → 0.1.3

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.
@@ -0,0 +1,406 @@
1
+ // Field flags:
2
+ // required — must be present on POST create
3
+ // mutable — patchable after create
4
+ // readOnly — server-set, never send
5
+ // decimal — send/receive as JSON string when IEEE754Compatible: true
6
+ // enumRef — see enums.ts
7
+ // itemEntity — child entity for arrays
8
+ export const ENTITIES = {
9
+ customer: {
10
+ name: "customer",
11
+ resourcePath: "customers",
12
+ category: "customers",
13
+ apiBase: "trimit",
14
+ keys: ["systemId"],
15
+ defaultExpand: [
16
+ "picture",
17
+ "defaultDimensions",
18
+ "priceGroupParameters",
19
+ "contactsInformation",
20
+ "customerFinancialDetail",
21
+ "configuredFields",
22
+ ],
23
+ fields: [
24
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true, description: "BC SystemId GUID — use in URL path for PATCH/DELETE." },
25
+ { name: "number", type: "string", required: true, mutable: false, description: "Customer number. Unique. 409 if duplicate on POST." },
26
+ { name: "displayName", type: "string", required: true, mutable: true },
27
+ { name: "type", type: "enum", enumRef: "customerType", required: true, mutable: true },
28
+ { name: "addressLine1", type: "string", required: true, mutable: true },
29
+ { name: "addressLine2", type: "string", mutable: true },
30
+ { name: "city", type: "string", required: true, mutable: true },
31
+ { name: "state", type: "string", mutable: true },
32
+ { name: "country", type: "string", required: true, mutable: true, description: "ISO country/region code." },
33
+ { name: "postalCode", type: "string", required: true, mutable: true },
34
+ { name: "email", type: "string", required: true, mutable: true },
35
+ { name: "phoneNumber", type: "string", mutable: true },
36
+ { name: "website", type: "string", mutable: true },
37
+ { name: "salespersonCode", type: "string", mutable: true },
38
+ { name: "salespersonCode2", type: "string", mutable: true },
39
+ { name: "salespersonCode3", type: "string", mutable: true },
40
+ { name: "creditLimit", type: "decimal", decimal: true, mutable: true },
41
+ { name: "blocked", type: "enum", enumRef: "customerBlocked", mutable: true },
42
+ { name: "chain", type: "string", mutable: true },
43
+ { name: "companyGroup", type: "string", mutable: true },
44
+ { name: "selltoGroup", type: "string", mutable: true },
45
+ { name: "selltoType", type: "string", mutable: true },
46
+ { name: "commissionGroup", type: "string", mutable: true },
47
+ { name: "bonusGroup", type: "string", mutable: true },
48
+ { name: "allocationPriority", type: "integer", mutable: true },
49
+ { name: "lastDateModified", type: "datetime", readOnly: true, description: "Incremental sync cursor." },
50
+ { name: "additionalFields", type: "array", itemEntity: "additionalField", mutable: true, description: "Replacement set on PATCH — sending replaces all rows." },
51
+ ],
52
+ navigationProperties: [
53
+ { name: "picture", target: "picture", multiplicity: "one" },
54
+ { name: "defaultDimensions", target: "dimensionAssignment", multiplicity: "many" },
55
+ { name: "priceGroupParameters", target: "priceGroupParameter", multiplicity: "many" },
56
+ { name: "contactsInformation", target: "contactInformation", multiplicity: "many" },
57
+ { name: "customerFinancialDetail", target: "customerFinancialDetail", multiplicity: "one" },
58
+ { name: "configuredFields", target: "configuredField", multiplicity: "many" },
59
+ { name: "additionalFields", target: "additionalField", multiplicity: "many" },
60
+ ],
61
+ },
62
+ additionalField: {
63
+ name: "additionalField",
64
+ resourcePath: "additionalFields",
65
+ category: "customers",
66
+ apiBase: "trimit",
67
+ keys: ["name"],
68
+ fields: [
69
+ { name: "number", type: "string", description: "Parent doc/customer number — present on line-level additionalFields." },
70
+ { name: "name", type: "string", required: true, primaryKey: true },
71
+ { name: "value", type: "string", required: true },
72
+ ],
73
+ navigationProperties: [],
74
+ notes: "Maps to BC configuredFields table. Customer + sales doc header use {name,value}; lines use {number?,name,value}.",
75
+ },
76
+ salesDocument: {
77
+ name: "salesDocument",
78
+ resourcePath: "salesDocuments",
79
+ category: "salesdocs",
80
+ apiBase: "trimit",
81
+ keys: ["systemId"],
82
+ defaultExpand: ["salesDocumentLines($expand=additionalFields)", "additionalFields"],
83
+ fields: [
84
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
85
+ { name: "docType", type: "enum", enumRef: "docType", required: true, description: "Case-sensitive. 'Order' not 'order'." },
86
+ { name: "docNo", type: "string", required: true, description: "Customer-facing doc number — unique per docType." },
87
+ { name: "sellToCustomerNo", type: "string", required: true, description: "BC customer number, e.g. '10000'." },
88
+ { name: "SellToCustomerName", type: "string", description: "Note capitalization — server returns SellTo* with capital S." },
89
+ { name: "sellToEmail", type: "string" },
90
+ { name: "selltoPhoneNo", type: "string" },
91
+ { name: "orderDate", type: "date", required: true, description: "YYYY-MM-DD." },
92
+ { name: "releaseDocument", type: "boolean", description: "true = release in BC on create." },
93
+ { name: "processedDate", type: "datetime", readOnly: true, description: "Set by BC when import journal row is processed." },
94
+ { name: "lastDateModified", type: "datetime", readOnly: true },
95
+ { name: "salesDocumentLines", type: "array", itemEntity: "salesDocumentLine", required: true },
96
+ { name: "additionalFields", type: "array", itemEntity: "additionalField" },
97
+ ],
98
+ navigationProperties: [
99
+ { name: "salesDocumentLines", target: "salesDocumentLine", multiplicity: "many" },
100
+ { name: "additionalFields", target: "additionalField", multiplicity: "many" },
101
+ ],
102
+ notes: "POST /salesDocuments writes to TRIMIT Sales Import Journal. processedDate set by BC when imported. GET /salesDocuments() returns processed rows. Use exportedDocuments markers to exclude from future polls.",
103
+ },
104
+ salesDocumentLine: {
105
+ name: "salesDocumentLine",
106
+ resourcePath: "salesDocumentLines",
107
+ category: "salesdocs",
108
+ apiBase: "trimit",
109
+ keys: ["lineNo"],
110
+ fields: [
111
+ { name: "lineNo", type: "integer", description: "Required when appending lines via add-fields call; auto-assigned on create." },
112
+ { name: "type", type: "enum", enumRef: "salesLineType", required: true },
113
+ { name: "no", type: "string", required: true, description: "Item number / G/L account / etc." },
114
+ { name: "unitPrice", type: "decimal", decimal: true, required: true },
115
+ { name: "quantity", type: "decimal", decimal: true, required: true },
116
+ { name: "unitOfMeasureCode", type: "string" },
117
+ { name: "locationCode", type: "string" },
118
+ { name: "periodCode", type: "string" },
119
+ { name: "discountAmount", type: "decimal", decimal: true },
120
+ { name: "additionalFields", type: "array", itemEntity: "additionalField" },
121
+ ],
122
+ navigationProperties: [
123
+ { name: "additionalFields", target: "additionalField", multiplicity: "many" },
124
+ ],
125
+ },
126
+ salesReturnOrder: {
127
+ name: "salesReturnOrder",
128
+ resourcePath: "salesReturnOrders",
129
+ category: "salesdocs",
130
+ apiBase: "trimit",
131
+ keys: ["systemId"],
132
+ defaultExpand: ["salesReturnOrderLines($expand=additionalFields)", "additionalFields"],
133
+ fields: [
134
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
135
+ { name: "returnOrderNo", type: "string", required: true },
136
+ { name: "sellToCustomerNo", type: "string" },
137
+ { name: "SellToCustomerName", type: "string" },
138
+ { name: "sellToEmail", type: "string" },
139
+ { name: "selltoPhoneNo", type: "string" },
140
+ { name: "orderDate", type: "date", required: true, description: "YYYY-MM-DD." },
141
+ { name: "releaseDocument", type: "boolean" },
142
+ { name: "salesReturnOrderLines", type: "array", itemEntity: "salesDocumentLine", required: true },
143
+ { name: "additionalFields", type: "array", itemEntity: "additionalField" },
144
+ ],
145
+ navigationProperties: [
146
+ { name: "salesReturnOrderLines", target: "salesDocumentLine", multiplicity: "many" },
147
+ { name: "additionalFields", target: "additionalField", multiplicity: "many" },
148
+ ],
149
+ notes: "Only return orders created via API are visible in /salesReturnOrders. BC-client-created return orders are not surfaced.",
150
+ },
151
+ exportedDocument: {
152
+ name: "exportedDocument",
153
+ resourcePath: "exportedDocuments",
154
+ category: "exported",
155
+ apiBase: "trimit",
156
+ keys: ["type", "number"],
157
+ fields: [
158
+ { name: "type", type: "enum", enumRef: "exportedDocumentType", required: true, primaryKey: true, description: "Case-sensitive." },
159
+ { name: "number", type: "string", required: true, primaryKey: true },
160
+ ],
161
+ navigationProperties: [],
162
+ notes: "POST body uses {type,number}. GET/DELETE path: exportedDocuments('Order','SO1001') — order matches keys[]. Marker, not state — does not change BC.",
163
+ },
164
+ master: {
165
+ name: "master",
166
+ resourcePath: "masters",
167
+ category: "products",
168
+ apiBase: "trimit",
169
+ keys: ["systemId"],
170
+ defaultExpand: [
171
+ "masterDescriptions",
172
+ "masterDefDims",
173
+ "masterAttributes",
174
+ "masterStatGroups",
175
+ "masterVarDims",
176
+ "masterCollections",
177
+ "masterCompositions",
178
+ "masterAddInfos",
179
+ "masterCarelabels",
180
+ "masterAddTags",
181
+ "masterSustainabilities",
182
+ "masterMeasureCharts",
183
+ "masterItems",
184
+ "masterInventories",
185
+ "masterImages",
186
+ "masterPrices",
187
+ "masterChoicelists",
188
+ "configuredFields",
189
+ ],
190
+ fields: [
191
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
192
+ { name: "number", type: "string", description: "Master code." },
193
+ { name: "noSystem", type: "string", description: "SKU template." },
194
+ { name: "lastDateModified", type: "datetime", readOnly: true },
195
+ ],
196
+ navigationProperties: [
197
+ { name: "masterItems", target: "item", multiplicity: "many" },
198
+ { name: "masterDescriptions", target: "masterDescription", multiplicity: "many" },
199
+ { name: "masterDefDims", target: "dimensionAssignment", multiplicity: "many" },
200
+ { name: "masterAttributes", target: "itemAttribute", multiplicity: "many" },
201
+ { name: "masterCollections", target: "collection", multiplicity: "many" },
202
+ { name: "masterImages", target: "masterImage", multiplicity: "many" },
203
+ { name: "masterPrices", target: "price", multiplicity: "many" },
204
+ { name: "configuredFields", target: "configuredField", multiplicity: "many" },
205
+ ],
206
+ notes: "Heavy payload — narrow with $select and trimmed $expand for production traffic.",
207
+ },
208
+ item: {
209
+ name: "item",
210
+ resourcePath: "items",
211
+ category: "products",
212
+ apiBase: "trimit",
213
+ keys: ["systemId"],
214
+ defaultExpand: [
215
+ "unitOfMeasure",
216
+ "extendedDescriptions",
217
+ "defaultDimensions",
218
+ "attributes",
219
+ "itemStatGroups",
220
+ "picture",
221
+ "itemCompositions",
222
+ "itemAddInfos",
223
+ "itemCarelabels",
224
+ "itemAddTags",
225
+ "itemSustainabilities",
226
+ "itemInventories",
227
+ "itemPrices",
228
+ "configuredFields",
229
+ ],
230
+ fields: [
231
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
232
+ { name: "number", type: "string", description: "Item/SKU number." },
233
+ { name: "description", type: "string" },
234
+ { name: "lastDateModified", type: "datetime", readOnly: true },
235
+ ],
236
+ navigationProperties: [
237
+ { name: "unitOfMeasure", target: "unitOfMeasure", multiplicity: "one" },
238
+ { name: "defaultDimensions", target: "dimensionAssignment", multiplicity: "many" },
239
+ { name: "attributes", target: "itemAttribute", multiplicity: "many" },
240
+ { name: "itemPrices", target: "price", multiplicity: "many" },
241
+ { name: "configuredFields", target: "configuredField", multiplicity: "many" },
242
+ ],
243
+ notes: "TRIMIT-enriched item. Different resource from standard /api/v2.0/.../items.",
244
+ },
245
+ product: {
246
+ name: "product",
247
+ resourcePath: "products",
248
+ category: "products",
249
+ apiBase: "trimit",
250
+ keys: ["systemId"],
251
+ fields: [
252
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
253
+ { name: "number", type: "string" },
254
+ { name: "lastDateModified", type: "datetime", readOnly: true, description: "Filter on this for delta sync." },
255
+ ],
256
+ navigationProperties: [],
257
+ notes: "Curated feed driven by configured BC categories.",
258
+ },
259
+ campaign: {
260
+ name: "campaign",
261
+ resourcePath: "campaigns",
262
+ category: "masterdata",
263
+ apiBase: "trimit",
264
+ keys: ["systemId"],
265
+ defaultExpand: ["defaultDimensions", "priceGroupParameters"],
266
+ fields: [
267
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
268
+ { name: "number", type: "string" },
269
+ { name: "description", type: "string" },
270
+ { name: "salespersonCode", type: "string" },
271
+ { name: "statusCode", type: "string" },
272
+ { name: "startingDate", type: "date" },
273
+ { name: "endingDate", type: "date" },
274
+ { name: "activated", type: "boolean" },
275
+ { name: "lastDateModified", type: "datetime", readOnly: true },
276
+ ],
277
+ navigationProperties: [
278
+ { name: "defaultDimensions", target: "dimensionAssignment", multiplicity: "many" },
279
+ { name: "priceGroupParameters", target: "priceGroupParameter", multiplicity: "many" },
280
+ ],
281
+ },
282
+ inventory: {
283
+ name: "inventory",
284
+ resourcePath: "inventories",
285
+ category: "inventory",
286
+ apiBase: "trimit",
287
+ keys: ["itemNo", "locationCode"],
288
+ fields: [
289
+ { name: "itemNo", type: "string", primaryKey: true },
290
+ { name: "locationCode", type: "string", primaryKey: true },
291
+ { name: "quantity", type: "decimal", decimal: true },
292
+ { name: "futureDelivers", type: "decimal", decimal: true, description: "Pre-order stock when TRIMIT 'Export Future Delivers' is enabled." },
293
+ ],
294
+ navigationProperties: [],
295
+ notes: "Use Data-Access-Intent: ReadOnly for heavy polling.",
296
+ },
297
+ location: {
298
+ name: "location",
299
+ resourcePath: "locations",
300
+ category: "inventory",
301
+ apiBase: "trimit",
302
+ keys: ["code"],
303
+ fields: [
304
+ { name: "code", type: "string", primaryKey: true },
305
+ { name: "name", type: "string" },
306
+ { name: "addressLine1", type: "string" },
307
+ { name: "city", type: "string" },
308
+ { name: "country", type: "string" },
309
+ ],
310
+ navigationProperties: [],
311
+ },
312
+ contact: {
313
+ name: "contact",
314
+ resourcePath: "contacts",
315
+ category: "customers",
316
+ apiBase: "trimit",
317
+ keys: ["systemId"],
318
+ defaultExpand: ["picture"],
319
+ fields: [
320
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
321
+ { name: "companyName", type: "string" },
322
+ { name: "addressLine1", type: "string" },
323
+ { name: "addressLine2", type: "string" },
324
+ { name: "city", type: "string" },
325
+ { name: "salespersonCode", type: "string" },
326
+ { name: "phoneNo", type: "string" },
327
+ { name: "email", type: "string" },
328
+ ],
329
+ navigationProperties: [{ name: "picture", target: "picture", multiplicity: "one" }],
330
+ },
331
+ salesperson: {
332
+ name: "salesperson",
333
+ resourcePath: "salespersons",
334
+ category: "customers",
335
+ apiBase: "trimit",
336
+ keys: ["code"],
337
+ fields: [
338
+ { name: "code", type: "string", primaryKey: true },
339
+ { name: "name", type: "string" },
340
+ { name: "email", type: "string" },
341
+ { name: "phoneNo", type: "string" },
342
+ { name: "commissionPercent", type: "decimal", decimal: true },
343
+ { name: "globalDimension1Code", type: "string" },
344
+ { name: "globalDimension2Code", type: "string" },
345
+ ],
346
+ navigationProperties: [],
347
+ },
348
+ postedSalesInvoice: {
349
+ name: "postedSalesInvoice",
350
+ resourcePath: "postedSalesInvoices",
351
+ category: "postedsales",
352
+ apiBase: "trimit",
353
+ keys: ["systemId"],
354
+ defaultExpand: ["postedSalesInvoiceLines", "trackingLines"],
355
+ fields: [
356
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
357
+ { name: "number", type: "string" },
358
+ { name: "customerNo", type: "string" },
359
+ { name: "postingDate", type: "date" },
360
+ { name: "totalAmount", type: "decimal", decimal: true },
361
+ ],
362
+ navigationProperties: [
363
+ { name: "postedSalesInvoiceLines", target: "postedSalesInvoiceLine", multiplicity: "many" },
364
+ { name: "trackingLines", target: "trackingLine", multiplicity: "many" },
365
+ ],
366
+ },
367
+ postedSalesShipment: {
368
+ name: "postedSalesShipment",
369
+ resourcePath: "postedSalesShipments",
370
+ category: "postedsales",
371
+ apiBase: "trimit",
372
+ keys: ["systemId"],
373
+ defaultExpand: ["postedSalesShipmentLines", "trackingLines"],
374
+ fields: [
375
+ { name: "systemId", type: "guid", primaryKey: true, readOnly: true },
376
+ { name: "number", type: "string" },
377
+ { name: "customerNo", type: "string" },
378
+ { name: "postingDate", type: "date" },
379
+ ],
380
+ navigationProperties: [
381
+ { name: "postedSalesShipmentLines", target: "postedSalesShipmentLine", multiplicity: "many" },
382
+ { name: "trackingLines", target: "trackingLine", multiplicity: "many" },
383
+ ],
384
+ },
385
+ dimensionAssignment: {
386
+ name: "dimensionAssignment",
387
+ resourcePath: "defaultDimensions",
388
+ category: "standard",
389
+ apiBase: "trimit",
390
+ keys: ["parentId", "dimensionCode"],
391
+ fields: [
392
+ { name: "parentType", type: "enum", enumRef: "dimensionParentType", required: true },
393
+ { name: "parentId", type: "guid", required: true },
394
+ { name: "dimensionCode", type: "string", required: true, primaryKey: true },
395
+ { name: "dimensionValueCode", type: "string", required: true },
396
+ ],
397
+ navigationProperties: [],
398
+ },
399
+ };
400
+ export const ENTITY_NAMES = Object.keys(ENTITIES);
401
+ export function getEntity(name) {
402
+ return ENTITIES[name];
403
+ }
404
+ export function findEntityByResourcePath(path) {
405
+ return Object.values(ENTITIES).find((e) => e.resourcePath === path);
406
+ }
@@ -0,0 +1,55 @@
1
+ export const ENUMS = {
2
+ docType: {
3
+ name: "docType",
4
+ values: ["Quote", "Order", "Invoice", "Credit Memo", "Blanket Order", "Return Order"],
5
+ caseSensitive: true,
6
+ description: "Sales document type for /salesDocuments POST. Case-sensitive.",
7
+ },
8
+ exportedDocumentType: {
9
+ name: "exportedDocumentType",
10
+ values: [
11
+ "Quote",
12
+ "Order",
13
+ "Invoice",
14
+ "Credit Memo",
15
+ "Blanket Order",
16
+ "Return Order",
17
+ "Posted Invoice",
18
+ "Posted Credit Memo",
19
+ "Posted Shipment",
20
+ "Posted Return Receipt",
21
+ ],
22
+ caseSensitive: true,
23
+ description: "Type values accepted by /exportedDocuments. Posted-* variants only valid here, not in /salesDocuments.",
24
+ },
25
+ customerType: {
26
+ name: "customerType",
27
+ values: ["Company", "Person"],
28
+ caseSensitive: true,
29
+ description: "Customer.type field.",
30
+ },
31
+ customerBlocked: {
32
+ name: "customerBlocked",
33
+ values: [" ", "Ship", "Invoice", "All"],
34
+ caseSensitive: true,
35
+ description: "Customer.blocked field. Space ' ' = not blocked. Case-sensitive.",
36
+ },
37
+ salesLineType: {
38
+ name: "salesLineType",
39
+ values: ["Item", "G/L Account", "Resource", "Fixed Asset", "Charge (Item)", "Comment"],
40
+ caseSensitive: true,
41
+ description: "Sales document line type. Case-sensitive — 'item' will fail.",
42
+ },
43
+ dimensionParentType: {
44
+ name: "dimensionParentType",
45
+ values: ["Customer", "Vendor", "Item", "Employee", "Resource", "G/L Account", "Bank Account", "Fixed Asset"],
46
+ caseSensitive: true,
47
+ description: "defaultDimensions[].parentType.",
48
+ },
49
+ batchMethod: {
50
+ name: "batchMethod",
51
+ values: ["GET", "POST", "PATCH", "PUT", "DELETE"],
52
+ caseSensitive: true,
53
+ description: "HTTP method inside /$batch sub-requests. Uppercase.",
54
+ },
55
+ };
@@ -0,0 +1,111 @@
1
+ const RULES = [
2
+ {
3
+ code: "auth.hardcoded-token",
4
+ severity: "error",
5
+ detect: (s) => /Bearer\s+ey[A-Za-z0-9_\-.]{20,}/.test(s),
6
+ message: "Hardcoded JWT bearer token in source.",
7
+ fix: "Fetch tokens at runtime from login.microsoftonline.com/{tenant}/oauth2/v2.0/token and cache them in memory.",
8
+ },
9
+ {
10
+ code: "auth.hardcoded-secret",
11
+ severity: "error",
12
+ detect: (s) => /client[_-]?secret\s*[:=]\s*['"]?[A-Za-z0-9~._\-]{16,}['"]?/.test(s) && !/process\.env|getenv|os\.environ/.test(s),
13
+ message: "Hardcoded client_secret. Load from environment / secret manager.",
14
+ },
15
+ {
16
+ code: "auth.no-refresh",
17
+ severity: "warning",
18
+ detect: (s) => /fetch\(/.test(s) && /Bearer/.test(s) && !/(401|InvalidAuthenticationToken|expires_in|expiresAt|tokenExpiry|refresh)/i.test(s),
19
+ message: "No token-refresh path detected. BC tokens expire ~1h; a long-running integration needs to handle 401 InvalidAuthenticationToken and re-acquire.",
20
+ },
21
+ {
22
+ code: "paging.no-next-link",
23
+ severity: "warning",
24
+ detect: (s) => /\/(salesDocuments|salesOrders|salesInvoices|postedSalesInvoices|customers|items|masters|products|inventories|campaigns)\b/.test(s) && /fetch\(/.test(s) && !/@odata\.nextLink|nextLink|odata\.nextLink/.test(s),
25
+ message: "Listing call without @odata.nextLink iteration. BC caps pages at 20 000 rows; large datasets silently truncate.",
26
+ fix: "Loop while response['@odata.nextLink'] is set; fetch that URL with the same Authorization header.",
27
+ },
28
+ {
29
+ code: "concurrency.no-if-match",
30
+ severity: "error",
31
+ detect: (s) => /method\s*:\s*['"]PATCH['"]/i.test(s) && !/If-Match/i.test(s),
32
+ message: "PATCH without If-Match header. BC OData rejects PATCH without If-Match.",
33
+ fix: "Add `If-Match: '*'` for unconditional update, or pass the @odata.etag value from a prior GET for optimistic concurrency.",
34
+ },
35
+ {
36
+ code: "concurrency.no-ieee754",
37
+ severity: "warning",
38
+ detect: (s) => /(creditLimit|unitPrice|quantity|discountAmount|totalAmount|commissionPercent)/.test(s) && !/IEEE754Compatible/i.test(s),
39
+ message: "Decimal field present without IEEE754Compatible: true. JS numbers can lose precision on large/high-scale decimals.",
40
+ fix: "Send `IEEE754Compatible: true` header and represent decimals as JSON strings ('123.45').",
41
+ },
42
+ {
43
+ code: "throttle.no-retry-after",
44
+ severity: "warning",
45
+ detect: (s) => /fetch\(/.test(s) && /(retry|backoff)/i.test(s) === false && /(\.status\s*===?\s*429|status\s*===?\s*429)/.test(s),
46
+ message: "429 handling without Retry-After backoff. Respect the header to avoid compounding throttling.",
47
+ },
48
+ {
49
+ code: "throttle.no-handling",
50
+ severity: "info",
51
+ detect: (s) => /fetch\(/.test(s) && /Bearer/.test(s) && !/429/.test(s),
52
+ message: "No visible 429 handling. BC enforces global throttling; add backoff for any production integration.",
53
+ },
54
+ {
55
+ code: "lifecycle.poll-without-exported",
56
+ severity: "warning",
57
+ detect: (s) => /\/salesDocuments\b/.test(s) && /poll|setInterval|setTimeout|while\s*\(/.test(s) && !/exportedDocuments/.test(s),
58
+ message: "Polling /salesDocuments without using /exportedDocuments markers will re-deliver the same docs forever.",
59
+ fix: "After processing each doc, POST /exportedDocuments {type, number}. Subsequent GETs exclude marked docs.",
60
+ },
61
+ {
62
+ code: "lifecycle.processed-filter",
63
+ severity: "info",
64
+ detect: (s) => /\/salesDocuments\(\)/.test(s) && !/processedDate\s*gt\s*0001-01-01/.test(s),
65
+ message: "GET /salesDocuments() (the parenthesized variant) is intended for processed docs. Pair with `$filter=(processedDate gt 0001-01-01)`.",
66
+ },
67
+ {
68
+ code: "batch.absolute-urls",
69
+ severity: "warning",
70
+ detect: (s) => /\/\$batch/.test(s) && /"url"\s*:\s*"https?:/.test(s),
71
+ message: "$batch sub-request URLs should be relative to the batch base, not absolute.",
72
+ },
73
+ {
74
+ code: "batch.no-id",
75
+ severity: "warning",
76
+ detect: (s) => /\/\$batch/.test(s) && /"method"\s*:\s*"POST"/.test(s) && !/"id"\s*:/.test(s),
77
+ message: "$batch sub-requests need a unique 'id' to match against the response.",
78
+ },
79
+ {
80
+ code: "filter.unquoted-string",
81
+ severity: "warning",
82
+ detect: (s) => /\$filter=[^&]*\beq\s+[A-Za-z][A-Za-z0-9]*\b(?!\s*\()/.test(s) && !/eq\s+(null|true|false|\d)/.test(s),
83
+ message: "Possible unquoted string literal in $filter. Wrap string values in single quotes: number eq '20002036'.",
84
+ },
85
+ {
86
+ code: "filter.equals-sign",
87
+ severity: "error",
88
+ detect: (s) => /\$filter=[^&]*(?<!\w)=\s*[^=]/.test(s),
89
+ message: "Found '=' in $filter — OData uses 'eq', not '='.",
90
+ },
91
+ {
92
+ code: "doctype.lowercase",
93
+ severity: "error",
94
+ detect: (s) => /"docType"\s*:\s*"(order|quote|invoice|credit memo|blanket order|return order)"/.test(s),
95
+ message: "docType is case-sensitive. 'order' will be rejected — use 'Order'.",
96
+ },
97
+ {
98
+ code: "endpoint.wrong-base",
99
+ severity: "warning",
100
+ detect: (s) => /api\.businesscentral\.dynamics\.com\/v2\.0\/[^/]+\/[^/]+\/api\/v2\.0\/.*\/(masters|products|campaigns|exportedDocuments|salesDocuments)/.test(s),
101
+ message: "Using /api/v2.0 path with a TRIMIT-only resource. Switch to /api/trimit/integration/v1.1.",
102
+ },
103
+ ];
104
+ export function lintSnippet(snippet) {
105
+ return RULES.filter((r) => r.detect(snippet)).map(({ code, severity, message, fix }) => ({
106
+ code,
107
+ severity,
108
+ message,
109
+ fix,
110
+ }));
111
+ }