@blamejs/blamejs-shop 0.4.45 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.45",
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.45",
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": {