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
119 if (split.length === cardCount) return { recipients: split };
120 if (split.length <= 1) return { recipients: new Array(cardCount).fill(split[0]) };
121 return {
122 recipients: null,
123 gap: {
124 code: 'recipient-card-count-mismatch',
125 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. 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.`,
126 },
127 };
128}
129
130// Validation for one already-resolved balance override value (Policy B).
131// Never throws, never silently coerces an invalid value into a number — returns a named gap
135 return { gap: { code: 'invalid-remaining-balance', summary: 'the resolved remainingBalance was an empty/whitespace string.' } };
136 }
137 const numeric = Number(remainingBalance);
138 if (!Number.isFinite(numeric)) {
139 return { gap: { code: 'invalid-remaining-balance', summary: `the resolved remainingBalance "${remainingBalance}" is not a finite number (NaN/Infinity/non-numeric text are all rejected).` } };
140 }
141 if (numeric < 0) {
142 return { gap: { code: 'invalid-remaining-balance', summary: `the resolved remainingBalance (${numeric}) is negative — a gift card balance cannot be negative.` } };
145 if (Math.abs(scaled - Math.round(scaled)) > 1e-9) {
146 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.` } };
147 }
148 if (faceValue != null && numeric > faceValue) {
149 return {
150 gap: {
151 code: 'remaining-balance-exceeds-face-value',
152 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. This indicates a source data-quality problem needing its own investigation, not a value to pass through.`,
153 },
154 };
155 }
156 return { amount: numeric };
157}
158
159// Per card, fixed 2026-08-18: the balance-policy signal is now
160// EXPLICIT (`options.balancePolicy`) instead of inferred from whether `remainingBalance` happens
161// to be set — a Policy B lookup that came back empty must fail closed as its own gap, never fall
162// through to Policy A's face-value path undetected (both used to look identical: an omitted
163// value). The override itself is resolved PER SOURCE CARD (`options.remainingBalanceByCard`,
164// keyed by the raw `pw_gift_card_number`), not one scalar shared across every card on a
165// multi-quantity line — two cards from the same line can have been independently, differently
166// spent, and sharing one balance would silently misprice one of them.
167// Shared by every per-card lookup map this module accepts (remainingBalanceByCard,
168// lifecycleByCard, ...): both a plain object and a Map are accepted, keyed by the raw
169// `pw_gift_card_number`. `has` is distinct from `value` so a map that explicitly sets an entry to
170// `null`/`undefined` (a lookup that ran and found nothing) is still visible as "present but
171// empty," not indistinguishable from "never looked up."
200 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.`,
201 };
202 }
203 return { amount: null };
204 }
205
206 if (balancePolicy !== 'B') {
207 return { gap: { code: 'invalid-balance-policy', summary: `options.balancePolicy must be "A", "B", or "C" — got ${JSON.stringify(balancePolicy)}.` } };
208 }
209
210 if (!hasOverrideEntry) {
211 return {
212 gap: {
213 code: 'unavailable-remaining-balance',
214 summary: `options.balancePolicy is "B" but options.remainingBalanceByCard has no entry for card ${sourceNumber} — 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.`,
215 },
216 };
217 }
218
219 if (isBlank(rawValue)) {
220 return {
221 gap: {
222 code: 'unavailable-remaining-balance',
223 summary: `options.balancePolicy is "B" but the resolved remainingBalance for card ${sourceNumber} is blank/null — a failed lookup must be reported as unavailable, never treated as "use face value".`,
281 return { gap: { code: 'invalid-lifecycle-policy', summary: `options.lifecyclePolicy must be "not-tracked" or "verified" — got ${JSON.stringify(lifecyclePolicy)}.` } };
282 }
283
284 // Fixed 2026-08-18: an OMITTED lifecyclePolicy still defaults to
285 // 'not-tracked' (breaking every existing caller to force an explicit value was judged too
286 // disruptive), but that default is no longer silent — every card built this way carries a
287 // prominent note recording the decision, instead of the fidelity loss being invisible unless a
288 // reader already knows to go looking for it.
289 const notes = [];
290 if (lifecyclePolicy == null) {
291 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. Pass lifecyclePolicy: "verified" with a real lifecycleByCard record if source lifecycle fidelity matters for this migration.`);
304 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.`,
305 },
306 };
307 }
308
309 // Unlike expiration shape/status, inactive state is honored regardless of lifecyclePolicy (see
310 // the active===false gate below). Therefore a supplied active value must always be a boolean:
311 // accepting "false" or 0 would silently recreate an inactive source card as active.
312 if (hasOwn(record, 'active') && typeof active !== 'boolean') {
313 return {
314 gap: {
315 code: 'invalid-lifecycle-data',
316 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.`,
317 },
318 };
319 }
320
321 if (hasOwn(record, 'expirationDateConversionError') && typeof conversionError !== 'boolean') {
322 return {
323 gap: {
324 code: 'invalid-lifecycle-data',
325 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.`,
334 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.`,
335 },
336 };
337 }
338
339 // Checked regardless of lifecyclePolicy, same as the active===false check below: if the
340 // caller's own data says the conversion failed, that is real signal to honor, not something a
341 // policy setting should be able to suppress. This is exactly the case the bridge's
342 // expiration_date_conversion_error field exists to report — a non-null
343 // source expiration_date that could not be converted must never be treated as "no expiration."
344 if (conversionError === true) {
345 return {
346 gap: {
347 code: 'expiration-date-conversion-failed',
348 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 (the bridge plugin's expiration_date_conversion_error) before proceeding with this card.`,
349 },
350 };
351 }
352
353 if (!isBlank(expirationDate) && !EXPIRATION_DATE_TIME_PATTERN.test(expirationDate)) {
354 return {
355 gap: {
356 code: 'invalid-expiration-date-shape',
357 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 the bridge plugin's raw expiration_date (a MySQL DATE) directly; use its expiration_date_gmt (preserving end-of-day/site-timezone semantics) instead.`,
358 },
359 };
360 }
361
362 if (active === false) {
363 if (!allowInactiveCardCreation) {
364 return {
365 gap: {
366 code: 'inactive-source-card',
367 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.`,
368 },
369 };
370 }
371 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).`);
420 if (!ORDER_STATUSES_WITH_ISSUED_CARDS.has(order.status)) {
421 return [{
422 sourceNumber: null,
423 payload: null,
424 gaps: [{
425 code: 'order-not-yet-completed',
426 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.`,
438 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.` }],
451 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.` }],
479 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 };
480 }
481
482 if (!CODE_ALLOWED_CHARS.test(code)) {
483 if (!normalizeCode) {
484 return {
485 sourceNumber,
486 payload: null,
487 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.` }],
492 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.`);
493 code = stripped;
494 }
495
496 if (code.length < CODE_MIN_LENGTH || code.length > CODE_MAX_LENGTH) {
497 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 };
498 }
499
500 let initialValueAmount = amount;
501 if (overrideAmount != null) {
502 initialValueAmount = overrideAmount;
503 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 (Policy B).`);
504 } else {
505 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.`);
506 }
507
508 const recipient = recipients[index];
509 if (!isBlank(recipient) && recipient !== buyerEmail) {
510 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.`);
511 }
512
513 const payload = {
514 code,
515 // CORRECTED 2026-08-17 (live 400 on the real endpoint): `currency` is top-level on giftCard,
516 // NOT nested inside `initialValue` — see gift-card.json's mappingGuidance.