@memberjunction/integration-connectors 5.51.0 → 6.1.0-edge.0

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 (155) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +109 -277
  3. package/dist/index.d.ts +0 -36
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +7 -38
  6. package/dist/index.js.map +1 -1
  7. package/package.json +14 -19
  8. package/dist/AptifyConnector.d.ts +0 -95
  9. package/dist/AptifyConnector.d.ts.map +0 -1
  10. package/dist/AptifyConnector.js +0 -760
  11. package/dist/AptifyConnector.js.map +0 -1
  12. package/dist/BlackbaudConnector.d.ts +0 -64
  13. package/dist/BlackbaudConnector.d.ts.map +0 -1
  14. package/dist/BlackbaudConnector.js +0 -916
  15. package/dist/BlackbaudConnector.js.map +0 -1
  16. package/dist/ConstantContactConnector.d.ts +0 -137
  17. package/dist/ConstantContactConnector.d.ts.map +0 -1
  18. package/dist/ConstantContactConnector.js +0 -663
  19. package/dist/ConstantContactConnector.js.map +0 -1
  20. package/dist/CventConnector.d.ts +0 -146
  21. package/dist/CventConnector.d.ts.map +0 -1
  22. package/dist/CventConnector.js +0 -540
  23. package/dist/CventConnector.js.map +0 -1
  24. package/dist/DynamicsDataverseConnector.d.ts +0 -296
  25. package/dist/DynamicsDataverseConnector.d.ts.map +0 -1
  26. package/dist/DynamicsDataverseConnector.js +0 -790
  27. package/dist/DynamicsDataverseConnector.js.map +0 -1
  28. package/dist/FileFeedConnector.d.ts +0 -64
  29. package/dist/FileFeedConnector.d.ts.map +0 -1
  30. package/dist/FileFeedConnector.js +0 -190
  31. package/dist/FileFeedConnector.js.map +0 -1
  32. package/dist/FontevaConnector.d.ts +0 -153
  33. package/dist/FontevaConnector.d.ts.map +0 -1
  34. package/dist/FontevaConnector.js +0 -421
  35. package/dist/FontevaConnector.js.map +0 -1
  36. package/dist/GrowthZoneConnector.d.ts +0 -183
  37. package/dist/GrowthZoneConnector.d.ts.map +0 -1
  38. package/dist/GrowthZoneConnector.js +0 -702
  39. package/dist/GrowthZoneConnector.js.map +0 -1
  40. package/dist/HivebriteConnector.d.ts +0 -151
  41. package/dist/HivebriteConnector.d.ts.map +0 -1
  42. package/dist/HivebriteConnector.js +0 -533
  43. package/dist/HivebriteConnector.js.map +0 -1
  44. package/dist/HubSpotConnector.d.ts +0 -523
  45. package/dist/HubSpotConnector.d.ts.map +0 -1
  46. package/dist/HubSpotConnector.js +0 -3110
  47. package/dist/HubSpotConnector.js.map +0 -1
  48. package/dist/IMISConnector.d.ts +0 -171
  49. package/dist/IMISConnector.d.ts.map +0 -1
  50. package/dist/IMISConnector.js +0 -695
  51. package/dist/IMISConnector.js.map +0 -1
  52. package/dist/MJToMJConnector.d.ts +0 -279
  53. package/dist/MJToMJConnector.d.ts.map +0 -1
  54. package/dist/MJToMJConnector.js +0 -893
  55. package/dist/MJToMJConnector.js.map +0 -1
  56. package/dist/MagnetMailConnector.d.ts +0 -163
  57. package/dist/MagnetMailConnector.d.ts.map +0 -1
  58. package/dist/MagnetMailConnector.js +0 -823
  59. package/dist/MagnetMailConnector.js.map +0 -1
  60. package/dist/MailchimpConnector.d.ts +0 -197
  61. package/dist/MailchimpConnector.d.ts.map +0 -1
  62. package/dist/MailchimpConnector.js +0 -791
  63. package/dist/MailchimpConnector.js.map +0 -1
  64. package/dist/MemberSuiteConnector.d.ts +0 -185
  65. package/dist/MemberSuiteConnector.d.ts.map +0 -1
  66. package/dist/MemberSuiteConnector.js +0 -762
  67. package/dist/MemberSuiteConnector.js.map +0 -1
  68. package/dist/NeonCRMConnector.d.ts +0 -273
  69. package/dist/NeonCRMConnector.d.ts.map +0 -1
  70. package/dist/NeonCRMConnector.js +0 -1039
  71. package/dist/NeonCRMConnector.js.map +0 -1
  72. package/dist/NetForumConnector.d.ts +0 -170
  73. package/dist/NetForumConnector.d.ts.map +0 -1
  74. package/dist/NetForumConnector.js +0 -778
  75. package/dist/NetForumConnector.js.map +0 -1
  76. package/dist/NetSuiteConnector.d.ts +0 -136
  77. package/dist/NetSuiteConnector.d.ts.map +0 -1
  78. package/dist/NetSuiteConnector.js +0 -703
  79. package/dist/NetSuiteConnector.js.map +0 -1
  80. package/dist/NimbleAMSConnector.d.ts +0 -142
  81. package/dist/NimbleAMSConnector.d.ts.map +0 -1
  82. package/dist/NimbleAMSConnector.js +0 -703
  83. package/dist/NimbleAMSConnector.js.map +0 -1
  84. package/dist/NoviConnector.d.ts +0 -178
  85. package/dist/NoviConnector.d.ts.map +0 -1
  86. package/dist/NoviConnector.js +0 -566
  87. package/dist/NoviConnector.js.map +0 -1
  88. package/dist/ORCIDConnector.d.ts +0 -207
  89. package/dist/ORCIDConnector.d.ts.map +0 -1
  90. package/dist/ORCIDConnector.js +0 -708
  91. package/dist/ORCIDConnector.js.map +0 -1
  92. package/dist/OpenWaterConnector.d.ts +0 -173
  93. package/dist/OpenWaterConnector.d.ts.map +0 -1
  94. package/dist/OpenWaterConnector.js +0 -893
  95. package/dist/OpenWaterConnector.js.map +0 -1
  96. package/dist/PathLMSConnector.d.ts +0 -395
  97. package/dist/PathLMSConnector.d.ts.map +0 -1
  98. package/dist/PathLMSConnector.js +0 -1281
  99. package/dist/PathLMSConnector.js.map +0 -1
  100. package/dist/PheedLoopConnector.d.ts +0 -119
  101. package/dist/PheedLoopConnector.d.ts.map +0 -1
  102. package/dist/PheedLoopConnector.js +0 -429
  103. package/dist/PheedLoopConnector.js.map +0 -1
  104. package/dist/PropFuelConnector.d.ts +0 -153
  105. package/dist/PropFuelConnector.d.ts.map +0 -1
  106. package/dist/PropFuelConnector.js +0 -450
  107. package/dist/PropFuelConnector.js.map +0 -1
  108. package/dist/QuickBooksConnector.d.ts +0 -92
  109. package/dist/QuickBooksConnector.d.ts.map +0 -1
  110. package/dist/QuickBooksConnector.js +0 -713
  111. package/dist/QuickBooksConnector.js.map +0 -1
  112. package/dist/RasaConnector.d.ts +0 -152
  113. package/dist/RasaConnector.d.ts.map +0 -1
  114. package/dist/RasaConnector.js +0 -929
  115. package/dist/RasaConnector.js.map +0 -1
  116. package/dist/Reach360Connector.d.ts +0 -175
  117. package/dist/Reach360Connector.d.ts.map +0 -1
  118. package/dist/Reach360Connector.js +0 -796
  119. package/dist/Reach360Connector.js.map +0 -1
  120. package/dist/RelationalDBConnector.d.ts +0 -108
  121. package/dist/RelationalDBConnector.d.ts.map +0 -1
  122. package/dist/RelationalDBConnector.js +0 -228
  123. package/dist/RelationalDBConnector.js.map +0 -1
  124. package/dist/RhythmConnector.d.ts +0 -188
  125. package/dist/RhythmConnector.d.ts.map +0 -1
  126. package/dist/RhythmConnector.js +0 -784
  127. package/dist/RhythmConnector.js.map +0 -1
  128. package/dist/SageIntacctConnector.d.ts +0 -337
  129. package/dist/SageIntacctConnector.d.ts.map +0 -1
  130. package/dist/SageIntacctConnector.js +0 -2081
  131. package/dist/SageIntacctConnector.js.map +0 -1
  132. package/dist/SalesforceConnector.d.ts +0 -461
  133. package/dist/SalesforceConnector.d.ts.map +0 -1
  134. package/dist/SalesforceConnector.js +0 -1862
  135. package/dist/SalesforceConnector.js.map +0 -1
  136. package/dist/SharePointConnector.d.ts +0 -188
  137. package/dist/SharePointConnector.d.ts.map +0 -1
  138. package/dist/SharePointConnector.js +0 -716
  139. package/dist/SharePointConnector.js.map +0 -1
  140. package/dist/WicketConnector.d.ts +0 -252
  141. package/dist/WicketConnector.d.ts.map +0 -1
  142. package/dist/WicketConnector.js +0 -938
  143. package/dist/WicketConnector.js.map +0 -1
  144. package/dist/WildApricotConnector.d.ts +0 -201
  145. package/dist/WildApricotConnector.d.ts.map +0 -1
  146. package/dist/WildApricotConnector.js +0 -1129
  147. package/dist/WildApricotConnector.js.map +0 -1
  148. package/dist/YourMembershipConnector.d.ts +0 -349
  149. package/dist/YourMembershipConnector.d.ts.map +0 -1
  150. package/dist/YourMembershipConnector.js +0 -4062
  151. package/dist/YourMembershipConnector.js.map +0 -1
  152. package/dist/generate-integration-actions.d.ts +0 -23
  153. package/dist/generate-integration-actions.d.ts.map +0 -1
  154. package/dist/generate-integration-actions.js +0 -201
  155. package/dist/generate-integration-actions.js.map +0 -1
@@ -1,2081 +0,0 @@
1
- var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
- var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
- if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
- else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
- return c > 3 && r && Object.defineProperty(target, key, r), r;
6
- };
7
- import { RegisterClass } from '@memberjunction/global';
8
- import { Metadata } from '@memberjunction/core';
9
- import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
10
- import { BaseIntegrationConnector, } from '@memberjunction/integration-engine';
11
- // ─── Constants ────────────────────────────────────────────────────────
12
- /** Sage Intacct XML Gateway endpoint */
13
- const INTACCT_API_ENDPOINT = 'https://api.intacct.com/ia/xml/xmlgw.phtml';
14
- /** Control ID prefix for XML requests */
15
- const CONTROL_ID_PREFIX = 'mj-intacct';
16
- /** DTD version for Sage Intacct API */
17
- const DTD_VERSION = '3.0';
18
- /** Default maximum retries */
19
- const DEFAULT_MAX_RETRIES = 3;
20
- /** Default HTTP request timeout in milliseconds */
21
- const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
22
- /** Default minimum milliseconds between API requests */
23
- const DEFAULT_MIN_REQUEST_INTERVAL_MS = 200;
24
- /** Default page size for readByQuery — SI's documented max. */
25
- // Direct probing against the live tenant proved that pageSize=1000 (and
26
- // even 2000) is accepted; the previously-observed DL02000001 errors were
27
- // from SQL-style ORDER BY in the WHERE clause, NOT from large pageSizes.
28
- // Critically, SI's readByQuery does NOT scan in PK-ascending order — at
29
- // pageSize=100 against APBILL it returned RECORDNOs 19..1354, silently
30
- // dropping 12..18 because those weren't in SI's first 100 of natural
31
- // order. PK-cursor advancement (PK > maxOfBatch) then made the missing
32
- // records permanently invisible. Using SI's documented maximum minimizes
33
- // the chance any object exceeds a single page; for the few that do (e.g.
34
- // GLBATCH with thousands of rows) SI provides a server-side resultId
35
- // that maintains scan consistency across readMore calls.
36
- const DEFAULT_PAGE_SIZE = 1000;
37
- /** Session lifetime — Sage Intacct sessions last ~10 minutes; refresh at 8 min */
38
- const SESSION_LIFETIME_MS = 8 * 60 * 1000;
39
- // ─── Range-chunked pagination constants ───────────────────────────────
40
- //
41
- // SI's readByQuery cannot guarantee PK-ascending scan order, and SI rejects
42
- // both `<orderby>` (XSD parse error) and SQL-style `ORDER BY` (DL02000001).
43
- // Without ordering, the only way to GUARANTEE complete record coverage for
44
- // numeric-PK objects is to walk RECORDNO in fixed numeric ranges:
45
- //
46
- // chunk 0: RECORDNO ∈ [0, CHUNK)
47
- // chunk 1: RECORDNO ∈ [CHUNK, 2*CHUNK)
48
- // chunk 2: RECORDNO ∈ [2*CHUNK, 3*CHUNK)
49
- // ...
50
- // stop when N consecutive chunks return zero records
51
- //
52
- // Within a chunk, if the response is a full page we know the chunk is too
53
- // dense (more records exist than we can safely retrieve in one page given
54
- // SI's scattered ordering). We HALVE the chunk and retry — recursive
55
- // subdivision down to a hard floor.
56
- //
57
- // Initial chunk size: 5000 RECORDNOs. For typical SI density (~0.1
58
- // records per RECORDNO unit, observed on APBILL: 148 records over 1342
59
- // units = density 0.11), a 5000 chunk holds ~550 records, well under
60
- // page-size. For dense objects (density up to 1.0), halving once gets us
61
- // to 2500 then 1250 (still fits in page-size=1000? No — needs another
62
- // halve to 625). The floor MIN_CHUNK protects against pathological
63
- // density (e.g. composite-PK objects with >1 record per RECORDNO unit,
64
- // which shouldn't exist for SI but is defensive).
65
- /** Initial RECORDNO range size when starting range-chunked walk. */
66
- const RANGE_INITIAL_CHUNK = 5000;
67
- /** Hard floor on chunk size during recursive halving. Throws if a chunk
68
- * at this size still returns a full page (signals the impossible case
69
- * of >pageSize records in <RANGE_MIN_CHUNK RECORDNOs). */
70
- const RANGE_MIN_CHUNK = 100;
71
- /** Starting probe value for upper-bound discovery. The first probe asks SI
72
- * whether any record has RECORDNO >= this value. Cheap to start small. */
73
- const RANGE_DISCOVERY_INITIAL_PROBE = 10000;
74
- /** Multiplier applied each iteration when the previous probe DID find
75
- * records. Geometric growth → log_RANGE_DISCOVERY_GROWTH(maxRecordno)
76
- * HTTP calls upfront, bounded at log10(SANITY_CAP) ≤ 11 calls. */
77
- const RANGE_DISCOVERY_GROWTH = 10;
78
- /** Hard sanity cap on max RECORDNO probe. If exponential probing exceeds
79
- * this without finding an empty range, throws — guards against runaway
80
- * loops when SI behaves pathologically. 100 billion is well beyond any
81
- * plausible real-world tenant's RECORDNO assignment. */
82
- const RANGE_DISCOVERY_SANITY_CAP = 100_000_000_000;
83
- /** Number of attempts for the discovery probe before giving up. Each
84
- * retry uses exponential backoff. A persistent failure THROWS rather
85
- * than silently treating the transient error as "no records exist past
86
- * this point" — fail-stop semantics over silent under-coverage. */
87
- const RANGE_DISCOVERY_MAX_ATTEMPTS = 3;
88
- /** Backoff between discovery probe retries. Doubled on each retry. */
89
- const RANGE_DISCOVERY_RETRY_BACKOFF_MS = 500;
90
- /** Sub-range verification confirms each "completed" chunk's record count
91
- * by independently querying its two halves and checking the sum matches.
92
- * This catches SI inconsistencies where a chunk query reports "complete"
93
- * with N records while sub-ranges sum to a different value. Skipped for
94
- * chunks already at minimum size since further splitting isn't possible. */
95
- const RANGE_VERIFICATION_ENABLED = true;
96
- /** Cursor prefix for range-chunked walks. Format:
97
- * `RANGE:<lo>:<chunkSize>:<upperBound>:<maxWatermarkB64>`
98
- * Watermark is base64-encoded to allow `:` inside it. */
99
- const RANGE_CURSOR_PREFIX = 'RANGE:';
100
- /**
101
- * Sage Intacct objects that exist only as children of a parent record. They
102
- * cannot be queried directly via `readByQuery` — SI returns them inline as
103
- * <line> elements within their parent's response. Mirroring them as
104
- * standalone tables produces empty result sets and confusing sync runs, so
105
- * we hide them from the picker and surface a clear error if a caller still
106
- * tries to fetch them.
107
- *
108
- * Map: child object name → parent object name. Match is case-insensitive.
109
- * Add entries here as more parent/child families come up. Patterns:
110
- * - `*Detail` / `*Details` — line-item children of transactions
111
- * - `*Line` — same family, older naming
112
- * - `*Item` — line items on POs/SOs/Invoices
113
- */
114
- const SAGE_INTACCT_CHILD_OBJECTS = {
115
- // Existing items / line-level entries
116
- 'APBILLITEM': 'APBILL',
117
- 'APBILLITEMS': 'APBILL',
118
- 'ARINVOICEITEM': 'ARINVOICE',
119
- 'ARINVOICEITEMS': 'ARINVOICE',
120
- 'ARPAYMENTITEM': 'ARPAYMENT',
121
- 'ARPAYMENTITEMS': 'ARPAYMENT',
122
- 'APPAYMENTITEM': 'APPAYMENT',
123
- 'APPAYMENTITEMS': 'APPAYMENT',
124
- 'GLBATCHENTRY': 'GLBATCH',
125
- 'GLBATCHENTRIES': 'GLBATCH',
126
- 'JOURNALENTRYITEM': 'GLBATCH',
127
- 'PURCHASINGDOCUMENTENTRY': 'PURCHASINGDOCUMENT',
128
- 'SODOCUMENTENTRY': 'SODOCUMENT',
129
- 'STOREORDERDETAIL': 'STOREORDER',
130
- 'STOREORDERDETAILS': 'STOREORDER',
131
- // Detail/line tables observed in production picker as silent skips —
132
- // these never have standalone schema; SI returns them inline with
133
- // the parent record. Mapping them to parents removes them from
134
- // pickers and prevents "0 fields discovered" failures at apply time.
135
- 'APBILLDETAIL': 'APBILL',
136
- 'APPAYMENTDETAIL': 'APPAYMENT',
137
- 'APDETAIL': 'APBILL',
138
- 'ARDETAIL': 'ARINVOICE',
139
- 'ARADJUSTMENTDETAIL': 'ARADJUSTMENT',
140
- 'ARINVOICEPAYMENT': 'ARINVOICE',
141
- 'ARRETAINAGERELEASEENTRY': 'ARRETAINAGERELEASE',
142
- 'APRETAINAGERELEASEENTRY': 'APRETAINAGERELEASE',
143
- 'APBILLPAYMENT': 'APBILL',
144
- 'APPOSTEDADVANCE': 'APPAYMENT',
145
- 'ARPOSTEDOVERPAYMENT': 'ARPAYMENT',
146
- 'ALLOCATIONENTRY': 'ALLOCATION',
147
- 'CONTRACTBILLINGSCHEDULEENTRY': 'CONTRACTBILLINGSCHEDULE',
148
- 'CONTRACTREVENUESCHEDULEENTRY': 'CONTRACTREVENUESCHEDULE',
149
- 'CONTRACTREVENUETEMPLATEENTRY': 'CONTRACTREVENUETEMPLATE',
150
- 'CONTRACTBILLINGTEMPLATEENTRY': 'CONTRACTBILLINGTEMPLATE',
151
- 'CONTRACTEXPENSESCHEDULEENTRY': 'CONTRACTEXPENSE',
152
- 'CONTRACTNEGATIVEBILLINGENTRY': 'CONTRACTNEGATIVEBILLING',
153
- 'CONTRACTUSAGEBILLING': 'CONTRACTUSAGE',
154
- 'CONTRACTMEABUNDLEENTRY': 'CONTRACT',
155
- 'CHANGEREQUESTENTRY': 'CHANGEREQUEST',
156
- 'TIMESHEETENTRY': 'TIMESHEET',
157
- // Audit/history line tables — children of audit records
158
- 'AUDITHISTORY': 'AUDITRECORD',
159
- 'COSTHISTORY': 'ITEM',
160
- 'LANDEDCOSTHISTORY': 'PURCHASINGDOCUMENT',
161
- 'BUYTOORDERHISTORY': 'PURCHASINGDOCUMENT',
162
- 'DROPSHIPHISTORY': 'SODOCUMENT',
163
- // System/ID-mapping helpers exposed but not standalone-syncable
164
- 'ROLEUSERS': 'ROLE',
165
- 'ROLEGROUPS': 'ROLE',
166
- 'ROLEPOLICYASSIGNMENT': 'ROLE',
167
- 'PARTNERFIELDMAP': 'PARTNER',
168
- 'COMPLIANCETASKITEM': 'CONTRACTCOMPLIANCETASKITEM',
169
- 'CONTRACTCOMPLIANCETASKITEM': 'CONTRACT',
170
- 'CONTRACTCOMPLIANCENOTE': 'CONTRACT',
171
- 'VENDORENTITYCONTACTS': 'VENDOR',
172
- };
173
- /** Sage Intacct data type to generic type mapping */
174
- const INTACCT_TYPE_MAP = {
175
- 'string': 'string',
176
- 'varchar': 'string',
177
- 'text': 'text',
178
- 'integer': 'integer',
179
- 'int': 'integer',
180
- 'decimal': 'decimal',
181
- 'number': 'decimal',
182
- 'float': 'decimal',
183
- 'double': 'decimal',
184
- 'currency': 'decimal',
185
- 'boolean': 'boolean',
186
- 'date': 'date',
187
- 'datetime': 'datetime',
188
- 'timestamp': 'datetime',
189
- 'percent': 'decimal',
190
- };
191
- // ─── Sage Intacct Object Metadata for Action Generation ───────────────
192
- const SAGE_INTACCT_OBJECTS = [
193
- {
194
- Name: 'CUSTOMER', DisplayName: 'Customer',
195
- Description: 'A customer record in Sage Intacct', SupportsWrite: true,
196
- Fields: [
197
- { Name: 'CUSTOMERID', DisplayName: 'Customer ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: true, Description: 'Unique customer identifier' },
198
- { Name: 'NAME', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Customer name' },
199
- { Name: 'DISPLAYCONTACT.EMAIL1', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary email address' },
200
- { Name: 'DISPLAYCONTACT.PHONE1', DisplayName: 'Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary phone number' },
201
- { Name: 'DISPLAYCONTACT.MAILADDRESS.ADDRESS1', DisplayName: 'Address Line 1', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mailing address line 1' },
202
- { Name: 'DISPLAYCONTACT.MAILADDRESS.CITY', DisplayName: 'City', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'City' },
203
- { Name: 'DISPLAYCONTACT.MAILADDRESS.STATE', DisplayName: 'State', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'State/province' },
204
- { Name: 'DISPLAYCONTACT.MAILADDRESS.ZIP', DisplayName: 'Zip Code', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Postal/zip code' },
205
- { Name: 'DISPLAYCONTACT.MAILADDRESS.COUNTRY', DisplayName: 'Country', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Country' },
206
- { Name: 'STATUS', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Customer status (active/inactive)' },
207
- { Name: 'TOTALDUE', DisplayName: 'Total Due', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total amount due' },
208
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
209
- ],
210
- },
211
- {
212
- Name: 'VENDOR', DisplayName: 'Vendor',
213
- Description: 'A vendor/supplier record in Sage Intacct', SupportsWrite: true,
214
- Fields: [
215
- { Name: 'VENDORID', DisplayName: 'Vendor ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: true, Description: 'Unique vendor identifier' },
216
- { Name: 'NAME', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Vendor name' },
217
- { Name: 'DISPLAYCONTACT.EMAIL1', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary email address' },
218
- { Name: 'DISPLAYCONTACT.PHONE1', DisplayName: 'Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary phone number' },
219
- { Name: 'STATUS', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Vendor status' },
220
- { Name: 'VENDTYPE', DisplayName: 'Vendor Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Vendor type category' },
221
- { Name: 'TOTALDUE', DisplayName: 'Total Due', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total amount due' },
222
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
223
- ],
224
- },
225
- {
226
- Name: 'GLACCOUNT', DisplayName: 'GL Account',
227
- Description: 'A General Ledger account in Sage Intacct', SupportsWrite: true,
228
- Fields: [
229
- { Name: 'ACCOUNTNO', DisplayName: 'Account Number', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: true, Description: 'GL account number' },
230
- { Name: 'TITLE', DisplayName: 'Title', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account title' },
231
- { Name: 'ACCOUNTTYPE', DisplayName: 'Account Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account type (e.g., balancesheet, incomestatement)' },
232
- { Name: 'NORMALBALANCE', DisplayName: 'Normal Balance', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Normal balance (debit/credit)' },
233
- { Name: 'STATUS', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account status' },
234
- { Name: 'CATEGORY', DisplayName: 'Category', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account category' },
235
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
236
- ],
237
- },
238
- {
239
- Name: 'APBILL', DisplayName: 'AP Bill',
240
- Description: 'An Accounts Payable bill in Sage Intacct', SupportsWrite: true,
241
- Fields: [
242
- { Name: 'RECORDNO', DisplayName: 'Record Number', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'System-assigned record number' },
243
- { Name: 'VENDORID', DisplayName: 'Vendor ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Vendor ID' },
244
- { Name: 'VENDORNAME', DisplayName: 'Vendor Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Vendor name' },
245
- { Name: 'WHENCREATED', DisplayName: 'Date Created', Type: 'date', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Bill date' },
246
- { Name: 'WHENDUE', DisplayName: 'Due Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Payment due date' },
247
- { Name: 'TOTALAMOUNT', DisplayName: 'Total Amount', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total bill amount' },
248
- { Name: 'TOTALDUE', DisplayName: 'Total Due', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Amount remaining' },
249
- { Name: 'STATE', DisplayName: 'State', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Bill state (Draft, Pending, etc.)' },
250
- { Name: 'DESCRIPTION', DisplayName: 'Description', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Bill description/memo' },
251
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
252
- ],
253
- },
254
- {
255
- Name: 'ARINVOICE', DisplayName: 'AR Invoice',
256
- Description: 'An Accounts Receivable invoice in Sage Intacct', SupportsWrite: true,
257
- Fields: [
258
- { Name: 'RECORDNO', DisplayName: 'Record Number', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'System-assigned record number' },
259
- { Name: 'CUSTOMERID', DisplayName: 'Customer ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Customer ID' },
260
- { Name: 'CUSTOMERNAME', DisplayName: 'Customer Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Customer name' },
261
- { Name: 'WHENCREATED', DisplayName: 'Date Created', Type: 'date', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Invoice date' },
262
- { Name: 'WHENDUE', DisplayName: 'Due Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Payment due date' },
263
- { Name: 'TOTALAMOUNT', DisplayName: 'Total Amount', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total invoice amount' },
264
- { Name: 'TOTALDUE', DisplayName: 'Total Due', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Amount remaining' },
265
- { Name: 'STATE', DisplayName: 'State', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Invoice state' },
266
- { Name: 'DESCRIPTION', DisplayName: 'Description', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Invoice description/memo' },
267
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
268
- ],
269
- },
270
- {
271
- Name: 'PROJECT', DisplayName: 'Project',
272
- Description: 'A project in Sage Intacct', SupportsWrite: true,
273
- Fields: [
274
- { Name: 'PROJECTID', DisplayName: 'Project ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: true, Description: 'Unique project identifier' },
275
- { Name: 'NAME', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project name' },
276
- { Name: 'PROJECTCATEGORY', DisplayName: 'Category', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project category' },
277
- { Name: 'STATUS', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project status' },
278
- { Name: 'CUSTOMERID', DisplayName: 'Customer ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Associated customer ID' },
279
- { Name: 'BEGINDATE', DisplayName: 'Begin Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project start date' },
280
- { Name: 'ENDDATE', DisplayName: 'End Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project end date' },
281
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
282
- ],
283
- },
284
- {
285
- Name: 'EMPLOYEE', DisplayName: 'Employee',
286
- Description: 'An employee record in Sage Intacct', SupportsWrite: true,
287
- Fields: [
288
- { Name: 'EMPLOYEEID', DisplayName: 'Employee ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: true, Description: 'Unique employee identifier' },
289
- { Name: 'PERSONALINFO.CONTACTNAME', DisplayName: 'Contact Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Employee contact name' },
290
- { Name: 'TITLE', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Job title' },
291
- { Name: 'STATUS', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Employee status' },
292
- { Name: 'DEPARTMENTID', DisplayName: 'Department ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Department identifier' },
293
- { Name: 'LOCATIONID', DisplayName: 'Location ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Location identifier' },
294
- { Name: 'STARTDATE', DisplayName: 'Start Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Employment start date' },
295
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
296
- ],
297
- },
298
- {
299
- Name: 'DEPARTMENT', DisplayName: 'Department',
300
- Description: 'A department in Sage Intacct', SupportsWrite: true,
301
- Fields: [
302
- { Name: 'DEPARTMENTID', DisplayName: 'Department ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: true, Description: 'Unique department identifier' },
303
- { Name: 'TITLE', DisplayName: 'Title', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Department title' },
304
- { Name: 'PARENTID', DisplayName: 'Parent ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent department ID' },
305
- { Name: 'STATUS', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Department status' },
306
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
307
- ],
308
- },
309
- {
310
- Name: 'CLASS', DisplayName: 'Class',
311
- Description: 'A class dimension in Sage Intacct for categorizing transactions', SupportsWrite: true,
312
- Fields: [
313
- { Name: 'CLASSID', DisplayName: 'Class ID', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: true, Description: 'Unique class identifier' },
314
- { Name: 'NAME', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Class name' },
315
- { Name: 'PARENTID', DisplayName: 'Parent ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent class ID' },
316
- { Name: 'STATUS', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Class status' },
317
- { Name: 'WHENMODIFIED', DisplayName: 'When Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modified timestamp' },
318
- ],
319
- },
320
- ];
321
- /** Primary key field name for each known Sage Intacct object.
322
- * This is a fallback used only when the IntegrationObject metadata does NOT
323
- * carry a DefaultQueryParams.pk_field hint. Prefer the metadata over this map. */
324
- const OBJECT_PK_MAP = {
325
- CUSTOMER: 'CUSTOMERID',
326
- VENDOR: 'VENDORID',
327
- GLACCOUNT: 'ACCOUNTNO',
328
- ACCOUNT: 'ACCOUNTNO',
329
- APBILL: 'RECORDNO',
330
- ARINVOICE: 'RECORDNO',
331
- PROJECT: 'PROJECTID',
332
- EMPLOYEE: 'EMPLOYEEID',
333
- DEPARTMENT: 'DEPARTMENTID',
334
- CLASS: 'CLASSID',
335
- LOCATION: 'LOCATIONID',
336
- ITEM: 'ITEMID',
337
- CONTACT: 'CONTACTNAME',
338
- WAREHOUSE: 'WAREHOUSEID',
339
- USER: 'LOGIN',
340
- CURRENCY: 'CURRENCYCODE',
341
- TASK: 'TASKID',
342
- CONTRACT: 'CONTRACTID',
343
- };
344
- // ─── Connector Implementation ─────────────────────────────────────────
345
- /**
346
- * Connector for the Sage Intacct Web Services API (XML over HTTPS).
347
- *
348
- * Extends BaseIntegrationConnector directly (not BaseRESTIntegrationConnector)
349
- * because Sage Intacct uses XML request/response bodies over a single gateway
350
- * endpoint, not standard REST resource URLs.
351
- *
352
- * Auth flow:
353
- * 1. POST XML to https://api.intacct.com/ia/xml/xmlgw.phtml
354
- * 2. The request includes Sender ID/Password (app credentials) and User ID/Password
355
- * 3. A getAPISession function returns a sessionid + endpoint
356
- * 4. Subsequent requests use the sessionid for authentication
357
- *
358
- * API operations:
359
- * - readByQuery: Query records with SQL-like filter expressions
360
- * - readMore: Fetch next page of a paginated result set
361
- * - read: Get a single record by key
362
- * - create / update / delete: Standard CRUD on objects
363
- * - inspect: Discover object fields and types
364
- *
365
- * Pagination:
366
- * - readByQuery returns a resultId + numRemaining
367
- * - readMore uses the resultId to fetch subsequent pages
368
- */
369
- let SageIntacctConnector = class SageIntacctConnector extends BaseIntegrationConnector {
370
- constructor() {
371
- // ── Session Cache ────────────────────────────────────────────────
372
- super(...arguments);
373
- this.cachedSession = null;
374
- /** Timestamp of the last API request, used for throttling */
375
- this.lastRequestTime = 0;
376
- }
377
- // ── Capability Getters ───────────────────────────────────────────
378
- get SupportsCreate() { return true; }
379
- get SupportsUpdate() { return true; }
380
- get SupportsDelete() { return true; }
381
- get SupportsSearch() { return true; }
382
- get SupportsListing() { return true; }
383
- get IntegrationName() { return 'Sage Intacct'; }
384
- // ── Action Generation ────────────────────────────────────────────
385
- GetIntegrationObjects() {
386
- return SAGE_INTACCT_OBJECTS;
387
- }
388
- GetActionGeneratorConfig() {
389
- const objects = this.GetIntegrationObjects();
390
- if (objects.length === 0)
391
- return null;
392
- return {
393
- IntegrationName: 'Sage Intacct',
394
- CategoryName: 'Sage Intacct',
395
- IconClass: 'fa-solid fa-file-invoice-dollar',
396
- Objects: objects,
397
- IncludeSearch: true,
398
- IncludeList: true,
399
- CategoryDescription: 'Sage Intacct accounting and ERP integration actions',
400
- ParentCategoryName: 'Business Apps',
401
- };
402
- }
403
- // ── Default Configuration ────────────────────────────────────────
404
- GetDefaultConfiguration() {
405
- return {
406
- DefaultSchemaName: 'SageIntacct',
407
- DefaultObjects: [
408
- {
409
- SourceObjectName: 'CUSTOMER',
410
- TargetTableName: 'SageIntacct_Customer',
411
- TargetEntityName: 'Sage Intacct Customers',
412
- SyncEnabled: true,
413
- FieldMappings: this.GetDefaultFieldMappings('CUSTOMER', 'Contacts'),
414
- },
415
- {
416
- SourceObjectName: 'VENDOR',
417
- TargetTableName: 'SageIntacct_Vendor',
418
- TargetEntityName: 'Sage Intacct Vendors',
419
- SyncEnabled: true,
420
- FieldMappings: this.GetDefaultFieldMappings('VENDOR', 'Companies'),
421
- },
422
- {
423
- SourceObjectName: 'GLACCOUNT',
424
- TargetTableName: 'SageIntacct_GLAccount',
425
- TargetEntityName: 'Sage Intacct GL Accounts',
426
- SyncEnabled: true,
427
- FieldMappings: [],
428
- },
429
- ],
430
- };
431
- }
432
- GetDefaultFieldMappings(objectName, _entityName) {
433
- switch (objectName) {
434
- case 'CUSTOMER':
435
- return [
436
- { SourceFieldName: 'CUSTOMERID', DestinationFieldName: 'ExternalID', IsKeyField: true },
437
- { SourceFieldName: 'NAME', DestinationFieldName: 'Name' },
438
- { SourceFieldName: 'DISPLAYCONTACT.EMAIL1', DestinationFieldName: 'Email' },
439
- { SourceFieldName: 'DISPLAYCONTACT.PHONE1', DestinationFieldName: 'Phone' },
440
- { SourceFieldName: 'STATUS', DestinationFieldName: 'Status' },
441
- ];
442
- case 'VENDOR':
443
- return [
444
- { SourceFieldName: 'VENDORID', DestinationFieldName: 'ExternalID', IsKeyField: true },
445
- { SourceFieldName: 'NAME', DestinationFieldName: 'Name' },
446
- { SourceFieldName: 'DISPLAYCONTACT.EMAIL1', DestinationFieldName: 'Email' },
447
- { SourceFieldName: 'DISPLAYCONTACT.PHONE1', DestinationFieldName: 'Phone' },
448
- { SourceFieldName: 'STATUS', DestinationFieldName: 'Status' },
449
- ];
450
- default:
451
- return [];
452
- }
453
- }
454
- // ─── TestConnection ──────────────────────────────────────────────
455
- async TestConnection(companyIntegration, contextUser) {
456
- try {
457
- const session = await this.GetSession(companyIntegration, contextUser, true);
458
- return {
459
- Success: true,
460
- Message: `Successfully connected to Sage Intacct (Company: ${session.Config.CompanyId})`,
461
- ServerVersion: `Sage Intacct Web Services API ${DTD_VERSION}`,
462
- };
463
- }
464
- catch (err) {
465
- const message = err instanceof Error ? err.message : String(err);
466
- return { Success: false, Message: `Connection failed: ${message}` };
467
- }
468
- }
469
- // ─── Discovery ───────────────────────────────────────────────────
470
- /**
471
- * Discovers available objects using the inspect API function with '*' wildcard.
472
- * Falls back to the known object list if the API call fails.
473
- */
474
- async DiscoverObjects(companyIntegration, contextUser) {
475
- // Strategy: union the curated static catalog with whatever SI's
476
- // dynamic `inspect *` endpoint returns. The dynamic list usually
477
- // contains many object names — some are valid API codes (CUSTOMER,
478
- // VENDOR, ARINVOICE, GLACCOUNT...) that the per-object describe
479
- // endpoint accepts cleanly, others are human display names ("AP
480
- // bill", "Work queue") that get rejected at describe time. Rather
481
- // than guess which is which up-front, we present everything and let
482
- // the describe phase weed out invalid names with logged warnings.
483
- // The curated catalog is unioned in so the picker always shows the
484
- // well-known objects even if dynamic discovery fails outright (e.g.
485
- // auth/network problem on the inspect call).
486
- const merged = new Map();
487
- // Seed with curated catalog — these have known-good API codes and
488
- // rich field metadata. They win on collisions.
489
- for (const obj of SAGE_INTACCT_OBJECTS) {
490
- merged.set(obj.Name, {
491
- Name: obj.Name,
492
- Label: obj.DisplayName,
493
- Description: obj.Description,
494
- SupportsIncrementalSync: true,
495
- SupportsWrite: obj.SupportsWrite,
496
- });
497
- }
498
- // Layer dynamic results on top — anything new gets added; anything
499
- // that collides with a curated entry is ignored (curated wins).
500
- try {
501
- const session = await this.GetSession(companyIntegration, contextUser);
502
- const xml = this.BuildInspectRequest(session, '*');
503
- const response = await this.SendXMLRequest(session, xml);
504
- const candidates = this.ParseInspectObjectsResponse(response);
505
- // Per-object probe: candidates from inspect-* are a mix of real
506
- // API codes and display-name-derived guesses. The only reliable
507
- // way to know which actually exist as API objects is to probe
508
- // each one with `inspect <name>` and keep the ones SI accepts.
509
- // Done in parallel (8-way) so 600+ probes don't sequence
510
- // serially. Curated names are skipped (already known good).
511
- const toProbe = candidates.filter(n => !merged.has(n));
512
- console.log(`[SageIntacct] DiscoverObjects: probing ${toProbe.length} candidate object names...`);
513
- const validatedNames = await this.ProbeObjectNames(session, toProbe);
514
- console.log(`[SageIntacct] DiscoverObjects: ${validatedNames.length}/${toProbe.length} candidates validated as real API objects`);
515
- for (const name of validatedNames) {
516
- if (!merged.has(name)) {
517
- merged.set(name, {
518
- Name: name,
519
- Label: this.FormatLabel(name),
520
- SupportsIncrementalSync: true,
521
- SupportsWrite: true,
522
- });
523
- }
524
- }
525
- }
526
- catch (err) {
527
- // Dynamic discovery failed — fall through with curated set only.
528
- // Logged so operators know the picker is showing the smaller set.
529
- const msg = err instanceof Error ? err.message : String(err);
530
- console.warn(`[SageIntacct] DiscoverObjects: dynamic inspect failed (${msg}); returning curated catalog only`);
531
- }
532
- // Filter out known child-only objects — they can't be fetched
533
- // independently from SI's API. Surfacing them in the picker leads
534
- // users to select objects that always come back empty.
535
- const filtered = [];
536
- const skippedChildren = [];
537
- for (const obj of merged.values()) {
538
- const parent = SAGE_INTACCT_CHILD_OBJECTS[obj.Name.toUpperCase()];
539
- if (parent) {
540
- skippedChildren.push(`${obj.Name} (child of ${parent})`);
541
- continue;
542
- }
543
- filtered.push(obj);
544
- }
545
- if (skippedChildren.length > 0) {
546
- console.log(`[SageIntacct] DiscoverObjects: filtered ${skippedChildren.length} known child-only objects from picker — ` +
547
- `they sync inline via their parent records.`);
548
- }
549
- return filtered;
550
- }
551
- /**
552
- * Probes a list of candidate object names against SI's per-object
553
- * `inspect <name>` endpoint to determine which are real API objects vs
554
- * display-name labels with no API equivalent. Runs in parallel with a
555
- * bounded worker pool so the 600+ probe round-trips don't serialize.
556
- *
557
- * Returns only the names that responded successfully — failures (including
558
- * "Object type X not found") are silently dropped. This is intentional:
559
- * the goal is to populate the picker with names the user can actually
560
- * sync, not surface every SI-side error.
561
- */
562
- async ProbeObjectNames(session, candidates) {
563
- if (candidates.length === 0)
564
- return [];
565
- const concurrency = 8;
566
- const validated = [];
567
- let cursor = 0;
568
- let zeroFieldCount = 0;
569
- const errorSamples = [];
570
- let totalErrors = 0;
571
- const worker = async () => {
572
- while (true) {
573
- const idx = cursor++;
574
- if (idx >= candidates.length)
575
- return;
576
- const name = candidates[idx];
577
- try {
578
- const xml = this.BuildInspectRequest(session, name);
579
- const response = await this.SendXMLRequest(session, xml);
580
- // Successful response = real API object. Empty inspect
581
- // fields don't mean it's not syncable — many SI objects
582
- // (especially platform/contract/role types) return no
583
- // schema via inspect but DO return data via readByQuery.
584
- // The earlier "require ≥1 field" check was too strict
585
- // and silently dropped 242 real objects per probe pass.
586
- this.CheckForErrors(response);
587
- validated.push(name);
588
- if (this.ParseInspectFieldsResponse(response).length === 0) {
589
- zeroFieldCount++;
590
- }
591
- }
592
- catch (err) {
593
- totalErrors++;
594
- if (errorSamples.length < 3) {
595
- const msg = err instanceof Error ? err.message : String(err);
596
- errorSamples.push(`"${name}": ${msg.slice(0, 200)}`);
597
- }
598
- }
599
- }
600
- };
601
- await Promise.all(Array.from({ length: concurrency }, () => worker()));
602
- console.log(`[SageIntacct] ProbeObjectNames: ${validated.length} validated, ` +
603
- `${zeroFieldCount} returned no fields, ${totalErrors} errored`);
604
- if (errorSamples.length > 0) {
605
- console.log(`[SageIntacct] ProbeObjectNames sample errors:\n ${errorSamples.join('\n ')}`);
606
- }
607
- return validated;
608
- }
609
- /**
610
- * Discovers fields on a specific object by combining the `inspect` API
611
- * call (standard fields) with a `lookup` call (which exposes custom fields
612
- * via the ISCUSTOM flag). Custom fields are returned with IsCustom=true so
613
- * the sync engine can flag them in IntegrationObjectField metadata.
614
- */
615
- async DiscoverFields(companyIntegration, objectName, contextUser) {
616
- const session = await this.GetSession(companyIntegration, contextUser);
617
- const inspectXml = this.BuildInspectRequest(session, objectName);
618
- const inspectResponse = await this.SendXMLRequest(session, inspectXml);
619
- let standardFields = this.ParseInspectFieldsResponse(inspectResponse);
620
- // Always pull lookup. Two reasons: (1) it's the source of ISCUSTOM
621
- // flags for custom fields, (2) it's the deterministic fallback when
622
- // inspect returns an empty <Fields/> (common for SI platform/role/
623
- // contract-template objects whose schema isn't surfaced via inspect
624
- // but IS via lookup). Same shape, different SI metadata API surface.
625
- let lookupFields = [];
626
- let customFieldNames = new Set();
627
- try {
628
- const lookupXml = this.BuildLookupRequest(session, objectName);
629
- const lookupResponse = await this.SendXMLRequest(session, lookupXml);
630
- lookupFields = this.ParseLookupFieldsResponse(lookupResponse);
631
- for (const f of lookupFields) {
632
- if (f.IsCustom)
633
- customFieldNames.add(f.Name);
634
- }
635
- }
636
- catch {
637
- // Lookup not supported for this object — proceed with inspect only.
638
- }
639
- // Lookup-as-fallback: when inspect returned no fields but lookup did,
640
- // promote lookup as the primary field source. Lookup-derived fields
641
- // are deterministic SI metadata, no inference.
642
- if (standardFields.length === 0 && lookupFields.length > 0) {
643
- console.log(`[SageIntacct] DiscoverFields: ${objectName} — inspect returned 0 fields, using ${lookupFields.length} fields from lookup API`);
644
- standardFields = lookupFields;
645
- }
646
- const pkField = this.GetPrimaryKeyField(objectName, companyIntegration.IntegrationID);
647
- // Curated catalog overlay: when SAGE_INTACCT_OBJECTS has a record for
648
- // this object, build a quick lookup of curated field metadata. Where
649
- // inspect is missing data (no MaxLength, no description, etc.) we fill
650
- // from the curated entry. Live inspect data still wins on direct
651
- // conflicts — curation is a supplement, not a source of truth.
652
- const curatedObj = SAGE_INTACCT_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
653
- const curatedFieldMap = new Map();
654
- if (curatedObj) {
655
- for (const cf of curatedObj.Fields) {
656
- curatedFieldMap.set(cf.Name.toLowerCase(), cf);
657
- }
658
- }
659
- const result = standardFields.map(f => {
660
- const isCustom = customFieldNames.has(f.Name) || f.IsCustom;
661
- if (isCustom) {
662
- console.debug(`[SageIntacct] Custom field detected on ${objectName}: ${f.Name}`);
663
- }
664
- const curated = curatedFieldMap.get(f.Name.toLowerCase());
665
- return {
666
- Name: f.Name,
667
- Label: f.Label || curated?.DisplayName || this.FormatLabel(f.Name),
668
- Description: curated?.Description,
669
- DataType: INTACCT_TYPE_MAP[f.DataType.toLowerCase()] ?? curated?.Type ?? 'string',
670
- IsRequired: f.IsRequired || curated?.IsRequired === true,
671
- IsUniqueKey: f.Name === pkField || curated?.IsPrimaryKey === true,
672
- IsReadOnly: f.IsReadOnly || curated?.IsReadOnly === true,
673
- IsForeignKey: !!f.References,
674
- ForeignKeyTarget: f.References ?? null,
675
- MaxLength: f.MaxLength ?? null,
676
- Precision: f.Precision ?? null,
677
- Scale: f.Scale ?? null,
678
- DefaultValue: f.DefaultValue ?? null,
679
- };
680
- });
681
- // Append curated fields that inspect didn't return — happens when SI
682
- // returns a partial field list for legacy objects, or when curation
683
- // covers a known field that the per-tenant SI instance omits.
684
- const inspectNames = new Set(result.map(f => f.Name.toLowerCase()));
685
- if (curatedObj) {
686
- for (const cf of curatedObj.Fields) {
687
- if (!inspectNames.has(cf.Name.toLowerCase())) {
688
- result.push({
689
- Name: cf.Name,
690
- Label: cf.DisplayName ?? cf.Name,
691
- Description: cf.Description,
692
- DataType: cf.Type ?? 'string',
693
- IsRequired: cf.IsRequired === true,
694
- IsUniqueKey: cf.IsPrimaryKey === true,
695
- IsReadOnly: cf.IsReadOnly === true,
696
- });
697
- inspectNames.add(cf.Name.toLowerCase());
698
- }
699
- }
700
- }
701
- // Engine cache fallback. SI's per-object inspect frequently returns
702
- // empty <Fields/> for platform/contract/role objects that ARE real
703
- // and ARE syncable via readByQuery — they just don't expose schema.
704
- // For those, hydrate constraints from previously-persisted
705
- // IntegrationObjectField rows so the user's prior describe survives
706
- // a transient SI gap. Live > curated > IOF cache (precedence order).
707
- const knownNames = new Set(result.map(f => f.Name.toLowerCase()));
708
- try {
709
- const integrationObj = IntegrationEngineBase.Instance.GetIntegrationObject(companyIntegration.IntegrationID, objectName);
710
- if (integrationObj) {
711
- const cachedFields = IntegrationEngineBase.Instance.GetIntegrationObjectFields(integrationObj.ID);
712
- let appended = 0;
713
- for (const cf of cachedFields) {
714
- if (cf.Status !== 'Active')
715
- continue;
716
- if (knownNames.has(cf.Name.toLowerCase()))
717
- continue;
718
- result.push({
719
- Name: cf.Name,
720
- Label: cf.DisplayName ?? cf.Name,
721
- Description: cf.Description ?? undefined,
722
- DataType: cf.Type ?? 'string',
723
- IsRequired: !!cf.IsRequired,
724
- IsUniqueKey: !!cf.IsPrimaryKey,
725
- IsReadOnly: !!cf.IsReadOnly,
726
- MaxLength: cf.Length ?? null,
727
- Precision: cf.Precision ?? null,
728
- Scale: cf.Scale ?? null,
729
- DefaultValue: cf.DefaultValue ?? null,
730
- });
731
- knownNames.add(cf.Name.toLowerCase());
732
- appended++;
733
- }
734
- if (appended > 0) {
735
- console.log(`[SageIntacct] DiscoverFields: hydrated ${appended} fields on ${objectName} from IntegrationObjectField cache (live inspect was missing them)`);
736
- }
737
- }
738
- }
739
- catch (err) {
740
- // Engine may not be configured; live result alone is fine.
741
- console.warn(`[SageIntacct] DiscoverFields: IOF cache fallback unavailable for ${objectName}: ${err instanceof Error ? err.message : String(err)}`);
742
- }
743
- // Final defensive dedupe by lowercased Name. Belt-and-suspenders for
744
- // the (IntegrationObjectID, Name) unique key — every code path that
745
- // ever adds to `result` is now guarded.
746
- const seen = new Set();
747
- return result.filter(f => {
748
- const key = f.Name.toLowerCase();
749
- if (seen.has(key)) {
750
- console.warn(`[SageIntacct] DiscoverFields: dropping duplicate field "${f.Name}" on ${objectName}`);
751
- return false;
752
- }
753
- seen.add(key);
754
- return true;
755
- });
756
- }
757
- BuildLookupRequest(session, objectName) {
758
- return this.WrapInSessionRequest(session, `
759
- <function controlid="${CONTROL_ID_PREFIX}-lookup-${Date.now()}">
760
- <lookup>
761
- <object>${this.EscapeXmlValue(objectName)}</object>
762
- </lookup>
763
- </function>`);
764
- }
765
- /**
766
- * Full parser for SI's `lookup <object>` response. Returns the same shape
767
- * as ParseInspectFieldsResponse so callers can use lookup as a drop-in
768
- * fallback when inspect comes back empty. SI's lookup endpoint frequently
769
- * returns a populated `<Fields>` block for objects whose `inspect` returns
770
- * an empty `<Fields/>` — same metadata, different API surface, same
771
- * deterministic source-of-truth from SI.
772
- *
773
- * Lookup uses uppercase tags (`<NAME>`, `<DATATYPE>`, `<ISCUSTOM>`) where
774
- * inspect uses mixed case (`<Name>`, `<DataType>`). Tag extraction tries
775
- * both casings to be robust against per-tenant API quirks.
776
- */
777
- ParseLookupFieldsResponse(xml) {
778
- this.CheckForErrors(xml);
779
- const fields = [];
780
- const seen = new Set();
781
- const fieldRegex = /<Field>\s*([\s\S]*?)\s*<\/Field>/gi;
782
- let match;
783
- while ((match = fieldRegex.exec(xml)) !== null) {
784
- const frag = match[1];
785
- const name = this.ExtractXmlValueFromFragment(frag, 'NAME')
786
- || this.ExtractXmlValueFromFragment(frag, 'Name');
787
- if (!name)
788
- continue;
789
- const key = name.toLowerCase();
790
- if (seen.has(key))
791
- continue;
792
- seen.add(key);
793
- const dataType = this.ExtractXmlValueFromFragment(frag, 'DATATYPE')
794
- || this.ExtractXmlValueFromFragment(frag, 'DataType')
795
- || 'string';
796
- const label = this.ExtractXmlValueFromFragment(frag, 'DISPLAYLABEL')
797
- || this.ExtractXmlValueFromFragment(frag, 'DisplayLabel')
798
- || name;
799
- const isRequired = (this.ExtractXmlValueFromFragment(frag, 'REQUIRED')
800
- || this.ExtractXmlValueFromFragment(frag, 'Required')).toLowerCase() === 'true';
801
- const isReadOnly = (this.ExtractXmlValueFromFragment(frag, 'READONLY')
802
- || this.ExtractXmlValueFromFragment(frag, 'ReadOnly')).toLowerCase() === 'true';
803
- const isCustom = (this.ExtractXmlValueFromFragment(frag, 'ISCUSTOM')
804
- || this.ExtractXmlValueFromFragment(frag, 'IsCustom')).toLowerCase() === 'true';
805
- const maxLength = this.ParseNumericTagFromFragment(frag, 'MAXLENGTH')
806
- ?? this.ParseNumericTagFromFragment(frag, 'MaxLength')
807
- ?? this.ParseNumericTagFromFragment(frag, 'LENGTH')
808
- ?? this.ParseNumericTagFromFragment(frag, 'Length');
809
- const precision = this.ParseNumericTagFromFragment(frag, 'PRECISION')
810
- ?? this.ParseNumericTagFromFragment(frag, 'Precision');
811
- const scale = this.ParseNumericTagFromFragment(frag, 'SCALE')
812
- ?? this.ParseNumericTagFromFragment(frag, 'Scale');
813
- const defaultValueRaw = this.ExtractXmlValueFromFragment(frag, 'DEFAULTVALUE')
814
- || this.ExtractXmlValueFromFragment(frag, 'DefaultValue');
815
- const referencesRaw = this.ExtractXmlValueFromFragment(frag, 'REFERENCES')
816
- || this.ExtractXmlValueFromFragment(frag, 'References');
817
- fields.push({
818
- Name: name, Label: label, DataType: dataType,
819
- IsRequired: isRequired, IsReadOnly: isReadOnly, IsCustom: isCustom,
820
- MaxLength: maxLength,
821
- Precision: precision,
822
- Scale: scale,
823
- DefaultValue: defaultValueRaw && defaultValueRaw.length > 0 ? defaultValueRaw : null,
824
- References: referencesRaw && referencesRaw.length > 0 ? referencesRaw : null,
825
- });
826
- }
827
- return fields;
828
- }
829
- // ─── FetchChanges ────────────────────────────────────────────────
830
- /**
831
- * Fetches records from a Sage Intacct object. Uses one of two strategies
832
- * based on the object's primary key shape:
833
- *
834
- * - Numeric PK (RECORDNO): walks RECORDNO in fixed numeric ranges with
835
- * recursive halving on dense chunks. Bulletproof — no dependence on
836
- * SI's scan order, pagination signals, or page-size limits.
837
- *
838
- * - String PK (CUSTOMERID, VENDORID, etc.): single readByQuery + readMore
839
- * loop using SI's server-side resultId. Hard fails (instead of silently
840
- * dropping records) if SI returns a full page without a resultId.
841
- */
842
- async FetchChanges(ctx) {
843
- // Guardrail: if a caller somehow ends up trying to fetch a child-only
844
- // object directly (entity map left over from a previous picker, or a
845
- // hand-set IntegrationObject row), bail with a clear message rather
846
- // than producing an empty result that looks like "the table is empty".
847
- const parentForChild = SAGE_INTACCT_CHILD_OBJECTS[ctx.ObjectName.toUpperCase()];
848
- if (parentForChild) {
849
- console.warn(`[SageIntacct] FetchChanges: "${ctx.ObjectName}" is a child of "${parentForChild}" and cannot be fetched directly. ` +
850
- `Sync the parent object instead — child rows arrive inline in the parent's response.`);
851
- return { Records: [], HasMore: false };
852
- }
853
- const session = await this.GetSession(ctx.CompanyIntegration, ctx.ContextUser);
854
- const pkField = this.GetPrimaryKeyField(ctx.ObjectName, ctx.CompanyIntegration.IntegrationID);
855
- const pageSize = Math.max(ctx.BatchSize || 0, DEFAULT_PAGE_SIZE);
856
- // Dispatch by PK shape. RECORDNO (universal SI numeric row id) gets
857
- // the bulletproof range-chunked walk. Everything else (string PKs)
858
- // takes the single-pull-with-readMore path.
859
- if (pkField === 'RECORDNO') {
860
- return this.FetchChangesByRange(session, ctx, pageSize);
861
- }
862
- return this.FetchChangesSinglePull(session, ctx, pageSize, pkField);
863
- }
864
- /**
865
- * Range-chunked walk over numeric RECORDNO. Each FetchChanges call
866
- * processes ONE chunk (one HTTP request to SI). The engine drives the
867
- * walk by passing the returned NextCursor back on the next call. Cursor
868
- * encodes (lo, chunkSize, upperBound, maxWatermarkSeen).
869
- *
870
- * Termination: walk completes when `lo >= upperBound`. The upperBound is
871
- * discovered ONCE at the start of the walk via exponential probe of SI's
872
- * actual MAX RECORDNO — no empty-chunk heuristic, no "what if records
873
- * exist past N" doubt. The walk covers exactly [0, MAX_RECORDNO + safety
874
- * margin), every RECORDNO accounted for.
875
- *
876
- * Density adaptation: full-page response → halve chunk, re-query same lo.
877
- * Watermark: tracked in cursor; emitted only on final batch.
878
- */
879
- async FetchChangesByRange(session, ctx, pageSize) {
880
- let state = this.parseRangeCursor(ctx.CurrentCursor);
881
- if (!state) {
882
- // First call of this walk — discover the actual upper bound on
883
- // RECORDNO so termination is precise rather than heuristic.
884
- const upperBound = await this.discoverUpperBound(session, ctx);
885
- // Start with chunkSize = min(upperBound, RANGE_INITIAL_CHUNK)
886
- // so small objects walk in a single chunk (no wasted traversal),
887
- // while huge ranges still cap at a sane starting point that won't
888
- // require many density halvings to fit in pageSize.
889
- const initialChunkSize = Math.max(Math.min(upperBound, RANGE_INITIAL_CHUNK), RANGE_MIN_CHUNK);
890
- state = {
891
- lo: 0,
892
- chunkSize: initialChunkSize,
893
- upperBound,
894
- maxWatermark: undefined,
895
- };
896
- }
897
- // If this call lands at-or-past the discovered upper bound, the walk
898
- // is over even before we make the first request. Emit the watermark
899
- // and terminate. (This shouldn't happen normally — advanceRangeWalk
900
- // catches it first — but defends against cursors hand-crafted by
901
- // tooling or replays.)
902
- if (state.lo >= state.upperBound) {
903
- console.log(`[SageIntacct] ${ctx.ObjectName}: RANGE walk complete — lo=${state.lo} >= upperBound=${state.upperBound}. Final watermark: ${state.maxWatermark ?? 'none'}`);
904
- return { Records: [], HasMore: false, NewWatermarkValue: state.maxWatermark };
905
- }
906
- const hi = Math.min(state.lo + state.chunkSize, state.upperBound);
907
- const filter = this.buildRangeFilter(ctx, state.lo, hi);
908
- console.log(`[SageIntacct] ${ctx.ObjectName}: RANGE [${state.lo}, ${hi}) chunkSize=${state.chunkSize} upperBound=${state.upperBound} — filter="${filter}"`);
909
- const xml = this.BuildReadByQueryRequest(session, ctx.ObjectName, filter, pageSize);
910
- const response = await this.SendXMLRequest(session, xml);
911
- const result = this.ParseReadByQueryResponseRaw(response, ctx.ObjectName, pageSize);
912
- // Density check: a full page back means SI may have more records in
913
- // this RECORDNO range than fit in one page. Because SI doesn't scan
914
- // PK-ascending, the records we got could be any random subset of the
915
- // chunk's true contents — keeping them risks a permanent miss of the
916
- // un-returned records. Discard, halve, re-query.
917
- if (result.records.length >= pageSize) {
918
- return this.handleDenseChunk(ctx, state, pageSize);
919
- }
920
- // Sub-range verification: independently query [lo, mid) and [mid, hi)
921
- // and confirm their counts sum to the main query's count. Catches SI
922
- // inconsistencies where a chunk reports "complete" while sub-ranges
923
- // tell a different story. Throws on mismatch (fail-stop) — partial
924
- // sync data is worse than no sync data.
925
- if (RANGE_VERIFICATION_ENABLED && (hi - state.lo) >= 2 * RANGE_MIN_CHUNK) {
926
- const verificationOutcome = await this.verifyChunk(session, ctx, state.lo, hi, pageSize, result.records.length);
927
- if (verificationOutcome === 'sub-range-dense') {
928
- // Either half is dense — drill down via standard halving.
929
- return this.handleDenseChunk(ctx, state, pageSize);
930
- }
931
- // verificationOutcome === 'verified' → sums match; main count is trustworthy.
932
- }
933
- // Chunk fully captured. Compute its contribution to the watermark and
934
- // decide whether to advance to the next chunk or terminate.
935
- return this.advanceRangeWalk(ctx, state, result.records);
936
- }
937
- /** Sub-range verification: query both halves of [lo, hi) independently
938
- * and confirm their counts sum to `mainCount`. Returns 'verified' when
939
- * sums match, 'sub-range-dense' when either half is at-or-above
940
- * pageSize (signalling that further drill-down is needed). Throws on
941
- * count mismatch — that's an SI inconsistency we refuse to paper over. */
942
- async verifyChunk(session, ctx, lo, hi, pageSize, mainCount) {
943
- const mid = lo + Math.floor((hi - lo) / 2);
944
- const leftFilter = this.buildRangeFilter(ctx, lo, mid);
945
- const rightFilter = this.buildRangeFilter(ctx, mid, hi);
946
- const [leftRaw, rightRaw] = await Promise.all([
947
- this.SendXMLRequest(session, this.BuildReadByQueryRequest(session, ctx.ObjectName, leftFilter, pageSize)),
948
- this.SendXMLRequest(session, this.BuildReadByQueryRequest(session, ctx.ObjectName, rightFilter, pageSize)),
949
- ]);
950
- const left = this.ParseReadByQueryResponseRaw(leftRaw, ctx.ObjectName, pageSize);
951
- const right = this.ParseReadByQueryResponseRaw(rightRaw, ctx.ObjectName, pageSize);
952
- // If either half is dense, we can't trust the parent's count yet —
953
- // drill into smaller chunks. The caller handles this via the standard
954
- // dense-halving path.
955
- if (left.records.length >= pageSize || right.records.length >= pageSize) {
956
- console.warn(`[SageIntacct] ${ctx.ObjectName}: verification of [${lo}, ${hi}) revealed dense sub-range ` +
957
- `(left=${left.records.length}, right=${right.records.length}). Drilling down.`);
958
- return 'sub-range-dense';
959
- }
960
- const sumOfHalves = left.records.length + right.records.length;
961
- if (sumOfHalves !== mainCount) {
962
- throw new Error(`[SageIntacct] ${ctx.ObjectName}: SI INCONSISTENCY in [${lo}, ${hi}). ` +
963
- `Main query returned ${mainCount} records, but sub-ranges ` +
964
- `[${lo}, ${mid}) + [${mid}, ${hi}) returned ${left.records.length} + ${right.records.length} = ${sumOfHalves}. ` +
965
- `SI is hiding records — refusing to continue sync with potentially incomplete data. ` +
966
- `Re-run sync; if persistent, this object may need manual investigation.`);
967
- }
968
- // Sums match — main count verified.
969
- return 'verified';
970
- }
971
- /** Exponential probe of SI for the highest RECORDNO that exists on this
972
- * object. Returns an upper bound (always strictly greater than the true
973
- * max). Costs O(log_RANGE_DISCOVERY_GROWTH(maxRecordno)) HTTP calls —
974
- * typically 1-5 calls for any plausible tenant.
975
- *
976
- * Uses pageSize=1 because we only care whether ANY record exists at or
977
- * above the probe point; the records themselves are discarded. Does NOT
978
- * apply WHENMODIFIED filter — we want the absolute upper bound on
979
- * RECORDNO regardless of when it was modified, so that incremental
980
- * syncs still cover the full RECORDNO space (each chunk inside the
981
- * walk applies its own WHENMODIFIED filter).
982
- *
983
- * Each probe is retried up to RANGE_DISCOVERY_MAX_ATTEMPTS times on
984
- * network/transport failure before throwing. A retried-but-still-failed
985
- * probe THROWS rather than silently treating the failure as "no records
986
- * exist past this point" — fail-stop matches the user requirement that
987
- * partial sync is worse than no sync. */
988
- async discoverUpperBound(session, ctx) {
989
- let probe = RANGE_DISCOVERY_INITIAL_PROBE;
990
- while (probe <= RANGE_DISCOVERY_SANITY_CAP) {
991
- const result = await this.probeRecordnoExistsRetried(session, ctx, probe);
992
- if (result.records.length === 0 && result.numRemaining === 0) {
993
- console.log(`[SageIntacct] ${ctx.ObjectName}: discovered upperBound=${probe} (no records at or above this RECORDNO)`);
994
- return probe;
995
- }
996
- probe *= RANGE_DISCOVERY_GROWTH;
997
- }
998
- throw new Error(`[SageIntacct] ${ctx.ObjectName}: max RECORDNO exceeds sanity cap of ${RANGE_DISCOVERY_SANITY_CAP}. ` +
999
- `This object's RECORDNOs are pathologically large; raise RANGE_DISCOVERY_SANITY_CAP.`);
1000
- }
1001
- /** Single discovery probe with retry on TRANSPORT errors only. SI-side
1002
- * errors (permission denied, syntax invalid, schema rejected) are
1003
- * deterministic and don't benefit from retry — they're propagated
1004
- * immediately so the caller (sync engine) can record the object as
1005
- * failed and move on. Throws after RANGE_DISCOVERY_MAX_ATTEMPTS
1006
- * exhausted on transport — does NOT silently treat persistent failure
1007
- * as "empty result." */
1008
- async probeRecordnoExistsRetried(session, ctx, probe) {
1009
- const filter = `RECORDNO >= ${probe}`;
1010
- let lastError;
1011
- for (let attempt = 1; attempt <= RANGE_DISCOVERY_MAX_ATTEMPTS; attempt++) {
1012
- try {
1013
- const xml = this.BuildReadByQueryRequest(session, ctx.ObjectName, filter, 1);
1014
- const response = await this.SendXMLRequest(session, xml);
1015
- return this.ParseReadByQueryResponseRaw(response, ctx.ObjectName, 1);
1016
- }
1017
- catch (err) {
1018
- lastError = err;
1019
- // SI-side errors (permission, schema, query syntax) are
1020
- // deterministic — retrying gives the same answer. Surface
1021
- // immediately so the engine can move on to the next object.
1022
- if (this.isSageIntacctApiError(err)) {
1023
- throw err;
1024
- }
1025
- const willRetry = attempt < RANGE_DISCOVERY_MAX_ATTEMPTS;
1026
- console.warn(`[SageIntacct] ${ctx.ObjectName}: discovery probe at RECORDNO >= ${probe} ` +
1027
- `transport attempt ${attempt}/${RANGE_DISCOVERY_MAX_ATTEMPTS} failed: ${err?.message ?? String(err)}` +
1028
- (willRetry ? ` — retrying after ${RANGE_DISCOVERY_RETRY_BACKOFF_MS * attempt}ms` : ' — giving up'));
1029
- if (willRetry) {
1030
- await new Promise(resolve => setTimeout(resolve, RANGE_DISCOVERY_RETRY_BACKOFF_MS * attempt));
1031
- }
1032
- }
1033
- }
1034
- throw new Error(`[SageIntacct] ${ctx.ObjectName}: discovery probe at RECORDNO >= ${probe} failed ` +
1035
- `after ${RANGE_DISCOVERY_MAX_ATTEMPTS} transport attempts. Refusing to assume "no records exist" ` +
1036
- `from a transport-level failure — sync would silently undercount. Last error: ` +
1037
- `${lastError?.message ?? String(lastError)}`);
1038
- }
1039
- /** Returns true when the error originated from SI's API response (a
1040
- * thrown structured error from CheckForErrors), false for transport /
1041
- * network / parser failures. SI-side errors are not worth retrying. */
1042
- isSageIntacctApiError(err) {
1043
- const msg = err?.message;
1044
- if (typeof msg !== 'string')
1045
- return false;
1046
- return msg.startsWith('Sage Intacct error ') || msg.startsWith('Sage Intacct API error');
1047
- }
1048
- /** Halve the current chunk and re-query its lower half on the next call.
1049
- * Throws if already at RANGE_MIN_CHUNK — that signals the impossible
1050
- * case where SI has more records in 100 RECORDNOs than fit in our
1051
- * configured page size, and we cannot guarantee complete sync. */
1052
- handleDenseChunk(ctx, state, pageSize) {
1053
- const halved = Math.max(Math.floor(state.chunkSize / 2), RANGE_MIN_CHUNK);
1054
- if (halved === state.chunkSize) {
1055
- throw new Error(`[SageIntacct] ${ctx.ObjectName}: chunk at minimum size ${state.chunkSize} ` +
1056
- `still returns ≥${pageSize} records. Cannot guarantee complete sync. ` +
1057
- `Increase DEFAULT_PAGE_SIZE or lower RANGE_MIN_CHUNK.`);
1058
- }
1059
- console.warn(`[SageIntacct] ${ctx.ObjectName}: dense chunk at [${state.lo}, ${state.lo + state.chunkSize}) — ` +
1060
- `halving to ${halved} and retrying. (Records from this query are discarded; they will be re-fetched in halved chunks.)`);
1061
- // Re-query the SAME lo with smaller chunkSize. upperBound unchanged.
1062
- const nextCursor = this.buildRangeCursor({ ...state, chunkSize: halved });
1063
- return { Records: [], HasMore: true, NextCursor: nextCursor };
1064
- }
1065
- /** Chunk completed safely. Advance to next chunk or terminate the walk
1066
- * when we cross the discovered upperBound. */
1067
- advanceRangeWalk(ctx, state, records) {
1068
- const externalRecords = records.map(r => this.toExternalRecord(r, ctx.ObjectName, 'RECORDNO'));
1069
- const chunkWatermark = this.ComputeWatermark(records);
1070
- const newMaxWatermark = this.maxWatermarkString(state.maxWatermark, chunkWatermark);
1071
- const nextLo = state.lo + state.chunkSize;
1072
- if (nextLo >= state.upperBound) {
1073
- console.log(`[SageIntacct] ${ctx.ObjectName}: RANGE walk complete — covered [0, ${state.upperBound}). Final watermark: ${newMaxWatermark ?? 'none'}`);
1074
- return {
1075
- Records: externalRecords,
1076
- HasMore: false,
1077
- NewWatermarkValue: newMaxWatermark,
1078
- };
1079
- }
1080
- const nextCursor = this.buildRangeCursor({
1081
- lo: nextLo,
1082
- chunkSize: RANGE_INITIAL_CHUNK, // grow back to default after dense recovery
1083
- upperBound: state.upperBound, // carried forward unchanged
1084
- maxWatermark: newMaxWatermark,
1085
- });
1086
- return {
1087
- Records: externalRecords,
1088
- HasMore: true,
1089
- NextCursor: nextCursor,
1090
- };
1091
- }
1092
- /** Single-page fetch + readMore loop for objects with string PKs (CUSTOMER,
1093
- * VENDOR, GLACCOUNT, etc.). These are typically small lookup tables; if
1094
- * one ever exceeds a single page, we trust SI's resultId. If SI returns
1095
- * a full page without a resultId, we HARD FAIL rather than silently
1096
- * drop records via PK-cursor pagination. */
1097
- async FetchChangesSinglePull(session, ctx, pageSize, pkField) {
1098
- // readMore continuation — SI maintains scan-order consistency on its
1099
- // server-side resultId, so this path is safe.
1100
- if (ctx.CurrentCursor) {
1101
- return this.FetchMoreRecords(session, ctx.CurrentCursor, ctx.ObjectName, pkField);
1102
- }
1103
- // Initial fetch with watermark filter only.
1104
- const hasWhenModified = this.ObjectHasWhenModified(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
1105
- const filterParts = [];
1106
- if (ctx.WatermarkValue && hasWhenModified) {
1107
- filterParts.push(`WHENMODIFIED >= '${this.NormalizeSageIntacctTimestamp(ctx.WatermarkValue)}'`);
1108
- }
1109
- const filter = filterParts.join(' AND ');
1110
- console.log(`[SageIntacct] ${ctx.ObjectName}: SINGLE-PULL fetch (${pkField}) — filter="${filter}"`);
1111
- const xml = this.BuildReadByQueryRequest(session, ctx.ObjectName, filter, pageSize);
1112
- const response = await this.SendXMLRequest(session, xml);
1113
- const raw = this.ParseReadByQueryResponseRaw(response, ctx.ObjectName, pageSize);
1114
- const externalRecords = raw.records.map(r => this.toExternalRecord(r, ctx.ObjectName, pkField));
1115
- const watermark = this.ComputeWatermark(raw.records);
1116
- // Safe terminal cases: small response (definitely complete) OR full
1117
- // page WITH resultId (readMore handles the rest deterministically).
1118
- if (raw.records.length < pageSize) {
1119
- return { Records: externalRecords, HasMore: false, NewWatermarkValue: watermark };
1120
- }
1121
- if (raw.resultId) {
1122
- return { Records: externalRecords, HasMore: true, NextCursor: raw.resultId };
1123
- }
1124
- // Dangerous case: full page, no resultId, string PK. PK-cursor
1125
- // pagination would silently drop records (SI's scan order is not
1126
- // PK-ascending). We REFUSE to proceed rather than corrupt the sync.
1127
- throw new Error(`[SageIntacct] ${ctx.ObjectName}: SI returned ${raw.records.length} records ` +
1128
- `(full page) with no resultId and the PK (${pkField}) is non-numeric. ` +
1129
- `Cannot safely paginate without ordering guarantees. ` +
1130
- `Either (a) raise DEFAULT_PAGE_SIZE so this object fits in one page, ` +
1131
- `or (b) add ${ctx.ObjectName} to the numeric-PK code path if it has a RECORDNO column.`);
1132
- }
1133
- parseRangeCursor(cursor) {
1134
- if (!cursor || !cursor.startsWith(RANGE_CURSOR_PREFIX))
1135
- return null;
1136
- const body = cursor.slice(RANGE_CURSOR_PREFIX.length);
1137
- const parts = body.split(':');
1138
- if (parts.length < 4)
1139
- return null;
1140
- const lo = parseInt(parts[0], 10);
1141
- const chunkSize = parseInt(parts[1], 10);
1142
- const upperBound = parseInt(parts[2], 10);
1143
- const maxWatermarkB64 = parts[3];
1144
- if (!Number.isFinite(lo) || !Number.isFinite(chunkSize) || !Number.isFinite(upperBound))
1145
- return null;
1146
- const maxWatermark = maxWatermarkB64 ? Buffer.from(maxWatermarkB64, 'base64').toString('utf8') : undefined;
1147
- return { lo, chunkSize, upperBound, maxWatermark };
1148
- }
1149
- buildRangeCursor(state) {
1150
- const wmB64 = state.maxWatermark ? Buffer.from(state.maxWatermark, 'utf8').toString('base64') : '';
1151
- return `${RANGE_CURSOR_PREFIX}${state.lo}:${state.chunkSize}:${state.upperBound}:${wmB64}`;
1152
- }
1153
- buildRangeFilter(ctx, lo, hi) {
1154
- const hasWhenModified = this.ObjectHasWhenModified(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
1155
- const parts = [];
1156
- if (ctx.WatermarkValue && hasWhenModified) {
1157
- parts.push(`WHENMODIFIED >= '${this.NormalizeSageIntacctTimestamp(ctx.WatermarkValue)}'`);
1158
- }
1159
- parts.push(`RECORDNO >= ${lo}`);
1160
- parts.push(`RECORDNO < ${hi}`);
1161
- return parts.join(' AND ');
1162
- }
1163
- /** Converts a watermark value to SI's expected datetime literal format
1164
- * (`MM/DD/YYYY HH:mm:ss`). The integration engine may pass watermarks
1165
- * in ISO 8601 (e.g. `2026-04-26T01:00:25.170Z`) when no prior SI-format
1166
- * watermark exists for an object — substituting that ISO string straight
1167
- * into a `WHENMODIFIED >= '...'` filter triggers SI's generic
1168
- * DL02000001 "There was an error processing the request" rejection.
1169
- * This converts to SI's accepted format using UTC components for
1170
- * deterministic results regardless of the host server's timezone. */
1171
- NormalizeSageIntacctTimestamp(value) {
1172
- // Already in SI's MM/DD/YYYY format — pass through unchanged.
1173
- if (/^\d{1,2}\/\d{1,2}\/\d{4}/.test(value)) {
1174
- return value;
1175
- }
1176
- const d = new Date(value);
1177
- if (Number.isNaN(d.getTime())) {
1178
- // Can't parse — let SI reject it loudly rather than silently
1179
- // re-format something we don't understand.
1180
- return value;
1181
- }
1182
- const pad = (n) => String(n).padStart(2, '0');
1183
- return `${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())}/${d.getUTCFullYear()} ` +
1184
- `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
1185
- }
1186
- toExternalRecord(record, objectName, pkField) {
1187
- return {
1188
- ExternalID: String(record[pkField] ?? ''),
1189
- ObjectType: objectName,
1190
- Fields: record,
1191
- ModifiedAt: record['WHENMODIFIED'] ? new Date(String(record['WHENMODIFIED'])) : undefined,
1192
- };
1193
- }
1194
- /** Returns the lex-greater of two ISO/SI watermark strings, or undefined
1195
- * if both are absent. SI's WHENMODIFIED format (`MM/DD/YYYY HH:mm:ss`)
1196
- * doesn't sort lex-correctly across years/months in pure string compare,
1197
- * so we parse to Date for comparison. */
1198
- maxWatermarkString(a, b) {
1199
- if (!a)
1200
- return b;
1201
- if (!b)
1202
- return a;
1203
- const da = new Date(a);
1204
- const db = new Date(b);
1205
- if (Number.isNaN(da.getTime()))
1206
- return b;
1207
- if (Number.isNaN(db.getTime()))
1208
- return a;
1209
- return da.getTime() >= db.getTime() ? a : b;
1210
- }
1211
- /**
1212
- * Returns true when the IntegrationObject's discovered field set includes
1213
- * WHENMODIFIED. Used to skip the watermark filter for objects that don't
1214
- * support it (most SI platform/lookup objects).
1215
- */
1216
- ObjectHasWhenModified(integrationID, objectName) {
1217
- try {
1218
- const obj = IntegrationEngineBase.Instance.GetIntegrationObject(integrationID, objectName);
1219
- if (!obj)
1220
- return true; // unknown — assume yes, preserve existing behavior
1221
- const fields = IntegrationEngineBase.Instance.GetIntegrationObjectFields(obj.ID);
1222
- if (!fields || fields.length === 0)
1223
- return true; // no metadata yet — preserve behavior
1224
- return fields.some(f => f.Name.toUpperCase() === 'WHENMODIFIED');
1225
- }
1226
- catch {
1227
- return true;
1228
- }
1229
- }
1230
- // ─── CRUD Operations ─────────────────────────────────────────────
1231
- async CreateRecord(ctx) {
1232
- const companyIntegration = ctx.CompanyIntegration;
1233
- const contextUser = ctx.ContextUser;
1234
- const session = await this.GetSession(companyIntegration, contextUser);
1235
- const xml = this.BuildCreateRequest(session, ctx.ObjectName, ctx.Attributes);
1236
- try {
1237
- const response = await this.SendXMLRequest(session, xml);
1238
- const recordKey = this.ExtractRecordKey(response, ctx.ObjectName);
1239
- return this.BuildCreatedResult(recordKey, 200, ctx.ObjectName);
1240
- }
1241
- catch (err) {
1242
- return this.BuildCRUDError(err, 'CreateRecord', ctx.ObjectName);
1243
- }
1244
- }
1245
- async UpdateRecord(ctx) {
1246
- const companyIntegration = ctx.CompanyIntegration;
1247
- const contextUser = ctx.ContextUser;
1248
- const session = await this.GetSession(companyIntegration, contextUser);
1249
- const pkField = this.GetPrimaryKeyField(ctx.ObjectName, companyIntegration.IntegrationID);
1250
- const xml = this.BuildUpdateRequest(session, ctx.ObjectName, ctx.ExternalID, ctx.Attributes, pkField);
1251
- try {
1252
- await this.SendXMLRequest(session, xml);
1253
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: 200 };
1254
- }
1255
- catch (err) {
1256
- return this.BuildCRUDError(err, 'UpdateRecord', ctx.ObjectName);
1257
- }
1258
- }
1259
- async DeleteRecord(ctx) {
1260
- const companyIntegration = ctx.CompanyIntegration;
1261
- const contextUser = ctx.ContextUser;
1262
- const session = await this.GetSession(companyIntegration, contextUser);
1263
- const xml = this.BuildDeleteRequest(session, ctx.ObjectName, ctx.ExternalID);
1264
- try {
1265
- await this.SendXMLRequest(session, xml);
1266
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: 200 };
1267
- }
1268
- catch (err) {
1269
- return this.BuildCRUDError(err, 'DeleteRecord', ctx.ObjectName);
1270
- }
1271
- }
1272
- async GetRecord(ctx) {
1273
- const companyIntegration = ctx.CompanyIntegration;
1274
- const contextUser = ctx.ContextUser;
1275
- const session = await this.GetSession(companyIntegration, contextUser);
1276
- const pkField = this.GetPrimaryKeyField(ctx.ObjectName, companyIntegration.IntegrationID);
1277
- const xml = this.BuildReadRequest(session, ctx.ObjectName, ctx.ExternalID);
1278
- try {
1279
- const response = await this.SendXMLRequest(session, xml);
1280
- const record = this.ParseSingleRecord(response, ctx.ObjectName);
1281
- if (!record)
1282
- return null;
1283
- return {
1284
- ExternalID: String(record[pkField] ?? ctx.ExternalID),
1285
- ObjectType: ctx.ObjectName,
1286
- Fields: record,
1287
- };
1288
- }
1289
- catch {
1290
- return null;
1291
- }
1292
- }
1293
- async SearchRecords(ctx) {
1294
- const companyIntegration = ctx.CompanyIntegration;
1295
- const contextUser = ctx.ContextUser;
1296
- const session = await this.GetSession(companyIntegration, contextUser);
1297
- const pkField = this.GetPrimaryKeyField(ctx.ObjectName, companyIntegration.IntegrationID);
1298
- const pageSize = ctx.PageSize ?? DEFAULT_PAGE_SIZE;
1299
- const filterParts = Object.entries(ctx.Filters).map(([field, value]) => `${field} = '${this.EscapeXmlValue(String(value))}'`);
1300
- const filter = filterParts.join(' AND ');
1301
- const xml = this.BuildReadByQueryRequest(session, ctx.ObjectName, filter, pageSize, pkField);
1302
- const response = await this.SendXMLRequest(session, xml);
1303
- const result = this.ParseReadByQueryResponse(response, ctx.ObjectName, pkField, pageSize);
1304
- return {
1305
- Records: result.Records,
1306
- TotalCount: result.Records.length + (result.HasMore ? 1 : 0), // Intacct doesn't give exact total in readByQuery
1307
- HasMore: result.HasMore,
1308
- };
1309
- }
1310
- async ListRecords(ctx) {
1311
- const companyIntegration = ctx.CompanyIntegration;
1312
- const contextUser = ctx.ContextUser;
1313
- const session = await this.GetSession(companyIntegration, contextUser);
1314
- const pkField = this.GetPrimaryKeyField(ctx.ObjectName, companyIntegration.IntegrationID);
1315
- const pageSize = ctx.PageSize ?? DEFAULT_PAGE_SIZE;
1316
- if (ctx.Cursor) {
1317
- const result = await this.FetchMoreRecords(session, ctx.Cursor, ctx.ObjectName, pkField);
1318
- return {
1319
- Records: result.Records,
1320
- HasMore: result.HasMore,
1321
- NextCursor: result.HasMore ? result.NextCursor : undefined,
1322
- };
1323
- }
1324
- const filter = ctx.Filter
1325
- ? Object.entries(ctx.Filter).map(([k, v]) => `${k} = '${this.EscapeXmlValue(String(v))}'`).join(' AND ')
1326
- : '';
1327
- const xml = this.BuildReadByQueryRequest(session, ctx.ObjectName, filter, pageSize, pkField);
1328
- const response = await this.SendXMLRequest(session, xml);
1329
- const result = this.ParseReadByQueryResponse(response, ctx.ObjectName, pkField, pageSize);
1330
- return {
1331
- Records: result.Records,
1332
- HasMore: result.HasMore,
1333
- NextCursor: result.HasMore ? result.NextCursor : undefined,
1334
- };
1335
- }
1336
- // ─── Session Management ──────────────────────────────────────────
1337
- /**
1338
- * Gets or refreshes a Sage Intacct API session.
1339
- * Sessions are cached and reused until they approach expiration.
1340
- */
1341
- async GetSession(companyIntegration, contextUser, forceRefresh = false) {
1342
- if (!forceRefresh && this.cachedSession && this.IsSessionValid()) {
1343
- return this.cachedSession;
1344
- }
1345
- const config = await this.ParseConfig(companyIntegration, contextUser);
1346
- const xml = this.BuildSessionRequest(config);
1347
- const responseXml = await this.PostXML(INTACCT_API_ENDPOINT, xml, config);
1348
- const session = this.ParseSessionResponse(responseXml, config);
1349
- this.cachedSession = session;
1350
- return session;
1351
- }
1352
- IsSessionValid() {
1353
- if (!this.cachedSession)
1354
- return false;
1355
- return (Date.now() - this.cachedSession.CreatedAt) < SESSION_LIFETIME_MS;
1356
- }
1357
- // ─── Configuration Parsing ───────────────────────────────────────
1358
- /**
1359
- * Parses connection configuration from CompanyIntegration credentials.
1360
- * Looks for a linked MJCredentialEntity or falls back to Configuration JSON.
1361
- */
1362
- async ParseConfig(companyIntegration, contextUser) {
1363
- // Try credential entity first
1364
- const credentialID = companyIntegration.Get('CredentialID');
1365
- if (credentialID) {
1366
- const config = await this.LoadFromCredentialEntity(credentialID, contextUser);
1367
- if (config)
1368
- return config;
1369
- }
1370
- // Fall back to Configuration JSON
1371
- const configJson = companyIntegration.Get('Configuration');
1372
- if (configJson) {
1373
- const parsed = JSON.parse(configJson);
1374
- return this.ValidateConfig(parsed);
1375
- }
1376
- throw new Error('SageIntacctConnector: No credentials or configuration found on CompanyIntegration');
1377
- }
1378
- async LoadFromCredentialEntity(credentialID, contextUser, provider) {
1379
- const md = provider ?? new Metadata();
1380
- const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
1381
- const loaded = await credential.Load(credentialID);
1382
- if (!loaded || !credential.Values)
1383
- return null;
1384
- try {
1385
- const parsed = JSON.parse(credential.Values);
1386
- return this.ValidateConfig(parsed);
1387
- }
1388
- catch {
1389
- return null;
1390
- }
1391
- }
1392
- ValidateConfig(raw) {
1393
- if (!raw.CompanyId)
1394
- throw new Error('SageIntacctConnector: CompanyId is required');
1395
- if (!raw.SenderId)
1396
- throw new Error('SageIntacctConnector: SenderId is required');
1397
- if (!raw.SenderPassword)
1398
- throw new Error('SageIntacctConnector: SenderPassword is required');
1399
- if (!raw.UserId)
1400
- throw new Error('SageIntacctConnector: UserId is required');
1401
- if (!raw.UserPassword)
1402
- throw new Error('SageIntacctConnector: UserPassword is required');
1403
- return {
1404
- CompanyId: raw.CompanyId,
1405
- SenderId: raw.SenderId,
1406
- SenderPassword: raw.SenderPassword,
1407
- UserId: raw.UserId,
1408
- UserPassword: raw.UserPassword,
1409
- EntityId: raw.EntityId,
1410
- MaxRetries: raw.MaxRetries ?? DEFAULT_MAX_RETRIES,
1411
- RequestTimeoutMs: raw.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
1412
- MinRequestIntervalMs: raw.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS,
1413
- };
1414
- }
1415
- // ─── XML Request Builders ────────────────────────────────────────
1416
- BuildSessionRequest(config) {
1417
- const entityTag = config.EntityId
1418
- ? `<locationid>${this.EscapeXmlValue(config.EntityId)}</locationid>`
1419
- : '';
1420
- return `<?xml version="1.0" encoding="UTF-8"?>
1421
- <request>
1422
- <control>
1423
- <senderid>${this.EscapeXmlValue(config.SenderId)}</senderid>
1424
- <password>${this.EscapeXmlValue(config.SenderPassword)}</password>
1425
- <controlid>${CONTROL_ID_PREFIX}-session-${Date.now()}</controlid>
1426
- <uniqueid>false</uniqueid>
1427
- <dtdversion>${DTD_VERSION}</dtdversion>
1428
- <includewhitespace>false</includewhitespace>
1429
- </control>
1430
- <operation>
1431
- <authentication>
1432
- <login>
1433
- <userid>${this.EscapeXmlValue(config.UserId)}</userid>
1434
- <companyid>${this.EscapeXmlValue(config.CompanyId)}</companyid>
1435
- <password>${this.EscapeXmlValue(config.UserPassword)}</password>
1436
- ${entityTag}
1437
- </login>
1438
- </authentication>
1439
- <content>
1440
- <function controlid="${CONTROL_ID_PREFIX}-getSession">
1441
- <getAPISession/>
1442
- </function>
1443
- </content>
1444
- </operation>
1445
- </request>`;
1446
- }
1447
- BuildReadByQueryRequest(session, objectName, filter, pageSize, _orderByField) {
1448
- // SI's readByQuery XSD does NOT accept an <orderby> child element —
1449
- // every request including it errors with "Element 'orderby' is not
1450
- // expected" and returns 0 records. Ordering, when needed, must be
1451
- // expressed in the WHERE/<query> clause via SI's SOQL-like syntax
1452
- // ("ORDER BY <field> ASC" appended to the query expression). The
1453
- // caller can append "ORDER BY <field>" inside `filter` if it wants
1454
- // deterministic ordering. The orderByField parameter is preserved
1455
- // for API stability but no longer emits the rejected XML element.
1456
- void _orderByField;
1457
- return this.WrapInSessionRequest(session, `
1458
- <function controlid="${CONTROL_ID_PREFIX}-readByQuery-${Date.now()}">
1459
- <readByQuery>
1460
- <object>${this.EscapeXmlValue(objectName)}</object>
1461
- <fields>*</fields>
1462
- <query>${this.EscapeXmlValue(filter)}</query>
1463
- <pagesize>${pageSize}</pagesize>
1464
- </readByQuery>
1465
- </function>`);
1466
- }
1467
- BuildReadMoreRequest(session, resultId) {
1468
- return this.WrapInSessionRequest(session, `
1469
- <function controlid="${CONTROL_ID_PREFIX}-readMore-${Date.now()}">
1470
- <readMore>
1471
- <resultId>${this.EscapeXmlValue(resultId)}</resultId>
1472
- </readMore>
1473
- </function>`);
1474
- }
1475
- BuildReadRequest(session, objectName, key) {
1476
- return this.WrapInSessionRequest(session, `
1477
- <function controlid="${CONTROL_ID_PREFIX}-read-${Date.now()}">
1478
- <read>
1479
- <object>${this.EscapeXmlValue(objectName)}</object>
1480
- <keys>${this.EscapeXmlValue(key)}</keys>
1481
- <fields>*</fields>
1482
- </read>
1483
- </function>`);
1484
- }
1485
- BuildCreateRequest(session, objectName, attributes) {
1486
- const fieldsXml = this.AttributesToXml(attributes);
1487
- return this.WrapInSessionRequest(session, `
1488
- <function controlid="${CONTROL_ID_PREFIX}-create-${Date.now()}">
1489
- <create>
1490
- <${objectName}>
1491
- ${fieldsXml}
1492
- </${objectName}>
1493
- </create>
1494
- </function>`);
1495
- }
1496
- BuildUpdateRequest(session, objectName, key, attributes, pkField) {
1497
- const resolvedPk = pkField ?? this.GetPrimaryKeyField(objectName);
1498
- const fieldsXml = this.AttributesToXml({ [resolvedPk]: key, ...attributes });
1499
- return this.WrapInSessionRequest(session, `
1500
- <function controlid="${CONTROL_ID_PREFIX}-update-${Date.now()}">
1501
- <update>
1502
- <${objectName}>
1503
- ${fieldsXml}
1504
- </${objectName}>
1505
- </update>
1506
- </function>`);
1507
- }
1508
- BuildDeleteRequest(session, objectName, key) {
1509
- return this.WrapInSessionRequest(session, `
1510
- <function controlid="${CONTROL_ID_PREFIX}-delete-${Date.now()}">
1511
- <delete>
1512
- <object>${this.EscapeXmlValue(objectName)}</object>
1513
- <keys>${this.EscapeXmlValue(key)}</keys>
1514
- </delete>
1515
- </function>`);
1516
- }
1517
- BuildInspectRequest(session, objectNameOrWildcard) {
1518
- return this.WrapInSessionRequest(session, `
1519
- <function controlid="${CONTROL_ID_PREFIX}-inspect-${Date.now()}">
1520
- <inspect>
1521
- <object>${this.EscapeXmlValue(objectNameOrWildcard)}</object>
1522
- </inspect>
1523
- </function>`);
1524
- }
1525
- /**
1526
- * Wraps a function XML fragment in a session-authenticated request envelope.
1527
- */
1528
- WrapInSessionRequest(session, functionXml) {
1529
- return `<?xml version="1.0" encoding="UTF-8"?>
1530
- <request>
1531
- <control>
1532
- <senderid>${this.EscapeXmlValue(session.Config.SenderId)}</senderid>
1533
- <password>${this.EscapeXmlValue(session.Config.SenderPassword)}</password>
1534
- <controlid>${CONTROL_ID_PREFIX}-${Date.now()}</controlid>
1535
- <uniqueid>false</uniqueid>
1536
- <dtdversion>${DTD_VERSION}</dtdversion>
1537
- <includewhitespace>false</includewhitespace>
1538
- </control>
1539
- <operation>
1540
- <authentication>
1541
- <sessionid>${this.EscapeXmlValue(session.SessionId)}</sessionid>
1542
- </authentication>
1543
- <content>
1544
- ${functionXml}
1545
- </content>
1546
- </operation>
1547
- </request>`;
1548
- }
1549
- // ─── XML Response Parsers ────────────────────────────────────────
1550
- ParseSessionResponse(xml, config) {
1551
- this.CheckForErrors(xml);
1552
- const sessionId = this.ExtractXmlValue(xml, 'sessionid');
1553
- const endpoint = this.ExtractXmlValue(xml, 'endpoint') || INTACCT_API_ENDPOINT;
1554
- if (!sessionId) {
1555
- throw new Error('SageIntacctConnector: getAPISession did not return a sessionid');
1556
- }
1557
- return {
1558
- SessionId: sessionId,
1559
- Endpoint: endpoint,
1560
- CreatedAt: Date.now(),
1561
- Config: config,
1562
- };
1563
- }
1564
- /** Lower-level parser: returns just the raw records + SI's signals
1565
- * without applying any pagination logic. Used by the range-chunked and
1566
- * single-pull paths which decide pagination for themselves. */
1567
- ParseReadByQueryResponseRaw(xml, objectName, pageSize) {
1568
- this.CheckForErrors(xml);
1569
- const numRemaining = parseInt(this.ExtractXmlValue(xml, 'numremaining') || '0', 10);
1570
- const resultId = this.ExtractXmlValue(xml, 'resultId') || undefined;
1571
- const records = this.ExtractRecords(xml, objectName);
1572
- console.log(`[SageIntacct] ${objectName}: SI response — records.length=${records.length}, ` +
1573
- `numremaining=${numRemaining}, resultId=${resultId ? 'present' : 'absent'}, pageSize=${pageSize}`);
1574
- return { records, numRemaining, resultId };
1575
- }
1576
- ParseReadByQueryResponse(xml, objectName, pkField, pageSize = DEFAULT_PAGE_SIZE) {
1577
- this.CheckForErrors(xml);
1578
- const numRemaining = parseInt(this.ExtractXmlValue(xml, 'numremaining') || '0', 10);
1579
- const resultId = this.ExtractXmlValue(xml, 'resultId') || undefined;
1580
- const records = this.ExtractRecords(xml, objectName);
1581
- console.log(`[SageIntacct] ${objectName}: SI response — records.length=${records.length}, ` +
1582
- `numremaining=${numRemaining}, resultId=${resultId ? 'present' : 'absent'}, pageSize=${pageSize}`);
1583
- const externalRecords = records.map(r => ({
1584
- ExternalID: String(r[pkField] ?? ''),
1585
- ObjectType: objectName,
1586
- Fields: r,
1587
- ModifiedAt: r['WHENMODIFIED'] ? new Date(String(r['WHENMODIFIED'])) : undefined,
1588
- }));
1589
- // Compute watermark from the latest WHENMODIFIED in this batch
1590
- const newWatermark = this.ComputeWatermark(records);
1591
- // Legacy parser used by SearchRecords/ListRecords/FetchMoreRecords
1592
- // (browse/search APIs that use SI's resultId pagination directly).
1593
- // The sync engine path uses the bulletproof range-chunked walk in
1594
- // FetchChangesByRange — it never reaches this branch.
1595
- //
1596
- // Safe pagination signals:
1597
- // - SI returned a resultId → readMore against that resultId
1598
- // - SI returned numremaining=0 AND records.length < pageSize → done
1599
- //
1600
- // Unsafe case: full page with no resultId. The previous PKCURSOR
1601
- // fallback (PK > maxPK on next call) silently dropped records when
1602
- // SI's scan order wasn't PK-ascending, which is the actual default.
1603
- // Better to fail loudly than corrupt the dataset.
1604
- const pageWasFull = records.length >= pageSize;
1605
- const apiSaysMore = numRemaining > 0;
1606
- let hasMore = false;
1607
- let nextCursor;
1608
- if (resultId && (apiSaysMore || pageWasFull)) {
1609
- hasMore = true;
1610
- nextCursor = resultId;
1611
- }
1612
- else if (pageWasFull) {
1613
- throw new Error(`[SageIntacct] ${objectName}: SI returned a full page (${records.length} records) ` +
1614
- `with no resultId. Cannot safely paginate — SI's readByQuery does not scan in ` +
1615
- `PK-ascending order, so PK-cursor advancement would silently drop records. ` +
1616
- `Use FetchChanges (range-chunked sync) for complete coverage, or raise ` +
1617
- `pageSize so this query fits in a single page.`);
1618
- }
1619
- // Suppress unused-warning when the legacy MaxPkValue helper is not
1620
- // referenced from this branch. Other callers (probe scripts) may
1621
- // still invoke it via reflection.
1622
- void pkField;
1623
- // Critical: only advance the watermark on the FINAL batch of an
1624
- // entity's fetch. Intermediate batches must keep the original
1625
- // watermark filter so PK-cursor pagination doesn't shrink the
1626
- // WHENMODIFIED window and silently drop records modified between
1627
- // the original watermark and the batch's max. The engine sets
1628
- // currentWatermark from NewWatermarkValue immediately and that
1629
- // becomes the next batch's WHERE filter — fine for the FINAL
1630
- // batch (entity fully drained), broken for any mid-pagination
1631
- // batch (next call still needs the original watermark to find
1632
- // the rest of the records).
1633
- const newWatermarkForReturn = hasMore ? undefined : newWatermark;
1634
- return {
1635
- Records: externalRecords,
1636
- HasMore: hasMore,
1637
- NewWatermarkValue: newWatermarkForReturn,
1638
- NextCursor: nextCursor,
1639
- };
1640
- }
1641
- ParseSingleRecord(xml, objectName) {
1642
- this.CheckForErrors(xml);
1643
- const records = this.ExtractRecords(xml, objectName);
1644
- return records.length > 0 ? records[0] : null;
1645
- }
1646
- /**
1647
- * Returns the maximum PK value across the records, picking the right
1648
- * comparator (numeric vs lexicographic) based on whether all values
1649
- * look like integers. Returns undefined when records is empty or no
1650
- * record has a usable PK value.
1651
- */
1652
- MaxPkValue(records, pkField) {
1653
- const values = [];
1654
- for (const r of records) {
1655
- const v = r[pkField];
1656
- if (v == null)
1657
- continue;
1658
- if (typeof v === 'number' && Number.isFinite(v))
1659
- values.push(v);
1660
- else if (typeof v === 'string' && v.length > 0)
1661
- values.push(v);
1662
- }
1663
- if (values.length === 0)
1664
- return undefined;
1665
- const allNumericStrings = values.every(v => typeof v === 'number' || (typeof v === 'string' && /^-?\d+$/.test(v)));
1666
- if (allNumericStrings) {
1667
- return values.reduce((m, v) => {
1668
- const n = typeof v === 'number' ? v : parseInt(v, 10);
1669
- return n > m ? n : m;
1670
- }, Number.NEGATIVE_INFINITY);
1671
- }
1672
- // String comparison — return lexicographically largest
1673
- return values.reduce((m, v) => {
1674
- const s = String(v);
1675
- return s > m ? s : m;
1676
- }, '');
1677
- }
1678
- ParseInspectObjectsResponse(xml) {
1679
- this.CheckForErrors(xml);
1680
- const objectNames = [];
1681
- const skipped = [];
1682
- // inspect with '*' returns <type>OBJECTNAME</type> elements containing
1683
- // a mix of (a) real API codes that work directly (CUSTOMER, APBILL),
1684
- // (b) human display names where the API code is a "strip spaces +
1685
- // uppercase" transform of the display name ("AP bill" → APBILL,
1686
- // "Order entry transaction" → ORDERENTRYTRANSACTION), and (c) proper-
1687
- // case display-only labels that have no API equivalent ("Channel",
1688
- // "User", "Entity"). We can't tell (b) from (c) up-front, so this
1689
- // method returns BOTH: real-looking API codes pass straight through,
1690
- // and display-name-shaped entries are normalized into candidate API
1691
- // codes. The caller then probes each candidate against per-object
1692
- // inspect to keep only the ones SI actually accepts.
1693
- const typeRegex = /<type[^>]*>([^<]+)<\/type>/gi;
1694
- const apiNameRegex = /^[A-Z][A-Z0-9_]*$/;
1695
- const seen = new Set();
1696
- let match;
1697
- while ((match = typeRegex.exec(xml)) !== null) {
1698
- const raw = match[1].trim();
1699
- if (!raw)
1700
- continue;
1701
- // Already an API-code-shaped name → keep verbatim
1702
- if (apiNameRegex.test(raw)) {
1703
- if (!seen.has(raw)) {
1704
- seen.add(raw);
1705
- objectNames.push(raw);
1706
- }
1707
- continue;
1708
- }
1709
- // Display-name-shaped → derive a candidate API code by stripping
1710
- // whitespace and uppercasing. SI accepts these for many objects
1711
- // where the inspect-* list shows the display label.
1712
- const candidate = raw.replace(/[\s\-]+/g, '').toUpperCase();
1713
- if (!candidate || !apiNameRegex.test(candidate)) {
1714
- skipped.push(raw);
1715
- continue;
1716
- }
1717
- if (!seen.has(candidate)) {
1718
- seen.add(candidate);
1719
- objectNames.push(candidate);
1720
- }
1721
- }
1722
- if (skipped.length > 0) {
1723
- console.log(`[SageIntacct] ParseInspectObjectsResponse: filtered ${skipped.length} non-API-code names ` +
1724
- `(e.g. "${skipped.slice(0, 3).join('", "')}"${skipped.length > 3 ? ', ...' : ''}) — ` +
1725
- `these entries cannot be inspected via the per-object endpoint.`);
1726
- }
1727
- return objectNames;
1728
- }
1729
- ParseInspectFieldsResponse(xml) {
1730
- this.CheckForErrors(xml);
1731
- const fields = [];
1732
- // SI's inspect can emit the same <Name> in multiple <Field> blocks
1733
- // (nested aliases, legacy + current paths for the same column, etc.).
1734
- // The unique key on IntegrationObjectField is (IntegrationObjectID,
1735
- // Name) so duplicates blow up at insert time. Dedupe here, first
1736
- // occurrence wins.
1737
- const seenNames = new Set();
1738
- // inspect returns <Field> elements with <Name>, <DataType>, etc.
1739
- // SI also exposes (when present): <MaxLength>, <Precision>, <Scale>,
1740
- // <DefaultValue>, <References> (FK target object name). Surface them
1741
- // so downstream DDL generation has real constraints to work with
1742
- // instead of guessing precision/length and producing brittle types.
1743
- const fieldRegex = /<Field>\s*([\s\S]*?)\s*<\/Field>/gi;
1744
- let match;
1745
- while ((match = fieldRegex.exec(xml)) !== null) {
1746
- const fieldXml = match[1];
1747
- const name = this.ExtractXmlValueFromFragment(fieldXml, 'Name');
1748
- const label = this.ExtractXmlValueFromFragment(fieldXml, 'DisplayLabel') || name;
1749
- const dataType = this.ExtractXmlValueFromFragment(fieldXml, 'DataName')
1750
- || this.ExtractXmlValueFromFragment(fieldXml, 'DataType')
1751
- || 'string';
1752
- const isRequired = this.ExtractXmlValueFromFragment(fieldXml, 'Required') === 'true';
1753
- const isReadOnly = this.ExtractXmlValueFromFragment(fieldXml, 'ReadOnly') === 'true'
1754
- || this.ExtractXmlValueFromFragment(fieldXml, 'SystemGenerated') === 'true';
1755
- const isCustom = this.ExtractXmlValueFromFragment(fieldXml, 'IsCustom').toLowerCase() === 'true'
1756
- || this.ExtractXmlValueFromFragment(fieldXml, 'ISCUSTOM').toLowerCase() === 'true';
1757
- const maxLength = this.ParseNumericTagFromFragment(fieldXml, 'MaxLength')
1758
- ?? this.ParseNumericTagFromFragment(fieldXml, 'Length');
1759
- const precision = this.ParseNumericTagFromFragment(fieldXml, 'Precision');
1760
- const scale = this.ParseNumericTagFromFragment(fieldXml, 'Scale');
1761
- const defaultValueRaw = this.ExtractXmlValueFromFragment(fieldXml, 'DefaultValue');
1762
- const defaultValue = defaultValueRaw && defaultValueRaw.length > 0 ? defaultValueRaw : null;
1763
- const referencesRaw = this.ExtractXmlValueFromFragment(fieldXml, 'References')
1764
- || this.ExtractXmlValueFromFragment(fieldXml, 'ReferencesObject');
1765
- const references = referencesRaw && referencesRaw.length > 0 ? referencesRaw : null;
1766
- if (name) {
1767
- const key = name.toLowerCase();
1768
- if (seenNames.has(key))
1769
- continue;
1770
- seenNames.add(key);
1771
- fields.push({
1772
- Name: name, Label: label, DataType: dataType,
1773
- IsRequired: isRequired, IsReadOnly: isReadOnly,
1774
- IsCustom: isCustom,
1775
- MaxLength: maxLength,
1776
- Precision: precision,
1777
- Scale: scale,
1778
- DefaultValue: defaultValue,
1779
- References: references,
1780
- });
1781
- }
1782
- }
1783
- return fields;
1784
- }
1785
- /**
1786
- * Pulls a tag value out of a fragment and parses it as a number. Returns
1787
- * null when the tag is absent, empty, or doesn't parse. Used for fields
1788
- * like MaxLength / Precision / Scale that SI reports inconsistently
1789
- * across object families (some return numbers, some omit, some return
1790
- * empty strings).
1791
- */
1792
- ParseNumericTagFromFragment(fragment, tagName) {
1793
- const raw = this.ExtractXmlValueFromFragment(fragment, tagName);
1794
- if (!raw || raw.length === 0)
1795
- return null;
1796
- const n = Number(raw);
1797
- return Number.isFinite(n) ? n : null;
1798
- }
1799
- // ─── HTTP Transport ──────────────────────────────────────────────
1800
- /**
1801
- * Sends an XML request to the Sage Intacct API using a session.
1802
- * Handles throttling and retry logic.
1803
- */
1804
- async SendXMLRequest(session, xml) {
1805
- return this.PostXML(session.Endpoint, xml, session.Config);
1806
- }
1807
- /**
1808
- * Posts XML to the Sage Intacct gateway with throttling and retry support.
1809
- */
1810
- async PostXML(url, xml, config) {
1811
- const maxRetries = config.MaxRetries ?? DEFAULT_MAX_RETRIES;
1812
- const timeoutMs = config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1813
- const minInterval = config.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
1814
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1815
- await this.Throttle(minInterval);
1816
- try {
1817
- const controller = new AbortController();
1818
- const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
1819
- const response = await fetch(url, {
1820
- method: 'POST',
1821
- headers: {
1822
- 'Content-Type': 'application/xml',
1823
- 'Accept': 'application/xml',
1824
- },
1825
- body: xml,
1826
- signal: controller.signal,
1827
- });
1828
- clearTimeout(timeoutHandle);
1829
- this.lastRequestTime = Date.now();
1830
- const responseText = await response.text();
1831
- if (!response.ok) {
1832
- throw new Error(`HTTP ${response.status}: ${responseText.slice(0, 500)}`);
1833
- }
1834
- return responseText;
1835
- }
1836
- catch (err) {
1837
- const message = err instanceof Error ? err.message : String(err);
1838
- const isRetryable = message.includes('abort') || message.includes('timeout')
1839
- || message.includes('ECONNRESET') || message.includes('429')
1840
- || message.includes('503');
1841
- if (!isRetryable || attempt === maxRetries) {
1842
- throw new Error(`Sage Intacct API request failed after ${attempt + 1} attempt(s): ${message}`);
1843
- }
1844
- // Exponential backoff: 1s, 2s, 4s, ...
1845
- const delay = Math.pow(2, attempt) * 1000;
1846
- await this.Sleep(delay);
1847
- }
1848
- }
1849
- throw new Error('Sage Intacct API request failed: max retries exceeded');
1850
- }
1851
- // ─── XML Parsing Helpers ─────────────────────────────────────────
1852
- /**
1853
- * Checks the API response for error elements and throws if found.
1854
- */
1855
- CheckForErrors(xml) {
1856
- // Check for control-level errors
1857
- const statusMatch = xml.match(/<status>(.*?)<\/status>/i);
1858
- if (statusMatch && statusMatch[1].toLowerCase() === 'failure') {
1859
- const errMsg = this.ExtractErrorMessage(xml);
1860
- throw new Error(`Sage Intacct API error: ${errMsg}`);
1861
- }
1862
- // Check for function-level errors
1863
- const errNoMatch = xml.match(/<errorno>(.*?)<\/errorno>/i);
1864
- if (errNoMatch) {
1865
- const errDesc = this.ExtractXmlValue(xml, 'description2')
1866
- || this.ExtractXmlValue(xml, 'description')
1867
- || this.ExtractXmlValue(xml, 'errormessage')
1868
- || 'Unknown error';
1869
- throw new Error(`Sage Intacct error ${errNoMatch[1]}: ${errDesc}`);
1870
- }
1871
- }
1872
- ExtractErrorMessage(xml) {
1873
- const desc2 = this.ExtractXmlValue(xml, 'description2');
1874
- if (desc2)
1875
- return desc2;
1876
- const desc = this.ExtractXmlValue(xml, 'description');
1877
- if (desc)
1878
- return desc;
1879
- const errMsg = this.ExtractXmlValue(xml, 'errormessage');
1880
- if (errMsg)
1881
- return errMsg;
1882
- return 'Unknown error (no description in response)';
1883
- }
1884
- /**
1885
- * Extracts the text content of an XML element by tag name.
1886
- * Simple regex-based parsing — sufficient for Sage Intacct's flat response format.
1887
- */
1888
- ExtractXmlValue(xml, tagName) {
1889
- const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`, 'i');
1890
- const match = xml.match(regex);
1891
- return match ? match[1].trim() : null;
1892
- }
1893
- ExtractXmlValueFromFragment(fragment, tagName) {
1894
- const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`, 'i');
1895
- const match = fragment.match(regex);
1896
- return match ? match[1].trim() : '';
1897
- }
1898
- /**
1899
- * Extracts records from a readByQuery/read response.
1900
- * Records are child elements within the <data> element, tagged by object name
1901
- * (e.g., <CUSTOMER>, <VENDOR>, etc.) or a generic lowercase tag.
1902
- */
1903
- ExtractRecords(xml, objectName) {
1904
- const records = [];
1905
- // Look for the data section
1906
- const dataMatch = xml.match(/<data[^>]*>([\s\S]*?)<\/data>/i);
1907
- if (!dataMatch)
1908
- return records;
1909
- const dataContent = dataMatch[1];
1910
- // Extract individual record elements — try both uppercase and lowercase
1911
- const recordRegex = new RegExp(`<${objectName}[^>]*>([\\s\\S]*?)</${objectName}>`, 'gi');
1912
- let match;
1913
- while ((match = recordRegex.exec(dataContent)) !== null) {
1914
- const recordXml = match[1];
1915
- const record = this.ParseRecordFields(recordXml);
1916
- records.push(record);
1917
- }
1918
- return records;
1919
- }
1920
- /**
1921
- * Parses field name/value pairs from a record XML fragment.
1922
- * Handles flat fields only (nested objects are returned as string values).
1923
- */
1924
- ParseRecordFields(recordXml) {
1925
- const fields = {};
1926
- const fieldRegex = /<(\w+)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g;
1927
- let match;
1928
- while ((match = fieldRegex.exec(recordXml)) !== null) {
1929
- const fieldName = match[1];
1930
- const value = match[2].trim();
1931
- // Attempt to parse numeric values
1932
- if (/^-?\d+$/.test(value)) {
1933
- fields[fieldName] = parseInt(value, 10);
1934
- }
1935
- else if (/^-?\d+\.\d+$/.test(value)) {
1936
- fields[fieldName] = parseFloat(value);
1937
- }
1938
- else if (value === '' || value === null) {
1939
- fields[fieldName] = null;
1940
- }
1941
- else {
1942
- fields[fieldName] = value;
1943
- }
1944
- }
1945
- return fields;
1946
- }
1947
- ExtractRecordKey(xml, objectName) {
1948
- // After create, Sage Intacct typically returns the key in a <RECORDNO> or object-specific PK field
1949
- const pkField = this.GetPrimaryKeyField(objectName);
1950
- const value = this.ExtractXmlValue(xml, pkField)
1951
- || this.ExtractXmlValue(xml, 'RECORDNO')
1952
- || this.ExtractXmlValue(xml, 'key')
1953
- || '';
1954
- return value;
1955
- }
1956
- // ─── Utility Helpers ─────────────────────────────────────────────
1957
- /**
1958
- * Converts an attributes object to XML field elements.
1959
- */
1960
- AttributesToXml(attributes) {
1961
- return Object.entries(attributes)
1962
- .filter(([, v]) => v != null)
1963
- .map(([key, value]) => `<${key}>${this.EscapeXmlValue(String(value))}</${key}>`)
1964
- .join('\n ');
1965
- }
1966
- /**
1967
- * Escapes special characters for XML content.
1968
- */
1969
- EscapeXmlValue(value) {
1970
- return value
1971
- .replace(/&/g, '&amp;')
1972
- .replace(/</g, '&lt;')
1973
- .replace(/>/g, '&gt;')
1974
- .replace(/"/g, '&quot;')
1975
- .replace(/'/g, '&apos;');
1976
- }
1977
- /**
1978
- * Gets the primary key field name for a Sage Intacct object, preferring
1979
- * the IntegrationObject metadata (DefaultQueryParams.pk_field or the
1980
- * IntegrationObjectField marked IsPrimaryKey) over the built-in fallback
1981
- * map. Returns 'RECORDNO' when nothing else is available.
1982
- *
1983
- * This is called frequently — the lookup is cheap (pure engine cache) and
1984
- * avoids hardcoded switch statements on object names.
1985
- */
1986
- GetPrimaryKeyField(objectName, integrationID) {
1987
- if (integrationID) {
1988
- const fromMetadata = this.resolvePkFromMetadata(integrationID, objectName);
1989
- if (fromMetadata)
1990
- return fromMetadata;
1991
- }
1992
- return OBJECT_PK_MAP[objectName.toUpperCase()] || 'RECORDNO';
1993
- }
1994
- /**
1995
- * Resolves the PK for an object from the engine cache.
1996
- * Checks (in order): DefaultQueryParams.pk_field, then the field record
1997
- * with IsPrimaryKey=true. Returns null when nothing is found.
1998
- */
1999
- resolvePkFromMetadata(integrationID, objectName) {
2000
- try {
2001
- const obj = IntegrationEngineBase.Instance.GetIntegrationObject(integrationID, objectName);
2002
- if (!obj)
2003
- return null;
2004
- if (obj.DefaultQueryParams) {
2005
- const parsed = JSON.parse(obj.DefaultQueryParams);
2006
- if (parsed.pk_field && typeof parsed.pk_field === 'string') {
2007
- return parsed.pk_field;
2008
- }
2009
- }
2010
- const fields = IntegrationEngineBase.Instance.GetIntegrationObjectFields(obj.ID);
2011
- const pkField = fields.find(f => f.IsPrimaryKey && f.Status === 'Active');
2012
- return pkField ? pkField.Name : null;
2013
- }
2014
- catch {
2015
- return null;
2016
- }
2017
- }
2018
- /**
2019
- * Formats an API object name into a human-readable label.
2020
- */
2021
- FormatLabel(name) {
2022
- // Convert ALLCAPS to Title Case with spaces before capitals
2023
- return name
2024
- .replace(/_/g, ' ')
2025
- .replace(/([A-Z])([A-Z]+)/g, (_match, first, rest) => first + rest.toLowerCase())
2026
- .replace(/^\w/, c => c.toUpperCase());
2027
- }
2028
- /**
2029
- * Computes the latest WHENMODIFIED watermark from a batch of records.
2030
- */
2031
- ComputeWatermark(records) {
2032
- let latest;
2033
- for (const record of records) {
2034
- const modified = record['WHENMODIFIED'];
2035
- if (modified && typeof modified === 'string') {
2036
- if (!latest || modified > latest) {
2037
- latest = modified;
2038
- }
2039
- }
2040
- }
2041
- return latest;
2042
- }
2043
- /**
2044
- * Fetches more records using a readMore request with a resultId cursor.
2045
- */
2046
- async FetchMoreRecords(session, resultId, objectName, pkField) {
2047
- const xml = this.BuildReadMoreRequest(session, resultId);
2048
- const response = await this.SendXMLRequest(session, xml);
2049
- // readMore inherits the original page size; the engine batch-size
2050
- // doesn't change mid-stream. DEFAULT_PAGE_SIZE is the server-side
2051
- // default and matches what BuildReadByQueryRequest uses.
2052
- return this.ParseReadByQueryResponse(response, objectName, pkField, DEFAULT_PAGE_SIZE);
2053
- }
2054
- /**
2055
- * Throttles API requests to respect rate limits.
2056
- */
2057
- async Throttle(minIntervalMs) {
2058
- const elapsed = Date.now() - this.lastRequestTime;
2059
- if (elapsed < minIntervalMs) {
2060
- await this.Sleep(minIntervalMs - elapsed);
2061
- }
2062
- }
2063
- Sleep(ms) {
2064
- return new Promise(resolve => setTimeout(resolve, ms));
2065
- }
2066
- BuildCRUDError(err, operation, objectName) {
2067
- const message = err instanceof Error ? err.message : String(err);
2068
- return {
2069
- Success: false,
2070
- ErrorMessage: `${operation} failed for ${objectName}: ${message}`,
2071
- StatusCode: 500,
2072
- };
2073
- }
2074
- };
2075
- SageIntacctConnector = __decorate([
2076
- RegisterClass(BaseIntegrationConnector, 'SageIntacctConnector')
2077
- ], SageIntacctConnector);
2078
- export { SageIntacctConnector };
2079
- /** Tree-shaking prevention function — import and call from module entry point */
2080
- export function LoadSageIntacctConnector() { }
2081
- //# sourceMappingURL=SageIntacctConnector.js.map