@blamejs/blamejs-shop 0.3.8 → 0.3.9

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.3.x
10
10
 
11
+ - v0.3.9 (2026-05-30) — **Automatic discounts now apply at checkout.** The automatic-discount rules you define in the admin console — an amount or percentage off a cart that meets a threshold — now reduce the order total at checkout. When a rule matches, the discount is applied (capped so it never exceeds the order subtotal) and shown in the quote the shopper pays; when no rule matches, or none are defined, the total is exactly as before, so a store that has not set up rules is unaffected. The admin screen for managing rules already shipped; this wires it through to the checkout quote and the charge. **Added:** *Automatic cart discounts at checkout* — When a cart matches a configured automatic-discount rule, the checkout reduces the order total by the rule's saving — capped to the order subtotal so the total is never negative — and the reduced amount is what the shopper is charged. With no matching rule, or no rules defined, the order total is unchanged, so the change stays invisible until you set rules up; any rule-engine error degrades to no discount rather than blocking checkout.
12
+
11
13
  - v0.3.8 (2026-05-30) — **Update the vendored blamejs runtime to v0.14.6.** Refreshes the vendored blamejs runtime from v0.14.5 to v0.14.6, a maintenance update. The createApp request-lifecycle security stack the shop composes — CSRF, fetch-metadata, body-parser, cookies, rate limiting — is unchanged in default behavior. v0.14.6 adds opt-in RFC 9457 problem+json and onDeny response hooks to the access-refusal middleware (defaults unchanged, so nothing the shop relies on shifts) and corrects a few internal export and bearer-scope paths. No operator action is required. **Changed:** *Vendored blamejs runtime updated to v0.14.6* — The pinned blamejs runtime moves from v0.14.5 to v0.14.6. The middleware the storefront and admin compose keeps its current default behavior; the release adds opt-in problem-details / onDeny response hooks to the refusal middleware and fixes internal export and bearer-scope paths. No behavior change for the shop.
12
14
 
13
15
  - v0.3.7 (2026-05-30) — **Shipping zones now apply at checkout.** The shipping zones you define in the admin console — per-region rates by destination, parcel weight, and order value — now drive the shipping options shown at checkout. When a cart's destination matches a zone, the shopper sees that zone's rates; when no zone matches, or none are defined, checkout falls back to the existing flat-rate table exactly as before, so a store that has not configured zones is entirely unaffected. A fully digital order is never quoted physical shipping. The admin screen for managing zones already shipped; this wires it through to the checkout quote. **Added:** *Zone-based shipping rates at checkout* — When a cart's destination matches a configured shipping zone, the checkout shipping quote uses that zone's rates — resolved by destination country and region, parcel weight, and order subtotal — instead of the flat-rate table. With no matching zone, or no zones defined, checkout returns the existing flat rates unchanged, so the change stays invisible until you set zones up; any lookup error degrades to the same flat fallback. A fully digital order (no shipping-requiring item) is never quoted physical shipping.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.8",
2
+ "version": "0.3.9",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/checkout.js CHANGED
@@ -146,6 +146,17 @@ function create(deps) {
146
146
  // server-authoritative computation the cart page renders, so the
147
147
  // "review your order" subtotal and the charged amount agree.
148
148
  var quantityDiscounts = deps.quantityDiscounts || null;
149
+ // Optional automatic-discount engine. When wired, the quote consults
150
+ // every operator-authored auto-discount rule against the resolved
151
+ // cart and reduces the subtotal by the matched savings (clamped to
152
+ // [0, subtotal] so a rule can never drive the order negative or
153
+ // charge more than the cart is worth). Disabled when absent — the
154
+ // discount is zero and every total is identical to the un-wired
155
+ // flow, so production stays unchanged until a rule is defined. Only
156
+ // subtotal-reducing savings feed `discount_minor`; a `free_shipping`
157
+ // rule's savings are a shipping concern and are left to the shipping
158
+ // primitive, not folded into the subtotal discount here.
159
+ var autoDiscount = deps.autoDiscount || null;
149
160
 
150
161
  // Reprice a list of cart lines through the quantity-discount engine.
151
162
  // Returns a shallow copy with `unit_amount_minor` overwritten by the
@@ -179,6 +190,72 @@ function create(deps) {
179
190
  return out;
180
191
  }
181
192
 
193
+ // Resolve the automatic-discount effect for a priced cart. Builds
194
+ // the engine's cart shape from the resolved lines + subtotal, asks
195
+ // the engine which operator-authored rules apply, and returns the
196
+ // subtotal-reducing savings (clamped to [0, subtotal]) plus the
197
+ // per-rule breakdown the post-commit recorder replays.
198
+ //
199
+ // FALLBACK (zero regression): no engine wired, a non-finite or
200
+ // negative aggregate, an empty result, or ANY throw collapses to
201
+ // `{ discount_minor: 0, applied: [] }` — byte-identical to the
202
+ // un-wired flow. The engine is consulted but never trusted to keep
203
+ // the buy path alive.
204
+ //
205
+ // PAYMENT SAFETY: the returned discount is clamped to
206
+ // [0, subtotalMinor] so it can never be negative and never exceeds
207
+ // the subtotal — the order total can therefore never go negative,
208
+ // and a customer can never be charged less than zero or credited
209
+ // more than the cart is worth.
210
+ async function _resolveAutoDiscount(lines, subtotalMinor, customerId) {
211
+ if (!autoDiscount || typeof autoDiscount.evaluate !== "function") {
212
+ return { discount_minor: 0, applied: [] };
213
+ }
214
+ try {
215
+ var evalLines = [];
216
+ for (var i = 0; i < lines.length; i += 1) {
217
+ var l = lines[i];
218
+ evalLines.push({
219
+ sku: l.sku,
220
+ quantity: l.qty,
221
+ unit_price_minor: l.unit_amount_minor,
222
+ });
223
+ }
224
+ var res = await autoDiscount.evaluate({
225
+ cart: {
226
+ subtotal_minor: subtotalMinor,
227
+ lines: evalLines,
228
+ },
229
+ customer_id: customerId || undefined,
230
+ });
231
+ var appliedRaw = res && Array.isArray(res.applied) ? res.applied : [];
232
+ var total = 0;
233
+ var applied = [];
234
+ for (var j = 0; j < appliedRaw.length; j += 1) {
235
+ var entry = appliedRaw[j];
236
+ // A `free_shipping` rule's savings are a shipping reduction,
237
+ // not a subtotal discount — excluded so the subtotal-level
238
+ // `discount_minor` stays a pure subtotal reduction (the
239
+ // shipping primitive owns the shipping side).
240
+ if (!entry || entry.value_kind === "free_shipping") continue;
241
+ var s = entry.savings_minor;
242
+ if (!Number.isInteger(s) || s <= 0) continue;
243
+ total += s;
244
+ applied.push({ rule_slug: entry.rule_slug, savings_minor: s });
245
+ }
246
+ if (!Number.isFinite(total) || total <= 0) {
247
+ return { discount_minor: 0, applied: [] };
248
+ }
249
+ // Clamp to the subtotal floor: the discount can reduce the
250
+ // subtotal to zero but never past it.
251
+ if (total > subtotalMinor) total = subtotalMinor;
252
+ return { discount_minor: total, applied: applied };
253
+ } catch (_e) {
254
+ // Any engine failure -> no discount, never a 5xx on the buy path.
255
+ return { discount_minor: 0, applied: [] };
256
+ }
257
+ }
258
+
182
259
  // Validate a gift-card code against a priced quote: the card exists,
183
260
  // is active, not expired, and matches the order currency. Returns
184
261
  // the credit to apply (capped at the grand total so an order total
@@ -336,6 +413,33 @@ function create(deps) {
336
413
  });
337
414
  }
338
415
 
416
+ // Record each applied auto-discount rule against a committed order
417
+ // so the engine's redemption counter + event log reflect the use.
418
+ // Best-effort / drop-silent: the customer has already been charged
419
+ // (or the order is otherwise placed) by the time this runs, so a
420
+ // recording failure must NEVER fail or reverse the order. Each
421
+ // record is isolated in its own try/catch so one bad row can't stop
422
+ // the rest. No-op when the engine isn't wired or nothing applied.
423
+ async function _recordAutoDiscounts(quote, orderId, customerId) {
424
+ if (!autoDiscount || typeof autoDiscount.recordApplication !== "function") return;
425
+ var applied = quote && Array.isArray(quote.auto_discounts) ? quote.auto_discounts : [];
426
+ for (var i = 0; i < applied.length; i += 1) {
427
+ var a = applied[i];
428
+ if (!a || !a.rule_slug) continue;
429
+ try {
430
+ await autoDiscount.recordApplication({
431
+ rule_slug: a.rule_slug,
432
+ order_id: orderId,
433
+ savings_minor: a.savings_minor,
434
+ customer_id: customerId || undefined,
435
+ });
436
+ } catch (_e) {
437
+ // drop-silent — by design: the order is already placed; a
438
+ // failed redemption record must not surface to the buyer.
439
+ }
440
+ }
441
+ }
442
+
339
443
  // Compose a quote from a cart + ship-to + (optional) selected
340
444
  // shipping service. Pure read — no DB writes.
341
445
  async function _buildQuote(input) {
@@ -390,10 +494,18 @@ function create(deps) {
390
494
  }
391
495
 
392
496
  var shippingMinor = selected ? selected.amount_minor : 0;
497
+
498
+ // Automatic discounts: consult the operator-authored rule engine
499
+ // (when wired) for the subtotal reduction this cart earns. The
500
+ // resolver clamps the result to [0, subtotal] and falls back to 0
501
+ // on any failure, so the total math below is identical to the
502
+ // un-wired flow whenever no rule applies.
503
+ var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null);
504
+
393
505
  var totals = pricing.totals(c, lines, {
394
506
  tax_minor: taxRow.tax_minor,
395
507
  shipping_minor: shippingMinor,
396
- discount_minor: 0,
508
+ discount_minor: autoDisc.discount_minor,
397
509
  });
398
510
 
399
511
  return {
@@ -405,6 +517,10 @@ function create(deps) {
405
517
  shipping_rates: ratesRow.services,
406
518
  selected_shipping: selected,
407
519
  totals: totals,
520
+ // Per-rule breakdown that fed `totals.discount_minor`, replayed
521
+ // by confirm()'s post-commit recorder. Empty when no rule
522
+ // applied (or no engine wired).
523
+ auto_discounts: autoDisc.applied,
408
524
  };
409
525
  }
410
526
 
@@ -499,6 +615,10 @@ function create(deps) {
499
615
  });
500
616
  if (gc) await _redeemGiftCard(gc, paidOrder.id);
501
617
  if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
618
+ // Best-effort: record the auto-discount redemptions against the
619
+ // placed order. Runs post-commit so a recording failure can't
620
+ // reverse an order that's already paid.
621
+ await _recordAutoDiscounts(quote, paidOrder.id, cartRow.customer_id || null);
502
622
  await cart.setStatus(quote.cart_id, "converted");
503
623
  var settled = await order.transition(paidOrder.id, "mark_paid", {
504
624
  reason: loy && !gc ? "loyalty:full" : "gift_card:full",
@@ -552,6 +672,10 @@ function create(deps) {
552
672
  // guard.
553
673
  if (gc) await _redeemGiftCard(gc, createdOrder.id);
554
674
  if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id);
675
+ // Best-effort: record the auto-discount redemptions against the
676
+ // placed order. Runs post-commit so a recording failure can't
677
+ // reverse or fail an order whose PaymentIntent already exists.
678
+ await _recordAutoDiscounts(quote, createdOrder.id, cartRow.customer_id || null);
555
679
 
556
680
  // Mark the cart converted so a refresh of the storefront
557
681
  // doesn't accidentally re-quote the same cart.
package/lib/storefront.js CHANGED
@@ -7139,7 +7139,7 @@ function mount(router, deps) {
7139
7139
 
7140
7140
  // Compute the cart/checkout totals the shopper sees BEFORE paying.
7141
7141
  // Composes the SAME tax + shipping primitives the charge runs through
7142
- // (via checkout.quote, which prices tax against the post-discount
7142
+ // (via checkout.quote, which prices tax against the pre-discount
7143
7143
  // subtotal + picks shipping rates for the destination), so the
7144
7144
  // displayed grand total agrees with what Stripe is later asked to
7145
7145
  // charge for that destination. Returns:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
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": {