@blamejs/blamejs-shop 0.4.27 → 0.4.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/SECURITY.md +22 -0
- package/lib/admin.js +197 -39
- package/lib/asset-manifest.json +3 -3
- package/lib/auto-discount.js +177 -6
- package/lib/checkout.js +550 -135
- package/lib/gift-card-ledger.js +208 -88
- package/lib/giftcards.js +56 -0
- package/lib/loyalty.js +61 -1
- package/lib/order.js +69 -3
- package/lib/payment.js +113 -7
- package/lib/refund-automation.js +50 -7
- package/lib/security-middleware.js +39 -0
- package/lib/store-credit.js +99 -79
- package/lib/storefront.js +9 -2
- package/package.json +1 -1
package/lib/payment.js
CHANGED
|
@@ -438,12 +438,43 @@ async function _runIdempotent(state, operation, key, requestObj, doCall) {
|
|
|
438
438
|
? result._stripeRawText
|
|
439
439
|
: JSON.stringify(result);
|
|
440
440
|
|
|
441
|
-
|
|
441
|
+
// Atomic claim: two concurrent same-key calls both miss the lookup above
|
|
442
|
+
// and both reach here — ON CONFLICT DO NOTHING lets exactly one cache its
|
|
443
|
+
// response while the loser defers to the winner's row instead of dying on
|
|
444
|
+
// the PRIMARY KEY violation. Never OR REPLACE / DO UPDATE: the loser must
|
|
445
|
+
// not overwrite the winner's cached response, and a same-key racer with a
|
|
446
|
+
// DIFFERENT body must still hit the collision refusal below.
|
|
447
|
+
var ins = await query(
|
|
442
448
|
"INSERT INTO payment_idempotency " +
|
|
443
449
|
"(idempotency_key, operation, request_hash, response_status, response_body, created_at, expires_at) " +
|
|
444
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
|
|
450
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) " +
|
|
451
|
+
"ON CONFLICT(idempotency_key) DO NOTHING",
|
|
445
452
|
[key, operation, requestHash, status, rawText, now, now + IDEMPOTENCY_TTL_MS],
|
|
446
453
|
);
|
|
454
|
+
var changes = ins && (ins.rowCount != null ? ins.rowCount
|
|
455
|
+
: (ins.meta && ins.meta.changes != null ? ins.meta.changes : ins.changes));
|
|
456
|
+
if (Number(changes || 0) === 0) {
|
|
457
|
+
// A concurrent call claimed the key first. Replay its cached row —
|
|
458
|
+
// unless our request body differs, which is the same collision the
|
|
459
|
+
// up-front check refuses.
|
|
460
|
+
var winner = (await query(
|
|
461
|
+
"SELECT request_hash, response_status, response_body " +
|
|
462
|
+
"FROM payment_idempotency WHERE idempotency_key = ?1 LIMIT 1",
|
|
463
|
+
[key],
|
|
464
|
+
)).rows[0];
|
|
465
|
+
if (winner && winner.request_hash !== requestHash) {
|
|
466
|
+
throw new TypeError("payment: idempotency_key collision (different inputs)");
|
|
467
|
+
}
|
|
468
|
+
if (winner) {
|
|
469
|
+
var winnerReplay = null;
|
|
470
|
+
try { winnerReplay = JSON.parse(winner.response_body); } catch (_e) { winnerReplay = { _raw: winner.response_body }; }
|
|
471
|
+
Object.defineProperty(winnerReplay, "_stripeStatus", { value: Number(winner.response_status), enumerable: false });
|
|
472
|
+
Object.defineProperty(winnerReplay, "_replayed", { value: true, enumerable: false });
|
|
473
|
+
return winnerReplay;
|
|
474
|
+
}
|
|
475
|
+
// Conflicted but the row vanished (TTL purge between the two
|
|
476
|
+
// statements) — fall through: our own result is still the outcome.
|
|
477
|
+
}
|
|
447
478
|
|
|
448
479
|
return result;
|
|
449
480
|
}
|
|
@@ -784,6 +815,32 @@ function _minorToDecimalString(minor, currency) {
|
|
|
784
815
|
return (neg ? "-" : "") + s.slice(0, s.length - dec) + "." + s.slice(s.length - dec);
|
|
785
816
|
}
|
|
786
817
|
|
|
818
|
+
// Inverse of _minorToDecimalString: parse a PayPal decimal amount string
|
|
819
|
+
// (e.g. webhook `resource.amount.value`) into exact integer minor units,
|
|
820
|
+
// using the same zero-decimal currency table. STRICT — money parsed off an
|
|
821
|
+
// inbound webhook decides refund accounting, so a malformed shape throws a
|
|
822
|
+
// TypeError rather than guessing (the caller maps that to a 5xx so the
|
|
823
|
+
// processor re-delivers; a guessed amount would silently mis-credit). Pure
|
|
824
|
+
// digit-string arithmetic — the value never passes through a float.
|
|
825
|
+
function _decimalToMinor(value, currency) {
|
|
826
|
+
if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) {
|
|
827
|
+
throw new TypeError("payment: decimal amount currency must be a 3-letter uppercase ISO 4217 code");
|
|
828
|
+
}
|
|
829
|
+
if (typeof value !== "string" || !/^\d{1,15}(\.\d{1,2})?$/.test(value)) {
|
|
830
|
+
throw new TypeError("payment: decimal amount must be a plain non-negative decimal string (got " + JSON.stringify(value) + ")");
|
|
831
|
+
}
|
|
832
|
+
var dec = PAYPAL_ZERO_DECIMAL[currency] ? 0 : 2;
|
|
833
|
+
var parts = value.split(".");
|
|
834
|
+
var frac = parts[1] || "";
|
|
835
|
+
if (frac.length > dec) {
|
|
836
|
+
// More fractional digits than the currency carries (e.g. "100.50" JPY)
|
|
837
|
+
// is a garbled amount, not a roundable one — refuse, never round money.
|
|
838
|
+
throw new TypeError("payment: decimal amount " + JSON.stringify(value) + " has more fractional digits than " + currency + " allows");
|
|
839
|
+
}
|
|
840
|
+
while (frac.length < dec) frac += "0";
|
|
841
|
+
return parseInt(parts[0] + frac, 10);
|
|
842
|
+
}
|
|
843
|
+
|
|
787
844
|
function _headerCI(headers, name) {
|
|
788
845
|
if (!headers) return undefined;
|
|
789
846
|
if (headers[name] != null) return headers[name];
|
|
@@ -833,7 +890,13 @@ async function _paypalToken(opts, state) {
|
|
|
833
890
|
return state.token;
|
|
834
891
|
}
|
|
835
892
|
|
|
836
|
-
|
|
893
|
+
// `breaker` selects which circuit the dial rides — every payment call rides
|
|
894
|
+
// the adapter's main `opts._breaker`; the webhook-verification dial rides its
|
|
895
|
+
// own (see verifyWebhook) so attacker-shaped verification traffic can't trip
|
|
896
|
+
// the circuit live checkouts depend on. The token exchange inside always
|
|
897
|
+
// rides the main breaker: its failures are credential/PayPal-health signals,
|
|
898
|
+
// not attacker-controllable per-request outcomes.
|
|
899
|
+
async function _paypalCall(opts, state, method, path, bodyObj, requestId, breaker) {
|
|
837
900
|
var token = await _paypalToken(opts, state);
|
|
838
901
|
var headers = {
|
|
839
902
|
"authorization": "Bearer " + token,
|
|
@@ -855,7 +918,7 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
|
|
|
855
918
|
// same id rides every retry attempt within one call). A keyless write
|
|
856
919
|
// rides the breaker but not the retry.
|
|
857
920
|
var idempotent = method === "GET" || !!requestId;
|
|
858
|
-
var json = await _dial(opts._breaker, idempotent, async function () {
|
|
921
|
+
var json = await _dial(breaker === undefined ? opts._breaker : breaker, idempotent, async function () {
|
|
859
922
|
var res = await httpClient.request({
|
|
860
923
|
method: method,
|
|
861
924
|
url: _paypalApiBase(opts) + path,
|
|
@@ -898,6 +961,17 @@ function paypal(opts) {
|
|
|
898
961
|
if (opts._breaker === undefined) {
|
|
899
962
|
opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-paypal");
|
|
900
963
|
}
|
|
964
|
+
// SEPARATE breaker for the webhook-verification dial. The verify call's
|
|
965
|
+
// failure rate is attacker-influenceable: any header-complete spam POST to
|
|
966
|
+
// the (necessarily unauthenticated) webhook route triggers a
|
|
967
|
+
// verify-webhook-signature dial whose 4xx counts as a breaker failure —
|
|
968
|
+
// five consecutive spam posts would otherwise open the SAME circuit live
|
|
969
|
+
// checkout's createOrder/captureOrder ride and fast-fail real payments for
|
|
970
|
+
// the cooldown window. Verification failures say nothing about PayPal's
|
|
971
|
+
// health as a payments peer, so they account against their own circuit.
|
|
972
|
+
if (opts._verifyBreaker === undefined) {
|
|
973
|
+
opts._verifyBreaker = opts.breaker === false ? null : _makeBreaker("psp-paypal-verify");
|
|
974
|
+
}
|
|
901
975
|
|
|
902
976
|
var state = {
|
|
903
977
|
query: opts.query || null,
|
|
@@ -923,8 +997,11 @@ function paypal(opts) {
|
|
|
923
997
|
name: "paypal",
|
|
924
998
|
|
|
925
999
|
// The per-adapter circuit breaker (or null when disabled). Same
|
|
926
|
-
// operator-dashboard surface as the Stripe adapter's.
|
|
927
|
-
|
|
1000
|
+
// operator-dashboard surface as the Stripe adapter's. `verifyBreaker`
|
|
1001
|
+
// is the webhook-verification dial's own circuit — kept separate so
|
|
1002
|
+
// spam against the webhook route can't open the payment circuit.
|
|
1003
|
+
breaker: opts._breaker,
|
|
1004
|
+
verifyBreaker: opts._verifyBreaker,
|
|
928
1005
|
|
|
929
1006
|
// Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
|
|
930
1007
|
// PayPal order id the buyer approves; `captureOrder` finalizes it.
|
|
@@ -1020,7 +1097,10 @@ function paypal(opts) {
|
|
|
1020
1097
|
webhook_event: event,
|
|
1021
1098
|
};
|
|
1022
1099
|
var res;
|
|
1023
|
-
|
|
1100
|
+
// Rides the verify-only breaker (opts._verifyBreaker), never the main
|
|
1101
|
+
// payment circuit — see the factory comment: verification traffic is
|
|
1102
|
+
// attacker-shaped and must not be able to fast-fail live checkouts.
|
|
1103
|
+
try { res = await _paypalCall(opts, state, "POST", "/v1/notifications/verify-webhook-signature", verifyBody, null, opts._verifyBreaker); }
|
|
1024
1104
|
catch (e) { return { ok: false, reason: "verify-call-failed", error: e && e.message }; }
|
|
1025
1105
|
if (res && res.verification_status === "SUCCESS") return { ok: true, event: event };
|
|
1026
1106
|
return { ok: false, reason: "verification-status-" + ((res && res.verification_status) || "unknown") };
|
|
@@ -1036,14 +1116,40 @@ function create(opts) {
|
|
|
1036
1116
|
throw new TypeError("payment.create: unknown adapter " + JSON.stringify(opts.adapter) + " — 'stripe' and 'paypal' are supported");
|
|
1037
1117
|
}
|
|
1038
1118
|
|
|
1119
|
+
// Boot-time PayPal configuration lint, called by the server entry point so
|
|
1120
|
+
// an incomplete env surfaces in the boot log instead of as a silent feature
|
|
1121
|
+
// gap. Returns an array of operator-actionable warning strings (empty when
|
|
1122
|
+
// nothing is wrong). Pure read of the supplied env map — never throws, never
|
|
1123
|
+
// changes behavior: webhook verification stays MANDATORY and fails closed
|
|
1124
|
+
// whether or not the operator saw the warning.
|
|
1125
|
+
function paypalConfigWarnings(env) {
|
|
1126
|
+
env = env && typeof env === "object" ? env : {};
|
|
1127
|
+
var warnings = [];
|
|
1128
|
+
if (env.PAYPAL_CLIENT_ID && env.PAYPAL_SECRET && !env.PAYPAL_WEBHOOK_ID) {
|
|
1129
|
+
warnings.push(
|
|
1130
|
+
"PAYPAL_WEBHOOK_ID is not set: PayPal checkout is configured, but every " +
|
|
1131
|
+
"/api/webhooks/paypal delivery will be refused (verification fails closed " +
|
|
1132
|
+
"without the webhook id), so out-of-band captures and refunds will not " +
|
|
1133
|
+
"reach the order ledger. Set PAYPAL_WEBHOOK_ID to the webhook id from the " +
|
|
1134
|
+
"PayPal developer dashboard.");
|
|
1135
|
+
}
|
|
1136
|
+
return warnings;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1039
1139
|
module.exports = {
|
|
1040
1140
|
create: create,
|
|
1041
1141
|
stripe: stripe,
|
|
1042
1142
|
paypal: paypal,
|
|
1143
|
+
paypalConfigWarnings: paypalConfigWarnings,
|
|
1043
1144
|
STRIPE_WEBHOOK_TOLERANCE: STRIPE_WEBHOOK_TOLERANCE,
|
|
1044
1145
|
IDEMPOTENCY_TTL_MS: IDEMPOTENCY_TTL_MS,
|
|
1045
1146
|
// Exposed for tests + Worker to share form-encoding shape.
|
|
1046
1147
|
_formEncode: _formEncode,
|
|
1047
1148
|
_verifyWebhook: _verifyWebhook,
|
|
1048
1149
|
_canonicalHash: _canonicalHash,
|
|
1150
|
+
// Exposed for the checkout webhook mirror + admin refund normalization —
|
|
1151
|
+
// exact decimal-string ↔ minor-unit conversion sharing one zero-decimal
|
|
1152
|
+
// currency table.
|
|
1153
|
+
_decimalToMinor: _decimalToMinor,
|
|
1154
|
+
_minorToDecimalString: _minorToDecimalString,
|
|
1049
1155
|
};
|
package/lib/refund-automation.js
CHANGED
|
@@ -342,6 +342,11 @@ function create(opts) {
|
|
|
342
342
|
var refundPolicyHandle = opts.refundPolicy || null;
|
|
343
343
|
var returnsHandle = opts.returns || null;
|
|
344
344
|
var paymentHandle = opts.payment || null;
|
|
345
|
+
// PayPal adapter — PayPal-captured orders refund against their CAPTURE id
|
|
346
|
+
// through this handle (executeAutoRefund routes by the request's
|
|
347
|
+
// `provider`). Absent, PayPal-provider requests are refused with a clear
|
|
348
|
+
// error instead of dialing Stripe with a PayPal id.
|
|
349
|
+
var paypalHandle = opts.paypal || null;
|
|
345
350
|
var riskProfileHandle = opts.customerRiskProfile || null;
|
|
346
351
|
|
|
347
352
|
// Per-factory monotonic clock. Two decisions written against the
|
|
@@ -664,6 +669,28 @@ function create(opts) {
|
|
|
664
669
|
}
|
|
665
670
|
paymentIntent = input.payment_intent;
|
|
666
671
|
}
|
|
672
|
+
// Which provider captured the order's charge — routes the refund dial.
|
|
673
|
+
// 'stripe' (default — the original surface) refunds the payment_intent
|
|
674
|
+
// through the `payment` handle; 'paypal' refunds the CAPTURE
|
|
675
|
+
// (`paypal_capture_id`, never the PayPal order id) through the `paypal`
|
|
676
|
+
// handle, with the amount named in `currency`. Validated up front so a
|
|
677
|
+
// request can never reach the wrong provider's API.
|
|
678
|
+
var provider = input.provider == null ? "stripe" : input.provider;
|
|
679
|
+
if (provider !== "stripe" && provider !== "paypal") {
|
|
680
|
+
throw new TypeError("refundAutomation.executeAutoRefund: provider must be 'stripe' or 'paypal'");
|
|
681
|
+
}
|
|
682
|
+
var paypalCaptureId = null;
|
|
683
|
+
var paypalCurrency = null;
|
|
684
|
+
if (provider === "paypal") {
|
|
685
|
+
if (typeof input.paypal_capture_id !== "string" || !input.paypal_capture_id.length) {
|
|
686
|
+
throw new TypeError("refundAutomation.executeAutoRefund: paypal_capture_id required when provider is 'paypal'");
|
|
687
|
+
}
|
|
688
|
+
if (typeof input.currency !== "string" || !CURRENCY_RE.test(input.currency)) {
|
|
689
|
+
throw new TypeError("refundAutomation.executeAutoRefund: currency (ISO 4217 alpha) required when provider is 'paypal'");
|
|
690
|
+
}
|
|
691
|
+
paypalCaptureId = input.paypal_capture_id;
|
|
692
|
+
paypalCurrency = input.currency;
|
|
693
|
+
}
|
|
667
694
|
|
|
668
695
|
var verdict = await evaluateForRefundRequest({
|
|
669
696
|
order_id: orderId,
|
|
@@ -685,20 +712,36 @@ function create(opts) {
|
|
|
685
712
|
[id, orderId, customerId, verdict.applied_rule, amount, reason, ts],
|
|
686
713
|
);
|
|
687
714
|
|
|
688
|
-
// Compose
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
715
|
+
// Compose the provider refund when the matching handle is wired,
|
|
716
|
+
// routed by `provider`. Absent the matching handle the primitive still
|
|
717
|
+
// records the decision so the operator can drive the actual refund
|
|
718
|
+
// out-of-band — EXCEPT a PayPal request with no PayPal handle, which is
|
|
719
|
+
// refused up front (falling through to the Stripe handle would dial
|
|
720
|
+
// Stripe with PayPal identifiers). The decision row above is already
|
|
721
|
+
// written either way, mirroring the decision-before-payment ordering.
|
|
693
722
|
var paymentResult = null;
|
|
694
|
-
if (
|
|
723
|
+
if (provider === "paypal") {
|
|
724
|
+
if (paypalHandle && typeof paypalHandle.refund === "function") {
|
|
725
|
+
// Each auto-refund decision is its own refund — the decision id as
|
|
726
|
+
// the idempotency key keeps a retry of THIS decision deduplicated at
|
|
727
|
+
// PayPal (the adapter folds it into the PayPal-Request-Id) while two
|
|
728
|
+
// distinct decisions on the same capture stay distinct refunds.
|
|
729
|
+
paymentResult = await paypalHandle.refund({
|
|
730
|
+
capture_id: paypalCaptureId,
|
|
731
|
+
amount_minor: amount,
|
|
732
|
+
currency: paypalCurrency,
|
|
733
|
+
}, "auto-refund:" + id);
|
|
734
|
+
} else if (paymentHandle) {
|
|
735
|
+
throw new TypeError("refundAutomation.executeAutoRefund: provider is 'paypal' but no paypal handle is wired — refusing to refund PayPal identifiers through the Stripe handle");
|
|
736
|
+
}
|
|
737
|
+
} else if (paymentHandle && typeof paymentHandle.refund === "function") {
|
|
695
738
|
var refundInput = {
|
|
696
739
|
amount_minor: amount,
|
|
697
740
|
reason: reason,
|
|
698
741
|
metadata: { order_id: orderId, customer_id: customerId, applied_rule: verdict.applied_rule },
|
|
699
742
|
};
|
|
700
743
|
if (paymentIntent != null) refundInput.payment_intent = paymentIntent;
|
|
701
|
-
paymentResult = await paymentHandle.refund(refundInput);
|
|
744
|
+
paymentResult = await paymentHandle.refund(refundInput, "auto-refund:" + id);
|
|
702
745
|
}
|
|
703
746
|
|
|
704
747
|
return {
|
|
@@ -77,6 +77,14 @@ var WEBHOOK_PATHS = [
|
|
|
77
77
|
// could wedge the health signal.
|
|
78
78
|
var HEALTH_PATH = "/_/health";
|
|
79
79
|
|
|
80
|
+
// Per-client-IP budget on POST /api/webhooks/paypal (see the limiter in
|
|
81
|
+
// mountRouteGuards). Generous against PayPal's real delivery cadence —
|
|
82
|
+
// redelivery is ~25 attempts per event spread over days, so even a
|
|
83
|
+
// post-downtime backlog flush of distinct events sits far under this —
|
|
84
|
+
// while bounding how many verify-webhook-signature dials a spammer can
|
|
85
|
+
// force per minute.
|
|
86
|
+
var PAYPAL_WEBHOOK_BUDGET_PER_MINUTE = 120;
|
|
87
|
+
|
|
80
88
|
// Worker→container internal endpoints — machine-to-machine POSTs over
|
|
81
89
|
// the Cloudflare service binding (cron ticks + the InventoryLock DO's
|
|
82
90
|
// low-stock event), each authenticated FIRST thing in its handler by a
|
|
@@ -745,6 +753,36 @@ function mountRouteGuards(r) {
|
|
|
745
753
|
return clientKey(req) + "|" + (req.pathname || req.url || "/");
|
|
746
754
|
},
|
|
747
755
|
});
|
|
756
|
+
// --- PayPal webhook per-IP budget -----------------------------------
|
|
757
|
+
//
|
|
758
|
+
// The webhook paths are exempt from the global + tight limiters above
|
|
759
|
+
// (a processor's server-to-server POST is unthrottleable by a human
|
|
760
|
+
// budget), but /api/webhooks/paypal is uniquely expensive to probe:
|
|
761
|
+
// verification is a server-to-server dial to PayPal's
|
|
762
|
+
// verify-webhook-signature API, so every header-complete spam POST costs
|
|
763
|
+
// an outbound request. The adapter's verify dial rides its own circuit
|
|
764
|
+
// (never the payment circuit — lib/payment.js), so spam can't fast-fail
|
|
765
|
+
// checkouts; this budget bounds the outbound dial volume itself. Sized
|
|
766
|
+
// for PayPal's real delivery shape: legitimate redelivery after downtime
|
|
767
|
+
// is ~25 attempts per event spread over days, so even a backlog flush of
|
|
768
|
+
// many distinct events sits far under this per-minute ceiling — and a
|
|
769
|
+
// clipped delivery is never lost: the limiter answers 429, PayPal treats
|
|
770
|
+
// any non-2xx as retry-later and redelivers. Stripe's webhook keeps no
|
|
771
|
+
// budget — its verification is a local HMAC (no dial to amplify).
|
|
772
|
+
var PAYPAL_WEBHOOK_PATH = "/api/webhooks/paypal";
|
|
773
|
+
var paypalWebhookLimiter = b.middleware.rateLimit({
|
|
774
|
+
backend: "memory",
|
|
775
|
+
algorithm: "fixed-window",
|
|
776
|
+
max: PAYPAL_WEBHOOK_BUDGET_PER_MINUTE,
|
|
777
|
+
windowMs: C.TIME.minutes(1),
|
|
778
|
+
keyFn: clientKey,
|
|
779
|
+
});
|
|
780
|
+
r.use(function paypalWebhookRateGuard(req, res, next) {
|
|
781
|
+
var pathname = req.pathname || req.url || "/";
|
|
782
|
+
if (pathname !== PAYPAL_WEBHOOK_PATH) return next();
|
|
783
|
+
return paypalWebhookLimiter(req, res, next);
|
|
784
|
+
});
|
|
785
|
+
|
|
748
786
|
r.use(function tightRateGuard(req, res, next) {
|
|
749
787
|
var pathname = req.pathname || req.url || "/";
|
|
750
788
|
// Never throttle the webhook or health paths.
|
|
@@ -781,6 +819,7 @@ module.exports = {
|
|
|
781
819
|
CSP_HOSTS: CSP_HOSTS,
|
|
782
820
|
WEBHOOK_PATHS: WEBHOOK_PATHS,
|
|
783
821
|
HEALTH_PATH: HEALTH_PATH,
|
|
822
|
+
PAYPAL_WEBHOOK_BUDGET_PER_MINUTE: PAYPAL_WEBHOOK_BUDGET_PER_MINUTE,
|
|
784
823
|
TIGHT_PREFIXES: TIGHT_PREFIXES,
|
|
785
824
|
EDGE_POST_PATHS: EDGE_POST_PATHS,
|
|
786
825
|
PUBLIC_WELL_KNOWN_PATHS: PUBLIC_WELL_KNOWN_PATHS,
|
package/lib/store-credit.js
CHANGED
|
@@ -146,16 +146,17 @@ function create(opts) {
|
|
|
146
146
|
query = function (sql, params) { return b.externalDb.query(sql, params); };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// O(1) current-balance read: the latest row by `occurred_at DESC
|
|
150
|
-
// holds `balance_after_minor` as the denormalized snapshot. No SUM
|
|
149
|
+
// O(1) current-balance read: the latest row by `occurred_at DESC, id
|
|
150
|
+
// DESC` holds `balance_after_minor` as the denormalized snapshot. No SUM
|
|
151
151
|
// aggregation at read time. Falls through to 0 when no rows exist
|
|
152
|
-
// (a customer that has never had a ledger row has zero credit).
|
|
153
|
-
//
|
|
154
|
-
// can
|
|
152
|
+
// (a customer that has never had a ledger row has zero credit). The id
|
|
153
|
+
// tie-break keeps any legacy same-millisecond rows deterministic; new
|
|
154
|
+
// writes can't tie — _writeRowAtomic computes a strictly-monotonic
|
|
155
|
+
// per-customer occurred_at inside the INSERT itself.
|
|
155
156
|
async function _readLatest(customerId) {
|
|
156
157
|
var r = await query(
|
|
157
158
|
"SELECT balance_after_minor, occurred_at FROM store_credit_ledger " +
|
|
158
|
-
"WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
|
|
159
|
+
"WHERE customer_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
|
|
159
160
|
[customerId],
|
|
160
161
|
);
|
|
161
162
|
if (!r.rows.length) return { balance: 0, occurred_at: null };
|
|
@@ -167,27 +168,62 @@ function create(opts) {
|
|
|
167
168
|
return latest.balance;
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
171
|
+
// Single-statement guarded write — the concurrency spine of the wallet.
|
|
172
|
+
// The live balance AND the strictly-monotonic per-customer occurred_at
|
|
173
|
+
// are computed by correlated subqueries INSIDE the INSERT, so two
|
|
174
|
+
// concurrent writes can never base off the same stale snapshot: the
|
|
175
|
+
// statements serialize at the database and the second sees the first's
|
|
176
|
+
// row. (A JS-side read-then-write here double-fulfilled concurrent
|
|
177
|
+
// debits and silently dropped one of two same-millisecond credits.)
|
|
178
|
+
// `kind` selects the guard + arithmetic:
|
|
179
|
+
// credit — balance_after = live + amount, no balance gate (the in-SQL
|
|
180
|
+
// occurred_at is what closes the same-millisecond tie);
|
|
181
|
+
// debit — balance_after = live - amount, gated on live >= amount
|
|
182
|
+
// (zero rows = insufficient, or lost the race — same refusal);
|
|
183
|
+
// expire — burns MIN(amount, live), gated on live > 0 (zero rows =
|
|
184
|
+
// wallet already empty; callers degrade gracefully — by
|
|
185
|
+
// design, never a throw).
|
|
186
|
+
// Returns the written row's resolved values, or null when the guard
|
|
187
|
+
// refused the write.
|
|
188
|
+
async function _writeRowAtomic(kind, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs) {
|
|
183
189
|
var id = b.uuid.v7();
|
|
184
|
-
|
|
190
|
+
var balSub = "COALESCE((SELECT balance_after_minor FROM store_credit_ledger " +
|
|
191
|
+
"WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
|
|
192
|
+
var tsSub = "COALESCE((SELECT occurred_at FROM store_credit_ledger " +
|
|
193
|
+
"WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
|
|
194
|
+
var tsExpr = "CASE WHEN ?8 > " + tsSub + " THEN ?8 ELSE " + tsSub + " + 1 END";
|
|
195
|
+
var amountExpr, afterExpr, guard;
|
|
196
|
+
if (kind === "credit") {
|
|
197
|
+
amountExpr = "?3";
|
|
198
|
+
afterExpr = balSub + " + ?3";
|
|
199
|
+
guard = "1";
|
|
200
|
+
} else if (kind === "debit") {
|
|
201
|
+
amountExpr = "?3";
|
|
202
|
+
afterExpr = balSub + " - ?3";
|
|
203
|
+
guard = balSub + " >= ?3";
|
|
204
|
+
} else { // expire — burn MIN(amount, live balance)
|
|
205
|
+
amountExpr = "CASE WHEN " + balSub + " < ?3 THEN " + balSub + " ELSE ?3 END";
|
|
206
|
+
afterExpr = "CASE WHEN " + balSub + " < ?3 THEN 0 ELSE " + balSub + " - ?3 END";
|
|
207
|
+
guard = balSub + " > 0";
|
|
208
|
+
}
|
|
209
|
+
var res = await query(
|
|
185
210
|
"INSERT INTO store_credit_ledger " +
|
|
186
211
|
"(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at) " +
|
|
187
|
-
"
|
|
188
|
-
|
|
212
|
+
"SELECT ?1, ?2, ?9, " + amountExpr + ", ?4, ?5, ?6, " + afterExpr + ", ?7, " + tsExpr + " " +
|
|
213
|
+
"WHERE " + guard,
|
|
214
|
+
[id, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs, kind],
|
|
189
215
|
);
|
|
190
|
-
return
|
|
216
|
+
if (Number(res.rowCount || 0) === 0) return null;
|
|
217
|
+
var row = (await query(
|
|
218
|
+
"SELECT amount_minor, balance_after_minor, occurred_at FROM store_credit_ledger WHERE id = ?1",
|
|
219
|
+
[id],
|
|
220
|
+
)).rows[0];
|
|
221
|
+
return {
|
|
222
|
+
id: id,
|
|
223
|
+
amount_minor: row.amount_minor,
|
|
224
|
+
balance_after_minor: row.balance_after_minor,
|
|
225
|
+
occurred_at: row.occurred_at,
|
|
226
|
+
};
|
|
191
227
|
}
|
|
192
228
|
|
|
193
229
|
return {
|
|
@@ -206,21 +242,18 @@ function create(opts) {
|
|
|
206
242
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
207
243
|
if (requested == null) requested = _now();
|
|
208
244
|
|
|
209
|
-
var
|
|
210
|
-
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
211
|
-
var after = latest.balance + amount;
|
|
212
|
-
var id = await _writeRow(customerId, "credit", amount, source, sourceRef, null, after, expiresAt, ts);
|
|
245
|
+
var w = await _writeRowAtomic("credit", customerId, amount, source, sourceRef, null, expiresAt, requested);
|
|
213
246
|
|
|
214
247
|
return {
|
|
215
|
-
id: id,
|
|
248
|
+
id: w.id,
|
|
216
249
|
customer_id: customerId,
|
|
217
250
|
kind: "credit",
|
|
218
251
|
amount_minor: amount,
|
|
219
252
|
source: source,
|
|
220
253
|
source_ref: sourceRef,
|
|
221
254
|
expires_at: expiresAt,
|
|
222
|
-
balance_after_minor:
|
|
223
|
-
occurred_at:
|
|
255
|
+
balance_after_minor: w.balance_after_minor,
|
|
256
|
+
occurred_at: w.occurred_at,
|
|
224
257
|
};
|
|
225
258
|
},
|
|
226
259
|
|
|
@@ -234,24 +267,24 @@ function create(opts) {
|
|
|
234
267
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
235
268
|
if (requested == null) requested = _now();
|
|
236
269
|
|
|
237
|
-
|
|
238
|
-
|
|
270
|
+
// The balance gate lives INSIDE the insert — a refused write covers
|
|
271
|
+
// both "always insufficient" and "a concurrent debit drained it
|
|
272
|
+
// first", with no window between check and write.
|
|
273
|
+
var w = await _writeRowAtomic("debit", customerId, amount, null, null, orderId, null, requested);
|
|
274
|
+
if (!w) {
|
|
239
275
|
var insufficient = new Error("storeCredit.debit: amount exceeds available balance");
|
|
240
276
|
insufficient.code = "STORE_CREDIT_INSUFFICIENT_BALANCE";
|
|
241
277
|
throw insufficient;
|
|
242
278
|
}
|
|
243
|
-
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
244
|
-
var after = latest.balance - amount;
|
|
245
|
-
var id = await _writeRow(customerId, "debit", amount, null, null, orderId, after, null, ts);
|
|
246
279
|
|
|
247
280
|
return {
|
|
248
|
-
id: id,
|
|
281
|
+
id: w.id,
|
|
249
282
|
customer_id: customerId,
|
|
250
283
|
kind: "debit",
|
|
251
284
|
amount_minor: amount,
|
|
252
285
|
order_id: orderId,
|
|
253
|
-
balance_after_minor:
|
|
254
|
-
occurred_at:
|
|
286
|
+
balance_after_minor: w.balance_after_minor,
|
|
287
|
+
occurred_at: w.occurred_at,
|
|
255
288
|
};
|
|
256
289
|
},
|
|
257
290
|
|
|
@@ -272,18 +305,15 @@ function create(opts) {
|
|
|
272
305
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
273
306
|
if (requested == null) requested = _now();
|
|
274
307
|
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// structured refusal so the caller can distinguish "already
|
|
285
|
-
// empty" from "actually burned N". A no-op expire is a
|
|
286
|
-
// valid outcome of a bulk sweep — don't throw.
|
|
308
|
+
// Expire caps at the current balance INSIDE the insert — operators
|
|
309
|
+
// running a scheduled sweep over computed "expiring before X"
|
|
310
|
+
// amounts degrade gracefully rather than refusing when an interim
|
|
311
|
+
// debit has already drained the wallet. A refused write (wallet
|
|
312
|
+
// already at zero) is the structured no-op below, never a throw —
|
|
313
|
+
// by design: a no-op expire is a valid outcome of a bulk sweep,
|
|
314
|
+
// and a zero-amount row would violate CHECK(amount_minor > 0).
|
|
315
|
+
var w = await _writeRowAtomic("expire", customerId, amount, null, reason, null, null, requested);
|
|
316
|
+
if (!w) {
|
|
287
317
|
return {
|
|
288
318
|
id: null,
|
|
289
319
|
customer_id: customerId,
|
|
@@ -291,24 +321,21 @@ function create(opts) {
|
|
|
291
321
|
amount_minor: 0,
|
|
292
322
|
requested_minor: amount,
|
|
293
323
|
reason: reason,
|
|
294
|
-
balance_after_minor:
|
|
324
|
+
balance_after_minor: await _currentBalance(customerId),
|
|
295
325
|
occurred_at: requested,
|
|
296
326
|
noop: true,
|
|
297
327
|
};
|
|
298
328
|
}
|
|
299
|
-
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
300
|
-
var after = latest.balance - toBurn;
|
|
301
|
-
var id = await _writeRow(customerId, "expire", toBurn, null, reason, null, after, null, ts);
|
|
302
329
|
|
|
303
330
|
return {
|
|
304
|
-
id: id,
|
|
331
|
+
id: w.id,
|
|
305
332
|
customer_id: customerId,
|
|
306
333
|
kind: "expire",
|
|
307
|
-
amount_minor:
|
|
334
|
+
amount_minor: w.amount_minor,
|
|
308
335
|
requested_minor: amount,
|
|
309
336
|
reason: reason,
|
|
310
|
-
balance_after_minor:
|
|
311
|
-
occurred_at:
|
|
337
|
+
balance_after_minor: w.balance_after_minor,
|
|
338
|
+
occurred_at: w.occurred_at,
|
|
312
339
|
noop: false,
|
|
313
340
|
};
|
|
314
341
|
},
|
|
@@ -585,29 +612,22 @@ function create(opts) {
|
|
|
585
612
|
continue;
|
|
586
613
|
}
|
|
587
614
|
|
|
588
|
-
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
// the
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
// trail
|
|
596
|
-
var
|
|
597
|
-
if (
|
|
598
|
-
// Wallet already empty — record nothing (no CHECK > 0
|
|
599
|
-
// violation). Operator can reconcile via history.
|
|
600
|
-
continue;
|
|
601
|
-
}
|
|
602
|
-
var ts = _resolveOccurredAt(now, latest.occurred_at);
|
|
603
|
-
var after = latest.balance - toBurn;
|
|
604
|
-
var id = await _writeRow(customerId, "expire", toBurn, null, SWEEP_SOURCE_REF, null, after, null, ts);
|
|
615
|
+
// The burn caps at the wallet's current balance INSIDE the
|
|
616
|
+
// guarded insert. Debits between the credit and the sweep may
|
|
617
|
+
// have spent the expired amount already; the write never drives
|
|
618
|
+
// the balance negative, and a wallet already at zero refuses the
|
|
619
|
+
// write entirely (no CHECK > 0 violation; operator reconciles
|
|
620
|
+
// via history). The expired credits were "first-out" from the
|
|
621
|
+
// operator's POV — the schema doesn't track FIFO at row level,
|
|
622
|
+
// so the audit trail reflects what was actually burned.
|
|
623
|
+
var w = await _writeRowAtomic("expire", customerId, pendingBurn, null, SWEEP_SOURCE_REF, null, null, now);
|
|
624
|
+
if (!w) continue;
|
|
605
625
|
processed.push({
|
|
606
|
-
id: id,
|
|
626
|
+
id: w.id,
|
|
607
627
|
customer_id: customerId,
|
|
608
|
-
amount_minor:
|
|
609
|
-
balance_after_minor:
|
|
610
|
-
occurred_at:
|
|
628
|
+
amount_minor: w.amount_minor,
|
|
629
|
+
balance_after_minor: w.balance_after_minor,
|
|
630
|
+
occurred_at: w.occurred_at,
|
|
611
631
|
});
|
|
612
632
|
}
|
|
613
633
|
return { processed: processed, swept_at: now };
|
package/lib/storefront.js
CHANGED
|
@@ -14864,7 +14864,7 @@ function mount(router, deps) {
|
|
|
14864
14864
|
// the buyer must lower a quantity or drop a line; nothing was
|
|
14865
14865
|
// charged and any holds placed mid-confirm were already released.
|
|
14866
14866
|
if (code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0 ||
|
|
14867
|
-
code === "INSUFFICIENT_STOCK") {
|
|
14867
|
+
code === "INSUFFICIENT_STOCK" || code === "AUTO_DISCOUNT_EXHAUSTED") {
|
|
14868
14868
|
try {
|
|
14869
14869
|
var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
|
|
14870
14870
|
if (coLines.length) {
|
|
@@ -14976,6 +14976,10 @@ function mount(router, deps) {
|
|
|
14976
14976
|
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
14977
14977
|
customer: { email: body.email, name: body.name },
|
|
14978
14978
|
gift_card_code: body.gift_card_code || undefined,
|
|
14979
|
+
// Loyalty points the signed-in shopper asked to spend — same
|
|
14980
|
+
// parse + credit handling as the card-form confirm, so both
|
|
14981
|
+
// payment buttons honor identical credits.
|
|
14982
|
+
loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
|
|
14979
14983
|
codes: ppCodes.length ? ppCodes : undefined,
|
|
14980
14984
|
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
14981
14985
|
return_url: body.return_url || undefined,
|
|
@@ -14991,7 +14995,10 @@ function mount(router, deps) {
|
|
|
14991
14995
|
return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
|
|
14992
14996
|
} catch (e) {
|
|
14993
14997
|
var ecode = (e && typeof e.code === "string") ? e.code : "";
|
|
14994
|
-
|
|
14998
|
+
// Customer-correctable credit errors (bad gift-card code,
|
|
14999
|
+
// insufficient loyalty balance, points on a guest cart) are 400s
|
|
15000
|
+
// whose message the button surfaces inline.
|
|
15001
|
+
var gcErr = ecode.indexOf("GIFTCARD_") === 0 || ecode.indexOf("LOYALTY_") === 0 || ecode === "AUTO_DISCOUNT_EXHAUSTED";
|
|
14995
15002
|
// Out-of-stock is a 409 (conflict) carrying the friendly per-line
|
|
14996
15003
|
// message so the PayPal button surfaces it; nothing was charged
|
|
14997
15004
|
// and the mid-confirm holds were already released.
|
package/package.json
CHANGED