@blamejs/blamejs-shop 0.4.98 → 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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +46 -2
- package/lib/dunning.js +38 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ 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
|
+
|
|
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.
|
|
14
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
17
|
- v0.4.97 (2026-06-25) — **Constraint-collision handling no longer depends on the database error text, so duplicate detection works in production instead of only in tests.** Several write paths detected a UNIQUE (or related) constraint collision by matching the database error message — looking for the literal text 'UNIQUE constraint failed'. That text only survives in local/test runs: in production, the data layer redacts the underlying SQLite error to a generic 'HTTP 500' with no constraint text, so the match silently failed and the intended collision handling was skipped — the raw error surfaced instead of the idempotent replay, the typed duplicate error, the regenerate-and-retry, or the claim reuse. Every such site now detects the collision by re-reading state through the unique key after the insert fails: if the row is present the failure was the collision and the site runs its existing handling; if not, the original error is re-thrown. This makes idempotent webhook replays, duplicate-key errors, code/token retries, and discount-claim reuse behave correctly in production, not just under tests. No configuration changes. **Fixed:** *Constraint-collision detection is now state-based, not error-message-based* — Duplicate-delivery webhook replays (webhook receiver), duplicate-key errors (tenant domain + slug, vendor SKU), regenerate-and-retry on a code/token collision (referral codes, gift-redemption tokens, API keys, affiliate codes), and the auto-discount per-order claim reuse all decided 'was this a constraint collision?' by scanning the SQLite error message. Production redacts that message to a bare 'HTTP 500', so the check was blind there and the collision branch never ran. Each site now re-reads by its unique key after an insert error and runs its handling only when the row is actually present, re-throwing any other error — the same approach the gift-card and affiliate-commission ledgers already use. The admin error-to-response mapper, which has no single key to re-read, now prefers a typed constraint code over the message text where the driver provides one.
|
package/lib/asset-manifest.json
CHANGED
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
|
-
|
|
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/lib/dunning.js
CHANGED
|
@@ -421,18 +421,32 @@ function create(opts) {
|
|
|
421
421
|
var attemptCount = Number(enrollment.attempt_count);
|
|
422
422
|
var stepIndex = attemptCount;
|
|
423
423
|
if (stepIndex >= schedule.length) {
|
|
424
|
-
// Walked off the end
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
424
|
+
// Walked off the end with no step left to run. If the attempt cap
|
|
425
|
+
// has been reached this is the "tried everything" auto-cancel, so
|
|
426
|
+
// we COMPOSE the subscription-side cancel (exitDunning) — not just
|
|
427
|
+
// flip the row, otherwise the enrollment reads cancelled while the
|
|
428
|
+
// recurring charge keeps firing. Below the cap it's an
|
|
429
|
+
// operator-authored schedule that ran out before resolution, which
|
|
430
|
+
// closes 'abandoned' with no cancel intent. Either way the audit
|
|
431
|
+
// event records what actually happened — never a fabricated cancel.
|
|
428
432
|
var closeAs = attemptCount >= cancelAfterAttempts ? "cancelled" : "abandoned";
|
|
433
|
+
var exhaustOutcome = closeAs === "cancelled"
|
|
434
|
+
? await _executeAction("cancel_subscription", enrollment)
|
|
435
|
+
: null;
|
|
429
436
|
await query(
|
|
430
437
|
"UPDATE dunning_enrollments SET status = ?1, next_action_at = NULL, " +
|
|
431
438
|
"ended_at = ?2, end_reason = ?3, last_action = ?4, last_action_at = ?2 " +
|
|
432
439
|
"WHERE id = ?5 AND status = 'active'",
|
|
433
|
-
[closeAs, now, "schedule_exhausted",
|
|
440
|
+
[closeAs, now, "schedule_exhausted",
|
|
441
|
+
closeAs === "cancelled" ? "cancel_subscription" : "schedule_exhausted",
|
|
442
|
+
enrollment.id],
|
|
443
|
+
);
|
|
444
|
+
await _writeEvent(
|
|
445
|
+
enrollment.id, attemptCount,
|
|
446
|
+
closeAs === "cancelled" ? "cancel_subscription" : "schedule_exhausted",
|
|
447
|
+
closeAs === "cancelled" ? exhaustOutcome : "ok",
|
|
448
|
+
now,
|
|
434
449
|
);
|
|
435
|
-
await _writeEvent(enrollment.id, attemptCount, "cancel_subscription", "ok", now);
|
|
436
450
|
return await _refetchEnrollment(enrollment.id);
|
|
437
451
|
}
|
|
438
452
|
|
|
@@ -444,13 +458,28 @@ function create(opts) {
|
|
|
444
458
|
var terminal = (action === "cancel_subscription") || (newAttemptCount >= cancelAfterAttempts);
|
|
445
459
|
|
|
446
460
|
if (terminal) {
|
|
461
|
+
// A `cancel_subscription` STEP already composed the subscription
|
|
462
|
+
// cancel via _executeAction above. When the ATTEMPT CAP fires on a
|
|
463
|
+
// non-cancel step (send_reminder / retry_charge), the enrollment
|
|
464
|
+
// still closes 'cancelled' — so the subscription-side cancel must
|
|
465
|
+
// be composed here too; the bare status flip would leave the
|
|
466
|
+
// subscription billing. The step's own action event is preserved;
|
|
467
|
+
// the composed cancel is recorded as its own event with the real
|
|
468
|
+
// outcome (so a failed cancel is visible, not masked as success).
|
|
469
|
+
var capCancelOutcome = null;
|
|
470
|
+
if (action !== "cancel_subscription") {
|
|
471
|
+
capCancelOutcome = await _executeAction("cancel_subscription", enrollment);
|
|
472
|
+
}
|
|
447
473
|
await query(
|
|
448
474
|
"UPDATE dunning_enrollments SET status = 'cancelled', next_action_at = NULL, " +
|
|
449
|
-
"attempt_count = ?1, last_action =
|
|
450
|
-
"ended_at = ?
|
|
451
|
-
[newAttemptCount,
|
|
475
|
+
"attempt_count = ?1, last_action = 'cancel_subscription', last_action_at = ?2, " +
|
|
476
|
+
"ended_at = ?2, end_reason = ?3 WHERE id = ?4 AND status = 'active'",
|
|
477
|
+
[newAttemptCount, now, action === "cancel_subscription" ? "policy_cancel_step" : "attempt_cap", enrollment.id],
|
|
452
478
|
);
|
|
453
479
|
await _writeEvent(enrollment.id, newAttemptCount, action, outcome, now);
|
|
480
|
+
if (action !== "cancel_subscription") {
|
|
481
|
+
await _writeEvent(enrollment.id, newAttemptCount, "cancel_subscription", capCancelOutcome, now);
|
|
482
|
+
}
|
|
454
483
|
return await _refetchEnrollment(enrollment.id);
|
|
455
484
|
}
|
|
456
485
|
|
package/package.json
CHANGED