Setting the file. One moment.
Gift Card Redemption Build · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page Post
lib/gift-card-redemption-build.js
lib/ gift-card-redemption-build.js
JavaScript · 250 lines · 14 KB
11 // fd0ad9fb-d439-406f-8a00-b6c6ad0d95ab): a real Redeem Gift Card call against a real imported
12 // order correctly decremented a card's balance (10 -> 7) and returned a transactionId. What this
13 // module is BLOCKED on is the INPUT, not the mechanism: the activity row shape it consumes comes
14 // from spec 0040's bridge plugin, which is code-only and not yet installed/live-tested on any
15 // real WordPress site (see that spec's Open Questions 1/2). This builder is ready to consume that
16 // data the moment it exists — see spec 0042 Decision 4.
17 //
18 // Ordering requirement (spec 0042 Decision 4, steps 1-2): the source card's issuance must already
19 // be recorded in the crosswalk (gift-card-build.js ran first) and the order this redemption
20 // belongs to must already be imported (order-channel-build.js / Decision 1-2), before a
21 // redemption can be linked — this module does not check either precondition itself, it only
22 // requires the caller to already have both resolved ids in hand.
23
24 // A single activity row's `action` label is NOT independently confirmed against the plugin's
25 // exact stored string — spec 0040's evidence only documents the function name
26 // (`debit_gift_cards()`), not the literal VARCHAR value it writes. Classifying by the SIGN of
27 // `amount` instead is grounded directly in the plugin's own documented balance computation
28 // (balance = SUM(activity.amount) — see spec 0040 Case 2), not a guessed string.
29 function isRedemptionActivityRow ( row ) {
30 return row != null && Number (row.amount) < 0 ;
31 }
32
33 // Fixed 2026-08-18 (PR review): a naive "replay every negative row" double-removes value the
34 // source has already restored. Most gift-card plugins (PW Gift Cards included, per its own
35 // debit/credit activity model — see spec 0040 Case 2) can credit a card back when an order tied
36 // to a prior debit is later cancelled, refunded, or fails — a -10/+10 pair for the SAME order
37 // must net to zero, not emit a 10.00 redemption. This exact reversal mechanism is not
38 // independently verified against the plugin's own PHP source in this repo yet, but netting per
39 // order is the only sound behavior regardless of the specific trigger, since ANY reversal for ANY
40 // reason produces exactly this shape: two-or-more rows, same order, opposite signs.
41 //
42 // Returns an array of 0 or 1 netted-activity objects (`{ orderId, amount, activityIds, rows }`) —
43 // an array, not a bare object, so a fully-reversed or never-redeemed order (net >= 0) can return
44 // "nothing to build" the same way a not-found lookup would, rather than a sentinel the caller has
45 // to remember to check.
46 function findRedemptionActivityForOrder ( activityRows , sourceOrderId ) {
47 const rowsForOrder = (activityRows || []). filter (
48 ( row ) => row != null && Number (row.order_id) === Number (sourceOrderId)
49 );
50 if (rowsForOrder. length === 0 ) return [];
51
52 const net = rowsForOrder. reduce (( sum , row ) => {
53 const rowAmount = Number (row.amount);
54 return sum + (Number. isFinite (rowAmount) ? rowAmount : 0 );
55 }, 0 );
56
57 // net >= 0: no debit at all for this order, or a later reversal already restored it in full —
58 // either way, nothing left to redeem in Wix.
59 if (net >= 0 ) return [];
60
61 return [{
62 orderId: sourceOrderId,
63 amount: net,
64 activityIds: rowsForOrder. map (( row ) => row.activity_id),
65 rows: rowsForOrder,
66 }];
67 }
68
69 // Fixed 2026-08-18 (PR review, fourth pass): Policy C's own soundness has a precondition this
70 // module did not check. Replaying every order-linked redemption reaches the CORRECT final balance
71 // only when the drift between face value and the bridge's reported current balance is FULLY
72 // explained by order-linked activity. The bridge explicitly allows manual, non-order-linked
73 // adjustments (extract_order_id() returns null for these — see spec 0040 Case 2's evidence): a
74 // card can carry face value 25, a manual credit +5 (order_id null), and a real redemption -10
75 // (order-linked) — the bridge's own current balance is 20, but blindly replaying only the
76 // order-linked -10 debit would leave the Wix card at 25 - 10 = 15, silently wrong by 5.
77 //
78 // This is the pre-write reconciliation spec 0042 Decision 5 requires before Policy C may be used
79 // for a card: predict the final balance IF only order-linked activity is replayed (summing, per
80 // order, the same net-per-order computation findRedemptionActivityForOrder itself uses — a
81 // net-positive order, like the one that issued the card, correctly contributes nothing to
82 // replay, exactly the way findRedemptionActivityForOrder already treats it), then compare that
83 // prediction against the bridge's own reported CURRENT balance (which sums literally every row,
84 // order-linked or not — no need to specifically detect "the issuance row" or "manual rows"; any
85 // unaccounted-for activity of any kind surfaces as a mismatch here). A mismatch means Policy C is
86 // not safe for this card — it must gap for manual review, or fall back to Policy B (create
87 // directly at the bridge's verified current balance) instead.
88 function verifyPolicyCReplaySufficiency ({ activityRows , faceValue , bridgeCurrentBalance } = {}) {
89 const face = faceValue == null ? NaN : Number (faceValue);
90 const bridgeBalance = bridgeCurrentBalance == null ? NaN : Number (bridgeCurrentBalance);
91 if ( ! Number. isFinite (face) || ! Number. isFinite (bridgeBalance)) {
92 return {
93 safe: false ,
94 gap: {
95 code: 'policy-c-verification-missing-input' ,
96 summary: `faceValue (${ JSON . stringify ( faceValue ) }) and bridgeCurrentBalance (${ JSON . stringify ( bridgeCurrentBalance ) }) must both be finite numbers to verify Policy C is safe for this card.` ,
97 },
98 };
99 }
100
101 const orderIds = Array. from (
102 new Set ((activityRows || []). map (( row ) => (row == null ? null : row.order_id)). filter (( orderId ) => orderId != null ))
103 );
104
105 const totalReplayAmount = orderIds. reduce (( sum , orderId ) => {
106 const net = findRedemptionActivityForOrder (activityRows, orderId);
107 return sum + net. reduce (( s , entry ) => s + Math. abs (entry.amount), 0 );
108 }, 0 );
109
110 const predictedFinalBalance = Math. round ((face - totalReplayAmount) * 100 ) / 100 ;
111 const roundedBridgeBalance = Math. round (bridgeBalance * 100 ) / 100 ;
112 const drift = Math. round ((roundedBridgeBalance - predictedFinalBalance) * 100 ) / 100 ;
113
114 if (Math. abs (drift) > 0.005 ) {
115 return {
116 safe: false ,
117 predictedFinalBalance,
118 bridgeCurrentBalance: roundedBridgeBalance,
119 drift,
120 gap: {
121 code: 'policy-c-unexplained-activity' ,
122 summary: `replaying only this card's order-linked activity would leave it at ${ predictedFinalBalance }, but the bridge reports a current balance of ${ roundedBridgeBalance } (drift ${ drift }) — some activity (e.g. a manual, non-order-linked adjustment) is not accounted for by order-linked replay. Policy C is not safe for this card; either gap it for manual review, or use Policy B (create directly at the bridge's verified current balance) instead.` ,
123 },
124 };
125 }
126
127 return { safe: true , predictedFinalBalance, bridgeCurrentBalance: roundedBridgeBalance };
128 }
129
130 // One order's redemption -> one Redeem Gift Card body. `sourceOrderId` is the WooCommerce order
131 // id whose redemption is being linked; the netted activity for it is DERIVED INTERNALLY from
132 // `options.activityRows` via findRedemptionActivityForOrder — see the fix note below for why this
133 // is no longer a caller-supplied parameter. `wixCode` is the code the card was actually CREATED
134 // with (gift-card-build.js's payload.code — after any options.normalizeCode stripping), resolved
135 // from the crosswalk row this card's issuance produced (sourceStableKey
136 // `woocommerce:giftCard:<number>`), never re-derived from the raw source number here. `wixOrderId`
137 // is the real Wix order id returned by Import Order for the order this redemption belongs to.
138 //
139 // Fixed 2026-08-18 (PR review, third pass): `options.balancePolicy` is now REQUIRED and must be
140 // `'C'` — the only one of spec 0042 Decision 5's three balance policies a redemption replay is
141 // safe under. Policy A is explicitly defined as "face value, no replay"; building a redemption
142 // for an 'A' card would contradict that policy's own accepted-overstatement design instead of
143 // implementing it (an 'A' card's overstatement is meant to be corrected manually, via
144 // disable-and-replace, or accepted — not silently patched by a redemption call the card's own
145 // policy never called for). Policy B already creates the card at its verified CURRENT balance —
146 // replaying the same historical redemption on a 'B' card would double-decrement it (once
147 // implicitly, by never having had the spend reflected in a face-value creation that never
148 // happened; once again by this call). 'C' is the one policy that means what a redemption call
149 // does: create at face value, then bring the balance down to correct by replaying real,
150 // order-linked redemptions — see gift-card-build.js's resolveCardBalance for its (identical to
151 // 'A') creation-time behavior.
152 //
153 // Fixed 2026-08-18 (PR review, fourth pass): declaring Policy C is not by itself proof that
154 // replay will reach the correct balance for THIS card — see verifyPolicyCReplaySufficiency's own
155 // comment for the manual-adjustment failure mode this closes. `options.activityRows`,
156 // `options.faceValue`, and `options.bridgeCurrentBalance` are now REQUIRED alongside
157 // `balancePolicy: 'C'`, and this function runs that verification itself on every call rather than
158 // trusting a boolean the caller claims to have already checked — a card that fails verification
159 // gets a named gap here, never a payload.
160 //
161 // Fixed 2026-08-18 (PR review, fifth pass): the fourth pass's own verification was DISCONNECTED
162 // from the payload it gated. `netActivity` used to be a second, independently caller-supplied
163 // argument — nothing checked that it was actually derived from the same `activityRows` the
164 // verification ran against, so a caller (or a bug) could pass activity rows that verify cleanly
165 // while separately passing a fabricated `netActivity.amount` and still get a real redemption
166 // payload built for an amount that was never verified at all. There is now exactly ONE source of
167 // truth: `sourceOrderId` plus `options.activityRows`, and this function derives the netted
168 // activity itself via `findRedemptionActivityForOrder` — the same call, on the same rows,
169 // `verifyPolicyCReplaySufficiency` already used — so the built payload can never diverge from
170 // what was verified.
171 function buildGiftCardRedemption ( sourceOrderId , options ) {
172 const { wixCode , wixOrderId , wixAppId = WIX_GIFT_CARDS_APP_ID , balancePolicy , activityRows , faceValue , bridgeCurrentBalance } = options || {};
173 const notes = [];
174
175 if (balancePolicy !== 'C' ) {
176 return {
177 payload: null ,
178 gaps: [{
179 code: 'redemption-replay-requires-policy-c' ,
180 summary: `options.balancePolicy must be "C" to build a redemption — got ${ JSON . stringify ( balancePolicy ) }. Policy A means "face value, no replay" (spec 0042 Decision 5); replaying a redemption on an 'A' card contradicts that policy instead of implementing it. Policy B already creates the card at its verified current balance; replaying the same redemption on a 'B' card would double-decrement it. Only a card explicitly declared Policy C — face value now, corrected later by replaying real order-linked redemptions — may be redeemed here.` ,
181 }],
182 notes,
183 };
184 }
185
186 const verification = verifyPolicyCReplaySufficiency ({ activityRows, faceValue, bridgeCurrentBalance });
187 if ( ! verification.safe) {
188 return { payload: null , gaps: [verification.gap], notes };
189 }
190
191 if ( isBlank (wixCode)) {
192 return {
193 payload: null ,
194 gaps: [{ code: 'missing-wix-code' , summary: 'options.wixCode is required — resolve it from the crosswalk row this card was issued under (sourceStableKey woocommerce:giftCard:<number>) before building a redemption; never re-derive it from the raw source number, which may have been normalized at creation.' }],
195 notes,
196 };
197 }
198 if ( isBlank (wixOrderId)) {
199 return {
200 payload: null ,
201 gaps: [{ code: 'missing-wix-order-id' , summary: 'options.wixOrderId is required — the order this redemption is linked to must already be imported (spec 0042 Decision 1/2) before its redemptions can be recorded.' }],
202 notes,
203 };
204 }
205
206 // Derived from the SAME activityRows the verification above just ran against — never a
207 // separately caller-supplied value (see the fifth-pass fix note above).
208 const [ netActivity = null ] = findRedemptionActivityForOrder (activityRows, sourceOrderId);
209
210 const netAmount = netActivity == null ? null : Number (netActivity.amount);
211 if (netActivity == null || ! Number. isFinite (netAmount) || netAmount >= 0 ) {
212 return {
213 payload: null ,
214 gaps: [{
215 code: 'not-a-net-redemption' ,
216 summary: `net activity for order ${ sourceOrderId } is "${ netActivity && netActivity . amount }" — not a negative net amount once every activity row for this order is netted together, so there is nothing to redeem (either no debit occurred, or a later same-order reversal already restored it).` ,
217 }],
218 notes,
219 };
220 }
221
222 const amount = Math. abs (netAmount);
223 if (amount <= 0 ) {
224 return {
225 payload: null ,
226 gaps: [{ code: 'invalid-redemption-amount' , summary: `net activity for order ${ netActivity . orderId } has amount "${ netActivity . amount }", not a positive value once its sign is taken.` }],
227 notes,
228 };
229 }
230
231 notes. push ( `redemption sourced from netted bridge-plugin activity for order_id ${ netActivity . orderId } (activity rows: ${ JSON . stringify ( netActivity . activityIds ) }; spec 0040 Case 2) — net amount after any same-order reversal is ${ netActivity . amount }.` );
232
233 return {
234 payload: {
235 code: wixCode,
236 amount: amount. toFixed ( 2 ),
237 orderId: wixOrderId,
238 appId: wixAppId,
239 },
240 gaps: [],
241 notes,
242 };
243 }
244
245 module . exports = {
246 isRedemptionActivityRow,
247 findRedemptionActivityForOrder,
248 verifyPolicyCReplaySufficiency,
249 buildGiftCardRedemption,
250 };