1'use strict';23const { isBlank } = require('./value-utils.js');45// rp-target-wix — the DETERMINISTIC "PW WooCommerce Gift Cards" issuance line item -> Wix6// gift-cards/v1 Create Gift Card payload builder. Companion to discount-rule-build.js / tax-build.js;7// implements domains/gift-cards/entities/gift-card.json's mappingGuidance/pitfalls as code.8//9// Input is one WooCommerce order (as returned by GET /wc/v3/orders/{id}) plus the specific10// line_item on it that carries pw_gift_card_number in its meta_data (see11// plugins/pw-woocommerce-gift-cards.json's gift-card entity for how that meta gets there and why12
121 if (split.length === cardCount) return { recipients: split };
122 if (split.length <= 1) return { recipients: new Array(cardCount).fill(split[0]) };
123 return {
124 recipients: null,
125 gap: {
126 code: 'recipient-card-count-mismatch',
127 summary: `pw_gift_card_to lists ${split.length} recipient(s) but ${cardCount} card(s) were issued on this line item — cannot safely pair recipients to cards (spec 0042 Decision 3). This is not a shape the plugin's own code produces under normal use; treat it as a data-integrity gap, not something to guess a pairing for.`,
128 },
129 };
130}
131
132// spec 0042 Decision 5's validation for one already-resolved balance override value (Policy B).
133// Never throws, never silently coerces an invalid value into a number — returns a named gap
137 return { gap: { code: 'invalid-remaining-balance', summary: 'the resolved remainingBalance was an empty/whitespace string.' } };
138 }
139 const numeric = Number(remainingBalance);
140 if (!Number.isFinite(numeric)) {
141 return { gap: { code: 'invalid-remaining-balance', summary: `the resolved remainingBalance "${remainingBalance}" is not a finite number (NaN/Infinity/non-numeric text are all rejected) — see spec 0042 Decision 5.` } };
142 }
143 if (numeric < 0) {
144 return { gap: { code: 'invalid-remaining-balance', summary: `the resolved remainingBalance (${numeric}) is negative — a gift card balance cannot be negative.` } };
147 if (Math.abs(scaled - Math.round(scaled)) > 1e-9) {
148 return { gap: { code: 'invalid-remaining-balance', summary: `the resolved remainingBalance (${numeric}) has more than ${MAX_MONEY_DECIMALS} decimal places — Wix money amounts are decimal strings with at most 2 decimal places.` } };
149 }
150 if (faceValue != null && numeric > faceValue) {
151 return {
152 gap: {
153 code: 'remaining-balance-exceeds-face-value',
154 summary: `the resolved remainingBalance (${numeric}) is greater than the card's face value (${faceValue}) — a stored-value card's balance cannot legitimately exceed what it was issued for in this migration's model (spec 0042 Decision 5). This indicates a source data-quality problem needing its own investigation, not a value to pass through.`,
155 },
156 };
157 }
158 return { amount: numeric };
159}
160
161// spec 0042 Decision 5, per card, fixed 2026-08-18 (PR review): the balance-policy signal is now
162// EXPLICIT (`options.balancePolicy`) instead of inferred from whether `remainingBalance` happens
163// to be set — a Policy B lookup that came back empty must fail closed as its own gap, never fall
164// through to Policy A's face-value path undetected (both used to look identical: an omitted
165// value). The override itself is resolved PER SOURCE CARD (`options.remainingBalanceByCard`,
166// keyed by the raw `pw_gift_card_number`), not one scalar shared across every card on a
167// multi-quantity line — two cards from the same line can have been independently, differently
168// spent, and sharing one balance would silently misprice one of them.
169// Shared by every per-card lookup map this module accepts (remainingBalanceByCard,
170// lifecycleByCard, ...): both a plain object and a Map are accepted, keyed by the raw
171// `pw_gift_card_number`. `has` is distinct from `value` so a map that explicitly sets an entry to
172// `null`/`undefined` (a lookup that ran and found nothing) is still visible as "present but
173// empty," not indistinguishable from "never looked up."
203 note: `options.remainingBalanceByCard has an entry for card ${sourceNumber}, but options.balancePolicy is not "B" — it was IGNORED; set balancePolicy: 'B' to actually apply it (spec 0042 Decision 5).`,
204 };
205 }
206 return { amount: null };
207 }
208
209 if (balancePolicy !== 'B') {
210 return { gap: { code: 'invalid-balance-policy', summary: `options.balancePolicy must be "A", "B", or "C" (spec 0042 Decision 5) — got ${JSON.stringify(balancePolicy)}.` } };
211 }
212
213 if (!hasOverrideEntry) {
214 return {
215 gap: {
216 code: 'unavailable-remaining-balance',
217 summary: `options.balancePolicy is "B" but options.remainingBalanceByCard has no entry for card ${sourceNumber} (spec 0042 Decision 5) — a failed/missing balance lookup must never fall back to face value silently; the caller must explicitly decide to skip this card, supply its verified balance, or halt the run.`,
218 },
219 };
220 }
221
222 if (isBlank(rawValue)) {
223 return {
224 gap: {
225 code: 'unavailable-remaining-balance',
226 summary: `options.balancePolicy is "B" but the resolved remainingBalance for card ${sourceNumber} is blank/null (spec 0042 Decision 5) — a failed lookup must be reported as unavailable, never treated as "use face value".`,
284 return { gap: { code: 'invalid-lifecycle-policy', summary: `options.lifecyclePolicy must be "not-tracked" or "verified" — got ${JSON.stringify(lifecyclePolicy)}.` } };
285 }
286
287 // Fixed 2026-08-18 (PR review, fourth pass): an OMITTED lifecyclePolicy still defaults to
288 // 'not-tracked' (breaking every existing caller to force an explicit value was judged too
289 // disruptive), but that default is no longer silent — every card built this way carries a
290 // prominent note recording the decision, instead of the fidelity loss being invisible unless a
291 // reader already knows to go looking for it.
292 const notes = [];
293 if (lifecyclePolicy == null) {
294 notes.push(`options.lifecyclePolicy was not set for card ${sourceNumber} — defaulting to "not-tracked": this card's active/expiration state was NOT verified against the source (spec 0042). Pass lifecyclePolicy: "verified" with a real lifecycleByCard record if source lifecycle fidelity matters for this migration.`);
307 summary: `options.lifecyclePolicy is "verified" but card ${sourceNumber} is missing required lifecycleByCard data (${missingRequiredFields.join(', ') || 'record'}) — a missing/failed lifecycle lookup must never be treated as "active, no expiration"; supply "active", "expirationDate", and "expirationDateConversionError" (expirationDate may be explicitly null only when expirationDateConversionError is explicitly false), or use lifecyclePolicy: "not-tracked" as an explicit, visible decision to accept the fidelity loss for this card.`,
308 },
309 };
310 }
311
312 // Unlike expiration shape/status, inactive state is honored regardless of lifecyclePolicy (see
313 // the active===false gate below). Therefore a supplied active value must always be a boolean:
314 // accepting "false" or 0 would silently recreate an inactive source card as active.
315 if (hasOwn(record, 'active') && typeof active !== 'boolean') {
316 return {
317 gap: {
318 code: 'invalid-lifecycle-data',
319 summary: `card ${sourceNumber}'s lifecycleByCard record has active ${JSON.stringify(active)} — this field must be an explicit boolean so an inactive source card can never be recreated as active through coercion.`,
320 },
321 };
322 }
323
324 if (hasOwn(record, 'expirationDateConversionError') && typeof conversionError !== 'boolean') {
325 return {
326 gap: {
327 code: 'invalid-lifecycle-data',
328 summary: `card ${sourceNumber}'s lifecycleByCard record has expirationDateConversionError ${JSON.stringify(conversionError)} — this field must be an explicit boolean so conversion failure cannot be confused with a "no expiration" value.`,
337 summary: `card ${sourceNumber}'s verified lifecycleByCard record has expirationDate ${JSON.stringify(expirationDate)} — this field must be either an explicit null (verified no expiration) or a nonblank RFC 3339 date-time string.`,
338 },
339 };
340 }
341
342 // Checked regardless of lifecyclePolicy, same as the active===false check below: if the
343 // caller's own data says the conversion failed, that is real signal to honor, not something a
344 // policy setting should be able to suppress. This is exactly the case the bridge's
345 // expiration_date_conversion_error field exists to report (spec 0040 Case 2) — a non-null
346 // source expiration_date that could not be converted must never be treated as "no expiration."
347 if (conversionError === true) {
348 return {
349 gap: {
350 code: 'expiration-date-conversion-failed',
351 summary: `card ${sourceNumber}'s lifecycleByCard record has expirationDateConversionError: true — the source's expiration date failed to convert to a usable value and must never be treated as "no expiration" or silently dropped. Investigate the source data (see spec 0040's expiration_date_conversion_error) before proceeding with this card.`,
352 },
353 };
354 }
355
356 if (!isBlank(expirationDate) && !EXPIRATION_DATE_TIME_PATTERN.test(expirationDate)) {
357 return {
358 gap: {
359 code: 'invalid-expiration-date-shape',
360 summary: `options.lifecycleByCard's record for card ${sourceNumber} has expirationDate "${expirationDate}", which is not an RFC 3339 date-time (e.g. "2026-11-11T00:00:00Z") — Wix's Gift Card Object requires a date-time, not a bare date. Never forward spec 0040's bridge plugin's raw expiration_date (a MySQL DATE) directly; use its expiration_date_gmt (preserving end-of-day/site-timezone semantics) instead.`,
361 },
362 };
363 }
364
365 if (active === false) {
366 if (!allowInactiveCardCreation) {
367 return {
368 gap: {
369 code: 'inactive-source-card',
370 summary: `card ${sourceNumber} is inactive at the source (active: false) — Wix's Create Gift Card has no "create disabled" option, so creating it normally would resurrect deactivated customer value as a live, spendable card. Set options.allowInactiveCardCreation to explicitly accept creating it active anyway (with a manual Disable Gift Card follow-up still owed), or exclude this card from the migration.`,
371 },
372 };
373 }
374 notes.push(`card ${sourceNumber} is inactive at the source but was created anyway per options.allowInactiveCardCreation — it now exists as an ACTIVE Wix gift card; a manual Disable Gift Card follow-up is required to match the source state (no verified Disable Gift Card request shape is recorded in this repo yet).`);
423 if (!ORDER_STATUSES_WITH_ISSUED_CARDS.has(order.status)) {
424 return [{
425 sourceNumber: null,
426 payload: null,
427 gaps: [{
428 code: 'order-not-yet-completed',
429 summary: `order ${order.id} has status "${order.status}" — PW WooCommerce Gift Cards only mints a card number once an order reaches processing/completed (see gift-card.json's issuance-is-core-meta-not-db-only pitfall). Re-check after the order progresses.`,
441 gaps: [{ code: 'no-gift-card-number', summary: `line item ${lineItem.id} on order ${order.id} has no pw_gift_card_number meta — it is not a gift-card issuance line, or the order hasn't triggered issuance yet.` }],
454 gaps: [{ code: 'unresolvable-amount', summary: `could not resolve a face value for line item ${lineItem.id} on order ${order.id} from either pw_gift_card_amount meta or subtotal/quantity.` }],
482 return { sourceNumber, payload: null, gaps: [{ code: 'no-gift-card-number', summary: `card #${index + 1} on line item ${lineItem.id} has a blank pw_gift_card_number value.` }], notes };
483 }
484
485 if (!CODE_ALLOWED_CHARS.test(code)) {
486 if (!normalizeCode) {
487 return {
488 sourceNumber,
489 payload: null,
490 gaps: [{ code: 'code-contains-disallowed-characters', summary: `source code "${code}" contains characters Wix's giftCard.code rejects (letters/numbers only — see gift-card.json's code-alphanumeric-only pitfall). Pass options.normalizeCode to strip them, but that changes the code the customer already holds — get an explicit decision first.` }],
495 notes.push(`code normalized for Wix (stripped non-alphanumeric characters): source "${code}" -> "${stripped}" — this changes the code the customer already holds; only done because options.normalizeCode was explicitly set.`);
496 code = stripped;
497 }
498
499 if (code.length < CODE_MIN_LENGTH || code.length > CODE_MAX_LENGTH) {
500 return { sourceNumber, payload: null, gaps: [{ code: 'code-length-out-of-range', summary: `code "${code}" is ${code.length} characters; Wix giftCard.code must be 8-20 characters and is immutable. Report this card instead of rewriting a code the customer already holds.` }], notes };
501 }
502
503 let initialValueAmount = amount;
504 if (overrideAmount != null) {
505 initialValueAmount = overrideAmount;
506 notes.push(`imported at the caller-resolved remainingBalanceByCard entry (${overrideAmount}) instead of face value (${amount}) — balance-policy decision made by the caller, not this builder (spec 0042 Decision 5, Policy B).`);
507 } else {
508 notes.push(`imported at face value (${amount}); redemption/balance history is not readable from source data (see plugins/pw-woocommerce-gift-cards.json's gift-card-activity redemption-invisible-even-in-order-rest-data pitfall) — if this card is known to have been partially spent, pass options.balancePolicy: 'B' with a verified remainingBalanceByCard entry (spec 0042 Decision 5).`);
509 }
510
511 const recipient = recipients[index];
512 if (!isBlank(recipient) && recipient !== buyerEmail) {
513 notes.push(`recipient ("${recipient}") differs from the buyer (billing.email "${buyerEmail}") — the Create Gift Card API has no documented recipient/contactId field, so this is carried as a note for a human CRM-linking step, not sent in the payload.`);
514 }
515
516 const payload = {
517 code,
518 // CORRECTED 2026-08-17 (live 400 on the real endpoint): `currency` is top-level on giftCard,
519 // NOT nested inside `initialValue` — see gift-card.json's mappingGuidance.