@blamejs/blamejs-shop 0.4.123 → 0.4.124

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.124 (2026-06-27) — **Adding the same item to a cart from two requests at once always sums the quantity, and the manager role can configure delivery cutoffs and postal zones.** Two correctness fixes. First, adding the same product to a cart from two requests at once — a double-click, or two open tabs — could drop one of the quantity increases: the cart line's quantity was read and then written back as an absolute value, so the second write overwrote the first instead of adding to it. Adding to the cart now sums the quantity in a single atomic database statement, so simultaneous adds of the same item always total correctly; the same atomic write covers applying a bundle's units onto an existing line and merging a guest cart into a signed-in cart at login. Second, an operator with the manager role was wrongly denied the delivery-estimate cutoff-time and postal-zone screens — those two actions were not mapped to the catalog-write permission and fell through to the owner-only fallback, so only the owner could reach them; managers can now manage them alongside the other delivery-estimate settings. **Fixed:** *Simultaneous add-to-cart requests for the same item sum the quantity instead of losing one* — Adding a line to a cart read the existing quantity and then wrote back the absolute sum, so two requests adding the same item to the same cart at the same time (a rapid double-click, or two tabs) could each read the same starting quantity and overwrite each other — one increase was lost. The add is now a single atomic upsert that increments the stored quantity in the database (`qty = qty + added`), so concurrent adds of the same item always total correctly and a concurrent first-add no longer fails on the cart's unique-line constraint. A plain re-add still keeps the line's existing price. The same atomic write applies to placing a bundle's units onto an existing line (the per-unit price re-blends to the combined value, rounded up so it never charges below it) and to merging a guest cart into a signed-in cart at login. · *The manager role can configure delivery cutoff times and postal zones* — The delivery-estimate cutoff-time and postal-zone actions were not mapped to the catalog-write permission, so they fell through to the owner-only fallback and an operator with the manager role was denied access to those two screens while being allowed the adjacent transit-time and holiday screens. Both actions now require catalog-write, matching the rest of the delivery-estimate settings, so managers can configure them.
12
+
11
13
  - v0.4.123 (2026-06-26) — **Vendored framework refreshed to 0.15.33.** Updates the vendored blamejs framework to 0.15.33. The upstream release is internal test-suite tooling only: it re-verifies three more of the framework's guard-suite suppression classes from scratch and renames their markers to descriptive tokens, recording the old names as retired so a re-verified class cannot inherit a stale approval. No runtime code, public API, or wire format changed upstream, so there is no runtime or behavior change for this storefront and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.33* — Refreshes the vendored blamejs framework to 0.15.33, an upstream release scoped to test-suite tooling — three more guard-suite suppression classes re-verified and their markers renamed to descriptive tokens, with the old names retired. The shipped framework runtime, public API, and wire format are unchanged, so no operator action is required.
12
14
 
13
15
  - v0.4.122 (2026-06-26) — **Loyalty points are clawed back against the goods subtotal they were earned on, not the order's grand total.** Loyalty points are earned on an order's goods value. When an order was refunded, the points clawback was computed as a proportion of the GRAND total instead — so refunding the full goods value while keeping non-refundable shipping or tax clawed back less than all of the earned points, letting a customer keep loyalty points on goods they had fully returned (a buy-then-return rewards-farming gap). The clawback now ratios against the DISCOUNTED goods value (the subtotal minus any order discount — the cash the customer actually paid for the goods), so returning the full goods value reverses all earned points even when shipping or tax is retained; the refund is applied goods-first (capped at the goods value). Separately, the clawback's points-times-amount product is now computed in BigInt, so a very large order can no longer drift the floored result by a point. Loyalty-tender restoration (returning points that were spent at checkout) still ratios against the cash refund and is unchanged. **Fixed:** *Earned-points clawback ratios against the discounted goods value, not the grand total* — Points are awarded on an order's goods value, but the refund clawback divided the refunded amount by the order's grand total. On an order carrying tax or shipping the grand total is larger than the goods value, so refunding the full goods value never reached a 100% clawback — the customer kept a slice of points on goods they fully returned. The refund path now passes the DISCOUNTED goods value — the subtotal minus any order discount, i.e. the cash actually paid for the goods — as the clawback base (the refund is treated goods-first and capped at that value), so a full goods return reverses all earned points even when non-refundable shipping or tax is kept, and a discounted order is no longer under-clawed. The terminal full-refund edge still claws 100%, and loyalty points spent as a checkout tender are still restored against the cash refunded. · *The proportional clawback computes points-times-amount in BigInt* — The clawback's points-awarded times refunded-amount product was computed with floating-point arithmetic. On a sufficiently large order the product can exceed the exact-integer range and drift the floored clawback by a point. It is now computed in BigInt — matching the framework's convention for money-by-quantity products — so the clawed amount is exact at any order size.
package/lib/admin.js CHANGED
@@ -159,7 +159,8 @@ var _ACTION_PERMISSION = Object.freeze({
159
159
  email_campaign: "catalog.write", winback_campaign: "catalog.write", suggestion: "catalog.write", sidebar_widget: "catalog.write",
160
160
  page: "catalog.write", help: "catalog.write", survey: "catalog.write",
161
161
  hours: "catalog.write", delivery_holiday: "catalog.write",
162
- delivery_transit: "catalog.write", tax_rate: "catalog.write",
162
+ delivery_transit: "catalog.write", delivery_cutoff: "catalog.write",
163
+ delivery_postal_zone: "catalog.write", tax_rate: "catalog.write",
163
164
  shipping_zone: "catalog.write", payment_domain: "catalog.write",
164
165
  webhook: "catalog.write", subscription_plan: "catalog.write", loyalty: "catalog.write",
165
166
  // orders / fulfilment / post-purchase
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.123",
2
+ "version": "0.4.124",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/cart.js CHANGED
@@ -207,30 +207,33 @@ function create(opts) {
207
207
  // Fetch variant SKU for the line snapshot.
208
208
  var variantRow = (await query("SELECT sku FROM variants WHERE id = ?1", [input.variant_id])).rows[0];
209
209
  if (!variantRow) throw new TypeError("cart.addLine: variant " + input.variant_id + " not found");
210
- // Upsert: if a line for this (cart, variant) exists, bump qty.
210
+ // Pre-read the existing line ONLY to preserve the over-cap TypeError UX
211
+ // (best-effort under concurrency — MAX_QTY is a sanity bound, not a money
212
+ // invariant). The write itself is a SINGLE atomic upsert: a pre-existing
213
+ // line bumps qty by the added amount in-DB (`qty = qty + excluded.qty`),
214
+ // so two concurrent adds of the same (cart, variant) sum exactly. The prior
215
+ // read-then-write-absolute lost an increment when two adds raced, and the
216
+ // bare INSERT could throw on the UNIQUE(cart_id, variant_id) constraint when
217
+ // a concurrent add created the line first. A qty-bump KEEPS the existing
218
+ // line's price (DO UPDATE never touches unit_amount_minor).
211
219
  var existing = (await query(
212
- "SELECT * FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2 LIMIT 1",
220
+ "SELECT qty FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2 LIMIT 1",
213
221
  [cartId, input.variant_id],
214
222
  )).rows[0];
223
+ if (existing) _qty(existing.qty + input.qty);
215
224
  var ts = _now();
216
- if (existing) {
217
- var newQty = existing.qty + input.qty;
218
- _qty(newQty);
219
- await query(
220
- "UPDATE cart_lines SET qty = ?1, updated_at = ?2 WHERE id = ?3",
221
- [newQty, ts, existing.id],
222
- );
223
- await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, cartId]);
224
- return Object.assign({}, existing, { qty: newQty, updated_at: ts });
225
- }
226
225
  var id = b.uuid.v7();
227
226
  await query(
228
227
  "INSERT INTO cart_lines (id, cart_id, variant_id, sku, qty, unit_amount_minor, unit_currency, added_at, updated_at) " +
229
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
228
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) " +
229
+ "ON CONFLICT (cart_id, variant_id) DO UPDATE SET qty = cart_lines.qty + excluded.qty, updated_at = excluded.updated_at",
230
230
  [id, cartId, input.variant_id, variantRow.sku, input.qty, unitAmount, unitCurrency, ts],
231
231
  );
232
232
  await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, cartId]);
233
- return { id: id, cart_id: cartId, variant_id: input.variant_id, sku: variantRow.sku, qty: input.qty, unit_amount_minor: unitAmount, unit_currency: unitCurrency, added_at: ts, updated_at: ts };
233
+ return (await query(
234
+ "SELECT * FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2 LIMIT 1",
235
+ [cartId, input.variant_id],
236
+ )).rows[0];
234
237
  },
235
238
 
236
239
  // Add a bundle member's units at the bundle's allocated price. addLine is a
@@ -270,42 +273,41 @@ function create(opts) {
270
273
  if (cartRow.status !== "active") throw new TypeError("cart.setBundleLine: cart status is " + cartRow.status + ", cannot modify");
271
274
  var variantRow = (await query("SELECT sku FROM variants WHERE id = ?1", [input.variant_id])).rows[0];
272
275
  if (!variantRow) throw new TypeError("cart.setBundleLine: variant " + input.variant_id + " not found");
276
+ // Pre-read the existing line ONLY to preserve the over-cap TypeError UX
277
+ // (best-effort under concurrency). The write is a SINGLE atomic upsert.
273
278
  var existing = (await query(
274
- "SELECT * FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2 LIMIT 1",
279
+ "SELECT qty FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2 LIMIT 1",
275
280
  [cartId, input.variant_id],
276
281
  )).rows[0];
282
+ if (existing) _qty(existing.qty + input.qty);
277
283
  var ts = _now();
278
- if (existing) {
279
- var mergedQty = existing.qty + input.qty;
280
- _qty(mergedQty);
281
- // Combined value of the units already on the line plus the bundle's
282
- // units at the bundle price, expressed as one integer per-unit price.
283
- // Round up so a non-divisible blend never charges below the combined
284
- // value (a sub-unit residual, never the standalone list price).
285
- var mergedTotal = (existing.qty * existing.unit_amount_minor) + (input.qty * input.unit_amount_minor);
286
- var blendedUnit = Math.ceil(mergedTotal / mergedQty);
287
- await query(
288
- "UPDATE cart_lines SET qty = ?1, unit_amount_minor = ?2, unit_currency = ?3, updated_at = ?4 WHERE id = ?5",
289
- [mergedQty, blendedUnit, input.unit_currency, ts, existing.id],
290
- );
291
- await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, cartId]);
292
- return Object.assign({}, existing, {
293
- qty: mergedQty, unit_amount_minor: blendedUnit,
294
- unit_currency: input.unit_currency, updated_at: ts,
295
- });
296
- }
297
284
  var id = b.uuid.v7();
285
+ // Atomic upsert: on a fresh line the bundle's allocated price stands; on a
286
+ // pre-existing line the per-unit price is re-blended to the COMBINED value
287
+ // (existing qty x existing price + bundle qty x bundle price), rounded UP via
288
+ // integer `(total + qty - 1) / qty` so a non-divisible blend never charges
289
+ // below the combined value (a sub-unit residual, never the standalone list
290
+ // price). Computing the blend in SQL keeps it a single atomic statement, so
291
+ // two bundle adds racing the same (cart, variant) can't lose an increment or
292
+ // mis-blend (the prior read-then-write-absolute could), and the fresh-line
293
+ // INSERT can't throw on the UNIQUE(cart_id, variant_id) constraint. SQLite
294
+ // evaluates every DO UPDATE right-hand side against the ORIGINAL row, so the
295
+ // blend reads the pre-bump qty/price even though `qty` is reassigned too.
298
296
  await query(
299
297
  "INSERT INTO cart_lines (id, cart_id, variant_id, sku, qty, unit_amount_minor, unit_currency, added_at, updated_at) " +
300
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
298
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) " +
299
+ "ON CONFLICT (cart_id, variant_id) DO UPDATE SET " +
300
+ "unit_amount_minor = (cart_lines.qty * cart_lines.unit_amount_minor + excluded.qty * excluded.unit_amount_minor + (cart_lines.qty + excluded.qty) - 1) / (cart_lines.qty + excluded.qty), " +
301
+ "qty = cart_lines.qty + excluded.qty, " +
302
+ "unit_currency = excluded.unit_currency, " +
303
+ "updated_at = excluded.updated_at",
301
304
  [id, cartId, input.variant_id, variantRow.sku, input.qty, input.unit_amount_minor, input.unit_currency, ts],
302
305
  );
303
306
  await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, cartId]);
304
- return {
305
- id: id, cart_id: cartId, variant_id: input.variant_id, sku: variantRow.sku,
306
- qty: input.qty, unit_amount_minor: input.unit_amount_minor,
307
- unit_currency: input.unit_currency, added_at: ts, updated_at: ts,
308
- };
307
+ return (await query(
308
+ "SELECT * FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2 LIMIT 1",
309
+ [cartId, input.variant_id],
310
+ )).rows[0];
309
311
  },
310
312
 
311
313
  // Scope the mutation to (lineId, cartId): a caller who learns
@@ -360,24 +362,16 @@ function create(opts) {
360
362
  var ts = _now();
361
363
  for (var i = 0; i < fromLines.length; i += 1) {
362
364
  var l = fromLines[i];
363
- var existing = (await query(
364
- "SELECT * FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2",
365
- [toCartId, l.variant_id],
366
- )).rows[0];
367
- if (existing) {
368
- var newQty = Math.min(existing.qty + l.qty, MAX_QTY);
369
- await query(
370
- "UPDATE cart_lines SET qty = ?1, updated_at = ?2 WHERE id = ?3",
371
- [newQty, ts, existing.id],
372
- );
373
- } else {
374
- var newId = b.uuid.v7();
375
- await query(
376
- "INSERT INTO cart_lines (id, cart_id, variant_id, sku, qty, unit_amount_minor, unit_currency, added_at, updated_at) " +
377
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
378
- [newId, toCartId, l.variant_id, l.sku, l.qty, l.unit_amount_minor, l.unit_currency, ts],
379
- );
380
- }
365
+ // Atomic upsert: a variant already on the surviving cart sums qty in-DB
366
+ // (clamped to MAX_QTY), collapsing the prior read-then-write-absolute so a
367
+ // concurrent write to the destination line can't lose a merged increment.
368
+ var newId = b.uuid.v7();
369
+ await query(
370
+ "INSERT INTO cart_lines (id, cart_id, variant_id, sku, qty, unit_amount_minor, unit_currency, added_at, updated_at) " +
371
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) " +
372
+ "ON CONFLICT (cart_id, variant_id) DO UPDATE SET qty = MIN(cart_lines.qty + excluded.qty, ?9), updated_at = excluded.updated_at",
373
+ [newId, toCartId, l.variant_id, l.sku, l.qty, l.unit_amount_minor, l.unit_currency, ts, MAX_QTY],
374
+ );
381
375
  }
382
376
  // Carry the anonymous cart's applied discount codes onto the
383
377
  // surviving cart so a code typed before sign-in isn't silently
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.123",
3
+ "version": "0.4.124",
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": {