Setting the file. One moment.
Shipping Build · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page Post
lib/ shipping-build.js
JavaScript · 243 lines · 14 KB
13 // built from that zone's *locations* — two different WC arrays fan into two different Wix arrays
14 // on the same region, not a 1:1 field rename.
15 //
16 // Input is the raw WooCommerce shape from GET /wc/v3/shipping/zones (zone), .../locations
17 // (zone.locations[], each {code, type}), and .../methods (zone.methods[], each {method_id,
18 // enabled, settings: {cost, ...}}) — already-fetched, per this codebase's "transform takes
19 // shaped input, fetching is the caller's job" convention (see tax-build.js).
20
21 // "Basic Shipping" is Wix's own first-party, non-carrier-integration delivery app — the direct
22 // equivalent of WooCommerce's flat_rate/free_shipping (a merchant-set price, no real courier
23 // calculation). Per dev.wix.com's own Add Delivery Carrier example (not a placeholder GUID — the
24 // docs' worked example uses this exact id) and confirmed installed on the reference store (2026-08-12) under
25 // displayName "Basic Shipping". Same "fixed platform constant" reasoning as discount-rule-build.js's
26 // WIX_STORES_APP_ID: apps Wix itself created keep one appId across every site. Still overridable.
27 const BASIC_SHIPPING_APP_ID = '45c44b27-ca7b-4891-8c0d-1747d588b835' ;
28
29 // WooCommerce's own continent -> member-country-code table (plugins/woocommerce/i18n/
30 // continents.php, github.com/woocommerce/woocommerce, fetched 2026-08-12), needed because a WC
31 // zone location can be `type: "continent"` (e.g. `EU`) but Wix's Destination object has no
32 // continent concept — only countryCode (+ optional subdivisions). A continent-type location is
33 // expanded to one Destination per member country. Only continents with a real zone hit in
34 // practice need to be complete here; unlisted continent codes fall through to
35 // UNRESOLVED_CONTINENT_CODES with a flagged gap rather than silently matching nothing.
36 const CONTINENT_COUNTRIES = {
37 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' ],
38 };
39
40 function isNumericString ( value ) {
41 return typeof value === 'string' && value. trim () !== '' && Number. isFinite ( Number (value));
42 }
43
44 // --- WC zone locations -> Wix destinations --------------------------------------------------
45 // WC location.code shapes, per WC_Shipping_Zone::get_zone_locations(): type "country" -> "US";
46 // type "state" -> "US:CA" (country-colon-state); type "continent" -> "EU"; type "postcode" ->
47 // a literal postcode/pattern, which Wix's Destination has no equivalent for at all (country/
48 // subdivision only) and is always a gap.
49 function normalizeZoneLocations ( locations ) {
50 const destinations = [];
51 const gaps = [];
52 for ( const location of locations || []) {
53 const type = String (location?.type || '' ). trim ();
54 const code = String (location?.code || '' ). trim ();
55 if ( ! code) continue ;
56 if (type === 'country' ) {
57 destinations. push ({ countryCode: code. toUpperCase () });
58 } else if (type === 'state' ) {
59 const [ country , state ] = code. split ( ':' );
60 if ( ! country || ! state) {
61 gaps. push ({ location, reason: `state-type location code "${ code }" did not parse as COUNTRY:STATE` });
62 continue ;
63 }
64 destinations. push ({ countryCode: country. toUpperCase (), subdivisions: [ `${ country . toUpperCase () }-${ state . toUpperCase () }` ] });
65 } else if (type === 'continent' ) {
66 const countries = CONTINENT_COUNTRIES [code. toUpperCase ()];
67 if ( ! countries) {
68 gaps. push ({ location, reason: `continent code "${ code }" has no country table here (add it to CONTINENT_COUNTRIES if this recurs)` });
69 continue ;
70 }
71 for ( const countryCode of countries) destinations. push ({ countryCode });
72 } else if (type === 'postcode' ) {
73 gaps. push ({ location, reason: 'postcode/postal-pattern zone matching has no Wix Destination equivalent (country/subdivision only) — reconfigure-in-wix' });
74 } else {
75 gaps. push ({ location, reason: `unrecognized WooCommerce zone-location type "${ type }"` });
76 }
77 }
78 return { destinations, gaps };
79 }
80
81 // --- WC shipping method -> Wix delivery carrier -----------------------------------------------
82 // Confidence, verified against WooCommerce core's own shipping method implementations
83 // (plugins/woocommerce/includes/shipping/class-wc-shipping-{flat-rate,free-shipping,local-pickup}.php):
84 // - HIGH: flat_rate's base `cost` setting -> backupRate.amount. Real, always-applied, no
85 // conditions in the free/core method.
86 // - HIGH: free_shipping -> backupRate.amount "0", active. The `requires`/`min_amount`/coupon
87 // condition WooCommerce itself evaluates at checkout has NO Wix Delivery Profile equivalent
88 // (a Wix delivery carrier's backupRate is unconditional once active) — always noted as a gap,
89 // never silently dropped, matching the tax domain's own "never speculative" discipline.
90 // - HIGH: local_pickup / pickup_location -> Wix's own "Pickup" carrier app is the direct
91 // equivalent, but its appId is resolved live (see resolvePickupAppId below), not hardcoded —
92 // unlike Basic Shipping, this has no doc-example corroboration as a fixed cross-site constant.
93 // - GAP, no calculation engine to replicate: any other method_id (e.g. a real-carrier plugin
94 // like WooCommerce Shipping/UPS/FedEx integrations) — carrier-calculated rates have no data
95 // equivalent, per delivery-profile.json's own pre-existing "zone-model-mismatch" pitfall.
96 // - Per-shipping-class cost overrides (flat_rate's `class_cost_*` settings) are a known,
97 // flagged gap: Wix's additionalCharges apply to every order in the region, not conditionally
98 // per product shipping class, so mapping a class-specific cost there would overcharge every
99 // other class. Recorded in `notes[]`, never auto-applied.
100 function classifyMethod ( method ) {
101 const methodId = String (method?.method_id || '' ). trim ();
102 const enabled = method?.enabled !== false ;
103 const title = String (method?.settings?.title?.value || method?.method_title || methodId). trim ();
104 const notes = [];
105
106 const classCostKeys = Object. keys (method?.settings || {}). filter (( key ) => / ^ class_cost_ \d +$ / . test (key));
107 if (classCostKeys. length > 0 ) {
108 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.` );
109 }
110
111 if (methodId === 'flat_rate' ) {
112 const cost = method?.settings?.cost?.value;
113 if ( ! isNumericString (cost)) {
114 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 };
115 }
116 return { kind: 'carrier' , carrierRole: 'basic' , enabled, title, amount: String ( Number (cost)), notes };
117 }
118 if (methodId === 'free_shipping' ) {
119 const requires = method?.settings?.requires?.value;
120 if (requires && requires !== '' ) {
121 notes. push ( `free_shipping requires="${ requires }"${ method ?. settings ?. min_amount ?. value ? ` (min_amount ${ method . settings . min_amount . value })` : ''} — WooCommerce's condition is evaluated at checkout; Wix's backupRate is unconditional once active, so this becomes "always free" in this region, not "free above a threshold". Verify this is the intended merchant policy.` );
122 }
123 return { kind: 'carrier' , carrierRole: 'basic' , enabled, title, amount: '0' , notes };
124 }
125 if (methodId === 'local_pickup' || methodId === 'pickup_location' ) {
126 return { kind: 'carrier' , carrierRole: 'pickup' , enabled, title, amount: '0' , notes };
127 }
128 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 };
129 }
130
131 // `appId` resolved live by the caller (see resolveBasicShippingAppId/resolvePickupAppId in
132 // wix-writers.js) — required, never silently defaulted to undefined.
133 function buildDeliveryCarrierInput ( classified , { appId }) {
134 if ( ! appId) throw new Error ( 'buildDeliveryCarrierInput: appId is required (resolve live for non-Basic-Shipping carriers)' );
135 if (classified.kind !== 'carrier' ) throw new Error ( `buildDeliveryCarrierInput: called on a non-carrier classification (${ classified . kind })` );
136 return {
137 appId,
138 backupRate: {
139 title: classified.title || 'Shipping' ,
140 amount: classified.amount,
141 active: true ,
142 },
143 };
144 }
145
146 // LIVE-DISCOVERED 2026-08-15: a deliveryCarrier's backupRate (buildDeliveryCarrierInput above)
147 // does NOT make Wix consider a region as having a working rate at checkout, and does NOT clear
148 // Wix's own "This region is missing rates" dashboard warning. That is driven by a SEPARATE
149 // resource, ShippingOption (`/ecom/v1/shipping-options`, keyed by `deliveryRegionId`) — found via
150 // dev.wix.com's "Fix Shipping Coverage Gaps" skill article, not documented on the Delivery
151 // Profile/Delivery Carrier pages at all. VERIFIED on the reference store: the two regions Wix auto-created at
152 // Stores install ("Domestic"/"International") each already had a real ShippingOption; the two
153 // regions this pipeline created ("Europe"/"Israel") had a correctly-shaped, active backupRate but
154 // NO ShippingOption, and Wix's dashboard still showed them as missing rates. A region migration
155 // needs BOTH buildDeliveryCarrierInput (the carrier attachment + fallback) AND this builder (the
156 // actual rate customers see) — never one without the other.
157 function buildShippingOptionInput ( classified , { deliveryRegionId , estimatedDeliveryTime = '5-7 business days' } = {}) {
158 if ( ! deliveryRegionId) throw new Error ( 'buildShippingOptionInput: deliveryRegionId is required' );
159 if (classified.kind !== 'carrier' ) throw new Error ( `buildShippingOptionInput: called on a non-carrier classification (${ classified . kind })` );
160 return {
161 title: classified.title || 'Shipping' ,
162 estimatedDeliveryTime,
163 deliveryRegionId,
164 rates: [{ amount: classified.amount, conditions: [], multiplyByQuantity: false }],
165 };
166 }
167
168 function buildDeliveryRegionInput ( zone , destinations ) {
169 if ( isBlank (zone?.name)) throw new Error ( 'buildDeliveryRegionInput: zone.name is required' );
170 return {
171 name: String (zone.name). trim (),
172 active: true ,
173 destinations,
174 };
175 }
176
177 function deliveryRegionDedupeKey ( destinations ) {
178 return [ ... (destinations || [])]
179 . map (( d ) => `${ String ( d . countryCode || '' ). toUpperCase () }:${ [ ... ( d . subdivisions || [])]. sort (). join ( ',' ) }` )
180 . sort ()
181 . join ( '|' );
182 }
183
184 // Wix's own checkout-blocking warning text (Tax/Delivery Locations UI), verbatim — reused here so
185 // the pipeline surfaces the exact same wording at the execution approval gate, not a paraphrase.
186 const MISSING_RATES_ALERT = 'This region is missing rates. Add them so customers can complete checkout.' ;
187
188 // A zone whose destinations resolve to a real region but which ends up with NO enabled carrier
189 // (every method disabled, or every method a `kind: 'gap'` — carrier-calculated, non-numeric
190 // flat_rate cost, unrecognized method_id — or the zone has no methods at all) creates a Wix
191 // Delivery Region with zero working delivery carriers. Every buyer matching that region hits a
192 // dead end at checkout — this is Wix's own live-verified UI warning for exactly that state (see
193 // delivery-profile.json's checkout-blocking-region-with-no-working-carrier pitfall), distinct
194 // from a routine per-method `notes[]` gap and surfaced loudly here rather than buried in one.
195 function hasWorkingCarrier ( methodPlans ) {
196 return (methodPlans || []). some (({ classified }) => classified.kind === 'carrier' && classified.enabled === true );
197 }
198
199 // A zone with empty locations AND empty methods is WooCommerce's unused default "catch-all"
200 // (id 0, "Locations not covered by your other zones" on a fresh install) — never migrate it
201 // speculatively; only zones the merchant actually configured (has locations, or is the sole
202 // remaining zone acting as a real rest-of-world catch-all) produce a plan.
203 function planShippingZones ( zones ) {
204 const plans = [];
205 for ( const zone of zones || []) {
206 const hasLocations = Array. isArray (zone.locations) && zone.locations. length > 0 ;
207 const hasMethods = Array. isArray (zone.methods) && zone.methods. length > 0 ;
208 if ( ! hasLocations && ! hasMethods) continue ;
209
210 const { destinations , gaps : locationGaps } = normalizeZoneLocations (zone.locations);
211 const methodPlans = (zone.methods || []). map (( method ) => ({ method, classified: classifyMethod (method) }));
212 const plan = {
213 zone,
214 destinations,
215 locationGaps,
216 methodPlans,
217 };
218 if (destinations. length > 0 && ! hasWorkingCarrier (methodPlans)) {
219 plan.alert = MISSING_RATES_ALERT ;
220 }
221 plans. push (plan);
222 }
223 return plans;
224 }
225
226 function shouldSkipShippingDomain ( zones ) {
227 return planShippingZones (zones). length === 0 ;
228 }
229
230 module . exports = {
231 BASIC_SHIPPING_APP_ID,
232 CONTINENT_COUNTRIES,
233 MISSING_RATES_ALERT,
234 normalizeZoneLocations,
235 classifyMethod,
236 hasWorkingCarrier,
237 buildDeliveryCarrierInput,
238 buildShippingOptionInput,
239 buildDeliveryRegionInput,
240 deliveryRegionDedupeKey,
241 planShippingZones,
242 shouldSkipShippingDomain,
243 };