@memberjunction/integration-connectors 5.51.0 → 6.1.0-edge.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +109 -277
- package/dist/index.d.ts +0 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -38
- package/dist/index.js.map +1 -1
- package/package.json +14 -19
- package/dist/AptifyConnector.d.ts +0 -95
- package/dist/AptifyConnector.d.ts.map +0 -1
- package/dist/AptifyConnector.js +0 -760
- package/dist/AptifyConnector.js.map +0 -1
- package/dist/BlackbaudConnector.d.ts +0 -64
- package/dist/BlackbaudConnector.d.ts.map +0 -1
- package/dist/BlackbaudConnector.js +0 -916
- package/dist/BlackbaudConnector.js.map +0 -1
- package/dist/ConstantContactConnector.d.ts +0 -137
- package/dist/ConstantContactConnector.d.ts.map +0 -1
- package/dist/ConstantContactConnector.js +0 -663
- package/dist/ConstantContactConnector.js.map +0 -1
- package/dist/CventConnector.d.ts +0 -146
- package/dist/CventConnector.d.ts.map +0 -1
- package/dist/CventConnector.js +0 -540
- package/dist/CventConnector.js.map +0 -1
- package/dist/DynamicsDataverseConnector.d.ts +0 -296
- package/dist/DynamicsDataverseConnector.d.ts.map +0 -1
- package/dist/DynamicsDataverseConnector.js +0 -790
- package/dist/DynamicsDataverseConnector.js.map +0 -1
- package/dist/FileFeedConnector.d.ts +0 -64
- package/dist/FileFeedConnector.d.ts.map +0 -1
- package/dist/FileFeedConnector.js +0 -190
- package/dist/FileFeedConnector.js.map +0 -1
- package/dist/FontevaConnector.d.ts +0 -153
- package/dist/FontevaConnector.d.ts.map +0 -1
- package/dist/FontevaConnector.js +0 -421
- package/dist/FontevaConnector.js.map +0 -1
- package/dist/GrowthZoneConnector.d.ts +0 -183
- package/dist/GrowthZoneConnector.d.ts.map +0 -1
- package/dist/GrowthZoneConnector.js +0 -702
- package/dist/GrowthZoneConnector.js.map +0 -1
- package/dist/HivebriteConnector.d.ts +0 -151
- package/dist/HivebriteConnector.d.ts.map +0 -1
- package/dist/HivebriteConnector.js +0 -533
- package/dist/HivebriteConnector.js.map +0 -1
- package/dist/HubSpotConnector.d.ts +0 -523
- package/dist/HubSpotConnector.d.ts.map +0 -1
- package/dist/HubSpotConnector.js +0 -3110
- package/dist/HubSpotConnector.js.map +0 -1
- package/dist/IMISConnector.d.ts +0 -171
- package/dist/IMISConnector.d.ts.map +0 -1
- package/dist/IMISConnector.js +0 -695
- package/dist/IMISConnector.js.map +0 -1
- package/dist/MJToMJConnector.d.ts +0 -279
- package/dist/MJToMJConnector.d.ts.map +0 -1
- package/dist/MJToMJConnector.js +0 -893
- package/dist/MJToMJConnector.js.map +0 -1
- package/dist/MagnetMailConnector.d.ts +0 -163
- package/dist/MagnetMailConnector.d.ts.map +0 -1
- package/dist/MagnetMailConnector.js +0 -823
- package/dist/MagnetMailConnector.js.map +0 -1
- package/dist/MailchimpConnector.d.ts +0 -197
- package/dist/MailchimpConnector.d.ts.map +0 -1
- package/dist/MailchimpConnector.js +0 -791
- package/dist/MailchimpConnector.js.map +0 -1
- package/dist/MemberSuiteConnector.d.ts +0 -185
- package/dist/MemberSuiteConnector.d.ts.map +0 -1
- package/dist/MemberSuiteConnector.js +0 -762
- package/dist/MemberSuiteConnector.js.map +0 -1
- package/dist/NeonCRMConnector.d.ts +0 -273
- package/dist/NeonCRMConnector.d.ts.map +0 -1
- package/dist/NeonCRMConnector.js +0 -1039
- package/dist/NeonCRMConnector.js.map +0 -1
- package/dist/NetForumConnector.d.ts +0 -170
- package/dist/NetForumConnector.d.ts.map +0 -1
- package/dist/NetForumConnector.js +0 -778
- package/dist/NetForumConnector.js.map +0 -1
- package/dist/NetSuiteConnector.d.ts +0 -136
- package/dist/NetSuiteConnector.d.ts.map +0 -1
- package/dist/NetSuiteConnector.js +0 -703
- package/dist/NetSuiteConnector.js.map +0 -1
- package/dist/NimbleAMSConnector.d.ts +0 -142
- package/dist/NimbleAMSConnector.d.ts.map +0 -1
- package/dist/NimbleAMSConnector.js +0 -703
- package/dist/NimbleAMSConnector.js.map +0 -1
- package/dist/NoviConnector.d.ts +0 -178
- package/dist/NoviConnector.d.ts.map +0 -1
- package/dist/NoviConnector.js +0 -566
- package/dist/NoviConnector.js.map +0 -1
- package/dist/ORCIDConnector.d.ts +0 -207
- package/dist/ORCIDConnector.d.ts.map +0 -1
- package/dist/ORCIDConnector.js +0 -708
- package/dist/ORCIDConnector.js.map +0 -1
- package/dist/OpenWaterConnector.d.ts +0 -173
- package/dist/OpenWaterConnector.d.ts.map +0 -1
- package/dist/OpenWaterConnector.js +0 -893
- package/dist/OpenWaterConnector.js.map +0 -1
- package/dist/PathLMSConnector.d.ts +0 -395
- package/dist/PathLMSConnector.d.ts.map +0 -1
- package/dist/PathLMSConnector.js +0 -1281
- package/dist/PathLMSConnector.js.map +0 -1
- package/dist/PheedLoopConnector.d.ts +0 -119
- package/dist/PheedLoopConnector.d.ts.map +0 -1
- package/dist/PheedLoopConnector.js +0 -429
- package/dist/PheedLoopConnector.js.map +0 -1
- package/dist/PropFuelConnector.d.ts +0 -153
- package/dist/PropFuelConnector.d.ts.map +0 -1
- package/dist/PropFuelConnector.js +0 -450
- package/dist/PropFuelConnector.js.map +0 -1
- package/dist/QuickBooksConnector.d.ts +0 -92
- package/dist/QuickBooksConnector.d.ts.map +0 -1
- package/dist/QuickBooksConnector.js +0 -713
- package/dist/QuickBooksConnector.js.map +0 -1
- package/dist/RasaConnector.d.ts +0 -152
- package/dist/RasaConnector.d.ts.map +0 -1
- package/dist/RasaConnector.js +0 -929
- package/dist/RasaConnector.js.map +0 -1
- package/dist/Reach360Connector.d.ts +0 -175
- package/dist/Reach360Connector.d.ts.map +0 -1
- package/dist/Reach360Connector.js +0 -796
- package/dist/Reach360Connector.js.map +0 -1
- package/dist/RelationalDBConnector.d.ts +0 -108
- package/dist/RelationalDBConnector.d.ts.map +0 -1
- package/dist/RelationalDBConnector.js +0 -228
- package/dist/RelationalDBConnector.js.map +0 -1
- package/dist/RhythmConnector.d.ts +0 -188
- package/dist/RhythmConnector.d.ts.map +0 -1
- package/dist/RhythmConnector.js +0 -784
- package/dist/RhythmConnector.js.map +0 -1
- package/dist/SageIntacctConnector.d.ts +0 -337
- package/dist/SageIntacctConnector.d.ts.map +0 -1
- package/dist/SageIntacctConnector.js +0 -2081
- package/dist/SageIntacctConnector.js.map +0 -1
- package/dist/SalesforceConnector.d.ts +0 -461
- package/dist/SalesforceConnector.d.ts.map +0 -1
- package/dist/SalesforceConnector.js +0 -1862
- package/dist/SalesforceConnector.js.map +0 -1
- package/dist/SharePointConnector.d.ts +0 -188
- package/dist/SharePointConnector.d.ts.map +0 -1
- package/dist/SharePointConnector.js +0 -716
- package/dist/SharePointConnector.js.map +0 -1
- package/dist/WicketConnector.d.ts +0 -252
- package/dist/WicketConnector.d.ts.map +0 -1
- package/dist/WicketConnector.js +0 -938
- package/dist/WicketConnector.js.map +0 -1
- package/dist/WildApricotConnector.d.ts +0 -201
- package/dist/WildApricotConnector.d.ts.map +0 -1
- package/dist/WildApricotConnector.js +0 -1129
- package/dist/WildApricotConnector.js.map +0 -1
- package/dist/YourMembershipConnector.d.ts +0 -349
- package/dist/YourMembershipConnector.d.ts.map +0 -1
- package/dist/YourMembershipConnector.js +0 -4062
- package/dist/YourMembershipConnector.js.map +0 -1
- package/dist/generate-integration-actions.d.ts +0 -23
- package/dist/generate-integration-actions.d.ts.map +0 -1
- package/dist/generate-integration-actions.js +0 -201
- 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, '&')
|
|
1972
|
-
.replace(/</g, '<')
|
|
1973
|
-
.replace(/>/g, '>')
|
|
1974
|
-
.replace(/"/g, '"')
|
|
1975
|
-
.replace(/'/g, ''');
|
|
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
|