Setting the file. One moment.
Wix Writers · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page Post
This file
Number 44.115
Position 115 of 115
Type JavaScript
Size 151 KB
Lines 2,995 lib/ wix-writers.js
JavaScript · 2,995 lines · 151 KB
15 // `queryStoresCategories`, `queryContacts`, `queryCoupons`, `queryOrders`) UNWRAPS the
16 // response to the entity array and returns ONE PAGE, discarding `pagingMetadata`. Two
17 // consequences that generated code has repeatedly got wrong:
18 // 1. **The returned value IS the array.** Reading `.products` / `.categories` off it a
19 // second time yields `undefined`, which `|| []` turns into an empty array — so a dedupe
20 // index or an existing-entity safety net comes back EMPTY instead of failing. It looks
21 // like a working sweep of a fresh site, and the import duplicates everything.
22 // 2. **A cursor loop cannot be built on these at all** — the cursor it would need is inside
23 // the metadata they threw away. Use a `queryAll*` primitive where one exists
24 // (`queryAllStoresCategories`, `queryAllStoresProducts`, `queryAllDataItems`); otherwise
25 // send `wix.send(build<X>Request(body))` and read `pagingMetadata.cursors.next` off the
26 // raw response.
27 // A partial sweep must THROW, never return what it has: "empty net" and "empty store" are
28 // indistinguishable downstream, and it is the latter that a caller assumes.
29 //
30 // Endpoints + request shapes marked `// VERIFIED:` were validated by REAL CALLS against
31 // a live Wix site (not just docs — see SKILL.md "Validate by real call"). Shapes marked
32 // `// UNVERIFIED:` are docs-schema/MCP-derived bootstrap primitives. They must be
33 // surfaced in execution plans until a live contract call promotes them to VERIFIED.
34
35 const fs = require ( 'node:fs/promises' );
36 const path = require ( 'node:path' );
37 const crypto = require ( 'node:crypto' );
38
39 const WIXAPIS = 'https://www.wixapis.com' ;
40
41 const SAFE_MODE_TRUE_VALUES = new Set ([ 'true' , '1' , 'yes' , 'on' ]);
42 const SAFE_MODE_FALSE_VALUES = new Set ([ 'false' , '0' , 'no' , 'off' ]);
43 const DEFAULT_SAFE_MODE_PHONE_NUMBER = '+972 50 0000000' ;
44 const NATIVE_EMAIL_FALLBACK_PATHS = [
45 'info.emails.items[].email' ,
46 'emails.items[].email' ,
47 'buyerInfo.email' ,
48 'billingInfo.email' ,
49 'shippingInfo.email' ,
50 'contact.email' ,
51 'order.buyerInfo.email' ,
52 'order.billingInfo.email' ,
53 'order.shippingInfo.email' ,
54 'member.loginEmail' ,
55 'loginEmail' ,
56 // Contacts V5 (GA) — flat contact shape; single create/update wraps as { contact },
57 // bulk upsert wraps each item as { contact } under contacts[]
58 'contact.email.email' ,
59 'contact.additionalEmails[].email' ,
60 'contacts[].contact.email.email' ,
61 'contacts[].contact.additionalEmails[].email' ,
62 ];
63 const NATIVE_PHONE_FALLBACK_PATHS = [
64 'info.phones.items[].phone' ,
65 'phones.items[].phone' ,
66 'buyerInfo.phone' ,
67 'billingInfo.phone' ,
68 'shippingInfo.phone' ,
69 'order.buyerInfo.phone' ,
70 'order.billingInfo.phone' ,
71 'order.shippingInfo.phone' ,
72 // Import Order (POST /ecom/v1/orders/import) — contact details carry phone, not email
73 'order.billingInfo.contactDetails.phone' ,
74 'order.shippingInfo.logistics.shippingDestination.contactDetails.phone' ,
75 'order.recipientInfo.contactDetails.phone' ,
76 // Contacts V5 (GA) — flat contact shape; single create/update wraps as { contact },
77 // bulk upsert wraps each item as { contact } under contacts[]
78 'contact.phone.phone' ,
79 'contact.additionalPhones[].phone' ,
80 'contact.addresses[].recipient.phone' ,
81 'contacts[].contact.phone.phone' ,
82 'contacts[].contact.additionalPhones[].phone' ,
83 'contacts[].contact.addresses[].recipient.phone' ,
84 ];
85 const EMAIL_PATTERN = / ^ [ ^ \s@] + @ [ ^ \s@] + \. [ ^ \s@] +$ / ;
86
87 class SafeModeBlockedError extends Error {
88 constructor ( message , details = {}) {
89 super (message);
90 this .name = 'SafeModeBlockedError' ;
91 this .code = 'SAFE_MODE_SUSPICIOUS_EMAIL' ;
92 this .safeMode = true ;
93 this .blockedPaths = details.blockedPaths || [];
94 this .sanitizerResult = details.sanitizerResult;
95 }
96 }
97
98 function normalizeSafeModeValue ( value , { defaultValue }) {
99 if (value == null || String (value). trim () === '' ) return defaultValue;
100 const normalized = String (value). trim (). toLowerCase ();
101 if ( SAFE_MODE_TRUE_VALUES . has (normalized)) return true ;
102 if ( SAFE_MODE_FALSE_VALUES . has (normalized)) return false ;
103 throw new Error ( `SAFE_MODE must be one of true, 1, yes, on, false, 0, no, off; got ${ JSON . stringify ( value ) }` );
104 }
105
106 function normalizeDryRunValue ( value , { defaultValue = false } = {}) {
107 if (value == null || String (value). trim () === '' ) return defaultValue;
108 const normalized = String (value). trim (). toLowerCase ();
109 if ( SAFE_MODE_TRUE_VALUES . has (normalized)) return true ;
110 if ( SAFE_MODE_FALSE_VALUES . has (normalized)) return false ;
111 throw new Error ( `DRY_RUN must be one of true, 1, yes, on, false, 0, no, off; got ${ JSON . stringify ( value ) }` );
112 }
113
114 function createDryRunConfig ( env = {}, argv = []) {
115 let dryRun = normalizeDryRunValue (env. DRY_RUN , { defaultValue: false });
116 for ( const arg of argv || []) {
117 if (arg === '--dry-run' ) dryRun = true ;
118 if (arg === '--no-dry-run' ) dryRun = false ;
119 }
120 return { dryRun };
121 }
122
123 function createSafeModeConfig ( env = {}) {
124 const safeMode = normalizeSafeModeValue (env. SAFE_MODE , { defaultValue: true });
125 const configuredPhone = env. SAFE_MODE_PHONE_NUMBER == null ? '' : String (env. SAFE_MODE_PHONE_NUMBER ). trim ();
126 return {
127 safeMode,
128 safeModePhoneNumber: safeMode ? (configuredPhone || DEFAULT_SAFE_MODE_PHONE_NUMBER ) : configuredPhone,
129 };
130 }
131
132 function safeEmailLocalPartComponent ( value , label , { allowHyphen = false } = {}) {
133 const pattern = allowHyphen ? / [ ^ a-z0-9-] + / g : / [ ^ a-z0-9] + / g ;
134 const normalized = String (value == null ? '' : value)
135 . trim ()
136 . toLowerCase ()
137 . replace (pattern, '_' )
138 . replace ( / ^ [_-] +| [_-] +$ / g , '' );
139 if ( ! normalized) throw new Error ( `mockEmailForEntity: ${ label } must normalize to a non-empty value` );
140 return normalized;
141 }
142
143 function mockEmailForEntity ( entityType , entityId ) {
144 const safeEntityType = safeEmailLocalPartComponent (entityType, 'entityType' );
145 const safeEntityId = safeEmailLocalPartComponent (entityId, 'entityId' , { allowHyphen: true });
146 return `replatform+${ safeEntityType }_${ safeEntityId }@wix.com` ;
147 }
148
149 function deepClone ( value ) {
150 if (Array. isArray (value)) return value. map (( item ) => deepClone (item));
151 if (value && typeof value === 'object' ) {
152 const out = {};
153 for ( const [ key , item ] of Object. entries (value)) out[key] = deepClone (item);
154 return out;
155 }
156 return value;
157 }
158
159 function parseSafeModePath ( pathValue ) {
160 if ( ! pathValue || typeof pathValue !== 'string' ) {
161 throw new Error ( 'safe-mode replacement path must be a non-empty string' );
162 }
163 return pathValue. split ( '.' ). map (( rawSegment ) => {
164 const match = rawSegment. match ( / ^ ( [A-Za-z0-9_$-] + )( \[\] ) ?$ / );
165 if ( ! match) throw new Error ( `invalid safe-mode replacement path segment: ${ rawSegment }` );
166 return { key: match[ 1 ], array: Boolean (match[ 2 ]) };
167 });
168 }
169
170 function pathToString ( segments ) {
171 return segments. map (( segment ) => `${ segment . key }${ segment . array ? '[]' : ''}` ). join ( '.' );
172 }
173
174 function setValuesAtPath ( root , pathValue , replacement ) {
175 const segments = parseSafeModePath (pathValue);
176 let changed = 0 ;
177 function visit ( node , index ) {
178 if ( ! node || typeof node !== 'object' ) return ;
179 const segment = segments[index];
180 if ( ! (segment.key in node)) return ;
181 if (segment.array) {
182 const items = node[segment.key];
183 if ( ! Array. isArray (items)) return ;
184 for ( const item of items) {
185 if (index === segments. length - 1 ) {
186 continue ;
187 }
188 visit (item, index + 1 );
189 }
190 return ;
191 }
192 if (index === segments. length - 1 ) {
193 // Primitive-only: a generic path like `contact.email` must not clobber the Contacts
194 // V5 email OBJECT ({ email, subscriptionStatus }) with a mock string. Raw emails
195 // left inside skipped objects are still caught by collectSuspiciousEmailPaths.
196 const current = node[segment.key];
197 if (current !== undefined && current !== null && typeof current !== 'object' ) {
198 if (current !== replacement) {
199 node[segment.key] = replacement;
200 changed += 1 ;
201 }
202 }
203 return ;
204 }
205 visit (node[segment.key], index + 1 );
206 }
207 visit (root, 0 );
208 return changed;
209 }
210
211 function collectSuspiciousEmailPaths ( value , { mockEmail }) {
212 const paths = [];
213 function visit ( node , segments ) {
214 if ( typeof node === 'string' ) {
215 if (node !== mockEmail && EMAIL_PATTERN . test (node. trim ())) paths. push ( pathToString (segments));
216 return ;
217 }
218 if (Array. isArray (node)) {
219 node. forEach (( item , index ) => visit (item, segments. concat ({ key: String (index), array: false })));
220 return ;
221 }
222 if (node && typeof node === 'object' ) {
223 for ( const [ key , item ] of Object. entries (node)) visit (item, segments. concat ({ key, array: false }));
224 }
225 }
226 visit (value, []);
227 return paths;
228 }
229
230 function normalizeReplacePaths ( replacePaths = []) {
231 if ( ! Array. isArray (replacePaths)) throw new Error ( 'safeModeOptions.replacePaths must be an array' );
232 return replacePaths. map (( entry ) => {
233 const kind = entry && entry.kind;
234 const path = entry && (entry.path || entry.targetPath);
235 if (kind !== 'email' && kind !== 'phone' ) throw new Error ( `safe-mode replacement kind must be email or phone; got ${ JSON . stringify ( kind ) }` );
236 parseSafeModePath (path);
237 return { kind, path };
238 });
239 }
240
241 function isSafeModeEnabled ( options = {}) {
242 if (options.safeMode === undefined ) return false ;
243 return normalizeSafeModeValue (options.safeMode, { defaultValue: true });
244 }
245
246 function sanitizeContactFieldsForSafeMode ( value , options = {}) {
247 if ( ! isSafeModeEnabled (options)) {
248 return {
249 value: deepClone (value),
250 blocked: false ,
251 blockedPaths: [],
252 emailFieldsReplaced: 0 ,
253 phoneFieldsReplaced: 0 ,
254 };
255 }
256 if ( ! options.entityType) throw new Error ( 'safe mode requires origin entityType' );
257 if ( ! options.entityId) throw new Error ( 'safe mode requires origin entityId' );
258 const safeModePhoneNumber = options.safeModePhoneNumber || DEFAULT_SAFE_MODE_PHONE_NUMBER ;
259 const mockEmail = mockEmailForEntity (options.entityType, options.entityId);
260 const sanitized = deepClone (value);
261 const replacePaths = normalizeReplacePaths (options.replacePaths || []);
262 let emailFieldsReplaced = 0 ;
263 let phoneFieldsReplaced = 0 ;
264
265 for ( const entry of replacePaths) {
266 if (entry.kind === 'email' ) emailFieldsReplaced += setValuesAtPath (sanitized, entry.path, mockEmail);
267 else phoneFieldsReplaced += setValuesAtPath (sanitized, entry.path, safeModePhoneNumber);
268 }
269 for ( const pathValue of NATIVE_EMAIL_FALLBACK_PATHS ) {
270 emailFieldsReplaced += setValuesAtPath (sanitized, pathValue, mockEmail);
271 }
272 for ( const pathValue of NATIVE_PHONE_FALLBACK_PATHS ) {
273 phoneFieldsReplaced += setValuesAtPath (sanitized, pathValue, safeModePhoneNumber);
274 }
275
276 const blockedPaths = collectSuspiciousEmailPaths (sanitized, { mockEmail });
277 return {
278 value: sanitized,
279 blocked: blockedPaths. length > 0 ,
280 blockedPaths,
281 emailFieldsReplaced,
282 phoneFieldsReplaced,
283 };
284 }
285
286 function sanitizeWixRequestBody ( body , options = {}) {
287 return sanitizeContactFieldsForSafeMode (body, options);
288 }
289
290 function buildSafeModeEvidence ( result , safeModeOptions ) {
291 if ( ! isSafeModeEnabled (safeModeOptions)) return null ;
292 return {
293 enabled: true ,
294 entityType: safeModeOptions.entityType || null ,
295 entityId: safeModeOptions.entityId == null ? null : String (safeModeOptions.entityId),
296 replacePathCount: normalizeReplacePaths (safeModeOptions.replacePaths || []). length ,
297 emailFieldsReplaced: result.emailFieldsReplaced,
298 phoneFieldsReplaced: result.phoneFieldsReplaced,
299 blockedPaths: result.blockedPaths. slice (),
300 };
301 }
302
303 function applySafeModeToRequest ( body , safeModeOptions ) {
304 const result = sanitizeWixRequestBody (body, safeModeOptions);
305 if (result.blocked) {
306 throw new SafeModeBlockedError ( 'safe mode blocked suspicious non-replaced email value before Wix write' , {
307 blockedPaths: result.blockedPaths,
308 sanitizerResult: result,
309 });
310 }
311 return {
312 body: result.value,
313 safeMode: buildSafeModeEvidence (result, safeModeOptions),
314 };
315 }
316
317 function applySafeModeToRequestBody ( body , safeModeOptions ) {
318 return applySafeModeToRequest (body, safeModeOptions).body;
319 }
320
321 // --- client ----------------------------------------------------------------
322 // config: { authToken, siteId }. authToken is an OAuth access token / API key with
323 // scopes for the selected writers, for example Blog manage, Wix Data collections manage,
324 // media import, Contacts manage/schema, and Members manage.
325 //
326 // Auth scheme normalization: Wix API keys (`IST.…`) are sent RAW in the Authorization
327 // header; OAuth access tokens (e.g. a Wix CLI token from `npx @wix/cli@latest token --site …`)
328 // must be sent as `Bearer <token>`. Detect and prefix so both credential kinds work.
329 function authHeaderValue ( token ) {
330 const t = String (token). trim ();
331 if ( / ^ Bearer \s / i . test (t)) return t; // already carries a scheme
332 if ( / ^ IST \. / . test (t)) return t; // Wix API key — sent as-is, no Bearer
333 return `Bearer ${ t }` ; // OAuth / CLI access token
334 }
335
336 function stripWixOrigin ( url ) {
337 const value = String (url || '' );
338 if (value. startsWith ( WIXAPIS )) return value. slice ( WIXAPIS . length ) || '/' ;
339 try {
340 const parsed = new URL (value);
341 return `${ parsed . pathname }${ parsed . search }` ;
342 } catch {
343 return value;
344 }
345 }
346
347 function stableHash ( value , length = 10 ) {
348 return crypto. createHash ( 'sha256' ). update ( String (value)). digest ( 'hex' ). slice ( 0 , length);
349 }
350
351 function safePlaceholderPart ( value , fallback = 'wix' ) {
352 const normalized = String (value || fallback)
353 . toLowerCase ()
354 . replace ( / [ ^ a-z0-9] + / g , '_' )
355 . replace ( / ^ _ +| _ +$ / g , '' );
356 return normalized || fallback;
357 }
358
359 function dryRunPlaceholderId ({ runId , entity , operation , sourceId , method , url , body }) {
360 const entityPart = safePlaceholderPart (entity || 'wix' );
361 const hashInput = JSON . stringify ({
362 runId: runId || 'dry-run' ,
363 entity: entity || null ,
364 operation: operation || null ,
365 sourceId: sourceId || null ,
366 method,
367 endpoint: stripWixOrigin (url),
368 body,
369 });
370 return `dry-run_${ entityPart }_${ stableHash ( hashInput , 8 ) }` ;
371 }
372
373 function responseShapeFromRequest ( request ) {
374 if (request.responseShape) return request.responseShape;
375 const url = String (request.url || '' );
376 const method = String (request.method || '' ). toUpperCase ();
377 if (url. includes ( '/ricos/v1/ricos-document/convert/to-ricos' )) return { type: 'object' , field: 'document' };
378 if (url. includes ( '/site-media/v1/files/import' )) return { type: 'object' , field: 'file' };
379 if (url. includes ( '/site-media/v1/files/' ) && method === 'GET' ) return { type: 'object' , field: 'file' };
380 if (url. includes ( '/blog/v3/categories' )) return method === 'GET' || url. includes ( '/query' ) ? { type: 'array' , field: 'categories' } : { type: 'object' , field: 'category' };
381 if (url. includes ( '/blog/v3/tags' )) return method === 'GET' || url. includes ( '/query' ) ? { type: 'array' , field: 'tags' } : { type: 'object' , field: 'tag' };
382 if (url. includes ( '/blog/v3/draft-posts' ) && url. includes ( '/publish' )) return { type: 'raw' };
383 if (url. includes ( '/blog/v3/draft-posts' )) return { type: 'object' , field: 'draftPost' };
384 if (url. includes ( '/wix-data/v2/items/query' )) return { type: 'array' , field: 'dataItems' };
385 if (url. includes ( '/wix-data/v2/items' )) return { type: 'object' , field: 'dataItem' , idFields: [ 'id' , '_id' ] };
386 if (url. includes ( '/stores/v3/bulk/products-with-inventory/create' )) return { type: 'bulk-products-with-inventory' };
387 if (url. includes ( '/stores/v3/products/query' )) return { type: 'array' , field: 'products' };
388 if (url. includes ( '/stores/v3/products/slug/' ) || (url. includes ( '/stores/v3/products/' ) && method === 'GET' )) return { type: 'object' , field: 'product' };
389 if (url. includes ( '/stores/v3/products' )) return { type: 'object' , field: 'product' };
390 if (url. includes ( '/categories/v1/categories/query' )) return { type: 'array' , field: 'categories' };
391 if (url. includes ( '/categories/v1/categories' )) return { type: 'object' , field: 'category' };
392 if (url. includes ( '/categories/v1/bulk/categories/add-item' )) return { type: 'raw' };
393 if (url. includes ( '/stores/v3/inventory-items' )) return { type: 'object' , field: 'inventoryItem' };
394 if (url. includes ( '/contacts/v5/bulk/contacts/upsert' )) return { type: 'bulk-contacts-upsert' };
395 if (url. includes ( '/contacts/v5/contacts/query' ) || url. includes ( '/contacts/v4/contacts/query' )) return { type: 'array' , field: 'contacts' };
396 if (url. includes ( '/contacts/v5/contacts' ) || url. includes ( '/contacts/v4/contacts' )) return { type: 'object' , field: 'contact' };
397 if (url. includes ( '/stores/v2/coupons/query' )) return { type: 'array' , field: 'coupons' };
398 if (url. includes ( '/stores/v2/coupons' )) return { type: 'object' , field: 'coupon' };
399 if (url. includes ( '/ecom/v1/orders/query' )) return { type: 'array' , field: 'orders' };
400 if (url. includes ( '/ecom/v1/orders' )) return { type: 'object' , field: 'order' };
401 if (url. includes ( '/members/v1/members' ) && method === 'GET' ) return { type: 'array' , field: 'members' };
402 if (url. includes ( '/members/v1/members' )) return { type: 'object' , field: 'member' };
403 if (url. includes ( '/apps-installer-service/v1/app-instances' )) return { type: 'array' , field: 'appInstances' };
404 if (url. includes ( '/apps-installer-service/v1/app-instance/install' )) return { type: 'object' , field: 'appInstance' };
405 if (url. includes ( '/bookings/v2/resources/query' )) return { type: 'array' , field: 'resources' };
406 if (url. includes ( '/bookings/v2/services/query' )) return { type: 'array' , field: 'services' };
407 if (url. includes ( '/bookings/v2/services' )) return { type: 'object' , field: 'service' , idFields: [ 'id' ] };
408 if (url. includes ( '/calendar/v3/events' )) return { type: 'object' , field: 'event' , idFields: [ 'id' ] };
409 if (url. includes ( '/ecom/v1/discount-rules/query' )) return { type: 'array' , field: 'discountRules' };
410 if (url. includes ( '/ecom/v1/discount-rules' )) return { type: 'object' , field: 'discountRule' , idFields: [ 'id' ] };
411 // `nonEmptyItem`: this array must never placeholder-empty in dry-run — resolveManualTaxCalculatorAppId
412 // (see the Tax section) filters this result for the non-Avalara entry and THROWS if it doesn't find
413 // exactly one. An empty `[]` placeholder would make every dry run of a tax-region-creating path throw
414 // before it ever reaches wix.send — the exact "crash instead of a usable placeholder" bug the
415 // refund/discount-rule writer fix (an earlier writer-fix review) was written to catch, generalized here via the
416 // shape descriptor itself rather than a one-off field-name check in placeholderPayload, so the next
417 // array endpoint with this requirement only needs to set this property, not add a new branch.
418 if (url. includes ( '/billing/v1/list-tax-calculators' )) {
419 return { type: 'array' , field: 'taxCalculatorDetails' , nonEmptyItem: { appId: 'dry-run-manual-tax-calculator-app-id' , displayName: 'Wix Manual Tax Calculator' , unsupportedCountries: [] } };
420 }
421 if (url. includes ( '/billing/v1/tax-groups/default-tax-groups' )) return { type: 'array' , field: 'taxGroups' };
422 if (url. includes ( '/billing/v1/tax-groups/query' )) return { type: 'array' , field: 'taxGroups' };
423 if (url. includes ( '/billing/v1/tax-groups' )) return { type: 'object' , field: 'taxGroup' , idFields: [ 'id' ] };
424 if (url. includes ( '/billing/v1/tax-regions/query' )) return { type: 'array' , field: 'taxRegions' };
425 if (url. includes ( '/billing/v1/tax-regions' )) return { type: 'object' , field: 'taxRegion' , idFields: [ 'id' ] };
426 if (url. includes ( '/billing/v1/manual-tax-mappings/query' )) return { type: 'array' , field: 'manualTaxMappings' };
427 if (url. includes ( '/billing/v1/manual-tax-mappings' )) return { type: 'object' , field: 'manualTaxMapping' , idFields: [ 'id' ] };
428 if (url. includes ( '/billing/v1/tax-settings' )) return { type: 'object' , field: 'taxSettings' };
429 // `nonEmptyItem`: this array must never placeholder-empty in dry-run — resolvePickupAppId
430 // filters this result for the Pickup entry and THROWS if it doesn't find exactly one. An
431 // empty `[]` placeholder would make every dry run of a path that resolves the Pickup appId
432 // throw before it ever reaches wix.send, the same class of bug the refund/discount-rule
433 // writer fix (an earlier writer-fix review) was written to catch, generalized here via the shape
434 // descriptor's own `nonEmptyItem` property rather than a one-off field-name check.
435 if (url. includes ( '/ecom/v1/delivery-profiles/installed-carriers' )) {
436 return { type: 'array' , field: 'installedDeliveryCarriers' , nonEmptyItem: { id: 'dry-run-pickup-carrier-app-id' , displayName: 'Pickup' , fallbackDefinitionMandatory: false } };
437 }
438 if (url. includes ( '/ecom/v1/delivery-profiles/query' )) return { type: 'array' , field: 'deliveryProfiles' };
439 // add-delivery-region (POST .../{profileId}/delivery-region) and remove-delivery-region
440 // (DELETE .../{profileId}/delivery-region/{regionId}) share a URL substring, distinguished
441 // only by method — check DELETE first or the add-region branch below would swallow it too.
442 if (url. includes ( '/delivery-region/' ) && method === 'DELETE' ) return { type: 'object' , field: 'deliveryProfile' , idFields: [ 'id' ] };
443 if (url. includes ( '/delivery-region' ) && method === 'POST' ) return { type: 'object' , field: 'deliveryProfile' , idFields: [ 'id' ] };
444 if (url. includes ( '/ecom/v1/delivery-profiles/add-delivery-carrier' )) return { type: 'object' , field: 'deliveryProfile' , idFields: [ 'id' ] };
445 if (url. includes ( '/ecom/v1/delivery-profiles/remove-delivery-carrier' )) return { type: 'object' , field: 'deliveryProfile' , idFields: [ 'id' ] };
446 if (url. includes ( '/ecom/v1/delivery-profiles/' ) && method === 'GET' ) return { type: 'object' , field: 'deliveryProfile' , idFields: [ 'id' ] };
447 if (url. includes ( '/ecom/v1/delivery-profiles' )) return { type: 'object' , field: 'deliveryProfile' , idFields: [ 'id' ] };
448 if (url. includes ( '/ecom/v1/shipping-options/query' )) return { type: 'array' , field: 'shippingOptions' };
449 if (url. includes ( '/ecom/v1/shipping-options' )) return { type: 'object' , field: 'shippingOption' , idFields: [ 'id' ] };
450 if (url. includes ( '/ecom/v1/order-billing/refund-payments' )) return { type: 'object' , field: 'refund' , idFields: [ 'id' ] };
451 if (url. includes ( '/ecom/v1/payments/orders/' ) && url. includes ( '/add-payment' )) return { type: 'add-order-payment' };
452 if (url. includes ( '/ecom/v1/payments/orders/' )) return { type: 'object' , field: 'orderTransactions' };
453 return { type: 'raw' };
454 }
455
456 // Bulk endpoints return one per-item result per input, correlated by `itemMetadata.originalIndex`
457 // (bulkCreateStoresProductsWithInventory / bulkUpsertContacts both read that field — see their
458 // comments above). A single generic placeholder object is the wrong shape for these: without a
459 // per-item result the caller's correlation logic reports every input as "unaccounted", which a
460 // dry run then surfaces as a false unexpectedSkipped/mismatch rather than a clean dry-run pass.
461 function bulkPlaceholderResults ( inputs , request , context , { withAction = false } = {}) {
462 return inputs. map (( _ , index ) => {
463 const id = dryRunPlaceholderId ({ ... request, ... context, sourceId: `${ context . sourceId || 'bulk'}-${ index }` });
464 return {
465 itemMetadata: { id, originalIndex: index, success: true },
466 ... (withAction ? { action: 'CREATED' } : {}),
467 item: { id, _dryRunPlaceholder: true },
468 };
469 });
470 }
471
472 function placeholderPayload ( shape , request , context ) {
473 if ( ! shape || shape.type === 'raw' ) return {};
474 if (shape.type === 'array' ) return { [shape.field]: shape.nonEmptyItem ? [shape.nonEmptyItem] : [] };
475 if (shape.type === 'bulk-products-with-inventory' ) {
476 const products = (request.body && Array. isArray (request.body.products)) ? request.body.products : [];
477 const results = bulkPlaceholderResults (products, request, context);
478 return {
479 productResults: { results, bulkActionMetadata: { totalSuccesses: results. length , totalFailures: 0 , undetailedFailures: 0 } },
480 inventoryResults: null ,
481 };
482 }
483 if (shape.type === 'bulk-contacts-upsert' ) {
484 const contacts = (request.body && Array. isArray (request.body.contacts)) ? request.body.contacts : [];
485 const results = bulkPlaceholderResults (contacts, request, context, { withAction: true });
486 return { results, bulkActionMetadata: { totalSuccesses: results. length , totalFailures: 0 , undetailedFailures: 0 } };
487 }
488 // Real shape is `{orderTransactions, paymentsIds}` (VERIFIED live 2026-08-12) — addOrderPayment
489 // reads `response.paymentsIds[0]` as the new payment's id, not a top-level `id`/`payment.id`,
490 // so the generic object-with-idFields placeholder below would leave paymentId undefined.
491 if (shape.type === 'add-order-payment' ) {
492 const id = dryRunPlaceholderId ({ ... request, ... context });
493 return { paymentsIds: [id], orderTransactions: { payments: [{ id, _dryRunPlaceholder: true }] } };
494 }
495 const id = dryRunPlaceholderId ({ ... request, ... context });
496 const payload = { id, _dryRunPlaceholder: true };
497 for ( const field of shape.idFields || []) payload[field] = id;
498 if (shape.field === 'document' ) return { document: { nodes: [], _dryRunPlaceholder: true } };
499 if (shape.field === 'file' ) return { file: { id, operationStatus: 'PENDING' , _dryRunPlaceholder: true } };
500 // Bookings Create Service always returns an auto-created `schedule.id` (see createBookingsService
501 // VERIFIED comment) that createCalendarEvent needs to build the session request — without a
502 // placeholder here, a dry run of the event-plugin-rest path silently skips capturing the
503 // session-create request entirely (no schedule.id to build it from).
504 if (shape.field === 'service' ) payload.schedule = { id: `${ id }-schedule` };
505 return { [shape.field]: payload };
506 }
507
508 function redactHeaders ( headers = {}) {
509 const out = {};
510 for ( const [ key , value ] of Object. entries (headers || {})) {
511 if ( / ^ authorization $ / i . test (key)) continue ;
512 if ( /cookie | token | api [-_] ? key | secret/ i . test (key)) {
513 out[key] = '[REDACTED]' ;
514 } else {
515 out[key] = value;
516 }
517 }
518 return out;
519 }
520
521 function redactSecrets ( value ) {
522 if (Array. isArray (value)) return value. map (( item ) => redactSecrets (item));
523 if (value && typeof value === 'object' ) {
524 const out = {};
525 for ( const [ key , item ] of Object. entries (value)) {
526 out[key] = /authorization | cookie | token | api [-_] ? key | secret | password/ i . test (key) ? '[REDACTED]' : redactSecrets (item);
527 }
528 return out;
529 }
530 return value;
531 }
532
533 async function appendJsonLine ( filePath , row ) {
534 await fs. mkdir (path. dirname (filePath), { recursive: true });
535 await fs. appendFile (filePath, `${ JSON . stringify ( row ) } \n ` , 'utf8' );
536 }
537
538 async function defaultCaptureSink ( capture , config ) {
539 if ( typeof config.captureSink === 'function' ) {
540 await config. captureSink (capture);
541 }
542 if (config.auditSink && typeof config.auditSink.appendRequestCapture === 'function' ) {
543 await config.auditSink. appendRequestCapture (capture);
544 } else if ( typeof config.auditSink === 'function' ) {
545 await config. auditSink (capture);
546 }
547 if (config.requestCapturePath) {
548 await appendJsonLine (config.requestCapturePath, capture);
549 } else if (config.projectDir) {
550 await appendJsonLine (path. join (config.projectDir, 'state' , 'attempts' , 'wix-request-captures.ndjson' ), capture);
551 }
552 }
553
554 async function dryRunSend ( request , config , defaultHeaders ) {
555 const method = String (request.method || '' ). toUpperCase ();
556 if ( ! method) throw new Error ( 'wix.send: method is required' );
557 if ( ! request.url) throw new Error ( 'wix.send: url is required' );
558 const headers = { ... defaultHeaders, ... (request.headers || {}) };
559 const body = request.body === undefined ? undefined : request.body;
560 const runId = config.runContext?.runId || config.runId || 'dry-run' ;
561 const phase = request.phase || config.runContext?.phase || config.phase || 'import' ;
562 const requestCaptureId = `reqcap_${ stableHash ( JSON . stringify ({ runId , method , url: request.url , body , operation: request.operation , sourceId: request.sourceId }), 12 ) }` ;
563 const capture = {
564 schemaVersion: 1 ,
565 requestCaptureId,
566 timestamp: new Date (). toISOString (),
567 runId,
568 dryRun: true ,
569 phase,
570 ... (request.entity ? { entity: request.entity } : {}),
571 ... (request.operation ? { operation: request.operation } : {}),
572 ... (request.sourceId ? { sourceId: String (request.sourceId) } : {}),
573 method,
574 endpoint: stripWixOrigin (request.url),
575 headers: redactHeaders (headers),
576 body: redactSecrets (body),
577 verification: request.verification || request.verificationLevel || 'unverified' ,
578 expectedLiveBehavior: request.expectedLiveBehavior || request.operation || method. toLowerCase (),
579 result: 'dry_run_skipped_wix_call' ,
580 authTokenStatus: config.authToken ? 'present' : 'would_block_live' ,
581 siteIdStatus: config.siteId ? 'present' : 'would_block_live' ,
582 ... (request.safeMode ? { safeMode: request.safeMode } : {}),
583 };
584 await defaultCaptureSink (capture, config);
585 const shape = responseShapeFromRequest (request);
586 return {
587 dryRun: true ,
588 result: 'dry_run_skipped_wix_call' ,
589 requestCaptureId,
590 ... (shape.type === 'array' ? { stateKnown: false , kind: 'wix_call_skipped' } : {}),
591 ... placeholderPayload (shape, request, {
592 runId,
593 entity: request.entity || shape.field,
594 operation: request.operation || request.expectedLiveBehavior,
595 sourceId: request.sourceId,
596 }),
597 };
598 }
599
600 function createWixClient ( config ) {
601 const dryRun = normalizeDryRunValue (config && config.dryRun, { defaultValue: false });
602 if ( ! dryRun && ( ! config || ! config.authToken)) {
603 throw new Error (
604 'createWixClient: no Wix write credentials. Provide an OAuth access token / API ' +
605 'key with the scopes required by the selected writers. In an autonomous run this ' +
606 'is injected at provisioning time.' ,
607 );
608 }
609 const headers = {
610 'Content-Type' : 'application/json' ,
611 ... (config && config.authToken ? { Authorization: authHeaderValue (config.authToken) } : {}),
612 ... (config && config.siteId ? { 'wix-site-id' : config.siteId } : {}),
613 };
614 const fetchImpl = config.fetch || fetch;
615 return {
616 async send ( request ) {
617 if (dryRun) return dryRunSend (request, config, headers);
618 const { method , url , body } = request;
619 const requestHeaders = { ... headers, ... (request.headers || {}) };
620 const res = await fetchImpl (url, { method, headers: requestHeaders, body: body ? JSON . stringify (body) : undefined });
621 const text = await res. text ();
622 const json = text ? JSON . parse (text) : null ;
623 if ( ! res.ok) throw new Error ( `${ method } ${ url } → ${ res . status }: ${ text . slice ( 0 , 400 ) }` );
624 return json;
625 },
626 };
627 }
628
629 function intentToWixRequest ( intent ) {
630 if ( ! intent || typeof intent !== 'object' ) {
631 throw new Error ( 'setup intent must be an object' );
632 }
633 if (intent.type === 'rest' ) {
634 return {
635 method: intent.method,
636 url: intent.url || `${ WIXAPIS }${ String ( intent . path || '' ). startsWith ( '/' ) ? intent . path : `/${ intent . path }`}` ,
637 body: intent.body,
638 headers: intent.headers,
639 phase: 'setup' ,
640 operation: intent.operation,
641 entity: intent.entity,
642 sourceId: intent.sourceId,
643 verification: intent.verification,
644 expectedLiveBehavior: intent.expectedLiveBehavior,
645 responseShape: intent.responseShape,
646 };
647 }
648 return {
649 method: intent.method || intent.type || 'SETUP' ,
650 url: intent.url || `wix-${ intent . type || 'setup'}:${ intent . operation || intent . command || intent . tool || 'step'}` ,
651 body: intent.body || intent.args || intent.commandArgs || {},
652 headers: intent.headers || {},
653 phase: 'setup' ,
654 operation: intent.operation || intent.command || intent.tool,
655 entity: intent.entity,
656 sourceId: intent.sourceId,
657 verification: intent.verification,
658 expectedLiveBehavior: intent.expectedLiveBehavior || intent.type,
659 responseShape: intent.responseShape || { type: 'raw' },
660 };
661 }
662
663 function createWixSetupExecutor ( config = {}) {
664 const dryRun = normalizeDryRunValue (config.dryRun, { defaultValue: false });
665 let wixClient = config.wixClient || (dryRun ? createWixClient ({
666 ... config,
667 dryRun,
668 runContext: { ... (config.runContext || {}), phase: 'setup' },
669 }) : null );
670 const transports = config.transports || {};
671
672 return {
673 async executeSetupStep ( step ) {
674 if ( ! step || typeof step !== 'object' ) {
675 throw new Error ( 'setup step must be an object' );
676 }
677 const intent = step.intent || ( typeof step.buildIntent === 'function' ? await step. buildIntent (step) : step);
678 const request = intentToWixRequest (intent);
679 if (dryRun) {
680 const response = await wixClient. send (request);
681 return {
682 dryRun: true ,
683 status: 'planned_dry_run' ,
684 stepId: step.id || intent.id || null ,
685 intent,
686 requestCaptureId: response.requestCaptureId,
687 result: response.result,
688 };
689 }
690 if (intent.type === 'rest' ) {
691 if ( ! wixClient) {
692 wixClient = createWixClient ({
693 ... config,
694 dryRun,
695 runContext: { ... (config.runContext || {}), phase: 'setup' },
696 });
697 }
698 return wixClient. send (request);
699 }
700 if (intent.type === 'mcp' && typeof transports.mcp === 'function' ) {
701 return transports. mcp (intent);
702 }
703 if (intent.type === 'cli' && typeof transports.cli === 'function' ) {
704 return transports. cli (intent);
705 }
706 if (intent.type === 'sdk' && typeof transports.sdk === 'function' ) {
707 return transports. sdk (intent);
708 }
709 throw new Error ( `unsupported setup transport: ${ intent . type || '<missing>'}` );
710 },
711 };
712 }
713
714 // --- missing-writer bootstrap ---------------------------------------------
715 // Generated migrations use this when Wix has a native entity but rp-target-wix does not
716 // yet ship a dedicated writer primitive. This keeps the write path explicit and logged
717 // without pretending generic CMS is an acceptable substitute for a native Wix entity.
718 function buildDirectRestRequest ({ method , path , url , body }, safeModeOptions ) {
719 if ( ! method) throw new Error ( 'buildDirectRestRequest: method is required' );
720 if ( ! path && ! url) throw new Error ( 'buildDirectRestRequest: path or url is required' );
721 const prepared = applySafeModeToRequest (body, safeModeOptions);
722 return {
723 method,
724 url: url || `${ WIXAPIS }${ path . startsWith ( '/' ) ? path : `/${ path }`}` ,
725 body: prepared.body,
726 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
727 };
728 }
729 async function sendDirectRest ( wix , request , safeModeOptions ) {
730 return wix. send ( buildDirectRestRequest (request, safeModeOptions));
731 }
732 async function notifyMissingWriter ({ sourceEntity , wixEntity , method , path , reason }) {
733 // NOOP for now. Replace with Slack/Jira/telemetry once the RePlatform team chooses a
734 // destination. Keep the return value structured so callers can log/report it.
735 return {
736 notified: false ,
737 noop: true ,
738 sourceEntity,
739 wixEntity,
740 method,
741 path,
742 reason,
743 };
744 }
745
746 // --- slugs ------------------------------------------------------------------
747 // Slug sanitizing lives in `wix-build.js` (`toWixSlug`, applied automatically by the
748 // `coerce: 'slug'` rule on `product.slug` in wix-target-spec.js) — NOT here, and deliberately
749 // not inside normalizeStoresProductV3. Two reasons it stays in the build layer: URL preservation
750 // needs the caller to record the original source slug alongside the `plannedTargetSlug` it
751 // derived, which a silent rewrite inside the writer would falsify; and the build layer is where
752 // the canonical→Wix payload rules are regression-locked. Do not add a second copy here.
753
754 // --- rich content: HTML → Ricos document -----------------------------------
755 // VERIFIED: POST /ricos/v1/ricos-document/convert/to-ricos with HTML input.
756 // VERIFIED-TRAP: `options.plugins` enum values are UPPERCASE. The public
757 // docs example shows lowercase (["image","link"]); lowercase returns HTTP 400.
758 // VERIFIED-TRAP: `source.html` is capped at 30000 chars (400 MAX_LENGTH).
759 // `convertHtmlToRichContent` transparently chunks larger HTML and merges the Ricos
760 // node arrays, so callers never have to think about the cap.
761 const RICOS_PLUGINS = [ 'IMAGE' , 'LINK' , 'VIDEO' , 'AUDIO' , 'HEADING' , 'DIVIDER' , 'CODE_BLOCK' , 'TABLE' , 'GALLERY' ];
762 const RICOS_HTML_CAP = 30000 ; // hard limit on source.html (400 MAX_LENGTH above this)
763 const RICOS_CHUNK_TARGET = 28000 ; // headroom under the cap
764 function buildConvertToRicosRequest ( html , plugins = RICOS_PLUGINS ) {
765 return { method: 'POST' , url: `${ WIXAPIS }/ricos/v1/ricos-document/convert/to-ricos` , body: { html, options: { plugins } } };
766 }
767 // split HTML at block-level close tags so each chunk stays under the cap
768 // without slicing through an element. A single block bigger than `max` is hard-split
769 // as a last resort (rare; logged by the caller).
770 function splitHtmlIntoChunks ( html , max = RICOS_CHUNK_TARGET ) {
771 if (html. length <= max) return [html];
772 const parts = html. split ( /(?<=< \/ (?:p | div | section | article | h [1-6] | ul | ol | li | blockquote | pre | figure | table | tbody | thead | tr)>)/ i );
773 const chunks = [];
774 let cur = '' ;
775 for ( const part of parts) {
776 if (part. length > max) {
777 if (cur) { chunks. push (cur); cur = '' ; }
778 for ( let i = 0 ; i < part. length ; i += max) chunks. push (part. slice (i, i + max));
779 continue ;
780 }
781 if (cur && cur. length + part. length > max) { chunks. push (cur); cur = '' ; }
782 cur += part;
783 }
784 if (cur) chunks. push (cur);
785 return chunks;
786 }
787 // OBSERVED (2026-07-29): this endpoint throttles a sustained burst with **403** (empty message,
788 // empty details) rather than 429. A 50-product bulk create converts one description per product,
789 // and the run died partway with 49 products unwritten; a single call and a burst of 12 succeeded
790 // moments later, so the condition is transient. Retry with backoff instead of failing the batch.
791 // A genuine permission 403 still surfaces, just after the attempts are exhausted.
792 const RICOS_RETRY_DELAYS_MS = [ 500 , 1500 , 4000 , 9000 , 20000 ];
793 function isRetryableRicosError ( err ) {
794 return / \b (403 | 429 | 500 | 502 | 503 | 504) \b / . test (err && err.message ? err.message : '' );
795 }
796 async function convertHtmlToRichContent ( wix , html , { plugins , mediaBySourceUrl } = {}) {
797 const chunks = splitHtmlIntoChunks (html || '' );
798 let merged = null ;
799 for ( const chunk of chunks) {
800 let document;
801 for ( let attempt = 0 ; ; attempt += 1 ) {
802 try {
803 ({ document } = await wix. send ( buildConvertToRicosRequest (chunk, plugins)));
804 break ;
805 } catch (err) {
806 if (attempt >= RICOS_RETRY_DELAYS_MS . length || ! isRetryableRicosError (err)) throw err;
807 await new Promise (( resolve ) => setTimeout (resolve, RICOS_RETRY_DELAYS_MS [attempt]));
808 }
809 }
810 if ( ! merged) merged = document;
811 else merged.nodes = (merged.nodes || []). concat (document.nodes || []);
812 }
813 return mediaBySourceUrl ? rewriteInlineMedia (merged, mediaBySourceUrl) : merged;
814 }
815 // VERIFIED-TRAP (2026-08-04, live to-ricos call): the converter nests the media object
816 // under a type-named key — `imageData.image.src.url`, `videoData.video.src.url`,
817 // `audioData.audio.src.url`. The earlier `media.src` / bare `src` paths matched nothing,
818 // so inline rewrites were silently a no-op (posts kept hot-linking the source host).
819 function rewriteInlineMedia ( ricosDocument , mediaBySourceUrl ) {
820 const MEDIA_KEYS = { imageData: 'image' , videoData: 'video' , audioData: 'audio' };
821 const visit = ( node ) => {
822 if ( ! node || typeof node !== 'object' ) return ;
823 for ( const [ key , inner ] of Object. entries ( MEDIA_KEYS )) {
824 const holder = node[key]?.[inner] || node[key]?.media || node[key];
825 const src = holder?.src?.url;
826 if (src && mediaBySourceUrl. has (src)) {
827 holder.src = { id: mediaBySourceUrl. get (src) };
828 }
829 }
830 (node.nodes || []). forEach (visit);
831 };
832 (ricosDocument?.nodes || []). forEach (visit);
833 return ricosDocument;
834 }
835
836 // --- media (import-from-URL) -----------------------------------------------
837 // VERIFIED: POST /site-media/v1/files/import. ASYNC — the response file has
838 // operationStatus PENDING. VERIFIED (2026-08-04, live): a PENDING id is immediately
839 // referenceable in BLOG content (heroImage.id + inline Ricos src.id) — create/publish
840 // succeed while PENDING, the reference survives, and the CDN URL serves pre-READY —
841 // so blog writers must NOT block on waitUntilFileReady per file. Poll only when the
842 // flow reads the descriptor back (dimensions land at READY) or must surface a FAILED
843 // import before content ships. Unverified for product media / CMS reference fields —
844 // keep the wait there (README Part 5 item 20).
845 function buildImportMediaRequest ({ sourceUrl , displayName , mimeType , mediaType , wpId }) {
846 return {
847 method: 'POST' ,
848 url: `${ WIXAPIS }/site-media/v1/files/import` ,
849 body: {
850 url: sourceUrl,
851 displayName,
852 mimeType: mimeType || undefined ,
853 mediaType: mediaType ? String (mediaType). toUpperCase () : undefined , // IMAGE | AUDIO | VIDEO | DOCUMENT
854 externalInfo: wpId != null ? { origin: 'wordpress' , externalId: String (wpId) } : undefined ,
855 },
856 };
857 }
858 async function importMedia ( wix , payload ) {
859 const { file } = await wix. send ( buildImportMediaRequest (payload));
860 return file; // { id, url, operationStatus, ... }
861 }
862 // VERIFIED: GET /site-media/v1/files/{id} returns the descriptor; poll until ready.
863 async function waitUntilFileReady ( wix , fileId , { tries = 10 , delayMs = 1500 } = {}) {
864 for ( let i = 0 ; i < tries; i ++ ) {
865 const r = await wix. send ({ method: 'GET' , url: `${ WIXAPIS }/site-media/v1/files/${ fileId }` });
866 const status = r?.file?.operationStatus;
867 if (status === 'READY' ) return r.file;
868 if (status === 'FAILED' ) throw new Error ( `media import failed for ${ fileId }` );
869 await new Promise (( res ) => setTimeout (res, delayMs));
870 }
871 return null ; // caller decides whether to proceed with a still-PENDING file
872 }
873
874 // --- blog taxonomies -------------------------------------------------------
875 // VERIFIED: POST /blog/v3/categories with { category: { label, slug, description } }.
876 function buildCreateCategoryRequest ({ label , slug , description }, safeModeOptions ) {
877 const prepared = applySafeModeToRequest ({ category: { label, slug, description: description || '' } }, safeModeOptions);
878 return {
879 method: 'POST' ,
880 url: `${ WIXAPIS }/blog/v3/categories` ,
881 body: prepared.body,
882 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
883 };
884 }
885 async function createBlogCategory ( wix , payload , safeModeOptions ) {
886 return ( await wix. send ( buildCreateCategoryRequest (payload, safeModeOptions))).category;
887 }
888 // VERIFIED: POST /blog/v3/tags. Body is TOP-LEVEL { label, language } — NOT
889 // { tag: { label, slug } }. `slug` is derived by Wix from the label.
890 function buildCreateTagRequest ({ label , language = 'en' }, safeModeOptions ) {
891 const prepared = applySafeModeToRequest ({ label, language }, safeModeOptions);
892 return {
893 method: 'POST' ,
894 url: `${ WIXAPIS }/blog/v3/tags` ,
895 body: prepared.body,
896 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
897 };
898 }
899 async function createBlogTag ( wix , payload , safeModeOptions ) {
900 return ( await wix. send ( buildCreateTagRequest (payload, safeModeOptions))).tag;
901 }
902 // VERIFIED: GET /blog/v3/tags lists tags as { id, label, slug, ... }. Used to resolve a
903 // tag id after a 409 ALREADY_EXISTS so it can still be attached to a post.
904 async function listBlogTags ( wix , { limit = 500 } = {}) {
905 const r = await wix. send ({ method: 'GET' , url: `${ WIXAPIS }/blog/v3/tags?paging.limit=${ limit }` });
906 return r.tags || [];
907 }
908
909 // --- blog posts ------------------------------------------------------------
910 // VERIFIED: POST /blog/v3/draft-posts then POST /blog/v3/draft-posts/{id}/publish.
911 // memberId is REQUIRED for 3rd-party app creates. Visible custom cover media requires
912 // BOTH `heroImage.id` and `media.{displayed,custom,wixMedia.image.id}` — `heroImage.id`
913 // alone leaves the cover hidden in Wix Blog.
914 // VERIFIED (2026-08-02): the site owner's auto-created user-member (present on our
915 // API-provisioned test site with zero Members-area interaction — single-site
916 // observation; resolve it via listMembers + loginEmail, never derive it from
917 // the account/user GUID — the observed id equality is undocumented) is accepted as
918 // memberId — attribute-to-owner needs no member provisioning. Author is re-assignable
919 // AFTER publish: PATCH
920 // /blog/v3/draft-posts/{id} { draftPost: { memberId } } then republish updates the
921 // published post (post id == draft id). Republish events are NOT suppressed by
922 // saveType=IMPORT — run author-upgrade passes inside the notification-mute window.
923 // VERIFIED (2026-06-10): tags attach via `tagIds` (array of tag GUIDs) on create — the
924 // builder must pass them or tags are created but never linked (postCount stays 0).
925 // VERIFIED-TRAP (2026-07-19): the draft-post REQUEST field for the slug is `seoSlug` —
926 // a `slug` key is silently ignored and Wix derives the slug from the title (only the
927 // RESPONSE carries `slug`). Fix-up after the fact: PATCH /blog/v3/draft-posts/{id} with
928 // { draftPost: { seoSlug } } then republish. Wix also reserves some slugs and coerces
929 // them (e.g. `pts` → `__pts`), which no request shape can override.
930 // VERIFIED-TRAP (2026-07-21, coffeeshop51): Wix rejects seoSlug whose percent-encoded
931 // form exceeds 100 chars (common for non-ASCII/Hebrew slugs: a 10-char Hebrew slug
932 // encodes to ~60 chars, so anything over ~15 chars blows the limit). Omit the slug
933 // when it is too long and let Wix derive it from the title.
934 function safeSeoslug ( slug ) {
935 if ( ! slug) return undefined ;
936 try { return encodeURIComponent (slug). length <= 100 ? slug : undefined ; } catch { return undefined ; }
937 }
938 function toDraftPostBody ({ title , memberId , richContent , excerpt , slug , categoryIds , tagIds , firstPublishedDate , heroImageId }) {
939 return {
940 title,
941 memberId, // REQUIRED
942 richContent, // Ricos document
943 excerpt: excerpt || undefined ,
944 seoSlug: safeSeoslug (slug),
945 categoryIds: categoryIds || [],
946 tagIds: tagIds && tagIds. length ? tagIds : undefined ,
947 firstPublishedDate: firstPublishedDate || undefined ,
948 heroImage: heroImageId ? { id: heroImageId } : undefined ,
949 media: heroImageId ? { displayed: true , custom: true , wixMedia: { image: { id: heroImageId } } } : undefined ,
950 };
951 }
952 function buildCreateDraftPostRequest ( payload ) {
953 return {
954 method: 'POST' ,
955 url: `${ WIXAPIS }/blog/v3/draft-posts` ,
956 body: { draftPost: toDraftPostBody (payload) },
957 };
958 }
959 async function createDraftPost ( wix , payload ) {
960 return ( await wix. send ( buildCreateDraftPostRequest (payload))).draftPost;
961 }
962 async function publishDraftPost ( wix , draftPostId ) {
963 return wix. send ({ method: 'POST' , url: `${ WIXAPIS }/blog/v3/draft-posts/${ draftPostId }/publish` , body: {} });
964 }
965
966 // VERIFIED (docs): DELETE /blog/v3/draft-posts/{draftPostId}. Despite the path, this also
967 // deletes an already-published post by the same id (draft id === published post id — see
968 // the "AFTER publish" note above). Moves to trash by default; pass permanent: true to skip
969 // the trash bin, which is what a throwaway test/verification post should use.
970 function buildDeleteDraftPostRequest ( draftPostId , { permanent = false } = {}) {
971 const query = permanent ? '?permanent=true' : '' ;
972 return { method: 'DELETE' , url: `${ WIXAPIS }/blog/v3/draft-posts/${ draftPostId }${ query }` };
973 }
974 async function deleteDraftPost ( wix , draftPostId , options ) {
975 return wix. send ( buildDeleteDraftPostRequest (draftPostId, options));
976 }
977
978 // UNVERIFIED: POST /blog/v3/bulk/draft-posts/create — bulk draft-post create, max 20
979 // posts per call (docs `draftPosts` validation: minItems 1, maxItems 20). Surfaced by the
980 // wix/skills `wix-manage` recipe (which recommends it "for any N ≥ 2", citing ~25–30s per
981 // single-post call) and confirmed against the public docs page; no live call yet, so per
982 // adapter policy it must be surfaced in the execution plan until the contract test
983 // promotes it. Whether the bulk create can publish directly (a `publish` flag) is
984 // unverified — publish remains per-post via publishDraftPost until proven otherwise.
985 const BLOG_BULK_CREATE_MAX = 20 ;
986 function buildBulkCreateDraftPostsRequest ( payloads ) {
987 if ( ! Array. isArray (payloads) || payloads. length < 1 || payloads. length > BLOG_BULK_CREATE_MAX ) {
988 throw new Error ( `buildBulkCreateDraftPostsRequest: expected 1..${ BLOG_BULK_CREATE_MAX } payloads, got ${ Array . isArray ( payloads ) ? payloads . length : typeof payloads }` );
989 }
990 return {
991 method: 'POST' ,
992 url: `${ WIXAPIS }/blog/v3/bulk/draft-posts/create` ,
993 body: { draftPosts: payloads. map (toDraftPostBody) },
994 };
995 }
996 // Chunks any number of payloads into ≤20-post calls, sequentially, and returns the
997 // concatenated raw per-call responses (response item shape unverified — callers must
998 // inspect until the live contract call pins it down).
999 async function bulkCreateDraftPosts ( wix , payloads ) {
1000 const responses = [];
1001 for ( let i = 0 ; i < payloads. length ; i += BLOG_BULK_CREATE_MAX ) {
1002 responses. push ( await wix. send ( buildBulkCreateDraftPostsRequest (payloads. slice (i, i + BLOG_BULK_CREATE_MAX ))));
1003 }
1004 return responses;
1005 }
1006
1007 // --- CMS items (Wix Data) --------------------------------------------------
1008 // VERIFIED: POST /wix-data/v2/items with { dataCollectionId, dataItem: { data } }.
1009 // Requires Wix Data enabled on the site (WDE0110 otherwise — see rp-execute-setup).
1010 // `data` is project-specific (the generated writer supplies the field map).
1011 // If the payload supplies `data._id`, Wix also requires `dataItem.id` to match.
1012 //
1013 // TRAP (found 2026-08-12 live run): when a caller sets a deterministic `data._id` (this
1014 // project's cms.js does, for podcast/dinekit/job-type, so the CMS item id is traceable back to
1015 // the source id), the live API 400s "WDE0080: dataItem id and data._id fields must match" unless
1016 // `dataItem.id` is ALSO set to that same value — `dataItem.id` is the authoritative item id;
1017 // `data._id` alone is not enough. Every insert in this project failed on this until fixed here.
1018 function buildInsertItemRequest ( collectionId , data , safeModeOptions ) {
1019 const dataItem = { data };
1020 if (data && typeof data === 'object' && ! Array. isArray (data) && data._id != null && String (data._id). trim () !== '' ) {
1021 dataItem.id = String (data._id);
1022 }
1023 const prepared = applySafeModeToRequest ({ dataCollectionId: collectionId, dataItem }, safeModeOptions);
1024 return {
1025 method: 'POST' ,
1026 url: `${ WIXAPIS }/wix-data/v2/items` ,
1027 body: prepared.body,
1028 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1029 };
1030 }
1031 async function insertDataItem ( wix , collectionId , data , safeModeOptions ) {
1032 return ( await wix. send ( buildInsertItemRequest (collectionId, data, safeModeOptions))).dataItem;
1033 }
1034 // VERIFIED: POST /wix-data/v2/items/query with { dataCollectionId, query }. Paginates via
1035 // query.paging {limit,offset}; returns dataItems[] (we return their `.data`). Required for
1036 // optional CMS mirror fetch: only for pre-execution seeding when an existing-site flow has
1037 // site-local reference data and valid local crosswalk state does not already exist. Runtime
1038 // resume/idempotency is owned by state/crosswalk/crosswalk.ndjson, not CMS.
1039 async function queryAllDataItems ( wix , collectionId , { pageSize = 100 } = {}) {
1040 const out = [];
1041 let offset = 0 ;
1042 for (;;) {
1043 const r = await wix. send ({ method: 'POST' , url: `${ WIXAPIS }/wix-data/v2/items/query` ,
1044 body: { dataCollectionId: collectionId, query: { paging: { limit: pageSize, offset } } } });
1045 const items = (r.dataItems || []). map (( d ) => d.data);
1046 out. push ( ... items);
1047 if (items. length < pageSize) break ;
1048 offset += pageSize;
1049 }
1050 return out;
1051 }
1052
1053 // --- Stores catalog (Catalog V3 ONLY) --------------------------------------
1054 // Catalog V1 is NOT a supported destination: these primitives target V3 exclusively, there is
1055 // no V1 fallback (it only masked real V3 errors as spurious 428s), and none should be added.
1056 // Catalog V3 is guaranteed at provisioning for a site this run creates (see
1057 // 0079-catalog-v3-guaranteed-retire-v1-gate.md), so callers need no pre-write check there; a
1058 // pre-existing site this run did not create is the one remaining case that can still be V1 —
1059 // see rp-execute-setup's "A V1_CATALOG verdict is terminal here". Wix Stores app id (installing it pulls in Wix
1060 // eCommerce): 215238eb-…
1061 const WIX_STORES_APP_ID = '215238eb-22a5-4c36-9e7b-e7c08025e04e' ;
1062 // Categories V3 require a top-level treeReference; appNamespace is always "@wix/stores".
1063 const STORES_TREE_REFERENCE = { appNamespace: '@wix/stores' };
1064 const PRODUCT_NAME_MAX = 80 ;
1065 const CHOICE_NAME_MAX = 50 ;
1066 // Products V3 schema: plainDescription is `string, maxLength 16000`. Unlike the Ricos path — which
1067 // chunked at 28k and merged node arrays, so it was effectively unbounded — this is a hard cap.
1068 const PLAIN_DESCRIPTION_MAX = 16000 ;
1069 const STORES_SUBSCRIPTION_DESCRIPTION_MAX = 60 ;
1070 const STORES_SUBSCRIPTION_FREQUENCIES = [ 'DAY' , 'WEEK' , 'MONTH' , 'YEAR' ];
1071 const STORES_SUBSCRIPTION_CONTRACT = {
1072 domain: 'stores' ,
1073 entity: 'product' ,
1074 surface: 'catalog-v3' ,
1075 operation: 'createProduct' ,
1076 path: 'product.subscriptionDetails' ,
1077 verificationLevel: 'live-create-and-readback' ,
1078 lastVerified: '2026-07-26' ,
1079 verifiedBy: 'migration-20260726-01' ,
1080 requiredPaths: [
1081 'product.subscriptionDetails.allowOneTimePurchases' ,
1082 'product.subscriptionDetails.subscriptions[]' ,
1083 'product.subscriptionDetails.subscriptions[].title' ,
1084 'product.subscriptionDetails.subscriptions[].description' ,
1085 'product.subscriptionDetails.subscriptions[].frequency' ,
1086 'product.subscriptionDetails.subscriptions[].interval' ,
1087 'product.subscriptionDetails.subscriptions[].autoRenewal' ,
1088 ],
1089 constraints: [
1090 {
1091 path: 'product.subscriptionDetails.subscriptions[].description' ,
1092 maxLength: STORES_SUBSCRIPTION_DESCRIPTION_MAX ,
1093 source: 'live-validation' ,
1094 },
1095 {
1096 path: 'product.subscriptionDetails.subscriptions[].frequency' ,
1097 enum: STORES_SUBSCRIPTION_FREQUENCIES ,
1098 source: 'live-create' ,
1099 },
1100 {
1101 path: 'product.subscriptionDetails.subscriptions[].interval' ,
1102 minimum: 1 ,
1103 integer: true ,
1104 source: 'live-create' ,
1105 },
1106 ],
1107 readback: {
1108 'product.subscriptionDetails' : 'returned-after-create' ,
1109 'product.subscriptionDetails.subscriptions[].id' : 'server-assigned' ,
1110 'product.subscriptionDetails.subscriptions[].title' : 'preserved' ,
1111 'product.subscriptionDetails.subscriptions[].description' : 'preserved' ,
1112 'product.subscriptionDetails.subscriptions[].frequency' : 'preserved' ,
1113 'product.subscriptionDetails.subscriptions[].interval' : 'preserved' ,
1114 'product.subscriptionDetails.subscriptions[].autoRenewal' : 'preserved' ,
1115 },
1116 };
1117
1118 function omitEmptyStringFields ( input , fields ) {
1119 const out = { ... input };
1120 for ( const field of fields) {
1121 if ( typeof out[field] === 'string' && out[field]. trim () === '' ) delete out[field];
1122 }
1123 return out;
1124 }
1125
1126 // Normalize a Catalog V3 product payload so callers never hit the known create traps.
1127 // All rules below are VERIFIED by live calls (2026-07-05, ilovecupcakes + suteka2):
1128 // - product name is capped at 80 chars; longer names 400 MAX_LENGTH.
1129 // - productType PHYSICAL requires a product-level physicalProperties object present
1130 // (400 ONE_OF_ALIGNMENT otherwise), even though the docs create example omits it.
1131 // - Option choice `name` is capped at 50 chars; option and variant choice names must be
1132 // truncated IDENTICALLY or the variant fails MISSING_VARIANT_OPTION_CHOICE.
1133 // - Variant optionChoiceNames require a `renderType` (default TEXT_CHOICES); omitting it
1134 // 428s MISSING_VARIANT_OPTION_CHOICE.
1135 // - compareAtPrice must be strictly greater than actualPrice; drop it otherwise (Wix
1136 // rejects a compare-at <= the actual price).
1137 function clampChoiceName ( name ) {
1138 const s = String (name);
1139 return s. length > CHOICE_NAME_MAX ? s. slice ( 0 , CHOICE_NAME_MAX ) : s;
1140 }
1141 function clampProductName ( name ) {
1142 const s = String (name || '' );
1143 return s. length > PRODUCT_NAME_MAX ? s. slice ( 0 , PRODUCT_NAME_MAX ) : s;
1144 }
1145 function isPublicHttpUrl ( value ) {
1146 try {
1147 const url = new URL ( String (value));
1148 return url.protocol === 'http:' || url.protocol === 'https:' ;
1149 } catch {
1150 return false ;
1151 }
1152 }
1153 function normalizeStoresProductMediaItems ( items = []) {
1154 return items
1155 . map (( item ) => {
1156 if ( ! item) return null ;
1157 if ( typeof item === 'string' ) {
1158 return isPublicHttpUrl (item) ? { url: item } : { id: item };
1159 }
1160 if (item.id) return { id: item.id };
1161 if (item.mediaId) return { id: item.mediaId };
1162 if (item.url && isPublicHttpUrl (item.url)) return { url: item.url };
1163 if (item.image?.id) return { id: item.image.id };
1164 return null ;
1165 })
1166 . filter (Boolean);
1167 }
1168 function buildStoresProductMedia ( items = []) {
1169 const normalizedItems = normalizeStoresProductMediaItems (items);
1170 return normalizedItems. length ? { itemsInfo: { items: normalizedItems } } : undefined ;
1171 }
1172 function compactText ( value ) {
1173 return String (value || '' ). replace ( /< [ ^ >] * >/ g , ' ' ). replace ( / \s + / g , ' ' ). trim ();
1174 }
1175 function clampStoresSubscriptionDescription ( value ) {
1176 const text = compactText (value);
1177 if (text. length <= STORES_SUBSCRIPTION_DESCRIPTION_MAX ) return text;
1178 return `${ text . slice ( 0 , STORES_SUBSCRIPTION_DESCRIPTION_MAX - 3 ). trimEnd () }...` ;
1179 }
1180 function normalizeStoresSubscriptionFrequency ( value ) {
1181 if (value == null ) return value;
1182 const frequency = String (value). trim (). toUpperCase ();
1183 return STORES_SUBSCRIPTION_FREQUENCIES . includes (frequency) ? frequency : value;
1184 }
1185 function normalizeStoresSubscriptionInterval ( value ) {
1186 if (value == null || value === '' ) return value;
1187 const interval = Number (value);
1188 return Number. isInteger (interval) && interval >= 1 ? interval : value;
1189 }
1190 function synthesizeStoresSubscriptionDescription ( subscription ) {
1191 if (subscription.description) return subscription.description;
1192 if (subscription.title) return subscription.title;
1193 const interval = normalizeStoresSubscriptionInterval (subscription.interval);
1194 const frequency = normalizeStoresSubscriptionFrequency (subscription.frequency);
1195 if (Number. isInteger (interval) && STORES_SUBSCRIPTION_FREQUENCIES . includes (frequency)) {
1196 const unit = frequency. toLowerCase ();
1197 return interval === 1 ? `Every ${ unit }` : `Every ${ interval } ${ unit }s` ;
1198 }
1199 return 'Subscription' ;
1200 }
1201 function normalizeStoresProductSubscriptions ( subscriptionDetails ) {
1202 if ( ! subscriptionDetails || typeof subscriptionDetails !== 'object' ) return subscriptionDetails;
1203 const normalized = { ... subscriptionDetails };
1204 if ( typeof normalized.allowOneTimePurchases !== 'boolean' ) normalized.allowOneTimePurchases = Boolean (normalized.allowOneTimePurchases);
1205 if (Array. isArray (subscriptionDetails.subscriptions)) {
1206 normalized.subscriptions = subscriptionDetails.subscriptions
1207 . filter (Boolean)
1208 . map (( subscription ) => ({
1209 ... subscription,
1210 description: clampStoresSubscriptionDescription ( synthesizeStoresSubscriptionDescription (subscription)),
1211 frequency: normalizeStoresSubscriptionFrequency (subscription.frequency),
1212 interval: normalizeStoresSubscriptionInterval (subscription.interval),
1213 }));
1214 }
1215 return normalized;
1216 }
1217 function validateStoresProductSubscriptionDetails ( product ) {
1218 const details = product && product.subscriptionDetails;
1219 const errors = [];
1220 const add = ( path , code , message ) => errors. push ({ path, code, message });
1221 if ( ! details || typeof details !== 'object' ) return { ok: true , errors };
1222 if ( typeof details.allowOneTimePurchases !== 'boolean' ) {
1223 add ( 'product.subscriptionDetails.allowOneTimePurchases' , 'required_boolean' , 'allowOneTimePurchases must be boolean' );
1224 }
1225 if ( ! Array. isArray (details.subscriptions) || details.subscriptions. length === 0 ) {
1226 add ( 'product.subscriptionDetails.subscriptions[]' , 'required_array' , 'subscriptions must contain at least one entry' );
1227 return { ok: false , errors };
1228 }
1229 details.subscriptions. forEach (( subscription , index ) => {
1230 const base = `product.subscriptionDetails.subscriptions[${ index }]` ;
1231 if ( ! compactText (subscription.title)) add ( `${ base }.title` , 'required' , 'title is required' );
1232 if ( ! compactText (subscription.description)) {
1233 add ( `${ base }.description` , 'required' , 'description is required' );
1234 } else if ( compactText (subscription.description). length > STORES_SUBSCRIPTION_DESCRIPTION_MAX ) {
1235 add ( `${ base }.description` , 'max_length' , `description must be at most ${ STORES_SUBSCRIPTION_DESCRIPTION_MAX } characters` );
1236 }
1237 if ( ! STORES_SUBSCRIPTION_FREQUENCIES . includes (subscription.frequency)) {
1238 add ( `${ base }.frequency` , 'enum' , `frequency must be one of ${ STORES_SUBSCRIPTION_FREQUENCIES . join ( ', ' ) }` );
1239 }
1240 if ( ! Number. isInteger (subscription.interval) || subscription.interval < 1 ) {
1241 add ( `${ base }.interval` , 'minimum' , 'interval must be an integer >= 1' );
1242 }
1243 if ( typeof subscription.autoRenewal !== 'boolean' ) add ( `${ base }.autoRenewal` , 'required_boolean' , 'autoRenewal must be boolean' );
1244 });
1245 return { ok: errors. length === 0 , errors };
1246 }
1247 // VERIFIED-TRAP (2026-07-19, nopong migration): variant `price` must be a MONEY OBJECT
1248 // ({ actualPrice: { amount: "14.95" } }) — a bare string/number 400s "Expected an object".
1249 // Generated transforms kept emitting scalars, so coerce here instead of failing at create.
1250 function toMoneyObject ( price ) {
1251 if (price == null || typeof price === 'object' ) return price;
1252 return { actualPrice: { amount: String (price) } };
1253 }
1254 function normalizeStoresProductV3 ( input ) {
1255 const product = { ... input };
1256 if (product.name != null ) product.name = clampProductName (product.name);
1257
1258 // A `description` STRING is HTML that belongs in plainDescription; `description` proper is a
1259 // Ricos document object. Callers that hand-build a product (or predate the plainDescription
1260 // switch) still pass the string, so route it here rather than sending HTML where an object
1261 // is expected.
1262 if ( typeof product.description === 'string' ) {
1263 const html = product.description. trim ();
1264 delete product.description;
1265 if (html && product.plainDescription == null ) product.plainDescription = html;
1266 }
1267 // TRAP (Products V3 schema): "plainDescription is ignored when a value is also passed to the
1268 // description field." Sending both is a SILENT failure — a 200 with an empty description — so
1269 // it is rejected here rather than discovered on a live site.
1270 if (product.plainDescription != null && product.description != null ) {
1271 throw new Error (
1272 `normalizeStoresProductV3: "${ product . name }" sets both description and plainDescription; Wix ignores plainDescription when description is present. Set exactly one.` ,
1273 );
1274 }
1275 if ( typeof product.plainDescription === 'string' && product.plainDescription. length > PLAIN_DESCRIPTION_MAX ) {
1276 throw new Error (
1277 `normalizeStoresProductV3: "${ product . name }" has a ${ product . plainDescription . length }-character plainDescription; Wix caps it at ${ PLAIN_DESCRIPTION_MAX }. Truncate it or move the overflow into an info section, and record the loss in mapping-gaps.json.` ,
1278 );
1279 }
1280 if (product.productType) product.productType = String (product.productType). toUpperCase ();
1281 if (product.subscriptionDetails) {
1282 // Catalog V3 carries recurring offers directly on the product object. Keep the
1283 // nested shape stable here so create/patch flows preserve subscription payloads
1284 // instead of relying on incidental shallow-copy behavior.
1285 product.subscriptionDetails = normalizeStoresProductSubscriptions (product.subscriptionDetails);
1286 }
1287
1288 const topLevelPrice = product.price;
1289 const topLevelSku = product.sku;
1290 const topLevelPhysicalProperties = product.physicalProperties;
1291 delete product.price;
1292 delete product.sku;
1293 if (product.media && Array. isArray (product.media.itemsInfo?.items || product.media.items)) {
1294 product.media = buildStoresProductMedia (product.media.itemsInfo?.items || product.media.items);
1295 }
1296
1297 if ( ! product.variantsInfo && (topLevelPrice || topLevelSku || topLevelPhysicalProperties)) {
1298 product.variantsInfo = {
1299 variants: [{
1300 visible: product.visible !== false ,
1301 ... (topLevelSku ? { sku: topLevelSku } : {}),
1302 ... (topLevelPrice ? { price: toMoneyObject (topLevelPrice) } : {}),
1303 ... (topLevelPhysicalProperties ? { physicalProperties: topLevelPhysicalProperties } : {}),
1304 }],
1305 };
1306 }
1307
1308 if ( String (product.productType || '' ). toUpperCase () === 'PHYSICAL' ) {
1309 product.physicalProperties = {};
1310 }
1311 if (Array. isArray (product.options)) {
1312 product.options = product.options. map (( o ) => ({
1313 ... o,
1314 optionRenderType: o.optionRenderType || 'TEXT_CHOICES' ,
1315 choicesSettings: o.choicesSettings && Array. isArray (o.choicesSettings.choices)
1316 // VERIFIED-TRAP (2026-07-21, coffeeshop51): `choiceType` is required on every choice — omitting it returns PRODUCT_OPTION_CHOICE_NAME_AND_TYPE_REQUIRED.
1317 ? { ... o.choicesSettings, choices: o.choicesSettings.choices. map (( c ) => ({ ... c, name: clampChoiceName (c.name), choiceType: c.choiceType || 'CHOICE_TEXT' })) }
1318 : o.choicesSettings,
1319 }));
1320 }
1321 const variants = product.variantsInfo && Array. isArray (product.variantsInfo.variants) ? product.variantsInfo.variants : null ;
1322 if (variants) {
1323 product.variantsInfo = {
1324 ... product.variantsInfo,
1325 variants: variants. map (( v ) => {
1326 const nv = { ... v };
1327 if (nv.price != null ) nv.price = toMoneyObject (nv.price);
1328 const price = nv.price;
1329 if (price && price.compareAtPrice && price.actualPrice) {
1330 const cmp = Number (price.compareAtPrice.amount);
1331 const act = Number (price.actualPrice.amount);
1332 if ( ! (cmp > act)) { const { compareAtPrice , ... rest } = price; nv.price = rest; }
1333 }
1334 if (Array. isArray (nv.choices)) {
1335 nv.choices = nv.choices. map (( ch ) => ch.optionChoiceNames
1336 ? { ... ch, optionChoiceNames: { renderType: 'TEXT_CHOICES' , ... ch.optionChoiceNames, choiceName: clampChoiceName (ch.optionChoiceNames.choiceName) } }
1337 : ch);
1338 }
1339 return nv;
1340 }),
1341 };
1342 }
1343 return product;
1344 }
1345
1346 // VERIFIED (Products V3 schema): `plainDescription` is a STRING of HTML (max 16000) that Wix
1347 // converts to rich content SERVER-SIDE. It is not a plain-text flattening and costs no fidelity
1348 // against `description` — it is the same conversion, just not ours to run.
1349 //
1350 // So an HTML string never routes through /ricos/v1/... on the product path. That matters at
1351 // scale: the previous behaviour converted one description PER PRODUCT before a bulk create, so a
1352 // 100-product batch was 100 serial round-trips plus the bulk call, and that burst is exactly what
1353 // the endpoint throttles with a 403 (see convertHtmlToRichContent above). It is now one call.
1354 // convertHtmlToRichContent stays for the blog path, where `richContent` really is a Ricos document.
1355 //
1356 // `wix` is retained (unused) so the (wix, input) call shape stays valid: flipping the signature
1357 // would make an existing `f(wix, product)` call normalize the CLIENT object and silently return
1358 // garbage. Now synchronous — `await` on the result is harmless.
1359 // eslint-disable-next-line no-unused-vars
1360 function normalizeStoresProductV3ForCreate ( wix , input ) {
1361 return normalizeStoresProductV3 (input);
1362 }
1363
1364 // VERIFIED (2026-07-05): POST /stores/v3/products with { product } (+ optional fields[]).
1365 function buildCreateStoresProductRequest ( product , safeModeOptions , fields ) {
1366 const prepared = applySafeModeToRequest ({ product: normalizeStoresProductV3 (product) }, safeModeOptions);
1367 const body = prepared.body;
1368 if (fields) body.fields = fields;
1369 return {
1370 method: 'POST' ,
1371 url: `${ WIXAPIS }/stores/v3/products` ,
1372 body,
1373 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1374 };
1375 }
1376 async function createStoresProduct ( wix , product , safeModeOptions , fields ) {
1377 const normalized = normalizeStoresProductV3ForCreate (wix, product);
1378 return ( await wix. send ( buildCreateStoresProductRequest (normalized, safeModeOptions, fields))).product;
1379 }
1380 // --- bulk product create (the scale path) ----------------------------------
1381 // UNVERIFIED: POST /stores/v3/bulk/products-with-inventory/create — up to 100 products with
1382 // their options, variants, inline brand/ribbon/infoSections AND per-variant inventory items
1383 // in ONE request. This is the path a migration of any real size must use; creating products
1384 // one at a time is only acceptable for a handful.
1385 //
1386 // PER-REQUEST LIMITS (all of them, simultaneously — exceeding ANY ONE rejects the whole
1387 // request, so batch with ndjson.readBatchesByLimits, not on record count alone):
1388 // products <= 100
1389 // variantsInfo.variants <= 1000 (total across the request)
1390 // options <= 100 (total; 2 options per product caps a batch at 50)
1391 // modifiers <= 100 (total)
1392 // infoSections <= 100 (total)
1393 // BULK_LIMITS below is the machine-readable copy — use it rather than re-typing the numbers.
1394 //
1395 // TRAP: bulk is NOT atomic. Each item succeeds or fails independently via
1396 // `results[i].itemMetadata.success`; a 200 response can still contain failures. Callers MUST
1397 // walk the per-item results and never infer success from the HTTP status.
1398 //
1399 // TRAP: `itemMetadata.originalIndex` correlates a result back to the request array. Do not
1400 // assume the response preserves request order — key on originalIndex, and fall back to
1401 // position only when it is absent.
1402 //
1403 // TRAP: `bulkActionMetadata.undetailedFailures` counts failures whose detail was dropped
1404 // because the threshold was exceeded. Ignoring it silently loses failed records.
1405 //
1406 // `returnEntity: false` (the default) still returns `itemMetadata.id`, which is all a
1407 // crosswalk needs — pass `returnEntity: true` only when the caller must inspect the created
1408 // entity (e.g. a contract probe verifying variant counts), because the payload is large.
1409 const BULK_PRODUCT_LIMITS = { records: 100 , variants: 1000 , options: 100 , modifiers: 100 , infoSections: 100 };
1410
1411 // Cost of one product against those limits, for readBatchesByLimits.
1412 function storesProductBulkCost ( product ) {
1413 const v = product && product.variantsInfo && product.variantsInfo.variants;
1414 return {
1415 variants: Array. isArray (v) ? Math. max ( 1 , v. length ) : 1 ,
1416 options: Array. isArray (product && product.options) ? product.options. length : 0 ,
1417 modifiers: Array. isArray (product && product.modifiers) ? product.modifiers. length : 0 ,
1418 infoSections: Array. isArray (product && product.infoSections) ? product.infoSections. length : 0 ,
1419 };
1420 }
1421
1422 function buildBulkCreateStoresProductsRequest ( products , { returnEntity = false , fields } = {}) {
1423 if ( ! Array. isArray (products) || products. length === 0 ) {
1424 throw new Error ( 'buildBulkCreateStoresProductsRequest: products must be a non-empty array' );
1425 }
1426 if (products. length > BULK_PRODUCT_LIMITS .records) {
1427 throw new Error (
1428 `buildBulkCreateStoresProductsRequest: ${ products . length } products exceeds the per-request limit of ${ BULK_PRODUCT_LIMITS . records }. ` +
1429 'Batch with ndjson.readBatchesByLimits using BULK_PRODUCT_LIMITS.' ,
1430 );
1431 }
1432 const body = { products: products. map (( p ) => normalizeStoresProductV3 (p)), returnEntity };
1433 if (fields) body.fields = fields;
1434 return { method: 'POST' , url: `${ WIXAPIS }/stores/v3/bulk/products-with-inventory/create` , body };
1435 }
1436
1437 // Normalizes each product, sends ONE bulk request, and returns a per-item outcome list already
1438 // correlated back to the input index. Callers get a flat shape they cannot accidentally read as
1439 // all-or-nothing.
1440 //
1441 // Normalization is local — HTML descriptions travel as `plainDescription` and Wix converts them
1442 // server-side, so this is one HTTP call, not one-per-product plus the bulk call.
1443 async function bulkCreateStoresProductsWithInventory ( wix , products , { returnEntity = false , fields } = {}) {
1444 const normalized = products. map (( product ) => normalizeStoresProductV3ForCreate (wix, product));
1445
1446 const response = await wix. send ( buildBulkCreateStoresProductsRequest (normalized, { returnEntity, fields }));
1447 // VERIFIED (2026-07-29) against the BulkCreateProductsWithInventoryResponse schema:
1448 // TRAP: products-with-inventory nests the per-item results ONE LEVEL DEEPER than its
1449 // sibling /stores/v3/bulk/products/create. Here they are `productResults.results` +
1450 // `productResults.bulkActionMetadata`; only `inventoryResults` is top-level. Reading
1451 // `response.results` yields undefined, which the unaccounted guard correctly reports as a
1452 // correlation failure AFTER the products have already been created. The flat fallback keeps
1453 // this tolerant of the sibling envelope.
1454 const productResults = (response && response.productResults) || {};
1455 const rawResults = productResults.results || (response && response.results) || [];
1456 const meta = productResults.bulkActionMetadata || (response && response.bulkActionMetadata) || {};
1457
1458 const results = rawResults. map (( r , position ) => {
1459 const im = (r && r.itemMetadata) || {};
1460 // originalIndex is authoritative; position is the documented fallback only.
1461 const index = Number. isInteger (im.originalIndex) ? im.originalIndex : position;
1462 return {
1463 index,
1464 inputProduct: products[index],
1465 success: im.success === true ,
1466 productId: im.id || (r.item && r.item.id) || null ,
1467 revision: (r.item && r.item.revision) || null ,
1468 product: r.item || null ,
1469 errorCode: im.error && im.error.code ? im.error.code : null ,
1470 errorDescription: im.error && im.error.description ? im.error.description : null ,
1471 };
1472 });
1473
1474 const succeeded = results. filter (( r ) => r.success);
1475 const failed = results. filter (( r ) => ! r.success);
1476 const undetailedFailures = meta.undetailedFailures || 0 ;
1477
1478 // A result set that does not account for every input is a correlation bug, not a partial
1479 // success — surface it rather than silently crosswalking the wrong ids.
1480 const unaccounted = products. length - results. length - undetailedFailures;
1481
1482 return {
1483 results,
1484 succeeded,
1485 failed,
1486 totalSuccesses: meta.totalSuccesses !== undefined ? meta.totalSuccesses : succeeded. length ,
1487 totalFailures: meta.totalFailures !== undefined ? meta.totalFailures : failed. length ,
1488 undetailedFailures,
1489 unaccounted: unaccounted > 0 ? unaccounted : 0 ,
1490 inventoryResults: (response && response.inventoryResults) || null ,
1491 };
1492 }
1493
1494 function buildQueryStoresProductsRequest ( query = { paging: { limit: 100 } }, fields ) {
1495 const body = { query };
1496 if (fields) body.fields = fields;
1497 return { method: 'POST' , url: `${ WIXAPIS }/stores/v3/products/query` , body };
1498 }
1499 // ONE PAGE, unwrapped to the products array, pagingMetadata discarded — see the READ/RETURN
1500 // CONTRACT at the top of this file. Do not build a dedupe index or a safety net on this.
1501 async function queryStoresProducts ( wix , query , fields ) {
1502 return ( await wix. send ( buildQueryStoresProductsRequest (query, fields))).products || [];
1503 }
1504 // OBSERVED (2026-07-29, shopify-mysite1): the only correct way to sweep the catalog, and the
1505 // primitive any crosswalk-recovery / name-match safety net must use. The unwrapping executor
1506 // above cannot be cursor-paged (it discards the cursor), and the hand-rolled loop that reads
1507 // `.products` off its already-unwrapped return value produces an EMPTY set — which reads as
1508 // "the store is empty" and is exactly the state under which an import re-creates the whole
1509 // catalog it already imported. Hence: throw on an incomplete sweep, never return a partial index.
1510 //
1511 // VERIFIED (2026-08-12, the reference store catalog backfill): cursorPaging from the very first page works
1512 // live against /stores/v3/products/query (confirms the "documented Wix convention" note below
1513 // by real call). `fields: ['DIRECT_CATEGORIES_INFO', 'MEDIA_ITEMS_INFO']` on the request body
1514 // (sibling of `query`, not nested inside it) returns `directCategoriesInfo.categories[]` and
1515 // `media.itemsInfo.items[]` per product — a plain query/get omits both (categories entirely;
1516 // media collapses to `media.main` only), mirroring the GET-product MEDIA_ITEMS_INFO trap noted
1517 // on buildMedia() in wix-build.js.
1518 async function queryAllStoresProducts ( wix , { pageSize = 100 , maxPages = 200 , fields } = {}) {
1519 const all = [];
1520 const seen = new Set ();
1521 let cursor = null ;
1522 let pages = 0 ;
1523 do {
1524 const query = cursor ? { cursorPaging: { limit: pageSize, cursor } } : { cursorPaging: { limit: pageSize } };
1525 const response = await wix. send ( buildQueryStoresProductsRequest (query, fields));
1526 for ( const product of response.products || []) {
1527 if (product && ! seen. has (product.id)) { seen. add (product.id); all. push (product); }
1528 }
1529 const meta = response.pagingMetadata || {};
1530 cursor = (meta.cursors && meta.cursors.next) || null ;
1531 pages += 1 ;
1532 } while (cursor && pages < maxPages);
1533 if (cursor) throw new Error ( `queryAllStoresProducts: still paging after ${ maxPages } pages; refusing to return a partial product index.` );
1534 return all;
1535 }
1536 // VERIFIED (migration-20260715-01): PATCH /stores/v3/products/{id} with
1537 // { product: { revision, media: { itemsInfo: { items: [{id}|{url}] } } } } updates
1538 // product gallery media. Prefer external URLs here when the source media is publicly
1539 // reachable: the Stores product API ingests them in the background, which avoids the
1540 // slower, heavily-throttled Media Manager pre-import path.
1541 function buildPatchStoresProductMediaRequest ({ productId , revision , items = [] }) {
1542 return {
1543 method: 'PATCH' ,
1544 url: `${ WIXAPIS }/stores/v3/products/${ productId }` ,
1545 body: {
1546 product: {
1547 revision,
1548 media: buildStoresProductMedia (items),
1549 },
1550 },
1551 };
1552 }
1553 async function patchStoresProductMedia ( wix , payload ) {
1554 return wix. send ( buildPatchStoresProductMediaRequest (payload));
1555 }
1556
1557 // VERIFIED (2026-08-12, the reference store catalog backfill): PATCH /stores/v3/products/{id} with
1558 // { product: { revision, tags: { publicTags: { tagIds: [...] } } } } attaches EXISTING Wix
1559 // Tag ids (from POST /tags/v1/tags) to a product. Confirmed live: 200, tags echoed back on
1560 // the response's `product.tags`, revision incremented as normal.
1561 //
1562 // TRAP (found live 2026-08-12): the shape that 400s the WHOLE bulk product-create request
1563 // ("Expected an object") is `tags: { publicTags: [...] }` — publicTags as a bare array. The
1564 // product object's `tags.publicTags` / `tags.privateTags` are each a `TagList` object wrapping
1565 // `tagIds: string[]`, per the Products V3 product-object docs — NOT an array of tag objects.
1566 // This PATCH is a full-replace of tags.publicTags.tagIds, not a merge/add: callers must send
1567 // the complete desired tagIds set (union with anything that must be preserved), same as
1568 // patchStoresProductMedia is a full-replace of media.itemsInfo.items.
1569 function buildPatchStoresProductTagsRequest ({ productId , revision , tagIds = [] }) {
1570 return {
1571 method: 'PATCH' ,
1572 url: `${ WIXAPIS }/stores/v3/products/${ productId }` ,
1573 body: {
1574 product: {
1575 revision,
1576 tags: { publicTags: { tagIds } },
1577 },
1578 },
1579 };
1580 }
1581 async function patchStoresProductTags ( wix , payload ) {
1582 return wix. send ( buildPatchStoresProductTagsRequest (payload));
1583 }
1584
1585 // UNVERIFIED: GET /stores/v3/products/{id} and GET /stores/v3/products/slug/{slug}.
1586 // Used by upsert flows to check whether a product already exists before creating it.
1587 // Both return 404 when the product is not found — callers should catch and treat as null.
1588 function buildGetStoresProductRequest ( id ) {
1589 return { method: 'GET' , url: `${ WIXAPIS }/stores/v3/products/${ encodeURIComponent ( id ) }` };
1590 }
1591 async function getStoresProduct ( wix , id ) {
1592 return ( await wix. send ( buildGetStoresProductRequest (id))).product;
1593 }
1594 function buildGetStoresProductBySlugRequest ( slug ) {
1595 return { method: 'GET' , url: `${ WIXAPIS }/stores/v3/products/slug/${ encodeURIComponent ( slug ) }` };
1596 }
1597 async function getStoresProductBySlug ( wix , slug ) {
1598 return ( await wix. send ( buildGetStoresProductBySlugRequest (slug))).product;
1599 }
1600 function buildDeleteStoresProductRequest ( id ) {
1601 return { method: 'DELETE' , url: `${ WIXAPIS }/stores/v3/products/${ encodeURIComponent ( id ) }` };
1602 }
1603 async function deleteStoresProduct ( wix , id ) {
1604 return wix. send ( buildDeleteStoresProductRequest (id));
1605 }
1606
1607 // UNVERIFIED (endpoint VERIFIED via patchStoresProductMedia): PATCH /stores/v3/products/{id}
1608 // with arbitrary product fields. The `revision` from the existing product is required.
1609 // A string `description` is moved to `plainDescription` by normalizeStoresProductV3 (same as
1610 // createStoresProduct); Wix converts that HTML to rich content server-side.
1611 // Do not use this for media-only updates — patchStoresProductMedia is the verified path for that.
1612 function buildPatchStoresProductRequest ({ productId , revision , ... productFields }, safeModeOptions ) {
1613 const prepared = applySafeModeToRequest ({ product: { revision, ... normalizeStoresProductV3 (productFields) } }, safeModeOptions);
1614 return {
1615 method: 'PATCH' ,
1616 url: `${ WIXAPIS }/stores/v3/products/${ productId }` ,
1617 body: prepared.body,
1618 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1619 };
1620 }
1621 async function patchStoresProduct ( wix , { productId , revision , ... productFields }, safeModeOptions ) {
1622 // No description handling here: buildPatchStoresProductRequest runs normalizeStoresProductV3,
1623 // which moves a string `description` to `plainDescription` for Wix to convert server-side.
1624 return ( await wix. send ( buildPatchStoresProductRequest ({ productId, revision, ... productFields }, safeModeOptions))).product;
1625 }
1626
1627 // UNVERIFIED: POST /categories/v1/categories/query returns ONE PAGE of Stores categories —
1628 // NOT all of them, whatever this comment used to say. treeReference is TOP-LEVEL (same trap as
1629 // create). Used to seed a name→id cache for upsert flows so existing categories are reused
1630 // instead of duplicated — which means the cache must be built with queryAllStoresCategories,
1631 // since a truncated cache duplicates exactly the categories it failed to read.
1632 function buildQueryStoresCategoriesRequest ( query = { paging: { limit: 100 } }) {
1633 return {
1634 method: 'POST' ,
1635 url: `${ WIXAPIS }/categories/v1/categories/query` ,
1636 body: { query, treeReference: STORES_TREE_REFERENCE },
1637 };
1638 }
1639 // ONE PAGE, unwrapped to the categories array, pagingMetadata discarded — see the READ/RETURN
1640 // CONTRACT at the top of this file. Use queryAllStoresCategories below for any dedupe index.
1641 async function queryStoresCategories ( wix , query ) {
1642 return ( await wix. send ( buildQueryStoresCategoriesRequest (query))).categories || [];
1643 }
1644 // OBSERVED (2026-07-29): `queryStoresCategories` returns ONE PAGE (100 max) and, by unwrapping to
1645 // the array, discards the pagingMetadata needed to fetch the rest. A dedupe index built from it is
1646 // silently truncated once a site passes 100 categories — a site with 119 read as 100, which would
1647 // duplicate the missing 19 on the next import. Any upsert/dedupe flow must use this instead.
1648 async function queryAllStoresCategories ( wix , { pageSize = 100 , maxPages = 200 } = {}) {
1649 const all = [];
1650 const seen = new Set ();
1651 let cursor = null ;
1652 let pages = 0 ;
1653 do {
1654 const query = cursor ? { cursorPaging: { limit: pageSize, cursor } } : { cursorPaging: { limit: pageSize } };
1655 const response = await wix. send ( buildQueryStoresCategoriesRequest (query));
1656 for ( const category of response.categories || []) {
1657 if (category && ! seen. has (category.id)) { seen. add (category.id); all. push (category); }
1658 }
1659 const meta = response.pagingMetadata || {};
1660 cursor = (meta.cursors && meta.cursors.next) || null ;
1661 pages += 1 ;
1662 } while (cursor && pages < maxPages);
1663 if (cursor) throw new Error ( `queryAllStoresCategories: still paging after ${ maxPages } pages; refusing to return a partial category index.` );
1664 return all;
1665 }
1666
1667 // VERIFIED (2026-07-05): POST /categories/v1/categories with { category, treeReference }.
1668 // treeReference is TOP-LEVEL (sibling of category), NOT a category property — nesting it
1669 // 400s "treeReference must not be empty".
1670 function buildCreateStoresCategoryRequest ( category , safeModeOptions ) {
1671 const prepared = applySafeModeToRequest ({
1672 category: omitEmptyStringFields (category, [ 'description' ]),
1673 treeReference: STORES_TREE_REFERENCE ,
1674 }, safeModeOptions);
1675 return {
1676 method: 'POST' ,
1677 url: `${ WIXAPIS }/categories/v1/categories` ,
1678 body: prepared.body,
1679 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1680 };
1681 }
1682 async function createStoresCategory ( wix , category , safeModeOptions ) {
1683 return ( await wix. send ( buildCreateStoresCategoryRequest (category, safeModeOptions))).category;
1684 }
1685
1686 // VERIFIED (2026-07-05): add one product to categories in bulk —
1687 // POST /categories/v1/bulk/categories/add-item with
1688 // { item:{ catalogItemId, appId }, categoryIds[], treeReference }. catalogItemId is the Wix
1689 // product id; appId is the Wix Stores app id.
1690 function buildBulkAddItemToCategoriesRequest ({ productId , categoryIds }) {
1691 return {
1692 method: 'POST' ,
1693 url: `${ WIXAPIS }/categories/v1/bulk/categories/add-item` ,
1694 body: { item: { catalogItemId: productId, appId: WIX_STORES_APP_ID }, categoryIds, treeReference: STORES_TREE_REFERENCE },
1695 };
1696 }
1697 async function bulkAddItemToCategories ( wix , payload ) {
1698 return wix. send ( buildBulkAddItemToCategoriesRequest (payload));
1699 }
1700
1701 // --- Contacts --------------------------------------------------------------
1702 // Contacts V5 is GA (verified in public docs 2026-08-04). The GA contract
1703 // is FLAT: one main `email`/`phone` (matching + subscription live on the main entries),
1704 // `additionalEmails`/`additionalPhones` arrays, an `addresses` array with the postal
1705 // fields NESTED under `address`, and `company: { name, jobTitle }`. There is no `info`
1706 // wrapper and no V4-style `emails.items` list wrapper anywhere in V5 requests. Create and
1707 // update both take `{ contact, allowDuplicates }`; update requires the current `revision`
1708 // and has no fieldMask. Live create/query/update verification is still pending a token
1709 // with Contacts permissions (the 2026-07-26 probe got 403), so writers stay UNVERIFIED
1710 // until a contract test promotes them — but the target shape is now the documented GA one.
1711 //
1712 // Custom fields: the GA V5 contact carries `extendedFields.namespaces.<ns>` and the V5
1713 // docs route field DEFINITIONS through the Data Extension Schema API with FQDN
1714 // `wix.contacts.*.contact` (values under the `_user_fields` namespace). The V4 Contacts
1715 // Extended Fields API (`POST /contacts/v4/extended-fields`, values under
1716 // `info.extendedFields`) still exists but pairs with the V4 write surface only — do not
1717 // mix the two. Labels are likewise a V4 concept; V5 exposes `tags.privateTags.tagIds`
1718 // managed through the Tags API (same FQDN). NOTE: the Data Extension Schema intro's
1719 // supported-objects table does not list contacts yet — docs inconsistency at GA cutover;
1720 // treat the V5 contact-object statement as authoritative but verify live during setup.
1721 const V5_CONTACT_PHONE_TAGS = new Set ([ 'OTHER' , 'MAIN' , 'HOME' , 'MOBILE' , 'WORK' , 'FAX' ]);
1722 const V5_CONTACT_ADDRESS_TAGS = new Set ([ 'OTHER' , 'HOME' , 'WORK' , 'BILLING' , 'SHIPPING' ]);
1723 function normalizeV5PhoneTag ( tag ) {
1724 const normalized = String (tag || '' ). trim (). toUpperCase ();
1725 if ( ! normalized) return undefined ;
1726 if ( V5_CONTACT_PHONE_TAGS . has (normalized)) return normalized;
1727 if (normalized === 'PRIMARY' || normalized === 'SOURCE_PRIMARY' || normalized === 'BILLING' ) return 'MAIN' ;
1728 if (normalized === 'SHIPPING' ) return 'HOME' ;
1729 return 'OTHER' ;
1730 }
1731 function normalizeV5AddressTag ( tag ) {
1732 const normalized = String (tag || '' ). trim (). toUpperCase ();
1733 if ( ! normalized) return undefined ;
1734 return V5_CONTACT_ADDRESS_TAGS . has (normalized) ? normalized : 'OTHER' ;
1735 }
1736 // GA ContactAddress keeps postal fields nested under `address`; anything else found flat
1737 // on the item (city, country, streetAddress, …) is moved into `address` so legacy flat
1738 // items survive the shape change.
1739 const V5_ADDRESS_ITEM_KEYS = new Set ([ 'id' , 'tag' , 'address' , 'defaultAddress' , 'recipient' ]);
1740 function normalizeV5AddressItem ( item ) {
1741 if ( ! item || typeof item !== 'object' || Array. isArray (item)) return item;
1742 const out = {};
1743 const address = item.address && typeof item.address === 'object' && ! Array. isArray (item.address)
1744 ? { ... item.address }
1745 : {};
1746 for ( const [ key , value ] of Object. entries (item)) {
1747 if (key === 'address' ) continue ;
1748 if ( V5_ADDRESS_ITEM_KEYS . has (key)) out[key] = value;
1749 else address[key] = value;
1750 }
1751 const tag = normalizeV5AddressTag (out.tag);
1752 if (tag) out.tag = tag;
1753 if (Object. keys (address). length ) out.address = address;
1754 return out;
1755 }
1756 function normalizeV5Contact ( contact = {}) {
1757 const normalized = { ... contact };
1758 if (normalized.phone && typeof normalized.phone === 'object' ) {
1759 const tag = normalizeV5PhoneTag (normalized.phone.tag);
1760 normalized.phone = { ... normalized.phone, ... (tag ? { tag } : {}) };
1761 }
1762 if (Array. isArray (normalized.additionalPhones)) {
1763 normalized.additionalPhones = normalized.additionalPhones. map (( item ) => {
1764 if ( ! item || typeof item !== 'object' ) return item;
1765 const tag = normalizeV5PhoneTag (item.tag);
1766 return { ... item, ... (tag ? { tag } : {}) };
1767 });
1768 }
1769 if (Array. isArray (normalized.addresses)) {
1770 normalized.addresses = normalized.addresses. map (( item ) => normalizeV5AddressItem (item));
1771 }
1772 return normalized;
1773 }
1774 // Legacy V4-style `info` payloads (pre-GA generated transforms) convert through this
1775 // STRICT whitelist: unknown keys throw instead of silently dropping source data.
1776 // `extendedFields` and `labelKeys` throw because they have no mechanical V5 equivalent —
1777 // V5 custom fields live under extendedFields.namespaces (Data Extension Schema) and
1778 // labels became tags (Tags API); both need a setup-time decision, not a converter guess.
1779 const V4_INFO_CONVERTIBLE_KEYS = new Set ([
1780 'name' , 'emails' , 'phones' , 'addresses' , 'company' , 'jobTitle' , 'birthdate' , 'locale' ,
1781 ]);
1782 function contactListItems ( value ) {
1783 if ( ! value) return [];
1784 if (Array. isArray (value)) return value;
1785 if (Array. isArray (value.items)) return value.items;
1786 return [];
1787 }
1788 function pickMainListItem ( items ) {
1789 if ( ! items. length ) return { main: undefined , rest: [] };
1790 const mainIndex = Math. max ( 0 , items. findIndex (( item ) => item
1791 && typeof item === 'object'
1792 && (item.primary === true || String (item.tag || '' ). trim (). toUpperCase () === 'MAIN' )));
1793 return { main: items[mainIndex], rest: items. filter (( _ , index ) => index !== mainIndex) };
1794 }
1795 function contactInfoToV5Contact ( info = {}) {
1796 const unknownKeys = Object. keys (info). filter (( key ) => ! V4_INFO_CONVERTIBLE_KEYS . has (key));
1797 if (unknownKeys. length ) {
1798 throw new Error (
1799 `contactInfoToV5Contact: cannot convert V4-style info key(s) ${ JSON . stringify ( unknownKeys ) } to the GA Contacts V5 contact shape. `
1800 + 'extendedFields values belong under contact.extendedFields.namespaces (Data Extension Schema, FQDN wix.contacts.*.contact); '
1801 + 'labels became tags (Tags API). Regenerate the transform against the flat GA contact shape.' ,
1802 );
1803 }
1804 const contact = {};
1805 if (info.name !== undefined ) contact.name = info.name;
1806 const emails = pickMainListItem ( contactListItems (info.emails));
1807 if (emails.main) contact.email = { email: emails.main.email };
1808 if (emails.rest. length ) contact.additionalEmails = emails.rest. map (( item ) => ({ email: item.email }));
1809 const phones = pickMainListItem ( contactListItems (info.phones));
1810 if (phones.main) {
1811 const tag = normalizeV5PhoneTag (phones.main.tag);
1812 contact.phone = { phone: phones.main.phone, ... (tag ? { tag } : {}) };
1813 }
1814 if (phones.rest. length ) {
1815 contact.additionalPhones = phones.rest. map (( item ) => {
1816 const tag = normalizeV5PhoneTag (item.tag);
1817 return { phone: item.phone, ... (tag ? { tag } : {}) };
1818 });
1819 }
1820 const addresses = contactListItems (info.addresses);
1821 if (addresses. length ) contact.addresses = addresses. map (( item ) => normalizeV5AddressItem (item));
1822 if (info.company !== undefined || info.jobTitle !== undefined ) {
1823 contact.company = {
1824 ... (info.company !== undefined ? { name: info.company } : {}),
1825 ... (info.jobTitle !== undefined ? { jobTitle: info.jobTitle } : {}),
1826 };
1827 }
1828 if (info.birthdate !== undefined ) contact.birthdate = info.birthdate;
1829 if (info.locale !== undefined ) contact.locale = info.locale;
1830 return contact;
1831 }
1832 function toEpochMilliseconds ( value ) {
1833 if (value == null || value === '' ) return undefined ;
1834 if ( typeof value === 'number' && Number. isFinite (value)) {
1835 return value >= 1e12 ? value : value * 1000 ;
1836 }
1837 const parsed = Date. parse ( String (value));
1838 if ( ! Number. isFinite (parsed)) return value;
1839 return parsed;
1840 }
1841 function normalizeCouponSpecification ( specification = {}) {
1842 const normalized = { ... specification };
1843 normalized.startTime = toEpochMilliseconds (specification.startTime);
1844 normalized.expirationTime = toEpochMilliseconds (specification.expirationTime);
1845 if (specification.moneyOffRate != null && specification.percentOffRate == null ) {
1846 normalized.percentOffRate = specification.moneyOffRate;
1847 delete normalized.moneyOffRate;
1848 }
1849 if (normalized.percentOffRate != null ) {
1850 normalized.percentOffRate = Number (normalized.percentOffRate);
1851 }
1852 if (normalized.moneyOffAmount != null && typeof normalized.moneyOffAmount === 'object' ) {
1853 normalized.moneyOffAmount = Number (normalized.moneyOffAmount.amount);
1854 } else if (normalized.moneyOffAmount != null ) {
1855 normalized.moneyOffAmount = Number (normalized.moneyOffAmount);
1856 }
1857 if (normalized.fixedPriceAmount != null && typeof normalized.fixedPriceAmount === 'object' ) {
1858 normalized.fixedPriceAmount = Number (normalized.fixedPriceAmount.amount);
1859 } else if (normalized.fixedPriceAmount != null ) {
1860 normalized.fixedPriceAmount = Number (normalized.fixedPriceAmount);
1861 }
1862 if (normalized.minimumSubtotal != null ) {
1863 normalized.minimumSubtotal = Number (normalized.minimumSubtotal);
1864 }
1865 if (normalized.usageLimit != null ) {
1866 normalized.usageLimit = Number (normalized.usageLimit);
1867 }
1868 if (normalized.limitPerCustomer != null ) {
1869 normalized.limitPerCustomer = Number (normalized.limitPerCustomer);
1870 }
1871 if (normalized.scope && Object. keys (normalized.scope). length === 0 ) {
1872 delete normalized.scope;
1873 }
1874 return normalized;
1875 }
1876 // GA request: POST /contacts/v5/contacts { contact: <flat contact>, allowDuplicates }.
1877 // Accepts the flat GA `contact` directly; a legacy V4-style `info` payload is converted
1878 // via contactInfoToV5Contact (strict — throws on non-mechanical keys). At least one of
1879 // name.first, name.last, email.email, or phone.phone is required by the API.
1880 function buildCreateContactRequest ({ contact , info , allowDuplicates = false }, safeModeOptions ) {
1881 if (contact !== undefined && info !== undefined ) {
1882 throw new Error ( 'buildCreateContactRequest: pass either the flat GA `contact` or a legacy `info`, not both' );
1883 }
1884 const flatContact = info !== undefined ? contactInfoToV5Contact (info) : contact;
1885 if ( ! flatContact || typeof flatContact !== 'object' || Array. isArray (flatContact)) {
1886 throw new Error ( 'buildCreateContactRequest: contact must be a flat GA Contacts V5 contact object' );
1887 }
1888 const safeModeEnabled = isSafeModeEnabled (safeModeOptions);
1889 const prepared = applySafeModeToRequest ({
1890 contact: normalizeV5Contact (flatContact),
1891 allowDuplicates: safeModeEnabled ? true : allowDuplicates,
1892 }, safeModeOptions);
1893 return {
1894 method: 'POST' ,
1895 url: `${ WIXAPIS }/contacts/v5/contacts` ,
1896 body: prepared.body,
1897 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1898 };
1899 }
1900 async function createContact ( wix , payload , safeModeOptions ) {
1901 return ( await wix. send ( buildCreateContactRequest (payload, safeModeOptions))).contact;
1902 }
1903
1904 // UNVERIFIED writer (no live run yet). DOCUMENTED endpoint: POST
1905 // /contacts/v5/bulk/contacts/upsert — the CONT-01 import path: 1-100 contacts per call,
1906 // synchronous, per-item results. Contact matching (main email, or main phone when no
1907 // email) decides create vs update, so re-runs upsert instead of duplicating; `externalId`
1908 // (set-once, max 100 chars) carries the source-system id for the crosswalk.
1909 // `upsertMode`: OVERWRITE (default) | APPEND | OVERWRITE_APPEND_ARRAYS.
1910 // Contacts use the same flat GA shape as createContact; each array item wraps as
1911 // `{ contact }`.
1912 const CONTACTS_BULK_UPSERT_MAX = 100 ;
1913 function buildBulkUpsertContactsRequest ( contacts , { upsertMode , returnEntity = false , updateMember } = {}, safeModeOptions ) {
1914 if ( ! Array. isArray (contacts) || contacts. length === 0 ) {
1915 throw new Error ( 'buildBulkUpsertContactsRequest: contacts must be a non-empty array' );
1916 }
1917 if (contacts. length > CONTACTS_BULK_UPSERT_MAX ) {
1918 throw new Error (
1919 `buildBulkUpsertContactsRequest: ${ contacts . length } contacts exceeds the per-request limit of ${ CONTACTS_BULK_UPSERT_MAX } — batch upstream` ,
1920 );
1921 }
1922 const prepared = applySafeModeToRequest ({
1923 contacts: contacts. map (( contact ) => ({ contact: normalizeV5Contact (contact) })),
1924 ... (upsertMode ? { upsertMode } : {}),
1925 ... (returnEntity ? { returnEntity: true } : {}),
1926 ... ( typeof updateMember === 'boolean' ? { updateMember } : {}),
1927 }, safeModeOptions);
1928 return {
1929 method: 'POST' ,
1930 url: `${ WIXAPIS }/contacts/v5/bulk/contacts/upsert` ,
1931 body: prepared.body,
1932 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1933 };
1934 }
1935 // Returns per-item outcomes correlated back to the input index — the same flat shape as
1936 // bulkCreateStoresProductsWithInventory, so callers cannot misread partial failure as
1937 // all-or-nothing.
1938 async function bulkUpsertContacts ( wix , contacts , options = {}, safeModeOptions ) {
1939 const response = await wix. send ( buildBulkUpsertContactsRequest (contacts, options, safeModeOptions));
1940 const rawResults = (response && response.results) || [];
1941 const meta = (response && response.bulkActionMetadata) || {};
1942 const results = rawResults. map (( r , position ) => {
1943 const im = (r && r.itemMetadata) || {};
1944 // originalIndex is authoritative; position is the documented fallback only.
1945 const index = Number. isInteger (im.originalIndex) ? im.originalIndex : position;
1946 return {
1947 index,
1948 inputContact: contacts[index],
1949 success: im.success === true ,
1950 contactId: im.id || (r.item && r.item.id) || null ,
1951 action: r.action || null , // CREATED | UPDATED
1952 contact: r.item || null , // populated only with returnEntity: true
1953 errorCode: im.error && im.error.code ? im.error.code : null ,
1954 errorDescription: im.error && im.error.description ? im.error.description : null ,
1955 };
1956 });
1957 const succeeded = results. filter (( r ) => r.success);
1958 const failed = results. filter (( r ) => ! r.success);
1959 const undetailedFailures = meta.undetailedFailures || 0 ;
1960 // A result set that does not account for every input is a correlation bug, not a partial
1961 // success — surface it rather than silently crosswalking the wrong ids.
1962 const unaccounted = contacts. length - results. length - undetailedFailures;
1963 return {
1964 results,
1965 succeeded,
1966 failed,
1967 totalSuccesses: meta.totalSuccesses !== undefined ? meta.totalSuccesses : succeeded. length ,
1968 totalFailures: meta.totalFailures !== undefined ? meta.totalFailures : failed. length ,
1969 undetailedFailures,
1970 unaccounted: unaccounted > 0 ? unaccounted : 0 ,
1971 };
1972 }
1973 function buildQueryContactsRequest ( query = { paging: { limit: 100 , offset: 0 } }) {
1974 return { method: 'POST' , url: `${ WIXAPIS }/contacts/v5/contacts/query` , body: { query } };
1975 }
1976 // ONE PAGE, unwrapped to the contacts array — see the READ/RETURN CONTRACT at the top of this
1977 // file. Contacts pages by `paging.{limit,offset}`, so a full sweep advances the offset off the
1978 // raw response rather than following a cursor; there is no queryAll* helper yet.
1979 async function queryContacts ( wix , query ) {
1980 return ( await wix. send ( buildQueryContactsRequest (query))).contacts || [];
1981 }
1982 function buildGetContactRequest ( contactId ) {
1983 if ( ! contactId) throw new Error ( 'buildGetContactRequest: contactId is required' );
1984 return { method: 'GET' , url: `${ WIXAPIS }/contacts/v5/contacts/${ contactId }` };
1985 }
1986 async function getContact ( wix , contactId ) {
1987 return ( await wix. send ( buildGetContactRequest (contactId))).contact;
1988 }
1989 // GA request: PATCH /contacts/v5/contacts/{id} { contact: { id, revision, <flat fields> },
1990 // allowDuplicates? }. The current revision is REQUIRED (optimistic concurrency); there is
1991 // no fieldMask in the GA contract — passing one throws so stale pre-GA call sites fail
1992 // loudly instead of sending an unrecognized parameter.
1993 function buildUpdateContactRequest ({ contactId , revision , contact , info , allowDuplicates , fieldMask }) {
1994 if (fieldMask !== undefined ) {
1995 throw new Error ( 'buildUpdateContactRequest: GA Contacts V5 update has no fieldMask; send the flat fields to change on `contact`' );
1996 }
1997 if (contact !== undefined && info !== undefined ) {
1998 throw new Error ( 'buildUpdateContactRequest: pass either the flat GA `contact` or a legacy `info`, not both' );
1999 }
2000 const id = contactId || contact?.id;
2001 if ( ! id) throw new Error ( 'buildUpdateContactRequest: contactId is required' );
2002 const rev = revision ?? contact?.revision;
2003 if (rev === undefined || rev === null ) {
2004 throw new Error ( 'buildUpdateContactRequest: revision is required (read the contact first and pass its current revision)' );
2005 }
2006 const flatContact = info !== undefined ? contactInfoToV5Contact (info) : (contact || {});
2007 const nextContact = {
2008 ... normalizeV5Contact (flatContact),
2009 id,
2010 revision: rev,
2011 };
2012 return {
2013 method: 'PATCH' ,
2014 url: `${ WIXAPIS }/contacts/v5/contacts/${ id }` ,
2015 body: {
2016 contact: nextContact,
2017 ... (allowDuplicates !== undefined ? { allowDuplicates } : {}),
2018 },
2019 };
2020 }
2021 async function updateContact ( wix , payload ) {
2022 return ( await wix. send ( buildUpdateContactRequest (payload))).contact;
2023 }
2024 // V4-surface setup helper. Find Or Create Extended Field defines V4 `info.extendedFields`
2025 // custom fields and pairs with V4 contact writers only. For the GA V5 surface, custom
2026 // field definitions go through the Data Extension Schema API (FQDN wix.contacts.*.contact)
2027 // and values are written under `contact.extendedFields.namespaces._user_fields`.
2028 function buildFindOrCreateContactExtendedFieldRequest ({ displayName , dataType = 'TEXT' }) {
2029 if ( ! displayName) throw new Error ( 'buildFindOrCreateContactExtendedFieldRequest: displayName is required' );
2030 return {
2031 method: 'POST' ,
2032 url: `${ WIXAPIS }/contacts/v4/extended-fields` ,
2033 body: { displayName, dataType },
2034 };
2035 }
2036 async function findOrCreateContactExtendedField ( wix , payload ) {
2037 return ( await wix. send ( buildFindOrCreateContactExtendedFieldRequest (payload))).field;
2038 }
2039
2040 // --- Coupons ---------------------------------------------------------------
2041 // UNVERIFIED: read-only probe showed /stores/v2/coupons/query reaches the Coupons service
2042 // but returned app-not-installed/unauthorized on the target site. The specification must
2043 // contain exactly one coupon type; generated code must decide per source coupon whether
2044 // native Wix Coupons can represent the source coupon exactly. CMS is not a fallback for a
2045 // missing writer; it is only for coupons whose semantics do not fit Wix Coupons.
2046 function buildCreateCouponRequest ( specification , safeModeOptions ) {
2047 const prepared = applySafeModeToRequest ({ specification: normalizeCouponSpecification (specification) }, safeModeOptions);
2048 return {
2049 method: 'POST' ,
2050 url: `${ WIXAPIS }/stores/v2/coupons` ,
2051 body: prepared.body,
2052 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
2053 };
2054 }
2055 async function createCoupon ( wix , specification , safeModeOptions ) {
2056 const response = await wix. send ( buildCreateCouponRequest (specification, safeModeOptions));
2057 if (response?.coupon?.id) return response.coupon;
2058 const code = String (specification?.code || '' ). trim ();
2059 if (code) {
2060 for ( let attempt = 0 ; attempt < 4 ; attempt += 1 ) {
2061 const coupons = await queryCoupons (wix, { paging: { limit: 200 , offset: 0 } });
2062 const matched = coupons. find (( coupon ) => String (coupon?.specification?.code || '' ). trim () === code);
2063 if (matched?.id) return matched;
2064 if (attempt < 3 ) {
2065 await new Promise (( resolve ) => setTimeout (resolve, 750 ));
2066 }
2067 }
2068 }
2069 return response.coupon;
2070 }
2071 function buildQueryCouponsRequest ( query = { paging: { limit: 100 , offset: 0 } }) {
2072 return { method: 'POST' , url: `${ WIXAPIS }/stores/v2/coupons/query` , body: { query } };
2073 }
2074 // ONE PAGE, unwrapped to the coupons array — see the READ/RETURN CONTRACT at the top of this file.
2075 async function queryCoupons ( wix , query ) {
2076 return ( await wix. send ( buildQueryCouponsRequest (query))).coupons || [];
2077 }
2078
2079 // --- eCom Discount Rules ("Automatic Discounts" in the dashboard) ----------------------------
2080 // DOCS-VERIFIED, not yet live-verified (authored 2026-08-12 from Create Discount Rule's own
2081 // request/response schema — no `…-object` reference page exists for this entity, see
2082 // discount-rule.json's objectPageException). Distinct from the Coupons API: a discount rule
2083 // applies automatically when its trigger is met, no customer-entered code. Promote to
2084 // verified-live in discount-rule.json once a real rule has been created and re-queried.
2085 function buildCreateDiscountRuleRequest ( discountRule ) {
2086 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/discount-rules` , body: { discountRule } };
2087 }
2088 async function createDiscountRule ( wix , discountRule ) {
2089 return ( await wix. send ( buildCreateDiscountRuleRequest (discountRule))).discountRule;
2090 }
2091 function buildQueryDiscountRulesRequest ( query = { paging: { limit: 100 } }) {
2092 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/discount-rules/query` , body: { query } };
2093 }
2094 async function queryDiscountRules ( wix , query ) {
2095 return ( await wix. send ( buildQueryDiscountRulesRequest (query))).discountRules || [];
2096 }
2097 function buildDeleteDiscountRuleRequest ( id ) {
2098 return { method: 'DELETE' , url: `${ WIXAPIS }/ecom/v1/discount-rules/${ encodeURIComponent ( id ) }` };
2099 }
2100 async function deleteDiscountRule ( wix , id ) {
2101 return wix. send ( buildDeleteDiscountRuleRequest (id));
2102 }
2103
2104 // --- Tax (Tax Groups / Tax Regions / Manual Tax Mappings) -------------------
2105 // VERIFIED (2026-08-12): real calls against the live reference store. All three APIs live under
2106 // `/billing/v1/...`, NOT `/ecom/v1/...` — a naming trap, since discount-rules/coupons above are
2107 // `/ecom/v1/...`/`/stores/v2/...`. Cooperating model (dev.wix.com "About the Tax APIs"):
2108 // - Tax Group: a bucket of products with the same tax treatment. Carries NO rate itself.
2109 // Every site already HAS default groups (Products, Shipping and delivery, Services,
2110 // Cancellation fees, the reference store's own live ids as of 2026-08-12) — `listDefaultTaxGroups`
2111 // returns those; `queryTaxGroups` returns ONLY custom groups a site created, never the
2112 // defaults (the "most common mistake" per Wix's own docs). A product joins a group via
2113 // the Stores Catalog V3 product's own `taxGroupId` field (Update Product), not any call
2114 // in this file — that's a plain Stores product write, already covered by the product
2115 // writer's PATCH path.
2116 // - Tax Region: a country[/subdivision] bound to ONE tax calculator app (`appId`). NEVER
2117 // hardcode the appId — it is installation-scoped. Resolve it live via `listTaxCalculators`
2118 // first. On the reference store (2026-08-12) the two installed calculators were "Wix Manual Tax
2119 // Calculator" (appId 57d13128-4a4c-494b-80b3-a6fb2e28018d) and "Avalara Tax Calculator"
2120 // (7516f85b-0868-4c23-9fcb-cea7784243df) — pick the one whose `displayName` contains
2121 // "Manual"/doesn't contain "Avalara" for a manually-transcribed rate; never assume a fixed
2122 // appId across sites. `subdivision` is ISO 3166-2 WITHOUT the country prefix (`NY`, not
2123 // `US-NY`) and only valid for AU/BR/CA/FR/DE/IN/IT/MX/NL/PT/ES/AE/GB/US — omit it (or `*`)
2124 // for any other country, matching the same "store the ISO code, not a display name" trap
2125 // Bookings hit for `locations[].custom.address.country`.
2126 // - Manual Tax Mapping: the actual rate for ONE (taxGroupId, taxRegionId) pair, only
2127 // meaningful for the "Wix manual tax calculator" region (Avalara computes its own rates
2128 // externally — no manual mapping needed or usable there). `taxRate` is a decimal-STRING
2129 // FRACTION ("0.07" for 7%), never a number/integer/percent-string, up to 6 decimal places.
2130 // - LIVE-VERIFIED FINDING (2026-08-12, not documented explicitly): a tax group with NO
2131 // manual tax mapping for a matched region calculates to EXACTLY ZERO tax
2132 // (taxAmount/taxableAmount both "0", empty taxBreakdown[]) — confirmed via a real
2133 // `calculateTax` call against the reference store with a genuinely mapping-less group next to a 7%-
2134 // mapped control group in the same request/region. This is the correct, simpler primitive
2135 // for "this product is tax-exempt" (WooCommerce `tax_status: "none"`): create ONE tax
2136 // group for it and DO NOT create any manual tax mapping for that group in any region — no
2137 // need to enumerate every country/region the exempt product might ship to.
2138 function buildCreateTaxGroupRequest ( taxGroup ) {
2139 return { method: 'POST' , url: `${ WIXAPIS }/billing/v1/tax-groups` , body: { taxGroup } };
2140 }
2141 async function createTaxGroup ( wix , taxGroup ) {
2142 return ( await wix. send ( buildCreateTaxGroupRequest (taxGroup))).taxGroup;
2143 }
2144 function buildQueryTaxGroupsRequest ( query = { cursorPaging: { limit: 100 } }) {
2145 return { method: 'POST' , url: `${ WIXAPIS }/billing/v1/tax-groups/query` , body: { query } };
2146 }
2147 // Returns ONLY custom tax groups this site created — NEVER the built-in defaults (Products,
2148 // Shipping and delivery, ...). Call `listDefaultTaxGroups` for those. See the READ/RETURN
2149 // CONTRACT at the top of this file: ONE PAGE, unwrapped to the array.
2150 async function queryTaxGroups ( wix , query ) {
2151 return ( await wix. send ( buildQueryTaxGroupsRequest (query))).taxGroups || [];
2152 }
2153 function buildListDefaultTaxGroupsRequest () {
2154 return { method: 'GET' , url: `${ WIXAPIS }/billing/v1/tax-groups/default-tax-groups` };
2155 }
2156 async function listDefaultTaxGroups ( wix ) {
2157 return ( await wix. send ( buildListDefaultTaxGroupsRequest ())).taxGroups || [];
2158 }
2159 function buildDeleteTaxGroupRequest ( id ) {
2160 return { method: 'DELETE' , url: `${ WIXAPIS }/billing/v1/tax-groups/${ encodeURIComponent ( id ) }` };
2161 }
2162 async function deleteTaxGroup ( wix , id ) {
2163 return wix. send ( buildDeleteTaxGroupRequest (id));
2164 }
2165 function buildListTaxCalculatorsRequest () {
2166 return { method: 'GET' , url: `${ WIXAPIS }/billing/v1/list-tax-calculators` };
2167 }
2168 async function listTaxCalculators ( wix ) {
2169 return ( await wix. send ( buildListTaxCalculatorsRequest ())).taxCalculatorDetails || [];
2170 }
2171 // Convenience: resolve the manual calculator's appId live rather than hardcoding it (calculator
2172 // appIds are installation-scoped and differ per site — see the comment block above). Picks the
2173 // calculator whose displayName does NOT mention "Avalara"; throws if none/more than one match so
2174 // a codegen caller notices a genuinely ambiguous site rather than silently picking the wrong one.
2175 async function resolveManualTaxCalculatorAppId ( wix ) {
2176 const calculators = await listTaxCalculators (wix);
2177 const manual = calculators. filter (( c ) => ! /avalara/ i . test ( String (c?.displayName || '' )));
2178 if (manual. length !== 1 ) {
2179 throw new Error ( `resolveManualTaxCalculatorAppId: expected exactly 1 non-Avalara calculator, found ${ manual . length } (${ JSON . stringify ( calculators ) })` );
2180 }
2181 return manual[ 0 ].appId;
2182 }
2183 function buildCreateTaxRegionRequest ( taxRegion ) {
2184 return { method: 'POST' , url: `${ WIXAPIS }/billing/v1/tax-regions` , body: { taxRegion } };
2185 }
2186 async function createTaxRegion ( wix , taxRegion ) {
2187 return ( await wix. send ( buildCreateTaxRegionRequest (taxRegion))).taxRegion;
2188 }
2189 function buildQueryTaxRegionsRequest ( query = { cursorPaging: { limit: 100 } }) {
2190 return { method: 'POST' , url: `${ WIXAPIS }/billing/v1/tax-regions/query` , body: { query } };
2191 }
2192 // ONE PAGE, unwrapped to the taxRegions array — see the READ/RETURN CONTRACT at the top of this file.
2193 async function queryTaxRegions ( wix , query ) {
2194 return ( await wix. send ( buildQueryTaxRegionsRequest (query))).taxRegions || [];
2195 }
2196 function buildDeleteTaxRegionRequest ( id ) {
2197 return { method: 'DELETE' , url: `${ WIXAPIS }/billing/v1/tax-regions/${ encodeURIComponent ( id ) }` };
2198 }
2199 async function deleteTaxRegion ( wix , id ) {
2200 return wix. send ( buildDeleteTaxRegionRequest (id));
2201 }
2202 function buildCreateManualTaxMappingRequest ( manualTaxMapping ) {
2203 return { method: 'POST' , url: `${ WIXAPIS }/billing/v1/manual-tax-mappings` , body: { manualTaxMapping } };
2204 }
2205 async function createManualTaxMapping ( wix , manualTaxMapping ) {
2206 return ( await wix. send ( buildCreateManualTaxMappingRequest (manualTaxMapping))).manualTaxMapping;
2207 }
2208 function buildQueryManualTaxMappingsRequest ( query = { cursorPaging: { limit: 100 } }) {
2209 return { method: 'POST' , url: `${ WIXAPIS }/billing/v1/manual-tax-mappings/query` , body: { query } };
2210 }
2211 // ONE PAGE, unwrapped to the manualTaxMappings array — see the READ/RETURN CONTRACT at the top of this file.
2212 async function queryManualTaxMappings ( wix , query ) {
2213 return ( await wix. send ( buildQueryManualTaxMappingsRequest (query))).manualTaxMappings || [];
2214 }
2215 function buildDeleteManualTaxMappingRequest ( id ) {
2216 return { method: 'DELETE' , url: `${ WIXAPIS }/billing/v1/manual-tax-mappings/${ encodeURIComponent ( id ) }` };
2217 }
2218 async function deleteManualTaxMapping ( wix , id ) {
2219 return wix. send ( buildDeleteManualTaxMappingRequest (id));
2220 }
2221 // Site-level, single-resource settings (one per site, not a crosswalked entity) — the direct
2222 // counterpart of WooCommerce's `GET /wc/v3/settings/tax` `woocommerce_prices_include_tax`.
2223 // VERIFIED live 2026-08-12: the reference store's default `taxIncludedInItemPrices: false` already matches
2224 // its WooCommerce `woocommerce_prices_include_tax: "no"`, so no live update call was needed for
2225 // this project — Upsert is still implemented for a source site where the two differ.
2226 function buildGetTaxSettingsRequest () {
2227 return { method: 'GET' , url: `${ WIXAPIS }/billing/v1/tax-settings` };
2228 }
2229 async function getTaxSettings ( wix ) {
2230 return ( await wix. send ( buildGetTaxSettingsRequest ())).taxSettings;
2231 }
2232 function buildUpsertTaxSettingsRequest ( taxSettings ) {
2233 return { method: 'POST' , url: `${ WIXAPIS }/billing/v1/tax-settings` , body: { taxSettings } };
2234 }
2235 async function upsertTaxSettings ( wix , taxSettings ) {
2236 return ( await wix. send ( buildUpsertTaxSettingsRequest (taxSettings))).taxSettings;
2237 }
2238
2239 // --- Delivery Profiles / Delivery Regions / Delivery Carriers ---------------
2240 // VERIFIED (2026-08-12): real calls against the live reference store. All under `/ecom/v1/...`.
2241 // Model (dev.wix.com "Delivery Profiles"): a DeliveryProfile is a named bundle of
2242 // DeliveryRegions; every site has exactly one `default: true` profile, auto-created when Wix
2243 // Stores/Bookings/Events/Restaurants is installed (the reference store's is "General profile", pre-existing
2244 // with "Domestic"/"International" regions from that auto-creation, NOT from any WooCommerce
2245 // data — do not assume default-profile regions already reflect the source site's real zones).
2246 // A DeliveryRegion matches on `destinations[]` (country/subdivision only — no continent, no
2247 // postcode) and carries `deliveryCarriers[]`, each ONE app (`appId`) + a `backupRate` (used
2248 // whenever the carrier doesn't return its own live rate, or unconditionally when
2249 // `backupRate.active: true` — this is the mechanism for a flat/free rate with no real courier
2250 // integration). `listInstalledDeliveryCarriers` (VERIFIED live 2026-08-12 on the reference store) returned:
2251 // "Pickup", "Basic Shipping" (id 45c44b27-..., a fixed cross-site constant — see
2252 // shipping-build.js), "Calculated by USPS" (real carrier calc, no data equivalent), "Local
2253 // delivery". `addDeliveryCarrier` REQUIRES `backupRate.amount` even for a real-carrier app.
2254 // REVISION GOTCHA (hit live 2026-08-12 migrating the reference store): EVERY mutating call against a
2255 // delivery profile — addDeliveryRegion AND addDeliveryCarrier, not just the region call — bumps
2256 // `revision` and returns the new one on `deliveryProfile.revision`. A caller doing several
2257 // region/carrier writes in sequence must carry that returned revision into the NEXT
2258 // addDeliveryRegion/removeDeliveryRegion call, not re-use the value from the original
2259 // queryDeliveryProfiles — passing a stale revision 409s with INVALID_REVISION. (addDeliveryCarrier
2260 // itself does not take a revision parameter, so this only bites the next add/removeDeliveryRegion
2261 // call after one or more addDeliveryCarrier calls.)
2262 function buildCreateDeliveryProfileRequest ( deliveryProfile ) {
2263 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/delivery-profiles` , body: { deliveryProfile } };
2264 }
2265 async function createDeliveryProfile ( wix , deliveryProfile ) {
2266 return ( await wix. send ( buildCreateDeliveryProfileRequest (deliveryProfile))).deliveryProfile;
2267 }
2268 function buildGetDeliveryProfileRequest ( id ) {
2269 return { method: 'GET' , url: `${ WIXAPIS }/ecom/v1/delivery-profiles/${ encodeURIComponent ( id ) }` };
2270 }
2271 async function getDeliveryProfile ( wix , id ) {
2272 return ( await wix. send ( buildGetDeliveryProfileRequest (id))).deliveryProfile;
2273 }
2274 function buildQueryDeliveryProfilesRequest ( query = { cursorPaging: { limit: 100 } }) {
2275 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/delivery-profiles/query` , body: { query } };
2276 }
2277 // ONE PAGE, unwrapped to the deliveryProfiles array — see the READ/RETURN CONTRACT at the top of
2278 // this file. Includes the site's default profile — filter on `.default` yourself if you need
2279 // only custom ones.
2280 async function queryDeliveryProfiles ( wix , query ) {
2281 return ( await wix. send ( buildQueryDeliveryProfilesRequest (query))).deliveryProfiles || [];
2282 }
2283 // NOTE the URL shape here is a path param (.../{deliveryProfileId}/delivery-region), unlike
2284 // add-delivery-carrier/remove-delivery-carrier below which are flat URLs with body params —
2285 // a real, verified API inconsistency, not a typo. `revision` is optional (the delivery
2286 // profile's current `revision`, for optimistic-concurrency conflict detection) but recommended
2287 // when the caller already has it from a preceding query/get.
2288 function buildAddDeliveryRegionRequest ( deliveryProfileId , deliveryRegion , revision ) {
2289 return {
2290 method: 'POST' ,
2291 url: `${ WIXAPIS }/ecom/v1/delivery-profiles/${ encodeURIComponent ( deliveryProfileId ) }/delivery-region` ,
2292 body: { deliveryRegion, ... (revision !== undefined ? { revision } : {}) },
2293 };
2294 }
2295 // Returns the UPDATED delivery profile, not just the new region — find it by matching `name`
2296 // (regions carry a server-assigned `id` you don't have until this call returns). `deliveryRegion`
2297 // may include `deliveryCarriers[]` inline (per dev.wix.com's own worked example) to create the
2298 // region and its carrier(s) in one call instead of a separate addDeliveryCarrier.
2299 async function addDeliveryRegion ( wix , deliveryProfileId , deliveryRegion , revision ) {
2300 return ( await wix. send ( buildAddDeliveryRegionRequest (deliveryProfileId, deliveryRegion, revision))).deliveryProfile;
2301 }
2302 function buildRemoveDeliveryRegionRequest ( deliveryProfileId , deliveryRegionId , revision ) {
2303 const url = new URL ( `${ WIXAPIS }/ecom/v1/delivery-profiles/${ encodeURIComponent ( deliveryProfileId ) }/delivery-region/${ encodeURIComponent ( deliveryRegionId ) }` );
2304 if (revision !== undefined ) url.searchParams. set ( 'revision' , revision);
2305 return { method: 'DELETE' , url: url. toString () };
2306 }
2307 async function removeDeliveryRegion ( wix , deliveryProfileId , deliveryRegionId , revision ) {
2308 return ( await wix. send ( buildRemoveDeliveryRegionRequest (deliveryProfileId, deliveryRegionId, revision))).deliveryProfile;
2309 }
2310 function buildAddDeliveryCarrierRequest ( deliveryRegionId , deliveryCarrier ) {
2311 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/delivery-profiles/add-delivery-carrier` , body: { deliveryRegionId, deliveryCarrier } };
2312 }
2313 async function addDeliveryCarrier ( wix , deliveryRegionId , deliveryCarrier ) {
2314 return ( await wix. send ( buildAddDeliveryCarrierRequest (deliveryRegionId, deliveryCarrier))).deliveryProfile;
2315 }
2316 function buildRemoveDeliveryCarrierRequest ( deliveryRegionId , appId ) {
2317 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/delivery-profiles/remove-delivery-carrier` , body: { deliveryRegionId, appId } };
2318 }
2319 async function removeDeliveryCarrier ( wix , deliveryRegionId , appId ) {
2320 return ( await wix. send ( buildRemoveDeliveryCarrierRequest (deliveryRegionId, appId))).deliveryProfile;
2321 }
2322 function buildListInstalledDeliveryCarriersRequest () {
2323 return { method: 'GET' , url: `${ WIXAPIS }/ecom/v1/delivery-profiles/installed-carriers` };
2324 }
2325 async function listInstalledDeliveryCarriers ( wix ) {
2326 return ( await wix. send ( buildListInstalledDeliveryCarriersRequest ())).installedDeliveryCarriers || [];
2327 }
2328 // Convenience: resolve the Pickup carrier's appId live rather than hardcoding it — unlike
2329 // Basic Shipping (a doc-example-corroborated fixed constant, see shipping-build.js), Pickup has
2330 // no such corroboration, so this resolves an installation-scoped id live by matching displayName,
2331 // the same pattern as the tax domain's resolveManualTaxCalculatorAppId.
2332 async function resolvePickupAppId ( wix ) {
2333 const installed = await listInstalledDeliveryCarriers (wix);
2334 const pickup = installed. filter (( c ) => /pickup/ i . test ( String (c?.displayName || '' )));
2335 if (pickup. length !== 1 ) {
2336 throw new Error ( `resolvePickupAppId: expected exactly 1 Pickup carrier, found ${ pickup . length } (${ JSON . stringify ( installed ) })` );
2337 }
2338 return pickup[ 0 ].id;
2339 }
2340
2341 // --- Shipping Options --------------------------------------------------------
2342 // CRITICAL, LIVE-DISCOVERED 2026-08-15 (not documented on the Delivery Profile/Delivery Carrier
2343 // pages at all — found via dev.wix.com's "Fix Shipping Coverage Gaps" skill article, a totally
2344 // different doc tree than delivery-profiles/*): a deliveryCarrier's `backupRate` (what
2345 // buildDeliveryCarrierInput above builds) is NOT what makes a region show a working rate at
2346 // checkout or clears Wix's own "This region is missing rates" dashboard warning. That is driven
2347 // by a SEPARATE resource, ShippingOption (`/ecom/v1/shipping-options`), keyed by
2348 // `deliveryRegionId`, with its own `rates[]` (amount + conditions). VERIFIED on the reference store: the two
2349 // regions Wix auto-created at Stores install ("Domestic"/"International") each already had a
2350 // real ShippingOption ("Free shipping", $0); the two regions this pipeline created via
2351 // addDeliveryRegion/addDeliveryCarrier ("Europe"/"Israel") had backupRate set correctly but NO
2352 // ShippingOption at all — confirmed via listDeliveryCarriers' dashboardTables (the same view
2353 // backing the dashboard's own warning) showing zero rows for those two regions despite a
2354 // correctly-shaped, active backupRate existing on the DeliveryCarrier object. A delivery-region
2355 // migration is INCOMPLETE without a matching ShippingOption per region — backupRate alone
2356 // silently produces a checkout-blocking region despite a fully successful, correctly-shaped API
2357 // write. See delivery-profile.json's shipping-options-not-backup-rate-drive-checkout pitfall.
2358 function buildCreateShippingOptionRequest ( shippingOption ) {
2359 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/shipping-options` , body: { shippingOption } };
2360 }
2361 async function createShippingOption ( wix , shippingOption ) {
2362 return ( await wix. send ( buildCreateShippingOptionRequest (shippingOption))).shippingOption;
2363 }
2364 function buildQueryShippingOptionsRequest ( query = { cursorPaging: { limit: 100 } }) {
2365 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/shipping-options/query` , body: { query } };
2366 }
2367 // ONE PAGE, unwrapped to the shippingOptions array — see the READ/RETURN CONTRACT at the top of
2368 // this file. Filter on `.deliveryRegionId` yourself to find what's already covering a region.
2369 async function queryShippingOptions ( wix , query ) {
2370 return ( await wix. send ( buildQueryShippingOptionsRequest (query))).shippingOptions || [];
2371 }
2372
2373 // --- eCom orders -----------------------------------------------------------
2374 // WARNING — createOrder is NOT for import. POST /ecom/v1/orders is the LIVE-commerce
2375 // Create Order (ECOM-02 in the owner tracker: Not import-suited): it decrements catalog
2376 // inventory, emails the buyer a confirmation, and auto-creates a contact. Historical
2377 // orders MUST go through importOrder below. createOrder remains only for creating a
2378 // genuine live/test order on purpose.
2379 function buildCreateOrderRequest ( order , safeModeOptions ) {
2380 const prepared = applySafeModeToRequest ({ order }, safeModeOptions);
2381 return {
2382 method: 'POST' ,
2383 url: `${ WIXAPIS }/ecom/v1/orders` ,
2384 body: prepared.body,
2385 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
2386 };
2387 }
2388 async function createOrder ( wix , order , safeModeOptions ) {
2389 return ( await wix. send ( buildCreateOrderRequest (order, safeModeOptions))).order;
2390 }
2391 function buildQueryOrdersRequest ( query = { paging: { limit: 100 } }) {
2392 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/orders/query` , body: { query } };
2393 }
2394 // ONE PAGE, unwrapped to the orders array — see the READ/RETURN CONTRACT at the top of this file.
2395 async function queryOrders ( wix , query ) {
2396 return ( await wix. send ( buildQueryOrdersRequest (query))).orders || [];
2397 }
2398
2399 // VERIFIED LIVE writer — 2026-08-12 (the reference store run, 5 orders) and re-verified 2026-08-16
2400 // (applied discounts, line-item descriptionLines/catalogReference.options, merchant-note
2401 // follow-up). Endpoint: POST /ecom/v1/orders/import — the dedicated migration path (Beta,
2402 // scope SCOPE.ECOM.IMPORT-ORDERS, ECOM-01 in the owner tracker).
2403 // LIVE-FOUND TRAPS the builder does NOT yet normalize (see ecom/order.json pitfalls):
2404 // - `number` must be numeric — a prefixed source order number fails the whole call with a
2405 // bare 400 {"message":"Not a numeric value"} and no field path.
2406 // - merchantDiscount.amount must be a Price OBJECT; a bare string 400s "Expected an object".
2407 // - appliedDiscounts[] comes back REORDERED — correlate by content, never by index.
2408 // - lineItems[].id is client-settable and preserved, which is what makes the
2409 // lineItemDiscounts[].id linkage resolvable within a single payload.
2410 // Values are stored AS-IS (no total/status recalculation). No side effects: no
2411 // buyer notifications, no inventory adjustment, no contact/invoice/receipt/subscription
2412 // creation; standard order webhooks don't fire — a single `OrderImported` event is emitted
2413 // instead (that event has exactly one consumer, so imported orders stay invisible to
2414 // contacts/loyalty and other event-driven views; see ECOM-01).
2415 // Required: lineItems (1-300; each needs quantity, productName.original, itemType, price,
2416 // and catalogItemId+appId when catalogReference is present), billingInfo.contactDetails,
2417 // channelInfo (no SHOPIFY/WOOCOMMERCE enum values — use OTHER_PLATFORM), priceSummary,
2418 // status, paymentStatus (full enum, incl. PAID without a real payment).
2419 // History: purchasedDate/createdDate/number are settable on import (immutable after).
2420 // Re-runs: sending an existing imported order's `id` fully replaces it; overwriting a
2421 // non-imported order fails with CANNOT_OVERWRITE_NON_IMPORTED_ORDER. Cleanup exists via
2422 // Bulk Delete Imported Orders; live-order numbering continues via Set Order Number Counter.
2423 function buildImportOrderRequest ( order , safeModeOptions ) {
2424 const prepared = applySafeModeToRequest ({ order }, safeModeOptions);
2425 return {
2426 method: 'POST' ,
2427 url: `${ WIXAPIS }/ecom/v1/orders/import` ,
2428 body: prepared.body,
2429 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
2430 };
2431 }
2432 async function importOrder ( wix , order , safeModeOptions ) {
2433 return ( await wix. send ( buildImportOrderRequest (order, safeModeOptions))).order;
2434 }
2435
2436 // --- Order Transactions / Order Billing (VERIFIED 2026-08-12) ---------------------------------
2437 // Historical refunds need TWO calls, neither of which moves real money — both are pure
2438 // record-keeping (per dev.wix.com: "This does NOT perform the actual charging"/"does NOT call
2439 // payment providers"). `Import Order` does not create any payment transaction record on its own
2440 // (verified live: a freshly-imported order's `/ecom/v1/payments/orders/{id}` reads back
2441 // `payments: []`), so a refund has nothing to reference without step 1 first.
2442 //
2443 // 1. Add Payments — POST /ecom/v1/payments/orders/{orderId}/add-payment. Records that the order
2444 // was paid (regularPaymentDetails.offlinePayment: true, status: APPROVED) without charging
2445 // anything. Returns the new payment's `id`, needed as `paymentId` in step 2.
2446 // 2. Refund Payments — POST /ecom/v1/order-billing/refund-payments. `paymentRefunds[].
2447 // externalRefund: true` is the load-bearing field: "Marks the payment as refunded without
2448 // calling the provider's API" — this is what makes it importSafe, unlike the previously
2449 // assumed path through the live-commerce `createOrder`/checkout flow. `sideEffects` is
2450 // intentionally omitted (no inventory restock, no customer email) for historical data.
2451 function buildListOrderTransactionsRequest ( orderId ) {
2452 return { method: 'GET' , url: `${ WIXAPIS }/ecom/v1/payments/orders/${ encodeURIComponent ( orderId ) }` };
2453 }
2454 async function listOrderTransactions ( wix , orderId ) {
2455 return ( await wix. send ( buildListOrderTransactionsRequest (orderId))).orderTransactions;
2456 }
2457 function buildAddOrderPaymentRequest ({ orderId , amount , offlinePayment = true , status = 'APPROVED' }) {
2458 return {
2459 method: 'POST' ,
2460 url: `${ WIXAPIS }/ecom/v1/payments/orders/${ encodeURIComponent ( orderId ) }/add-payment` ,
2461 body: { payments: [{ regularPaymentDetails: { offlinePayment, status }, amount: { amount: String (amount) } }] },
2462 };
2463 }
2464 async function addOrderPayment ( wix , payload ) {
2465 const response = await wix. send ( buildAddOrderPaymentRequest (payload));
2466 const ids = response.paymentsIds || [];
2467 return { paymentId: ids[ 0 ], orderTransactions: response.orderTransactions };
2468 }
2469 function buildRefundOrderPaymentRequest ({ orderId , paymentId , amount , reason }) {
2470 return {
2471 method: 'POST' ,
2472 url: `${ WIXAPIS }/ecom/v1/order-billing/refund-payments` ,
2473 body: {
2474 orderId,
2475 paymentRefunds: [{ paymentId, amount: { amount: String (amount) }, externalRefund: true }],
2476 ... (reason ? { customerReason: String (reason). slice ( 0 , 200 ) } : {}),
2477 },
2478 };
2479 }
2480 async function refundOrderPayment ( wix , payload ) {
2481 return ( await wix. send ( buildRefundOrderPaymentRequest (payload))).refund;
2482 }
2483 // Convenience wrapper for the historical-refund path: ensures a payment record exists (adding one
2484 // sized to the order total if the order has none yet — Import Order never creates one), then
2485 // refunds the requested amount against it, externally.
2486 async function ensureOrderPaymentAndRefund ( wix , { orderId , orderTotal , refundAmount , reason }) {
2487 const existing = await listOrderTransactions (wix, orderId);
2488 let paymentId = (existing.payments || []). find (( p ) => ! p.refundDisabled)?.id;
2489 if ( ! paymentId) {
2490 const added = await addOrderPayment (wix, { orderId, amount: orderTotal });
2491 paymentId = added.paymentId;
2492 }
2493 return refundOrderPayment (wix, { orderId, paymentId, amount: refundAmount, reason });
2494 }
2495
2496 // --- Stores inventory (Catalog V3 Inventory Items API) ----------------------
2497 // UNVERIFIED: POST /stores/v3/inventory-items creates one inventory item per variant.
2498 // Inventory items are NOT created automatically when a product is created — a separate
2499 // call is required for each variant (per productId + variantId combination).
2500 // To mark a variant as in stock without quantity tracking: set `inStock: true`.
2501 // Omit `locationId` to target the default location (the one Wix's standard checkout
2502 // deducts from). The combination of variantId + locationId must be unique.
2503 //
2504 // How to determine variantIds: `createStoresProduct` returns the full product object;
2505 // the variant IDs are at `product.variantsInfo.variants[].id`.
2506 function buildCreateInventoryItemRequest ({ variantId , productId , locationId , inStock , quantity , trackQuantity , preorderInfo }) {
2507 const item = {
2508 variantId,
2509 productId,
2510 ... (locationId ? { locationId } : {}),
2511 ... ( typeof inStock === 'boolean' ? { inStock } : {}),
2512 ... (quantity != null ? { quantity } : {}),
2513 ... ( typeof trackQuantity === 'boolean' ? { trackQuantity } : {}),
2514 ... (preorderInfo ? { preorderInfo } : {}),
2515 };
2516 return { method: 'POST' , url: `${ WIXAPIS }/stores/v3/inventory-items` , body: { inventoryItem: item } };
2517 }
2518 async function createInventoryItem ( wix , payload ) {
2519 return ( await wix. send ( buildCreateInventoryItemRequest (payload))).inventoryItem;
2520 }
2521 // Convenience: mark all variants of a product as in stock (untracked mode) at the
2522 // default location. Pass the product object returned by `createStoresProduct`.
2523 async function setProductVariantsInStock ( wix , { productId , variantIds , locationId } = {}) {
2524 const results = [];
2525 for ( const variantId of (variantIds || [])) {
2526 results. push ( await createInventoryItem (wix, { variantId, productId, inStock: true , locationId }));
2527 }
2528 return results;
2529 }
2530
2531 // --- members ---------------------------------------------------------------
2532 // VERIFIED: GET /members/v1/members (reconcile), POST /members/v1/members (create).
2533 // Dedup by loginEmail — gated PII; null email cannot dedup/create (use a fallback).
2534 // DOCUMENTED: no bulk create; >=1s spacing between Create Member calls is the
2535 // documented rate-limit floor — space sequential creates and resume via crosswalk.
2536 // DOCUMENTED: create sends no email and does not fire the signup automations
2537 // trigger — member import is silent by default. Passwords are NEVER imported
2538 // (project decision 2026-08-03). Activation (decided): passwordless members
2539 // complete the standard forgot-password flow (confirmed 2026-08-03); delivery is
2540 // a post-import label-wave automation (owner-created, label-added trigger,
2541 // branded email pointing at Log in -> Forgot password; importer labels contacts
2542 // in API batches), enabled only after the import window. There is deliberately
2543 // no send-set-password-email writer here: its link dies in 3h and mass-sending
2544 // it is the exact notification-blast this lib exists to avoid.
2545 // VERIFIED-TRAP (2026-07-19): the default (PUBLIC) fieldset OMITS loginEmail, which
2546 // silently breaks dedupe-by-loginEmail; request fieldsets=FULL so the field is present.
2547 // VERIFIED (2026-08-02, single-site observation): the member list can already contain
2548 // AUTO-CREATED user-members for the site owner / contributing Wix users (status
2549 // APPROVED) even on an API-provisioned site nobody ever visited — seen on our test
2550 // site. Never dedupe or reconcile these against source-site
2551 // members. The owner's user-member is a valid blog author memberId — attribute-to-owner
2552 // blog imports need no member provisioning. Resolve it from THIS list by loginEmail:
2553 // the observed id equality (member id == account GUID) is n=1 on a solo account and
2554 // undocumented — never construct a memberId from the account/user id.
2555 async function listMembers ( wix , { limit = 50 } = {}) {
2556 return wix. send ({ method: 'GET' , url: `${ WIXAPIS }/members/v1/members?fieldsets=FULL&paging.limit=${ limit }` });
2557 }
2558 function buildCreateMemberRequest ({ email , name , slug }, safeModeOptions ) {
2559 if ( ! email) return { skipped: true , reason: 'no email — gated PII; authenticated source re-run required' };
2560 const prepared = applySafeModeToRequest ({ member: { loginEmail: email, contact: { firstName: name }, profile: { nickname: name, slug } } }, safeModeOptions);
2561 return {
2562 method: 'POST' ,
2563 url: `${ WIXAPIS }/members/v1/members` ,
2564 body: prepared.body,
2565 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
2566 };
2567 }
2568 async function createMember ( wix , payload , safeModeOptions ) {
2569 const request = buildCreateMemberRequest (payload, safeModeOptions);
2570 if (request.skipped) return request;
2571 return ( await wix. send (request)).member;
2572 }
2573
2574 // --- site notifications mute (Notification Preferences V1) ------------------
2575 // VERIFIED (2026-08-04, full cycle live on a test target): mute → state read →
2576 // idempotent re-mute → unmute → state restored, all HTTP 200. All three calls
2577 // return { siteMuteState: { muted, reason?, mutedBy: { wixUserId } } } — the
2578 // executors unwrap to `siteMuteState`.
2579 // Scope (proto doc comment, confirmed by Ping): mutes ALL notifications of the site
2580 // in context, for all recipients and all channels — sendability is denied regardless
2581 // of recipient-level preferences.
2582 // Spec 0012 hard invariant: when mute is in effect (always for new sites; explicit
2583 // opt-in for existing), a failed mute call means the run NEVER proceeds to import
2584 // writes — halt, no degraded mode.
2585 // AUTH TRAP (verified 2026-08-04): the permission grant covers USER tokens only.
2586 // The CLI-minted OauthNG site token (WIX_AUTH_TOKEN from config/wix.env) works; an
2587 // account API key gets a uniform empty-body 403 on all three endpoints.
2588 // IDEMPOTENCY TRAP (verified 2026-08-04): re-muting an already-muted site succeeds
2589 // but OVERWRITES `reason` (last caller wins) — the import preflight's re-call must
2590 // pass the same project-identifying reason as setup, or the audit trail degrades.
2591 // `unmuteSiteNotifications` is NEVER called by the flow itself — explicit owner
2592 // request only; after an on-request unmute, confirm with
2593 // getSiteMuteState (muted: false).
2594 const SITE_MUTE_REASON_MAX = 500 ;
2595 function buildMuteSiteNotificationsRequest ({ reason } = {}) {
2596 const body = reason ? { reason: String (reason). slice ( 0 , SITE_MUTE_REASON_MAX ) } : {};
2597 return { method: 'POST' , url: `${ WIXAPIS }/notification-preferences/v1/site-mute/mute` , body };
2598 }
2599 async function muteSiteNotifications ( wix , payload ) {
2600 return ( await wix. send ( buildMuteSiteNotificationsRequest (payload))).siteMuteState;
2601 }
2602 function buildUnmuteSiteNotificationsRequest () {
2603 return { method: 'POST' , url: `${ WIXAPIS }/notification-preferences/v1/site-mute/unmute` , body: {} };
2604 }
2605 async function unmuteSiteNotifications ( wix ) {
2606 return ( await wix. send ( buildUnmuteSiteNotificationsRequest ())).siteMuteState;
2607 }
2608 function buildGetSiteMuteStateRequest () {
2609 return { method: 'GET' , url: `${ WIXAPIS }/notification-preferences/v1/site-mute` };
2610 }
2611 async function getSiteMuteState ( wix ) {
2612 return ( await wix. send ( buildGetSiteMuteStateRequest ())).siteMuteState;
2613 }
2614
2615 // --- Bookings (VERIFIED 2026-08-12) -----------------------------------------------------
2616 // Wix Bookings is NOT automatically present on a fresh headless site, and the absence is easy
2617 // to miss: `/bookings/v2/services` and `/bookings/v2/resources/query` both answer with ordinary
2618 // 200s/validation errors (never an "app not installed" error) even when the Bookings app
2619 // instance does not exist in `GET /apps-installer-service/v1/app-instances` — so a plausible
2620 // response from either endpoint is NOT proof the app is installed. Confirmed live on this site:
2621 // Create Service failed with `form Form with id 00000000-0000-0000-0000-000000000000 doesn't
2622 // exist` (Clone Form on that same id also 404s `FORM_NOT_FOUND`) until the app instance was
2623 // installed via installWixApp below; installing it auto-provisions BOTH the default booking
2624 // form at that same all-zero id AND one default "Business Owner" staff resource, so no
2625 // form-cloning step is needed in the normal path (buildCloneBookingFormRequest/cloneBookingForm
2626 // are kept below only for the custom-booking-form scenario in the Wix Forms Integration docs,
2627 // not part of ensureBookingsProvisioned's default flow).
2628 const WIX_BOOKINGS_APP_DEF_ID = '13d21c63-b5ec-5912-8397-c3a5ddb27a97' ;
2629 const DEFAULT_BOOKING_FORM_ID = '00000000-0000-0000-0000-000000000000' ;
2630
2631 // VERIFIED (2026-08-12): body shape matches rp-execute-setup's already-verified Install App
2632 // contract (SKILL.md "Installing / enabling Wix apps IS automatable") — all four top-level
2633 // fields are required, confirmed by live 400s on other apps. Idempotent in practice: installing
2634 // an already-installed app instance does not error or duplicate the instance.
2635 function buildInstallWixAppRequest ({ appDefId , siteId }) {
2636 if ( ! appDefId) throw new Error ( 'buildInstallWixAppRequest: appDefId is required' );
2637 return {
2638 method: 'POST' ,
2639 url: `${ WIXAPIS }/apps-installer-service/v1/app-instance/install` ,
2640 body: {
2641 appInstance: { appDefId, enabled: true },
2642 tenant: { tenantType: 'SITE' , id: siteId },
2643 installType: 'INSTALL_TYPE_SITE' ,
2644 appsInstallOptions: {},
2645 },
2646 };
2647 }
2648 async function installWixApp ( wix , { appDefId , siteId }) {
2649 return wix. send ( buildInstallWixAppRequest ({ appDefId, siteId }));
2650 }
2651 function buildGetInstalledWixAppsRequest () {
2652 return { method: 'GET' , url: `${ WIXAPIS }/apps-installer-service/v1/app-instances` };
2653 }
2654 // ONE PAGE, unwrapped to the appInstances array — see the READ/RETURN CONTRACT note at the top
2655 // of this file. No cursor has been observed on this endpoint across any site seen so far, but
2656 // treat the unwrap the same cautious way as the other query* helpers here.
2657 async function getInstalledWixApps ( wix ) {
2658 return ( await wix. send ( buildGetInstalledWixAppsRequest ())).appInstances || [];
2659 }
2660 async function isWixAppInstalled ( wix , appDefId ) {
2661 const apps = await getInstalledWixApps (wix);
2662 return apps. some (( a ) => a && a.appDefId === appDefId);
2663 }
2664
2665 // VERIFIED (2026-08-12): POST /form-schema-service/v4/forms/{formId}/clone with an EMPTY body
2666 // clones the named form and inherits its namespace. Creating a form directly in the
2667 // `wix.bookings.v2.bookings` namespace via the generic Create Form call 400s
2668 // `UNSUPPORTED_FORM_NAMESPACE` even with the Bookings app installed — namespace-owned forms
2669 // must be cloned from the app's own default/existing form, never authored fresh. Not needed for
2670 // a stock booking form (see ensureBookingsProvisioned); use this only to build a CUSTOM form per
2671 // the Wix Forms Integration flow (clone, then edit fields, then pass the new id as
2672 // `service.form.id` on create).
2673 function buildCloneBookingFormRequest ( sourceFormId = DEFAULT_BOOKING_FORM_ID ) {
2674 return {
2675 method: 'POST' ,
2676 url: `${ WIXAPIS }/form-schema-service/v4/forms/${ sourceFormId }/clone` ,
2677 body: {},
2678 };
2679 }
2680 async function cloneBookingForm ( wix , sourceFormId ) {
2681 return ( await wix. send ( buildCloneBookingFormRequest (sourceFormId))).form;
2682 }
2683
2684 // VERIFIED (2026-08-12): POST /bookings/v2/resources/query. Installing Bookings auto-provisions
2685 // one default staff resource named "Business Owner" — its id is what a CLASS/COURSE session's
2686 // `resources[]` must reference (see createCalendarEvent below); there is no way to create a
2687 // session with zero resources.
2688 function buildQueryBookingsResourcesRequest ( query = { paging: { limit: 100 } }) {
2689 return { method: 'POST' , url: `${ WIXAPIS }/bookings/v2/resources/query` , body: { query } };
2690 }
2691 // ONE PAGE, unwrapped to the resources array — see the READ/RETURN CONTRACT note at the top of
2692 // this file.
2693 async function queryBookingsResources ( wix , query ) {
2694 return ( await wix. send ( buildQueryBookingsResourcesRequest (query))).resources || [];
2695 }
2696
2697 // VERIFIED (2026-08-12): POST /bookings/v2/services. Real shape traps found bisecting on a live
2698 // site:
2699 // 1. `payment.options` has no default — omitting it 400s "It is mandatory to specify either
2700 // payment.options.online or payment.options.inPerson as true", even for a NO_FEE service.
2701 // 2. `service.form` defaults to the all-zero form id when omitted entirely, and THAT default
2702 // 400s "doesn't exist" on a site where Bookings was never installed — see the app-install
2703 // note above; once installed, omitting `form` resolves to the real auto-provisioned default
2704 // and needs no explicit id.
2705 // 3. `locations[].type: 'CUSTOM'` accepts a free-text `custom.address.formattedAddress` and
2706 // `city`; `country` validates as an ISO-3166-1 alpha-2 code (`IL`, not `Israel`) — an
2707 // unmapped/invalid code is rejected, so callers must convert or omit it, never pass the
2708 // source's country name through unchecked.
2709 // 4. `defaultCapacity` must be `1` for `type: 'APPOINTMENT'` and `> 1` for `CLASS`/`COURSE`.
2710 function buildCreateBookingsServiceRequest ( service , safeModeOptions ) {
2711 const prepared = applySafeModeToRequest ({ service }, safeModeOptions);
2712 return {
2713 method: 'POST' ,
2714 url: `${ WIXAPIS }/bookings/v2/services` ,
2715 body: prepared.body,
2716 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
2717 };
2718 }
2719 async function createBookingsService ( wix , service , safeModeOptions ) {
2720 return ( await wix. send ( buildCreateBookingsServiceRequest (service, safeModeOptions))).service;
2721 }
2722 function buildQueryBookingsServicesRequest ( query = { paging: { limit: 100 } }) {
2723 return { method: 'POST' , url: `${ WIXAPIS }/bookings/v2/services/query` , body: { query } };
2724 }
2725 // ONE PAGE, unwrapped to the services array — see the READ/RETURN CONTRACT note at the top of
2726 // this file.
2727 async function queryBookingsServices ( wix , query ) {
2728 return ( await wix. send ( buildQueryBookingsServicesRequest (query))).services || [];
2729 }
2730 function buildDeleteBookingsServiceRequest ( id ) {
2731 return { method: 'DELETE' , url: `${ WIXAPIS }/bookings/v2/services/${ encodeURIComponent ( id ) }` };
2732 }
2733 async function deleteBookingsService ( wix , id ) {
2734 return wix. send ( buildDeleteBookingsServiceRequest (id));
2735 }
2736
2737 // VERIFIED (2026-08-12): a Bookings Service's one-time (or recurring) date/time is NOT written
2738 // through a dedicated "Bookings session" endpoint at all — it is an ordinary Calendar V3 event
2739 // (`POST /calendar/v3/events`) on the schedule the service auto-created
2740 // (`service.schedule.id` from the Create Service response). Real shape traps:
2741 // 1. `start.localDate` / `end.localDate` are LOCAL date-time strings with NO offset/zone suffix
2742 // (`2026-09-01T14:00:00`), paired with a separate `timeZone` (IANA tz id) — the same
2743 // `{seconds,nanos}`/offset-string trap documented for Wix Events applies here too.
2744 // 2. `event.type` must equal the owning service's `type` (e.g. `CLASS`) or the create 400s
2745 // "type must match the service type".
2746 // 3. A `CLASS`/`COURSE` event additionally 400s "resources must have at least 1 resource for
2747 // class events" unless `resources: [{ id, permissionRole: 'WRITER' }]` names a real Bookings
2748 // resource (see queryBookingsResources) — `COMMENTER` is the only other valid
2749 // `permissionRole`, but it is read-mostly and not appropriate for the owning write.
2750 // `title`, `totalCapacity`, and `location` are inherited from the service/schedule when omitted
2751 // (see the response's `inheritedFields`) — omit them so the session mirrors the service by
2752 // construction instead of risking drift between the two.
2753 function buildCreateCalendarEventRequest ( event ) {
2754 return { method: 'POST' , url: `${ WIXAPIS }/calendar/v3/events` , body: { event } };
2755 }
2756 async function createCalendarEvent ( wix , event ) {
2757 return ( await wix. send ( buildCreateCalendarEventRequest (event))).event;
2758 }
2759 function buildDeleteCalendarEventRequest ( id ) {
2760 return { method: 'DELETE' , url: `${ WIXAPIS }/calendar/v3/events/${ encodeURIComponent ( id ) }` };
2761 }
2762 async function deleteCalendarEvent ( wix , id ) {
2763 return wix. send ( buildDeleteCalendarEventRequest (id));
2764 }
2765
2766 // Composite provisioning helper: idempotently ensures Bookings is installed and returns the
2767 // default staff resource id every CLASS/COURSE session needs. Cheap enough to call once per run
2768 // (an install-status GET, an install POST only when missing, and a resources GET), but callers
2769 // should still call it once per run rather than once per record.
2770 async function ensureBookingsProvisioned ( wix , { siteId } = {}) {
2771 const installed = await isWixAppInstalled (wix, WIX_BOOKINGS_APP_DEF_ID );
2772 if ( ! installed) {
2773 await installWixApp (wix, { appDefId: WIX_BOOKINGS_APP_DEF_ID , siteId });
2774 }
2775 // The default "Business Owner" resource is auto-provisioned as a side effect of the install
2776 // above, not synchronously guaranteed by the install response — read-after-write race, same
2777 // shape as createCoupon's query-back retry elsewhere in this file. Only retry right after a
2778 // fresh install; an already-installed site's resources are stable and querying once is enough.
2779 let resources = await queryBookingsResources (wix);
2780 if ( ! resources. length && ! installed) {
2781 for ( let attempt = 0 ; attempt < 4 && ! resources. length ; attempt += 1 ) {
2782 await new Promise (( resolve ) => setTimeout (resolve, 750 ));
2783 resources = await queryBookingsResources (wix);
2784 }
2785 }
2786 const defaultResource = resources[ 0 ];
2787 return {
2788 alreadyInstalled: installed,
2789 defaultResourceId: defaultResource && defaultResource.id,
2790 formId: DEFAULT_BOOKING_FORM_ID ,
2791 };
2792 }
2793
2794 module . exports = {
2795 WIXAPIS,
2796 RICOS_PLUGINS,
2797 RICOS_HTML_CAP,
2798 DEFAULT_SAFE_MODE_PHONE_NUMBER,
2799 SafeModeBlockedError,
2800 createSafeModeConfig,
2801 createDryRunConfig,
2802 normalizeDryRunValue,
2803 createWixSetupExecutor,
2804 mockEmailForEntity,
2805 sanitizeContactFieldsForSafeMode,
2806 sanitizeWixRequestBody,
2807 createWixClient,
2808 buildDirectRestRequest,
2809 sendDirectRest,
2810 notifyMissingWriter,
2811 buildConvertToRicosRequest,
2812 splitHtmlIntoChunks,
2813 convertHtmlToRichContent,
2814 rewriteInlineMedia,
2815 buildImportMediaRequest,
2816 importMedia,
2817 waitUntilFileReady,
2818 buildCreateCategoryRequest,
2819 createBlogCategory,
2820 buildCreateTagRequest,
2821 createBlogTag,
2822 listBlogTags,
2823 buildCreateDraftPostRequest,
2824 createDraftPost,
2825 publishDraftPost,
2826 buildDeleteDraftPostRequest,
2827 deleteDraftPost,
2828 BLOG_BULK_CREATE_MAX,
2829 buildBulkCreateDraftPostsRequest,
2830 bulkCreateDraftPosts,
2831 buildInsertItemRequest,
2832 insertDataItem,
2833 queryAllDataItems,
2834 WIX_STORES_APP_ID,
2835 STORES_TREE_REFERENCE,
2836 STORES_SUBSCRIPTION_CONTRACT,
2837 STORES_SUBSCRIPTION_DESCRIPTION_MAX,
2838 STORES_SUBSCRIPTION_FREQUENCIES,
2839 normalizeStoresProductV3,
2840 normalizeStoresProductV3ForCreate,
2841 normalizeStoresProductMediaItems,
2842 normalizeStoresProductSubscriptions,
2843 clampStoresSubscriptionDescription,
2844 validateStoresProductSubscriptionDetails,
2845 buildStoresProductMedia,
2846 buildCreateStoresProductRequest,
2847 createStoresProduct,
2848 BULK_PRODUCT_LIMITS,
2849 storesProductBulkCost,
2850 buildBulkCreateStoresProductsRequest,
2851 bulkCreateStoresProductsWithInventory,
2852 buildQueryStoresProductsRequest,
2853 queryStoresProducts,
2854 queryAllStoresProducts,
2855 buildPatchStoresProductMediaRequest,
2856 patchStoresProductMedia,
2857 buildPatchStoresProductTagsRequest,
2858 patchStoresProductTags,
2859 buildGetStoresProductRequest,
2860 getStoresProduct,
2861 buildGetStoresProductBySlugRequest,
2862 getStoresProductBySlug,
2863 buildDeleteStoresProductRequest,
2864 deleteStoresProduct,
2865 buildPatchStoresProductRequest,
2866 patchStoresProduct,
2867 buildQueryStoresCategoriesRequest,
2868 queryStoresCategories,
2869 queryAllStoresCategories,
2870 buildCreateStoresCategoryRequest,
2871 createStoresCategory,
2872 buildBulkAddItemToCategoriesRequest,
2873 bulkAddItemToCategories,
2874 buildCreateInventoryItemRequest,
2875 createInventoryItem,
2876 setProductVariantsInStock,
2877 normalizeV5Contact,
2878 contactInfoToV5Contact,
2879 buildCreateContactRequest,
2880 createContact,
2881 CONTACTS_BULK_UPSERT_MAX,
2882 buildBulkUpsertContactsRequest,
2883 bulkUpsertContacts,
2884 buildQueryContactsRequest,
2885 queryContacts,
2886 buildGetContactRequest,
2887 getContact,
2888 buildUpdateContactRequest,
2889 updateContact,
2890 buildFindOrCreateContactExtendedFieldRequest,
2891 findOrCreateContactExtendedField,
2892 buildCreateCouponRequest,
2893 createCoupon,
2894 buildQueryCouponsRequest,
2895 queryCoupons,
2896 buildCreateDiscountRuleRequest,
2897 createDiscountRule,
2898 buildQueryDiscountRulesRequest,
2899 queryDiscountRules,
2900 buildDeleteDiscountRuleRequest,
2901 deleteDiscountRule,
2902 buildCreateTaxGroupRequest,
2903 createTaxGroup,
2904 buildQueryTaxGroupsRequest,
2905 queryTaxGroups,
2906 buildListDefaultTaxGroupsRequest,
2907 listDefaultTaxGroups,
2908 buildDeleteTaxGroupRequest,
2909 deleteTaxGroup,
2910 buildListTaxCalculatorsRequest,
2911 listTaxCalculators,
2912 resolveManualTaxCalculatorAppId,
2913 buildCreateTaxRegionRequest,
2914 createTaxRegion,
2915 buildQueryTaxRegionsRequest,
2916 queryTaxRegions,
2917 buildDeleteTaxRegionRequest,
2918 deleteTaxRegion,
2919 buildCreateManualTaxMappingRequest,
2920 createManualTaxMapping,
2921 buildQueryManualTaxMappingsRequest,
2922 queryManualTaxMappings,
2923 buildDeleteManualTaxMappingRequest,
2924 deleteManualTaxMapping,
2925 buildGetTaxSettingsRequest,
2926 getTaxSettings,
2927 buildUpsertTaxSettingsRequest,
2928 upsertTaxSettings,
2929 buildCreateDeliveryProfileRequest,
2930 createDeliveryProfile,
2931 buildGetDeliveryProfileRequest,
2932 getDeliveryProfile,
2933 buildQueryDeliveryProfilesRequest,
2934 queryDeliveryProfiles,
2935 buildAddDeliveryRegionRequest,
2936 addDeliveryRegion,
2937 buildRemoveDeliveryRegionRequest,
2938 removeDeliveryRegion,
2939 buildAddDeliveryCarrierRequest,
2940 addDeliveryCarrier,
2941 buildRemoveDeliveryCarrierRequest,
2942 removeDeliveryCarrier,
2943 buildListInstalledDeliveryCarriersRequest,
2944 listInstalledDeliveryCarriers,
2945 resolvePickupAppId,
2946 buildCreateShippingOptionRequest,
2947 createShippingOption,
2948 buildQueryShippingOptionsRequest,
2949 queryShippingOptions,
2950 buildCreateOrderRequest,
2951 createOrder,
2952 buildImportOrderRequest,
2953 importOrder,
2954 buildQueryOrdersRequest,
2955 queryOrders,
2956 buildListOrderTransactionsRequest,
2957 listOrderTransactions,
2958 buildAddOrderPaymentRequest,
2959 addOrderPayment,
2960 buildRefundOrderPaymentRequest,
2961 refundOrderPayment,
2962 ensureOrderPaymentAndRefund,
2963 listMembers,
2964 buildCreateMemberRequest,
2965 createMember,
2966 SITE_MUTE_REASON_MAX,
2967 buildMuteSiteNotificationsRequest,
2968 muteSiteNotifications,
2969 buildUnmuteSiteNotificationsRequest,
2970 unmuteSiteNotifications,
2971 buildGetSiteMuteStateRequest,
2972 getSiteMuteState,
2973 WIX_BOOKINGS_APP_DEF_ID,
2974 DEFAULT_BOOKING_FORM_ID,
2975 buildInstallWixAppRequest,
2976 installWixApp,
2977 buildGetInstalledWixAppsRequest,
2978 getInstalledWixApps,
2979 isWixAppInstalled,
2980 buildCloneBookingFormRequest,
2981 cloneBookingForm,
2982 buildQueryBookingsResourcesRequest,
2983 queryBookingsResources,
2984 buildCreateBookingsServiceRequest,
2985 createBookingsService,
2986 buildQueryBookingsServicesRequest,
2987 queryBookingsServices,
2988 buildDeleteBookingsServiceRequest,
2989 deleteBookingsService,
2990 buildCreateCalendarEventRequest,
2991 createCalendarEvent,
2992 buildDeleteCalendarEventRequest,
2993 deleteCalendarEvent,
2994 ensureBookingsProvisioned,
2995 };