@blamejs/blamejs-shop 0.4.99 → 0.4.100

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.100 (2026-06-25) — **A coupon-stacking policy is now enforced at checkout, so two code-gated discounts can no longer combine past a policy that forbids it.** The coupon-stacking policy surface decides which discount codes may combine on one order — whether codes stack at all, a per-order code cap, and codes that are exclusive of everything else. That policy was applied in the admin surface but never on the buy path: checkout summed every code-gated rule a shopper had unlocked, so an operator who authored a policy forbidding combination still had it ignored on the charged price, and a customer could stack two codes the policy said could not combine. Checkout now consults the stacking policy before the discount engine sums anything: when an active policy governs the cart, the presented codes are narrowed to the policy-approved subset (honoring the non-combinable, over-cap, and exclusive-code rules) and only that subset unlocks discounts. Governance is opt-in — with no active policy the presented codes pass through exactly as before, so carts that never used stacking policies are unaffected. Operators who offer multiple combinable codes and want them to keep stacking should define a policy with allow_combine.with_other_codes enabled. **Fixed:** *Code-gated discounts now honor the coupon-stacking policy at checkout* — Checkout resolved automatic discounts by summing every rule a shopper's presented codes unlocked, with no consultation of the coupon-stacking policy — so a policy that forbade combining codes, capped the number of codes per order, or marked a code exclusive was enforced only in the admin surface, never on the price the customer was actually charged. Checkout now evaluates the stacking policy first: when an active policy governs the cart, the presented codes are reduced to the policy-approved subset before any discount is computed, so non-combinable, over-cap, and exclusive-code rules hold on the charged total. When no stacking policy is active the codes are left untouched, so existing carts that don't use stacking policies see no change; to keep multiple codes combining, define a policy with allow_combine.with_other_codes enabled.
12
+
11
13
  - v0.4.99 (2026-06-25) — **A subscription whose dunning retries are exhausted is now actually cancelled, instead of being marked cancelled while it keeps billing.** Dunning walks a failed-payment enrollment through its retry schedule and, when the attempt cap is reached, is documented to auto-cancel the subscription. Only an explicit cancel_subscription step in the schedule actually composed the subscription-side cancel, though: when the cap fired on the final retry/reminder step instead — the common case for a schedule that simply runs out of attempts — the enrollment was flipped to 'cancelled' but the subscription cancel was never propagated, so the recurring charge kept firing against a customer the policy had given up on. The attempt-cap close now composes the same subscription cancel an explicit cancel step does, and records it as its own audit event with its real outcome (a failed cancel is visible, not masked). A separate audit-integrity fix removes a hardcoded 'cancelled the subscription' event that the schedule-exhaustion branch wrote unconditionally, including when no cancel was attempted. No configuration changes; operators running dunning policies that rely on the attempt cap (rather than an explicit cancel step) should upgrade. **Fixed:** *Reaching the dunning attempt cap now cancels the subscription, not just the enrollment row* — When a dunning enrollment reached cancel_after_attempts on a retry or reminder step, the enrollment was closed as 'cancelled' but the subscription-side cancellation was composed only for an explicit cancel_subscription step — so a policy that relied on the attempt cap to give up left the underlying subscription active and continuing to bill. The attempt-cap close now composes the subscription cancel that the documented 'tried everything' exit always promised, resolving the invoice's subscription and signaling its cancellation exactly as an explicit cancel step does. The cancel is recorded as its own entry in the dunning audit log with the actual outcome, so a cancel that fails downstream is surfaced for follow-up rather than reported as success. · *The dunning audit log no longer records a cancellation that did not happen* — The schedule-exhaustion path wrote a fixed 'cancel_subscription / ok' audit entry regardless of how the enrollment actually closed — so an enrollment that ended without a cancellation still showed a successful cancel in the history an operator replays on the customer's timeline. The terminal audit entry now reflects what actually happened: a real cancel with its real outcome when the subscription is cancelled, and a plain schedule-exhausted record when it is not.
12
14
 
13
15
  - v0.4.98 (2026-06-25) — **A concurrent vendor registration with the same slug now returns a typed duplicate error instead of leaking a raw database error.** Registering a vendor checks whether the slug is already taken before inserting, then inserts. Two registrations of the same slug racing each other can both pass that pre-check, so the second insert collides on the slug primary key. That insert had no failure handling, so the raw database error surfaced — and in production, where the data layer redacts it to a generic 'HTTP 500', the caller got an opaque error rather than the typed 'slug already registered' it gets from the pre-check. The insert is now wrapped: on any failure it re-reads by slug, and if a row exists it raises the same typed duplicate error the pre-check raises, otherwise it re-throws. No configuration changes. **Fixed:** *Racing vendor registrations surface a typed duplicate error, not a raw 500* — vendors.registerVendor pre-checks the slug and then inserts, but a concurrent registration of the same slug can slip past the pre-check and collide on the slug primary key. The insert is now wrapped to detect that state-agnostically — re-reading by slug after a failure and raising the same typed VENDOR_SLUG_TAKEN error the pre-check raises (rather than leaking the raw constraint error, which production redacts to a bare 500). Mirrors the assignSku duplicate handling.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.99",
2
+ "version": "0.4.100",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/checkout.js CHANGED
@@ -228,6 +228,13 @@ function create(deps) {
228
228
  // rule's savings are a shipping concern and are left to the shipping
229
229
  // primitive, not folded into the subtotal discount here.
230
230
  var autoDiscount = deps.autoDiscount || null;
231
+ // Optional coupon-stacking governor. When wired, it decides which of
232
+ // the shopper-presented codes may combine on one order (the operator's
233
+ // allow_combine / max_codes_per_order / exclusive_codes policy) before
234
+ // the discount engine sums anything — so two code-gated rules can't
235
+ // silently stack past a policy that forbids it. Absent → every
236
+ // presented code reaches the engine (the pre-governor behaviour).
237
+ var couponStacking = deps.couponStacking || null;
231
238
  // Optional discount-allocation recorder. When wired, a placed order
232
239
  // that carried a cart-level discount gets a per-line breakdown of how
233
240
  // that discount split across the order lines, recorded as a back-office
@@ -550,6 +557,42 @@ function create(deps) {
550
557
  unit_price_minor: l.unit_amount_minor,
551
558
  });
552
559
  }
560
+ // Apply the coupon-stacking policy BEFORE the engine sums anything.
561
+ // Only the surviving subset of presented codes unlocks code-gated
562
+ // rules, so a non-stackable / exclusive / over-cap code cannot
563
+ // inflate the discount. Only consulted when the governor is wired
564
+ // AND more than one code is presented (a single code can never
565
+ // "stack"); drop-silent on any consult failure so a governor outage
566
+ // never breaks the buy path — the discount clamp below still bounds
567
+ // the result to [0, subtotal].
568
+ var effectiveCodes = Array.isArray(codes) && codes.length ? codes : undefined;
569
+ if (couponStacking && typeof couponStacking.evaluate === "function" &&
570
+ typeof couponStacking.activePoliciesForCart === "function" &&
571
+ Array.isArray(codes) && codes.length > 1) {
572
+ try {
573
+ // Governance is opt-in: only an ACTIVE stacking policy narrows the
574
+ // presented codes. With no governing policy the operator hasn't
575
+ // asked checkout to police stacking, so the codes pass through
576
+ // unchanged — the cart accepted them and silently dropping one
577
+ // here would diverge the charged price from what the cart showed.
578
+ var govCart = { subtotal_minor: subtotalMinor };
579
+ var policies = await couponStacking.activePoliciesForCart({
580
+ cart: govCart, customer_id: customerId || undefined,
581
+ });
582
+ if (Array.isArray(policies) && policies.length > 0) {
583
+ var stack = await couponStacking.evaluate({
584
+ codes: codes,
585
+ cart: govCart,
586
+ customer_id: customerId || undefined,
587
+ });
588
+ if (stack && Array.isArray(stack.applied)) {
589
+ effectiveCodes = stack.applied.length ? stack.applied : undefined;
590
+ }
591
+ }
592
+ } catch (_e) {
593
+ effectiveCodes = Array.isArray(codes) && codes.length ? codes : undefined;
594
+ }
595
+ }
553
596
  var res = await autoDiscount.evaluate({
554
597
  cart: {
555
598
  subtotal_minor: subtotalMinor,
@@ -558,8 +601,9 @@ function create(deps) {
558
601
  customer_id: customerId || undefined,
559
602
  // Shopper-presented coupon codes unlock code-gated rules. Absent /
560
603
  // empty leaves only the pure-automatic rules in play, so a quote
561
- // with no codes is byte-identical to the pre-code behaviour.
562
- codes: Array.isArray(codes) && codes.length ? codes : undefined,
604
+ // with no codes is byte-identical to the pre-code behaviour. The
605
+ // coupon-stacking governor (above) may narrow this set.
606
+ codes: effectiveCodes,
563
607
  });
564
608
  var appliedRaw = res && Array.isArray(res.applied) ? res.applied : [];
565
609
  var total = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.99",
3
+ "version": "0.4.100",
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": {