@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.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/SECURITY.md +13 -0
- package/lib/admin.js +499 -54
- package/lib/asset-manifest.json +3 -3
- package/lib/checkout.js +263 -19
- package/lib/order.js +69 -3
- package/lib/payment.js +80 -5
- package/lib/quotes.js +216 -82
- package/lib/refund-automation.js +50 -7
- package/lib/security-middleware.js +40 -0
- package/lib/storefront.js +21 -2
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
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-
|
|
50
|
-
"fingerprinted": "js/paypal-checkout.
|
|
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
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
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", {
|
|
1462
|
-
|
|
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.
|
|
1489
|
-
// + the PayPal order already exist; a redeem throw must
|
|
1490
|
-
// the outer catch (which re-throws and would leave the
|
|
1491
|
-
// an orphaned PayPal order). The buyer pays the
|
|
1492
|
-
// amount; a failed debit is surfaced for
|
|
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 {
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
if (
|
|
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:
|
|
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, ?
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|