Setting the file. One moment.
Shipping Build · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
This file
Number 41.161
Position 161 of 174
Type JavaScript
Size 86 KB
Lines 1,535 lib/ shipping-build.js
JavaScript · 1,535 lines · 86 KB
// WooCommerce zones are (locations[] match rule) + (methods[], each independently priced).
12 // Wix delivery regions are (destinations[] match rule) + (deliveryCarriers[], each an APP with
13 // one backupRate). A WooCommerce zone's *methods* become Wix *delivery carriers* on the region
14 // built from that zone's *locations* — two different WooCommerce arrays fan into two different
15 // Wix arrays on the same region, not a 1:1 field rename.
16 //
17 // Input is the raw WooCommerce shape from GET /wc/v3/shipping/zones (zone), .../locations
18 // (zone.locations[], each {code, type}), and .../methods (zone.methods[], each {method_id,
19 // enabled, settings: {cost, ...}}) — already-fetched, per this codebase's "transform takes
20 // shaped input, fetching is the caller's job" convention (see tax-build.js).
21 //
22 // ONE ZONE METHOD IS NOT ONE CHECKOUT CHOICE. A WooCommerce zone lists every shipping
23 // *behaviour* as its own method row, because WooCommerce has nowhere else to put them: a
24 // minimum-order free-shipping threshold and a coupon-gated free shipping are both rows, and
25 // neither is something a buyer picks. Writing one Wix ShippingOption per zone method therefore
26 // reproduces — and multiplies — a display defect the source already has. This builder resolves
27 // zone methods to the merchant's actual policy first (see planShippingZones), and reports every
28 // resolution it made so a human can confirm the intent was read correctly.
29
30 // "Basic Shipping" is Wix's own first-party, non-carrier-integration delivery app — the direct
31 // equivalent of WooCommerce's flat_rate/free_shipping (a merchant-set price, no real courier
32 // calculation). Per dev.wix.com's own Add Delivery Carrier example (not a placeholder GUID — the
33 // docs' worked example uses this exact id) and confirmed installed on a live site under
34 // displayName "Basic Shipping". Same "fixed platform constant" reasoning as discount-rule-build.js's
35 // WIX_STORES_APP_ID: apps Wix itself created keep one appId across every site. Still overridable.
36 const BASIC_SHIPPING_APP_ID = '45c44b27-ca7b-4891-8c0d-1747d588b835' ;
37
38 // WooCommerce's own continent -> member-country-code table (plugins/woocommerce/i18n/
39 // continents.php, github.com/woocommerce/woocommerce, fetched 2026-08-12), needed because a WC
40 // zone location can be `type: "continent"` (e.g. `EU`) but Wix's Destination object has no
41 // continent concept — only countryCode (+ optional subdivisions). A continent-type location is
42 // expanded to one Destination per member country. Only continents with a real zone hit in
43 // practice need to be complete here; unlisted continent codes fall through to a flagged gap
44 // rather than silently matching nothing.
45 const CONTINENT_COUNTRIES = {
46 EU: [ 'AD' , 'AL' , 'AT' , 'AX' , 'BA' , 'BE' , 'BG' , 'BY' , 'CH' , 'CZ' , 'DE' , 'DK' , 'EE' , 'ES' , 'FI' , 'FO' , 'FR' , 'GB' , 'GG' , 'GI' , 'GR' , 'HR' , 'HU' , 'IE' , 'IM' , 'IS' , 'IT' , 'JE' , 'LI' , 'LT' , 'LU' , 'LV' , 'MC' , 'MD' , 'ME' , 'MK' , 'MT' , 'NL' , 'NO' , 'PL' , 'PT' , 'RO' , 'RS' , 'RU' , 'SE' , 'SI' , 'SJ' , 'SK' , 'SM' , 'TR' , 'UA' , 'VA' , 'XK' ],
47 };
48
49 function isNumericString ( value ) {
50 return typeof value === 'string' && value. trim () !== '' && Number. isFinite ( Number (value));
51 }
52
53 function toAmountString ( value ) {
54 return String ( Number (value));
55 }
56
57 // --- WC zone locations -> Wix destinations --------------------------------------------------
58 // WC location.code shapes, per WC_Shipping_Zone::get_zone_locations(): type "country" -> "US";
59 // type "state" -> "US:CA" (country-colon-state); type "continent" -> "EU"; type "postcode" ->
60 // a literal postcode/pattern, which Wix's Destination has no equivalent for at all (country/
61 // subdivision only) and is always a gap.
62 function normalizeZoneLocations ( locations ) {
63 const destinations = [];
64 const gaps = [];
65 for ( const location of locations || []) {
66 const type = String (location?.type || '' ). trim ();
67 const code = String (location?.code || '' ). trim ();
68 if ( ! code) continue ;
69 if (type === 'country' ) {
70 destinations. push ({ countryCode: code. toUpperCase () });
71 } else if (type === 'state' ) {
72 const [ country , state ] = code. split ( ':' );
73 if ( ! country || ! state) {
74 gaps. push ({ location, reason: `state-type location code "${ code }" did not parse as COUNTRY:STATE` });
75 continue ;
76 }
77 destinations. push ({ countryCode: country. toUpperCase (), subdivisions: [ `${ country . toUpperCase () }-${ state . toUpperCase () }` ] });
78 } else if (type === 'continent' ) {
79 const countries = CONTINENT_COUNTRIES [code. toUpperCase ()];
80 if ( ! countries) {
81 gaps. push ({ location, reason: `continent code "${ code }" has no country table here (add it to CONTINENT_COUNTRIES if this recurs)` });
82 continue ;
83 }
84 for ( const countryCode of countries) destinations. push ({ countryCode });
85 } else if (type === 'postcode' ) {
86 gaps. push ({ location, reason: 'postcode/postal-pattern zone matching has no Wix Destination equivalent (country/subdivision only) — reconfigure-in-wix' });
87 } else {
88 gaps. push ({ location, reason: `unrecognized WooCommerce zone-location type "${ type }"` });
89 }
90 }
91 return { destinations, gaps };
92 }
93
94 // --- Conditional rates -------------------------------------------------------------------------
95 // ShippingOption.rates and PickupLocation.rates are the SAME type: ConditionalRates, from
96 // com.wix.ecom.delivery.rates.v1. Shape (authoritative, read off the service definition, not
97 // the public docs):
98 // ConditionalRates { conditions[0..10], amount (decimal string >= 0, max 3 dp), multiplyByQuantity }
99 // Condition { type: BY_TOTAL_WEIGHT|BY_TOTAL_PRICE|BY_TOTAL_QUANTITY, value, operator: EQ|GT|GTE|LT|LTE }
100 // Conditions AND together; an empty conditions[] means "always applies". The runtime rule,
101 // verbatim from the definition: "at runtime for a given shipment input, up to one rate (price)
102 // should be returned in an option. If more than one rate is valid then we return the lowest one."
103 //
104 // This is what makes a source minimum-order free-shipping threshold migratable rather than a gap.
105 //
106 // LIVE-VERIFIED 2026-09-16: the explicit two-way LT/GTE partition below
107 // (priceThresholdRates) was originally written on the theory that it would make the merchant's
108 // dashboard show the same two bands the source had. That theory was never live-checked and is
109 // WRONG: the Wix dashboard's own rate editor renders it as "Rate by price" mode with invalid,
110 // unfillable "From"/"Up to" range fields (confirmed via screenshot — a merchant opening this
111 // rate sees validation errors), even though the write succeeds and the checkout economics are
112 // correct. The combination that renders correctly, as the dashboard's simpler "Flat rate" mode
113 // with its "Offer free shipping when customers purchase a certain amount" checkbox (confirmed
114 // live), is `[unconditionalRate(paidAmount), ...freeAboveThresholdRates(thresholdAmount)]` —
115 // i.e. LEANING ON the lowest-wins tiebreak this comment used to say to avoid.
116 // resolveZoneMethods uses that combination, not priceThresholdRates, for exactly this reason.
117 // priceThresholdRates is kept below (still schema-valid, still economically correct — the
118 // dashboard-editability problem is cosmetic, not a data-correctness one) for any caller that
119 // only needs the rates[] shape and does not need it to open cleanly in the dashboard's own
120 // editor; do not reach for it as the default without re-verifying that live first.
121 const CONDITION_BY_TOTAL_PRICE = 'BY_TOTAL_PRICE' ;
122
123 function unconditionalRate ( amount ) {
124 return { conditions: [], amount: String (amount), multiplyByQuantity: false };
125 }
126
127 function priceThresholdRates ( paidAmount , thresholdAmount ) {
128 return [
129 { conditions: [{ type: CONDITION_BY_TOTAL_PRICE , operator: 'LT' , value: String (thresholdAmount) }], amount: String (paidAmount), multiplyByQuantity: false },
130 { conditions: [{ type: CONDITION_BY_TOTAL_PRICE , operator: 'GTE' , value: String (thresholdAmount) }], amount: '0' , multiplyByQuantity: false },
131 ];
132 }
133
134 function freeAboveThresholdRates ( thresholdAmount ) {
135 return [
136 { conditions: [{ type: CONDITION_BY_TOTAL_PRICE , operator: 'GTE' , value: String (thresholdAmount) }], amount: '0' , multiplyByQuantity: false },
137 ];
138 }
139
140 // The one thing the service definition does NOT settle: whether BY_TOTAL_PRICE is measured
141 // before or after cart discounts. WooCommerce's own free_shipping has an `ignore_discounts`
142 // setting for exactly this, so the two platforms can disagree. Ship the threshold as written,
143 // carry this note on every option built from one, and confirm it at the first live checkout —
144 // it is a decision, not a measurement, and must never be reported as verified.
145 function thresholdDiscountBasisNote ( ignoreDiscounts ) {
146 const sourceBasis = String (ignoreDiscounts) === 'yes' ? 'the pre-discount subtotal' : 'the post-discount subtotal' ;
147 return `Threshold basis unverified: WooCommerce compares ${ sourceBasis } (ignore_discounts="${ ignoreDiscounts ?? 'no'}"); Wix's BY_TOTAL_PRICE does not document which side it uses. Shipped as written — confirm at the first live checkout, and do not report this rate as verified until then.` ;
148 }
149
150 // --- WC shipping method -> a classification ----------------------------------------------------
151 // Kinds, and why each exists:
152 // 'carrier' — a real buyer-facing delivery choice with a price. carrierRole 'basic'.
153 // 'threshold' — NOT a choice. A minimum-order free-shipping rule that belongs ON the paid
154 // options in the same zone (see planShippingZones), never as its own row.
155 // 'coupon-domain' — NOT a choice, and NOT a shipping setting at all. Coupon-gated free shipping
156 // lives entirely on the Wix coupon; the WooCommerce zone row exists only
157 // because WooCommerce needs one to switch the behaviour on.
158 // 'pickup' — NOT a shipping option. A Wix PickupLocation (see buildPickupLocationInput);
159 // writing it as a ShippingOption puts a 0-priced *delivery* next to the real
160 // paid one, which is how collection ends up looking like free shipping.
161 // 'gap' — no data equivalent; reported, never guessed at.
162 //
163 // Confidence, verified against WooCommerce core's own shipping method implementations
164 // (plugins/woocommerce/includes/shipping/class-wc-shipping-{flat-rate,free-shipping,local-pickup}.php):
165 // - HIGH: flat_rate's base `cost` setting -> a paid option. Real, always-applied.
166 // - HIGH: free_shipping's `requires` decides everything. Empty means genuinely always free —
167 // the ONE case where an unconditional 0 is correct.
168 // - HIGH: local_pickup / pickup_location -> a Wix PickupLocation.
169 // - GAP, no calculation engine to replicate: any other method_id (e.g. a real-carrier plugin
170 // like a UPS/FedEx integration) — carrier-calculated rates have no data equivalent.
171 // - Per-shipping-class cost overrides (flat_rate's `class_cost_*` settings) are a known,
172 // flagged gap: Wix's additionalCharges apply to every order in the region, not conditionally
173 // per product shipping class, so mapping a class-specific cost there would overcharge every
174 // other class. Recorded in `notes[]`, never auto-applied.
175 function classifyMethod ( method ) {
176 const methodId = String (method?.method_id || '' ). trim ();
177 const enabled = method?.enabled !== false ;
178 const title = String (method?.settings?.title?.value || method?.method_title || methodId). trim ();
179 const notes = [];
180
181 const classCostKeys = Object. keys (method?.settings || {}). filter (( key ) => / ^ class_cost_ \d +$ / . test (key) && ! isBlank (method.settings[key]?.value));
182 if (classCostKeys. length > 0 ) {
183 notes. push ( `Per-shipping-class cost overrides present (${ classCostKeys . join ( ', ' ) }) — not applied; Wix additionalCharges are unconditional per-order, not per-product-class, so mapping these would overcharge every other class. Verify manually.` );
184 }
185
186 if (methodId === 'flat_rate' ) {
187 const cost = method?.settings?.cost?.value;
188 if ( ! isNumericString (cost)) {
189 return { kind: 'gap' , enabled, title, reason: `flat_rate cost "${ cost }" is not a plain numeric value (formula costs like "10 * [qty]" have no static Wix equivalent)` , notes };
190 }
191 return { kind: 'carrier' , carrierRole: 'basic' , enabled, title, amount: toAmountString (cost), notes };
192 }
193
194 if (methodId === 'free_shipping' ) {
195 const requires = String (method?.settings?.requires?.value || '' ). trim ();
196 const minAmount = method?.settings?.min_amount?.value;
197 const ignoreDiscounts = method?.settings?.ignore_discounts?.value;
198
199 if (requires === '' ) {
200 // Genuinely unconditional. The only shape where a 0-amount, condition-free option is right.
201 return { kind: 'carrier' , carrierRole: 'basic' , enabled, title, amount: '0' , notes };
202 }
203 if (requires === 'coupon' ) {
204 notes. push ( 'Coupon-gated free shipping has no Wix delivery-settings counterpart: at checkout the buyer sees the normal delivery price and the coupon removes it. Handled entirely in the coupon domain — see resolveFreeShippingCouponCoverage. Nothing is lost, so this is NOT an unmigrated gap.' );
205 return { kind: 'coupon-domain' , enabled, title, notes };
206 }
207 if (requires === 'both' ) {
208 // "minimum order AND a coupon". Wix rate conditions can express the amount but have no
209 // coupon term, so a threshold-only migration would hand out free shipping the source
210 // withheld. Being MORE generous than the source is the same class of defect as being
211 // unconditional — report it, never guess it.
212 return {
213 kind: 'gap' ,
214 enabled,
215 title,
216 reason: `free_shipping requires="both" needs a minimum order (${ minAmount }) AND a coupon; a Wix rate condition can carry the amount but has no coupon term, so migrating the threshold alone would give free shipping the source withholds. Needs a merchant decision — reconfigure-in-wix.` ,
217 notes,
218 };
219 }
220 if (requires === 'min_amount' || requires === 'either' ) {
221 if ( ! isNumericString (minAmount)) {
222 return { kind: 'gap' , enabled, title, reason: `free_shipping requires="${ requires }" but min_amount "${ minAmount }" is not numeric — there is no threshold to build` , notes };
223 }
224 if (requires === 'either' ) {
225 notes. push ( `free_shipping requires="either" means the minimum order OR a coupon. The ${ minAmount } threshold migrates here; the coupon half is a coupon-domain concern (a Wix freeShipping coupon covers it).` );
226 }
227 notes. push ( thresholdDiscountBasisNote (ignoreDiscounts));
228 return { kind: 'threshold' , enabled, title, thresholdAmount: toAmountString (minAmount), amount: '0' , notes };
229 }
230 return { kind: 'gap' , enabled, title, reason: `free_shipping requires="${ requires }" is not a recognized WooCommerce value` , notes };
231 }
232
233 if (methodId === 'local_pickup' || methodId === 'pickup_location' ) {
234 const cost = method?.settings?.cost?.value;
235 const amount = isNumericString (cost) ? toAmountString (cost) : '0' ;
236 if ( ! isBlank (cost) && ! isNumericString (cost)) {
237 notes. push ( `local_pickup cost "${ cost }" is not a plain numeric value — the pickup location is created at 0 instead. Verify manually.` );
238 }
239 return { kind: 'pickup' , enabled, title, amount, notes };
240 }
241
242 return { kind: 'gap' , enabled, title, reason: `method_id "${ methodId }" has no calculation engine to replicate — likely a real-carrier integration; carrier-calculated rates have no Wix data equivalent (reconfigure-in-wix)` , notes };
243 }
244
245 // --- Delivery carrier ---------------------------------------------------------------------------
246 // `appId` resolved live by the caller (see resolveBasicShippingAppId/resolvePickupAppId in
247 // wix-writers.js) — required, never silently defaulted to undefined.
248 //
249 // A carrier attaches an APP to a region and supplies a fallback price; it is not the rate the
250 // buyer sees (that is the ShippingOption).
251 //
252 // PICKUP IS THE EXCEPTION, and it cuts both ways — LIVE-LEARNED 2026-09-01, both halves the
253 // hard way. An ACTIVE Pickup backupRate renders as a 0-priced DELIVERY row beside the paid
254 // one. But removing the Pickup carrier ENTIRELY made the collection point vanish from
255 // checkout, even with a live, active PickupLocation bound to the region. The region needs the
256 // app attached in order to render collection at all. So a pickup gets a carrier via
257 // buildPickupCarrierInput below — amount '0', active FALSE — never through this function, and
258 // never with an active rate.
259 function buildDeliveryCarrierInput ( classified , { appId }) {
260 if ( ! appId) throw new Error ( 'buildDeliveryCarrierInput: appId is required (resolve live for non-Basic-Shipping carriers)' );
261 if (classified.kind !== 'carrier' ) throw new Error ( `buildDeliveryCarrierInput: called on a non-carrier classification (${ classified . kind }) — only a real paid/free delivery choice gets an active carrier; pickup uses buildPickupCarrierInput (inactive rate) and a threshold folds into a sibling option` );
262 return {
263 appId,
264 backupRate: {
265 title: classified.title || 'Shipping' ,
266 amount: classified.amount,
267 active: true ,
268 },
269 };
270 }
271
272 // The Pickup carrier attachment: present so the region can RENDER collection, with an
273 // INACTIVE backup rate so the carrier itself never becomes a delivery row. Both halves matter
274 // and each was learned by breaking a live store in the opposite direction. `appId` is
275 // installation-scoped — resolve it live with wix-writers.js's resolvePickupAppId, never
276 // hardcode it. The PickupLocation is still what describes the actual collection point.
277 function buildPickupCarrierInput ( classified , { appId }) {
278 if ( ! appId) throw new Error ( 'buildPickupCarrierInput: appId is required (resolve the Pickup app live with resolvePickupAppId)' );
279 if (classified.kind !== 'pickup' ) throw new Error ( `buildPickupCarrierInput: called on a non-pickup classification (${ classified . kind })` );
280 return {
281 appId,
282 backupRate: {
283 title: classified.title || 'Pickup' ,
284 amount: '0' ,
285 // NEVER true. An active pickup backup rate is a 0-priced delivery row beside the paid one.
286 active: false ,
287 },
288 };
289 }
290
291 // --- Shipping option ----------------------------------------------------------------------------
292 // LIVE-DISCOVERED: a deliveryCarrier's backupRate (buildDeliveryCarrierInput above) does NOT make
293 // Wix consider a region as having a working rate at checkout, and does NOT clear Wix's own "This
294 // region is missing rates" dashboard warning. That is driven by a SEPARATE resource,
295 // ShippingOption (`/ecom/v1/shipping-options`). A region migration needs BOTH — never one
296 // without the other, for every 'carrier'-kind method.
297 //
298 // FIELD NAMES, read off the service definition (wix-private/ecom shipping_option.proto):
299 // - `estimatedDeliveryTime` (field 12, `estimated_delivery_time`, max 500). The proto's
300 // `translatable` annotation names it `estimate_delivery_time` -- a key in the translation
301 // system, NOT a request field. Wix accepts the misspelled `estimateDeliveryTime` with a 200 and
302 // silently drops it; that is how every migrated option lost its delivery time unnoticed. The
303 // builder never emits the misspelled key.
304 // - `deliveryRegionIds` (plural). The singular `deliveryRegionId` is deprecated with a removal
305 // target already in the past. Both are sent: the plural is what is honoured going forward,
306 // the singular keeps older reads/tooling working until it is actually removed.
307 // There is NO default estimate here. The text is resolved per option by resolveDeliveryEstimate
308 // (merchant > source > a reviewed default in the DESTINATION site's language > none), so an
309 // English promise can never reach a non-English checkout by falling through a default argument.
310 const ESTIMATED_DELIVERY_TIME_MAX = 500 ;
311 function buildShippingOptionInput ( classified , { deliveryRegionId , estimatedDeliveryTime , estimateDeliveryTime , rates } = {}) {
312 if ( ! deliveryRegionId) throw new Error ( 'buildShippingOptionInput: deliveryRegionId is required' );
313 if (classified.kind !== 'carrier' ) throw new Error ( `buildShippingOptionInput: called on a non-carrier classification (${ classified . kind }) — a pickup method must become a PickupLocation, not a shipping option` );
314 // The legacy (wrong) key is accepted as an input alias for one release so an older caller keeps
315 // working -- but it is translated, never emitted, and a conflicting pair is a caller bug.
316 if (estimatedDeliveryTime !== undefined && estimateDeliveryTime !== undefined && estimatedDeliveryTime !== estimateDeliveryTime) {
317 throw new Error ( 'buildShippingOptionInput: estimatedDeliveryTime and the legacy estimateDeliveryTime were both given with different values' );
318 }
319 const estimate = estimatedDeliveryTime !== undefined ? estimatedDeliveryTime : estimateDeliveryTime;
320 if (estimate !== undefined && estimate !== null && typeof estimate !== 'string' ) {
321 throw new Error ( 'buildShippingOptionInput: estimatedDeliveryTime must be a string' );
322 }
323 if ( typeof estimate === 'string' && estimate. length > ESTIMATED_DELIVERY_TIME_MAX ) {
324 throw new Error ( `buildShippingOptionInput: estimatedDeliveryTime is ${ estimate . length } characters; the service maximum is ${ ESTIMATED_DELIVERY_TIME_MAX } (never truncated)` );
325 }
326 const resolvedRates = Array. isArray (rates) && rates. length > 0 ? rates : [ unconditionalRate (classified.amount)];
327 return {
328 title: classified.title || 'Shipping' ,
329 ... ( typeof estimate === 'string' && estimate !== '' ? { estimatedDeliveryTime: estimate } : {}),
330 deliveryRegionId,
331 deliveryRegionIds: [deliveryRegionId],
332 rates: resolvedRates,
333 };
334 }
335
336 // --- Pickup location ----------------------------------------------------------------------------
337 // A Wix PickupLocation is its own entity, not a shipping option and not a delivery carrier.
338 // POST https://www.wixapis.com/pickup-locations/v1/pickup-locations, permission
339 // ECOM.PICKUP_LOCATION_CREATE. `address` is REQUIRED by the create call.
340 //
341 // The address is supplied by the caller, because where it comes from depends on the source
342 // shape — see resolvePickupAddressSource above. A source using Blocks Local Pickup already has
343 // real addresses; a source using the LEGACY local_pickup method has none anywhere, and only the
344 // merchant knows. Throwing here is deliberate either way: a silently address-less pickup
345 // location fails server-side with far less context, and inventing one is worse than stopping.
346 //
347 // `shippingRuleId` is deliberately NOT sent. It is deprecated and marked internal, replaced by
348 // `deliveryRegionIds`, with a removal target already in the past — the Wix dashboard still sends
349 // it, which is not a reason for a migration to.
350 //
351 // `amount` overrides the classification's fee, and exists because the two pickup shapes keep the
352 // fee in different places. LEGACY local_pickup carries it on the zone method, so
353 // classifyZoneMethod already has it. BLOCKS pickup carries it once, globally, in
354 // `pickup_location_settings.cost` — which the zone method does not have and the classification
355 // therefore reads as 0. A Blocks caller passes the resolved location's `amount` here; a legacy
356 // caller passes nothing and keeps the classified value.
357 function buildPickupLocationInput ( classified , { deliveryRegionIds , address , deliveryTime , instructions , active = true , amount } = {}) {
358 if (classified.kind !== 'pickup' ) throw new Error ( `buildPickupLocationInput: called on a non-pickup classification (${ classified . kind })` );
359 if ( ! Array. isArray (deliveryRegionIds) || deliveryRegionIds. length === 0 ) {
360 throw new Error ( 'buildPickupLocationInput: deliveryRegionIds is required (a pickup location must be bound to at least one delivery region)' );
361 }
362 if ( ! address || isBlank (address.country)) {
363 throw new Error ( 'buildPickupLocationInput: address with a country is required — WooCommerce \' s local_pickup method carries no address, so the caller must supply one from the store address or the merchant' );
364 }
365 const name = classified.title || 'Pickup' ;
366 return {
367 name,
368 address,
369 ... ( isBlank (deliveryTime) ? {} : { deliveryTime: String (deliveryTime) }),
370 ... ( isBlank (instructions) ? {} : { instructions: String (instructions) }),
371 active,
372 rates: [ unconditionalRate ( isBlank (amount) ? (classified.amount || '0' ) : String (amount))],
373 deliveryRegionIds: [ ... deliveryRegionIds],
374 };
375 }
376
377 // --- Where a collection address actually comes from ---------------------------------------------
378 // WooCommerce has TWO local-pickup implementations, and they differ on the one thing that
379 // matters here — whether an address exists in the source at all:
380 //
381 // 1. BLOCKS Local Pickup (`pickup_location` method). Real locations WITH addresses, stored as
382 // WordPress options and exposed on `GET /wp/v2/settings` as `pickup_locations[]`:
383 // { name, address: { address_1, city, state, postcode, country }, details, enabled }
384 // plus `pickup_location_settings` = { enabled, title, tax_status, cost }. Note the address
385 // shape here keeps `state` SEPARATE from `country` — unlike the store address's
386 // `woocommerce_default_country`, which packs both into one COUNTRY:STATE string.
387 //
388 // 2. LEGACY `local_pickup` zone method. Settings are only { title, tax_status, cost }. There is
389 // no address, no linked record, nothing to read. The town is often named in the method's own
390 // title text — that is a label, not an address, and must never be parsed as one.
391 //
392 // So "the source has no pickup address" is true only for the legacy shape. Check the Blocks
393 // locations FIRST; a store using them needs no human input at all.
394 // WooCommerce writes option booleans as the strings 'yes'/'no', but the same field arrives as a
395 // real boolean on some routes and is simply absent on others. Absent means "not configured", which
396 // for a global feature switch has to read as ON — a store with pickup locations and no explicit
397 // flag is using them. Only an explicit no/false turns it off.
398 function isExplicitlyDisabled ( value ) {
399 if (value === false ) return true ;
400 if ( typeof value !== 'string' ) return false ;
401 const normalized = value. trim (). toLowerCase ();
402 return normalized === 'no' || normalized === 'false' || normalized === '0' || normalized === 'off' ;
403 }
404
405 // `pickup_location_settings` is the GLOBAL half of Blocks Local Pickup and it owns two things no
406 // individual location carries:
407 //
408 // enabled — the master switch. Every location can be individually `enabled: true` while pickup
409 // as a whole is off, and reading only the per-location flag then re-enables a
410 // fulfillment method the merchant deliberately turned off.
411 // cost — the collection fee, set ONCE for all locations. Blocks pickup has no per-zone cost
412 // field, so `method.settings.cost` (which classifyZoneMethod reads, correctly, for the
413 // LEGACY local_pickup zone method) is absent for this shape. Without reading it here
414 // the fee silently becomes 0 and every pickup order loses it.
415 //
416 // Both fields sit in the payload the default discovery sweep already captures, so nothing extra
417 // has to be fetched — they simply have to be read.
418 function pickupLocationsFromWpSettings ( wpSettings ) {
419 const raw = wpSettings?.pickup_locations;
420 if ( ! Array. isArray (raw)) return [];
421 const globalSettings = wpSettings?.pickup_location_settings;
422 // The option is an object when configured; WooCommerce serializes an unconfigured one as [].
423 const global = globalSettings && typeof globalSettings === 'object' && ! Array. isArray (globalSettings) ? globalSettings : {};
424 const globallyDisabled = isExplicitlyDisabled (global.enabled);
425 const globalCost = global.cost;
426 const amount = isNumericString ( String (globalCost ?? '' )) ? toAmountString (globalCost) : '0' ;
427 return raw
428 . filter (( entry ) => entry && typeof entry === 'object' )
429 . map (( entry ) => {
430 const source = entry.address || {};
431 const address = {
432 ... ( isBlank (source.country) ? {} : { country: String (source.country). trim (). toUpperCase () }),
433 ... ( isBlank (source.state) ? {} : { subdivision: String (source.state). trim () }),
434 ... ( isBlank (source.city) ? {} : { city: String (source.city). trim () }),
435 ... ( isBlank (source.postcode) ? {} : { postalCode: String (source.postcode). trim () }),
436 // address_1 is one free-text line and Wix's addressLine is the matching free-text field.
437 // Same discipline as the store address: never parse a house number out of it.
438 ... ( isBlank (source.address_1) ? {} : { addressLine: String (source.address_1). trim () }),
439 };
440 return {
441 name: isBlank (entry.name) ? '' : String (entry.name). trim (),
442 address: Object. keys (address). length > 0 ? address : null ,
443 instructions: isBlank (entry.details) ? '' : String (entry.details). trim (),
444 // Both gates must pass. The master switch cannot be overridden by a per-location flag.
445 active: ! globallyDisabled && entry.enabled !== false ,
446 // The global fee, carried per location so the caller passes it straight to
447 // buildPickupLocationInput's `amount` without having to know where it came from.
448 amount,
449 ... (globallyDisabled ? { globallyDisabled: true } : {}),
450 };
451 });
452 }
453
454 // A pickup location with NO street address is a real merchant choice, not a failure. Plenty of
455 // shops coordinate collection by phone and deliberately do not publish a street — the source
456 // method's own title usually says so ("contact us to arrange collection"). Wix's create call
457 // REQUIRES the `address` object, so "blank" cannot mean absent; the honest minimum is the one
458 // part we genuinely know — the country, taken from the delivery region this pickup is bound to.
459 //
460 // Derived, never guessed: if the region's destinations span more than one country there is no
461 // single answer and this throws rather than picking one.
462 function buildMinimalPickupAddress ( destinations ) {
463 const countries = [ ...new Set ((destinations || []). map (( d ) => String (d?.countryCode || '' ). trim (). toUpperCase ()). filter (Boolean))];
464 if (countries. length === 0 ) {
465 throw new Error ( 'buildMinimalPickupAddress: the delivery region has no destination country to derive a minimal address from' );
466 }
467 if (countries. length > 1 ) {
468 throw new Error ( `buildMinimalPickupAddress: the delivery region spans ${ countries . length } countries (${ countries . join ( ', ' ) }) — there is no single country for a collection point; ask the merchant` );
469 }
470 return { country: countries[ 0 ] };
471 }
472
473 // Resolves the address question for a zone's pickup methods, in the order that avoids asking a
474 // human anything the source already answered:
475 // 'blocks' — the source carries real pickup locations with addresses. Build them directly.
476 // 'minimal' — the merchant was asked and declined to publish a street address. Country only,
477 // derived from the region; the location's NAME carries where and how to collect.
478 // An explicit choice, only ever reached via `addressDeclined`.
479 // 'proposal' — legacy method only. Nothing in the source has an address, so the store's own
480 // address (see store-config-build.js) is offered for a human to CONFIRM or
481 // replace. It is a starting point, never an answer: a shop's registered address
482 // and its collection point are routinely different places.
483 // 'ask' — legacy method and no store address captured either. Ask outright.
484 // The caller never invents an address in any branch.
485 function resolvePickupAddressSource ({ pickupPlans = [], wpSettings = null , storeAddress = null , addressDeclined = false , destinations = [] } = {}) {
486 const blocks = pickupLocationsFromWpSettings (wpSettings). filter (( location ) => location.address);
487 if (blocks. length > 0 ) {
488 return { strategy: 'blocks' , locations: blocks, needsConfirmation: false };
489 }
490 if ((pickupPlans || []). length === 0 ) {
491 return { strategy: 'none' , locations: [], needsConfirmation: false };
492 }
493 if (addressDeclined) {
494 return {
495 strategy: 'minimal' ,
496 locations: [],
497 minimalAddress: buildMinimalPickupAddress (destinations),
498 needsConfirmation: false ,
499 note: 'Address deliberately left incomplete at the merchant \' s request: country only, derived from the delivery region. The location name is what tells buyers where and how to collect. Wix accepts this because only the address OBJECT is required, not its parts — but a country-only collection point has not been verified for how it renders at checkout, so look at it once live.' ,
500 };
501 }
502 if (storeAddress) {
503 return {
504 strategy: 'proposal' ,
505 locations: [],
506 proposedAddress: storeAddress,
507 needsConfirmation: true ,
508 question: 'The source has a legacy pickup method, which carries no address. The store \' s own registered address is proposed below — confirm it is where customers collect, or replace it. These are routinely different places.' ,
509 };
510 }
511 return {
512 strategy: 'ask' ,
513 locations: [],
514 needsConfirmation: true ,
515 question: 'The source has a legacy pickup method, which carries no address, and no store address was captured either. Ask the merchant where customers collect.' ,
516 };
517 }
518
519 // --- Coupon-gated free shipping: look first, create last -----------------------------------------
520 // Decision: coupon-gated free shipping is carried by a coupon, so *something* has to exist — but
521 // this pipeline never invents one. Resolution order:
522 // 1. source — the source already has coupons carrying the free-shipping flag; the coupon
523 // pipeline is already moving them. Create nothing, record which codes cover it.
524 // 2. target — already present on the destination (a re-run, a partial earlier run, or a
525 // coupon the merchant made by hand). Create nothing.
526 // 3. none — report it. A dead source setting or a coupon discovery missed. The merchant
527 // decides; a migration does not conjure a discount code nobody asked for.
528 // `targetCoupons: null` means step 2 has not been run yet — the caller must do that live read
529 // before treating a 'none' as final, which is why the un-run state has its own value.
530 // A Wix coupon read back from the API nests its fields under `specification` — the query response
531 // is `{ coupon: { id, specification: { code, freeShipping, ... } } }`, which wix-writers.js's own
532 // post-create lookup already relies on (`coupon?.specification?.code`). Reading the flat shape
533 // only meant an existing target coupon came back as coverage 'none' on every re-run.
534 // Both shapes are accepted rather than one: a caller may hand over already-unwrapped
535 // specifications, and rejecting those would trade one wrong answer for another.
536 function couponSpec ( coupon ) {
537 if ( ! coupon || typeof coupon !== 'object' ) return {};
538 return coupon.specification && typeof coupon.specification === 'object' ? coupon.specification : coupon;
539 }
540
541 function resolveFreeShippingCouponCoverage ({ sourceCoupons = [], targetCoupons = null } = {}) {
542 const sourceMatches = (sourceCoupons || []). filter (( coupon ) => coupon?.free_shipping === true );
543 if (sourceMatches. length > 0 ) {
544 return { coverage: 'source' , codes: sourceMatches. map (( coupon ) => coupon.code). filter (Boolean), createCoupon: false };
545 }
546 if (targetCoupons === null || targetCoupons === undefined ) {
547 return { coverage: 'unchecked' , codes: [], createCoupon: false , reason: 'no free-shipping coupon in the source; query the destination for coupons with freeShipping=true before concluding anything' };
548 }
549 const targetMatches = (targetCoupons || []). filter (( coupon ) => couponSpec (coupon).freeShipping === true );
550 if (targetMatches. length > 0 ) {
551 return { coverage: 'target' , codes: targetMatches. map (( coupon ) => couponSpec (coupon).code). filter (Boolean), createCoupon: false };
552 }
553 return {
554 coverage: 'none' ,
555 codes: [],
556 createCoupon: false ,
557 reason: 'a coupon-gated free-shipping method exists but no coupon carries the behaviour, in the source or on the destination — either a dead source setting or a coupon the discovery missed. Report for a merchant decision; do not create one.' ,
558 };
559 }
560
561 // A source coupon may set the free-shipping flag AND a discount amount at once. Wix's
562 // `freeShipping` is its own coupon type and cannot also carry a value, so one half has to go.
563 // Decision: keep freeShipping, report the dropped amount with its code and value — the merchant
564 // re-creates a plain discount coupon in a minute, but the free-shipping behaviour has no other
565 // expression in Wix. Reported with both values so the call is reversible coupon by coupon.
566 function findFreeShippingCouponConflicts ( sourceCoupons ) {
567 return (sourceCoupons || [])
568 . filter (( coupon ) => coupon?.free_shipping === true && isNumericString ( String (coupon?.amount ?? '' )) && Number (coupon.amount) > 0 )
569 . map (( coupon ) => ({
570 code: coupon.code,
571 discountType: coupon.discount_type,
572 droppedAmount: String (coupon.amount),
573 keeping: 'freeShipping' ,
574 reason: 'Wix coupons cannot carry freeShipping and a discount amount at once. Keeping freeShipping; the amount above is not migrated. Re-create it as a separate discount coupon if the merchant wants both.' ,
575 }));
576 }
577
578 function buildDeliveryRegionInput ( zone , destinations ) {
579 if ( isBlank (zone?.name)) throw new Error ( 'buildDeliveryRegionInput: zone.name is required' );
580 return {
581 name: String (zone.name). trim (),
582 active: true ,
583 destinations,
584 };
585 }
586
587 function deliveryRegionDedupeKey ( destinations ) {
588 return [ ... (destinations || [])]
589 . map (( d ) => `${ String ( d . countryCode || '' ). toUpperCase () }:${ [ ... ( d . subdivisions || [])]. sort (). join ( ',' ) }` )
590 . sort ()
591 . join ( '|' );
592 }
593
594 // Wix's own checkout-blocking warning text (Tax/Delivery Locations UI), verbatim — reused here so
595 // the pipeline surfaces the exact same wording at the execution approval gate, not a paraphrase.
596 const MISSING_RATES_ALERT = 'This region is missing rates. Add them so customers can complete checkout.' ;
597
598 // A zone whose destinations resolve to a real region but which ends up with no way for a buyer
599 // to receive the order creates a Wix Delivery Region every matching buyer hits a dead end on.
600 // Three kinds count as coverage, and the last two are why this is not simply "has a carrier":
601 // - 'carrier' — a delivery choice, obviously.
602 // - 'pickup' — collection is a real way to receive an order; it just never becomes a carrier.
603 // - 'threshold' — with no paid sibling to fold onto it still yields a conditioned free option.
604 // The authoritative signal for the alert is the RESOLVED output (see planShippingZones), because
605 // a method can classify as coverage and still be folded away; this predicate stays exported for
606 // callers reasoning about a method list they have not resolved yet.
607 function hasWorkingCarrier ( methodPlans ) {
608 const covering = new Set ([ 'carrier' , 'pickup' , 'threshold' ]);
609 return (methodPlans || []). some (({ classified }) => covering. has (classified.kind) && classified.enabled === true );
610 }
611
612 // --- Option identity ------------------------------------------------------------
613 // One Wix option can come from several source methods (a threshold folded onto a paid rate, or
614 // two identical rows collapsed), so its identity is the SET of source method instance ids, not a
615 // title. Sorted numerically and de-duplicated, so the same zone always yields the same key.
616 function sortInstanceIds ( ids ) {
617 const unique = [ ...new Set ((ids || []). filter (( id ) => id !== undefined && id !== null && id !== '' ). map (String))];
618 return unique. sort (( a , b ) => {
619 const na = Number (a);
620 const nb = Number (b);
621 if (Number. isFinite (na) && Number. isFinite (nb) && na !== nb) return na - nb;
622 return a. localeCompare (b);
623 });
624 }
625
626 function methodInstanceIds ( ... plans ) {
627 return sortInstanceIds (plans. filter (Boolean). map (({ method }) => (method ? (method.instance_id ?? method.id) : undefined )));
628 }
629
630 function shippingOptionKey ( zoneId , option ) {
631 if (zoneId === undefined || zoneId === null || zoneId === '' ) throw new Error ( 'shippingOptionKey: zoneId is required' );
632 const ids = sortInstanceIds (option && option.sourceMethodInstanceIds);
633 if (ids. length === 0 ) throw new Error ( `shippingOptionKey: option in zone ${ zoneId } carries no sourceMethodInstanceIds` );
634 return `${ zoneId }:${ ids . join ( '+' ) }` ;
635 }
636
637 function ratesKey ( rates ) {
638 return JSON . stringify (rates || []);
639 }
640
641 // --- Resolving a zone's methods to the merchant's actual policy ---------------------------------
642 // This is the step that stops the pipeline photocopying the source's own display defect.
643 //
644 // 1. Enabled 'threshold' methods are removed as rows and folded onto the paid 'carrier' options
645 // in the same zone, as an explicit LT/GTE price partition. Several thresholds -> the LOWEST
646 // one wins, matching WooCommerce showing free shipping as soon as any threshold is met.
647 // 2. A zone with a threshold and no paid option to fold onto keeps the threshold as its own
648 // option, carrying the GTE condition. Never as an unconditional 0.
649 // 3. Identical survivors (same title, same rates) collapse to one.
650 // Every removal is recorded in resolvedSourceDefects[] with what was collapsed and why —
651 // a silent collapse is indistinguishable from never having noticed.
652 function resolveZoneMethods ( methodPlans ) {
653 const enabledPlans = (methodPlans || []). filter (({ classified }) => classified.enabled === true );
654 const thresholds = enabledPlans. filter (({ classified }) => classified.kind === 'threshold' );
655 const carriers = enabledPlans. filter (({ classified }) => classified.kind === 'carrier' );
656 const pickups = enabledPlans. filter (({ classified }) => classified.kind === 'pickup' );
657 const couponDomain = enabledPlans. filter (({ classified }) => classified.kind === 'coupon-domain' );
658
659 const resolvedSourceDefects = [];
660 const lowestThresholdPlan = [ ... thresholds]
661 . sort (( a , b ) => Number (a.classified.thresholdAmount) - Number (b.classified.thresholdAmount))[ 0 ];
662 const lowestThreshold = lowestThresholdPlan ? lowestThresholdPlan.classified : undefined ;
663
664 const paidCarriers = carriers. filter (({ classified }) => Number (classified.amount) > 0 );
665
666 const optionPlans = carriers. map (( plan ) => {
667 const { classified } = plan;
668 const isPaid = Number (classified.amount) > 0 ;
669 if (lowestThreshold && isPaid) {
670 return {
671 classified,
672 // LIVE-VERIFIED 2026-09-16: priceThresholdRates' explicit LT/GTE
673 // two-band partition is REJECTED by the Wix dashboard's own rate editor -- it renders
674 // as "Rate by price" mode with invalid, unfillable "From"/"Up to" boundary fields (a
675 // merchant opening it sees validation errors), even though the API accepts the write
676 // and the underlying checkout economics were confirmed correct by
677 // verifyShippingEconomics. Switching to an unconditional base rate PLUS a separate
678 // GTE-threshold free rate -- relying on the service's own documented "more than one
679 // valid rate: return the lowest" tiebreak, exactly what freeAboveThresholdRates is for
680 // -- rendered correctly as "Flat rate" mode with the "Offer free shipping when
681 // customers purchase a certain amount" checkbox, confirmed live by the user. This is
682 // the correct encoding for a paid-carrier-plus-threshold fold; priceThresholdRates
683 // itself is kept (still schema-valid, still economically correct) but is no longer
684 // used here, and should not be reached for by new code without re-verifying the
685 // dashboard editor first.
686 rates: [ unconditionalRate (classified.amount), ... freeAboveThresholdRates (lowestThreshold.thresholdAmount)],
687 notes: [ ... classified.notes, ... lowestThreshold.notes],
688 sourceMethodTitles: [classified.title, lowestThreshold.title],
689 sourceMethodInstanceIds: methodInstanceIds (plan, lowestThresholdPlan),
690 };
691 }
692 return { classified, rates: [ unconditionalRate (classified.amount)], notes: [ ... classified.notes], sourceMethodTitles: [classified.title], sourceMethodInstanceIds: methodInstanceIds (plan) };
693 });
694
695 if (lowestThreshold) {
696 if (paidCarriers. length > 0 ) {
697 resolvedSourceDefects. push ({
698 kind: 'threshold-folded' ,
699 removedOption: lowestThreshold.title,
700 foldedOnto: paidCarriers. map (({ classified }) => classified.title),
701 summary: `The source lists "${ lowestThreshold . title }" as its own delivery choice, but it is a minimum-order rule, not a choice — WooCommerce shows it alongside the paid option, so buyers see the same delivery offered twice. Migrated as a price band on ${ paidCarriers . length === 1 ? 'that option' : 'those options'} instead: full price under ${ lowestThreshold . thresholdAmount }, free at ${ lowestThreshold . thresholdAmount } and above.` ,
702 });
703 } else {
704 optionPlans. push ({
705 classified: { kind: 'carrier' , carrierRole: 'basic' , enabled: true , title: lowestThreshold.title, amount: '0' , notes: lowestThreshold.notes },
706 rates: freeAboveThresholdRates (lowestThreshold.thresholdAmount),
707 notes: [ ... lowestThreshold.notes, 'No paid option in this zone to fold the threshold onto, so it stays a standalone option — conditioned, never unconditional.' ],
708 sourceMethodTitles: [lowestThreshold.title],
709 sourceMethodInstanceIds: methodInstanceIds (lowestThresholdPlan),
710 });
711 }
712 for ( const extra of thresholds. map (({ classified }) => classified). filter (( c ) => c !== lowestThreshold)) {
713 resolvedSourceDefects. push ({
714 kind: 'threshold-superseded' ,
715 removedOption: extra.title,
716 summary: `A second minimum-order free-shipping rule at ${ extra . thresholdAmount } is superseded by the lower ${ lowestThreshold . thresholdAmount } threshold, which is already met first. Not migrated as a separate row.` ,
717 });
718 }
719 }
720
721 for ( const { classified } of couponDomain) {
722 resolvedSourceDefects. push ({
723 kind: 'coupon-domain-removed' ,
724 removedOption: classified.title,
725 summary: `"${ classified . title }" is coupon-gated free shipping. In Wix that lives entirely on the coupon — checkout shows the normal delivery price and the coupon removes it — so it is not a delivery setting and produces no shipping row. Nothing is lost.` ,
726 });
727 }
728
729 const deduped = [];
730 const seen = new Map ();
731 for ( const option of optionPlans) {
732 const key = `${ option . classified . title } \u0000 ${ ratesKey ( option . rates ) }` ;
733 if (seen. has (key)) {
734 const kept = seen. get (key);
735 kept.sourceMethodTitles. push ( ... option.sourceMethodTitles);
736 kept.sourceMethodInstanceIds = sortInstanceIds ([ ... kept.sourceMethodInstanceIds, ... option.sourceMethodInstanceIds]);
737 resolvedSourceDefects. push ({
738 kind: 'duplicate-collapsed' ,
739 removedOption: option.classified.title,
740 summary: `The source has more than one method producing an identical "${ option . classified . title }" option at the same price, so buyers see the same delivery choice listed twice. Migrated once.` ,
741 });
742 continue ;
743 }
744 seen. set (key, option);
745 deduped. push (option);
746 }
747
748 return { shippingOptions: deduped, pickups, couponDomain, resolvedSourceDefects };
749 }
750
751 // A zone with empty locations AND empty methods is WooCommerce's unused default "catch-all"
752 // (id 0, "Locations not covered by your other zones" on a fresh install) — never migrate it
753 // speculatively; only zones the merchant actually configured produce a plan.
754 function planShippingZones ( zones , { sourceCoupons = [], targetCoupons = null } = {}) {
755 const plans = [];
756 for ( const zone of zones || []) {
757 const hasLocations = Array. isArray (zone.locations) && zone.locations. length > 0 ;
758 const hasMethods = Array. isArray (zone.methods) && zone.methods. length > 0 ;
759 if ( ! hasLocations && ! hasMethods) continue ;
760
761 const { destinations , gaps : locationGaps } = normalizeZoneLocations (zone.locations);
762 const methodPlans = (zone.methods || []). map (( method ) => ({ method, classified: classifyMethod (method) }));
763 const resolved = resolveZoneMethods (methodPlans);
764
765 const plan = {
766 zone,
767 destinations,
768 locationGaps,
769 methodPlans,
770 shippingOptions: resolved.shippingOptions,
771 pickups: resolved.pickups,
772 resolvedSourceDefects: resolved.resolvedSourceDefects,
773 couponCoverage: resolved.couponDomain. length > 0
774 ? resolveFreeShippingCouponCoverage ({ sourceCoupons, targetCoupons })
775 : null ,
776 };
777 // Judged on the RESOLVED output, not on the raw method list: a zone can look covered
778 // method-by-method and still resolve to nothing a buyer can pick.
779 if (destinations. length > 0 && plan.shippingOptions. length === 0 && plan.pickups. length === 0 ) {
780 plan.alert = MISSING_RATES_ALERT ;
781 }
782 plans. push (plan);
783 }
784 return plans;
785 }
786
787 function shouldSkipShippingDomain ( zones ) {
788 return planShippingZones (zones). length === 0 ;
789 }
790
791 // --- Verification: check the money, not the row count -------------------------------------------
792 // "3 options created" passed while a live store was broken in three different ways at once, so
793 // the shipping verification asserts economics against what was actually read back:
794 // (a) nothing is free-and-unconditional while something else on the same region costs money —
795 // the exact shape of "a dropped condition turned 'free above X' into 'free always'";
796 // (b) no shipping option was created for a source pickup method;
797 // (c) every carrier's appId matches the role it was classified as;
798 // (d) no two options on one region are identical.
799 // Returns violations[]; an empty array is the only passing result.
800 function verifyShippingEconomics ({ regionName , shippingOptions = [], carriers = [], pickupTitles = [], expectedCarrierAppIds = {} } = {}) {
801 const violations = [];
802 const where = regionName ? ` on region "${ regionName }"` : '' ;
803
804 const isUnconditionalFree = ( option ) => (option.rates || []). some (( rate ) => Number (rate.amount) === 0 && (rate.conditions || []). length === 0 );
805 const anyPaid = shippingOptions. some (( option ) => (option.rates || []). some (( rate ) => Number (rate.amount) > 0 ));
806 if (anyPaid) {
807 for ( const option of shippingOptions. filter (isUnconditionalFree)) {
808 violations. push ({
809 check: 'unconditional-free-alongside-paid' ,
810 severity: 'blocker' ,
811 summary: `"${ option . title }"${ where } is free with no condition while another option costs money. Buyers will always pick the free one, so the paid rate is unreachable — this is what a dropped source condition looks like.` ,
812 });
813 }
814 }
815
816 const pickupSet = new Set (pickupTitles. map (( title ) => String (title)));
817 for ( const option of shippingOptions) {
818 if (pickupSet. has ( String (option.title))) {
819 violations. push ({
820 check: 'shipping-option-for-a-pickup-method' ,
821 severity: 'blocker' ,
822 summary: `"${ option . title }"${ where } came from a source pickup method but was written as a shipping option. Collection then shows as a 0-priced delivery next to the paid one. It must be a PickupLocation.` ,
823 });
824 }
825 }
826
827 for ( const carrier of carriers) {
828 const expected = expectedCarrierAppIds[carrier.role];
829 if (expected && carrier.appId !== expected) {
830 violations. push ({
831 check: 'carrier-app-id-does-not-match-role' ,
832 severity: 'blocker' ,
833 summary: `A carrier classified "${ carrier . role }"${ where } read back with appId ${ carrier . appId }, expected ${ expected }.` ,
834 });
835 }
836 }
837
838 const seen = new Set ();
839 for ( const option of shippingOptions) {
840 const key = `${ option . title } \u0000 ${ ratesKey ( option . rates ) }` ;
841 if (seen. has (key)) {
842 violations. push ({
843 check: 'duplicate-option' ,
844 severity: 'blocker' ,
845 summary: `"${ option . title }"${ where } exists more than once with identical rates — the same delivery choice is listed twice at checkout.` ,
846 });
847 continue ;
848 }
849 seen. add (key);
850 }
851
852 return violations;
853 }
854
855 // =================================================================================================
856 // The delivery estimate: resolution, verified-write accounting, follow-up items.
857 // Everything below is pure except applyTargetMissingDecision/auditShippingEstimates, which READ
858 // the destination (GET only) and never write to it.
859 // =================================================================================================
860
861 // The reviewed defaults. Adding a language means adding a reviewed row here first;
862 // an unlisted language gets NO estimate and a merchant item, never English.
863 const DEFAULT_ESTIMATE_BY_LANGUAGE = Object. freeze ({
864 en: '5-7 business days' ,
865 he: '5–7 ימי עסקים' ,
866 es: '5-7 días hábiles' ,
867 fr: '5 à 7 jours ouvrés' ,
868 de: '5–7 Werktage' ,
869 it: '5-7 giorni lavorativi' ,
870 pt: '5 a 7 dias úteis' ,
871 nl: '5-7 werkdagen' ,
872 });
873
874 const SHIPPING_OPTION_ENTITY = 'shipping-option' ;
875 const SHIPPING_OPTION_TARGET_TYPE = 'ecom/shipping-option' ;
876 const SHIPPING_OPTION_OPERATION = 'createShippingOptionVerified' ;
877 const SHIPPING_ESTIMATES_FILE = 'execution/shipping-estimates.json' ;
878 const ORCHESTRATION_STATE_CLI = 'skills/wix-replatform/scripts/orchestration-state.js' ;
879 const SHIPPING_ESTIMATE_AUDIT_CLI = 'skills/wix-replatform/resources/rp-execute-import/scripts/shipping-estimate-audit.js' ;
880 const SHIPPING_COMPLETION_CODES = Object. freeze ([
881 'shipping-estimate-default' ,
882 'shipping-estimate-missing' ,
883 'shipping-estimate-legacy-blank' ,
884 'shipping-option-reconcile' ,
885 'shipping-option-target-missing' ,
886 'shipping-region-preexisting-options' ,
887 'shipping-region-coverage-differs' ,
888 'shipping-placeholder-option-deleted' ,
889 ]);
890 const SHIPPING_PLACEHOLDER_LEDGER = 'execution/shipping-placeholder-deletions.json' ;
891 const ACTION_LIST_LIMIT = 15 ;
892
893 function textOf ( value ) {
894 if (value === undefined || value === null ) return '' ;
895 if ( typeof value === 'string' ) return value. trim ();
896 if ( typeof value === 'number' || typeof value === 'boolean' ) return String (value);
897 return '' ;
898 }
899
900 // The DESTINATION site's language, from a Site Properties read. `null` (a failed read, or nothing
901 // set) is treated as an unsupported language by resolveDeliveryEstimate.
902 function siteLanguageFromProperties ( properties ) {
903 const raw = textOf (properties && properties.language) || textOf (properties && properties.locale && properties.locale.languageCode);
904 return raw ? raw. toLowerCase () : null ;
905 }
906
907 function primaryLanguage ( siteLanguage ) {
908 const raw = textOf (siteLanguage). toLowerCase ();
909 return raw ? raw. split ( / [-_] / )[ 0 ] : null ;
910 }
911
912 function resolveDeliveryEstimate ({ sourceText , merchantText , siteLanguage } = {}) {
913 const merchant = textOf (merchantText);
914 if (merchant) return { text: merchant, origin: 'merchant' };
915 const source = textOf (sourceText);
916 if (source) return { text: source, origin: 'source' };
917 const language = primaryLanguage (siteLanguage);
918 if (language && Object . prototype .hasOwnProperty. call ( DEFAULT_ESTIMATE_BY_LANGUAGE , language)) {
919 return { text: DEFAULT_ESTIMATE_BY_LANGUAGE [language], origin: 'default' };
920 }
921 return { text: '' , origin: 'missing' };
922 }
923
924 // Only a `source: "user"` decision is the merchant's own words. Anything else is listed, never used.
925 function userDecision ( decisions , key ) {
926 const entry = decisions && typeof decisions === 'object' ? decisions[key] : undefined ;
927 if ( ! entry || typeof entry !== 'object' || Array. isArray (entry)) return { entry: null , ignored: null };
928 if (entry.source === 'user' ) return { entry, ignored: null };
929 return { entry: null , ignored: { key, source: entry.source === undefined ? null : entry.source } };
930 }
931
932 function planShippingEstimates ({ zonePlans = [], decisions = {}, siteLanguage = null } = {}) {
933 const options = [];
934 const ignoredDecisions = [];
935 const ignoredKeys = new Set ();
936 for ( const plan of zonePlans || []) {
937 const zone = plan.zone || {};
938 const zoneId = String (zone.id);
939 const decisionKey = `shipping-delivery-estimate:${ zoneId }` ;
940 const { entry , ignored } = userDecision (decisions, decisionKey);
941 if (ignored && ! ignoredKeys. has (decisionKey)) {
942 ignoredKeys. add (decisionKey);
943 ignoredDecisions. push (ignored);
944 }
945 const sourceText = textOf (zone.deliveryEstimate);
946 const merchantText = entry ? textOf (entry.value) : '' ;
947 for ( const option of plan.shippingOptions || []) {
948 const { text , origin } = resolveDeliveryEstimate ({ sourceText, merchantText, siteLanguage });
949 options. push ({
950 optionKey: shippingOptionKey (zoneId, option),
951 zoneId,
952 zoneName: textOf (zone.name),
953 title: option.classified.title || 'Shipping' ,
954 sourceText,
955 merchantText,
956 text,
957 origin,
958 });
959 }
960 }
961 return { schemaVersion: 1 , siteLanguage: siteLanguage || null , options, ignoredDecisions, legacy: [] };
962 }
963
964 // --- Read-back verification -----------------------------------------------------
965 function canonicalCondition ( condition ) {
966 return JSON . stringify ([ textOf (condition && condition.type), textOf (condition && condition.operator), Number (condition && condition.value)]);
967 }
968
969 function canonicalRate ( rate ) {
970 return JSON . stringify ([
971 Number (rate && rate.amount),
972 (Array. isArray (rate && rate.conditions) ? rate.conditions : []). map (canonicalCondition). sort (),
973 Boolean (rate && rate.multiplyByQuantity),
974 ]);
975 }
976
977 function ratesEqual ( sentRates , storedRates ) {
978 const a = (Array. isArray (sentRates) ? sentRates : []). map (canonicalRate). sort ();
979 const b = (Array. isArray (storedRates) ? storedRates : []). map (canonicalRate). sort ();
980 return a. length === b. length && a. every (( value , index ) => value === b[index]);
981 }
982
983 function storedEstimate ( option ) {
984 return typeof (option && option.estimatedDeliveryTime) === 'string' ? option.estimatedDeliveryTime : '' ;
985 }
986
987 function verifyShippingOptionReadBack ( sent , stored , { ignoreEstimate = false } = {}) {
988 const mismatched = [];
989 if ( ! stored || typeof stored !== 'object' ) return { ok: false , mismatched: [ 'title' , 'estimatedDeliveryTime' , 'rates' ] };
990 if ((sent && sent.title) !== stored.title) mismatched. push ( 'title' );
991 if ( ! ignoreEstimate && storedEstimate (sent) !== storedEstimate (stored)) mismatched. push ( 'estimatedDeliveryTime' );
992 if ( ! ratesEqual (sent && sent.rates, stored.rates)) mismatched. push ( 'rates' );
993 return { ok: mismatched. length === 0 , mismatched };
994 }
995
996 // --- Accounting: one result -> the exact journal/crosswalk rows ------------------
997 const OUTCOME_ROWS = {
998 'shipping-option-created' : { status: 'imported' },
999 'shipping-option-unverified' : { status: 'succeeded_unverified' , errorCode: 'READ_BACK_MISMATCH' },
1000 'shipping-option-unknown' : { status: 'succeeded_unverified' , errorCode: 'OUTCOME_UNKNOWN' },
1001 'shipping-option-ambiguous' : { status: 'succeeded_unverified' , errorCode: 'AMBIGUOUS_MATCH' },
1002 'shipping-option-absent' : { status: 'failed_retryable' , errorCode: 'KNOWN_ABSENT' },
1003 'shipping-option-not-found' : { status: 'failed_terminal' , errorCode: 'NOT_FOUND' },
1004 'shipping-option-not-attempted' : { status: 'failed_retryable' },
1005 };
1006
1007 function failedStatusFor ( httpStatus ) {
1008 return httpStatus === 429 || (Number. isInteger (httpStatus) && httpStatus >= 500 ) ? 'failed_retryable' : 'failed_terminal' ;
1009 }
1010
1011 // The message is prose only: every identifier lives in a structured field.
1012 function outcomeMessage ( result , status ) {
1013 switch (result.outcome) {
1014 case 'shipping-option-unverified' :
1015 return result.reason || `read-back did not match what was sent (${ ( result . mismatched || []). join ( ', ' ) || 'unknown fields'})` ;
1016 case 'shipping-option-unknown' :
1017 return `create outcome unknown (${ result . reason || 'no readable reply'}); reconcile by reading the region back, never re-send` ;
1018 case 'shipping-option-ambiguous' :
1019 return `several options match after an unknown outcome (${ ( result . candidateTargetIds || []). length } candidates); a recorded shipping-option-adopt decision is required` ;
1020 case 'shipping-option-absent' :
1021 return 'reconciliation found no option this run created; the next normal pass may create it' ;
1022 case 'shipping-option-not-found' :
1023 return 'the recorded target returned 404; never re-created automatically' ;
1024 case 'shipping-option-not-attempted' :
1025 return result.reason || 'not attempted' ;
1026 case 'shipping-option-failed' :
1027 if (result.requestSent === false ) return `create refused before sending${ result . errorCode ? ` (${ result . errorCode })` : ''}; nothing reached Wix` ;
1028 return `create rejected with HTTP ${ result . httpStatus === null || result . httpStatus === undefined ? 'unknown' : result . httpStatus }${ result . errorCode ? ` (${ result . errorCode })` : ''}` ;
1029 default :
1030 return status;
1031 }
1032 }
1033
1034 function shippingOptionWriteRecord ( result , ctx = {}) {
1035 if ( ! result || typeof result.outcome !== 'string' ) throw new Error ( 'shippingOptionWriteRecord: result.outcome is required' );
1036 for ( const field of [ 'attemptId' , 'sourceSystem' , 'sourceId' , 'sourceStableKey' , 'writeSpecId' , 'runId' , 'phase' ]) {
1037 if ( ! ctx[field] || typeof ctx[field] !== 'string' ) throw new Error ( `shippingOptionWriteRecord: ctx.${ field } is required` );
1038 }
1039 let status;
1040 let errorCode = null ;
1041 if (result.outcome === 'shipping-option-failed' ) {
1042 // A create refused before sending is retryable: nothing was sent.
1043 status = result.requestSent === false ? 'failed_retryable' : failedStatusFor (result.httpStatus);
1044 errorCode = result.errorCode || null ;
1045 } else if ( OUTCOME_ROWS [result.outcome]) {
1046 status = OUTCOME_ROWS [result.outcome].status;
1047 errorCode = result.errorCode || OUTCOME_ROWS [result.outcome].errorCode || null ;
1048 } else {
1049 throw new Error ( `shippingOptionWriteRecord: unknown outcome ${ result . outcome }` );
1050 }
1051 const id = result.id || null ;
1052 const baseReconcile = ctx.reconcile && typeof ctx.reconcile === 'object' ? ctx.reconcile : null ;
1053 const candidateTargetIds = Array. isArray (result.candidateTargetIds) && result.candidateTargetIds. length > 0 ? [ ... result.candidateTargetIds] : null ;
1054 const reconcile = baseReconcile || candidateTargetIds
1055 ? { ... (baseReconcile || {}), ... (candidateTargetIds ? { candidateTargetIds } : {}) }
1056 : undefined ;
1057 const attempt = {
1058 schemaVersion: 1 ,
1059 attemptId: ctx.attemptId,
1060 sourceSystem: ctx.sourceSystem,
1061 sourceEntityType: SHIPPING_OPTION_ENTITY ,
1062 sourceId: ctx.sourceId,
1063 sourceStableKey: ctx.sourceStableKey,
1064 writeSpecId: ctx.writeSpecId,
1065 runId: ctx.runId,
1066 phase: ctx.phase,
1067 operation: SHIPPING_OPTION_OPERATION ,
1068 targetEntityType: SHIPPING_OPTION_TARGET_TYPE ,
1069 status,
1070 ... (status === 'imported' ? { targetId: id } : {}),
1071 ... (result.estimateKept ? { estimateKept: result.estimateKept } : {}),
1072 ... (reconcile ? { reconcile } : {}),
1073 ... (result.requestSent === false ? { requestSent: false } : {}),
1074 updatedAt: ctx.now || new Date (). toISOString (),
1075 };
1076 if (status !== 'imported' ) {
1077 attempt.failure = {
1078 sourceId: ctx.sourceId,
1079 targetEntityId: id,
1080 operation: SHIPPING_OPTION_OPERATION ,
1081 httpStatus: Number. isInteger (result.httpStatus) ? result.httpStatus : null ,
1082 errorCode,
1083 scopeGroupName: null ,
1084 outcome: { failed_retryable: 'failed_retryable' , failed_terminal: 'failed_terminal' , succeeded_unverified: 'unresolved' }[status],
1085 ... (Array. isArray (result.mismatched) && result.mismatched. length ? { mismatched: [ ... result.mismatched] } : {}),
1086 message: outcomeMessage (result, status),
1087 };
1088 }
1089 const crosswalk = status === 'imported'
1090 ? {
1091 schemaVersion: 1 ,
1092 sourceSystem: ctx.sourceSystem,
1093 sourceEntityType: SHIPPING_OPTION_ENTITY ,
1094 sourceId: ctx.sourceId,
1095 sourceStableKey: ctx.sourceStableKey,
1096 targetSystem: 'wix' ,
1097 targetEntityType: SHIPPING_OPTION_TARGET_TYPE ,
1098 targetId: id,
1099 status: 'imported' ,
1100 updatedAt: attempt.updatedAt,
1101 }
1102 : null ;
1103 return { attempt, crosswalk };
1104 }
1105
1106 // --- Linked targets: missing-target decisions and the read-only audit --------
1107 function isNotFound ( error ) {
1108 return Boolean (error && error.status === 404 && error.outcomeKnown !== false );
1109 }
1110
1111 async function readShippingOption ( wix , id ) {
1112 const { getShippingOption } = require ( './wix-writers.js' );
1113 try {
1114 const option = await getShippingOption (wix, id);
1115 // A 2xx without the requested option is an unknown read, never evidence: no blank-estimate
1116 // conclusion and no crosswalk adoption may follow from it.
1117 if ( ! option || typeof option !== 'object' || Array. isArray (option) || option.id !== id) {
1118 return { ok: false , notFound: false , malformed: true };
1119 }
1120 return { ok: true , option };
1121 } catch (error) {
1122 if (wixAuth. isRunInterruption (error)) throw error;
1123 return { ok: false , notFound: isNotFound (error), error };
1124 }
1125 }
1126
1127 // One shared applier for the shipping stage and the finished-project audit, so a recorded
1128 // `shipping-option-target-missing:<optionKey>` decision behaves identically in both. It never
1129 // writes to Wix; a changed adoption returns ONE replacement crosswalk row for the caller to upsert.
1130 async function applyTargetMissingDecision ( wix , { crosswalkRow , decisions = {}, now } = {}) {
1131 if ( ! crosswalkRow || ! crosswalkRow.sourceId) throw new Error ( 'applyTargetMissingDecision: crosswalkRow is required' );
1132 const key = `shipping-option-target-missing:${ crosswalkRow . sourceId }` ;
1133 const { entry , ignored } = userDecision (decisions, key);
1134 const value = entry ? textOf (entry.value) : '' ;
1135 if ( ! entry || ! value) return { state: 'open' , ... (ignored ? { ignoredDecision: ignored } : {}) };
1136 if (value === 'retired' ) return { state: 'retired' };
1137 const match = / ^ recreated:( . + ) $ / . exec (value);
1138 if ( ! match) return { state: 'open' , invalidDecision: { key, value } };
1139 const newTargetId = match[ 1 ]. trim ();
1140 if (crosswalkRow.targetId === newTargetId && crosswalkRow.provenance === 'operator-recreated' ) {
1141 // Already adopted by an earlier run: no mutation, so a decision that stays in decisions.json
1142 // can never grow or rewrite the crosswalk.
1143 return { state: 'adopted' , auditRow: crosswalkRow };
1144 }
1145 const read = await readShippingOption (wix, newTargetId);
1146 if ( ! read.ok && read.notFound) return { state: 'recorded-id-missing' , newTargetId };
1147 if ( ! read.ok) return { state: 'open' , readError: true , newTargetId };
1148 const replacement = {
1149 ... crosswalkRow,
1150 targetId: newTargetId,
1151 provenance: 'operator-recreated' ,
1152 updatedAt: now || new Date (). toISOString (),
1153 };
1154 delete replacement.verification;
1155 return { state: 'adopted' , crosswalkUpsert: replacement, auditRow: replacement, option: read.option };
1156 }
1157
1158 function fixedWriterTargetIds ( attemptRows ) {
1159 const ids = new Set ();
1160 for ( const row of attemptRows || []) {
1161 if (row && row.status === 'imported' && row.operation === SHIPPING_OPTION_OPERATION && row.targetId) ids. add (row.targetId);
1162 }
1163 return ids;
1164 }
1165
1166 function resolutionByKey ( resolution ) {
1167 const map = new Map ();
1168 for ( const option of (resolution && Array. isArray (resolution.options) ? resolution.options : [])) map. set (option.optionKey, option);
1169 return map;
1170 }
1171
1172 function optionLabel ( optionKey , resolution ) {
1173 const resolved = resolutionByKey (resolution). get (optionKey);
1174 return resolved ? `${ resolved . zoneName } → ${ resolved . title } [${ optionKey }]` : `option ${ optionKey }` ;
1175 }
1176
1177 // Read-only. Processes the EFFECTIVE crosswalk (one row per source key), so each source option gets
1178 // at most one GET, one result and one contribution per run. Returns what the fragment needs:
1179 // readBack — optionKey -> stored option, for targets the fixed writer created (or an
1180 // adopted operator-recreated target), feeding -default / -missing;
1181 // legacyBlank — earlier-migration options still showing no estimate (report only);
1182 // targetMissing — linked targets that 404 (either provenance), with no valid user decision;
1183 // crosswalkUpserts — replacement rows for a changed `recreated:<id>` adoption.
1184 async function auditShippingEstimates ( wix , { crosswalkBySource = {}, attemptRows = [], resolution = null , decisions = {}, now } = {}) {
1185 if (Array. isArray (crosswalkBySource)) throw new Error ( 'auditShippingEstimates: pass the effective crosswalk bySource map, never raw crosswalk history' );
1186 const fixedIds = fixedWriterTargetIds (attemptRows);
1187 const resolved = resolutionByKey (resolution);
1188 const out = { readBack: {}, recreated: {}, legacyBlank: [], targetMissing: [], crosswalkUpserts: [], retired: [], gets: 0 };
1189 const rows = Object. values (crosswalkBySource)
1190 . filter (( row ) => row && row.targetEntityType === SHIPPING_OPTION_TARGET_TYPE )
1191 . sort (( a , b ) => String (a.sourceStableKey). localeCompare ( String (b.sourceStableKey)));
1192 for ( const linked of rows) {
1193 const optionKey = linked.sourceId;
1194 const label = optionLabel (optionKey, resolution);
1195 const decision = await applyTargetMissingDecision (wix, { crosswalkRow: linked, decisions, now });
1196 if (decision.state === 'retired' ) {
1197 out.retired. push (optionKey);
1198 continue ;
1199 }
1200 if (decision.state === 'recorded-id-missing' ) {
1201 out.targetMissing. push ({
1202 optionKey, targetId: linked.targetId, provenance: fixedIds. has (linked.targetId) ? 'fixed-writer' : 'earlier-migration' ,
1203 label, recordedIdMissing: true , newTargetId: decision.newTargetId,
1204 });
1205 continue ;
1206 }
1207 const row = decision.state === 'adopted' ? decision.auditRow : linked;
1208 if (decision.crosswalkUpsert) out.crosswalkUpserts. push (decision.crosswalkUpsert);
1209 let option = decision.option;
1210 if ( ! option) {
1211 out.gets += 1 ;
1212 const read = await readShippingOption (wix, row.targetId);
1213 if ( ! read.ok && read.notFound) {
1214 out.targetMissing. push ({
1215 optionKey, targetId: row.targetId, provenance: fixedIds. has (row.targetId) ? 'fixed-writer' : 'earlier-migration' , label,
1216 });
1217 continue ;
1218 }
1219 if ( ! read.ok) continue ; // unknown read: nothing is concluded, nothing is written
1220 option = read.option;
1221 } else {
1222 out.gets += 1 ;
1223 }
1224 if (row.provenance === 'operator-recreated' ) {
1225 out.recreated[optionKey] = option;
1226 continue ;
1227 }
1228 if (fixedIds. has (row.targetId)) {
1229 out.readBack[optionKey] = option;
1230 continue ;
1231 }
1232 // Earlier-migration evidence: report a blank estimate, never edit it.
1233 if ( storedEstimate (option) === '' ) {
1234 const proposal = resolved. get (optionKey);
1235 out.legacyBlank. push ({
1236 optionKey,
1237 targetId: row.targetId,
1238 title: option && option.title ? option.title : (proposal ? proposal.title : null ),
1239 label: proposal ? `${ proposal . zoneName } → ${ proposal . title }` : `option ${ optionKey }` ,
1240 proposedText: proposal && proposal.text ? proposal.text : null ,
1241 proposedOrigin: proposal ? proposal.origin : 'unknown' ,
1242 });
1243 }
1244 }
1245 return out;
1246 }
1247
1248 // --- The completion fragment ----------------------------------------------------
1249 function shellQuote ( value ) {
1250 return `'${ String ( value ). replace ( /'/ g , `' \\ ''` ) }'` ;
1251 }
1252
1253 function listItems ( items ) {
1254 const shown = items. slice ( 0 , ACTION_LIST_LIMIT );
1255 const more = items. length - shown. length ;
1256 return `${ shown . join ( '; ' ) }${ more > 0 ? `; and ${ more } more` : ''}` ;
1257 }
1258
1259 function relayPrompt ( code , count , projectDirArg ) {
1260 if (count <= ACTION_LIST_LIMIT ) return '' ;
1261 return ` Then list every option in ${ SHIPPING_ESTIMATES_FILE } with code ${ code } and send it to the Cutover Operator: \` node ${ SHIPPING_ESTIMATE_AUDIT_CLI } --project-dir ${ projectDirArg } --list ${ code } \` .` ;
1262 }
1263
1264 function actionRow ({ code , severity , bmStep , items , prompt , deadlineReason , projectDirArg }) {
1265 return {
1266 code,
1267 severity,
1268 action: `${ bmStep } Affected: ${ listItems ( items ) }. Requires Import Operator: ${ prompt }${ relayPrompt ( code , items . length , projectDirArg ) }` ,
1269 deadlineReason,
1270 entity: SHIPPING_OPTION_ENTITY ,
1271 affectedCount: items. length ,
1272 };
1273 }
1274
1275 function rerunSentence ( projectDirArg ) {
1276 return `rerun the shipping stage, or for a finished project \` node ${ SHIPPING_ESTIMATE_AUDIT_CLI } --project-dir ${ projectDirArg } \` ` ;
1277 }
1278
1279 function decideCommand ( projectDirArg , key , value ) {
1280 return ` \` node ${ ORCHESTRATION_STATE_CLI } decide --project-dir ${ projectDirArg } --key ${ shellQuote ( key ) } --value ${ shellQuote ( value ) } --source user \` ` ;
1281 }
1282
1283 // Wix's own option on a new site's default regions: "Free shipping", one unconditional zero rate.
1284 // Only this exact shape counts; anything else on a reused region is the merchant's and is kept.
1285 function isWixPlaceholderShippingOption ( option ) {
1286 const rates = (option && option.rates) || [];
1287 return Boolean (option) && String (option.title || '' ). trim () === 'Free shipping' && rates. length === 1
1288 && Number (rates[ 0 ].amount) === 0 && ! (rates[ 0 ].conditions || []). length && ! rates[ 0 ].multiplyByQuantity;
1289 }
1290
1291 // How each zone's region compares with the zone, read from the CURRENT profile (not from attempt
1292 // rows, which a generated importer may write without findings). `regions` is the profile's
1293 // deliveryRegions; a zone with no region id is listed as such.
1294 function regionCoverage ({ zonePlans = [], regionIdByZone = {}, regions = [] } = {}) {
1295 const atomsOf = ( destinations ) => new Set ((destinations || []). flatMap (( d ) => ((d.subdivisions || []). length ? d.subdivisions : [ '*' ]). map (( sub ) => `${ String ( d . countryCode ). toUpperCase () }:${ sub }` )));
1296 const label = ( key ) => (key. endsWith ( ':*' ) ? key. slice ( 0 , - 2 ) : key. split ( ':' )[ 1 ]);
1297 const byId = new Map (regions. map (( region ) => [region.id, region]));
1298 return zonePlans. map (( plan ) => {
1299 const zoneId = String (plan.zone.id);
1300 const regionId = regionIdByZone[zoneId] || null ;
1301 const region = regionId ? byId. get (regionId) : null ;
1302 const zoneAtoms = atomsOf (plan.destinations);
1303 const regionAtoms = region ? atomsOf (region.destinations) : new Set ();
1304 return {
1305 zoneId,
1306 zoneName: plan.zone.name || null ,
1307 regionId,
1308 regionName: region ? region.name || null : null ,
1309 noRegion: ! regionId,
1310 regionMissing: Boolean (regionId) && ! region,
1311 widerBy: region ? [ ... regionAtoms]. filter (( key ) => ! zoneAtoms. has (key)). map (label) : [],
1312 missing: region ? [ ... zoneAtoms]. filter (( key ) => ! regionAtoms. has (key)). map (label) : [],
1313 };
1314 });
1315 }
1316
1317 // The delivery-region state as Cutover Operator actions, all in Business Manager:
1318 // coverage from regionCoverage (live profile);
1319 // preexisting [{ regionId, regionName, zones[], options[{id,title}], complete }] -- options on a
1320 // zone's region the migration did not create, read live, so the item clears;
1321 // deletions the placeholder deletion ledger (execution/shipping-placeholder-deletions.json).
1322 function deliveryRegionCompletionActions ({ coverage = [], preexisting = [], deletions = [] } = {}) {
1323 const operatorActions = [];
1324 const zoneLabel = ( entry ) => `zone ${ entry . zoneName ? `"${ entry . zoneName }"` : entry . zoneId }` ;
1325 const open = preexisting. filter (( entry ) => entry.options. length || ! entry.complete);
1326 if (open. length ) {
1327 operatorActions. push ({
1328 code: 'shipping-region-preexisting-options' ,
1329 severity: 'high' ,
1330 action: `Business Manager → Settings → Shipping & delivery: these regions have shipping options the migration did not create, and buyers see them at checkout beside the imported rates. Ask the merchant whether to keep each one; delete it if not. Affected: ${ listItems ( open . map (( entry ) => `region "${ entry . regionName || entry . regionId }" (${ entry . zones . join ( ', ' ) }): ${ entry . complete ? entry . options . map (( o ) => `"${ o . title || o . id }"` ). join ( ', ' ) : 'its options could not be listed; check them by hand'}` )) }.` ,
1331 deadlineReason: 'Buyers can pick an option the source store never offered' ,
1332 entity: 'shipping-zone' ,
1333 affectedCount: open. reduce (( n , entry ) => n + Math. max (entry.options. length , entry.complete ? 0 : 1 ), 0 ),
1334 });
1335 }
1336 const differs = coverage. filter (( entry ) => entry.noRegion || entry.regionMissing || entry.widerBy. length || entry.missing. length );
1337 if (differs. length ) {
1338 const why = ( entry ) => {
1339 if (entry.noRegion) return 'has no delivery region; its rates were not written' ;
1340 if (entry.regionMissing) return `its region ${ entry . regionId } no longer exists` ;
1341 return [entry.widerBy. length ? `region "${ entry . regionName }" also covers ${ entry . widerBy . join ( ', ' ) }, so the zone's rates apply there too` : null ,
1342 entry.missing. length ? `${ entry . missing . join ( ', ' ) } stay on another region, so the zone's rates do not apply there` : null ]. filter (Boolean). join ( '; ' );
1343 };
1344 operatorActions. push ({
1345 code: 'shipping-region-coverage-differs' ,
1346 severity: differs. some (( entry ) => entry.noRegion || entry.regionMissing) ? 'high' : 'medium' ,
1347 action: `Business Manager → Settings → Shipping & delivery: the site already had regions for some source destinations, so these zones' rates do not cover exactly what the source did. Check each and adjust the regions or rates. Affected: ${ listItems ( differs . map (( entry ) => `${ zoneLabel ( entry ) }: ${ why ( entry ) }` )) }.` ,
1348 deadlineReason: 'Some destinations get different shipping rates than on the source store' ,
1349 entity: 'shipping-zone' ,
1350 affectedCount: differs. length ,
1351 });
1352 }
1353 const done = deletions. filter (( entry ) => entry.status === 'deleted' || entry.status === 'already-gone' );
1354 if (done. length ) {
1355 operatorActions. push ({
1356 code: 'shipping-placeholder-option-deleted' ,
1357 severity: 'low' ,
1358 action: `Done by the import, for the record: on this new site, Wix's placeholder "Free shipping" option was deleted from each listed region, because the imported rates replace it. Nothing to do unless the merchant wants it back; each deleted option is kept in ${ SHIPPING_PLACEHOLDER_LEDGER } to re-create in Business Manager → Settings → Shipping & delivery. Deleted: ${ listItems ( done . map (( entry ) => `"${ entry . option && entry . option . title }" on region "${ entry . regionName || entry . regionId }" (${ ( entry . zones || []). join ( ', ' ) })` )) }.` ,
1359 deadlineReason: 'Record of a change the import made to checkout' ,
1360 entity: 'shipping-zone' ,
1361 affectedCount: done. length ,
1362 });
1363 }
1364 return operatorActions;
1365 }
1366
1367 // Builds the shipping operatorActions from the CURRENT state, not just this run's events,
1368 // and also returns the per-option `items` written to execution/shipping-estimates.json.
1369 function shippingEstimateCompletionFragment ({ resolution = null , readBack = {}, latestAttempts = {}, audit = null , projectDir = null , regions = {} } = {}) {
1370 const projectDirArg = projectDir ? shellQuote (projectDir) : '<projectDir>' ;
1371 const operatorActions = [];
1372 const items = [];
1373 const resolved = resolution && Array. isArray (resolution.options) ? resolution.options : [];
1374 const resolvedByKey = resolutionByKey (resolution);
1375 const effectiveAudit = audit || { recreated: {}, legacyBlank: [], targetMissing: [] };
1376
1377 // -default: the migration's default is still what checkout shows.
1378 const defaults = resolved. filter (( option ) => option.origin === 'default'
1379 && readBack[option.optionKey] && storedEstimate (readBack[option.optionKey]) === option.text);
1380 if (defaults. length ) {
1381 const zones = [ ...new Map (defaults. map (( option ) => [option.zoneId, option])). values ()];
1382 const commands = zones. map (( option ) => decideCommand (projectDirArg, `shipping-delivery-estimate:${ option . zoneId }` , option.text));
1383 operatorActions. push ( actionRow ({
1384 code: 'shipping-estimate-default' ,
1385 severity: 'medium' ,
1386 bmStep: 'Business Manager: ask the merchant to confirm the delivery time shown on each listed option, or enter theirs (Settings → Shipping & delivery).' ,
1387 items: defaults. map (( option ) => `${ option . zoneName } → ${ option . title } (proposed: "${ option . text }")` ),
1388 prompt: `if the merchant confirms the default unchanged, record it for each listed zone: ${ commands . join ( '; ' ) }; then ${ rerunSentence ( projectDirArg ) }. A changed value entered in Business Manager clears this item on that rerun.` ,
1389 deadlineReason: 'Checkout promises a window the store never stated' ,
1390 projectDirArg,
1391 }));
1392 for ( const option of defaults) items. push ({ code: 'shipping-estimate-default' , optionKey: option.optionKey, label: `${ option . zoneName } → ${ option . title }` , proposedText: option.text });
1393 }
1394
1395 // -missing: a fixed-writer (or hand-recreated) option still blank at checkout.
1396 const missing = [];
1397 for ( const [ optionKey , stored ] of Object. entries (readBack)) {
1398 if ( storedEstimate (stored) !== '' ) continue ;
1399 const option = resolvedByKey. get (optionKey);
1400 missing. push ({ optionKey, label: option ? `${ option . zoneName } → ${ option . title }` : `option ${ optionKey }` , proposedText: null });
1401 }
1402 for ( const [ optionKey , stored ] of Object. entries (effectiveAudit.recreated || {})) {
1403 if ( storedEstimate (stored) !== '' ) continue ;
1404 const option = resolvedByKey. get (optionKey);
1405 missing. push ({ optionKey, label: `${ option ? `${ option . zoneName } → ${ option . title }` : `option ${ optionKey }`} (recreated by hand)` , proposedText: null });
1406 }
1407 missing. sort (( a , b ) => a.optionKey. localeCompare (b.optionKey));
1408 if (missing. length ) {
1409 operatorActions. push ( actionRow ({
1410 code: 'shipping-estimate-missing' ,
1411 severity: 'high' ,
1412 bmStep: 'Business Manager: ask the merchant for a delivery time and enter it on each listed option (Settings → Shipping & delivery).' ,
1413 items: missing. map (( entry ) => entry.label),
1414 prompt: `after the delivery times are entered, ${ rerunSentence ( projectDirArg ) } so the read-back clears this item.` ,
1415 deadlineReason: 'Checkout shows no delivery time' ,
1416 projectDirArg,
1417 }));
1418 for ( const entry of missing) items. push ({ code: 'shipping-estimate-missing' , ... entry });
1419 }
1420
1421 // -reconcile: every latest folded row still AMBIGUOUS_MATCH, until a recorded choice resolves it.
1422 const ambiguous = Object. values (latestAttempts || {})
1423 . filter (( row ) => row && row.sourceEntityType === SHIPPING_OPTION_ENTITY && row.status === 'succeeded_unverified'
1424 && row.failure && row.failure.errorCode === 'AMBIGUOUS_MATCH' )
1425 . sort (( a , b ) => String (a.sourceId). localeCompare ( String (b.sourceId)));
1426 if (ambiguous. length ) {
1427 const commands = ambiguous. map (( row ) => decideCommand (projectDirArg, `shipping-option-adopt:${ row . sourceId }` , '<targetId>' ));
1428 operatorActions. push ( actionRow ({
1429 code: 'shipping-option-reconcile' ,
1430 severity: 'high' ,
1431 bmStep: 'Business Manager: open the listed candidate options and identify which one, if any, the migration created. Do not delete any yet.' ,
1432 items: ambiguous. map (( row ) => `${ optionLabel ( row . sourceId , resolution ) } (candidates: ${ (( row . reconcile && row . reconcile . candidateTargetIds ) || []). join ( ', ' ) })` ),
1433 prompt: `record the choice: ${ commands . join ( '; ' ) } (or --value none when none of them is the migration's), then run the import with --unresolved-only.` ,
1434 deadlineReason: "A write's outcome is unknown and several options match; nothing more is created until this is recorded" ,
1435 projectDirArg,
1436 }));
1437 for ( const row of ambiguous) items. push ({ code: 'shipping-option-reconcile' , optionKey: row.sourceId, candidateTargetIds: [ ... ((row.reconcile && row.reconcile.candidateTargetIds) || [])] });
1438 }
1439
1440 // -target-missing: a crosswalked option that 404s. Never recreated automatically.
1441 const targetMissing = [ ... (effectiveAudit.targetMissing || [])]. sort (( a , b ) => a.optionKey. localeCompare (b.optionKey));
1442 if (targetMissing. length ) {
1443 const commands = targetMissing. map (( entry ) => `${ decideCommand ( projectDirArg , `shipping-option-target-missing:${ entry . optionKey }` , 'recreated:<newTargetId>' ) } (or --value retired)` );
1444 operatorActions. push ( actionRow ({
1445 code: 'shipping-option-target-missing' ,
1446 severity: 'high' ,
1447 bmStep: 'Business Manager: these shipping options no longer exist. Ask the merchant whether each should be offered again; if yes, create it in Settings → Shipping & delivery and note its id.' ,
1448 items: targetMissing. map (( entry ) => `${ entry . label } (was ${ entry . targetId }, created by ${ entry . provenance === 'fixed-writer' ? 'the fixed writer' : 'an earlier migration'})${ entry . recordedIdMissing ? ` — the recorded new id was not found (${ entry . newTargetId })` : ''}` ),
1449 prompt: `record the outcome: ${ commands . join ( '; ' ) }. Then ${ rerunSentence ( projectDirArg ) }.` ,
1450 deadlineReason: 'A shipping choice the migration created is gone from checkout; the migration will not recreate it' ,
1451 projectDirArg,
1452 }));
1453 for ( const entry of targetMissing) items. push ({ code: 'shipping-option-target-missing' , optionKey: entry.optionKey, targetId: entry.targetId, provenance: entry.provenance, label: entry.label, ... (entry.recordedIdMissing ? { recordedIdMissing: true } : {}) });
1454 }
1455
1456 // -legacy-blank: an earlier migration's option, report only.
1457 const legacy = [ ... (effectiveAudit.legacyBlank || [])]. sort (( a , b ) => a.optionKey. localeCompare (b.optionKey));
1458 if (legacy. length ) {
1459 operatorActions. push ( actionRow ({
1460 code: 'shipping-estimate-legacy-blank' ,
1461 severity: 'medium' ,
1462 bmStep: 'Business Manager: ask the merchant for a delivery time and enter it on each listed option (Settings → Shipping & delivery); the proposed text is listed.' ,
1463 items: legacy. map (( entry ) => `${ entry . label } (proposed: ${ entry . proposedText ? `"${ entry . proposedText }"` : 'none — ask the merchant'})` ),
1464 prompt: `after entry, rerun \` node ${ SHIPPING_ESTIMATE_AUDIT_CLI } --project-dir ${ projectDirArg } \` so the read-back clears this item.` ,
1465 deadlineReason: 'Created before the field fix; the shopper sees no delivery time' ,
1466 projectDirArg,
1467 }));
1468 for ( const entry of legacy) items. push ({ code: 'shipping-estimate-legacy-blank' , ... entry });
1469 }
1470
1471 operatorActions. push ( ... deliveryRegionCompletionActions (regions || {}));
1472 return { operatorActions, items };
1473 }
1474
1475 // Each run REPLACES the previous run's shipping items, so a resolved item drops out on the next run.
1476 function mergeShippingEstimateCompletion ( completionInput = {}, fragment = { operatorActions: [] }) {
1477 const codes = new Set ( SHIPPING_COMPLETION_CODES );
1478 return {
1479 ... completionInput,
1480 operatorActions: [
1481 ... (completionInput.operatorActions || []). filter (( row ) => ! codes. has (row && row.code)),
1482 ... ((fragment && fragment.operatorActions) || []),
1483 ],
1484 };
1485 }
1486
1487 module . exports = {
1488 BASIC_SHIPPING_APP_ID,
1489 CONTINENT_COUNTRIES,
1490 MISSING_RATES_ALERT,
1491 CONDITION_BY_TOTAL_PRICE,
1492 normalizeZoneLocations,
1493 classifyMethod,
1494 hasWorkingCarrier,
1495 unconditionalRate,
1496 priceThresholdRates,
1497 freeAboveThresholdRates,
1498 buildDeliveryCarrierInput,
1499 buildPickupCarrierInput,
1500 buildShippingOptionInput,
1501 buildPickupLocationInput,
1502 buildDeliveryRegionInput,
1503 deliveryRegionDedupeKey,
1504 resolveZoneMethods,
1505 pickupLocationsFromWpSettings,
1506 buildMinimalPickupAddress,
1507 resolvePickupAddressSource,
1508 resolveFreeShippingCouponCoverage,
1509 findFreeShippingCouponConflicts,
1510 planShippingZones,
1511 shouldSkipShippingDomain,
1512 verifyShippingEconomics,
1513 // Delivery estimate: resolution, verified write, follow-up items
1514 ESTIMATED_DELIVERY_TIME_MAX,
1515 DEFAULT_ESTIMATE_BY_LANGUAGE,
1516 SHIPPING_COMPLETION_CODES,
1517 SHIPPING_ESTIMATES_FILE,
1518 SHIPPING_OPTION_OPERATION,
1519 SHIPPING_OPTION_TARGET_TYPE,
1520 shippingOptionKey,
1521 siteLanguageFromProperties,
1522 resolveDeliveryEstimate,
1523 planShippingEstimates,
1524 verifyShippingOptionReadBack,
1525 shippingOptionWriteRecord,
1526 applyTargetMissingDecision,
1527 auditShippingEstimates,
1528 shippingEstimateCompletionFragment,
1529 deliveryRegionCompletionActions,
1530 isWixPlaceholderShippingOption,
1531 regionCoverage,
1532 SHIPPING_PLACEHOLDER_LEDGER,
1533 mergeShippingEstimateCompletion,
1534 shellQuote,
1535 };