@blamejs/blamejs-shop 0.4.27 → 0.4.29

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
@@ -36,6 +36,10 @@
36
36
  */
37
37
 
38
38
  var b = require("./vendor/blamejs");
39
+ // Shared decimal↔minor conversion (zero-decimal-currency aware) — the
40
+ // PAYMENT.CAPTURE.REFUNDED mirror parses the webhook's decimal amount with
41
+ // the same table the adapter encodes outbound amounts from.
42
+ var paymentLib = require("./payment");
39
43
 
40
44
  function _uuid(s, label) {
41
45
  try { return b.guardUuid.sanitize(s, { profile: "strict" }); }
@@ -262,6 +266,14 @@ function create(deps) {
262
266
  var webhookReplayQuery = (typeof deps.webhookReplayQuery === "function")
263
267
  ? deps.webhookReplayQuery : null;
264
268
  var STRIPE_REPLAY_TTL_MS = b.constants.TIME.minutes(5); // matches the signature tolerance window
269
+ // PayPal claims live in the same store but keep a much longer window:
270
+ // PayPal's verify-webhook-signature API has no timestamp tolerance of ours
271
+ // to lean on, and PayPal redelivers an unacknowledged event for ~3 days
272
+ // (up to ~25 attempts) — a claim that expired before the last legitimate
273
+ // redelivery would let a captured payload re-apply. Claimed ids are
274
+ // namespaced ("paypal:<event-id>") so the two providers can never collide
275
+ // in the shared table.
276
+ var PAYPAL_REPLAY_TTL_MS = b.constants.TIME.days(3);
265
277
  var _stripeReplayStore = null;
266
278
  function _stripeReplay() {
267
279
  if (!webhookReplayQuery) return null;
@@ -300,6 +312,147 @@ function create(deps) {
300
312
  return _stripeReplayStore;
301
313
  }
302
314
 
315
+ // Has a provider refund with this id already been mirrored into the
316
+ // order's ledger? Scans the hydrated transition rows for a `refund` row
317
+ // whose metadata carries the same provider refund id. This is what makes
318
+ // the webhook refund mirror idempotent across BOTH paths a refund reaches
319
+ // us twice: the admin console issues the refund (stamping the provider
320
+ // refund id on its ledger row) and the provider then mirrors the same
321
+ // refund back as a webhook; or the provider redelivers the same event
322
+ // under a fresh delivery attempt after the replay claim's TTL.
323
+ function _refundAlreadyRecorded(o, metaKey, refundId) {
324
+ if (!refundId) return false;
325
+ var rows = (o && o.transitions) || [];
326
+ for (var i = 0; i < rows.length; i += 1) {
327
+ if (rows[i].on_event !== "refund") continue;
328
+ var meta;
329
+ try { meta = JSON.parse(rows[i].metadata_json || "{}"); }
330
+ catch (_e) { meta = {}; }
331
+ if (meta && meta[metaKey] === refundId) return true;
332
+ }
333
+ return false;
334
+ }
335
+
336
+ // Mirror a PayPal PAYMENT.CAPTURE.REFUNDED event into the order ledger,
337
+ // AMOUNT-AWARE. The resource is the refund object itself: its `amount` is
338
+ // that single refund's value (NOT a cumulative figure), so a $5 dashboard
339
+ // refund on a $50 order must append a $5 partial-refund row — never drive
340
+ // the terminal refund edge, which re-credits every gift-card/loyalty
341
+ // credit on the order. Only a slice that clears the remaining balance is
342
+ // terminal. A missing/garbled/currency-mismatched amount THROWS a coded
343
+ // error the webhook route maps to a 5xx so PayPal redelivers — guessing
344
+ // "full refund" here is customer-influenceable value creation.
345
+ async function _mirrorPaypalRefund(o, event, ppOrderId) {
346
+ var eventType = event.event_type;
347
+ var resource = event.resource || {};
348
+ var refundId = (typeof resource.id === "string" && resource.id.length) ? resource.id : null;
349
+ if (o.status === "refunded") {
350
+ return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
351
+ }
352
+ if (_refundAlreadyRecorded(o, "paypal_refund_id", refundId)) {
353
+ return { handled: true, event_type: eventType, order: o, skipped: "already-recorded", paypal_refund_id: refundId };
354
+ }
355
+ var amount = resource.amount || {};
356
+ var amountMinor;
357
+ try {
358
+ var ccy = typeof amount.currency_code === "string" ? amount.currency_code.toUpperCase() : "";
359
+ if (ccy !== String(o.currency || "").toUpperCase()) {
360
+ throw new TypeError("refund currency " + JSON.stringify(amount.currency_code) +
361
+ " does not match order currency " + JSON.stringify(o.currency));
362
+ }
363
+ amountMinor = paymentLib._decimalToMinor(amount.value, ccy);
364
+ } catch (e) {
365
+ var bad = new Error("checkout: PAYMENT.CAPTURE.REFUNDED resource.amount is missing or unparseable — " +
366
+ ((e && e.message) || String(e)));
367
+ bad.code = "PAYPAL_REFUND_AMOUNT_INVALID";
368
+ throw bad;
369
+ }
370
+ if (amountMinor <= 0) {
371
+ var zero = new Error("checkout: PAYMENT.CAPTURE.REFUNDED resource.amount must be positive");
372
+ zero.code = "PAYPAL_REFUND_AMOUNT_INVALID";
373
+ throw zero;
374
+ }
375
+ var refundedSoFar = await order.refundedTotalMinor(o.id);
376
+ var grand = Number(o.grand_total_minor) || 0;
377
+ var remaining = grand - refundedSoFar;
378
+ if (remaining <= 0) {
379
+ return { handled: true, event_type: eventType, order: o, skipped: "nothing-remaining" };
380
+ }
381
+ var meta = { paypal_event_id: event.id, paypal_order_id: ppOrderId, paypal_refund_id: refundId };
382
+ if (amountMinor >= remaining) {
383
+ // Balance-clearing — drive the terminal refund edge (full credit
384
+ // reversal). The stamped amount is CAPPED at the remaining balance so
385
+ // refundedTotalMinor converges exactly on the grand total.
386
+ var updated = await order.transition(o.id, "refund", {
387
+ reason: "paypal:" + eventType,
388
+ metadata: Object.assign({}, meta, { amount_minor: remaining }),
389
+ });
390
+ return { handled: true, event_type: eventType, order: updated, amount_minor: remaining };
391
+ }
392
+ // Partial — append the same-state ledger row; recordPartialRefund runs
393
+ // the proportional gift-card / loyalty reversal against the cumulative
394
+ // refunded total.
395
+ var updatedPartial = await order.recordPartialRefund(o.id, {
396
+ amount_minor: amountMinor,
397
+ reason: "paypal:" + eventType,
398
+ metadata: meta,
399
+ });
400
+ return { handled: true, event_type: eventType, order: updatedPartial, partial: true, amount_minor: amountMinor };
401
+ }
402
+
403
+ // Mirror a Stripe charge.refunded event into the order ledger,
404
+ // AMOUNT-AWARE — same discipline as the PayPal mirror above, adapted to
405
+ // Stripe's shape: charge.refunded fires on EVERY refund (partial
406
+ // included); `amount_refunded` is the CUMULATIVE minor-unit total refunded
407
+ // on the charge and `refunded` is true only when the charge is fully
408
+ // refunded. The mirrored slice is the DELTA between Stripe's cumulative
409
+ // figure and the local ledger, which makes the mirror naturally
410
+ // idempotent against the console's own refunds (the console records
411
+ // first; the event's delta is then zero). A missing/garbled
412
+ // amount_refunded throws (5xx → Stripe redelivers) — never guess full.
413
+ async function _mirrorStripeRefund(o, event) {
414
+ var eventType = event.type;
415
+ if (o.status === "refunded") {
416
+ return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
417
+ }
418
+ var charge = (event.data && event.data.object) || {};
419
+ var cumulative = charge.amount_refunded;
420
+ if (!Number.isInteger(cumulative) || cumulative < 0) {
421
+ var bad = new Error("checkout: charge.refunded carries no integer amount_refunded — refusing to guess a refund amount");
422
+ bad.code = "STRIPE_REFUND_AMOUNT_INVALID";
423
+ throw bad;
424
+ }
425
+ var refundedSoFar = await order.refundedTotalMinor(o.id);
426
+ var grand = Number(o.grand_total_minor) || 0;
427
+ var remaining = grand - refundedSoFar;
428
+ var delta = cumulative - refundedSoFar;
429
+ var meta = { stripe_event_id: event.id };
430
+ if (charge.refunded === true || cumulative >= grand) {
431
+ // Charge fully refunded — terminal edge. (On a split-tender order the
432
+ // charge covers only the provider-paid share; a full charge refund
433
+ // still voids the order, and the terminal edge re-credits the
434
+ // gift-card / loyalty share — same semantics the admin full-refund
435
+ // console applies.) Stamp the capped remaining balance so the ledger
436
+ // converges on the grand total.
437
+ var updated = await order.transition(o.id, "refund", {
438
+ reason: "stripe:" + eventType,
439
+ metadata: remaining > 0 ? Object.assign({}, meta, { amount_minor: remaining }) : meta,
440
+ });
441
+ return { handled: true, event_type: eventType, order: updated };
442
+ }
443
+ if (delta <= 0) {
444
+ // Ledger already at (or past) Stripe's cumulative figure — the
445
+ // console mirrored this refund when it issued it.
446
+ return { handled: true, event_type: eventType, order: o, skipped: "already-recorded" };
447
+ }
448
+ var updatedPartial = await order.recordPartialRefund(o.id, {
449
+ amount_minor: delta,
450
+ reason: "stripe:" + eventType,
451
+ metadata: meta,
452
+ });
453
+ return { handled: true, event_type: eventType, order: updatedPartial, partial: true, amount_minor: delta };
454
+ }
455
+
303
456
  // Reprice a list of cart lines through the quantity-discount engine.
304
457
  // Returns a shallow copy with `unit_amount_minor` overwritten by the
305
458
  // discounted unit for each line's SKU at its quantity. A line whose
@@ -453,35 +606,60 @@ function create(deps) {
453
606
  // card; a card worth less reduces the amount due, never below 0.
454
607
  var grand = quote.totals.grand_total_minor;
455
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;
456
613
  // `code_plain` is the bearer code the redeem decrement re-hashes;
457
614
  // it lives only in this in-memory resolution, never persisted.
458
615
  return { card: card, code_plain: code, applied_minor: applied, balance_minor: card.balance_minor };
459
616
  }
460
617
 
461
- // Burn the resolved credit against the created order. The
462
- // `giftcards.redeem` decrement is the authoritative double-spend
463
- // guard its `balance_minor >= ?` SQL predicate refuses a second
464
- // spend that would overdraw, and a re-quote of the same card on a
465
- // new order only ever debits the remaining balance. The ledger
466
- // debit rides alongside as the operator-facing audit row, keyed on
467
- // the order id. Called once per order, immediately after the order
468
- // row exists, so a card is never burned for an order that failed to
469
- // create.
470
- //
471
- // The ledger debit is BEST-EFFORT: the card row's balance (decremented by
472
- // redeem above) is the authoritative spendable balance, and the ledger is
473
- // the audit trail. A ledger write failure (e.g. an unmigrated ledger table,
474
- // or a ledger view that lags the card row) must not throw out of the
475
- // post-create redeem path and strand the already-created order + its live
476
- // PaymentIntent. The card-row debit already happened; the ledger row is
477
- // reconcilable from the redemption record. Same posture the admin issue
478
- // route takes when seeding the opening credit.
479
- async function _redeemGiftCard(resolved, orderId) {
480
- 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({
481
631
  code: resolved.code_plain,
482
- order_id: orderId,
632
+ order_id: null,
483
633
  amount_minor: resolved.applied_minor,
484
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
+ }
485
663
  if (giftCardLedger) {
486
664
  try {
487
665
  await giftCardLedger.debit({
@@ -506,32 +684,51 @@ function create(deps) {
506
684
  } catch (_auditErr) { /* drop-silent — the redemption record is the durable trail */ }
507
685
  }
508
686
  }
509
- return redemption;
510
687
  }
511
688
 
512
- // Run a post-create credit redemption (gift-card / loyalty burn) so a
513
- // failure can NEVER strand the already-created order + its live
514
- // PaymentIntent. The order row and the PaymentIntent exist by the time
515
- // this runs, and the buyer will pay the credit-reduced amount so a
516
- // throw out of the redeem (a card that raced to zero, a transient DB
517
- // error) must not bubble up and leave the cart un-converted with an
518
- // orphaned charge. The failure is captured to the audit sink for operator
519
- // reconciliation (the credit reduced the charge but the debit didn't
520
- // land, so the operator settles the card / points by hand) and the flow
521
- // continues. `fn` returns the redeem promise (or null when there's no
522
- // credit of that kind to burn).
523
- async function _settleCreditPostCreate(kind, orderId, fn) {
524
- try {
525
- return await fn();
526
- } 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) {
527
701
  try {
528
- b.audit.safeEmit({
529
- action: "checkout." + kind + ".redeem.error",
530
- outcome: "failure",
531
- metadata: { order_id: orderId, message: (e && e.message) || String(e) },
532
- });
533
- } catch (_auditErr) { /* drop-silent — never let the audit write mask the original failure */ }
534
- 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
+ }
535
732
  }
536
733
  }
537
734
 
@@ -595,45 +792,127 @@ function create(deps) {
595
792
  return { points: spentPoints, applied_minor: appliedMinor };
596
793
  }
597
794
 
598
- // Burn the resolved loyalty credit against the created order. The
599
- // `loyalty.redeem` decrement is the authoritative double-spend guard
600
- // (its `balance_points >= ?` SQL predicate refuses an overdraw), and
601
- // the `order_id` link records the redemption against the order in the
602
- // loyalty audit trail. Called once per order, after the order row
603
- // exists, so points are never spent for an order that failed to
604
- // create.
605
- async function _redeemLoyalty(resolved, customerId, orderId) {
606
- 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({
607
804
  customer_id: customerId,
608
805
  points: resolved.points,
609
- order_id: orderId,
610
- notes: "checkout-credit:" + orderId,
806
+ order_id: null,
807
+ notes: "checkout-credit",
611
808
  });
612
809
  }
613
810
 
614
- // Record each applied auto-discount rule against a committed order
615
- // so the engine's redemption counter + event log reflect the use.
616
- // Best-effort / drop-silent: the customer has already been charged
617
- // (or the order is otherwise placed) by the time this runs, so a
618
- // recording failure must NEVER fail or reverse the order. Each
619
- // record is isolated in its own try/catch so one bad row can't stop
620
- // the rest. No-op when the engine isn't wired or nothing applied.
621
- async function _recordAutoDiscounts(quote, orderId, customerId) {
622
- 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;
623
876
  var applied = quote && Array.isArray(quote.auto_discounts) ? quote.auto_discounts : [];
624
877
  for (var i = 0; i < applied.length; i += 1) {
625
878
  var a = applied[i];
626
879
  if (!a || !a.rule_slug) continue;
627
880
  try {
628
- await autoDiscount.recordApplication({
629
- rule_slug: a.rule_slug,
630
- order_id: orderId,
631
- savings_minor: a.savings_minor,
632
- customer_id: customerId || undefined,
881
+ await autoDiscount.linkClaimToOrder({
882
+ rule_slug: a.rule_slug,
883
+ claim_ref: claimRef,
884
+ order_id: orderId,
633
885
  });
634
- } catch (_e) {
635
- // drop-silent — by design: the order is already placed; a
636
- // 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 */ }
637
916
  }
638
917
  }
639
918
  }
@@ -1002,6 +1281,12 @@ function create(deps) {
1002
1281
  // retry on the same cart. Both releases are atomic + self-targeting,
1003
1282
  // so neither can disturb a genuinely completed checkout.
1004
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);
1005
1290
  if (rollbackCtx.stockHolds) {
1006
1291
  try { await _releaseStockHolds(rollbackCtx.stockHolds); }
1007
1292
  catch (_holdErr) { /* drop-silent — claim release below + original error are the actionable parts */ }
@@ -1060,6 +1345,32 @@ function create(deps) {
1060
1345
  }
1061
1346
  var amountDue = afterGiftCard - (loy ? loy.applied_minor : 0);
1062
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
+
1063
1374
  var orderLines = quote.lines.map(function (l) {
1064
1375
  return {
1065
1376
  variant_id: l.variant_id,
@@ -1095,6 +1406,7 @@ function create(deps) {
1095
1406
  shipping_minor: quote.totals.shipping_minor,
1096
1407
  grand_total_minor: quote.totals.grand_total_minor,
1097
1408
  payment_intent_id: null,
1409
+ payment_provider: null, // credits covered the whole total — no provider charge to refund
1098
1410
  ship_to: input.ship_to,
1099
1411
  customer_email_hash: emailHash,
1100
1412
  lines: orderLines,
@@ -1102,20 +1414,17 @@ function create(deps) {
1102
1414
  // The pending order now owns the holds — a throw from here on must
1103
1415
  // NOT blanket-release them (see the conditional rollback in confirm).
1104
1416
  if (rollbackCtx) rollbackCtx.orderCreated = true;
1105
- // Same settlement posture as the PaymentIntent branch below: the
1106
- // order exists and the credits priced it, so a redeem failure here
1107
- // must not strand the cart un-converted it is captured and
1108
- // reconciled, never propagated.
1109
- await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
1110
- return gc ? _redeemGiftCard(gc, paidOrder.id) : null;
1111
- });
1112
- await _settleCreditPostCreate("loyalty", paidOrder.id, function () {
1113
- return loy ? _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id) : null;
1114
- });
1115
- // Best-effort: record the auto-discount redemptions against the
1116
- // placed order. Runs post-commit so a recording failure can't
1117
- // reverse an order that's already paid.
1118
- 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);
1119
1428
  // Best-effort: record how the cart-level discount split across the
1120
1429
  // order lines (accounting + refund precision). Post-commit +
1121
1430
  // drop-silent — no impact on the already-settled order.
@@ -1162,6 +1471,7 @@ function create(deps) {
1162
1471
  shipping_minor: quote.totals.shipping_minor,
1163
1472
  grand_total_minor: quote.totals.grand_total_minor,
1164
1473
  payment_intent_id: pi.id,
1474
+ payment_provider: "stripe", // refund surfaces route the refund dial by this
1165
1475
  ship_to: input.ship_to,
1166
1476
  customer_email_hash: emailHash,
1167
1477
  lines: orderLines,
@@ -1172,25 +1482,21 @@ function create(deps) {
1172
1482
  // settles them now that the order exists.
1173
1483
  if (rollbackCtx) rollbackCtx.orderCreated = true;
1174
1484
 
1175
- // Burn the gift-card + loyalty credits against the created order.
1176
- // POST-COMMIT: the order row + its PaymentIntent already exist, so a
1177
- // redeem failure must NOT throw out of confirm that would leave the
1178
- // cart un-converted (still `active`) while a live, charge-able
1179
- // PaymentIntent and order sit orphaned, and the conditional rollback
1180
- // above no longer fires (orderCreated is set). Each redeem is captured
1181
- // and reconciled instead (see _settleCreditPostCreate): the order is
1182
- // valid and the buyer pays the credit-reduced amount; a failed debit is
1183
- // surfaced for the operator to reconcile, never a stranded cart.
1184
- await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
1185
- return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
1186
- });
1187
- await _settleCreditPostCreate("loyalty", createdOrder.id, function () {
1188
- return loy ? _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id) : null;
1189
- });
1190
- // Best-effort: record the auto-discount redemptions against the
1191
- // placed order. Runs post-commit so a recording failure can't
1192
- // reverse or fail an order whose PaymentIntent already exists.
1193
- 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);
1194
1500
  // Best-effort: record how the cart-level discount split across the
1195
1501
  // order lines (accounting + refund precision). Post-commit +
1196
1502
  // drop-silent — the PaymentIntent already exists; this never
@@ -1358,6 +1664,13 @@ function create(deps) {
1358
1664
  var o = await order.byPaymentIntent(pi);
1359
1665
  if (!o) return { handled: false, event_type: eventType, reason: "order-not-found" };
1360
1666
 
1667
+ // Refund events are AMOUNT-AWARE — a partial dashboard refund must
1668
+ // append a partial ledger row, never drive the terminal refund edge
1669
+ // (which re-credits every gift-card/loyalty credit on the order).
1670
+ if (eventType === "charge.refunded") {
1671
+ return await _mirrorStripeRefund(o, event);
1672
+ }
1673
+
1361
1674
  // Idempotency: if the order is already in a state the event
1362
1675
  // would advance to, skip the transition (re-deliveries from
1363
1676
  // Stripe are common).
@@ -1420,20 +1733,56 @@ function create(deps) {
1420
1733
  // approves is reaped by the stale-pending reaper (no PayPal-side
1421
1734
  // cancel needed — the capture path guards on local status).
1422
1735
  var ppOrderCreated = false;
1736
+ var ppRollback = {};
1423
1737
  try {
1424
1738
  // Resolve an optional gift-card credit before opening the PayPal
1425
1739
  // order so a bad code fails without a remote round-trip.
1426
1740
  var gc = await _resolveGiftCard(input.gift_card_code, quote);
1427
- var amountDue = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
1428
1741
  var cartRow = await cart.get(quote.cart_id);
1742
+ // Loyalty points credit stacks on top of any gift-card credit — same
1743
+ // resolution + residual re-cap discipline as the Stripe confirm path
1744
+ // (_confirmAfterHolds), so the two payment buttons honor identical
1745
+ // credits. Requires a signed-in customer; the resolver refuses a
1746
+ // points request on a guest cart with a clean coded error.
1747
+ var ppLoyaltyCustomerId = cartRow ? (cartRow.customer_id || null) : null;
1748
+ var loy = await _resolveLoyaltyCredit(input.loyalty_redeem_points, ppLoyaltyCustomerId, quote);
1749
+ var afterGiftCard = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
1750
+ if (loy && loy.applied_minor > afterGiftCard) {
1751
+ loy.applied_minor = afterGiftCard < 0 ? 0 : afterGiftCard;
1752
+ var ppPerUsd = loyalty.REDEMPTION_POINTS_PER_USD;
1753
+ loy.points = Math.ceil((loy.applied_minor * ppPerUsd) / 100);
1754
+ if (loy.applied_minor <= 0) loy = null;
1755
+ }
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
+
1429
1777
  var emailHash = customers ? customers.hashEmail(email) : null;
1430
1778
  var ppLines = quote.lines.map(function (l) {
1431
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 };
1432
1780
  });
1433
1781
 
1434
- // Gift card fully covers the order — no PayPal order (PayPal
1435
- // refuses a zero-amount order). Create + burn + mark paid, same
1436
- // as the Stripe full-coverage path.
1782
+ // Credits fully cover the order — no PayPal order (PayPal refuses a
1783
+ // zero-amount order). Create + burn + mark paid, same as the Stripe
1784
+ // full-coverage path. No provider charge → payment_provider stays
1785
+ // null (there is nothing a provider could refund).
1437
1786
  if (amountDue === 0) {
1438
1787
  var paidOrder = await order.createFromCart({
1439
1788
  cart_id: quote.cart_id,
@@ -1446,20 +1795,27 @@ function create(deps) {
1446
1795
  shipping_minor: quote.totals.shipping_minor,
1447
1796
  grand_total_minor: quote.totals.grand_total_minor,
1448
1797
  payment_intent_id: null,
1798
+ payment_provider: null,
1449
1799
  ship_to: input.ship_to,
1450
1800
  customer_email_hash: emailHash,
1451
1801
  lines: ppLines,
1452
1802
  });
1453
1803
  ppOrderCreated = true;
1454
- // Same settlement posture as the Stripe branches: the order exists,
1455
- // so a redeem failure is captured and reconciled, never allowed to
1456
- // strand the cart un-converted.
1457
- await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
1458
- return _redeemGiftCard(gc, paidOrder.id);
1459
- });
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);
1460
1810
  await cart.setStatus(quote.cart_id, "converted");
1461
- var settled = await order.transition(paidOrder.id, "mark_paid", { reason: "gift_card:full" });
1462
- return { order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD", gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 } };
1811
+ var settled = await order.transition(paidOrder.id, "mark_paid", {
1812
+ reason: loy && !gc ? "loyalty:full" : "gift_card:full",
1813
+ });
1814
+ return {
1815
+ order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD",
1816
+ gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: 0 } : null,
1817
+ loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: 0 } : null,
1818
+ };
1463
1819
  }
1464
1820
 
1465
1821
  var ppOrder = await paypal.createOrder({
@@ -1480,26 +1836,37 @@ function create(deps) {
1480
1836
  shipping_minor: quote.totals.shipping_minor,
1481
1837
  grand_total_minor: quote.totals.grand_total_minor,
1482
1838
  payment_intent_id: ppOrder.id, // the PayPal order id (opaque); links the webhook + capture
1839
+ payment_provider: "paypal", // refund surfaces route the refund dial by this
1483
1840
  ship_to: input.ship_to,
1484
1841
  customer_email_hash: emailHash,
1485
1842
  lines: ppLines,
1486
1843
  });
1487
1844
  ppOrderCreated = true;
1488
- // Post-commit gift-card burn captured, never stranding. The order row
1489
- // + the PayPal order already exist; a redeem throw must not bubble to
1490
- // the outer catch (which re-throws and would leave the cart active with
1491
- // an orphaned PayPal order). The buyer pays the credit-reduced PayPal
1492
- // amount; a failed debit is surfaced for reconciliation.
1493
- await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
1494
- return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
1495
- });
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);
1496
1853
  await cart.setStatus(quote.cart_id, "converted");
1497
- return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
1854
+ return {
1855
+ order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status,
1856
+ gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null,
1857
+ loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: amountDue } : null,
1858
+ };
1498
1859
  } catch (e) {
1499
1860
  // A throw BEFORE the order row commits (PayPal open failure,
1500
- // gift-card error) releases the holds so PayPal can't strand stock.
1501
- // After the order commits it owns the holds don't blanket-release.
1502
- 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
+ }
1503
1870
  throw e;
1504
1871
  }
1505
1872
  },
@@ -1527,6 +1894,16 @@ function create(deps) {
1527
1894
  var completed = cap && (cap.status === "COMPLETED" ||
1528
1895
  (captureId && cap.purchase_units[0].payments.captures[0].status === "COMPLETED"));
1529
1896
  if (completed && o.status === "pending") {
1897
+ // Persist the capture id on the order row — refunds dial
1898
+ // /v2/payments/captures/<capture-id>/refund, NOT the PayPal order id
1899
+ // stored in payment_intent_id. Best-effort (drop-silent — by
1900
+ // design): the metadata stamp on the transition below remains the
1901
+ // recoverable source, and a persistence refusal must never block
1902
+ // settling a real payment.
1903
+ if (captureId) {
1904
+ try { await order.setPaypalCapture(o.id, captureId); }
1905
+ catch (_e2) { /* drop-silent — recoverable from the transition metadata */ }
1906
+ }
1530
1907
  await order.transition(o.id, "mark_paid", {
1531
1908
  reason: "paypal:capture",
1532
1909
  metadata: { paypal_order_id: paypalOrderId, paypal_capture_id: captureId },
@@ -1554,6 +1931,24 @@ function create(deps) {
1554
1931
  if (!eventType || !Object.prototype.hasOwnProperty.call(PAYPAL_EVENT_MAP, eventType)) {
1555
1932
  return { handled: false, event_type: eventType || null };
1556
1933
  }
1934
+
1935
+ // Replay defense — atomically claim this verified event id BEFORE any
1936
+ // transition or ledger write, same ordering as the Stripe handler.
1937
+ // Load-bearing for the refund mirror below: recordPartialRefund is an
1938
+ // append (not state-idempotent), so a re-delivered partial REFUNDED
1939
+ // event that raced past state checks would otherwise double-append.
1940
+ // A store error fails CLOSED inside the nonceStore (not-fresh), so a
1941
+ // wiped/unreachable store refuses rather than re-applies. No-op when
1942
+ // the store isn't wired (the refund-id dedupe in the mirror still
1943
+ // covers sequential re-delivery).
1944
+ var replay = _stripeReplay();
1945
+ if (replay && typeof event.id === "string" && event.id.length > 0) {
1946
+ var fresh = await replay.checkAndInsert("paypal:" + event.id, Date.now() + PAYPAL_REPLAY_TTL_MS);
1947
+ if (!fresh) {
1948
+ return { handled: true, event_type: eventType, skipped: "replay", event_id: event.id };
1949
+ }
1950
+ }
1951
+
1557
1952
  var fsmEvent = PAYPAL_EVENT_MAP[eventType];
1558
1953
  if (!fsmEvent) return { handled: false, event_type: eventType, reason: "no-state-change" };
1559
1954
  // The PayPal order id lives in the capture resource's related ids.
@@ -1562,14 +1957,34 @@ function create(deps) {
1562
1957
  if (!ppOrderId) return { handled: false, event_type: eventType, reason: "no-order-id" };
1563
1958
  var o = await order.byPaymentIntent(ppOrderId);
1564
1959
  if (!o) return { handled: false, event_type: eventType, reason: "order-not-found" };
1565
- var alreadyAdvanced = (
1566
- (fsmEvent === "mark_paid" && o.status !== "pending") ||
1567
- (fsmEvent === "refund" && o.status === "refunded")
1568
- );
1569
- if (alreadyAdvanced) return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
1960
+
1961
+ // Refund events are AMOUNT-AWARE see _mirrorPaypalRefund: a partial
1962
+ // dashboard refund appends a partial ledger row; only a
1963
+ // balance-clearing slice drives the terminal refund edge.
1964
+ if (eventType === "PAYMENT.CAPTURE.REFUNDED") {
1965
+ return await _mirrorPaypalRefund(o, event, ppOrderId);
1966
+ }
1967
+
1968
+ if (fsmEvent === "mark_paid" && o.status !== "pending") {
1969
+ return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
1970
+ }
1971
+ // The COMPLETED resource is the capture itself — persist its id so
1972
+ // refunds can run against the capture without re-dialing PayPal. The
1973
+ // write is best-effort here (the metadata stamp below remains the
1974
+ // recoverable source): a refused write must never block settling a
1975
+ // real payment.
1976
+ var ppCaptureId = (eventType === "PAYMENT.CAPTURE.COMPLETED" && event.resource &&
1977
+ typeof event.resource.id === "string" && event.resource.id.length)
1978
+ ? event.resource.id : null;
1979
+ if (ppCaptureId) {
1980
+ try { await order.setPaypalCapture(o.id, ppCaptureId); }
1981
+ catch (_e) { /* drop-silent — by design: capture-id persistence must not block mark_paid; the transition metadata keeps it recoverable */ }
1982
+ }
1570
1983
  var updated = await order.transition(o.id, fsmEvent, {
1571
1984
  reason: "paypal:" + eventType,
1572
- metadata: { paypal_event_id: event.id, paypal_order_id: ppOrderId },
1985
+ metadata: ppCaptureId
1986
+ ? { paypal_event_id: event.id, paypal_order_id: ppOrderId, paypal_capture_id: ppCaptureId }
1987
+ : { paypal_event_id: event.id, paypal_order_id: ppOrderId },
1573
1988
  });
1574
1989
  return { handled: true, event_type: eventType, order: updated };
1575
1990
  },