@blamejs/blamejs-shop 0.4.44 → 0.4.46

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.46 (2026-06-14) — **A refund webhook records its own amount, so refunds processed out of order can't over-count the refunded total.** The Stripe charge.refunded mirror recorded the difference between the charge's cumulative refunded figure and the order's recorded refund total. When two refunds happen close together and the second refund's webhook is processed before the first's has been recorded — including two webhooks racing on the same order — both read the same not-yet-updated recorded total, so the later one records the full cumulative difference and double-counts the earlier refund, overstating the order's refunded total (and, with the over-refund cap keyed on that total, potentially distorting later refund limits). The mirror now records each event's own refund amount — the charge lists its refunds newest-first — keyed on the refund id, so a re-delivery or the console's own mirror of the same refund is still de-duplicated. Each refund is counted exactly once regardless of the order or timing its webhooks arrive in. No migration to apply. **Fixed:** *A refund webhook records its own amount, not the cumulative difference* — The charge.refunded mirror now records the specific refund that triggered the event, taken from the charge's own refunds list, instead of the difference between the charge's cumulative refunded figure and the order's ledger. The previous difference-based approach read the recorded total once and could record a value that included an earlier refund whose webhook hadn't landed yet — so when that earlier webhook arrived, the order's refunded total over-counted. Keying each entry on the refund id keeps re-deliveries and the console's own mirror de-duplicated, so the same refund is never recorded twice and the total converges on the true figure whatever order the webhooks arrive in.
12
+
13
+ - v0.4.45 (2026-06-13) — **Sales tax is computed on the discounted price, so a discounted order is no longer over-taxed.** Checkout calculated sales tax on the full pre-discount subtotal even though the tax primitive's documented contract is to receive the post-discount subtotal (lib/tax.js: "Tax is computed against subtotal_minor (post-discount, pre-shipping)"). So every order carrying an automatic discount or a coupon was taxed on merchandise value the customer didn't pay for, over-collecting tax and overstating the order total. The tax base is now the subtotal minus the discount, computed by resolving the discount before the tax call. Shipping rates still gate on the pre-discount merchandise value — a free-shipping threshold is earned on what's in the cart, not the post-discount total — and an order with no discount is unaffected. No migration to apply. **Fixed:** *A discounted order is taxed on the price actually paid* — Sales tax now applies to the post-discount subtotal: the automatic-discount and coupon reduction is resolved first and subtracted from the taxable base before tax is calculated. Previously tax was computed on the full pre-discount subtotal, so a discounted or couponed order was over-taxed and its total overstated by the tax on the discounted-away amount. An order with no discount sees no change, and free-shipping thresholds continue to be evaluated against the pre-discount merchandise value.
14
+
11
15
  - v0.4.44 (2026-06-13) — **Close three integrity gaps: loyalty tier ratcheting, an unenforced per-customer promo cap, and a double-counted referral funnel.** Three confirmed correctness fixes. Cancelling a loyalty redemption refunded the points to the customer's lifetime total as well as their spendable balance — but a redemption only ever debited the spendable balance, so repeatedly redeeming and cancelling ratcheted the lifetime total, which drives tier placement, upward without limit; the refund now credits the spendable balance only and never the lifetime total. A promo bundle's per-customer redemption cap was stored and editable but never consulted at redemption, so a capped bundle was redeemable an unlimited number of times by one customer; the cap is now enforced with an atomic count-and-insert that also holds when a customer's checkouts race. And recording a referred customer's first purchase read the funnel-completion flag and then wrote it in separate steps, so two concurrent recordings — a double-submit or a re-delivered purchase webhook — could both complete the funnel and credit the referrer twice; the completion is now claimed atomically and counts exactly once. No migration to apply. **Fixed:** *Loyalty tier can no longer be ratcheted by redeem-then-cancel* — Cancelling a redemption now refunds the debited points to the spendable balance only, through a new balance-only credit that never touches the lifetime total. Previously the refund went through the general adjustment path, which also credits the lifetime total and recomputes the tier — but a redemption never reduced the lifetime total in the first place, so each redeem-then-cancel cycle inflated it and a repeated loop could climb the tier ladder without ever earning the points. · *A promo bundle's per-customer cap is now enforced* — A bundle's per-customer redemption cap is now checked when a redemption is recorded: the ledger insert is conditional on the customer's existing redemption count for that bundle being below the cap, in a single atomic statement, so two concurrent checkouts for the same customer can't both slip past a nearly-full cap. The cap was previously stored and editable but never consulted, leaving a per-customer-capped bundle redeemable any number of times by one customer. A bundle with no per-customer cap is unchanged. · *A referral funnel is counted once under concurrency* — Recording a referred customer's first qualifying purchase now claims the funnel-completion transition atomically — the referrer's count is incremented only by the call that wins the claim. Previously the flag was read and then written in separate steps, so two concurrent recordings (a double-submit, or a purchase webhook re-delivered) could both pass the not-yet-completed check and credit the referrer twice. Signup attribution is likewise claimed atomically so two different customers can't both be pinned to the same pending invitation.
12
16
 
13
17
  - v0.4.43 (2026-06-13) — **Harden input handling: linear-time pattern matching on caller-influenced strings, complete escaping, and redacted edge-bridge error responses.** A set of input-handling hardening fixes. Several internal pattern matches that run on caller-influenced strings used regular expressions whose shape could be driven into super-linear (polynomial) backtracking by a long run of a single character — a denial-of-service shape. These are rewritten to linear-time equivalents that accept and reject exactly the same inputs: the email-shape guard in the analytics and clickstream PII filters, a whitespace trim in the captcha gate, and the trailing-slash trim on the D1 and R2 bridge URLs. A JSON-escape helper in the catalog draft path now escapes backslashes as well as quotes so a crafted value can't smuggle an escape. And the edge worker's database- and storage-bridge error responses no longer return internal error detail to the caller — the detail is logged at the edge and the client receives only a generic failure code. No migration to apply. **Security:** *Pattern matching on caller-influenced input is now linear-time* — The email-shape detector used by the analytics and clickstream PII guards, the whitespace trim in the captcha gate, and the trailing-slash trim applied to the D1 and R2 bridge URLs were regular expressions whose backtracking could grow polynomially with the input length — a long run of one character could pin a request thread. Each is replaced with a linear-time form (a bounded, unambiguous regex; a native trim; a small character loop) proven to accept and reject the identical set of inputs, so the same values still pass and fail while a crafted long input can no longer cause super-linear work. · *Edge bridge errors no longer leak internal detail* — The Cloudflare Worker's database-bridge and storage-bridge endpoints returned the underlying error message in their 500 responses. They now return only a generic failure code to the caller and log the (redacted) detail at the edge, so an internal error string or stack fragment is never exposed in a response body. · *Complete escaping in the catalog draft lookup* — The catalog draft path builds a JSON-fragment match string from a SKU; its escape step now escapes backslashes before quotes, so the escaping is complete and a value containing a backslash cannot break out of the quoted fragment.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.44",
2
+ "version": "0.4.46",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/checkout.js CHANGED
@@ -444,14 +444,14 @@ function create(deps) {
444
444
  var remaining = grand - refundedSoFar;
445
445
  var delta = cumulative - refundedSoFar;
446
446
  var meta = { stripe_event_id: event.id };
447
- // Stamp the provider refund id (the charge carries its refunds newest-
448
- // first) so a console refund recorded AFTER this webhook dedupes against
449
- // it — recordPartialRefund keys idempotency on the refund id, and without
450
- // it a webhook-first refund and the console write would both land,
451
- // double-counting the refunded total on a split-tender order.
452
- var _stripeRefundId = charge && charge.refunds && Array.isArray(charge.refunds.data) &&
453
- charge.refunds.data[0] && charge.refunds.data[0].id;
454
- if (_stripeRefundId) { meta.stripe_refund_id = _stripeRefundId; }
447
+ // The charge lists its refunds newest-first; the [0] entry is the refund
448
+ // that triggered THIS event. Its id keys idempotency (so a console mirror
449
+ // or a re-delivery dedupes — recordPartialRefund dedupes on the refund id),
450
+ // and its amount is THIS refund's own figure, used below instead of the
451
+ // cumulative-minus-ledger delta.
452
+ var _latestRefund = (charge && charge.refunds && Array.isArray(charge.refunds.data))
453
+ ? charge.refunds.data[0] : null;
454
+ if (_latestRefund && _latestRefund.id) { meta.stripe_refund_id = _latestRefund.id; }
455
455
  if (charge.refunded === true || cumulative >= grand) {
456
456
  // Charge fully refunded — terminal edge. (On a split-tender order the
457
457
  // charge covers only the provider-paid share; a full charge refund
@@ -470,12 +470,21 @@ function create(deps) {
470
470
  // console mirrored this refund when it issued it.
471
471
  return { handled: true, event_type: eventType, order: o, skipped: "already-recorded" };
472
472
  }
473
+ // Record THIS refund's own amount, NOT the cumulative-minus-ledger delta:
474
+ // two concurrent distinct-refund webhooks both read `refundedSoFar` before
475
+ // either writes, so the delta double-counts the earlier refund. The event's
476
+ // own refund amount is exact and, keyed on the refund id, dedupes a
477
+ // re-delivery or the console's mirror. Fall back to the delta only when the
478
+ // event doesn't carry the refund's amount.
479
+ var _refundAmount = (_latestRefund && Number.isInteger(_latestRefund.amount) && _latestRefund.amount > 0)
480
+ ? _latestRefund.amount
481
+ : delta;
473
482
  var updatedPartial = await order.recordPartialRefund(o.id, {
474
- amount_minor: delta,
483
+ amount_minor: _refundAmount,
475
484
  reason: "stripe:" + eventType,
476
485
  metadata: meta,
477
486
  });
478
- return { handled: true, event_type: eventType, order: updatedPartial, partial: true, amount_minor: delta };
487
+ return { handled: true, event_type: eventType, order: updatedPartial, partial: true, amount_minor: _refundAmount };
479
488
  }
480
489
 
481
490
  // Reprice a list of cart lines through the quantity-discount engine.
@@ -1139,10 +1148,22 @@ function create(deps) {
1139
1148
  }
1140
1149
 
1141
1150
  var sub = pricing.subtotal(lines, { currency: c.currency });
1151
+
1152
+ // Resolve the automatic discount BEFORE tax: sales tax is owed on the
1153
+ // DISCOUNTED price the customer actually pays, not the pre-discount
1154
+ // subtotal, so the discount has to be known to compute the tax base. The
1155
+ // resolver clamps to [0, subtotal] and falls back to 0 on any failure, so
1156
+ // the math is identical to the un-wired flow whenever no rule applies.
1157
+ // (Pure computation here — the redemption CLAIM happens later, in confirm.)
1158
+ var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null, input.codes);
1159
+ var taxableMinor = Math.max(0, sub.amount_minor - autoDisc.discount_minor);
1160
+
1142
1161
  var taxRow = await tax.calculate({
1143
1162
  shipTo: input.ship_to,
1144
- subtotal_minor: sub.amount_minor,
1163
+ subtotal_minor: taxableMinor,
1145
1164
  });
1165
+ // Shipping rates still gate on the PRE-discount subtotal — a free-shipping
1166
+ // threshold is earned on merchandise value, not the post-discount total.
1146
1167
  var ratesRow = await shipping.rates({
1147
1168
  shipTo: input.ship_to,
1148
1169
  lines: enrichedLines,
@@ -1182,13 +1203,6 @@ function create(deps) {
1182
1203
 
1183
1204
  var shippingMinor = selected ? selected.amount_minor : 0;
1184
1205
 
1185
- // Automatic discounts: consult the operator-authored rule engine
1186
- // (when wired) for the subtotal reduction this cart earns. The
1187
- // resolver clamps the result to [0, subtotal] and falls back to 0
1188
- // on any failure, so the total math below is identical to the
1189
- // un-wired flow whenever no rule applies.
1190
- var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null, input.codes);
1191
-
1192
1206
  var totals = pricing.totals(c, lines, {
1193
1207
  tax_minor: taxRow.tax_minor,
1194
1208
  shipping_minor: shippingMinor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.44",
3
+ "version": "0.4.46",
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": {