@blamejs/blamejs-shop 0.4.28 → 0.4.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/checkout.js CHANGED
@@ -606,35 +606,60 @@ function create(deps) {
606
606
  // card; a card worth less reduces the amount due, never below 0.
607
607
  var grand = quote.totals.grand_total_minor;
608
608
  var applied = card.balance_minor < grand ? card.balance_minor : grand;
609
+ // A zero balance applies nothing — treat it as no credit rather than
610
+ // attempting a zero-amount debit (an anomalous active card at 0 would
611
+ // otherwise turn into a validation throw deep in the redeem).
612
+ if (applied <= 0) return null;
609
613
  // `code_plain` is the bearer code the redeem decrement re-hashes;
610
614
  // it lives only in this in-memory resolution, never persisted.
611
615
  return { card: card, code_plain: code, applied_minor: applied, balance_minor: card.balance_minor };
612
616
  }
613
617
 
614
- // Burn the resolved credit against the created order. The
615
- // `giftcards.redeem` decrement is the authoritative double-spend
616
- // guard its `balance_minor >= ?` SQL predicate refuses a second
617
- // spend that would overdraw, and a re-quote of the same card on a
618
- // new order only ever debits the remaining balance. The ledger
619
- // debit rides alongside as the operator-facing audit row, keyed on
620
- // the order id. Called once per order, immediately after the order
621
- // row exists, so a card is never burned for an order that failed to
622
- // create.
623
- //
624
- // The ledger debit is BEST-EFFORT: the card row's balance (decremented by
625
- // redeem above) is the authoritative spendable balance, and the ledger is
626
- // the audit trail. A ledger write failure (e.g. an unmigrated ledger table,
627
- // or a ledger view that lags the card row) must not throw out of the
628
- // post-create redeem path and strand the already-created order + its live
629
- // PaymentIntent. The card-row debit already happened; the ledger row is
630
- // reconcilable from the redemption record. Same posture the admin issue
631
- // route takes when seeding the opening credit.
632
- async function _redeemGiftCard(resolved, orderId) {
633
- var redemption = await giftcards.redeem({
618
+ // Debit the resolved gift-card credit the authoritative double-spend
619
+ // gate. Runs BEFORE the order (and any PaymentIntent / PayPal order)
620
+ // exists: two checkouts presenting the same card race the
621
+ // `balance_minor >= ?` SQL predicate HERE, while nothing has been
622
+ // charged or created, so the loser's coded GIFTCARD_INSUFFICIENT_BALANCE
623
+ // propagates into a clean rollback (holds + claim released) and a
624
+ // re-quote never a second paid order against an already-spent card.
625
+ // The redemption row is written with a NULL order id and attached to the
626
+ // order by _settleGiftCardPostCreate once the order row exists; a
627
+ // checkout that dies between debit and order creation reverses the debit
628
+ // via giftcards.reverseRedemptionById in the rollback path.
629
+ async function _debitGiftCard(resolved) {
630
+ return giftcards.redeem({
634
631
  code: resolved.code_plain,
635
- order_id: orderId,
632
+ order_id: null,
636
633
  amount_minor: resolved.applied_minor,
637
634
  });
635
+ }
636
+
637
+ // Attach a pre-charge gift-card debit to the order it paid for and write
638
+ // the operator-facing ledger audit row. The order link is what the
639
+ // refund/cancel reversers key on (reverseRedemption /
640
+ // reverseRedemptionProRata select by order_id), so a link failure is
641
+ // surfaced LOUDLY to the audit sink for reconciliation — but neither the
642
+ // link nor the ledger row may throw out of the post-create path: the
643
+ // order and its live charge already exist, the card debit already
644
+ // landed, and both writes are reconcilable from the redemption record.
645
+ async function _settleGiftCardPostCreate(resolved, redemption, orderId) {
646
+ try {
647
+ var linked = await giftcards.linkRedemptionToOrder(redemption.redemption_id, orderId);
648
+ if (!linked) throw new Error("redemption " + redemption.redemption_id + " did not accept the order link");
649
+ } catch (linkErr) {
650
+ try {
651
+ b.audit.safeEmit({
652
+ action: "checkout.giftcard.order_link.error",
653
+ outcome: "failure",
654
+ metadata: {
655
+ gift_card_id: resolved.card.id,
656
+ redemption_id: redemption.redemption_id,
657
+ order_id: orderId,
658
+ message: (linkErr && linkErr.message) || String(linkErr),
659
+ },
660
+ });
661
+ } catch (_auditErr) { /* drop-silent — the redemption record is the durable trail */ }
662
+ }
638
663
  if (giftCardLedger) {
639
664
  try {
640
665
  await giftCardLedger.debit({
@@ -659,32 +684,51 @@ function create(deps) {
659
684
  } catch (_auditErr) { /* drop-silent — the redemption record is the durable trail */ }
660
685
  }
661
686
  }
662
- return redemption;
663
687
  }
664
688
 
665
- // Run a post-create credit redemption (gift-card / loyalty burn) so a
666
- // failure can NEVER strand the already-created order + its live
667
- // PaymentIntent. The order row and the PaymentIntent exist by the time
668
- // this runs, and the buyer will pay the credit-reduced amount so a
669
- // throw out of the redeem (a card that raced to zero, a transient DB
670
- // error) must not bubble up and leave the cart un-converted with an
671
- // orphaned charge. The failure is captured to the audit sink for operator
672
- // reconciliation (the credit reduced the charge but the debit didn't
673
- // land, so the operator settles the card / points by hand) and the flow
674
- // continues. `fn` returns the redeem promise (or null when there's no
675
- // credit of that kind to burn).
676
- async function _settleCreditPostCreate(kind, orderId, fn) {
677
- try {
678
- return await fn();
679
- } catch (e) {
689
+ // Reverse the pre-charge credit debits when a checkout dies BEFORE its
690
+ // order exists (PaymentIntent refused, PayPal open failed, order insert
691
+ // threw). The card/points were taken for an order that will now never
692
+ // be created, so the rollback paths call this before releasing holds and
693
+ // the cart claim. Both reversals are claim-guarded (exactly-once) and
694
+ // keyed on the debit row itself no order id was ever attached. A
695
+ // reversal failure is audited, never thrown: the original checkout error
696
+ // is the actionable one, and the unreversed debit is reconcilable from
697
+ // the audit trail.
698
+ async function _reverseCreditDebits(rollbackCtx) {
699
+ if (!rollbackCtx) return;
700
+ if (rollbackCtx.giftCardDebit) {
680
701
  try {
681
- b.audit.safeEmit({
682
- action: "checkout." + kind + ".redeem.error",
683
- outcome: "failure",
684
- metadata: { order_id: orderId, message: (e && e.message) || String(e) },
685
- });
686
- } catch (_auditErr) { /* drop-silent — never let the audit write mask the original failure */ }
687
- return null;
702
+ await giftcards.reverseRedemptionById(rollbackCtx.giftCardDebit.redemption.redemption_id);
703
+ } catch (revErr) {
704
+ try {
705
+ b.audit.safeEmit({
706
+ action: "checkout.giftcard.rollback_reverse.error",
707
+ outcome: "failure",
708
+ metadata: {
709
+ redemption_id: rollbackCtx.giftCardDebit.redemption.redemption_id,
710
+ amount_minor: rollbackCtx.giftCardDebit.resolved.applied_minor,
711
+ message: (revErr && revErr.message) || String(revErr),
712
+ },
713
+ });
714
+ } catch (_auditErr) { /* drop-silent — the original checkout error is the actionable one */ }
715
+ }
716
+ }
717
+ if (rollbackCtx.loyaltyDebit) {
718
+ try {
719
+ await loyalty.reverseRedemptionById(rollbackCtx.loyaltyDebit.redemption.tx_id);
720
+ } catch (revErr) {
721
+ try {
722
+ b.audit.safeEmit({
723
+ action: "checkout.loyalty.rollback_reverse.error",
724
+ outcome: "failure",
725
+ metadata: {
726
+ tx_id: rollbackCtx.loyaltyDebit.redemption.tx_id,
727
+ message: (revErr && revErr.message) || String(revErr),
728
+ },
729
+ });
730
+ } catch (_auditErr) { /* drop-silent — the original checkout error is the actionable one */ }
731
+ }
688
732
  }
689
733
  }
690
734
 
@@ -748,45 +792,127 @@ function create(deps) {
748
792
  return { points: spentPoints, applied_minor: appliedMinor };
749
793
  }
750
794
 
751
- // Burn the resolved loyalty credit against the created order. The
752
- // `loyalty.redeem` decrement is the authoritative double-spend guard
753
- // (its `balance_points >= ?` SQL predicate refuses an overdraw), and
754
- // the `order_id` link records the redemption against the order in the
755
- // loyalty audit trail. Called once per order, after the order row
756
- // exists, so points are never spent for an order that failed to
757
- // create.
758
- async function _redeemLoyalty(resolved, customerId, orderId) {
759
- await loyalty.redeem({
795
+ // Debit the resolved loyalty credit same pre-charge discipline as the
796
+ // gift-card debit: the `balance_points >= ?` predicate in loyalty.redeem
797
+ // is the cross-cart double-spend gate, so it must land before any money
798
+ // moves. The burn's ledger row is written with a NULL order id and
799
+ // attached by _settleLoyaltyPostCreate once the order exists; a checkout
800
+ // that dies in between reverses via loyalty.reverseRedemptionById in the
801
+ // rollback path.
802
+ async function _debitLoyalty(resolved, customerId) {
803
+ return loyalty.redeem({
760
804
  customer_id: customerId,
761
805
  points: resolved.points,
762
- order_id: orderId,
763
- notes: "checkout-credit:" + orderId,
806
+ order_id: null,
807
+ notes: "checkout-credit",
764
808
  });
765
809
  }
766
810
 
767
- // Record each applied auto-discount rule against a committed order
768
- // so the engine's redemption counter + event log reflect the use.
769
- // Best-effort / drop-silent: the customer has already been charged
770
- // (or the order is otherwise placed) by the time this runs, so a
771
- // recording failure must NEVER fail or reverse the order. Each
772
- // record is isolated in its own try/catch so one bad row can't stop
773
- // the rest. No-op when the engine isn't wired or nothing applied.
774
- async function _recordAutoDiscounts(quote, orderId, customerId) {
775
- if (!autoDiscount || typeof autoDiscount.recordApplication !== "function") return;
811
+ // Attach a pre-charge loyalty burn to the order it tendered for. The
812
+ // order link is what restoreRedemption keys on for refund restores, so a
813
+ // link failure is audited loudly — but never thrown: the order and its
814
+ // live charge already exist and the burn is reconcilable from the
815
+ // ledger row.
816
+ async function _settleLoyaltyPostCreate(redemption, orderId) {
817
+ try {
818
+ var linked = await loyalty.linkRedemptionToOrder(redemption.tx_id, orderId);
819
+ if (!linked) throw new Error("loyalty tx " + redemption.tx_id + " did not accept the order link");
820
+ } catch (linkErr) {
821
+ try {
822
+ b.audit.safeEmit({
823
+ action: "checkout.loyalty.order_link.error",
824
+ outcome: "failure",
825
+ metadata: {
826
+ tx_id: redemption.tx_id,
827
+ order_id: orderId,
828
+ message: (linkErr && linkErr.message) || String(linkErr),
829
+ },
830
+ });
831
+ } catch (_auditErr) { /* drop-silent — the burn row is the durable trail */ }
832
+ }
833
+ }
834
+
835
+ // Reserve each applied auto-discount rule's redemption BEFORE any money
836
+ // moves — a capped rule is a finite resource, so checkout claims it
837
+ // pre-charge under the same reserve-then-settle discipline inventory
838
+ // holds follow. A refused claim (cap exhausted, or lost to a concurrent
839
+ // order — the bypass that let a single-use code apply to every racing
840
+ // checkout) FAILS CLOSED with a coded error: the buyer re-quotes and
841
+ // sees the true price, never a silently-different charge. Claims are
842
+ // stashed on the rollback holder so a later pre-order throw releases
843
+ // them; claim-ref reuse makes a same-checkout retry idempotent.
844
+ async function _claimAutoDiscounts(quote, customerId, claimRef, rollbackHolder) {
845
+ if (!autoDiscount || typeof autoDiscount.claimRedemption !== "function") return;
846
+ var applied = quote && Array.isArray(quote.auto_discounts) ? quote.auto_discounts : [];
847
+ for (var i = 0; i < applied.length; i += 1) {
848
+ var a = applied[i];
849
+ if (!a || !a.rule_slug) continue;
850
+ var claim = await autoDiscount.claimRedemption({
851
+ rule_slug: a.rule_slug,
852
+ claim_ref: claimRef,
853
+ savings_minor: a.savings_minor,
854
+ customer_id: customerId || undefined,
855
+ });
856
+ if (!claim.claimed) {
857
+ var gone = new Error("checkout: the " + JSON.stringify(a.rule_slug) +
858
+ " discount is no longer available (" + claim.reason + ")");
859
+ gone.code = "AUTO_DISCOUNT_EXHAUSTED";
860
+ throw gone;
861
+ }
862
+ if (rollbackHolder) {
863
+ if (!rollbackHolder.autoDiscountClaims) rollbackHolder.autoDiscountClaims = [];
864
+ rollbackHolder.autoDiscountClaims.push({ rule_slug: a.rule_slug, claim_ref: claimRef });
865
+ }
866
+ }
867
+ }
868
+
869
+ // Re-key the pre-charge claims to the order that now exists, so the
870
+ // redemption rows the metrics/admin surfaces read carry the real order
871
+ // id. POST-COMMIT best-effort with a loud audit — the order and its
872
+ // charge already exist; the claim row itself (and the cap it reserved)
873
+ // landed pre-charge and is correct regardless.
874
+ async function _linkAutoDiscounts(quote, claimRef, orderId) {
875
+ if (!autoDiscount || typeof autoDiscount.linkClaimToOrder !== "function") return;
776
876
  var applied = quote && Array.isArray(quote.auto_discounts) ? quote.auto_discounts : [];
777
877
  for (var i = 0; i < applied.length; i += 1) {
778
878
  var a = applied[i];
779
879
  if (!a || !a.rule_slug) continue;
780
880
  try {
781
- await autoDiscount.recordApplication({
782
- rule_slug: a.rule_slug,
783
- order_id: orderId,
784
- savings_minor: a.savings_minor,
785
- customer_id: customerId || undefined,
881
+ await autoDiscount.linkClaimToOrder({
882
+ rule_slug: a.rule_slug,
883
+ claim_ref: claimRef,
884
+ order_id: orderId,
786
885
  });
787
- } catch (_e) {
788
- // drop-silent — by design: the order is already placed; a
789
- // failed redemption record must not surface to the buyer.
886
+ } catch (linkErr) {
887
+ try {
888
+ b.audit.safeEmit({
889
+ action: "checkout.autodiscount.order_link.error",
890
+ outcome: "failure",
891
+ metadata: { rule_slug: a.rule_slug, order_id: orderId, message: (linkErr && linkErr.message) || String(linkErr) },
892
+ });
893
+ } catch (_auditErr) { /* drop-silent — the claim row is the durable trail */ }
894
+ }
895
+ }
896
+ }
897
+
898
+ // Release pre-charge discount claims when the checkout dies before its
899
+ // order exists — the reservation goes back to the cap. Audited, never
900
+ // thrown: the original checkout error is the actionable one.
901
+ async function _releaseAutoDiscountClaims(rollbackHolder) {
902
+ if (!rollbackHolder || !rollbackHolder.autoDiscountClaims) return;
903
+ if (!autoDiscount || typeof autoDiscount.releaseClaim !== "function") return;
904
+ for (var i = 0; i < rollbackHolder.autoDiscountClaims.length; i += 1) {
905
+ var cl = rollbackHolder.autoDiscountClaims[i];
906
+ try {
907
+ await autoDiscount.releaseClaim({ rule_slug: cl.rule_slug, claim_ref: cl.claim_ref });
908
+ } catch (relErr) {
909
+ try {
910
+ b.audit.safeEmit({
911
+ action: "checkout.autodiscount.claim_release.error",
912
+ outcome: "failure",
913
+ metadata: { rule_slug: cl.rule_slug, claim_ref: cl.claim_ref, message: (relErr && relErr.message) || String(relErr) },
914
+ });
915
+ } catch (_auditErr) { /* drop-silent — the original checkout error is the actionable one */ }
790
916
  }
791
917
  }
792
918
  }
@@ -1155,6 +1281,12 @@ function create(deps) {
1155
1281
  // retry on the same cart. Both releases are atomic + self-targeting,
1156
1282
  // so neither can disturb a genuinely completed checkout.
1157
1283
  if (!rollbackCtx.orderCreated) {
1284
+ // Reverse any pre-charge credit debits and discount claims first
1285
+ // — the card/points/caps were taken for an order that will now
1286
+ // never exist (both helpers are claim-guarded, audited, and
1287
+ // never throw).
1288
+ await _reverseCreditDebits(rollbackCtx);
1289
+ await _releaseAutoDiscountClaims(rollbackCtx);
1158
1290
  if (rollbackCtx.stockHolds) {
1159
1291
  try { await _releaseStockHolds(rollbackCtx.stockHolds); }
1160
1292
  catch (_holdErr) { /* drop-silent — claim release below + original error are the actionable parts */ }
@@ -1213,6 +1345,32 @@ function create(deps) {
1213
1345
  }
1214
1346
  var amountDue = afterGiftCard - (loy ? loy.applied_minor : 0);
1215
1347
 
1348
+ // Reserve the applied auto-discount redemptions FIRST (cheapest
1349
+ // claim, fails closed on an exhausted cap with a clean re-quote),
1350
+ // then the credit debits — all inside the rollback-protected region.
1351
+ await _claimAutoDiscounts(quote, loyaltyCustomerId, idempotencyKey, rollbackCtx);
1352
+
1353
+ // Authoritative credit debits — PRE-charge, inside the rollback-
1354
+ // protected region (rollbackCtx.orderCreated is still false). The SQL
1355
+ // balance gates in giftcards.redeem / loyalty.redeem are the
1356
+ // CROSS-CART double-spend guard: two checkouts presenting the same
1357
+ // card (or the same points balance) race those predicates here,
1358
+ // before any PaymentIntent or order exists, and the loser's coded
1359
+ // error propagates into confirm()'s clean rollback (holds + claim
1360
+ // released) for a re-quote. The debited rows carry no order id yet —
1361
+ // they are linked post-create, or reversed by the rollback when a
1362
+ // later step throws before the order exists.
1363
+ var gcRedemption = null;
1364
+ if (gc) {
1365
+ gcRedemption = await _debitGiftCard(gc);
1366
+ if (rollbackCtx) rollbackCtx.giftCardDebit = { resolved: gc, redemption: gcRedemption };
1367
+ }
1368
+ var loyRedemption = null;
1369
+ if (loy) {
1370
+ loyRedemption = await _debitLoyalty(loy, loyaltyCustomerId);
1371
+ if (rollbackCtx) rollbackCtx.loyaltyDebit = { redemption: loyRedemption };
1372
+ }
1373
+
1216
1374
  var orderLines = quote.lines.map(function (l) {
1217
1375
  return {
1218
1376
  variant_id: l.variant_id,
@@ -1256,20 +1414,17 @@ function create(deps) {
1256
1414
  // The pending order now owns the holds — a throw from here on must
1257
1415
  // NOT blanket-release them (see the conditional rollback in confirm).
1258
1416
  if (rollbackCtx) rollbackCtx.orderCreated = true;
1259
- // Same settlement posture as the PaymentIntent branch below: the
1260
- // order exists and the credits priced it, so a redeem failure here
1261
- // must not strand the cart un-converted it is captured and
1262
- // reconciled, never propagated.
1263
- await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
1264
- return gc ? _redeemGiftCard(gc, paidOrder.id) : null;
1265
- });
1266
- await _settleCreditPostCreate("loyalty", paidOrder.id, function () {
1267
- return loy ? _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id) : null;
1268
- });
1269
- // Best-effort: record the auto-discount redemptions against the
1270
- // placed order. Runs post-commit so a recording failure can't
1271
- // reverse an order that's already paid.
1272
- await _recordAutoDiscounts(quote, paidOrder.id, cartRow.customer_id || null);
1417
+ // The credits were already debited pre-charge above (the debit IS
1418
+ // the double-spend gate); what remains is attaching the debit rows
1419
+ // to the order that now exists + the ledger audit row. Both are
1420
+ // best-effort with loud audits — a failure here must not strand
1421
+ // the cart un-converted.
1422
+ if (gc) await _settleGiftCardPostCreate(gc, gcRedemption, paidOrder.id);
1423
+ if (loy) await _settleLoyaltyPostCreate(loyRedemption, paidOrder.id);
1424
+ // Re-key the pre-charge discount claims to the placed order. Runs
1425
+ // post-commit so a link failure can't reverse an order that's
1426
+ // already paid (the cap reservation itself landed pre-charge).
1427
+ await _linkAutoDiscounts(quote, idempotencyKey, paidOrder.id);
1273
1428
  // Best-effort: record how the cart-level discount split across the
1274
1429
  // order lines (accounting + refund precision). Post-commit +
1275
1430
  // drop-silent — no impact on the already-settled order.
@@ -1327,25 +1482,21 @@ function create(deps) {
1327
1482
  // settles them now that the order exists.
1328
1483
  if (rollbackCtx) rollbackCtx.orderCreated = true;
1329
1484
 
1330
- // Burn the gift-card + loyalty credits against the created order.
1331
- // POST-COMMIT: the order row + its PaymentIntent already exist, so a
1332
- // redeem failure must NOT throw out of confirm that would leave the
1333
- // cart un-converted (still `active`) while a live, charge-able
1334
- // PaymentIntent and order sit orphaned, and the conditional rollback
1335
- // above no longer fires (orderCreated is set). Each redeem is captured
1336
- // and reconciled instead (see _settleCreditPostCreate): the order is
1337
- // valid and the buyer pays the credit-reduced amount; a failed debit is
1338
- // surfaced for the operator to reconcile, never a stranded cart.
1339
- await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
1340
- return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
1341
- });
1342
- await _settleCreditPostCreate("loyalty", createdOrder.id, function () {
1343
- return loy ? _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id) : null;
1344
- });
1345
- // Best-effort: record the auto-discount redemptions against the
1346
- // placed order. Runs post-commit so a recording failure can't
1347
- // reverse or fail an order whose PaymentIntent already exists.
1348
- await _recordAutoDiscounts(quote, createdOrder.id, cartRow.customer_id || null);
1485
+ // Attach the pre-charge credit debits to the created order. The
1486
+ // authoritative card/points debits already landed BEFORE the
1487
+ // PaymentIntent was opened (the debit is the double-spend gate); the
1488
+ // remaining writes the order link the refund reversers key on, and
1489
+ // the gift-card ledger audit row are POST-COMMIT best-effort with
1490
+ // loud audits, because the order + its live PaymentIntent now exist
1491
+ // and a throw from here would leave the cart un-converted with an
1492
+ // orphaned charge (the conditional rollback above no longer fires
1493
+ // orderCreated is set).
1494
+ if (gc) await _settleGiftCardPostCreate(gc, gcRedemption, createdOrder.id);
1495
+ if (loy) await _settleLoyaltyPostCreate(loyRedemption, createdOrder.id);
1496
+ // Re-key the pre-charge discount claims to the placed order. Runs
1497
+ // post-commit so a link failure can't reverse or fail an order whose
1498
+ // PaymentIntent already exists (the cap reservation landed pre-charge).
1499
+ await _linkAutoDiscounts(quote, idempotencyKey, createdOrder.id);
1349
1500
  // Best-effort: record how the cart-level discount split across the
1350
1501
  // order lines (accounting + refund precision). Post-commit +
1351
1502
  // drop-silent — the PaymentIntent already exists; this never
@@ -1582,6 +1733,7 @@ function create(deps) {
1582
1733
  // approves is reaped by the stale-pending reaper (no PayPal-side
1583
1734
  // cancel needed — the capture path guards on local status).
1584
1735
  var ppOrderCreated = false;
1736
+ var ppRollback = {};
1585
1737
  try {
1586
1738
  // Resolve an optional gift-card credit before opening the PayPal
1587
1739
  // order so a bad code fails without a remote round-trip.
@@ -1602,6 +1754,26 @@ function create(deps) {
1602
1754
  if (loy.applied_minor <= 0) loy = null;
1603
1755
  }
1604
1756
  var amountDue = afterGiftCard - (loy ? loy.applied_minor : 0);
1757
+
1758
+ // Reserve the applied auto-discount redemptions, then the credit
1759
+ // debits — PRE-charge, same cross-cart double-spend discipline as
1760
+ // the Stripe confirm (see _confirmAfterHolds): the SQL gates race
1761
+ // here, before the PayPal order or the local order exist, and the
1762
+ // loser's coded error rolls this checkout back cleanly. Linked
1763
+ // post-create; reversed by the catch below when a later step throws
1764
+ // before the order exists.
1765
+ await _claimAutoDiscounts(quote, ppLoyaltyCustomerId, idempotencyKey, ppRollback);
1766
+ var gcRedemption = null;
1767
+ if (gc) {
1768
+ gcRedemption = await _debitGiftCard(gc);
1769
+ ppRollback.giftCardDebit = { resolved: gc, redemption: gcRedemption };
1770
+ }
1771
+ var loyRedemption = null;
1772
+ if (loy) {
1773
+ loyRedemption = await _debitLoyalty(loy, ppLoyaltyCustomerId);
1774
+ ppRollback.loyaltyDebit = { redemption: loyRedemption };
1775
+ }
1776
+
1605
1777
  var emailHash = customers ? customers.hashEmail(email) : null;
1606
1778
  var ppLines = quote.lines.map(function (l) {
1607
1779
  return { variant_id: l.variant_id, sku: l.sku, qty: l.qty, unit_amount_minor: l.unit_amount_minor, unit_currency: l.unit_currency, stock_held_qty: l._held_qty || 0 };
@@ -1629,15 +1801,12 @@ function create(deps) {
1629
1801
  lines: ppLines,
1630
1802
  });
1631
1803
  ppOrderCreated = true;
1632
- // Same settlement posture as the Stripe branches: the order exists,
1633
- // so a redeem failure is captured and reconciled, never allowed to
1634
- // strand the cart un-converted.
1635
- await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
1636
- return gc ? _redeemGiftCard(gc, paidOrder.id) : null;
1637
- });
1638
- await _settleCreditPostCreate("loyalty", paidOrder.id, function () {
1639
- return loy ? _redeemLoyalty(loy, ppLoyaltyCustomerId, paidOrder.id) : null;
1640
- });
1804
+ // Same settlement posture as the Stripe branches: the authoritative
1805
+ // debits + claims landed pre-charge; attach them to the order that
1806
+ // now exists (best-effort, loudly audited — never stranding).
1807
+ if (gc) await _settleGiftCardPostCreate(gc, gcRedemption, paidOrder.id);
1808
+ if (loy) await _settleLoyaltyPostCreate(loyRedemption, paidOrder.id);
1809
+ await _linkAutoDiscounts(quote, idempotencyKey, paidOrder.id);
1641
1810
  await cart.setStatus(quote.cart_id, "converted");
1642
1811
  var settled = await order.transition(paidOrder.id, "mark_paid", {
1643
1812
  reason: loy && !gc ? "loyalty:full" : "gift_card:full",
@@ -1673,18 +1842,14 @@ function create(deps) {
1673
1842
  lines: ppLines,
1674
1843
  });
1675
1844
  ppOrderCreated = true;
1676
- // Post-commit gift-card + loyalty burn captured, never stranding.
1677
- // The order row + the PayPal order already exist; a redeem throw must
1678
- // not bubble to the outer catch (which re-throws and would leave the
1679
- // cart active with an orphaned PayPal order). The buyer pays the
1680
- // credit-reduced PayPal amount; a failed debit is surfaced for
1681
- // reconciliation.
1682
- await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
1683
- return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
1684
- });
1685
- await _settleCreditPostCreate("loyalty", createdOrder.id, function () {
1686
- return loy ? _redeemLoyalty(loy, ppLoyaltyCustomerId, createdOrder.id) : null;
1687
- });
1845
+ // Attach the pre-charge debits + claims to the created order — the
1846
+ // authoritative card/points debits and cap reservations landed before
1847
+ // the PayPal order was opened; the order links + ledger row are
1848
+ // post-commit best-effort with loud audits (a throw here would leave
1849
+ // the cart active with an orphaned PayPal order).
1850
+ if (gc) await _settleGiftCardPostCreate(gc, gcRedemption, createdOrder.id);
1851
+ if (loy) await _settleLoyaltyPostCreate(loyRedemption, createdOrder.id);
1852
+ await _linkAutoDiscounts(quote, idempotencyKey, createdOrder.id);
1688
1853
  await cart.setStatus(quote.cart_id, "converted");
1689
1854
  return {
1690
1855
  order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status,
@@ -1693,9 +1858,15 @@ function create(deps) {
1693
1858
  };
1694
1859
  } catch (e) {
1695
1860
  // A throw BEFORE the order row commits (PayPal open failure,
1696
- // gift-card error) releases the holds so PayPal can't strand stock.
1697
- // After the order commits it owns the holds don't blanket-release.
1698
- if (!ppOrderCreated) await _releaseStockHolds(ppHolds);
1861
+ // gift-card error) reverses any pre-charge credit debits and
1862
+ // discount claims and releases the holds so PayPal can't strand
1863
+ // stock, burn credit, or leak a cap reservation. After the order
1864
+ // commits it owns the holds — don't blanket-release.
1865
+ if (!ppOrderCreated) {
1866
+ await _reverseCreditDebits(ppRollback);
1867
+ await _releaseAutoDiscountClaims(ppRollback);
1868
+ await _releaseStockHolds(ppHolds);
1869
+ }
1699
1870
  throw e;
1700
1871
  }
1701
1872
  },
@@ -12,7 +12,9 @@
12
12
  * readers (customers, order, order-notes, subscriptions,
13
13
  * addresses, payment-methods, support-tickets, loyalty, reviews,
14
14
  * consent ledger, wishlist, surveys, recently-viewed, suggestion
15
- * box, save-for-later, store credit) to assemble the bundle —
15
+ * box, save-for-later, store credit, guest-order reconciliations,
16
+ * stock alerts, quotes, order ratings, product Q&A, customer
17
+ * notes, gift cards, referrals) to assemble the bundle —
16
18
  * every table that keys a row by the customer, so the export holds
17
19
  * the whole record, not just the order/identity core.
18
20
  * Delivery (email / signed URL / secure
@@ -85,7 +87,10 @@
85
87
  * methods, support tickets, loyalty, reviews,
86
88
  * consent ledger, wishlist, surveys,
87
89
  * recently-viewed, suggestion box,
88
- * save-for-later, store credit).
90
+ * save-for-later, store credit, guest-order
91
+ * reconciliations, stock alerts, quotes,
92
+ * order ratings, product Q&A, customer
93
+ * notes, gift cards, referrals).
89
94
  * - `orders_only` — only `order` + `orderNotes` contribute;
90
95
  * identity / loyalty / subscriptions /
91
96
  * addresses / payment methods / support
@@ -103,7 +108,9 @@
103
108
  * @related customers, order, orderNotes, subscriptions, addresses,
104
109
  * paymentMethods, supportTickets, loyalty, reviews,
105
110
  * consentLedger, wishlist, customerSurveys, recentlyViewed,
106
- * suggestionBox, saveForLater, storeCredit, orderExport
111
+ * suggestionBox, saveForLater, storeCredit, stockAlerts,
112
+ * quotes, orderRatings, productQA, customerNotes,
113
+ * giftcards, referrals, orderExport
107
114
  */
108
115
 
109
116
  var b = require("./vendor/blamejs");
@@ -195,6 +202,14 @@ var SCOPE_SECTIONS = Object.freeze({
195
202
  // credit wallet ledger — each keys a row by the customer, so a
196
203
  // subject-access export holds them too.
197
204
  "suggestionBox", "saveForLater", "storeCredit",
205
+ // The remaining customer-keyed domains: the guest-order
206
+ // reconciliation audit trail, back-in-stock alert subscriptions
207
+ // (which hold a plaintext email), RFQ quotes, post-fulfillment
208
+ // ratings, product Q&A questions, operator CRM notes about the
209
+ // customer, gift cards issued to them, and referral activity in
210
+ // both directions (as referrer and as referred friend).
211
+ "guestOrderReconciliations", "stockAlerts", "quotes", "orderRatings",
212
+ "productQa", "customerNotes", "giftcards", "referrals",
198
213
  ]),
199
214
  orders_only: Object.freeze(["order", "orderNotes"]),
200
215
  identity_only: Object.freeze(["customers", "addresses"]),
@@ -371,6 +386,14 @@ function create(opts) {
371
386
  suggestionBox: opts.suggestionBox || null,
372
387
  saveForLater: opts.saveForLater || null,
373
388
  storeCredit: opts.storeCredit || null,
389
+ guestOrderReconciliations: opts.guestOrderReconciliations || null,
390
+ stockAlerts: opts.stockAlerts || null,
391
+ quotes: opts.quotes || null,
392
+ orderRatings: opts.orderRatings || null,
393
+ productQa: opts.productQa || null,
394
+ customerNotes: opts.customerNotes || null,
395
+ giftcards: opts.giftcards || null,
396
+ referrals: opts.referrals || null,
374
397
  };
375
398
 
376
399
  // ---- requestExport -------------------------------------------------
@@ -619,10 +642,12 @@ function create(opts) {
619
642
  }
620
643
 
621
644
  var domainOrder = [
622
- "recentlyViewed", "wishlist", "saveForLater", "suggestionBox",
623
- "surveys", "reviews", "consentLedger",
624
- "supportTickets", "orderNotes", "order", "subscriptions",
625
- "paymentMethods", "loyalty", "storeCredit", "addresses", "customers",
645
+ "recentlyViewed", "wishlist", "saveForLater", "stockAlerts",
646
+ "suggestionBox", "surveys", "reviews", "orderRatings", "productQa",
647
+ "quotes", "customerNotes", "consentLedger",
648
+ "supportTickets", "orderNotes", "order", "guestOrderReconciliations",
649
+ "subscriptions", "paymentMethods", "loyalty", "storeCredit",
650
+ "giftcards", "referrals", "addresses", "customers",
626
651
  ];
627
652
  var perDomain = [];
628
653
  var domainsAbsent = [];