@blamejs/blamejs-shop 0.4.44 → 0.4.45

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.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
+
11
13
  - 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
14
 
13
15
  - 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.45",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/checkout.js CHANGED
@@ -1139,10 +1139,22 @@ function create(deps) {
1139
1139
  }
1140
1140
 
1141
1141
  var sub = pricing.subtotal(lines, { currency: c.currency });
1142
+
1143
+ // Resolve the automatic discount BEFORE tax: sales tax is owed on the
1144
+ // DISCOUNTED price the customer actually pays, not the pre-discount
1145
+ // subtotal, so the discount has to be known to compute the tax base. The
1146
+ // resolver clamps to [0, subtotal] and falls back to 0 on any failure, so
1147
+ // the math is identical to the un-wired flow whenever no rule applies.
1148
+ // (Pure computation here — the redemption CLAIM happens later, in confirm.)
1149
+ var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null, input.codes);
1150
+ var taxableMinor = Math.max(0, sub.amount_minor - autoDisc.discount_minor);
1151
+
1142
1152
  var taxRow = await tax.calculate({
1143
1153
  shipTo: input.ship_to,
1144
- subtotal_minor: sub.amount_minor,
1154
+ subtotal_minor: taxableMinor,
1145
1155
  });
1156
+ // Shipping rates still gate on the PRE-discount subtotal — a free-shipping
1157
+ // threshold is earned on merchandise value, not the post-discount total.
1146
1158
  var ratesRow = await shipping.rates({
1147
1159
  shipTo: input.ship_to,
1148
1160
  lines: enrichedLines,
@@ -1182,13 +1194,6 @@ function create(deps) {
1182
1194
 
1183
1195
  var shippingMinor = selected ? selected.amount_minor : 0;
1184
1196
 
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
1197
  var totals = pricing.totals(c, lines, {
1193
1198
  tax_minor: taxRow.tax_minor,
1194
1199
  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.45",
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": {