1'use strict';23// Coupons: one discovery surface, redeemability classification, and a4// mapping that reports rather than approximates.5//6// The traps this module exists for are about COUNTING, not writing:7// * `wc/v3/coupons` returns published coupons; the `posts` table also holds drafts. A run that8// takes its denominator from the database and writes from REST books a permanent shortfall9// (the posts count includes drafts and auto-drafts). Here the REST set is the discovered set, and the database10// count is a reconciliation check whose difference is reported as deliberately excluded.11// * `usage_limit: 0` (or empty) means UNLIMITED in WooCommerce. Read as a real limit it12// classifies live coupons as exhausted.13// * "imported N coupons" can be a claim of substance the run does not have: a store may hold mostly
177 findings.push({ code: 'coupon-database-count-unavailable', note: 'reconciliation against the posts table was not performed; the REST sweep stands alone' });
189 // ONE surface owns the numerator classes. `mappings` is the per-coupon mapCouponToWix result
190 // (same order as restCoupons); when it is supplied the unmappable counts come from it, because
191 // the mapper refuses more than the type table does (a zero amount, a percentage over 100, a
192 // scope Wix cannot hold). Deriving them from discount_type alone let a mapper refusal and a
193 // type-table count cancel each other into a balanced, wrong report (review).
194 if (Array.isArray(mappings)) {
195 if (mappings.length !== restCoupons.length) throw new Error(`mappings (${mappings.length}) must align one-to-one with restCoupons (${restCoupons.length})`);
196 counts['coupon-type-unmappable'] = mappings.filter((m) => m && m.outcome === OUTCOMES.TYPE_UNMAPPABLE).length;
197 counts['coupon-scope-unmappable'] = mappings.filter((m) => m && m.outcome === OUTCOMES.SCOPE_UNMAPPABLE).length;
199 if (unresolvedByMapper !== counts['coupons-usage-limit-unresolved']) throw new Error(`the mapper refused ${unresolvedByMapper} coupons as usage-unresolved but the classification counts ${counts['coupons-usage-limit-unresolved']}; the mappings do not describe these coupons`);
200 // Plugin rules. Held coupons ARE written (switched off), so this is a sub-count of
201 // the written coupons and never a term of the accounting identity.
216 findings.push({ code: 'coupon-plugin-start-date-timezone-assumed', coupons: zoneAssumed.length, couponCodes: zoneAssumed, note: 'plugin start dates read as UTC midnight because the shop\'s UTC offset was not supplied; pass utcOffsetMinutes to mapCouponToWix' });
217 }
218 for (const row of [...unrecognized.values()].sort((a, b) => b.coupons - a.coupons || a.metaKey.localeCompare(b.metaKey))) {
219 findings.push({ code: 'coupon-meta-unrecognized', ...row, note: 'coupon meta no plugin profile names; the coupon was mapped from its core fields. If a plugin enforces this key as a rule, add it to that plugin profile\'s couponRules[]' });
220 }
221 } else {
222 counts['coupon-type-unmappable'] = Object.values(unmappableTypes).reduce((a, b) => a + b, 0);
223 counts['coupon-scope-unmappable'] = 0;
224 findings.push({ code: 'coupon-unmappable-counts-preliminary', note: 'derived from discount_type only; pass the per-coupon mappings for the report' });
225 }
226 if (Array.isArray(writeOutcomes)) Object.assign(counts, tallyCouponWriteOutcomes(writeOutcomes));
227 return {
228 counts,
229 byType,
230 unmappableTypes,
231 notPublished: notPublished || {},
232 findings,
233 // Honest count language: never the bare "imported N coupons".
234 summary: `${discovered} published coupon codes discovered (${counts['coupons-redeemable']} redeemable, ${counts['coupons-expired']} expired, ${counts['coupons-exhausted']} fully redeemed${counts['coupons-usage-limit-unresolved'] > 0 ? `, ${counts['coupons-usage-limit-unresolved']} with unreadable usage fields` : ''}, ${counts['coupons-no-expiry']} with no expiry)`,
235 };
236}
237
238function money(value) {
239 const n = Number(value);
240 return Number.isFinite(n) && n > 0 ? n : null;
321 if (utcOffsetMinutes !== null && !validOffset(utcOffsetMinutes)) throw new Error(`utcOffsetMinutes must be an integer between -840 and 840 (the shop's offset), got ${utcOffsetMinutes}`);
404 note: 'the target holds two decimal places; report this coupon rather than rounding it, which would put a different amount on the target while every check reported a match',
405 },
406 };
407 }
408 }
409
410 const spec = {
411 name: code,
412 code,
413 // An exhausted single-use code imported ACTIVE would become redeemable again on Wix, because
414 // Wix starts its own usage count at zero. Imported inactive, it is a record of the promotion
415 // and not a second giveaway. (The contract says import and classify; what
416 // `active` should be is this module's recorded assumption.)
492 // WooCommerce takes the amount off EVERY qualifying unit in the cart; Wix's moneyOffAmount
493 // takes it off the order once (or off the one lowest-priced item). Neither shape is the
494 // promotion the merchant published, and an active coupon with the wrong value is not made
495 // safe by a finding beside it (review: it was written storewide, one-per-order).
496 return { outcome: OUTCOMES.TYPE_UNMAPPABLE, specification: null, classification, findings, reason: { discountType: type, scope: 'unrestricted', note: 'a per-unit fixed amount on every product has no Wix coupon shape; moneyOffAmount discounts the order once' } };
497 }
498 if (products.length === 0 && categories.length === 0) {
499 // VERIFIED LIVE 2026-09-05: a coupon sent with BOTH `scope` and `minimumSubtotal` reads back with the
500 // scope and no minimum -- the minimum is silently dropped. Storewide is implied when no
501 // scope is sent, and `minimumSubtotal` alone satisfies the scope-or-minimum rule, so an
502 // unrestricted coupon with a minimum spend carries the minimum and no scope.
503 if (minimum !== null) spec.minimumSubtotal = minimum;
527 if (minimum !== null && spec.scope) constraints.push({ constraint: 'scope-with-minimum', minimumAmount: String(coupon.minimum_amount), note: 'a product/collection scope and a minimum spend cannot both be expressed (the write drops the minimum)' });
528 if (constraints.length > 0) {
529 return { outcome: OUTCOMES.SCOPE_UNMAPPABLE, specification: null, classification, findings, reason: { scope: 'constraints', constraints, note: 'the source coupon carries eligibility constraints Wix cannot express; written without them it would be more generous than published' } };
530 }
531 // Wix allows one coupon per order regardless, so individual_use is implicit: counted once, not per coupon.
551 result.findings.push({ code: 'coupon-plugin-start-date-timezone-assumed', couponCode: code, metaKey: pluginRules.startDateRule.metaKey, value: pluginRules.startDateRule.value, note: 'the shop\'s UTC offset was not supplied; the start date was read as UTC midnight and may be off by the shop\'s offset' });
552 }
553 }
554 if (pluginRules.notCarried.length > 0) {
555 // Written switched off, tagged, with a cutover task -- never live without its rule.
556 result.specification.active = false;
557 if (!result.specification.tags.includes(RULE_NOT_CARRIED_TAG)) result.specification.tags.push(RULE_NOT_CARRIED_TAG);
588 action: `These coupons had a rule from a coupon plugin that Wix coupons cannot hold (a free gift, a shipping, payment, role or location limit, an automatic apply, or a product limit). They were imported switched off and tagged ${RULE_NOT_CARRIED_TAG}. In Business Manager → Marketing → Coupons, switch each one on as it is, change it, or leave it off.${redeemable.length ? ` ${redeemable.length} of them could still be used on the old store.` : ''}`,
589 consequence: 'Until switched on, customers cannot use these codes on Wix.',
590 deadlineReason: 'Before the DNS switch: live codes stop working at cutover until the operator acts.',
591 resolutionEvidence: 'Each listed coupon\'s active state on Wix matches the operator\'s recorded choice, or the operator dismisses it.',
592 affectedCount: codes.length,
593 affectedIds: codes,
594 details,
595 }];
596}
597
598// The exhaustive sweep. `fetchPage(page)` -> { items, total } where `total` is the
599// X-WP-Total header; runs until an EMPTY page (or, with the header present, a short one), and
600// refuses to report a denominator that disagrees with the header -- a one-page sweep reporting 100
607 const { items, total } = await fetchPage(page);
608 if (!Array.isArray(items)) throw new Error(`fetchPage(${page}) returned no items array`);
609 if (total !== undefined && total !== null && headerTotal === null) {
610 // The header is the denominator's witness, so it is READ, not coerced: `Number("garbage")`
611 // is NaN, a NaN header let a short first page end the sweep and skipped the reconciliation
612 // that exists to catch exactly that (review). A header that is not a non-negative integer
613 // is refused outright; a caller whose server sends none passes null.
614 const text = String(total).trim();
615 if (!/^\d+$/.test(text)) throw new Error(`fetchPage(${page}) reported a malformed total ${JSON.stringify(total)}; X-WP-Total must be a non-negative integer, and the sweep refuses an unreadable denominator (pass null when the server sends no header)`);
616 headerTotal = Number(text);
617 }
618 all.push(...items);
619 if (items.length === 0) break;
620 // A short page ends the sweep only when the header is there to confirm the total. A server
621 // that caps the page size below `perPage` and sends no X-WP-Total returns a short page that
622 // is FULL (review demonstrated two rows accepted as the whole list); without the header the
623 // sweep runs on to an empty page.
624 if (items.length < perPage && headerTotal !== null) break;
625 if (page === maxPages) throw new Error(`coupon sweep exceeded ${maxPages} pages; refusing to report a partial denominator`);
626 }
627 // De-duplicate by id FIRST: a store trading mid-sweep can shift a row onto a second page, and
628 // that row counted twice is not a denominator error. A count that still disagrees with the
629 // header after de-duplication is -- a row was missed, or appeared, and the sweep cannot vouch.
630 const seen = new Set();
631 const unique = all.filter((c) => { const id = String(c.id); if (seen.has(id)) return false; seen.add(id); return true; });