@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/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
- await query(
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
- async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
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
- breaker: opts._breaker,
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
- try { res = await _paypalCall(opts, state, "POST", "/v1/notifications/verify-webhook-signature", verifyBody, null); }
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
  };
@@ -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 payment.refund when wired. The handle's exact shape
689
- // mirrors the framework's payment primitive (`{ payment_intent,
690
- // amount_minor, reason, metadata }`). Absent a handle the
691
- // primitive still records the decision so the operator can
692
- // drive the actual refund out-of-band.
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 (paymentHandle && typeof paymentHandle.refund === "function") {
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,
@@ -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
- // Returns both the snapshot and the occurred_at so the write path
154
- // can guarantee strict monotonicity (see `_resolveOccurredAt`).
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
- // Two writes against the same customer in the same millisecond
171
- // would tie on `occurred_at` and make the "latest row" ambiguous.
172
- // Bump the requested timestamp to `prior + 1` when it would
173
- // collide (or land older than the prior row, which an
174
- // out-of-order operator write could trigger). The result is a
175
- // strictly-monotonic per-customer `occurred_at` sequence.
176
- function _resolveOccurredAt(requestedTs, latestTs) {
177
- if (latestTs == null) return requestedTs;
178
- if (requestedTs > latestTs) return requestedTs;
179
- return latestTs + 1;
180
- }
181
-
182
- async function _writeRow(customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts) {
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
- await query(
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
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
188
- [id, customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts],
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 id;
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 latest = await _readLatest(customerId);
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: after,
223
- occurred_at: ts,
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
- var latest = await _readLatest(customerId);
238
- if (amount > latest.balance) {
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: after,
254
- occurred_at: ts,
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
- var latest = await _readLatest(customerId);
276
- // Expire caps at the current balance operators running a
277
- // scheduled sweep over computed "expiring before X" amounts
278
- // should degrade gracefully rather than refusing when an
279
- // interim debit has already drained the wallet.
280
- var toBurn = amount > latest.balance ? latest.balance : amount;
281
- if (toBurn === 0) {
282
- // No-op write: persisting a zero-amount row would violate
283
- // the CHECK(amount_minor > 0) constraint. Surface a
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: latest.balance,
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: toBurn,
334
+ amount_minor: w.amount_minor,
308
335
  requested_minor: amount,
309
336
  reason: reason,
310
- balance_after_minor: after,
311
- occurred_at: ts,
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
- var latest = await _readLatest(customerId);
589
- // Cap the burn at the wallet's current balance.
590
- // Debits between the credit and the sweep may have spent
591
- // the expired amount already; we never drive the balance
592
- // negative. The expired credits were "first-out" from the
593
- // operator's POV but the schema doesn't track FIFO at
594
- // row-level, so cap by current balance and let the audit
595
- // trail reflect what was actually burned.
596
- var toBurn = pendingBurn > latest.balance ? latest.balance : pendingBurn;
597
- if (toBurn <= 0) {
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: toBurn,
609
- balance_after_minor: after,
610
- occurred_at: ts,
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
- var gcErr = ecode.indexOf("GIFTCARD_") === 0;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {