@blamejs/blamejs-shop 0.4.26 → 0.4.28

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.26",
2
+ "version": "0.4.28",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -46,8 +46,8 @@
46
46
  "fingerprinted": "js/pay.683a905563e54a47.js"
47
47
  },
48
48
  "js/paypal-checkout.js": {
49
- "integrity": "sha384-LI6y/1z0Y9F8Kx8RhW4EwY2WqJPXLwJozCXqnhDT+dTckLHyvhly0SsRpH0bsdui",
50
- "fingerprinted": "js/paypal-checkout.b05ab5572cc3728f.js"
49
+ "integrity": "sha384-Fxi5GnmvGA/ZoOC7Vomdo2ng37kkp+G9OgjnRiMSQWtl+fmzdUd5p4Yc+fmPQBrt",
50
+ "fingerprinted": "js/paypal-checkout.7ee687882cc1b588.js"
51
51
  },
52
52
  "js/saved-card.js": {
53
53
  "integrity": "sha384-Kaj6n+Any4rwCH2lyREHoq30MrAZtEd/fTa+tDnIrMJ4zO01YWRhW5TTujcYyuVn",
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
@@ -1095,6 +1248,7 @@ function create(deps) {
1095
1248
  shipping_minor: quote.totals.shipping_minor,
1096
1249
  grand_total_minor: quote.totals.grand_total_minor,
1097
1250
  payment_intent_id: null,
1251
+ payment_provider: null, // credits covered the whole total — no provider charge to refund
1098
1252
  ship_to: input.ship_to,
1099
1253
  customer_email_hash: emailHash,
1100
1254
  lines: orderLines,
@@ -1162,6 +1316,7 @@ function create(deps) {
1162
1316
  shipping_minor: quote.totals.shipping_minor,
1163
1317
  grand_total_minor: quote.totals.grand_total_minor,
1164
1318
  payment_intent_id: pi.id,
1319
+ payment_provider: "stripe", // refund surfaces route the refund dial by this
1165
1320
  ship_to: input.ship_to,
1166
1321
  customer_email_hash: emailHash,
1167
1322
  lines: orderLines,
@@ -1358,6 +1513,13 @@ function create(deps) {
1358
1513
  var o = await order.byPaymentIntent(pi);
1359
1514
  if (!o) return { handled: false, event_type: eventType, reason: "order-not-found" };
1360
1515
 
1516
+ // Refund events are AMOUNT-AWARE — a partial dashboard refund must
1517
+ // append a partial ledger row, never drive the terminal refund edge
1518
+ // (which re-credits every gift-card/loyalty credit on the order).
1519
+ if (eventType === "charge.refunded") {
1520
+ return await _mirrorStripeRefund(o, event);
1521
+ }
1522
+
1361
1523
  // Idempotency: if the order is already in a state the event
1362
1524
  // would advance to, skip the transition (re-deliveries from
1363
1525
  // Stripe are common).
@@ -1424,16 +1586,31 @@ function create(deps) {
1424
1586
  // Resolve an optional gift-card credit before opening the PayPal
1425
1587
  // order so a bad code fails without a remote round-trip.
1426
1588
  var gc = await _resolveGiftCard(input.gift_card_code, quote);
1427
- var amountDue = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
1428
1589
  var cartRow = await cart.get(quote.cart_id);
1590
+ // Loyalty points credit stacks on top of any gift-card credit — same
1591
+ // resolution + residual re-cap discipline as the Stripe confirm path
1592
+ // (_confirmAfterHolds), so the two payment buttons honor identical
1593
+ // credits. Requires a signed-in customer; the resolver refuses a
1594
+ // points request on a guest cart with a clean coded error.
1595
+ var ppLoyaltyCustomerId = cartRow ? (cartRow.customer_id || null) : null;
1596
+ var loy = await _resolveLoyaltyCredit(input.loyalty_redeem_points, ppLoyaltyCustomerId, quote);
1597
+ var afterGiftCard = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
1598
+ if (loy && loy.applied_minor > afterGiftCard) {
1599
+ loy.applied_minor = afterGiftCard < 0 ? 0 : afterGiftCard;
1600
+ var ppPerUsd = loyalty.REDEMPTION_POINTS_PER_USD;
1601
+ loy.points = Math.ceil((loy.applied_minor * ppPerUsd) / 100);
1602
+ if (loy.applied_minor <= 0) loy = null;
1603
+ }
1604
+ var amountDue = afterGiftCard - (loy ? loy.applied_minor : 0);
1429
1605
  var emailHash = customers ? customers.hashEmail(email) : null;
1430
1606
  var ppLines = quote.lines.map(function (l) {
1431
1607
  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
1608
  });
1433
1609
 
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.
1610
+ // Credits fully cover the order — no PayPal order (PayPal refuses a
1611
+ // zero-amount order). Create + burn + mark paid, same as the Stripe
1612
+ // full-coverage path. No provider charge → payment_provider stays
1613
+ // null (there is nothing a provider could refund).
1437
1614
  if (amountDue === 0) {
1438
1615
  var paidOrder = await order.createFromCart({
1439
1616
  cart_id: quote.cart_id,
@@ -1446,6 +1623,7 @@ function create(deps) {
1446
1623
  shipping_minor: quote.totals.shipping_minor,
1447
1624
  grand_total_minor: quote.totals.grand_total_minor,
1448
1625
  payment_intent_id: null,
1626
+ payment_provider: null,
1449
1627
  ship_to: input.ship_to,
1450
1628
  customer_email_hash: emailHash,
1451
1629
  lines: ppLines,
@@ -1455,11 +1633,20 @@ function create(deps) {
1455
1633
  // so a redeem failure is captured and reconciled, never allowed to
1456
1634
  // strand the cart un-converted.
1457
1635
  await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
1458
- return _redeemGiftCard(gc, paidOrder.id);
1636
+ return gc ? _redeemGiftCard(gc, paidOrder.id) : null;
1637
+ });
1638
+ await _settleCreditPostCreate("loyalty", paidOrder.id, function () {
1639
+ return loy ? _redeemLoyalty(loy, ppLoyaltyCustomerId, paidOrder.id) : null;
1459
1640
  });
1460
1641
  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 } };
1642
+ var settled = await order.transition(paidOrder.id, "mark_paid", {
1643
+ reason: loy && !gc ? "loyalty:full" : "gift_card:full",
1644
+ });
1645
+ return {
1646
+ order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD",
1647
+ gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: 0 } : null,
1648
+ loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: 0 } : null,
1649
+ };
1463
1650
  }
1464
1651
 
1465
1652
  var ppOrder = await paypal.createOrder({
@@ -1480,21 +1667,30 @@ function create(deps) {
1480
1667
  shipping_minor: quote.totals.shipping_minor,
1481
1668
  grand_total_minor: quote.totals.grand_total_minor,
1482
1669
  payment_intent_id: ppOrder.id, // the PayPal order id (opaque); links the webhook + capture
1670
+ payment_provider: "paypal", // refund surfaces route the refund dial by this
1483
1671
  ship_to: input.ship_to,
1484
1672
  customer_email_hash: emailHash,
1485
1673
  lines: ppLines,
1486
1674
  });
1487
1675
  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.
1676
+ // Post-commit gift-card + loyalty burn — captured, never stranding.
1677
+ // The order row + the PayPal order already exist; a redeem throw must
1678
+ // not bubble to the outer catch (which re-throws and would leave the
1679
+ // cart active with an orphaned PayPal order). The buyer pays the
1680
+ // credit-reduced PayPal amount; a failed debit is surfaced for
1681
+ // reconciliation.
1493
1682
  await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
1494
1683
  return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
1495
1684
  });
1685
+ await _settleCreditPostCreate("loyalty", createdOrder.id, function () {
1686
+ return loy ? _redeemLoyalty(loy, ppLoyaltyCustomerId, createdOrder.id) : null;
1687
+ });
1496
1688
  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 };
1689
+ return {
1690
+ order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status,
1691
+ gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null,
1692
+ loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: amountDue } : null,
1693
+ };
1498
1694
  } catch (e) {
1499
1695
  // A throw BEFORE the order row commits (PayPal open failure,
1500
1696
  // gift-card error) releases the holds so PayPal can't strand stock.
@@ -1527,6 +1723,16 @@ function create(deps) {
1527
1723
  var completed = cap && (cap.status === "COMPLETED" ||
1528
1724
  (captureId && cap.purchase_units[0].payments.captures[0].status === "COMPLETED"));
1529
1725
  if (completed && o.status === "pending") {
1726
+ // Persist the capture id on the order row — refunds dial
1727
+ // /v2/payments/captures/<capture-id>/refund, NOT the PayPal order id
1728
+ // stored in payment_intent_id. Best-effort (drop-silent — by
1729
+ // design): the metadata stamp on the transition below remains the
1730
+ // recoverable source, and a persistence refusal must never block
1731
+ // settling a real payment.
1732
+ if (captureId) {
1733
+ try { await order.setPaypalCapture(o.id, captureId); }
1734
+ catch (_e2) { /* drop-silent — recoverable from the transition metadata */ }
1735
+ }
1530
1736
  await order.transition(o.id, "mark_paid", {
1531
1737
  reason: "paypal:capture",
1532
1738
  metadata: { paypal_order_id: paypalOrderId, paypal_capture_id: captureId },
@@ -1554,6 +1760,24 @@ function create(deps) {
1554
1760
  if (!eventType || !Object.prototype.hasOwnProperty.call(PAYPAL_EVENT_MAP, eventType)) {
1555
1761
  return { handled: false, event_type: eventType || null };
1556
1762
  }
1763
+
1764
+ // Replay defense — atomically claim this verified event id BEFORE any
1765
+ // transition or ledger write, same ordering as the Stripe handler.
1766
+ // Load-bearing for the refund mirror below: recordPartialRefund is an
1767
+ // append (not state-idempotent), so a re-delivered partial REFUNDED
1768
+ // event that raced past state checks would otherwise double-append.
1769
+ // A store error fails CLOSED inside the nonceStore (not-fresh), so a
1770
+ // wiped/unreachable store refuses rather than re-applies. No-op when
1771
+ // the store isn't wired (the refund-id dedupe in the mirror still
1772
+ // covers sequential re-delivery).
1773
+ var replay = _stripeReplay();
1774
+ if (replay && typeof event.id === "string" && event.id.length > 0) {
1775
+ var fresh = await replay.checkAndInsert("paypal:" + event.id, Date.now() + PAYPAL_REPLAY_TTL_MS);
1776
+ if (!fresh) {
1777
+ return { handled: true, event_type: eventType, skipped: "replay", event_id: event.id };
1778
+ }
1779
+ }
1780
+
1557
1781
  var fsmEvent = PAYPAL_EVENT_MAP[eventType];
1558
1782
  if (!fsmEvent) return { handled: false, event_type: eventType, reason: "no-state-change" };
1559
1783
  // The PayPal order id lives in the capture resource's related ids.
@@ -1562,14 +1786,34 @@ function create(deps) {
1562
1786
  if (!ppOrderId) return { handled: false, event_type: eventType, reason: "no-order-id" };
1563
1787
  var o = await order.byPaymentIntent(ppOrderId);
1564
1788
  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" };
1789
+
1790
+ // Refund events are AMOUNT-AWARE see _mirrorPaypalRefund: a partial
1791
+ // dashboard refund appends a partial ledger row; only a
1792
+ // balance-clearing slice drives the terminal refund edge.
1793
+ if (eventType === "PAYMENT.CAPTURE.REFUNDED") {
1794
+ return await _mirrorPaypalRefund(o, event, ppOrderId);
1795
+ }
1796
+
1797
+ if (fsmEvent === "mark_paid" && o.status !== "pending") {
1798
+ return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
1799
+ }
1800
+ // The COMPLETED resource is the capture itself — persist its id so
1801
+ // refunds can run against the capture without re-dialing PayPal. The
1802
+ // write is best-effort here (the metadata stamp below remains the
1803
+ // recoverable source): a refused write must never block settling a
1804
+ // real payment.
1805
+ var ppCaptureId = (eventType === "PAYMENT.CAPTURE.COMPLETED" && event.resource &&
1806
+ typeof event.resource.id === "string" && event.resource.id.length)
1807
+ ? event.resource.id : null;
1808
+ if (ppCaptureId) {
1809
+ try { await order.setPaypalCapture(o.id, ppCaptureId); }
1810
+ catch (_e) { /* drop-silent — by design: capture-id persistence must not block mark_paid; the transition metadata keeps it recoverable */ }
1811
+ }
1570
1812
  var updated = await order.transition(o.id, fsmEvent, {
1571
1813
  reason: "paypal:" + eventType,
1572
- metadata: { paypal_event_id: event.id, paypal_order_id: ppOrderId },
1814
+ metadata: ppCaptureId
1815
+ ? { paypal_event_id: event.id, paypal_order_id: ppOrderId, paypal_capture_id: ppCaptureId }
1816
+ : { paypal_event_id: event.id, paypal_order_id: ppOrderId },
1573
1817
  });
1574
1818
  return { handled: true, event_type: eventType, order: updated };
1575
1819
  },
package/lib/order.js CHANGED
@@ -443,19 +443,28 @@ function create(opts) {
443
443
  _nonNegInt(input.shipping_minor, "shipping_minor");
444
444
  _nonNegInt(input.grand_total_minor, "grand_total_minor");
445
445
  _shipTo(input.ship_to);
446
+ // Which payment provider captured (or will capture) this order's
447
+ // charge — refund surfaces route the refund dial by this column.
448
+ // Optional: a fully-credited order (gift card / loyalty cover the
449
+ // whole total) carries no provider charge and stays NULL.
450
+ if (input.payment_provider != null &&
451
+ input.payment_provider !== "stripe" && input.payment_provider !== "paypal") {
452
+ throw new TypeError("order.createFromCart: payment_provider must be 'stripe', 'paypal', or null");
453
+ }
446
454
 
447
455
  var id = b.uuid.v7();
448
456
  var ts = _now();
449
457
  await query(
450
458
  "INSERT INTO orders (id, cart_id, customer_id, session_id, status, currency, " +
451
459
  "subtotal_minor, discount_minor, tax_minor, shipping_minor, grand_total_minor, " +
452
- "payment_intent_id, ship_to_json, customer_email_hash, created_at, updated_at) " +
453
- "VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14)",
460
+ "payment_intent_id, payment_provider, ship_to_json, customer_email_hash, created_at, updated_at) " +
461
+ "VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?15)",
454
462
  [
455
463
  id, input.cart_id, input.customer_id || null, input.session_id,
456
464
  input.currency, input.subtotal_minor, input.discount_minor,
457
465
  input.tax_minor, input.shipping_minor, input.grand_total_minor,
458
- input.payment_intent_id || null, JSON.stringify(input.ship_to),
466
+ input.payment_intent_id || null, input.payment_provider || null,
467
+ JSON.stringify(input.ship_to),
459
468
  input.customer_email_hash || null, ts,
460
469
  ],
461
470
  );
@@ -518,6 +527,63 @@ function create(opts) {
518
527
  return await this.get(rows[0].id);
519
528
  },
520
529
 
530
+ // Persist the PayPal CAPTURE id on the order (and stamp the provider) —
531
+ // PayPal refunds run against the capture, not the order id stored in
532
+ // payment_intent_id. Called by the capture flow and the
533
+ // PAYMENT.CAPTURE.COMPLETED webhook backstop. Write-once: the first
534
+ // capture id recorded wins; a different id for an already-captured order
535
+ // is refused with a coded error (an order has exactly one capture in
536
+ // this flow — a mismatch means crossed wires, never something to
537
+ // overwrite silently). Re-recording the SAME id is an idempotent no-op.
538
+ setPaypalCapture: async function (orderId, captureId) {
539
+ _uuid(orderId, "order id");
540
+ if (typeof captureId !== "string" || !captureId.length || captureId.length > 64 ||
541
+ /[\u0000-\u001f\u007f]/.test(captureId)) {
542
+ throw new TypeError("order.setPaypalCapture: captureId must be a non-empty string <= 64 chars without control bytes");
543
+ }
544
+ var upd = await query(
545
+ "UPDATE orders SET paypal_capture_id = ?1, payment_provider = 'paypal' " +
546
+ "WHERE id = ?2 AND (paypal_capture_id IS NULL OR paypal_capture_id = ?1)",
547
+ [captureId, orderId],
548
+ );
549
+ if (Number(upd.rowCount || 0) > 0) return true;
550
+ var row = (await query("SELECT id, paypal_capture_id FROM orders WHERE id = ?1", [orderId])).rows[0];
551
+ if (!row) throw new TypeError("order.setPaypalCapture: order " + orderId + " not found");
552
+ var err = new Error("order.setPaypalCapture: order " + orderId + " already carries capture " +
553
+ row.paypal_capture_id + " — refusing to overwrite with " + captureId);
554
+ err.code = "PAYPAL_CAPTURE_MISMATCH";
555
+ throw err;
556
+ },
557
+
558
+ // Resolve the PayPal capture id for an order: the persisted column when
559
+ // present, else recovered from the order's transition metadata (the
560
+ // capture flow stamps `paypal_capture_id` on the mark_paid transition —
561
+ // the only place pre-existing orders recorded it) and healed back onto
562
+ // the column so the scan runs at most once per order. Returns the
563
+ // capture id string or null (a Stripe / uncaptured order). Callers with
564
+ // a PayPal handle may fall back to a remote getOrder read when this
565
+ // local resolution returns null.
566
+ paypalCaptureId: async function (orderId) {
567
+ _uuid(orderId, "order id");
568
+ var row = (await query("SELECT paypal_capture_id FROM orders WHERE id = ?1", [orderId])).rows[0];
569
+ if (!row) return null;
570
+ if (row.paypal_capture_id) return row.paypal_capture_id;
571
+ var transitions = (await query(
572
+ "SELECT metadata_json FROM order_transitions WHERE order_id = ?1 ORDER BY occurred_at ASC",
573
+ [orderId],
574
+ )).rows;
575
+ for (var i = 0; i < transitions.length; i += 1) {
576
+ var meta;
577
+ try { meta = JSON.parse(transitions[i].metadata_json || "{}"); }
578
+ catch (_e) { meta = {}; }
579
+ if (meta && typeof meta.paypal_capture_id === "string" && meta.paypal_capture_id.length) {
580
+ await this.setPaypalCapture(orderId, meta.paypal_capture_id);
581
+ return meta.paypal_capture_id;
582
+ }
583
+ }
584
+ return null;
585
+ },
586
+
521
587
  // Fire a transition. Replays the FSM from the current state +
522
588
  // history, dispatches the event, and persists the new state on
523
589
  // the orders row + appends an order_transitions row.
package/lib/payment.js CHANGED
@@ -784,6 +784,32 @@ function _minorToDecimalString(minor, currency) {
784
784
  return (neg ? "-" : "") + s.slice(0, s.length - dec) + "." + s.slice(s.length - dec);
785
785
  }
786
786
 
787
+ // Inverse of _minorToDecimalString: parse a PayPal decimal amount string
788
+ // (e.g. webhook `resource.amount.value`) into exact integer minor units,
789
+ // using the same zero-decimal currency table. STRICT — money parsed off an
790
+ // inbound webhook decides refund accounting, so a malformed shape throws a
791
+ // TypeError rather than guessing (the caller maps that to a 5xx so the
792
+ // processor re-delivers; a guessed amount would silently mis-credit). Pure
793
+ // digit-string arithmetic — the value never passes through a float.
794
+ function _decimalToMinor(value, currency) {
795
+ if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) {
796
+ throw new TypeError("payment: decimal amount currency must be a 3-letter uppercase ISO 4217 code");
797
+ }
798
+ if (typeof value !== "string" || !/^\d{1,15}(\.\d{1,2})?$/.test(value)) {
799
+ throw new TypeError("payment: decimal amount must be a plain non-negative decimal string (got " + JSON.stringify(value) + ")");
800
+ }
801
+ var dec = PAYPAL_ZERO_DECIMAL[currency] ? 0 : 2;
802
+ var parts = value.split(".");
803
+ var frac = parts[1] || "";
804
+ if (frac.length > dec) {
805
+ // More fractional digits than the currency carries (e.g. "100.50" JPY)
806
+ // is a garbled amount, not a roundable one — refuse, never round money.
807
+ throw new TypeError("payment: decimal amount " + JSON.stringify(value) + " has more fractional digits than " + currency + " allows");
808
+ }
809
+ while (frac.length < dec) frac += "0";
810
+ return parseInt(parts[0] + frac, 10);
811
+ }
812
+
787
813
  function _headerCI(headers, name) {
788
814
  if (!headers) return undefined;
789
815
  if (headers[name] != null) return headers[name];
@@ -833,7 +859,13 @@ async function _paypalToken(opts, state) {
833
859
  return state.token;
834
860
  }
835
861
 
836
- async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
862
+ // `breaker` selects which circuit the dial rides — every payment call rides
863
+ // the adapter's main `opts._breaker`; the webhook-verification dial rides its
864
+ // own (see verifyWebhook) so attacker-shaped verification traffic can't trip
865
+ // the circuit live checkouts depend on. The token exchange inside always
866
+ // rides the main breaker: its failures are credential/PayPal-health signals,
867
+ // not attacker-controllable per-request outcomes.
868
+ async function _paypalCall(opts, state, method, path, bodyObj, requestId, breaker) {
837
869
  var token = await _paypalToken(opts, state);
838
870
  var headers = {
839
871
  "authorization": "Bearer " + token,
@@ -855,7 +887,7 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
855
887
  // same id rides every retry attempt within one call). A keyless write
856
888
  // rides the breaker but not the retry.
857
889
  var idempotent = method === "GET" || !!requestId;
858
- var json = await _dial(opts._breaker, idempotent, async function () {
890
+ var json = await _dial(breaker === undefined ? opts._breaker : breaker, idempotent, async function () {
859
891
  var res = await httpClient.request({
860
892
  method: method,
861
893
  url: _paypalApiBase(opts) + path,
@@ -898,6 +930,17 @@ function paypal(opts) {
898
930
  if (opts._breaker === undefined) {
899
931
  opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-paypal");
900
932
  }
933
+ // SEPARATE breaker for the webhook-verification dial. The verify call's
934
+ // failure rate is attacker-influenceable: any header-complete spam POST to
935
+ // the (necessarily unauthenticated) webhook route triggers a
936
+ // verify-webhook-signature dial whose 4xx counts as a breaker failure —
937
+ // five consecutive spam posts would otherwise open the SAME circuit live
938
+ // checkout's createOrder/captureOrder ride and fast-fail real payments for
939
+ // the cooldown window. Verification failures say nothing about PayPal's
940
+ // health as a payments peer, so they account against their own circuit.
941
+ if (opts._verifyBreaker === undefined) {
942
+ opts._verifyBreaker = opts.breaker === false ? null : _makeBreaker("psp-paypal-verify");
943
+ }
901
944
 
902
945
  var state = {
903
946
  query: opts.query || null,
@@ -923,8 +966,11 @@ function paypal(opts) {
923
966
  name: "paypal",
924
967
 
925
968
  // The per-adapter circuit breaker (or null when disabled). Same
926
- // operator-dashboard surface as the Stripe adapter's.
927
- breaker: opts._breaker,
969
+ // operator-dashboard surface as the Stripe adapter's. `verifyBreaker`
970
+ // is the webhook-verification dial's own circuit — kept separate so
971
+ // spam against the webhook route can't open the payment circuit.
972
+ breaker: opts._breaker,
973
+ verifyBreaker: opts._verifyBreaker,
928
974
 
929
975
  // Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
930
976
  // PayPal order id the buyer approves; `captureOrder` finalizes it.
@@ -1020,7 +1066,10 @@ function paypal(opts) {
1020
1066
  webhook_event: event,
1021
1067
  };
1022
1068
  var res;
1023
- try { res = await _paypalCall(opts, state, "POST", "/v1/notifications/verify-webhook-signature", verifyBody, null); }
1069
+ // Rides the verify-only breaker (opts._verifyBreaker), never the main
1070
+ // payment circuit — see the factory comment: verification traffic is
1071
+ // attacker-shaped and must not be able to fast-fail live checkouts.
1072
+ try { res = await _paypalCall(opts, state, "POST", "/v1/notifications/verify-webhook-signature", verifyBody, null, opts._verifyBreaker); }
1024
1073
  catch (e) { return { ok: false, reason: "verify-call-failed", error: e && e.message }; }
1025
1074
  if (res && res.verification_status === "SUCCESS") return { ok: true, event: event };
1026
1075
  return { ok: false, reason: "verification-status-" + ((res && res.verification_status) || "unknown") };
@@ -1036,14 +1085,40 @@ function create(opts) {
1036
1085
  throw new TypeError("payment.create: unknown adapter " + JSON.stringify(opts.adapter) + " — 'stripe' and 'paypal' are supported");
1037
1086
  }
1038
1087
 
1088
+ // Boot-time PayPal configuration lint, called by the server entry point so
1089
+ // an incomplete env surfaces in the boot log instead of as a silent feature
1090
+ // gap. Returns an array of operator-actionable warning strings (empty when
1091
+ // nothing is wrong). Pure read of the supplied env map — never throws, never
1092
+ // changes behavior: webhook verification stays MANDATORY and fails closed
1093
+ // whether or not the operator saw the warning.
1094
+ function paypalConfigWarnings(env) {
1095
+ env = env && typeof env === "object" ? env : {};
1096
+ var warnings = [];
1097
+ if (env.PAYPAL_CLIENT_ID && env.PAYPAL_SECRET && !env.PAYPAL_WEBHOOK_ID) {
1098
+ warnings.push(
1099
+ "PAYPAL_WEBHOOK_ID is not set: PayPal checkout is configured, but every " +
1100
+ "/api/webhooks/paypal delivery will be refused (verification fails closed " +
1101
+ "without the webhook id), so out-of-band captures and refunds will not " +
1102
+ "reach the order ledger. Set PAYPAL_WEBHOOK_ID to the webhook id from the " +
1103
+ "PayPal developer dashboard.");
1104
+ }
1105
+ return warnings;
1106
+ }
1107
+
1039
1108
  module.exports = {
1040
1109
  create: create,
1041
1110
  stripe: stripe,
1042
1111
  paypal: paypal,
1112
+ paypalConfigWarnings: paypalConfigWarnings,
1043
1113
  STRIPE_WEBHOOK_TOLERANCE: STRIPE_WEBHOOK_TOLERANCE,
1044
1114
  IDEMPOTENCY_TTL_MS: IDEMPOTENCY_TTL_MS,
1045
1115
  // Exposed for tests + Worker to share form-encoding shape.
1046
1116
  _formEncode: _formEncode,
1047
1117
  _verifyWebhook: _verifyWebhook,
1048
1118
  _canonicalHash: _canonicalHash,
1119
+ // Exposed for the checkout webhook mirror + admin refund normalization —
1120
+ // exact decimal-string ↔ minor-unit conversion sharing one zero-decimal
1121
+ // currency table.
1122
+ _decimalToMinor: _decimalToMinor,
1123
+ _minorToDecimalString: _minorToDecimalString,
1049
1124
  };