Setting the file. One moment.
Order History · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
319
function orderContactFields
— line 319
This file
Number 41.151
Position 151 of 174
Type JavaScript
Size 63 KB
Lines 1,127 lib/ order-history.js
JavaScript · 1,127 lines · 63 KB
13 // and the source's evidence. No ordering, no reconciliation, no gate invocation, no state.
14 //
15 // THE ORDER WRITE'S IDENTITY. LIVE-VERIFIED 2026-09-05 against a disposable site:
16 // Import Order ACCEPTS a
17 // caller-supplied `id` it has never seen and keeps it; a re-send with the same id fully replaces
18 // the order body; payments and extendedFields attached to the order SURVIVE the replace; a
19 // non-GUID id is refused with a 400. So the order id is DERIVED here from the source site and
20 // the source order id, which makes the order write idempotent on the target's own terms: a run
21 // that lost its crosswalk asks the target whether the derived id exists, and finds it. The
22 // crosswalk is still written -- before anything else -- but it is an accelerator and a report
23 // input, not the only thing standing between a crash and a duplicated store.
24 //
25 // What the probe ALSO showed, and why an existing order is never re-sent: a different order body
26 // with an existing id replaces it silently, and nothing distinguishes "replace mine" from
27 // "clobber a merchant's edits". So an order that already exists is RECONCILED (left alone) and
28 // its history continued, never re-imported. Search Orders is not used as an existence check: it
29 // read a stale number within a second of a change, and an id read is exact.
30
31 const crypto = require ( 'node:crypto' );
32 const fs = require ( 'node:fs/promises' );
33 const path = require ( 'node:path' );
34 const w = require ( './wix-writers.js' );
35 const paymentMapping = require ( './order-payment-mapping.js' );
36 const localState = require ( '../../../lib/local-state.js' );
37 const approvalStamp = require ( '../../../lib/preservation-approval-stamp.js' );
38 const orderBuyer = require ( './order-buyer.js' );
39 const customerMember = require ( './customer-member.js' );
40 const fsSync = require ( 'node:fs' );
41
42 const GUID = / ^ [0-9a-f] {8} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {12}$ / i ;
43
44 // A bounded retry for the TWO writes in this file that have earned one, and nowhere else. It is
45 // deliberately not on the shared client: bulk coupon, contact, member, form-submission, product
46 // and media creation are not reconciled against the target before writing, so a failure arriving
47 // AFTER the target committed would be retried into a second copy -- worse than the problem.
48 //
49 // Both sites below are reconciled first, so the second attempt is demonstrably the SAME attempt:
50 // the order id is derived from the source and the writer refuses a caller-chosen one, and the
51 // payment write is preceded by a read that decides create-or-adopt.
52 //
53 // `isTransientWixError` says the condition is likely to clear; whether a retry can duplicate
54 // anything is this call site's question, and only these two can answer it yes.
55 const WRITE_RETRY_ATTEMPTS = 3 ;
56 const WRITE_RETRY_DELAYS_MS = [ 500 , 1500 ];
57 const realSleep = ( ms ) => new Promise (( resolve ) => setTimeout (resolve, ms));
58
59 const ORDER_OUTCOMES = Object. freeze ({
60 IMPORTED: 'order-imported' ,
61 ALREADY_IMPORTED: 'order-already-imported' ,
62 IMPORT_FAILED: 'order-import-failed' ,
63 TARGET_READ_FAILED: 'order-target-read-failed' ,
64 });
65
66 // The evidence contract. A key outside this list is REFUSED, not ignored: an ignored key is how
67 // `sourceAmount` for `amount` reported every order in a live store as never paid, with no reason.
68 const EVIDENCE_KEYS = Object. freeze ([
69 'amount' , 'paidDate' , 'altPaidDate' , 'currency' , 'status' , 'distinctAmountCount' , 'utcOffsetMinutes' ,
70 'paidDateZone' , 'altPaidDateZone' ,
71 'methodName' , 'providerTransactionId' , 'offlinePayment' , 'cardDisplay' ,
72 'refunds' , 'invoice' ,
73 ]);
74 const REFUND_KEYS = Object. freeze ([ 'sourceId' , 'amount' , 'reason' ]);
75 const INVOICE_KEYS = Object. freeze ([ 'approval' , 'sourceUrl' , 'invoiceNumber' , 'pendingMediaFileId' ]);
76 // Names a caller is likely to reach for, and where each actually goes.
77 const MISNAMED = Object. freeze ({
78 sourceAmount: 'amount' , total: 'amount' , orderTotal: 'amount' ,
79 sourcePaidDate: 'paidDate' , datePaid: 'paidDate' , paid: 'paidDate' ,
80 // The two source stamps are NOT interchangeable and must not both land on `paidDate`: the
81 // `_gmt` sibling is universal by the source schema's definition of that key, the plain one is
82 // the shop's wall clock. Routing both here is how one shop offset came to be applied to a
83 // value that was already universal, moving the payment by the whole offset.
84 date_paid_gmt: "paidDate, declared paidDateZone: 'utc'" ,
85 date_paid: "altPaidDate, declared altPaidDateZone: 'site-local'" ,
86 sourceCurrency: 'currency' ,
87 targetCurrency: 'nothing here -- the target currency is read from order.currency' ,
88 transactionId: 'providerTransactionId' , transaction_id: 'providerTransactionId' , reference: 'providerTransactionId' ,
89 method: 'methodName' , paymentMethod: 'methodName' , payment_method: 'methodName' , payment_method_title: 'methodName' ,
90 sourceStatus: 'status' ,
91 refund: 'refunds (an array)' , invoices: 'invoice (one document per order)' ,
92 });
93
94 function isPlainObject ( value ) {
95 return Boolean (value) && typeof value === 'object' && ! Array. isArray (value);
96 }
97
98 function assertKeys ( object , allowed , label ) {
99 if ( ! isPlainObject (object)) throw new Error ( `${ label } must be an object, got ${ JSON . stringify ( object ) }` );
100 const unknown = Object. keys (object). filter (( key ) => ! allowed. includes (key));
101 if (unknown. length === 0 ) return ;
102 const hints = unknown. map (( key ) => ( MISNAMED [key] ? `${ key } (this contract calls it ${ MISNAMED [ key ] })` : key));
103 throw new Error (
104 `${ label } carries keys this contract does not read: ${ hints . join ( ', ' ) }. Accepted: ${ allowed . join ( ', ' ) }. `
105 + 'Unknown keys are refused rather than ignored, because an ignored key reads exactly like a source that evidences nothing.' ,
106 );
107 }
108
109 // Known keys with malformed VALUES are as silent as unknown keys: `offlinePayment: "false"` was
110 // dropped rather than read, a scalar `cardDisplay` lost the card evidence, and
111 // `distinctAmountCount: "unknown"` became NaN, which is "not more than one", which let a
112 // multi-payment order through the gate. Each is refused with the key and the value it got.
113 const EVIDENCE_TYPES = Object. freeze ({
114 amount : ( v ) => typeof v === 'string' || ( typeof v === 'number' && Number. isFinite (v)),
115 paidDate : ( v ) => typeof v === 'string' ,
116 altPaidDate : ( v ) => typeof v === 'string' ,
117 currency : ( v ) => typeof v === 'string' ,
118 status : ( v ) => typeof v === 'string' ,
119 distinctAmountCount : ( v ) => Number. isInteger (v) && v >= 1 ,
120 // UTC-14 to UTC+14 is every offset that exists; 999999 accepted here moved a payment two years.
121 utcOffsetMinutes : ( v ) => Number. isInteger (v) && v >= - 840 && v <= 840 ,
122 // A clock is a two-member enumeration, refused rather than coerced: 'UTC', true and 0 each
123 // read exactly like a correctly-declared clock once coerced, and would restore the defect
124 // these keys exist to remove -- silently, on a financial value.
125 paidDateZone : ( v ) => v === 'utc' || v === 'site-local' ,
126 altPaidDateZone : ( v ) => v === 'utc' || v === 'site-local' ,
127 methodName : ( v ) => typeof v === 'string' ,
128 providerTransactionId : ( v ) => typeof v === 'string' ,
129 offlinePayment : ( v ) => typeof v === 'boolean' ,
130 cardDisplay : ( v ) => isPlainObject (v)
131 && Object. keys (v). every (( k ) => k === 'lastFourDigits' || k === 'brand' )
132 && Object. values (v). every (( x ) => x === null || x === undefined || typeof x === 'string' ),
133 });
134 const EVIDENCE_TYPE_HINTS = Object. freeze ({
135 amount: 'a decimal string or finite number' ,
136 paidDate: 'an ISO date-time string' , altPaidDate: 'an ISO date-time string' ,
137 currency: 'an ISO 4217 code string' , status: 'a string' ,
138 distinctAmountCount: 'a positive integer' , utcOffsetMinutes: 'an integer between -840 and 840' ,
139 paidDateZone: "'utc' or 'site-local'" , altPaidDateZone: "'utc' or 'site-local'" ,
140 methodName: 'a string' , providerTransactionId: 'a string' ,
141 offlinePayment: 'a real boolean (not the string "false")' ,
142 cardDisplay: 'an object { lastFourDigits?, brand? } of strings' ,
143 });
144
145 function assertEvidence ( evidence , { sourceId = null } = {}) {
146 assertKeys (evidence, EVIDENCE_KEYS , 'evidence' );
147 // Internal: the source id the approval must name. Not a contract key -- set on a shallow copy.
148 evidence = { ... evidence, __sourceId: sourceId };
149 for ( const [ key , accepts ] of Object. entries ( EVIDENCE_TYPES )) {
150 const value = evidence[key];
151 if (value === undefined || value === null ) continue ;
152 if ( ! accepts (value)) {
153 throw new Error ( `evidence.${ key } must be ${ EVIDENCE_TYPE_HINTS [ key ] }, got ${ JSON . stringify ( value ) }` );
154 }
155 }
156 // Cross-key: a shop offset supplied beside a stamp whose clock nobody stated. Knowing the shop's
157 // offset is exactly the case where getting the clock wrong moves the money -- the offset is
158 // applied, so a stamp that was already universal lands hours out. The 'site-local' default exists
159 // to leave callers that never knew the offset untouched; it is not allowed to cover this shape.
160 // Per PRESENT stamp, not once per record: declaring one clock and leaving the other on the
161 // default reads as though the caller had been updated while still exposing a stamp the offset can
162 // move. Deliberately NOT widened to "offset present => declaration required": an unpaid order in
163 // a shop whose offset the run happens to know has no stamp to misapply it to.
164 if (evidence.utcOffsetMinutes !== undefined && evidence.utcOffsetMinutes !== null ) {
165 for ( const [ stamp , zoneKey ] of [[ 'paidDate' , 'paidDateZone' ], [ 'altPaidDate' , 'altPaidDateZone' ]]) {
166 if (evidence[stamp] === undefined || evidence[stamp] === null ) continue ;
167 if (evidence[zoneKey] !== undefined && evidence[zoneKey] !== null ) continue ;
168 throw new Error (
169 `evidence.${ stamp } needs ${ zoneKey } ('utc' or 'site-local') when utcOffsetMinutes is supplied; `
170 + 'without it the offset may be applied to a stamp that is already universal' ,
171 );
172 }
173 }
174 if (evidence.refunds !== undefined ) {
175 if ( ! Array. isArray (evidence.refunds)) throw new Error ( 'evidence.refunds must be an array (one entry per source refund)' );
176 evidence.refunds. forEach (( refund , index ) => {
177 assertKeys (refund, REFUND_KEYS , `evidence.refunds[${ index }]` );
178 if (refund.sourceId === undefined || refund.sourceId === null || String (refund.sourceId). trim () === '' ) {
179 throw new Error ( `evidence.refunds[${ index }] needs a sourceId; occurrence numbers are assigned by it and a re-run recognizes its own refunds through it` );
180 }
181 // A refund with no positive amount would reach Refund Payments as `amount: "undefined"` --
182 // after the payment create it hangs off. Refused here, before any target call.
183 const amount = paymentMapping. canonicalizeDecimal (refund.amount);
184 if (amount === null || ! ( Number (amount) > 0 )) {
185 throw new Error ( `evidence.refunds[${ index }] needs a positive amount, got ${ JSON . stringify ( refund . amount ) }` );
186 }
187 });
188 }
189 if (evidence.invoice !== undefined && evidence.invoice !== null ) {
190 const invoice = evidence.invoice;
191 assertKeys (invoice, INVOICE_KEYS , 'evidence.invoice' );
192 // The approval must be the PLANNER's. The writer only insists on the stamp where an opt-in is
193 // relied on; this composite insists everywhere, because a hand-built reference-only approval
194 // with an object for a number wrote "[object Object]" onto an order in review. The plan is the
195 // one producer, and it stamps everything it issues.
196 if ( ! isPlainObject (invoice.approval) || ! approvalStamp. verifyApprovalStamp (invoice.approval)) {
197 throw new Error ( 'evidence.invoice.approval must be an approval issued by resolvePreservationPlan() (it carries the plan \' s stamp); pass the plan \' s object, do not rebuild it' );
198 }
199 // The planner's approvals name their order, and the stamp covers that name. One handed over
200 // for a different source order would preserve somebody else's document onto this one (review
201 // demonstrated it), and one with the order DELETED after stamping would fit any order (review
202 // demonstrated that too) -- so the order is required, not merely compared when present.
203 if (invoice.approval.orderId === undefined || invoice.approval.orderId === null || String (invoice.approval.orderId). trim () === '' ) {
204 throw new Error ( 'evidence.invoice.approval names no source order (orderId); resolvePreservationPlan() issues approvals per order, and one without an order could be handed to any' );
205 }
206 if ( String (invoice.approval.orderId) !== String (evidence.__sourceId)) {
207 throw new Error ( `evidence.invoice.approval is for source order ${ invoice . approval . orderId }, not ${ evidence . __sourceId }` );
208 }
209 const optional = ( key , accepts , hint ) => {
210 const v = invoice[key];
211 if (v === undefined || v === null ) return ;
212 if ( ! accepts (v)) throw new Error ( `evidence.invoice.${ key } must be ${ hint }, got ${ JSON . stringify ( v ) }` );
213 };
214 optional ( 'sourceUrl' , ( v ) => typeof v === 'string' , 'a URL string' );
215 optional ( 'invoiceNumber' , ( v ) => typeof v === 'string' || typeof v === 'number' , 'a string or number' );
216 optional ( 'pendingMediaFileId' , ( v ) => typeof v === 'string' && v. trim () !== '' , 'a Media file id string' );
217 }
218 }
219
220 // A stable GUID for a source order on this target. Version-5 style: a SHA-1 over a namespaced
221 // name with the version and variant bits set, which is what Import Order accepted live. The
222 // source SITE is part of the name so two stores importing "order 1042" into one Wix site cannot
223 // collide -- and so a migration re-run from the same store converges on the same order.
224 // `shop.example`, `https://shop.example/`, `SHOP.EXAMPLE:443` and `shop.example/wp` are one site.
225 // Four spellings deriving four ids would re-import a store the day a config string gained a
226 // scheme, so the host is normalized to its bare lowercase hostname (a non-default port is kept).
227 function normalizeSourceSiteHost ( sourceSiteHost ) {
228 let text = String (sourceSiteHost === undefined || sourceSiteHost === null ? '' : sourceSiteHost). trim (). toLowerCase ();
229 if ( ! text) return '' ;
230 if ( ! / ^ [a-z][a-z0-9+.-] * : \/\/ / . test (text)) text = `https://${ text }` ;
231 let url;
232 try { url = new URL (text); } catch (error) { return '' ; }
233 return url.port && url.port !== '443' ? `${ url . hostname }:${ url . port }` : url.hostname;
234 }
235
236 function deriveImportedOrderId ({ sourceSiteHost , sourceId } = {}) {
237 const host = normalizeSourceSiteHost (sourceSiteHost);
238 const id = sourceId === undefined || sourceId === null ? '' : String (sourceId). trim ();
239 if ( ! host) throw new Error ( 'deriveImportedOrderId needs sourceSiteHost (a hostname, or a URL it can be read from): the id is namespaced by the source site' );
240 if ( ! id) throw new Error ( 'deriveImportedOrderId needs the source order id' );
241 const hash = crypto. createHash ( 'sha1' ). update ( `replatform:ecom:order:${ host }:${ id }` ). digest ();
242 hash[ 6 ] = (hash[ 6 ] & 0x0f ) | 0x50 ;
243 hash[ 8 ] = (hash[ 8 ] & 0x3f ) | 0x80 ;
244 const hex = hash. toString ( 'hex' );
245 return `${ hex . slice ( 0 , 8 ) }-${ hex . slice ( 8 , 12 ) }-${ hex . slice ( 12 , 16 ) }-${ hex . slice ( 16 , 20 ) }-${ hex . slice ( 20 , 32 ) }` ;
246 }
247
248 // Same rule as rp-source-wordpress's assignRefundOccurrences, kept target-side so this module does
249 // not depend on a source adapter: two source refunds of equal amount on one order are the first
250 // and second OCCURRENCE of that amount, ordered by source id, and reconcile as such. Pass ALL of an
251 // order's refunds in one call -- occurrences are relative to the list.
252 function withOccurrences ( refunds ) {
253 const seen = new Map ();
254 return [ ... refunds]
255 . sort (( a , b ) => String (a.sourceId). localeCompare ( String (b.sourceId)))
256 . map (( refund ) => {
257 const key = paymentMapping. canonicalizeDecimal (refund.amount) || String (refund.amount);
258 const occurrence = (seen. get (key) || 0 ) + 1 ;
259 seen. set (key, occurrence);
260 return { ... refund, sourceOccurrence: occurrence };
261 });
262 }
263
264 function describeError ( error ) {
265 return {
266 message: String ((error && error.message) || error). slice ( 0 , 300 ),
267 ... (error && error.status ? { status: error.status } : {}),
268 // Conditional, like the rest: an unknown outcome is the exceptional case, and making this
269 // unconditional would change the shape of every row already written to a results ledger.
270 // Without it, the rejected-vs-unknown distinction is lost the moment it is recorded.
271 ... (error && error.outcomeKnown === false ? { outcomeKnown: false } : {}),
272 ... (error && error.applicationErrorCode ? { applicationErrorCode: error.applicationErrorCode } : {}),
273 // A refusal before the network (readiness, raw-request guard) wrote nothing; kept so the
274 // journal row can say retryable rather than guess from a missing status.
275 ... (error && error.requestSent === false ? { requestSent: false } : {}),
276 ... (error && typeof error.code === 'string' && error.code ? { code: error.code } : {}),
277 };
278 }
279
280 function assertCrosswalk ( crosswalk ) {
281 const has = ( name ) => crosswalk && typeof crosswalk[name] === 'function' ;
282 if ( ! has ( 'get' ) || ! has ( 'set' ) || ! has ( 'getInvoiceFile' ) || ! has ( 'setInvoiceFile' )) {
283 throw new Error (
284 'importOrderHistory needs a persisted crosswalk with get(sourceId), set(sourceId, orderId), '
285 + 'getInvoiceFile(sourceId) and setInvoiceFile(sourceId, fileId). It is not optional: a run that '
286 + 'forgets it re-imports the store, and one that forgets the invoice file imports a second copy of a '
287 + 'tax document. createProjectOrderCrosswalk({ projectDir }) provides all four over state/crosswalk/.' ,
288 );
289 }
290 }
291
292 // Fill every remaining stage with a named outcome once the order stage has failed. Nothing else
293 // is attempted: with no order there is nothing to attach a payment or a document to, and a payment
294 // that was never attempted because of an import failure is a FAILURE, not an ineligibility.
295 function failRemaining ( outcomes , errors , { gate , refunds , evidence , cause }) {
296 outcomes.payment = gate.eligible ? 'payment-failed' : gate.outcome;
297 if (gate.eligible) errors.payment = { cause };
298 outcomes.refunds = refunds. map (( refund ) => ({
299 sourceId: refund.sourceId,
300 sourceOccurrence: refund.sourceOccurrence,
301 outcome: gate.eligible ? 'refund-failed' : 'refund-without-payment' ,
302 ... (gate.eligible ? { error: { cause } } : {}),
303 }));
304 outcomes.invoice = evidence.invoice ? 'invoice-failed' : 'invoice-none-found' ;
305 if (evidence.invoice) errors.invoice = { cause };
306 }
307
308 // ---- contact fields --------------------------
309 // A source email or phone Import Order refuses fails the WHOLE order. The policy: a
310 // contact detail is removed only when Import Order's own rejection NAMES that field, and the
311 // order is then sent once more without it. Never removed on a local or customer-path check
312 // alone, never rewritten, never replaced with an invented value. Every removal is a named
313 // finding carrying the field and a reason, never the value.
314 //
315 // The field list is READ from `domains/ecom/entities/order.json` -> `contactFields[]`, so the
316 // rules and the entity knowledge cannot drift apart.
317 const ORDER_ENTITY_JSON = path. join (__dirname, '..' , 'domains' , 'ecom' , 'entities' , 'order.json' );
318 let CONTACT_FIELDS = null ;
319 function orderContactFields () {
320 if ( CONTACT_FIELDS ) return CONTACT_FIELDS ;
321 const entity = JSON . parse (fsSync. readFileSync ( ORDER_ENTITY_JSON , 'utf8' ));
322 const fields = Array. isArray (entity.contactFields) ? entity.contactFields : [];
323 if (fields. length === 0 ) throw new Error ( `${ ORDER_ENTITY_JSON } carries no contactFields[]; the contact-field rules read their list from there and will not guess one` );
324 for ( const field of fields) {
325 if (field.kind !== 'email' && field.kind !== 'phone' ) {
326 throw new Error ( `contactFields[] entry ${ field . targetPath } has kind ${ JSON . stringify ( field . kind ) }; only "email" and "phone" are known` );
327 }
328 }
329 CONTACT_FIELDS = Object. freeze (fields. map (( field ) => Object. freeze ({ kind: String (field.kind), targetPath: String (field.targetPath) })));
330 return CONTACT_FIELDS ;
331 }
332
333 function readAtPath ( object , dotted ) {
334 let node = object;
335 for ( const key of dotted. split ( '.' )) {
336 if ( ! isPlainObject (node)) return undefined ;
337 node = node[key];
338 }
339 return node;
340 }
341
342 // Copy-on-write down the path only; the caller's object is never mutated.
343 function withoutPath ( object , dotted ) {
344 const [ head , ... rest ] = dotted. split ( '.' );
345 if ( ! isPlainObject (object) || ! (head in object)) return object;
346 if (rest. length === 0 ) {
347 const next = { ... object };
348 delete next[head];
349 return next;
350 }
351 const child = withoutPath (object[head], rest. join ( '.' ));
352 return child === object[head] ? object : { ... object, [head]: child };
353 }
354
355 function presentValue ( value ) {
356 if (value === undefined || value === null ) return null ;
357 const text = String (value). trim ();
358 return text === '' ? null : text;
359 }
360
361 // Advisory only. Run on the SOURCE-DERIVED body, before the first send. The body is never
362 // changed; a suspect is a forecast, not a loss.
363 function checkOrderContactFields ( order , { sourceId = null } = {}) {
364 if ( ! isPlainObject (order)) throw new Error ( 'checkOrderContactFields needs the mapped target order body as `order`' );
365 const findings = [];
366 const id = sourceId === undefined || sourceId === null ? null : String (sourceId);
367 const countryCode = presentValue ( readAtPath (order, 'billingInfo.address.country' ));
368 for ( const field of orderContactFields ()) {
369 const value = presentValue ( readAtPath (order, field.targetPath));
370 if (value === null ) continue ; // an absent detail is not a defect
371 if (field.kind === 'email' ) {
372 if (orderBuyer. normalizeEmail (value) === null ) {
373 findings. push ({ code: 'order-contact-email-suspect' , ... (id ? { sourceId: id } : {}), field: field.targetPath, reason: 'not-a-sendable-address' });
374 }
375 } else {
376 const normalized = customerMember. normalizePhoneE164 (value, countryCode);
377 if ( ! normalized.ok) {
378 findings. push ({ code: 'order-contact-phone-suspect' , ... (id ? { sourceId: id } : {}), field: field.targetPath, reason: String (normalized.reason) });
379 }
380 }
381 }
382 return { findings };
383 }
384
385 // Which contact fields did Import Order's rejection NAME? Only a 400 with a structured
386 // `fieldViolations[]` counts, and only an EXACT path match (after an optional leading `order.`
387 // request wrapper) against `contactFields[]`. Message text is never parsed. All or
388 // nothing: one violation that is not an identified contact field means no removal (null).
389 function identifyRejectedContactFields ( error ) {
390 if ( ! error || error.status !== 400 ) return null ;
391 const violations = error.body && error.body.details && error.body.details.validationError
392 && error.body.details.validationError.fieldViolations;
393 if ( ! Array. isArray (violations) || violations. length === 0 ) return null ;
394 const known = new Set ( orderContactFields (). map (( field ) => field.targetPath));
395 const paths = [];
396 for ( const violation of violations) {
397 const raw = violation && typeof violation.field === 'string' ? violation.field : null ;
398 if (raw === null ) return null ;
399 const field = raw. startsWith ( 'order.' ) ? raw. slice ( 'order.' . length ) : raw;
400 if ( ! known. has (field)) return null ;
401 if ( ! paths. includes (field)) paths. push (field);
402 }
403 return paths;
404 }
405
406 // ---- source orders with no line items -------------------------------------
407 // Import Order requires 1-300 lineItems. A source order that recorded none (items deleted at the
408 // source, a total with no lines) would fail terminally. Human-approved policy (2026-11-01):
409 // import it with ONE custom line "Items not recorded in source" priced at the
410 // order total, in the order's own currency, with no tax information invented, and hand each such
411 // order to cutover. Financial data, so this is a human-approved policy, not an engineering default.
412 const PLACEHOLDER_LINE_NAME = 'Items not recorded in source' ;
413 const PLACEHOLDER_LINE_ITEM_TYPE = 'not-recorded-in-source' ;
414 const PLACEHOLDER_GAP_CODE = 'order-line-items-not-recorded' ;
415 const PLACEHOLDER_GAP_ACTION = 'Each of these orders recorded no line items at the source, so it was imported with one custom line "Items not recorded in source" at the order total (no tax). Confirm the order total with the merchant; if the original items are known, note them on the order. Do not delete or re-import the order.' ;
416 function placeholderLineId ( derivedId ) {
417 const hash = crypto. createHash ( 'sha1' ). update ( `replatform:ecom:order-line:${ derivedId }:not-recorded` ). digest ();
418 hash[ 6 ] = (hash[ 6 ] & 0x0f ) | 0x50 ;
419 hash[ 8 ] = (hash[ 8 ] & 0x3f ) | 0x80 ;
420 const hex = hash. toString ( 'hex' );
421 return `${ hex . slice ( 0 , 8 ) }-${ hex . slice ( 8 , 12 ) }-${ hex . slice ( 12 , 16 ) }-${ hex . slice ( 16 , 20 ) }-${ hex . slice ( 20 , 32 ) }` ;
422 }
423 function placeholderAmount ( order ) {
424 const raw = order && order.priceSummary && order.priceSummary.total && order.priceSummary.total.amount;
425 const n = Number (raw);
426 return raw !== undefined && raw !== null && String (raw). trim () !== '' && Number. isFinite (n) && n >= 0 ? String (raw). trim () : null ;
427 }
428 // An order with lineItems is returned unchanged. Pure: the caller's body is never mutated.
429 function withPlaceholderLineItem ( order , { derivedId }) {
430 if ( ! isPlainObject (order) || (Array. isArray (order.lineItems) && order.lineItems. length > 0 )) return { order, placeholder: null };
431 // No usable order total: the line is priced at 0 and the finding says so (totalMissing).
432 const total = placeholderAmount (order), amount = total === null ? '0' : total;
433 const line = {
434 id: placeholderLineId (derivedId),
435 quantity: 1 ,
436 productName: { original: PLACEHOLDER_LINE_NAME },
437 itemType: { custom: PLACEHOLDER_LINE_ITEM_TYPE },
438 price: { amount },
439 lineItemPrice: { amount },
440 };
441 return { order: { ... order, lineItems: [line] }, placeholder: { lineItemId: line.id, amount, currency: order.currency === undefined ? null : order.currency, ... (total === null ? { totalMissing: true } : {}) } };
442 }
443
444 // The composite. Returns outcomes; throws only for CALLER errors (a malformed contract), never for
445 // a target failure -- each stage's failure is a named outcome so a resumed run reconciles what
446 // landed and retries only what did not.
447 async function importOrderHistory ( wix , {
448 order ,
449 sourceId ,
450 sourceSiteHost ,
451 evidence ,
452 crosswalk ,
453 setupVerification = null ,
454 pacer = null ,
455 // Injectable only so the retry tests do not spend the delay ladder in real time.
456 sleep = null ,
457 // The buyer, from resolveOrderBuyer -- stamped, so a hand-built memberId cannot reach
458 // the order. Optional: an order with no buyer resolution imports with whatever contactId the
459 // mapped body already carries, and never a memberId.
460 buyer = null ,
461 } = {}) {
462 if ( ! wix || typeof wix.send !== 'function' ) throw new Error ( 'importOrderHistory needs a Wix client' );
463 const sleepFor = sleep || realSleep;
464 const findings_pre = [];
465 if ( ! isPlainObject (order)) throw new Error ( 'importOrderHistory needs the mapped target order body as `order`' );
466 assertCrosswalk (crosswalk);
467 assertEvidence (evidence, { sourceId });
468 const derivedId = deriveImportedOrderId ({ sourceSiteHost, sourceId });
469 if (order.id !== undefined && order.id !== derivedId) {
470 throw new Error ( `order.id is not the caller's to choose: got ${ order . id }, this order derives to ${ derivedId }. Omit it.` );
471 }
472 // buyerInfo.memberId is what member-facing reads authorize on, and Import Order stores any GUID
473 // unvalidated (verified live 2026-09-05). So it is never the caller's to set on the body: it comes
474 // through `buyer`, corroborated and stamped by resolveOrderBuyer, or not at all.
475 orderBuyer. assertBodyBuyerInfo (order);
476 // No buyer resolution is itself a counted outcome, so the buyer stage partitions the order
477 // population like the payment stage does.
478 let buyerOutcome = 'buyer-not-resolved' ;
479 if (buyer !== null && buyer !== undefined ) {
480 if ( ! orderBuyer. verifyBuyerStamp (buyer)) throw new Error ( '`buyer` must be the unchanged result of resolveOrderBuyer (its stamp did not verify)' );
481 // The resolver corroborated the member against ONE order's billing email, and the stamp covers
482 // that email. The BODY's email must be the same one: a result resolved for jane@ handed over
483 // beside a body carrying victim@ would attribute the victim's order and invoice to Jane, with
484 // the stamp verifying (review demonstrated it). Bound for every outcome -- a resolution made for
485 // a different order is wrong whatever it decided -- and a linked result requires the body to
486 // carry the email at all, so the two are never merely "both absent".
487 const rawBodyEmail = order.buyerInfo && order.buyerInfo.email;
488 const bodyEmail = orderBuyer. normalizeEmail (rawBodyEmail);
489 const bodyHasEmail = rawBodyEmail !== undefined && rawBodyEmail !== null && String (rawBodyEmail). trim () !== '' ;
490 if (buyer.email && bodyHasEmail && bodyEmail !== buyer.email) {
491 throw new Error ( '`buyer` was resolved for a different billing email than order.buyerInfo.email carries; resolve the buyer from THIS order \' s billing email' );
492 }
493 if (buyer.memberId && ( ! bodyHasEmail || bodyEmail !== buyer.email)) {
494 throw new Error ( 'a linked `buyer` requires order.buyerInfo.email to be the billing email the member was corroborated against; the body carries none or another' );
495 }
496 const info = orderBuyer. buyerInfoFrom (buyer);
497 // Replace, not merge: only the body's email survives beside what the resolver decided.
498 const kept = order.buyerInfo && order.buyerInfo.email ? { email: order.buyerInfo.email } : {};
499 order = { ... order, buyerInfo: { ... kept, ... (order.buyerInfo && order.buyerInfo.contactId && ! info.contactId ? { contactId: order.buyerInfo.contactId } : {}), ... info } };
500 buyerOutcome = buyer.outcome;
501 for ( const finding of buyer.findings || []) findings_pre. push (finding);
502 }
503
504 const findings = findings_pre;
505 const errors = {};
506 const placed = withPlaceholderLineItem (order, { derivedId });
507 order = placed.order;
508 if (placed.placeholder) findings. push ({ code: PLACEHOLDER_GAP_CODE , sourceId: String (sourceId), ... placed.placeholder });
509 const outcomes = { order: null , payment: null , refunds: [], invoice: null , buyer: buyerOutcome };
510
511 // The gate runs FIRST and from the evidence structure, so a caller cannot mis-name a parameter
512 // into a silent zero. It is pure: no target read is spent on an order the source never paid.
513 const gate = paymentMapping. evaluatePaymentEligibility ({
514 sourceAmount: evidence.amount === undefined ? null : evidence.amount,
515 paidDate: evidence.paidDate === undefined ? null : evidence.paidDate,
516 altPaidDate: evidence.altPaidDate === undefined ? null : evidence.altPaidDate,
517 distinctAmountCount: evidence.distinctAmountCount === undefined ? 1 : evidence.distinctAmountCount,
518 sourceCurrency: evidence.currency === undefined ? null : evidence.currency,
519 targetCurrency: order.currency === undefined ? null : order.currency,
520 utcOffsetMinutes: evidence.utcOffsetMinutes === undefined ? null : evidence.utcOffsetMinutes,
521 paidDateZone: evidence.paidDateZone === undefined || evidence.paidDateZone === null ? 'site-local' : evidence.paidDateZone,
522 altPaidDateZone: evidence.altPaidDateZone === undefined || evidence.altPaidDateZone === null ? 'site-local' : evidence.altPaidDateZone,
523 sourceStatus: evidence.status === undefined ? null : evidence.status,
524 });
525 findings. push ( ... (gate.findings || []));
526
527 let payment = null ;
528 if (gate.eligible) {
529 const built = paymentMapping. buildPaymentRecord ({
530 amount: gate.amount,
531 createdDate: gate.createdDate,
532 methodName: evidence.methodName === undefined ? null : evidence.methodName,
533 providerTransactionId: evidence.providerTransactionId === undefined ? null : evidence.providerTransactionId,
534 offlinePayment: evidence.offlinePayment,
535 cardDisplay: evidence.cardDisplay === undefined ? null : evidence.cardDisplay,
536 });
537 payment = built.payment;
538 findings. push ( ... (built.findings || []));
539 }
540 const refunds = withOccurrences (evidence.refunds || []);
541
542 // ---- 1. The order. Crosswalk hit or miss, the TARGET is asked once by id. -------------------
543 // A hit is not proof: the docs' own use of Bulk Delete Imported Orders is "clear a faulty
544 // migration before re-importing", and a crosswalk that survived that must not report an order
545 // the target no longer has. Only a readable 404 proves absence; an unreadable gateway
546 // response must not authorize Import Order, which can replace an existing order.
547 let orderId = derivedId;
548 const readPacer = pacer || w. sharedTargetReadPacer (wix);
549 const existsOnTarget = async ( id ) => {
550 const read = await readPacer. read (() => w. getOrder (wix, id));
551 if (read.ok) return { exists: true };
552 if (read.error && read.error.status === 404 && read.error.outcomeKnown !== false ) return { exists: false };
553 return { exists: null , error: read.error };
554 };
555 const known = await crosswalk. get (sourceId);
556 // The crosswalk's answer is trusted as the order's identity, so its SHAPE is not: a row object
557 // where a string was expected became `orders/[object Object]/add-payment` in review. Only a
558 // GUID-shaped string may stand for an order; anything else is a broken crosswalk, which is a
559 // caller problem and stops the run rather than continuing history onto a nonsense id.
560 if (known !== null && known !== undefined && known !== '' && ! GUID . test ( String (known))) {
561 throw new Error ( `the crosswalk returned ${ JSON . stringify ( known ) } for source order ${ sourceId }; a crosswalk value must be the Wix order id (a GUID string) or null` );
562 }
563 // A row from before deterministic ids (a Wix-minted id) is still THE order: honouring it is what
564 // keeps an earlier migration from being re-imported under a derived id.
565 const candidateId = known ? String (known) : derivedId;
566 const probe = await existsOnTarget (candidateId);
567 if (probe.exists === null ) {
568 // Cannot tell whether the order is there. Importing now could replace a merchant-edited order;
569 // not importing loses nothing a retry cannot recover. So: a named outcome, no write.
570 outcomes.order = ORDER_OUTCOMES . TARGET_READ_FAILED ;
571 errors.order = describeError (probe.error);
572 failRemaining (outcomes, errors, { gate, refunds, evidence, cause: 'order-not-imported' });
573 return { orderId: null , derivedId, outcomes, findings, errors, paymentId: null , invoice: null };
574 }
575 if (probe.exists) {
576 orderId = candidateId;
577 if (known && orderId !== derivedId) findings. push ({ code: 'order-id-from-crosswalk' , sourceId: String (sourceId), orderId });
578 outcomes.order = ORDER_OUTCOMES . ALREADY_IMPORTED ;
579 if ( ! known) {
580 // The crosswalk had lost it; adopt it.
581 try { await crosswalk. set (sourceId, orderId); } catch (error) { errors.crosswalk = describeError (error); }
582 }
583 } else {
584 let derivedExists = false ;
585 if (known) {
586 // The crosswalk points at an order the target no longer has -- a cleared migration, or a
587 // stale row. Before importing under the derived id, ask whether THAT order already exists:
588 // Import Order with an existing id silently replaces it, and a merchant's edits with it
589 // (review demonstrated exactly that). If it exists, adopt it and replace the row.
590 const derivedProbe = await existsOnTarget (derivedId);
591 if (derivedProbe.exists === null ) {
592 outcomes.order = ORDER_OUTCOMES . TARGET_READ_FAILED ;
593 errors.order = describeError (derivedProbe.error);
594 failRemaining (outcomes, errors, { gate, refunds, evidence, cause: 'order-not-imported' });
595 return { orderId: null , derivedId, outcomes, findings, errors, paymentId: null , invoice: null };
596 }
597 derivedExists = derivedProbe.exists === true ;
598 findings. push (derivedExists
599 ? { code: 'order-crosswalk-row-stale' , sourceId: String (sourceId), previousOrderId: String (known), orderId: derivedId }
600 : { code: 'order-reimported-after-deletion' , sourceId: String (sourceId), previousOrderId: String (known) });
601 }
602 if (derivedExists) {
603 orderId = derivedId;
604 outcomes.order = ORDER_OUTCOMES . ALREADY_IMPORTED ;
605 } else {
606 try {
607 // Retried on a transient failure, and safe to: the id is derived from the source and
608 // `:296` refuses a caller-chosen one, so a second attempt re-sends the same identity. A
609 // retry that comes back already-imported is a SUCCESS -- the existing ALREADY_IMPORTED
610 // outcome covers it. Only the send is wrapped: the checks below it are permanent
611 // refusals, not conditions that clear.
612 const send = async ( body ) => {
613 for ( let attempt = 1 ; ; attempt += 1 ) {
614 try {
615 return await w. importOrder (wix, { ... body, id: derivedId });
616 } catch (error) {
617 if (wixAuth. isRunInterruption (error)) throw error;
618 if (attempt >= WRITE_RETRY_ATTEMPTS || ! w. isTransientWixError (error)) throw error;
619 await sleepFor ( WRITE_RETRY_DELAYS_MS [attempt - 1 ]);
620 }
621 }
622 };
623 // Advisory, on the source-derived body, once, before the first send.
624 // Not repeated for the retry below.
625 findings. push ( ... checkOrderContactFields (order, { sourceId }).findings);
626 let imported = null ;
627 let removed = [];
628 try {
629 imported = await send (order);
630 } catch (error) {
631 if (wixAuth. isRunInterruption (error)) throw error;
632 // One retry, only when the rejection names contact fields the body
633 // actually carries. The first call was refused and created nothing, so the retry under
634 // the same derived id is not a duplicate. The retry body is the SOURCE-DERIVED order
635 // minus exactly those paths, and on a
636 // member-linked order it keeps `buyerInfo.memberId`/`contactId` untouched.
637 const paths = identifyRejectedContactFields (error);
638 if ( ! paths || ! paths. every (( p ) => readAtPath (order, p) !== undefined )) throw error;
639 const retryBody = paths. reduce (( body , p ) => withoutPath (body, p), order);
640 imported = await send (retryBody);
641 removed = paths;
642 }
643 if ( ! imported || ! imported.id) {
644 throw new Error ( 'Import Order returned no order id' );
645 }
646 if (imported.id !== derivedId && ! GUID . test ( String (imported.id))) {
647 // Cannot be recorded (the crosswalk accepts only GUIDs) and cannot be trusted. Reported
648 // with the id so a human can find the order by number; the run does not continue history
649 // onto it.
650 throw new Error ( `Import Order returned a non-GUID id ${ JSON . stringify ( imported . id ) } for source order ${ sourceId } (number ${ order . number }); the order may exist on the target under it` );
651 }
652 if (imported.id !== derivedId) {
653 // The order EXISTS, under an id we did not choose. Reporting a failure here would leave
654 // no crosswalk row, and every re-run would import it again -- the one outcome this
655 // module exists to prevent. So the target's id is adopted and recorded, the order counts
656 // as imported, and the finding says the identity model did not hold on this target.
657 orderId = String (imported.id);
658 findings. push ({ code: 'order-id-not-derived' , sourceId: String (sourceId), orderId, derivedId });
659 }
660 outcomes.order = ORDER_OUTCOMES . IMPORTED ;
661 // One drop finding per removed path, only once the order landed without it. Field and
662 // reason, never the value, the member id or the rejection's wording.
663 const kinds = new Map ( orderContactFields (). map (( field ) => [field.targetPath, field.kind]));
664 for ( const field of removed) {
665 findings. push ({
666 code: kinds. get (field) === 'email' ? 'order-contact-email-dropped' : 'order-contact-phone-dropped' ,
667 sourceId: String (sourceId),
668 field,
669 reason: 'import-order-rejected-field' ,
670 ... (field === 'buyerInfo.email' && buyerOutcome === orderBuyer. OUTCOMES . LINKED ? { memberLinkKept: true } : {}),
671 });
672 }
673 } catch (error) {
674 if (wixAuth. isRunInterruption (error)) throw error;
675 outcomes.order = ORDER_OUTCOMES . IMPORT_FAILED ;
676 errors.order = describeError (error);
677 failRemaining (outcomes, errors, { gate, refunds, evidence, cause: 'order-not-imported' });
678 return { orderId: null , derivedId, outcomes, findings, errors, paymentId: null , invoice: null };
679 }
680 }
681 // Persisted BEFORE any further write. The one thing nothing else catches. If the write
682 // itself fails the order is still on the target and the next run finds it by derived id, so
683 // the run continues -- but the caller is told, because a crosswalk that silently stopped
684 // persisting is a report that undercounts what landed.
685 try {
686 await crosswalk. set (sourceId, orderId);
687 } catch (error) {
688 errors.crosswalk = describeError (error);
689 }
690 }
691
692 // A landed order that carries the placeholder line is a cutover item, never only a finding.
693 if (placed.placeholder && orderId && [ ORDER_OUTCOMES . IMPORTED , ORDER_OUTCOMES . ALREADY_IMPORTED ]. includes (outcomes.order)) {
694 try {
695 await w. recordWrittenGap (wix, { code: PLACEHOLDER_GAP_CODE , action: PLACEHOLDER_GAP_ACTION , operatorRole: 'Cutover Operator' , severity: 'medium' ,
696 detail: { targetId: orderId, orderNumber: order.number === undefined ? null : String (order.number), amount: placed.placeholder.amount, currency: placed.placeholder.currency, ... (placed.placeholder.totalMissing ? { totalMissing: true } : {}) } });
697 } catch (error) { if (wixAuth. isRunInterruption (error)) throw error; errors.cutoverItem = describeError (error); }
698 }
699
700 // ---- 2. Payment and refunds, from ONE reconciliation read. ---------------------------------
701 let paymentId = null ;
702 if ( ! gate.eligible) {
703 outcomes.payment = gate.outcome;
704 outcomes.refunds = refunds. map (( refund ) => ({ sourceId: refund.sourceId, sourceOccurrence: refund.sourceOccurrence, outcome: 'refund-without-payment' }));
705 } else {
706 // The read and the write are retried as ONE pair or not at all. The read decides create-or-
707 // adopt, and a failed write attempt is exactly what can make that decision stale -- the
708 // attempt that failed may be what created the payment. Retrying the write alone would re-use
709 // the pre-failure decision and add a second payment. `assertReconciledBinding`
710 // (`wix-writers.js:3729`) refuses a read taken for a different order or a different payment
711 // fingerprint, but it cannot detect a stale read for the right one.
712 //
713 // A `payment-target-read-failed` reconcile does NOT enter this loop: the pacer already
714 // retried it internally and `writeOrderPayment` returns that outcome rather than throwing.
715 let reconciled = await w. reconcileOrderPaymentTarget (wix, { orderId, payment, pacer });
716 let written = null ;
717 try {
718 for ( let attempt = 1 ; ; attempt += 1 ) {
719 try {
720 written = await w. writeOrderPayment (wix, { orderId, payment, pacer, reconciled });
721 break ;
722 } catch (error) {
723 if (wixAuth. isRunInterruption (error)) throw error;
724 if (attempt >= WRITE_RETRY_ATTEMPTS || ! w. isTransientWixError (error)) throw error;
725 await sleepFor ( WRITE_RETRY_DELAYS_MS [attempt - 1 ]);
726 // Re-read before re-writing. Coming back RECONCILED means the failed attempt committed
727 // after all, and the writer turns that into `payment-reconciled-existing` with no
728 // second write -- which is the outcome, not a failure.
729 reconciled = await w. reconcileOrderPaymentTarget (wix, { orderId, payment, pacer });
730 }
731 }
732 outcomes.payment = written.outcome;
733 paymentId = written.paymentId || null ;
734 if (written.error) errors.payment = describeError (written.error);
735 // "Written" with no id is not written: the refund path would see CREATE still standing and
736 // add the payment a second time. Demonstrated in review with an empty `paymentsIds`.
737 if (outcomes.payment === 'payment-written' && ! paymentId) {
738 outcomes.payment = 'payment-failed' ;
739 errors.payment = { cause: 'add-payment-returned-no-id' };
740 }
741 } catch (error) {
742 if (wixAuth. isRunInterruption (error)) throw error;
743 outcomes.payment = 'payment-failed' ;
744 errors.payment = describeError (error);
745 }
746 // No payment id means no payment to hang anything off, whatever the outcome's name: ambiguous
747 // reconciliation carries no error object from the writer, so the cause is recorded here.
748 if ( ! paymentId && ! errors.payment) errors.payment = { cause: outcomes.payment };
749 // The refund path sees the same read, updated with the payment this run just created so it
750 // does not create a second one. A payment stage that failed leaves nothing to refund against.
751 const forRefunds = paymentId && reconciled.outcome === paymentMapping. RECONCILE . CREATE
752 ? { ... reconciled, outcome: paymentMapping. RECONCILE . RECONCILED , paymentId, matchedBy: 'written-this-run' }
753 : reconciled;
754 for ( const refund of refunds) {
755 const line = { sourceId: refund.sourceId, sourceOccurrence: refund.sourceOccurrence, outcome: null , refundId: null , paymentId: null };
756 if ( ! paymentId) {
757 // Refunds are REFUND outcomes. Letting the writer answer here returned the payment stage's
758 // own code as the refund's, which the report counts as a payment bucket and never as a
759 // refund that did not land.
760 line.outcome = 'refund-failed' ;
761 line.error = { cause: outcomes.payment };
762 } else {
763 try {
764 const result = await w. ensureOrderPaymentAndRefund (wix, {
765 orderId,
766 payment,
767 refundAmount: refund.amount,
768 reason: refund.reason,
769 sourceRefundId: refund.sourceId,
770 sourceOccurrence: refund.sourceOccurrence,
771 pacer,
772 reconciled: forRefunds,
773 });
774 line.outcome = result.outcome;
775 line.refundId = result.refundId || (result.refund && result.refund.id) || null ;
776 line.paymentId = result.paymentId || null ;
777 } catch (error) {
778 if (wixAuth. isRunInterruption (error)) throw error;
779 line.outcome = 'refund-failed' ;
780 line.error = describeError (error);
781 }
782 }
783 outcomes.refunds. push (line);
784 }
785 }
786
787 // ---- 3. The invoice document. ---------------------------------------------------------------
788 let invoice = null ;
789 if ( ! evidence.invoice) {
790 outcomes.invoice = 'invoice-none-found' ;
791 } else {
792 // A document that reached Media but whose order patch failed is remembered HERE, in the same
793 // persisted state as the order id, not by the caller: a generated importer following the
794 // documented loop without persisting it imported a second copy in review. The caller may still
795 // pass one explicitly (an older project's state); the crosswalk's answer is the fallback.
796 let pendingMediaFileId = evidence.invoice.pendingMediaFileId || null ;
797 let rememberedPending = null ;
798 if ( ! pendingMediaFileId) {
799 try {
800 const remembered = await crosswalk. getInvoiceFile (sourceId);
801 // Either a bare id (older rows) or { fileId, sourceUrl }. A remembered file is only THIS
802 // document's if it was imported from the same URL: a re-issued invoice under a new link
803 // must not be "resumed" onto the old bytes (review demonstrated it).
804 const rememberedId = typeof remembered === 'string' ? remembered : remembered && remembered.fileId;
805 const rememberedUrl = remembered && typeof remembered === 'object' ? remembered.sourceUrl || null : null ;
806 if (rememberedId && String (rememberedId). trim () !== '' ) {
807 if ( ! rememberedUrl || rememberedUrl === (evidence.invoice.sourceUrl || null )) { pendingMediaFileId = String (rememberedId); rememberedPending = pendingMediaFileId; }
808 else findings. push ({ code: 'invoice-pending-file-superseded' , sourceId: String (sourceId), note: 'a remembered Media file was imported from a different URL and was not resumed' });
809 }
810 } catch (error) {
811 errors.crosswalk = errors.crosswalk || describeError (error);
812 }
813 }
814 try {
815 invoice = await w. preserveOrderInvoiceDocument (wix, {
816 orderId,
817 sourceUrl: evidence.invoice.sourceUrl,
818 invoiceNumber: evidence.invoice.invoiceNumber,
819 approval: evidence.invoice.approval,
820 setupVerification,
821 sourceSiteHost,
822 pendingMediaFileId,
823 });
824 outcomes.invoice = invoice.outcome;
825 // The writer's findings (an over-length number or link, a key the order PATCH dropped) are
826 // losses the report must count, and the report counts `findings`, not `invoice.findings`.
827 for ( const finding of invoice.findings || []) findings. push (finding);
828 // Remember a READY-but-unrecorded file (bound to its URL); CLEAR the memory once the document
829 // is settled or the remembered import is dead. Left in place, a FAILED file id was resumed
830 // forever and the document never imported (review finding).
831 const settled = [ 'invoice-preserved' , 'invoice-already-preserved' , 'invoice-number-only' , 'invoice-import-failed' ]. includes (invoice.outcome);
832 try {
833 if (invoice.pendingMediaFileId && invoice.pendingMediaFileId !== pendingMediaFileId) {
834 await crosswalk. setInvoiceFile (sourceId, invoice.pendingMediaFileId, { sourceUrl: evidence.invoice.sourceUrl || null });
835 } else if (settled && (rememberedPending || (invoice.outcome === 'invoice-import-failed' && pendingMediaFileId))) {
836 await crosswalk. setInvoiceFile (sourceId, null , { sourceUrl: null });
837 }
838 } catch (error) {
839 errors.crosswalk = errors.crosswalk || describeError (error);
840 }
841 } catch (error) {
842 if (wixAuth. isRunInterruption (error)) throw error;
843 outcomes.invoice = 'invoice-failed' ;
844 errors.invoice = describeError (error);
845 }
846 }
847
848 return { orderId, derivedId, outcomes, findings, errors, paymentId, invoice };
849 }
850
851 // A crosswalk over the project's own state/crosswalk/crosswalk.ndjson, in the row shape and key
852 // convention the rest of the runtime uses (`<sourceSystem>:<sourceEntityType>:<sourceId>`, as in
853 // lib/import-recovery.js), so recovery tooling sees these rows and these rows see its. Loaded
854 // once, appended per order (O(1) -- the last row per key wins), so a large store does not rewrite
855 // its whole crosswalk seven thousand times.
856 //
857 // The loader is deliberately NOT lib/local-state's: that one throws on any unparsable line and on
858 // any invalid row of any entity type, which turns a process killed mid-append -- the crash this
859 // module exists to survive -- into a run that fails every order after it. Here a TRAILING partial
860 // line (no newline terminator, not parseable) is dropped and reported; any other bad line still
861 // throws, because that is corruption, not a crash.
862 function createProjectOrderCrosswalk ({ projectDir , sourceSystem = 'wordpress' , sourceEntityType = 'order' } = {}) {
863 if ( ! projectDir) throw new Error ( 'createProjectOrderCrosswalk needs the migration projectDir' );
864 const filePath = localState. crosswalkPath (projectDir);
865 const pendingPath = path. join (path. dirname (filePath), 'pending-invoice-files.ndjson' );
866 const key = ( sourceId ) => `${ sourceSystem }:${ sourceEntityType }:${ String ( sourceId ) }` ;
867 const index = new Map ();
868 // Bytes of COMPLETE lines already indexed. The file is append-only, so a refresh reads from here
869 // rather than re-parsing seven thousand rows -- and a MISS always refreshes first, because
870 // another process (or lib/local-state's upsert) may have appended a row since we last looked.
871 // Review demonstrated the alternative: a cached index returning null for a legacy row written
872 // moments earlier, and a second order imported.
873 let offset = 0 ;
874 let lineCount = 0 ;
875 let loaded = false ;
876 let inode = null ;
877 const state = { droppedPartialLine: null , droppedPendingPartialLine: null };
878
879 // The file is append-only for THIS adapter, but lib/local-state's upsertCrosswalkRow rewrites
880 // and compacts it (temp file + rename). A rewrite that shrank the file below our offset made the
881 // incremental read return nothing and a legacy row stay invisible -- review demonstrated a
882 // second order imported that way. So every refresh stats first: a new inode or a smaller file
883 // means "start over", not "nothing new".
884 async function statFile () {
885 try {
886 const st = await fs. stat (filePath);
887 return { size: st.size, ino: st.ino };
888 } catch (error) {
889 if (error && error.code === 'ENOENT' ) return { size: 0 , ino: null };
890 throw error;
891 }
892 }
893
894 async function readFrom ( position ) {
895 let handle;
896 try {
897 handle = await fs. open (filePath, 'r' );
898 } catch (error) {
899 if (error && error.code === 'ENOENT' ) return '' ;
900 throw error;
901 }
902 try {
903 const { size } = await handle. stat ();
904 if (size <= position) return '' ;
905 const buffer = Buffer. alloc (size - position);
906 await handle. read (buffer, 0 , buffer. length , position);
907 return buffer;
908 } finally {
909 await handle. close ();
910 }
911 }
912
913 async function refresh () {
914 const st = await statFile ();
915 if ((inode !== null && st.ino !== inode) || st.size < offset) {
916 index. clear ();
917 offset = 0 ;
918 lineCount = 0 ;
919 }
920 inode = st.ino;
921 const chunkBuffer = await readFrom (offset);
922 if ( ! chunkBuffer || chunkBuffer. length === 0 ) return ;
923 // Split on the BYTE, not the decoded string: a tail torn inside a multibyte character decodes
924 // to U+FFFD and mis-measures the bytes to truncate (review demonstrated a glued, fatal file).
925 const lastNewline = chunkBuffer. lastIndexOf ( 0x0a );
926 const completeBytes = lastNewline === - 1 ? 0 : lastNewline + 1 ;
927 const tailBytes = chunkBuffer. length - completeBytes;
928 const parts = completeBytes > 0 ? chunkBuffer. subarray ( 0 , completeBytes). toString ( 'utf8' ). split ( ' \n ' ) : [ '' ];
929 if (completeBytes > 0 ) parts. pop (); // the trailing '' after the final newline
930 const tail = tailBytes > 0 ? chunkBuffer. subarray (completeBytes). toString ( 'utf8' ) : '' ;
931 for ( const raw of parts) {
932 lineCount += 1 ;
933 const line = raw. replace ( / \r $ / , '' ). trim ();
934 if ( ! line) continue ;
935 let row;
936 try {
937 row = JSON . parse (line);
938 } catch (error) {
939 throw new Error ( `${ filePath }:${ lineCount } invalid NDJSON: ${ error . message }` );
940 }
941 if (row && row.targetEntityType === 'ecom/order' && typeof row.sourceStableKey === 'string' && typeof row.targetId === 'string' ) {
942 index. set (row.sourceStableKey, row.targetId);
943 }
944 }
945 offset += completeBytes;
946 if (tailBytes > 0 && tail. trim ()) {
947 // A last line with no newline: either a complete row from a writer that omits the
948 // terminator, or a line torn by a kill mid-append. A torn line is TRUNCATED on disk, not just
949 // skipped -- skipped, the next append would glue a row onto it and corrupt the file for every
950 // later reader (review demonstrated this too).
951 let row = null ;
952 try { row = JSON . parse (tail. trim ()); } catch (error) { row = null ; }
953 if (row) {
954 // A complete row another writer left unterminated. Indexed; the terminator is added by
955 // set() just before our next append, so the file is never touched only to tidy it.
956 lineCount += 1 ;
957 if (row.targetEntityType === 'ecom/order' && typeof row.sourceStableKey === 'string' && typeof row.targetId === 'string' ) {
958 index. set (row.sourceStableKey, row.targetId);
959 }
960 offset += tailBytes;
961 needsTerminator = true ;
962 } else {
963 // Truncate only if nothing has been appended since we read: a neighbour's row landing
964 // between the read and the truncate would otherwise be deleted. One process per project
965 // crosswalk is the supported model (lib/local-state has a state lock for the runtime); this
966 // check narrows the window, it does not close it.
967 const now = await statFile ();
968 if (now.ino === inode && now.size === offset + tailBytes) {
969 await fs. truncate (filePath, offset);
970 state.droppedPartialLine = { line: lineCount + 1 , length: tail. length };
971 }
972 }
973 }
974 }
975 let needsTerminator = false ;
976
977 // The pending-invoice sibling file gets the SAME crash discipline as the main crosswalk. Review
978 // demonstrated the alternative: a row torn by a kill, the next append glued onto it, one
979 // unparsable line holding two pending file ids, both silently ignored -- and a tax document
980 // imported twice. A torn TAIL is truncated on disk; a bad line anywhere else is corruption and
981 // throws; a complete row left unterminated is read and terminated before the next append.
982 async function readPendingRows () {
983 let buffer;
984 try {
985 buffer = await fs. readFile (pendingPath);
986 } catch (error) {
987 if ( ! error || error.code !== 'ENOENT' ) throw error;
988 return { rows: [], needsTerminator: false };
989 }
990 const lastNewline = buffer. lastIndexOf ( 0x0a );
991 const completeBytes = lastNewline === - 1 ? 0 : lastNewline + 1 ;
992 const complete = completeBytes > 0 ? buffer. subarray ( 0 , completeBytes). toString ( 'utf8' ). split ( ' \n ' ) : [];
993 if (complete. length > 0 ) complete. pop (); // the '' after the final newline
994 const rows = [];
995 complete. forEach (( raw , i ) => {
996 const line = raw. replace ( / \r $ / , '' ). trim ();
997 if ( ! line) return ;
998 try { rows. push ( JSON . parse (line)); } catch (error) { throw new Error ( `${ pendingPath }:${ i + 1 } invalid NDJSON: ${ error . message }` ); }
999 });
1000 const tail = buffer. subarray (completeBytes). toString ( 'utf8' );
1001 let pendingNeedsTerminator = false ;
1002 if (tail. trim ()) {
1003 let row = null ;
1004 try { row = JSON . parse (tail. trim ()); } catch (error) { row = null ; }
1005 if (row) {
1006 rows. push (row);
1007 pendingNeedsTerminator = true ;
1008 } else {
1009 await fs. truncate (pendingPath, completeBytes);
1010 state.droppedPendingPartialLine = { line: complete. length + 1 , length: tail. length };
1011 }
1012 }
1013 return { rows, needsTerminator: pendingNeedsTerminator };
1014 }
1015
1016 async function ensureLoaded () {
1017 if ( ! loaded) {
1018 await refresh ();
1019 loaded = true ;
1020 }
1021 }
1022
1023 return {
1024 async get ( sourceId ) {
1025 await ensureLoaded ();
1026 const hit = index. get ( key (sourceId));
1027 if (hit) return hit;
1028 await refresh ();
1029 return index. get ( key (sourceId)) || null ;
1030 },
1031 async set ( sourceId , targetId ) {
1032 await ensureLoaded ();
1033 if (needsTerminator) {
1034 await fs. appendFile (filePath, ' \n ' , 'utf8' );
1035 needsTerminator = false ;
1036 }
1037 await localState. appendCrosswalkRow (projectDir, {
1038 schemaVersion: localState. SCHEMA_VERSION ,
1039 sourceSystem,
1040 sourceEntityType,
1041 sourceId: String (sourceId),
1042 sourceStableKey: key (sourceId),
1043 targetSystem: 'wix' ,
1044 targetEntityType: 'ecom/order' ,
1045 targetId: String (targetId),
1046 status: 'imported' ,
1047 updatedAt: new Date (). toISOString (),
1048 });
1049 index. set ( key (sourceId), String (targetId));
1050 await refresh (); // pick up our own row's bytes (and anything a neighbour appended)
1051 },
1052 // A Media file that reached READY but whose order patch failed, remembered per source order so
1053 // the resume records THAT file rather than importing another. Kept in a sibling file, read in
1054 // full on every ask: it only ever holds the failures, so it stays small, and a stale in-memory
1055 // copy here would cost a duplicated tax document.
1056 async getInvoiceFile ( sourceId ) {
1057 const { rows } = await readPendingRows ();
1058 let found = null ;
1059 for ( const row of rows) {
1060 if (row && row.sourceStableKey === key (sourceId)) found = row.fileId ? { fileId: row.fileId, sourceUrl: row.sourceUrl || null } : null ; // last row wins; a null row clears
1061 }
1062 return found;
1063 },
1064 async setInvoiceFile ( sourceId , fileId , { sourceUrl = null } = {}) {
1065 // Repair first: a torn tail is truncated and an unterminated complete row gets its newline,
1066 // so this append always starts a clean line.
1067 const { needsTerminator } = await readPendingRows ();
1068 await fs. mkdir (path. dirname (pendingPath), { recursive: true });
1069 if (needsTerminator) await fs. appendFile (pendingPath, ' \n ' , 'utf8' );
1070 await fs. appendFile (pendingPath, `${ JSON . stringify ({ sourceStableKey: key (sourceId) , fileId: fileId === null ? null : String (fileId) , sourceUrl: sourceUrl === null ? null : String (sourceUrl) , updatedAt: new Date (). toISOString () }) } \n ` , 'utf8' );
1071 },
1072 // What the loaders had to drop, if anything -- a caller may want to report it.
1073 droppedPartialLine () { return state.droppedPartialLine; },
1074 droppedPendingPartialLine () { return state.droppedPendingPartialLine; },
1075 };
1076 }
1077
1078 // The attempt-journal row for one importOrderHistory result -- the only place that decides it, so
1079 // a generated runner does not. The order is `imported` exactly when the target holds it (the result
1080 // carries `orderId`), with that id and with every failed payment, refund or invoice stage as a
1081 // retryable finding; a rerun reconciles the order by id and retries only those stages. An order
1082 // that was not created never carries a targetId: `derivedId` is where it WOULD be, not evidence
1083 // that it is, and travels as `failure.intendedTargetId`. Status from the kept error, per
1084 // lib/multi-step-attempt.js.
1085 const LANDED_ORDER_OUTCOMES = new Set ([ ORDER_OUTCOMES . IMPORTED , ORDER_OUTCOMES . ALREADY_IMPORTED ]);
1086 const stageFailed = ( outcome ) => typeof outcome === 'string' && (outcome. endsWith ( '-failed' ) || outcome === 'payment-reconciliation-ambiguous' );
1087
1088 function orderHistoryAttempt ( result , { sourceStableKey } = {}) {
1089 if ( ! result || ! result.outcomes || ! Object. values ( ORDER_OUTCOMES ). includes (result.outcomes.order)) {
1090 throw new Error ( `orderHistoryAttempt needs an importOrderHistory result; got order outcome ${ JSON . stringify ( result && result . outcomes && result . outcomes . order ) }` );
1091 }
1092 const { outcomes , errors = {} } = result;
1093 const landed = LANDED_ORDER_OUTCOMES . has (outcomes.order) && Boolean (result.orderId);
1094 const subSteps = [
1095 { stage: 'payment' , outcome: outcomes.payment, failed: stageFailed (outcomes.payment), error: errors.payment },
1096 ... (outcomes.refunds || []). map (( refund ) => ({ stage: 'refund' , outcome: refund.outcome, failed: stageFailed (refund.outcome), error: refund.error })),
1097 { stage: 'invoice' , outcome: outcomes.invoice, failed: stageFailed (outcomes.invoice), error: errors.invoice },
1098 ... (errors.crosswalk ? [{ stage: 'crosswalk' , outcome: 'crosswalk-write-failed' , failed: true , error: errors.crosswalk }] : []),
1099 // The placeholder-line order landed, but its cutover item could not be filed.
1100 ... (errors.cutoverItem ? [{ stage: 'cutover-item' , outcome: 'cutover-item-write-failed' , failed: true , error: errors.cutoverItem }] : []),
1101 ]. filter (( step ) => step.outcome);
1102 const base = landed
1103 ? { outcome: outcomes.order, targetId: result.orderId }
1104 // A target read that could not decide stopped before Import Order: nothing was written.
1105 : { outcome: outcomes.order, intendedTargetId: result.derivedId || null , error: errors.order || null , writeAttempted: outcomes.order !== ORDER_OUTCOMES . TARGET_READ_FAILED };
1106 return require ( '../../../lib/multi-step-attempt.js' ). classifyMultiStepAttempt ({
1107 sourceStableKey, operation: 'importOrder' , base, subSteps, findings: result.findings || [],
1108 });
1109 }
1110
1111 module . exports = {
1112 ORDER_OUTCOMES,
1113 PLACEHOLDER_LINE_NAME,
1114 PLACEHOLDER_GAP_CODE,
1115 withPlaceholderLineItem,
1116 orderHistoryAttempt,
1117 EVIDENCE_KEYS,
1118 REFUND_KEYS,
1119 INVOICE_KEYS,
1120 deriveImportedOrderId,
1121 normalizeSourceSiteHost,
1122 assertEvidence,
1123 checkOrderContactFields,
1124 identifyRejectedContactFields,
1125 importOrderHistory,
1126 createProjectOrderCrosswalk,
1127 };