@blamejs/blamejs-shop 0.4.22 → 0.4.24
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 +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1273 -15
- package/lib/asset-manifest.json +5 -5
- package/lib/checkout.js +70 -0
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-accounts.js +52 -1
- package/lib/operator-audit-log.js +186 -6
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +178 -69
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +27 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +1088 -129
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
package/lib/order.js
CHANGED
|
@@ -209,6 +209,22 @@ function create(opts) {
|
|
|
209
209
|
// fire-and-forget on the same detached-promise discipline as the
|
|
210
210
|
// loyalty fan-out: the transition has already persisted.
|
|
211
211
|
var referrals = opts.referrals || null;
|
|
212
|
+
// Optional new-order observer — a `function (order)` the FSM calls,
|
|
213
|
+
// fire-and-forget, the moment an order reaches `paid` (pending →
|
|
214
|
+
// paid). It's how the operator console learns a sale settled without
|
|
215
|
+
// polling: the application points the slot at an adapter that drops
|
|
216
|
+
// an operator-inbox entry (and a navbar badge ticks up). Late-bound
|
|
217
|
+
// via `setNewOrderObserver` because the operator-ping adapter is
|
|
218
|
+
// constructed AFTER the order primitive (it composes the same order
|
|
219
|
+
// handle to read the customer/total), so the slot is assigned once
|
|
220
|
+
// the wiring is complete. Opt-in like the other fan-outs; absent it
|
|
221
|
+
// the paid edge is a clean no-op. The call is detached on the same
|
|
222
|
+
// discipline as the loyalty / referral fan-outs — the transition has
|
|
223
|
+
// already persisted, so an observer read/write must never add latency
|
|
224
|
+
// to (or fail) the order transition.
|
|
225
|
+
var newOrderObserver = (typeof opts.newOrderObserver === "function")
|
|
226
|
+
? opts.newOrderObserver
|
|
227
|
+
: null;
|
|
212
228
|
// Optional inventory handle — when present, the order FSM converts the
|
|
213
229
|
// confirm-time stock holds into real shelf movements as the order
|
|
214
230
|
// changes state. On `mark_paid` (pending → paid) each shippable line's
|
|
@@ -258,6 +274,18 @@ function create(opts) {
|
|
|
258
274
|
// balance is authoritative; the ledger is the audit trail surfaced in the
|
|
259
275
|
// admin console).
|
|
260
276
|
var giftCardLedger = opts.giftCardLedger || null;
|
|
277
|
+
// Optional loyalty handle — when present, the FSM restores loyalty points
|
|
278
|
+
// a customer SPENT as a checkout tender when the order is refunded /
|
|
279
|
+
// cancelled, symmetric with the gift-card-spend restore above. The points
|
|
280
|
+
// were debited at checkout via loyalty.redeem against this order; a refund
|
|
281
|
+
// returns the buyer's money, so the points they tendered have to come back
|
|
282
|
+
// to their balance or the refund silently burns them (inconsistent with the
|
|
283
|
+
// gift-card spend, which IS restored). Distinct from `loyaltyEarnRules`
|
|
284
|
+
// (which reverses points EARNED on the purchase) — this restores points
|
|
285
|
+
// SPENT on it. Restore is proportional to the refunded amount + idempotent
|
|
286
|
+
// (restoreRedemption tracks cumulative restored per redeem row). Opt-in;
|
|
287
|
+
// absent it, a deploy without loyalty-as-tender runs unchanged.
|
|
288
|
+
var loyalty = opts.loyalty || null;
|
|
261
289
|
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
262
290
|
// b.pagination so an operator can't hand-craft one to skip past a
|
|
263
291
|
// hidden order or replay across deployments. The secret defaults
|
|
@@ -328,13 +356,29 @@ function create(opts) {
|
|
|
328
356
|
// so a reversal failure is caught, NOT re-thrown, and surfaced loudly (an
|
|
329
357
|
// `order.giftcard.reversal.error` audit event plus, when the error-log
|
|
330
358
|
// handle is wired, a durable /admin/errors row) for manual reconciliation.
|
|
331
|
-
async function _settleGiftCards(orderId) {
|
|
359
|
+
async function _settleGiftCards(orderId, refundedMinor, orderTotalMinor) {
|
|
332
360
|
if (!giftCards) return;
|
|
333
361
|
try {
|
|
334
|
-
var reversed
|
|
362
|
+
var reversed;
|
|
363
|
+
if (typeof giftCards.reverseRedemptionProRata === "function"
|
|
364
|
+
&& Number.isInteger(orderTotalMinor) && orderTotalMinor > 0) {
|
|
365
|
+
// Proportional reversal — re-mint only the share the refund covers.
|
|
366
|
+
// reversed_minor tracks the cumulative already credited per
|
|
367
|
+
// redemption, so a partial-then-final refund sequence converges
|
|
368
|
+
// exactly on the original spend and never over-credits.
|
|
369
|
+
reversed = await giftCards.reverseRedemptionProRata(orderId, {
|
|
370
|
+
refunded_minor: refundedMinor,
|
|
371
|
+
order_total_minor: orderTotalMinor,
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
// Fallback — a giftCards handle without the pro-rata method, or a
|
|
375
|
+
// zero-total order: all-or-nothing reversal of the full spend.
|
|
376
|
+
reversed = await giftCards.reverseRedemption(orderId);
|
|
377
|
+
}
|
|
335
378
|
if (giftCardLedger && typeof giftCardLedger.credit === "function") {
|
|
336
379
|
for (var i = 0; i < reversed.length; i += 1) {
|
|
337
380
|
var rev = reversed[i];
|
|
381
|
+
if (!rev || !(Number(rev.amount_minor) > 0)) continue;
|
|
338
382
|
try {
|
|
339
383
|
// allow:money-binding-currency-without-catalog-check — this is a REVERSAL credit of an existing redemption: it replays the exact amount already debited from an already-issued card (whose currency was ISO-4217-validated at giftcards.issue time). No currency is supplied or chosen here — the ledger credit carries no currency field; it inherits the card's. There is no new currency surface to catalog-check.
|
|
340
384
|
await giftCardLedger.credit({
|
|
@@ -584,32 +628,58 @@ function create(opts) {
|
|
|
584
628
|
// transition has already persisted and the webhook must return 2xx, so
|
|
585
629
|
// a reversal failure surfaces loudly for reconciliation rather than
|
|
586
630
|
// 500ing the request.
|
|
631
|
+
// Death-edge value reversal. A cancel (pending-abandon or post-paid)
|
|
632
|
+
// or a balance-clearing refund returns EVERY credit the order consumed
|
|
633
|
+
// or earned, in full: refundedMinor = the order's grand total. The
|
|
634
|
+
// pro-rata reversers track the cumulative already reversed (per
|
|
635
|
+
// redemption / award / redeem row), so when partial refunds preceded
|
|
636
|
+
// this edge they credit only the remaining delta — a partial-then-final
|
|
637
|
+
// sequence converges exactly on the order total, never over-crediting.
|
|
587
638
|
if (result.to === "cancelled" || result.to === "refunded") {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
639
|
+
var _refTotal = Number(refreshed && refreshed.grand_total_minor) || 0;
|
|
640
|
+
// Gift-card spend — SYNCHRONOUS (the customer's own money), idempotent,
|
|
641
|
+
// drop-silent-with-capture (see _settleGiftCards).
|
|
642
|
+
await _settleGiftCards(orderId, _refTotal, _refTotal);
|
|
643
|
+
if (refreshed && refreshed.customer_id && _refTotal > 0) {
|
|
644
|
+
var _revCustomer = refreshed.customer_id;
|
|
645
|
+
var _revOrderId = refreshed.id;
|
|
646
|
+
// Loyalty points SPENT as a checkout tender — restored to the
|
|
647
|
+
// balance. Detached + drop-silent (loyalty ledger holds its own
|
|
648
|
+
// audit trail); restoreRedemption claims each redeem row's slice so
|
|
649
|
+
// a re-delivered webhook can't double-restore.
|
|
650
|
+
if (loyalty && typeof loyalty.restoreRedemption === "function") {
|
|
651
|
+
Promise.resolve().then(function () {
|
|
652
|
+
return loyalty.restoreRedemption(_revOrderId, {
|
|
653
|
+
refunded_minor: _refTotal,
|
|
654
|
+
order_total_minor: _refTotal,
|
|
655
|
+
});
|
|
656
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
657
|
+
}
|
|
658
|
+
// Loyalty points EARNED on the purchase — clawed back proportionally
|
|
659
|
+
// (full, on a death edge). Detached + drop-silent; clawed_points
|
|
660
|
+
// makes the claw idempotent and convergent across partial slices.
|
|
661
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEventProRata === "function") {
|
|
662
|
+
Promise.resolve().then(function () {
|
|
663
|
+
return loyaltyEarnRules.reverseForEventProRata({
|
|
664
|
+
customer_id: _revCustomer,
|
|
665
|
+
trigger_event_ref: "order:" + _revOrderId,
|
|
666
|
+
refunded_minor: _refTotal,
|
|
667
|
+
order_total_minor: _refTotal,
|
|
668
|
+
});
|
|
669
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Referral funnel — the qualifying first order being voided rolls back
|
|
673
|
+
// the both-rewarded completion and decrements the referrer's count.
|
|
674
|
+
// Terminal edge only (a partial refund doesn't void the order).
|
|
675
|
+
// Detached + drop-silent; reverseForOrder claims the invitation with an
|
|
676
|
+
// unreversed predicate so a re-delivered webhook reverses exactly once.
|
|
677
|
+
if (referrals && typeof referrals.reverseForOrder === "function" && refreshed) {
|
|
678
|
+
var _refrOrderId = refreshed.id;
|
|
679
|
+
Promise.resolve().then(function () {
|
|
680
|
+
return referrals.reverseForOrder(_refrOrderId);
|
|
681
|
+
}).catch(function () { /* drop-silent — referral funnel holds its own audit trail */ });
|
|
682
|
+
}
|
|
613
683
|
}
|
|
614
684
|
// Fan-out to merchant webhook subscribers is fire-and-forget. The
|
|
615
685
|
// transition has already persisted; the request must not wait on
|
|
@@ -688,9 +758,139 @@ function create(opts) {
|
|
|
688
758
|
return referrals.trackPurchase({ customer_id: _refCustomer, order_id: _refOrderId });
|
|
689
759
|
}).catch(function () { /* drop-silent — referral funnel holds its own audit trail */ });
|
|
690
760
|
}
|
|
761
|
+
// New-order operator ping — fire-and-forget, same discipline as the
|
|
762
|
+
// loyalty / referral fan-outs above. Only on the paid transition
|
|
763
|
+
// (pending → paid is the edge that owns inventory debit, so this
|
|
764
|
+
// fires exactly once per real sale — a re-delivered mark_paid is
|
|
765
|
+
// collapsed to a no-op transition upstream). Guest orders fire too:
|
|
766
|
+
// the operator wants to know a sale settled regardless of whether
|
|
767
|
+
// the buyer has an account. The observer is detached so a slow /
|
|
768
|
+
// failing inbox write never adds latency to (or fails) the order
|
|
769
|
+
// transition; the inbox enqueue holds its own durable audit trail.
|
|
770
|
+
if (newOrderObserver && result.to === "paid" && refreshed) {
|
|
771
|
+
var _pingOrder = refreshed;
|
|
772
|
+
Promise.resolve().then(function () {
|
|
773
|
+
return newOrderObserver(_pingOrder);
|
|
774
|
+
}).catch(function () { /* drop-silent — the observer owns its own failure trail */ });
|
|
775
|
+
}
|
|
691
776
|
return refreshed;
|
|
692
777
|
},
|
|
693
778
|
|
|
779
|
+
// Late-bind the new-order observer (see the `newOrderObserver` slot
|
|
780
|
+
// in the factory). The operator-ping adapter is constructed AFTER
|
|
781
|
+
// this primitive (it composes the same order handle), so the wiring
|
|
782
|
+
// assigns the slot once both halves exist. A non-function is refused
|
|
783
|
+
// at the boundary so a typo surfaces at boot, not as a silent
|
|
784
|
+
// never-firing ping. Pass `null` to detach.
|
|
785
|
+
setNewOrderObserver: function (fn) {
|
|
786
|
+
if (fn != null && typeof fn !== "function") {
|
|
787
|
+
throw new TypeError("order.setNewOrderObserver: observer must be a function or null");
|
|
788
|
+
}
|
|
789
|
+
newOrderObserver = fn || null;
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
// Sum of every refund recorded against this order, in minor units.
|
|
793
|
+
// Both the FSM `refund` transition (full / balance-clearing refund →
|
|
794
|
+
// `refunded`) and a partial-refund record (recordPartialRefund — a
|
|
795
|
+
// same-state self-loop) write an `order_transitions` row whose
|
|
796
|
+
// metadata carries `amount_minor`, so the running refunded total is
|
|
797
|
+
// the SUM of `amount_minor` across every `on_event = 'refund'` row.
|
|
798
|
+
// Rows with no amount in metadata (a legacy full refund that recorded
|
|
799
|
+
// none) contribute 0 here — the partial-refund console always records
|
|
800
|
+
// the amount, so a mixed history still totals correctly going forward.
|
|
801
|
+
// Integer minor-unit arithmetic throughout (never a float): the values
|
|
802
|
+
// are exact minor-unit integers from the order totals + provider refund
|
|
803
|
+
// amounts, so the sum is exact.
|
|
804
|
+
refundedTotalMinor: async function (orderId) {
|
|
805
|
+
_uuid(orderId, "order id");
|
|
806
|
+
var rows = (await query(
|
|
807
|
+
"SELECT metadata_json FROM order_transitions " +
|
|
808
|
+
"WHERE order_id = ?1 AND on_event = 'refund'",
|
|
809
|
+
[orderId],
|
|
810
|
+
)).rows;
|
|
811
|
+
var total = 0;
|
|
812
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
813
|
+
var meta;
|
|
814
|
+
try { meta = JSON.parse(rows[i].metadata_json || "{}"); }
|
|
815
|
+
catch (_e) { meta = {}; }
|
|
816
|
+
var amt = meta && meta.amount_minor;
|
|
817
|
+
if (Number.isInteger(amt) && amt > 0) total += amt;
|
|
818
|
+
}
|
|
819
|
+
return total;
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
// Record a PARTIAL refund that does NOT clear the order's balance — the
|
|
823
|
+
// money has already moved at the payment provider; this appends the
|
|
824
|
+
// audit row WITHOUT changing the order's FSM state (a same-state
|
|
825
|
+
// self-loop `refund` transition). A partial refund leaves the order in
|
|
826
|
+
// its current lifecycle state (a paid / fulfilling / shipped order keeps
|
|
827
|
+
// moving) — only a balance-clearing refund drives the FSM `refund` edge
|
|
828
|
+
// to the terminal `refunded` state via `transition`. The caller (the
|
|
829
|
+
// admin refund console) is responsible for choosing partial-vs-final:
|
|
830
|
+
// it issues the provider refund first, then calls this for a partial or
|
|
831
|
+
// `transition('refund')` for the final slice. `amount_minor` is a
|
|
832
|
+
// positive integer in minor units (validated here as a config/entry
|
|
833
|
+
// contract — a bad amount throws so the caller surfaces a 4xx, never a
|
|
834
|
+
// silently-wrong ledger row). Returns the refreshed order.
|
|
835
|
+
recordPartialRefund: async function (orderId, opts2) {
|
|
836
|
+
_uuid(orderId, "order id");
|
|
837
|
+
opts2 = opts2 || {};
|
|
838
|
+
_positiveInt(opts2.amount_minor, "amount_minor");
|
|
839
|
+
var current = (await query("SELECT * FROM orders WHERE id = ?1", [orderId])).rows[0];
|
|
840
|
+
if (!current) throw new TypeError("order.recordPartialRefund: order " + orderId + " not found");
|
|
841
|
+
var meta = Object.assign({}, opts2.metadata || {});
|
|
842
|
+
meta.amount_minor = opts2.amount_minor;
|
|
843
|
+
meta.partial = true;
|
|
844
|
+
var ts = _now();
|
|
845
|
+
await query(
|
|
846
|
+
"INSERT INTO order_transitions (id, order_id, from_state, to_state, on_event, reason, metadata_json, occurred_at) " +
|
|
847
|
+
"VALUES (?1, ?2, ?3, ?3, 'refund', ?4, ?5, ?6)",
|
|
848
|
+
[
|
|
849
|
+
b.uuid.v7(), orderId, current.status,
|
|
850
|
+
opts2.reason || null,
|
|
851
|
+
JSON.stringify(meta),
|
|
852
|
+
ts,
|
|
853
|
+
],
|
|
854
|
+
);
|
|
855
|
+
// updated_at is bumped so the order surfaces at the top of the
|
|
856
|
+
// operator recent-orders list after a partial refund, matching how a
|
|
857
|
+
// real FSM transition touches it.
|
|
858
|
+
await query("UPDATE orders SET updated_at = ?1 WHERE id = ?2", [ts, orderId]);
|
|
859
|
+
// Return the credits this refund covers, in proportion to the refunded
|
|
860
|
+
// total SO FAR (this slice included). Gift-card spend is re-minted and
|
|
861
|
+
// loyalty (redeemed + earned) restored / clawed against the cumulative
|
|
862
|
+
// refunded amount, so a partial-then-final refund sequence converges
|
|
863
|
+
// exactly on the order total. Referral funnel reversal waits for the
|
|
864
|
+
// terminal refund edge — a partial refund doesn't void the order.
|
|
865
|
+
var _ptTotal = Number(current.grand_total_minor) || 0;
|
|
866
|
+
if (_ptTotal > 0) {
|
|
867
|
+
var _ptRefunded = await this.refundedTotalMinor(orderId);
|
|
868
|
+
await _settleGiftCards(orderId, _ptRefunded, _ptTotal);
|
|
869
|
+
if (current.customer_id) {
|
|
870
|
+
var _ptCust = current.customer_id;
|
|
871
|
+
if (loyalty && typeof loyalty.restoreRedemption === "function") {
|
|
872
|
+
Promise.resolve().then(function () {
|
|
873
|
+
return loyalty.restoreRedemption(orderId, {
|
|
874
|
+
refunded_minor: _ptRefunded,
|
|
875
|
+
order_total_minor: _ptTotal,
|
|
876
|
+
});
|
|
877
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
878
|
+
}
|
|
879
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEventProRata === "function") {
|
|
880
|
+
Promise.resolve().then(function () {
|
|
881
|
+
return loyaltyEarnRules.reverseForEventProRata({
|
|
882
|
+
customer_id: _ptCust,
|
|
883
|
+
trigger_event_ref: "order:" + orderId,
|
|
884
|
+
refunded_minor: _ptRefunded,
|
|
885
|
+
order_total_minor: _ptTotal,
|
|
886
|
+
});
|
|
887
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return await this.get(orderId);
|
|
892
|
+
},
|
|
893
|
+
|
|
694
894
|
// Paginated history for a single customer. Tuple cursor
|
|
695
895
|
// (updated_at, id) ordered DESC so the customer's most-recent
|
|
696
896
|
// activity surfaces first. Mirrors the cursor shape used by
|
package/lib/payment.js
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
* verification (HMAC-SHA256 over `<timestamp>.<body>` with
|
|
9
9
|
* `whsec_...` secret, ±5 min tolerance, constant-time compare) and
|
|
10
10
|
* outbound API calls (PaymentIntent create / retrieve / confirm /
|
|
11
|
-
* cancel + Refund)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* cancel + Refund) on `b.httpClient` (SSRF-gated, response-capped,
|
|
12
|
+
* ALPN HTTP/2). Each adapter instance wraps its dials in a per-upstream
|
|
13
|
+
* `b.circuitBreaker` plus a bounded `b.retry` (the latter only on
|
|
14
|
+
* idempotent dials — GET reads + idempotency-keyed writes — so a
|
|
15
|
+
* transient blip never re-sends a one-shot charge). While the breaker is
|
|
16
|
+
* open the dial fast-fails with `code: "CIRCUIT_OPEN"` BEFORE the
|
|
17
|
+
* request, so checkout renders the recoverable payment-unavailable page
|
|
18
|
+
* rather than stranding an order mid-charge. No `stripe` npm dep — every
|
|
19
|
+
* byte is either node built-in or vendored blamejs primitive.
|
|
14
20
|
*
|
|
15
21
|
* Future Adyen / Mollie / Paddle adapters land as additional
|
|
16
22
|
* factory functions returning the same `{ verifyWebhook,
|
|
@@ -48,6 +54,59 @@ var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
|
|
|
48
54
|
var STRIPE_HTTP_TIMEOUT_MS = 15000;
|
|
49
55
|
var CURRENCY_RE = /^[a-z]{3}$/; // Stripe wants lowercase ISO 4217
|
|
50
56
|
|
|
57
|
+
// ---- PSP dial resilience (circuit breaker + bounded retry) ----------------
|
|
58
|
+
//
|
|
59
|
+
// b.httpClient does NOT itself circuit-break or retry — it SSRF-gates, caps,
|
|
60
|
+
// and pools, but the failure-threshold breaker + backoff retry are separate
|
|
61
|
+
// primitives a consumer composes around it. Each adapter instance owns ONE
|
|
62
|
+
// breaker per upstream (Stripe / PayPal): per-target is the only correct
|
|
63
|
+
// scope — sharing a breaker across unrelated peers defeats the
|
|
64
|
+
// failure-threshold semantic.
|
|
65
|
+
//
|
|
66
|
+
// Open-circuit posture: while the breaker is open every dial fast-fails with
|
|
67
|
+
// a plain Error carrying `code: "CIRCUIT_OPEN"` (NOT a TypeError), so the
|
|
68
|
+
// storefront's checkout handler renders it through the existing recoverable
|
|
69
|
+
// "checkout didn't go through" page — the same structured payment-unavailable
|
|
70
|
+
// surface a raw upstream 5xx already produces — rather than charging or
|
|
71
|
+
// 400-ing a field. Nothing is captured: the breaker fails BEFORE the dial, so
|
|
72
|
+
// no order is stranded mid-charge.
|
|
73
|
+
//
|
|
74
|
+
// Retry rides ONLY on idempotent dials — GET reads and writes carrying an
|
|
75
|
+
// idempotency key (Stripe Idempotency-Key / PayPal-Request-Id). A
|
|
76
|
+
// keyless POST is sent exactly once: re-driving it could double-charge.
|
|
77
|
+
var BREAKER_FAILURE_THRESHOLD = 5; // consecutive failures before opening
|
|
78
|
+
var BREAKER_COOLDOWN_MS = C.TIME.seconds(30); // open → half-open probe delay
|
|
79
|
+
var BREAKER_SUCCESS_THRESHOLD = 2; // half-open probes to re-close
|
|
80
|
+
var DIAL_RETRY_MAX_ATTEMPTS = 3; // total tries incl. first (idempotent only)
|
|
81
|
+
var DIAL_RETRY_BASE_DELAY_MS = 200;
|
|
82
|
+
var DIAL_RETRY_MAX_DELAY_MS = C.TIME.seconds(2);
|
|
83
|
+
|
|
84
|
+
// One breaker per adapter instance. `idempotent` selects whether the dial
|
|
85
|
+
// also rides the bounded backoff retry — the breaker counts the retry loop's
|
|
86
|
+
// FINAL outcome as a single call (b.retry.withBreaker), so a transient blip
|
|
87
|
+
// that the retry rides out never inflates the failure counter.
|
|
88
|
+
function _makeBreaker(name) {
|
|
89
|
+
return b.circuitBreaker.create({
|
|
90
|
+
name: name,
|
|
91
|
+
failureThreshold: BREAKER_FAILURE_THRESHOLD,
|
|
92
|
+
cooldownMs: BREAKER_COOLDOWN_MS,
|
|
93
|
+
successThreshold: BREAKER_SUCCESS_THRESHOLD,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _dial(breaker, idempotent, fn) {
|
|
98
|
+
if (!breaker) return fn();
|
|
99
|
+
if (!idempotent) return breaker.wrap(fn);
|
|
100
|
+
return b.retry.withBreaker(fn, {
|
|
101
|
+
breaker: breaker,
|
|
102
|
+
retry: {
|
|
103
|
+
maxAttempts: DIAL_RETRY_MAX_ATTEMPTS,
|
|
104
|
+
baseDelayMs: DIAL_RETRY_BASE_DELAY_MS,
|
|
105
|
+
maxDelayMs: DIAL_RETRY_MAX_DELAY_MS,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
51
110
|
// Stripe holds idempotency keys for 24h, so the local cache row
|
|
52
111
|
// expires on the same window — operators who run `cleanupExpired()`
|
|
53
112
|
// on a daily schedule keep the table small without ever shortening
|
|
@@ -196,33 +255,47 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
|
|
|
196
255
|
headers["idempotency-key"] = idempotencyKey;
|
|
197
256
|
}
|
|
198
257
|
var httpClient = opts.httpClient || b.httpClient;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
258
|
+
// A GET read is always idempotent; a write is idempotent only when it
|
|
259
|
+
// carries an Idempotency-Key (Stripe dedupes a replay of the SAME key
|
|
260
|
+
// server-side). A keyless POST rides the breaker but NOT the retry, so
|
|
261
|
+
// a transient blip never re-sends it (double-charge guard).
|
|
262
|
+
var idempotent = method === "GET" || !!idempotencyKey;
|
|
263
|
+
// The HTTP request AND the non-2xx → throw both run INSIDE the dialed
|
|
264
|
+
// closure so the breaker counts a 5xx / transport error as a failure
|
|
265
|
+
// (and the idempotent-path retry retries it). A 4xx is the request's
|
|
266
|
+
// fault, not the peer's health — `err.statusCode` makes the retry
|
|
267
|
+
// classifier skip it, and a 4xx still increments the breaker, which is
|
|
268
|
+
// acceptable since a sustained stream of 4xx is itself a degraded state.
|
|
269
|
+
var json = await _dial(opts._breaker, idempotent, async function () {
|
|
270
|
+
var res = await httpClient.request({
|
|
271
|
+
method: method,
|
|
272
|
+
url: url,
|
|
273
|
+
headers: headers,
|
|
274
|
+
body: body || undefined,
|
|
275
|
+
timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
|
|
276
|
+
agent: _PSP_TLS_AGENT,
|
|
277
|
+
});
|
|
278
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
279
|
+
var parsed = null;
|
|
280
|
+
try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
|
|
281
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
282
|
+
var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
|
|
283
|
+
(parsed && parsed.error && parsed.error.message ? " — " + parsed.error.message : ""));
|
|
284
|
+
err.code = (parsed && parsed.error && parsed.error.code) || "STRIPE_HTTP_" + res.statusCode;
|
|
285
|
+
err.statusCode = res.statusCode;
|
|
286
|
+
err.stripe = parsed && parsed.error || null;
|
|
287
|
+
err._stripeRawText = text;
|
|
288
|
+
err._stripeStatus = res.statusCode;
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
// Carry the raw status + serialised body alongside the parsed JSON
|
|
292
|
+
// so the idempotency layer can persist them verbatim for replay
|
|
293
|
+
// without re-stringifying (preserves byte-for-byte fidelity with
|
|
294
|
+
// what Stripe returned, including field ordering).
|
|
295
|
+
Object.defineProperty(parsed, "_stripeStatus", { value: res.statusCode, enumerable: false });
|
|
296
|
+
Object.defineProperty(parsed, "_stripeRawText", { value: text, enumerable: false });
|
|
297
|
+
return parsed;
|
|
206
298
|
});
|
|
207
|
-
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
208
|
-
var json = null;
|
|
209
|
-
try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
|
|
210
|
-
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
211
|
-
var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
|
|
212
|
-
(json && json.error && json.error.message ? " — " + json.error.message : ""));
|
|
213
|
-
err.code = (json && json.error && json.error.code) || "STRIPE_HTTP_" + res.statusCode;
|
|
214
|
-
err.statusCode = res.statusCode;
|
|
215
|
-
err.stripe = json && json.error || null;
|
|
216
|
-
err._stripeRawText = text;
|
|
217
|
-
err._stripeStatus = res.statusCode;
|
|
218
|
-
throw err;
|
|
219
|
-
}
|
|
220
|
-
// Carry the raw status + serialised body alongside the parsed JSON
|
|
221
|
-
// so the idempotency layer can persist them verbatim for replay
|
|
222
|
-
// without re-stringifying (preserves byte-for-byte fidelity with
|
|
223
|
-
// what Stripe returned, including field ordering).
|
|
224
|
-
Object.defineProperty(json, "_stripeStatus", { value: res.statusCode, enumerable: false });
|
|
225
|
-
Object.defineProperty(json, "_stripeRawText", { value: text, enumerable: false });
|
|
226
299
|
return json;
|
|
227
300
|
}
|
|
228
301
|
|
|
@@ -330,6 +403,13 @@ function stripe(opts) {
|
|
|
330
403
|
throw new TypeError("payment: now must be a function returning current epoch ms");
|
|
331
404
|
}
|
|
332
405
|
|
|
406
|
+
// One circuit breaker per adapter instance, guarding every Stripe dial.
|
|
407
|
+
// Stashed on opts so the module-level `_stripeCall` reaches it without a
|
|
408
|
+
// signature change. Skippable in tests via `breaker: false`.
|
|
409
|
+
if (opts._breaker === undefined) {
|
|
410
|
+
opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-stripe");
|
|
411
|
+
}
|
|
412
|
+
|
|
333
413
|
// Idempotency state shared across every mutating call. When `query`
|
|
334
414
|
// is not supplied the primitive runs in legacy mode — every
|
|
335
415
|
// mutating call goes straight to Stripe, no cache writes, no
|
|
@@ -349,6 +429,11 @@ function stripe(opts) {
|
|
|
349
429
|
return {
|
|
350
430
|
name: "stripe",
|
|
351
431
|
|
|
432
|
+
// The per-adapter circuit breaker (or null when disabled). Exposed so
|
|
433
|
+
// an operator dashboard can read `breaker.getState()` ("closed" /
|
|
434
|
+
// "open" / "half") and reset it after a confirmed recovery.
|
|
435
|
+
breaker: opts._breaker,
|
|
436
|
+
|
|
352
437
|
verifyWebhook: function (headers, rawBody, vOpts) {
|
|
353
438
|
return _verifyWebhook(headers, rawBody, opts.webhookSecret, vOpts);
|
|
354
439
|
},
|
|
@@ -648,28 +733,33 @@ async function _paypalToken(opts, state) {
|
|
|
648
733
|
if (state.token && now < state.tokenExpiresAt) return state.token;
|
|
649
734
|
var httpClient = opts.httpClient || b.httpClient;
|
|
650
735
|
var basic = Buffer.from(opts.clientId + ":" + opts.secret).toString("base64");
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
736
|
+
// The client-credentials token exchange is idempotent (re-asking for a
|
|
737
|
+
// token is always safe), so it rides the breaker AND the bounded retry.
|
|
738
|
+
var json = await _dial(opts._breaker, true, async function () {
|
|
739
|
+
var res = await httpClient.request({
|
|
740
|
+
method: "POST",
|
|
741
|
+
url: _paypalApiBase(opts) + "/v1/oauth2/token",
|
|
742
|
+
headers: {
|
|
743
|
+
"authorization": "Basic " + basic,
|
|
744
|
+
"accept": "application/json",
|
|
745
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
746
|
+
"user-agent": "blamejs-shop (zero-dep)",
|
|
747
|
+
},
|
|
748
|
+
body: "grant_type=client_credentials",
|
|
749
|
+
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
750
|
+
agent: _PSP_TLS_AGENT,
|
|
751
|
+
});
|
|
752
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
753
|
+
var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = {}; }
|
|
754
|
+
if (res.statusCode < 200 || res.statusCode >= 300 || !parsed.access_token) {
|
|
755
|
+
var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
|
|
756
|
+
(parsed && parsed.error_description ? " — " + parsed.error_description : ""));
|
|
757
|
+
err.code = "PAYPAL_AUTH_" + res.statusCode;
|
|
758
|
+
err.statusCode = res.statusCode;
|
|
759
|
+
throw err;
|
|
760
|
+
}
|
|
761
|
+
return parsed;
|
|
663
762
|
});
|
|
664
|
-
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
665
|
-
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = {}; }
|
|
666
|
-
if (res.statusCode < 200 || res.statusCode >= 300 || !json.access_token) {
|
|
667
|
-
var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
|
|
668
|
-
(json && json.error_description ? " — " + json.error_description : ""));
|
|
669
|
-
err.code = "PAYPAL_AUTH_" + res.statusCode;
|
|
670
|
-
err.statusCode = res.statusCode;
|
|
671
|
-
throw err;
|
|
672
|
-
}
|
|
673
763
|
state.token = json.access_token;
|
|
674
764
|
var ttlMs = (typeof json.expires_in === "number" ? json.expires_in : 0) * 1000; // allow:raw-time-literal — PayPal expires_in is a runtime seconds value; *1000 → ms
|
|
675
765
|
state.tokenExpiresAt = now + Math.max(0, ttlMs - PAYPAL_TOKEN_SKEW_MS);
|
|
@@ -688,26 +778,34 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
|
|
|
688
778
|
var body = bodyObj != null ? JSON.stringify(bodyObj) : undefined;
|
|
689
779
|
if (body) headers["content-length"] = Buffer.byteLength(body, "utf8");
|
|
690
780
|
var httpClient = opts.httpClient || b.httpClient;
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
781
|
+
// A GET is idempotent; a write is idempotent only when it carries a
|
|
782
|
+
// PayPal-Request-Id (PayPal dedupes a replay of the SAME id, and the
|
|
783
|
+
// same id rides every retry attempt within one call). A keyless write
|
|
784
|
+
// rides the breaker but not the retry.
|
|
785
|
+
var idempotent = method === "GET" || !!requestId;
|
|
786
|
+
var json = await _dial(opts._breaker, idempotent, async function () {
|
|
787
|
+
var res = await httpClient.request({
|
|
788
|
+
method: method,
|
|
789
|
+
url: _paypalApiBase(opts) + path,
|
|
790
|
+
headers: headers,
|
|
791
|
+
body: body,
|
|
792
|
+
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
793
|
+
agent: _PSP_TLS_AGENT,
|
|
794
|
+
});
|
|
795
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
796
|
+
var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
|
|
797
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
798
|
+
var detail = parsed && (parsed.message || (parsed.details && parsed.details[0] && parsed.details[0].description)) || "";
|
|
799
|
+
var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
|
|
800
|
+
err.code = (parsed && parsed.name) || "PAYPAL_HTTP_" + res.statusCode;
|
|
801
|
+
err.statusCode = res.statusCode;
|
|
802
|
+
err.paypal = parsed || null;
|
|
803
|
+
throw err;
|
|
804
|
+
}
|
|
805
|
+
Object.defineProperty(parsed, "_paypalStatus", { value: res.statusCode, enumerable: false });
|
|
806
|
+
Object.defineProperty(parsed, "_paypalRawText", { value: text, enumerable: false });
|
|
807
|
+
return parsed;
|
|
698
808
|
});
|
|
699
|
-
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
700
|
-
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
|
|
701
|
-
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
702
|
-
var detail = json && (json.message || (json.details && json.details[0] && json.details[0].description)) || "";
|
|
703
|
-
var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
|
|
704
|
-
err.code = (json && json.name) || "PAYPAL_HTTP_" + res.statusCode;
|
|
705
|
-
err.statusCode = res.statusCode;
|
|
706
|
-
err.paypal = json || null;
|
|
707
|
-
throw err;
|
|
708
|
-
}
|
|
709
|
-
Object.defineProperty(json, "_paypalStatus", { value: res.statusCode, enumerable: false });
|
|
710
|
-
Object.defineProperty(json, "_paypalRawText", { value: text, enumerable: false });
|
|
711
809
|
return json;
|
|
712
810
|
}
|
|
713
811
|
|
|
@@ -721,6 +819,13 @@ function paypal(opts) {
|
|
|
721
819
|
if (opts.now != null && typeof opts.now !== "function") {
|
|
722
820
|
throw new TypeError("payment: now must be a function returning current epoch ms");
|
|
723
821
|
}
|
|
822
|
+
// One circuit breaker per adapter instance, guarding every PayPal dial
|
|
823
|
+
// (the token exchange + every Orders-v2 call). Skippable in tests via
|
|
824
|
+
// `breaker: false`.
|
|
825
|
+
if (opts._breaker === undefined) {
|
|
826
|
+
opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-paypal");
|
|
827
|
+
}
|
|
828
|
+
|
|
724
829
|
var state = {
|
|
725
830
|
query: opts.query || null,
|
|
726
831
|
now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
|
|
@@ -744,6 +849,10 @@ function paypal(opts) {
|
|
|
744
849
|
return {
|
|
745
850
|
name: "paypal",
|
|
746
851
|
|
|
852
|
+
// The per-adapter circuit breaker (or null when disabled). Same
|
|
853
|
+
// operator-dashboard surface as the Stripe adapter's.
|
|
854
|
+
breaker: opts._breaker,
|
|
855
|
+
|
|
747
856
|
// Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
|
|
748
857
|
// PayPal order id the buyer approves; `captureOrder` finalizes it.
|
|
749
858
|
createOrder: function (input, idempotencyKey) {
|