1'use strict';23// A spent coupon must not import redeemable, and a migrated coupon must be identifiable.4//5// A bearer voucher is money: a fixed amount off, single use, no scope, no minimum, no expiry.6// Whoever holds the code can spend it. rp-source-wordpress/lib/coupon-discovery.js already gets7// the STATIC rule right (exhausted at capture -> imported inactive, because Wix starts its own8// usage count at zero). This module covers the TEMPORAL half, which nothing else does:9//10// * the snapshot the import reads is dated, and a stale one is refused rather than imported;11// * a voucher spent between capture and write is not written at all;12// * a voucher spent AFTER the import is reconciled at cutover against a fresh source read.13//14
55// The remedies the platform actually offers, in preference order. DEACTIVATE is first because it
56// keeps the coupon id, the code, the dates and the amount -- the merchant keeps a readable record
57// of a voucher that can no longer be cashed, and nothing that referenced the coupon id is
58// orphaned. DELETE stays available because it is the only option if a code must disappear
59// entirely; note it frees the code for re-creation but mints a new id.
60const REMEDY = Object.freeze({
61 DEACTIVATE: 'deactivate-on-target',
62 DELETE: 'delete-on-target',
63 NONE: 'no-action-needed',
64 ASK: 'owner-decision-required',
65});
66
67// The tag vocabulary, frozen here so two passes cannot invent variants of the same word. `tags` is a real validated field on the coupon specification
68// (array<string>, max 100 items, max 255 chars each) and it is settable at create AND updatable
69// in place through the same minimal-specification PATCH as `active`.
70const TAGS = Object.freeze({
71 IMPORTED: 'Imported',
72 ALREADY_USED: 'Already_Used',
73 PARTIALLY_USED: 'Partially_Used',
74 DISABLED_AT_SOURCE: 'Disabled_At_Source',
75 // A coupon plugin's rule Wix cannot hold: written switched off for the operator.
192 if (at === null) throw new Error('classifySnapshot needs `now`');
193 if (!Number.isInteger(maxAgeMs) || maxAgeMs <= 0) throw new Error('classifySnapshot needs a positive integer maxAgeMs (the run\'s freshness budget)');
194 if (captured === null) {
195 return { verdict: SNAPSHOT.UNDATED, capturedAt: null, ageMs: null, maxAgeMs, fresh: false, reason: 'the source snapshot carries no capture time, so its redeemability values cannot be dated' };
196 }
197 const ageMs = at - captured;
198 if (ageMs > maxAgeMs) {
199 return { verdict: SNAPSHOT.STALE, capturedAt: captured, ageMs, maxAgeMs, fresh: false, reason: `the source snapshot is ${Math.round(ageMs / 3600000)}h old and the run's budget is ${Math.round(maxAgeMs / 3600000)}h; a voucher spent since capture would import fully redeemable` };
229 // A face value the target cannot hold is refused BEFORE anything else. Writing it means the
230 // target silently holds a different amount from the source while every check reports a match,
231 // and that is worse than not writing the coupon at all.
232 if (facePrecisionUnsupported) {
233 return { ...base, outcome: OUTCOMES.PRECISION_UNSUPPORTED, write: false, reason: `the face value ${faceValue} carries more than ${TARGET_DECIMALS} decimal places, which the target cannot hold. Report it; do not round it into the target` };
234 }
235 if (!freshCoupon) {
236 return { ...base, outcome: OUTCOMES.UNDECIDABLE, write: false, reason: 'the pre-write re-read of this coupon did not return a row; redeemability is unknown and unknown is not written' };
263 ? `spent at source after the snapshot was captured (${snapshotCount} -> ${count} of ${limit}); written switched off so ${faceValue || 'its value'} cannot be redeemed again, and the record survives`
264 : `already used up at capture (${count} of ${limit}); written switched off as a record of the promotion`,
265 };
266 }
267 const remaining = limit - count;
268 return {
269 ...base,
270 outcome: OUTCOMES.WRITE_OK,
271 write: true,
272 writeActive: true,
273 // The uses the holder has LEFT, never the original limit.
293 const base = { code, wixId, faceValue, facePrecisionUnsupported };
294
295 if (!targetCoupon) {
296 // Not "fine": this surface's read lag is long enough that absent is UNKNOWN, never gone.
297 return { ...base, outcome: OUTCOMES.TARGET_GONE, remedy: REMEDY.ASK, reason: 'the coupon did not read back from the target; this surface\'s read lag makes absent indistinguishable from deleted, so re-read before concluding anything' };
298 }
299 // FINDING 1: `active` must be an explicit boolean. An object with no `active` at all read as
300 // "not true" and therefore as "already switched off" -- a partial or truncated read-back
301 // silently reported a coupon as handled. Absent is UNKNOWN, always.
305 return { ...base, outcome: OUTCOMES.TARGET_STATE_UNREADABLE, remedy: REMEDY.ASK, reason: 'the target row came back without a readable `active` flag, so whether it is redeemable is unknown; re-read rather than assuming either state' };
306 }
307 if (!freshCoupon) {
308 return { ...base, outcome: OUTCOMES.SOURCE_GONE, remedy: REMEDY.ASK, targetActive: targetActive === true, reason: 'the source row for this code was not found in the fresh read; it may have been deleted at source, which is a different decision from being spent' };
328 // switch it off and say why in the same call, so the record survives
329 // and is self-explanatory in the dashboard.
330 setTags: [TAGS.IMPORTED, TAGS.ALREADY_USED],
331 reason: `spent at source (${count} of ${limit}) and still active on the target, so ${faceValue || 'its value'} is redeemable a second time. Remedy: deactivate in place and tag ${TAGS.ALREADY_USED} -- the coupon id, code, dates and amount all survive`,
332 };
333 }
334 if (spentAtSource && targetActive === false) {
335 return { ...base, outcome: OUTCOMES.RECONCILED_MATCH, remedy: REMEDY.NONE, targetActive: false, usageCount: count, usageLimit: limit, reason: 'used up at source and already switched off on the target' };
336 }
337 // Redeemable at source but switched off on the target is a deliberate hold (an exhausted snapshot,
338 // or a plugin rule the target cannot carry), not drift: no remedy here, and the reason says so.
339 const targetState = targetActive ? 'still redeemable on the target' : 'switched off on the target (held for the operator)';
340 return { ...base, outcome: OUTCOMES.RECONCILED_MATCH, remedy: REMEDY.NONE, targetActive, usageCount: count, usageLimit: limit, usageUnlimited: limit === null, reason: limit === null ? `unlimited at source and ${targetState}` : `still redeemable at source and ${targetState}` };
341}
342
343// Turns the per-voucher verdicts into the remedy plan a human approves: counts,
344// the money still live on the target, and one row per code with everything needed to rule on it.
345// Read-only by construction — this returns a plan, it does not apply one.
387 // deleting removes a record an imported order points at through
388 // appliedDiscounts[].coupon.id. The owner rules with that list visible, not after.
389 affectedOrders: [...affectedOrders],
390 reason: v.reason || null,
391 });
392 }
393 rows.sort((a, b) => {
394 const bm = toMinorUnits(b.faceValue) ?? -1n;
395 const am = toMinorUnits(a.faceValue) ?? -1n;
396 return bm === am ? a.code.localeCompare(b.code) : (bm > am ? 1 : -1);
397 });
398
399 const blocking = [];
400 if (precisionGaps.length > 0) {
401 blocking.push({ code: 'voucher-face-value-precision-unsupported', detail: `${precisionGaps.length} coupon(s) carry a face value with more than ${TARGET_DECIMALS} decimal places, which the target cannot hold: ${precisionGaps.slice(0, 5).map((r) => `${r.code} (${r.faceValue})`).join(', ')}. Report the gap — do NOT round it into the target and do not count it in the liability` });
402 }
403 if (counts[OUTCOMES.TARGET_STATE_UNREADABLE] > 0) {
404 blocking.push({ code: 'voucher-target-state-unreadable', detail: `${counts[OUTCOMES.TARGET_STATE_UNREADABLE]} coupon(s) came back without a readable \`active\` flag. Whether they are redeemable is unknown — re-read before approving a plan built on them` });
405 }
406 if (counts[OUTCOMES.TARGET_GONE] > 0) {
407 blocking.push({ code: 'voucher-target-read-incomplete', detail: `${counts[OUTCOMES.TARGET_GONE]} voucher(s) did not read back from the target. Absent is UNKNOWN on this surface, not deleted — re-read after a settle before approving a plan built on it` });
408 }
409 return {
410 counts,
411 rows,
412 // the count and the money are the same fact, and only one of them gets decided on.
413 liability: {
414 currency: currency ? String(currency) : null,
415 live: minorUnitsToDecimal(liveLiability),
416 codes: counts[OUTCOMES.REMEDY_PENDING],
417 // Codes whose face value could not be counted exactly -- unreadable, or carrying more
418 // precision than the target can hold. The total above EXCLUDES them, and saying so is the
419 // point: a total that silently absorbed them would look complete.
420 unknownValueFor: liabilityUnknownFor,
421 },
422 blocking,
423 approvable: blocking.length === 0,
424 // There is no apply path in this module on purpose. A caller that wants to act reads
425 // `rows[].remedy`, gets an explicit owner decision, and is verified afterwards.
426 readOnly: true,
427 };
428}
429
430// Builds the Update Coupon request for a remedy, in the ONE shape that actually
431// writes: a specification carrying ONLY the changed fields, plus a field mask naming them.
432// Refusing to build any other shape is the point -- a caller that spreads the existing
433// specification in gets a 200 and a silent no-op, which is the defect this whole module guards.
468 return { applied: false, verdict: 'remedy-unverified', reason: 'a 2xx is not evidence of a change on this surface -- a PATCH carrying the FULL specification alongside a narrow field mask returns 200 with an empty body and writes nothing; pass the post-settle read-back' };
472 if (readBackAfterSettle === null) return { applied: true, verdict: 'remedy-applied', reason: `the code is absent from the target after ~${settleS}s, which is what a delete looks like` };
473 return { applied: false, verdict: 'remedy-unverified', reason: 'the coupon still reads back after the delete; treat as UNKNOWN and re-read, do not re-issue' };
474 }
475 // DEACTIVATE: absent is NOT success here. A deactivated coupon must still be readable; an
476 // absent one means something deleted it, which is a different outcome than the one requested.
477 if (readBackAfterSettle === null) {
478 return { applied: false, verdict: 'remedy-unverified', reason: `the coupon did not read back after ~${settleS}s. A deactivate leaves the record in place, so absent is UNKNOWN (this surface's read lag), never "switched off"` };
488 return { applied: false, verdict: 'remedy-unverified', reason: `the update returned 2xx and the coupon still reads back active after ~${settleS}s -- the signature of a full-specification PATCH being silently ignored. Re-send with ONLY the changed fields` };
489 }
490 return { applied: false, verdict: 'remedy-unverified', reason: `the read-back carried no readable \`active\` flag after ~${settleS}s, so nothing was confirmed. Re-read; absent is never proof of a change` };