@blamejs/blamejs-shop 0.4.97 → 0.4.99
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/dunning.js +38 -9
- package/lib/vendors.js +30 -13
- 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.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
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- v0.4.96 (2026-06-25) — **Compose two newly-public framework primitives directly, removing an internal-path dependency and a drift-prone duplicate transition table.** Two internal refactors that adopt framework surface the application previously had to work around, with no change in behaviour. The text-guard module composed the framework's codepoint-threat catalog by reaching into an internal vendored module path, because the catalog was not on the framework's public surface; it now composes the public b.codepointClass (the same module, reference-identical), so a future framework refresh that moves the internal file can't break the import. The quotes module kept a hand-rolled loop that re-derived a quote's next status from a second copy of its transition table; it now resolves the destination through the state machine's own side-effect-free resolver, off the single transition definition, so the two can no longer drift apart. No configuration changes. **Changed:** *Text-guard composes the public codepoint-threat catalog* — The free-text guard built its bidi / control / null-byte / zero-width / mixed-script screening on the framework's codepoint catalog by requiring an internal vendored module path, since the catalog was not yet exported publicly. It now composes the public b.codepointClass — the identical module — so the screening no longer depends on an internal file location that an upstream refresh could relocate. · *Quote transitions resolve through the state machine instead of a duplicate table* — Validating a quote's lifecycle edge (respond / accept / reject / cancel / expire / convert) used the state machine to check the edge was legal, then a separate hand-rolled loop over a second copy of the transition table to read the destination status. The destination is now resolved by the state machine's own side-effect-free resolver, off the single transition definition the machine is built from, removing the duplicate table walk that could drift from it.
|
package/lib/asset-manifest.json
CHANGED
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/lib/vendors.js
CHANGED
|
@@ -398,19 +398,36 @@ function create(opts) {
|
|
|
398
398
|
}
|
|
399
399
|
|
|
400
400
|
var ts = _now();
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
401
|
+
try {
|
|
402
|
+
await query(
|
|
403
|
+
"INSERT INTO vendors " +
|
|
404
|
+
"(slug, name, contact_email_hash, contact_email_normalised, " +
|
|
405
|
+
" contact_phone, address_json, payout_method, payout_address, " +
|
|
406
|
+
" commission_split_bps, status, paused_at, archived_at, " +
|
|
407
|
+
" created_at, updated_at) " +
|
|
408
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, ?12, ?12)",
|
|
409
|
+
[
|
|
410
|
+
slug, name, emailHash, emailNorm, phone, addressJson,
|
|
411
|
+
payoutMethod, payoutAddr, splitBps, status,
|
|
412
|
+
status === "paused" ? ts : null, ts,
|
|
413
|
+
],
|
|
414
|
+
);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
// The pre-check above can miss a CONCURRENT registration, and the
|
|
417
|
+
// production D1 service binding redacts the SQLite slug-PRIMARY-KEY
|
|
418
|
+
// constraint message to a generic "HTTP 500", so a message regex would
|
|
419
|
+
// be blind there. State-agnostic detection: re-read by the UNIQUE key
|
|
420
|
+
// (vendors.slug) — if the row now exists a racing register won, so
|
|
421
|
+
// surface the SAME typed duplicate the pre-check throws; otherwise the
|
|
422
|
+
// insert failed for another reason, so re-throw.
|
|
423
|
+
var raced = await _getVendorRaw(slug);
|
|
424
|
+
if (raced) {
|
|
425
|
+
var racedDupe = new Error("vendors.registerVendor: slug " + JSON.stringify(slug) + " already registered");
|
|
426
|
+
racedDupe.code = "VENDOR_SLUG_TAKEN";
|
|
427
|
+
throw racedDupe;
|
|
428
|
+
}
|
|
429
|
+
throw e;
|
|
430
|
+
}
|
|
414
431
|
return _hydrateVendor(await _getVendorRaw(slug));
|
|
415
432
|
},
|
|
416
433
|
|
package/package.json
CHANGED