@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/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/SECURITY.md +22 -0
- package/lib/admin.js +197 -39
- package/lib/asset-manifest.json +3 -3
- package/lib/auto-discount.js +177 -6
- package/lib/checkout.js +550 -135
- package/lib/gift-card-ledger.js +208 -88
- package/lib/giftcards.js +56 -0
- package/lib/loyalty.js +61 -1
- package/lib/order.js +69 -3
- package/lib/payment.js +113 -7
- package/lib/refund-automation.js +50 -7
- package/lib/security-middleware.js +39 -0
- package/lib/store-credit.js +99 -79
- package/lib/storefront.js +9 -2
- package/package.json +1 -1
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
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
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:
|
|
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
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
//
|
|
599
|
-
// `
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
async function
|
|
606
|
-
|
|
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:
|
|
610
|
-
notes: "checkout-credit
|
|
806
|
+
order_id: null,
|
|
807
|
+
notes: "checkout-credit",
|
|
611
808
|
});
|
|
612
809
|
}
|
|
613
810
|
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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.
|
|
629
|
-
rule_slug:
|
|
630
|
-
|
|
631
|
-
|
|
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 (
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
// and
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
await
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
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
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
await
|
|
1458
|
-
|
|
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", {
|
|
1462
|
-
|
|
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
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
// the
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
await
|
|
1494
|
-
|
|
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 {
|
|
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)
|
|
1501
|
-
//
|
|
1502
|
-
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
if (
|
|
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:
|
|
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
|
},
|