Setting the file. One moment.
Order Payment Mapping · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
359
function existingRefundFingerprints
— line 359
This file
Number 41.153
Position 153 of 174
Type JavaScript
Size 21 KB
Lines 420 lib/ order-payment-mapping.js
JavaScript · 420 lines · 21 KB
14 // and reproduce the revenue overstatement this work exists to remove. A paid date is required.
15 // A transaction reference enriches a payment; it never creates one.
16 //
17 // SCHEMA DRIFT, re-checked against the live Add Payments schema 2026-09-03 (this check
18 // must precede coding, and it found a real bug):
19 //
20 // 1. `status` is a PAYMENT-level field, sibling to `amount` and `createdDate`. It is NOT a
21 // member of `regularPaymentDetails`, which is where wix-writers.js was putting it. A
22 // request built the old way used a field the schema still carries as DEPRECATED, marked as
23 // replaced by the top-level one -- so it was not ignored outright, but its behaviour is not
24 // guaranteed to persist. Write the current field.
25 // 2. `providerTransactionId` and `gatewayTransactionId` are "not returned for offline
26 // payments". Read-back verification must therefore skip the reference on a payment written
27 // with `offlinePayment: true`, or every offline payment reads as drift.
28 // 3. The order's `paymentStatus` is recalculated ASYNCHRONOUSLY. Reading the order straight
29 // after the write may still show the previous status, so no verification may assert on it
30 // immediately.
31 //
32 // Confirmed by the same re-check: `createdDate` is settable and defaults to now when omitted
33 // (which is why "leave it undated" is not an option the API offers), `providerTransactionId` caps
34 // at 100, `userDefinedName.custom` at 150, `lastFourDigits` at 4, `brand` at 100, up to 50
35 // payments per call and 100 per order, and the whole call fails if any external transaction id
36 // already exists on the order.
37
38 const PAYMENT_STATUS = 'APPROVED' ;
39
40 const LIMITS = {
41 providerTransactionId: 100 ,
42 methodName: 150 ,
43 lastFourDigits: 4 ,
44 cardBrand: 100 ,
45 };
46
47 // Mutually exclusive per-order outcomes. Their sum must equal the discovered source-order
48 // population, so a new reason MUST be added here rather than folded into an existing one.
49 const OUTCOMES = {
50 ELIGIBLE: 'eligible' ,
51 NO_AMOUNT: 'payment-not-written-no-amount' ,
52 NO_PAID_DATE: 'payment-not-written-no-paid-date' ,
53 MULTI_PAYMENT: 'payment-not-written-multi-payment' ,
54 CURRENCY_MISMATCH: 'payment-not-written-currency-mismatch' ,
55 };
56
57 function trimmed ( value ) {
58 if (value === null || value === undefined ) return null ;
59 const text = String (value). trim ();
60 return text. length > 0 ? text : null ;
61 }
62
63 // A decimal is canonicalized as a string, never through a float: "55.750" and "55.75" are the
64 // same payment and must fingerprint identically, but parsing money as a Number to achieve that
65 // invites the classic 0.1 + 0.2 rounding error into an amount comparison.
66 function canonicalizeDecimal ( raw ) {
67 const text = trimmed (raw);
68 if (text === null ) return null ;
69 const normalized = text. replace ( / ^ \+ / , '' );
70 if ( ! / ^ - ? \d + ( \. \d + ) ?$ / . test (normalized)) return null ;
71 if ( ! normalized. includes ( '.' )) return normalized. replace ( / ^ (- ? )0 + ( \d )/ , '$1$2' );
72 let [whole, fraction] = normalized. split ( '.' );
73 fraction = fraction. replace ( /0 +$ / , '' );
74 whole = whole. replace ( / ^ (- ? )0 + ( \d )/ , '$1$2' );
75 return fraction. length > 0 ? `${ whole }.${ fraction }` : whole;
76 }
77
78 function isPositive ( canonical ) {
79 return canonical !== null && ! canonical. startsWith ( '-' ) && / [1-9] / . test (canonical);
80 }
81
82 // A paid stamp becomes an instant under the clock the CALLER declares for that stamp, never under
83 // one clock guessed for the whole order. A source typically records the same payment twice -- once
84 // in universal time and once on the shop's wall clock, with the zone stripped off -- and applying
85 // one shop offset to both is arithmetically incapable of being right for both: it leaves the
86 // universal reading shifted by the whole offset, which is a merchant-visible financial error.
87 //
88 // `zone` is a fact about the source KEY ('utc' | 'site-local'), known from the source schema before
89 // any shop is examined. `utcOffsetMinutes` is a fact about the SHOP. They are not interchangeable:
90 // "already universal" must never be expressed as `utcOffsetMinutes: 0`, which asserts a measurement
91 // ("this shop sits at UTC+0") in place of a definition.
92 //
93 // value shape | zone | site offset | instant | finding
94 // ----------------------------|---------------|-------------|---------------------------|---------
95 // epoch digits | either | either | the epoch, as UTC | none
96 // string with an explicit zone| either | either | as written | none
97 // bare wall clock | 'utc' | either | read as UTC, NO shift | none
98 // bare wall clock | 'site-local' | known | shifted by the site offset| none
99 // bare wall clock | 'site-local' | unknown | read as UTC | paid-date-timezone-assumed
100 // anything else | either | either | none | paid-date-unparseable
101 //
102 // The offset is only ever reachable from the bare-wall-clock branch: an epoch stamp and a string
103 // carrying its own zone are already unambiguous, so declaring a clock on them changes nothing.
104 //
105 // The assumed-timezone finding is kept, and narrowed to where it is true: a stamp that claims the
106 // shop's clock while nobody has said what the shop's clock is. It is NOT a licence to invent an
107 // offset from the shop's address, and it carries the field it came from so an operator can act on
108 // one stamp rather than on every paid order in the run.
109 const PAID_DATE_ZONES = Object. freeze ([ 'utc' , 'site-local' ]);
110
111 function normalizePaidDate ( raw , { utcOffsetMinutes = null , zone = 'site-local' } = {}) {
112 // A zone this function does not understand must not degrade into the default: an ignored clock
113 // declaration reads exactly like an honoured one, which is how the defect above stayed invisible.
114 if ( ! PAID_DATE_ZONES . includes (zone)) {
115 throw new Error ( `normalizePaidDate zone must be one of ${ PAID_DATE_ZONES . join ( ' | ' ) }, got ${ JSON . stringify ( zone ) }` );
116 }
117 const text = trimmed (raw);
118 if (text === null ) return { instant: null , finding: null };
119
120 if ( / ^ \d +$ / . test (text)) {
121 const seconds = text. length >= 13 ? Number (text) / 1000 : Number (text);
122 if ( ! Number. isFinite (seconds) || seconds <= 0 ) {
123 return { instant: null , finding: 'paid-date-unparseable' };
124 }
125 // `toISOString()` THROWS on an out-of-range date, so a garbage epoch aborted the run instead
126 // of producing the named outcome the accounting depends on.
127 const stamped = new Date (seconds * 1000 );
128 if (Number. isNaN (stamped. getTime ())) return { instant: null , finding: 'paid-date-unparseable' };
129 return { instant: stamped. toISOString (), finding: null };
130 }
131
132 if ( /(Z | [+-]\d {2} : ? \d {2} ) $ / . test (text)) {
133 const parsed = new Date (text);
134 if (Number. isNaN (parsed. getTime ())) return { instant: null , finding: 'paid-date-unparseable' };
135 return { instant: parsed. toISOString (), finding: null };
136 }
137
138 const wallClock = text. match ( / ^ ( \d {4} )-( \d {2} )-( \d {2} ) [T ] ( \d {2} ):( \d {2} )(?::( \d {2} )) ?$ / );
139 if (wallClock) {
140 const [, y , mo , d , h , mi , s ] = wallClock;
141 const millis = Date. UTC ( Number (y), Number (mo) - 1 , Number (d), Number (h), Number (mi), Number (s || '0' ));
142 if (Number. isNaN (millis)) return { instant: null , finding: 'paid-date-unparseable' };
143 // Date.UTC ROLLS OVER rather than rejecting: "2024-02-31 25:99:99" silently becomes
144 // 2024-03-03T02:40:39Z. A date that does not round-trip to the components it was written
145 // with is not a date, and inventing a plausible instant from a corrupt one is worse than
146 // reporting it -- this value is the whole basis for claiming when the order was paid.
147 const roundTrip = new Date (millis);
148 const sameInstant = roundTrip. getUTCFullYear () === Number (y)
149 && roundTrip. getUTCMonth () === Number (mo) - 1
150 && roundTrip. getUTCDate () === Number (d)
151 && roundTrip. getUTCHours () === Number (h)
152 && roundTrip. getUTCMinutes () === Number (mi)
153 && roundTrip. getUTCSeconds () === Number (s || '0' );
154 if ( ! sameInstant) return { instant: null , finding: 'paid-date-unparseable' };
155 // Declared universal: the components ARE the UTC components. Nothing is applied to them, with
156 // or without a shop offset, because the shop's offset is not a fact about this stamp.
157 if (zone === 'utc' ) return { instant: new Date (millis). toISOString (), finding: null };
158 if (utcOffsetMinutes === null ) {
159 return { instant: new Date (millis). toISOString (), finding: 'paid-date-timezone-assumed' };
160 }
161 return { instant: new Date (millis - utcOffsetMinutes * 60000 ). toISOString (), finding: null };
162 }
163
164 return { instant: null , finding: 'paid-date-unparseable' };
165 }
166
167 // The gate, in a fixed order. Exactly one outcome comes back, because the accounting requires the outcomes to partition the population -- an order that fails two checks
168 // is reported under the first, with the rest of its problems available as findings.
169 function evaluatePaymentEligibility ({
170 sourceAmount = null ,
171 paidDate = null ,
172 altPaidDate = null ,
173 distinctAmountCount = 1 ,
174 sourceCurrency = null ,
175 targetCurrency = null ,
176 utcOffsetMinutes = null ,
177 // The clock each stamp is written in, declared per FIELD by the step that read the source. The
178 // default reproduces the historical reading for a caller that declares nothing; the contract in
179 // order-history.js refuses the one shape that default must not cover -- a shop offset supplied
180 // beside a present stamp whose clock nobody stated.
181 paidDateZone = 'site-local' ,
182 altPaidDateZone = 'site-local' ,
183 sourceStatus = null ,
184 conflictingStatuses = [ 'cancelled' , 'failed' , 'pending' , 'on-hold' , 'refunded' ],
185 } = {}) {
186 const findings = [];
187 const amount = canonicalizeDecimal (sourceAmount);
188
189 if ( ! isPositive (amount)) {
190 return { eligible: false , outcome: OUTCOMES . NO_AMOUNT , amount: null , createdDate: null , findings };
191 }
192
193 const primary = normalizePaidDate (paidDate, { utcOffsetMinutes, zone: paidDateZone });
194 const secondary = normalizePaidDate (altPaidDate, { utcOffsetMinutes, zone: altPaidDateZone });
195 if (primary.finding) findings. push ({ code: primary.finding, field: 'paidDate' });
196 if (secondary.finding && ! primary.instant) findings. push ({ code: secondary.finding, field: 'altPaidDate' });
197
198 const createdDate = primary.instant || secondary.instant;
199 if ( ! createdDate) {
200 return { eligible: false , outcome: OUTCOMES . NO_PAID_DATE , amount, createdDate: null , findings };
201 }
202 // This narrows the finding's condition as well as the caveat's. Two stamps read in DIFFERENT
203 // declared clocks, exactly one of which had to be assumed, are not a comparison: their gap is the
204 // size of the assumption, not evidence that the source disagreed with itself. Reporting it is the
205 // invented disagreement this change removes, and "resolving" it picks between one real reading
206 // and one guess. The test is deliberately tied to the clocks DIFFERING, so nothing reachable
207 // without the new keys moves: with no declaration both stamps share the 'site-local' default, and
208 // a pair that is both-assumed or neither-assumed still reports exactly as it does today.
209 const assumed = ( side ) => side.finding === 'paid-date-timezone-assumed' ;
210 const comparable = paidDateZone === altPaidDateZone || assumed (primary) === assumed (secondary);
211 if (primary.instant && secondary.instant && primary.instant !== secondary.instant && comparable) {
212 findings. push ({ code: 'paid-date-disagreement' , preferred: primary.instant, rejected: secondary.instant });
213 }
214
215 if ( Number (distinctAmountCount) > 1 ) {
216 return { eligible: false , outcome: OUTCOMES . MULTI_PAYMENT , amount, createdDate, findings };
217 }
218
219 // Wix derives payment currency from the order -- a payment request has no currency field -- so
220 // a currency mismatch cannot be expressed in the record and must stop the write instead.
221 if (sourceCurrency && targetCurrency && trimmed (sourceCurrency). toUpperCase () !== trimmed (targetCurrency). toUpperCase ()) {
222 return { eligible: false , outcome: OUTCOMES . CURRENCY_MISMATCH , amount, createdDate, findings };
223 }
224
225 // A paid date on a cancelled or failed order is preserved, not discarded: payment followed by
226 // cancellation is a real sequence, and the date is the stronger historical fact. The
227 // contradiction is counted so an implausible volume of it is visible.
228 const status = trimmed (sourceStatus);
229 if (status && conflictingStatuses. some (( needle ) => status. toLowerCase (). includes (needle))) {
230 findings. push ({ code: 'payment-status-conflict' , sourceStatus: status });
231 }
232
233 return { eligible: true , outcome: OUTCOMES . ELIGIBLE , amount, createdDate, findings };
234 }
235
236 function capped ( value , limit , code , field , findings ) {
237 const text = trimmed (value);
238 if (text === null ) return null ;
239 if (text. length > limit) {
240 findings. push ({ code, field, length: text. length , limit });
241 return null ;
242 }
243 return text;
244 }
245
246 // Builds the payment exactly as the re-checked schema shapes it: status and createdDate at
247 // payment level, everything method-shaped inside regularPaymentDetails. `savedPaymentMethod` is
248 // never written -- the field is writable but no reusable credential can accompany it, so setting
249 // it would assert a charging agreement that does not exist. `refundDisabled` stays unset until
250 // its interaction with external refunds is verified.
251 function buildPaymentRecord ({
252 amount ,
253 createdDate ,
254 methodName = null ,
255 providerTransactionId = null ,
256 offlinePayment = undefined ,
257 cardDisplay = null ,
258 } = {}) {
259 const findings = [];
260 const details = {};
261
262 const method = capped (methodName, LIMITS .methodName, 'method-name-over-length' , 'paymentMethodName' , findings);
263 if (method) details.paymentMethodName = { userDefinedName: { custom: method } };
264
265 const reference = capped (providerTransactionId, LIMITS .providerTransactionId, 'reference-over-length' , 'providerTransactionId' , findings);
266 if (reference) details.providerTransactionId = reference;
267 else if ( trimmed (providerTransactionId) === null ) findings. push ({ code: 'no-transaction-reference' });
268
269 if ( typeof offlinePayment === 'boolean' ) details.offlinePayment = offlinePayment;
270
271 if (cardDisplay) {
272 const card = {};
273 const lastFour = capped (cardDisplay.lastFourDigits, LIMITS .lastFourDigits, 'card-display-over-length' , 'lastFourDigits' , findings);
274 const brand = capped (cardDisplay.brand, LIMITS .cardBrand, 'card-display-over-length' , 'brand' , findings);
275 if (lastFour) card.lastFourDigits = lastFour;
276 if (brand) card.brand = brand;
277 if (Object. keys (card). length > 0 ) details.creditCardDetails = card;
278 }
279
280 return {
281 payment: {
282 amount: { amount },
283 createdDate,
284 status: PAYMENT_STATUS ,
285 regularPaymentDetails: details,
286 },
287 findings,
288 };
289 }
290
291 // Fallback identity for records with no provider reference. Deliberately built from the
292 // facts the source could supply, so a payment this migration wrote in an earlier run recognizes
293 // itself even when the crosswalk row is gone.
294 function paymentFingerprint ( payment ) {
295 const details = (payment && payment.regularPaymentDetails) || {};
296 const card = details.creditCardDetails || {};
297 const method = details.paymentMethodName
298 && details.paymentMethodName.userDefinedName
299 && details.paymentMethodName.userDefinedName.custom;
300 return JSON . stringify ([
301 canonicalizeDecimal (payment && payment.amount && payment.amount.amount),
302 payment && payment.createdDate ? new Date (payment.createdDate). toISOString () : null ,
303 method ? String (method). trim (). toLowerCase () : null ,
304 typeof details.offlinePayment === 'boolean' ? details.offlinePayment : null ,
305 trimmed (card.lastFourDigits),
306 card.brand ? String (card.brand). trim (). toLowerCase () : null ,
307 ]);
308 }
309
310 const RECONCILE = {
311 CREATE: 'create' ,
312 RECONCILED: 'payment-reconciled-existing' ,
313 AMBIGUOUS: 'payment-reconciliation-ambiguous' ,
314 };
315
316 // Reconciliation is by identity, never by position. The defect this replaces picked "the first
317 // payment that is not refundDisabled", which matches an unrelated record -- including one the
318 // merchant created by hand in the Business Manager.
319 function reconcilePayment ({ payment , existingPayments = [] } = {}) {
320 const wanted = (payment && payment.regularPaymentDetails) || {};
321 const reference = trimmed (wanted.providerTransactionId);
322
323 // `providerTransactionId` and `gatewayTransactionId` are not returned for offline payments, so
324 // on a re-run an offline payment we wrote WITH a reference reads back WITHOUT one. Matching by
325 // reference there can only ever miss, and a miss means "create" -- the payment gets written a
326 // second time. Offline payments are therefore matched on the fact fingerprint, which excludes
327 // the reference and so matches either shape.
328 const offline = wanted.offlinePayment === true ;
329
330 if (reference && ! offline) {
331 const matches = existingPayments. filter (
332 ( candidate ) => trimmed (candidate && candidate.regularPaymentDetails && candidate.regularPaymentDetails.providerTransactionId) === reference,
333 );
334 if (matches. length === 1 ) return { outcome: RECONCILE . RECONCILED , match: matches[ 0 ], matchedBy: 'providerTransactionId' };
335 if (matches. length > 1 ) return { outcome: RECONCILE . AMBIGUOUS , match: null , matchedBy: 'providerTransactionId' , matchCount: matches. length };
336 return { outcome: RECONCILE . CREATE , match: null , matchedBy: null };
337 }
338
339 const target = paymentFingerprint (payment);
340 const matches = existingPayments. filter (( candidate ) => paymentFingerprint (candidate) === target);
341 if (matches. length === 1 ) return { outcome: RECONCILE . RECONCILED , match: matches[ 0 ], matchedBy: 'fingerprint' };
342 if (matches. length > 1 ) return { outcome: RECONCILE . AMBIGUOUS , match: null , matchedBy: 'fingerprint' , matchCount: matches. length };
343 return { outcome: RECONCILE . CREATE , match: null , matchedBy: null };
344 }
345
346 // Refund identity, and why it has to be a fingerprint.
347 //
348 // A source refund entity has a stable id, but the target Refund object has nowhere to put it: the
349 // only free-text field, `details.reason`, is customer-visible, so a source identifier must not go
350 // there. So a written refund is recognized by what it IS -- the payment it refunds, its amount,
351 // and the external marker -- exactly as an unreferenced payment is.
352 //
353 // Without this a clean re-run writes the same external refund again, and the order's balance
354 // reads as refunded twice.
355 function refundFingerprint ({ paymentId , amount }) {
356 return JSON . stringify ([ trimmed (paymentId), canonicalizeDecimal (amount), true ]);
357 }
358
359 function existingRefundFingerprints ( refunds = []) {
360 const out = [];
361 for ( const refund of refunds) {
362 for ( const transaction of (refund && refund.transactions) || []) {
363 // Only OUR kind of refund counts, and only one that did not fail: a FAILED refund
364 // transaction means the money never moved, so it must not suppress a re-attempt.
365 if (transaction.externalRefund !== true ) continue ;
366 if (transaction.refundStatus === 'FAILED' ) continue ;
367 out. push ({
368 refundId: refund.id,
369 fingerprint: refundFingerprint ({
370 paymentId: transaction.paymentId,
371 amount: transaction.amount && transaction.amount.amount,
372 }),
373 });
374 }
375 }
376 return out;
377 }
378
379 // No AMBIGUOUS member: with occurrence-aware identity there is no ambiguous case to report. The
380 // Nth source refund either finds N matches or it does not.
381 const RECONCILE_REFUND = {
382 CREATE: 'create' ,
383 RECONCILED: 'refund-reconciled-existing' ,
384 };
385
386 // Identity by facts alone is not identity: two DISTINCT source refunds can share a payment and
387 // an amount (a 10.00 refund issued twice), and treating the second as "already written" silently
388 // drops a real refund. So the caller states WHICH occurrence this is among the source refunds
389 // sharing these facts for this payment, and reconciliation compares counts rather than existence.
390 //
391 // occurrence 1 with 0 matches -> create. occurrence 2 with 1 match -> create (the second refund
392 // is still missing). occurrence 1 or 2 with 2 matches -> reconciled. That makes a re-run idempotent
393 // without collapsing duplicates that genuinely exist.
394 function reconcileRefund ({ paymentId , amount , existingRefunds = [], sourceOccurrence = 1 } = {}) {
395 const target = refundFingerprint ({ paymentId, amount });
396 const matches = existingRefundFingerprints (existingRefunds). filter (( entry ) => entry.fingerprint === target);
397 const occurrence = Number. isInteger (sourceOccurrence) && sourceOccurrence > 0 ? sourceOccurrence : 1 ;
398
399 if (matches. length >= occurrence) {
400 return { outcome: RECONCILE_REFUND . RECONCILED , refundId: matches[occurrence - 1 ].refundId, matchCount: matches. length };
401 }
402 return { outcome: RECONCILE_REFUND . CREATE , refundId: null , matchCount: matches. length };
403 }
404
405 module . exports = {
406 LIMITS,
407 OUTCOMES,
408 PAID_DATE_ZONES,
409 RECONCILE,
410 PAYMENT_STATUS,
411 canonicalizeDecimal,
412 normalizePaidDate,
413 evaluatePaymentEligibility,
414 buildPaymentRecord,
415 paymentFingerprint,
416 reconcilePayment,
417 refundFingerprint,
418 reconcileRefund,
419 RECONCILE_REFUND,
420 };