@blamejs/blamejs-shop 0.2.30 → 0.2.31

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.2.x
10
10
 
11
+ - v0.2.31 (2026-05-29) — **Abandoned-cart recovery.** A scheduled pass now finds carts left idle with items and no order and emails the shopper a link back — composed from the cart-abandonment scanner and a multi-step recovery sequence, gated on marketing consent (anyone who has withdrawn marketing consent is skipped) and idempotent (one email per abandonment, no double-sends across ticks). It stays completely inert until an SMTP mailer and an email resolver are configured, so a store without them is unaffected; the existing once-a-minute cron drives it. **Added:** *Abandoned-cart recovery* — A scheduled pass detects carts that have items, no paid order, and have been idle past a configurable threshold (`shop.cart_recovery_after_hours`, default 4h), then emails a recovery link to shoppers whose address is known. Enrolment is gated on the marketing-consent ledger and the send path honors the suppression list, so a withdrawn or unsubscribed shopper is never contacted; sends are idempotent. The pass no-ops cleanly until an SMTP mailer (`SMTP_HOST` / `MAIL_FROM`) and an email resolver are wired, so deploying it changes nothing until you opt in.
12
+
11
13
  - v0.2.30 (2026-05-29) — **Fulfillment operations in the admin console — pick lists, labels, split shipments.** The admin console gains a fulfillment workspace. Pick lists batch unfulfilled orders into a warehouse worksheet — generate from eligible orders or a pasted set of order ids, confirm or recount each line, then complete the list to fan out shipments, with a print-optimized worksheet. Shipping labels can be recorded per shipment on the order detail (carrier, service, parcel dimensions, tracking, and cost). Split shipments let you ship part of an order now and the rest later, planning per-line parcel assignments — without forcing the order's lifecycle forward until everything ships. Separately, an accessibility fix: the footer column headings move from h4 to h2, removing a heading-level skip on every page (the footer looks identical). **Added:** *Pick lists* — `/admin/pick-lists` batches unfulfilled orders into a warehouse worksheet — generate from auto-selected eligible orders or a pasted set of order ids, confirm/recount each line with a variance summary, cancel with a reason, and complete the list (which fans out one shipment per parent order). A self-contained `@media print` worksheet prints the picks. · *Shipping labels* — Record a carrier-minted shipping label per shipment from the order detail — carrier, service, parcel dimensions, tracking number, label URL, and cost — with a mark-used action and a per-shipment label list. Available where shipment tracking is wired. · *Split shipments* — Plan and execute partial shipments for an order: assign line items and quantities to parcels and ship them as separate shipments. The order stays in its fulfilling state until all parcels have shipped, so a partial shipment never marks the order delivered prematurely. **Fixed:** *Footer heading order* — The footer column headings were emitted as `h4` directly after the page's `h2`, skipping intermediate levels — a WCAG 1.3.1 heading-hierarchy violation. They are now `h2` (correct for a top-level region inside the footer landmark). The footer's appearance is unchanged.
12
14
 
13
15
  - v0.2.29 (2026-05-29) — **Show the real total before checkout and truthful stock on every product.** Two storefront truthfulness fixes. The cart and checkout now show the real total before payment — subtotal plus estimated tax, shipping, and any discount, all totalled — computed by the same engine that runs the actual charge, so the figure shown matches what will be charged (labelled estimated until an address narrows the tax and shipping, then exact; a destination with no matching rule reads 'calculated at checkout' rather than a wrong number). Cart lines also surface real stock state. On product pages, the buy box now reflects real per-variant inventory: an out-of-stock variant disables Add to cart with an honest message, and a low-stock variant shows 'Only N left'. **Fixed:** *The cart and checkout show your real total before you pay* — Previously the cart total was just the subtotal and checkout showed no tax/shipping until the payment step. Both now render subtotal + estimated tax + estimated shipping + discount + total, computed from the same tax/shipping primitives as the Stripe charge — the displayed estimate equals the confirmed order total to the cent. Figures are labelled 'estimated' until a shipping address is entered (then exact); a destination with no matching tax or shipping rule reads 'calculated at checkout', and the subtotal stays honest. Cart lines now also show out-of-stock / low-stock state. · *Product availability reflects real inventory* — The product buy box now reads real per-variant stock: an out-of-stock variant disables 'Add to cart' and shows an out-of-stock message (in the badge and the JSON-LD), and a variant at or below the configured low-stock threshold shows 'Only N left'. The add-to-cart control is no longer enabled for items that can't be bought.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.30",
2
+ "version": "0.2.31",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -0,0 +1,482 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.cartRecoveryPass
4
+ * @title Cart-recovery orchestration — one bounded scan → enroll → dispatch tick
5
+ *
6
+ * @intro
7
+ * `cart-abandonment` detects idle carts (writes detection rows) and
8
+ * `cart-recovery` owns the multi-step nurture FSM (enroll, dispatch,
9
+ * recover). This primitive is the glue that drives both on a cron
10
+ * cadence: one `runPass()` call scans for freshly abandoned carts,
11
+ * enrolls the eligible ones into a recovery sequence, and dispatches
12
+ * every nurture step that has come due — all inside a single bounded,
13
+ * drop-silent tick that is safe to fire every minute.
14
+ *
15
+ * Composition:
16
+ *
17
+ * var pass = bShop.cartRecoveryPass.create({
18
+ * cartAbandonment: bShop.cartAbandonment.create({ query: q, cart: cart }),
19
+ * cartRecovery: bShop.cartRecovery.create({
20
+ * query: q,
21
+ * email: bShop.email.create({ mailer: m }),
22
+ * emailSuppressions: bShop.emailSuppressions.create({ query: q }),
23
+ * }),
24
+ * config: bShop.config.create({ query: q }),
25
+ * consentLedger: bShop.consentLedger.create({ query: q }),
26
+ * sequenceSlug: "default-recovery",
27
+ * cartUrlBase: "https://shop.example/cart",
28
+ * resolveEmail: async function (candidate) {
29
+ * // Operator-owned: map an abandoned cart's customer_id to a
30
+ * // deliverable plaintext address. Return null when none.
31
+ * return await myEmailLookup(candidate.customer_id);
32
+ * },
33
+ * });
34
+ *
35
+ * // Cron-driven; drop-silent — never throws out of the cron.
36
+ * var summary = await pass.runPass();
37
+ *
38
+ * Eligibility (who gets enrolled):
39
+ *
40
+ * 1. The cart has a `customer_id` (a known shopper). Guest carts
41
+ * carry no email anywhere in the schema — the `carts` row has
42
+ * only `session_id` + `customer_id`, and the customer record
43
+ * stores an `email_hash`, never the plaintext — so there is no
44
+ * deliverable address to recover for an anonymous cart. Guest-
45
+ * cart recovery re-opens the day the operator persists a guest
46
+ * email on the cart (a checkout-email-capture field) and feeds
47
+ * it through `resolveEmail`; until then it's an inert branch,
48
+ * not a silent drop — the pass counts the skip.
49
+ *
50
+ * 2. `resolveEmail(candidate)` returns a non-empty plaintext
51
+ * address. The operator owns the customer_id → address route;
52
+ * absent the hook (or a null return) the candidate is skipped
53
+ * `no-email` and never enrolled.
54
+ *
55
+ * 3. The customer has not WITHDRAWN marketing-email consent. The
56
+ * consent ledger's latest `marketing_email` decision must not be
57
+ * `withdrawn`. A missing decision reads as "no objection on
58
+ * file" — the operator's checkout opt-in flow is the place that
59
+ * records the positive grant; this gate only refuses a recorded
60
+ * withdrawal so a one-click unsubscribe (which writes a
61
+ * `withdrawn` row) stops the next nurture before it enrolls.
62
+ * When no `consentLedger` dep is wired the gate is skipped
63
+ * (the operator runs without the ledger) and the suppression
64
+ * list inside the dispatcher remains the backstop.
65
+ *
66
+ * The suppression-list check (hard bounces / spam complaints /
67
+ * one-click unsubscribe rows) lives inside `cartRecovery.dispatchTick`
68
+ * — it runs per send, AFTER enrollment, and cancels the whole
69
+ * enrollment on a hit. The consent gate here is the BEFORE-enrollment
70
+ * complement: it keeps an opted-out customer out of the sequence in
71
+ * the first place rather than enrolling them and skipping the send.
72
+ *
73
+ * Idempotency + cadence:
74
+ *
75
+ * * `cartAbandonment.scan` is idempotent at the idle-window level —
76
+ * a cart already detected inside the current window is skipped,
77
+ * so re-running the pass every minute does not re-detect the same
78
+ * cart.
79
+ * * Each detection enrolls AT MOST once: the pass records the
80
+ * detection_id on the enrollment and, before enrolling, checks
81
+ * the abandonment detection's `reminder_status`. A detection
82
+ * already marked `sent` / `skipped-*` is never re-enrolled.
83
+ * * `cartRecovery.dispatchTick` sends one email per step and walks
84
+ * the FSM forward, so a cart is never emailed twice for the same
85
+ * step.
86
+ *
87
+ * Safe-to-run-every-minute:
88
+ *
89
+ * The scan + dispatch batches are bounded (`maxCarts`,
90
+ * `maxDispatch`, both capped). The pass wraps the whole body in a
91
+ * try/catch and returns a summary object on success OR a
92
+ * `{ ok: false, error }` shape on failure — it NEVER throws, so a
93
+ * slow/failing send can't escape the cron handler. (Drop-silent —
94
+ * by design: this is a hot-path scheduled sink, not a config
95
+ * entry point.)
96
+ *
97
+ * Config threshold:
98
+ *
99
+ * `shop.cart_recovery_after_hours` (default 4) sets how long a cart
100
+ * sits idle before it's eligible. Read through the `config`
101
+ * primitive when wired; an out-of-range stored value throws at the
102
+ * config-read boundary (entry-point tier) so an operator catches a
103
+ * fat-fingered value rather than silently shipping a 0-hour or
104
+ * 1000-hour threshold.
105
+ *
106
+ * Gating (no-op when unconfigured):
107
+ *
108
+ * The pass is INERT unless `cartRecovery` is wired with a live
109
+ * `email` primitive AND a `resolveEmail` hook is supplied — the two
110
+ * things needed to actually deliver mail. Absent either, `runPass`
111
+ * returns `{ ok: true, enabled: false, reason }` without scanning,
112
+ * so an operator who hasn't configured a mailer pays nothing and
113
+ * the cron stays quiet. This is the common case today: no mailer is
114
+ * wired into the default deploy, so the cron's recovery branch
115
+ * no-ops cleanly until the operator opts in.
116
+ *
117
+ * Composes ONLY blamejs + sibling shop primitives:
118
+ * - shop.cartAbandonment — scan + detection bookkeeping.
119
+ * - shop.cartRecovery — enroll + dispatch FSM.
120
+ * - shop.config — threshold read (optional).
121
+ * - shop.consentLedger — marketing opt-out gate (optional).
122
+ * - b.constants.TIME — hours → ms conversion.
123
+ *
124
+ * @primitive cartRecoveryPass
125
+ * @related shop.cartAbandonment, shop.cartRecovery, shop.config,
126
+ * shop.consentLedger, shop.email, shop.emailSuppressions
127
+ */
128
+
129
+ var b = require("./vendor/blamejs");
130
+ var C = b.constants;
131
+
132
+ // ---- constants ----------------------------------------------------------
133
+
134
+ var CONFIG_KEY_AFTER_HOURS = "shop.cart_recovery_after_hours";
135
+ var DEFAULT_AFTER_HOURS = 4;
136
+ var MIN_AFTER_HOURS = 1;
137
+ var MAX_AFTER_HOURS = 720; // 30 days — the abandonment max-age ceiling
138
+
139
+ var DEFAULT_MAX_CARTS = 200;
140
+ var MAX_MAX_CARTS = 2000;
141
+ var DEFAULT_MAX_DISPATCH = 200;
142
+ var MAX_MAX_DISPATCH = 2000;
143
+
144
+ var DEFAULT_SEQUENCE_SLUG = "default-recovery";
145
+
146
+ // The consent ledger records `marketing_email` decisions as `granted`
147
+ // / `withdrawn`. We refuse enrollment only on a recorded withdrawal —
148
+ // a missing decision is "no objection on file" (the positive grant is
149
+ // recorded by the operator's checkout opt-in, not invented here).
150
+ var CONSENT_KIND_MARKETING = "marketing_email";
151
+ var CONSENT_WITHDRAWN = "withdrawn";
152
+
153
+ // ---- validators ---------------------------------------------------------
154
+ //
155
+ // Config-time / entry-point tier — THROW on bad operator input so a
156
+ // typo is caught at boot or at the config-read boundary rather than
157
+ // silently shipping a nonsense threshold.
158
+
159
+ function _positiveIntInRange(n, label, min, max) {
160
+ if (typeof n !== "number" || !isFinite(n) || !Number.isInteger(n) || n < min || n > max) {
161
+ throw new TypeError(
162
+ "cartRecoveryPass: " + label + " must be an integer in [" + min + ", " + max + "]"
163
+ );
164
+ }
165
+ return n;
166
+ }
167
+
168
+ // ---- factory ------------------------------------------------------------
169
+
170
+ function create(opts) {
171
+ opts = opts || {};
172
+
173
+ // Required dep: the abandonment scanner. The pass can't find idle
174
+ // carts without it, so refuse at factory time (boot) rather than at
175
+ // first tick.
176
+ if (!opts.cartAbandonment || typeof opts.cartAbandonment.scan !== "function") {
177
+ throw new TypeError(
178
+ "cartRecoveryPass.create: opts.cartAbandonment (with .scan()) is required"
179
+ );
180
+ }
181
+ var cartAbandonment = opts.cartAbandonment;
182
+
183
+ // Required dep: the recovery FSM. The pass enrolls detections + ticks
184
+ // the dispatcher through it.
185
+ if (
186
+ !opts.cartRecovery ||
187
+ typeof opts.cartRecovery.enrollDetection !== "function" ||
188
+ typeof opts.cartRecovery.dispatchTick !== "function" ||
189
+ typeof opts.cartRecovery.defineSequence !== "function"
190
+ ) {
191
+ throw new TypeError(
192
+ "cartRecoveryPass.create: opts.cartRecovery (with .enrollDetection/.dispatchTick/.defineSequence) is required"
193
+ );
194
+ }
195
+ var cartRecovery = opts.cartRecovery;
196
+
197
+ // Optional dep: the config primitive. When wired, the idle threshold
198
+ // is read from `shop.cart_recovery_after_hours`; absent it, the
199
+ // default (4h) is used.
200
+ var config = opts.config || null;
201
+ if (config && typeof config.get !== "function") {
202
+ throw new TypeError(
203
+ "cartRecoveryPass.create: opts.config must expose a get(key, default) method"
204
+ );
205
+ }
206
+
207
+ // Optional dep: the consent ledger. When wired, a recorded
208
+ // marketing-email withdrawal keeps the customer out of the sequence
209
+ // before enrollment. Absent it, the suppression list inside the
210
+ // dispatcher is the backstop.
211
+ var consentLedger = opts.consentLedger || null;
212
+ if (consentLedger && typeof consentLedger.currentStateForCustomer !== "function") {
213
+ throw new TypeError(
214
+ "cartRecoveryPass.create: opts.consentLedger must expose currentStateForCustomer(customerId)"
215
+ );
216
+ }
217
+
218
+ // Operator-owned resolver: an abandoned cart's customer_id → a
219
+ // deliverable plaintext address. The customer record stores only an
220
+ // email_hash, so the framework cannot recover the address on its own
221
+ // — the operator wires the route (typically through their own
222
+ // customer/address store). Absent the hook the pass is inert (no
223
+ // deliverable address means nothing to send).
224
+ var resolveEmail = opts.resolveEmail || null;
225
+ if (resolveEmail != null && typeof resolveEmail !== "function") {
226
+ throw new TypeError(
227
+ "cartRecoveryPass.create: opts.resolveEmail must be a function (candidate) => Promise<string|null>"
228
+ );
229
+ }
230
+
231
+ // The recovery sequence detections enroll into. Validated lazily —
232
+ // `defineSequence` / `enrollDetection` enforce the slug grammar.
233
+ var sequenceSlug = typeof opts.sequenceSlug === "string" && opts.sequenceSlug.length
234
+ ? opts.sequenceSlug
235
+ : DEFAULT_SEQUENCE_SLUG;
236
+
237
+ // Base URL the recovery email's cart link is built from. Passed
238
+ // straight through to the dispatcher.
239
+ var cartUrlBase = typeof opts.cartUrlBase === "string" && opts.cartUrlBase.length
240
+ ? opts.cartUrlBase
241
+ : null;
242
+
243
+ var maxCarts = opts.maxCarts == null
244
+ ? DEFAULT_MAX_CARTS
245
+ : _positiveIntInRange(opts.maxCarts, "maxCarts", 1, MAX_MAX_CARTS);
246
+ var maxDispatch = opts.maxDispatch == null
247
+ ? DEFAULT_MAX_DISPATCH
248
+ : _positiveIntInRange(opts.maxDispatch, "maxDispatch", 1, MAX_MAX_DISPATCH);
249
+
250
+ // The dispatcher can actually deliver mail only when the recovery
251
+ // primitive was wired with a live `email` dep AND we have a resolver
252
+ // to turn a customer_id into a plaintext address. Inspect the
253
+ // recovery primitive's exposed deps so the gate reflects the real
254
+ // wiring rather than a separate config flag.
255
+ function _deliveryConfigured() {
256
+ var deps = cartRecovery._deps || {};
257
+ var hasEmail = !!(deps.email && typeof deps.email.sendAbandonedCartReminder === "function");
258
+ return hasEmail && typeof resolveEmail === "function";
259
+ }
260
+
261
+ // ---- internals --------------------------------------------------------
262
+
263
+ // Read + validate the idle threshold. Config-time / entry-point tier
264
+ // — a stored value outside [MIN, MAX] hours THROWS so the operator
265
+ // catches a bad config row rather than silently shipping a nonsense
266
+ // cadence. Absent the config dep (or the key), the default applies.
267
+ async function _resolveAfterHours() {
268
+ if (!config) return DEFAULT_AFTER_HOURS;
269
+ var raw = await config.get(CONFIG_KEY_AFTER_HOURS, DEFAULT_AFTER_HOURS);
270
+ if (raw == null) return DEFAULT_AFTER_HOURS;
271
+ var n = typeof raw === "string" ? Number(raw) : raw;
272
+ return _positiveIntInRange(
273
+ n, CONFIG_KEY_AFTER_HOURS, MIN_AFTER_HOURS, MAX_AFTER_HOURS
274
+ );
275
+ }
276
+
277
+ // True when the customer has a RECORDED marketing-email withdrawal.
278
+ // A missing decision (no row) returns false — "no objection on file".
279
+ // Reads through the consent ledger when wired; absent it, the gate is
280
+ // open (the suppression list is the backstop inside the dispatcher).
281
+ async function _hasWithdrawnMarketing(customerId) {
282
+ if (!consentLedger || !customerId) return false;
283
+ var state = await consentLedger.currentStateForCustomer(customerId);
284
+ var decision = state && state[CONSENT_KIND_MARKETING];
285
+ return !!(decision && decision.state === CONSENT_WITHDRAWN);
286
+ }
287
+
288
+ // Ensure the recovery sequence the pass enrolls into exists. The
289
+ // operator can pre-define a richer sequence with the same slug; this
290
+ // only seeds a sane 3-step default (1h reminder → 24h discount → 72h
291
+ // last-chance) the first time so a fresh deploy isn't inert for lack
292
+ // of a sequence row. `defineSequence` is an upsert keyed on slug, but
293
+ // we only seed when absent so we never stomp an operator's edits.
294
+ async function _ensureSequence(now) {
295
+ var existing = await cartRecovery.getSequence(sequenceSlug);
296
+ if (existing) return existing;
297
+ return await cartRecovery.defineSequence({
298
+ slug: sequenceSlug,
299
+ title: "Cart recovery",
300
+ steps: [
301
+ { step_index: 0, offset_ms: C.TIME.hours(1), kind: "reminder" },
302
+ { step_index: 1, offset_ms: C.TIME.hours(24), kind: "discount" },
303
+ { step_index: 2, offset_ms: C.TIME.hours(72), kind: "last_chance" },
304
+ ],
305
+ now: now,
306
+ });
307
+ }
308
+
309
+ // ---- surface ----------------------------------------------------------
310
+
311
+ return {
312
+ CONFIG_KEY_AFTER_HOURS: CONFIG_KEY_AFTER_HOURS,
313
+ DEFAULT_AFTER_HOURS: DEFAULT_AFTER_HOURS,
314
+
315
+ // One full orchestration tick: scan → consent-gate → enroll →
316
+ // dispatch. Drop-silent (by design) — wraps the whole body in a
317
+ // try/catch and NEVER throws, so a slow/failing send can't escape
318
+ // the cron handler. Returns a summary object describing what
319
+ // happened (or `{ ok: false, error }` on an internal failure).
320
+ //
321
+ // Safe to call every minute: the scan + dispatch batches are
322
+ // bounded, the abandonment scan is window-idempotent, and each
323
+ // detection enrolls at most once.
324
+ runPass: async function (passOpts) {
325
+ passOpts = passOpts || {};
326
+
327
+ // Gate: inert unless mail can actually be delivered. No scan, no
328
+ // DB churn — return a cheap "disabled" summary.
329
+ if (!_deliveryConfigured()) {
330
+ return {
331
+ ok: true,
332
+ enabled: false,
333
+ reason: "cart-recovery email delivery not configured (needs cartRecovery.email + resolveEmail)",
334
+ };
335
+ }
336
+
337
+ try {
338
+ var now = passOpts.now == null ? Date.now() : passOpts.now;
339
+
340
+ var afterHours = await _resolveAfterHours();
341
+ var idleMs = C.TIME.hours(afterHours);
342
+
343
+ await _ensureSequence(now);
344
+
345
+ // 1. Scan for freshly abandoned carts. The scanner writes a
346
+ // detection row per new candidate + returns the list.
347
+ var scan = await cartAbandonment.scan({
348
+ idle_threshold_ms: idleMs,
349
+ max_carts: maxCarts,
350
+ });
351
+
352
+ var enrolled = 0;
353
+ var skippedNoCustomer = 0;
354
+ var skippedNoEmail = 0;
355
+ var skippedOptOut = 0;
356
+
357
+ var candidates = scan.candidates || [];
358
+ for (var i = 0; i < candidates.length; i += 1) {
359
+ var cand = candidates[i];
360
+
361
+ // Guest cart — no customer record, so no address anywhere in
362
+ // the schema. Mark the detection skipped so the abandonment
363
+ // dashboard reflects the outcome, then move on. (Re-opens
364
+ // when the operator persists a guest email on the cart.)
365
+ if (!cand.customer_id) {
366
+ skippedNoCustomer += 1;
367
+ await cartAbandonment.markReminderSkipped(cand.detection_id, {
368
+ reason: "anonymous",
369
+ });
370
+ continue;
371
+ }
372
+
373
+ // Marketing opt-out gate — refuse enrollment for a customer
374
+ // who has recorded a marketing-email withdrawal. The
375
+ // suppression list inside the dispatcher catches bounces /
376
+ // complaints; this catches the explicit consent withdrawal
377
+ // BEFORE we enroll them.
378
+ var optedOut = await _hasWithdrawnMarketing(cand.customer_id);
379
+ if (optedOut) {
380
+ skippedOptOut += 1;
381
+ await cartAbandonment.markReminderSkipped(cand.detection_id, {
382
+ reason: "opted-out",
383
+ });
384
+ continue;
385
+ }
386
+
387
+ // Resolve a deliverable address. Absent one, mark the
388
+ // detection skipped + don't enroll (nothing to send).
389
+ var address = null;
390
+ try { address = await resolveEmail(cand); }
391
+ catch (_e) { address = null; }
392
+ if (typeof address !== "string" || !address.length) {
393
+ skippedNoEmail += 1;
394
+ await cartAbandonment.markReminderSkipped(cand.detection_id, {
395
+ reason: "no-email",
396
+ });
397
+ continue;
398
+ }
399
+
400
+ // Enroll into the recovery sequence. The dispatcher (below /
401
+ // next tick) owns the actual send + the per-send suppression
402
+ // check. We mark the abandonment detection `sent` to record
403
+ // that fan-out has begun — the nurture FSM is now the source
404
+ // of truth for this cart.
405
+ await cartRecovery.enrollDetection({
406
+ sequence_slug: sequenceSlug,
407
+ detection_id: cand.detection_id,
408
+ cart_id: cand.cart_id,
409
+ customer_id: cand.customer_id,
410
+ customer_email: address,
411
+ now: now,
412
+ });
413
+ await cartAbandonment.markReminderSent(cand.detection_id, {
414
+ sent_at: now,
415
+ });
416
+ enrolled += 1;
417
+ }
418
+
419
+ // 2. Dispatch every nurture step that has come due. The
420
+ // dispatcher walks `status='enrolled' AND next_step_at <=
421
+ // now`, sends through the email primitive (subject to the
422
+ // per-send suppression gate), and advances the FSM. The
423
+ // resolver re-supplies the plaintext address (the
424
+ // enrollment row stores only the hash).
425
+ var dispatch = await cartRecovery.dispatchTick({
426
+ now: now,
427
+ max_batch: maxDispatch,
428
+ cart_url_base: cartUrlBase,
429
+ resolveEmail: function (enrollment) {
430
+ // The dispatcher hands the enrollment row; route through
431
+ // the same operator resolver, shaping the input to the
432
+ // candidate-like fields the resolver reads.
433
+ return resolveEmail({
434
+ customer_id: enrollment.customer_id,
435
+ cart_id: enrollment.cart_id,
436
+ detection_id: enrollment.detection_id,
437
+ });
438
+ },
439
+ });
440
+
441
+ return {
442
+ ok: true,
443
+ enabled: true,
444
+ after_hours: afterHours,
445
+ scanned: scan.carts_scanned,
446
+ detected: scan.carts_detected,
447
+ enrolled: enrolled,
448
+ skipped_no_customer: skippedNoCustomer,
449
+ skipped_no_email: skippedNoEmail,
450
+ skipped_opt_out: skippedOptOut,
451
+ dispatched: dispatch.dispatched,
452
+ };
453
+ } catch (e) {
454
+ // Drop-silent — by design. A scheduled hot-path sink must never
455
+ // throw out of the cron handler (that would crash-loop the
456
+ // tick). Return the failure as data so the caller's
457
+ // observability sink can surface it.
458
+ var msg = e && e.message ? String(e.message) : String(e || "unknown");
459
+ if (msg.length > 1024) msg = msg.slice(0, 1024);
460
+ return { ok: false, enabled: true, error: msg };
461
+ }
462
+ },
463
+
464
+ // Expose deps + resolved gate so a wiring sanity check (and the
465
+ // tests) can assert the pass reached the factory correctly.
466
+ _deps: {
467
+ cartAbandonment: cartAbandonment,
468
+ cartRecovery: cartRecovery,
469
+ config: config,
470
+ consentLedger: consentLedger,
471
+ resolveEmail: resolveEmail,
472
+ sequenceSlug: sequenceSlug,
473
+ },
474
+ _deliveryConfigured: _deliveryConfigured,
475
+ };
476
+ }
477
+
478
+ module.exports = {
479
+ create: create,
480
+ CONFIG_KEY_AFTER_HOURS: CONFIG_KEY_AFTER_HOURS,
481
+ DEFAULT_AFTER_HOURS: DEFAULT_AFTER_HOURS,
482
+ };
package/lib/index.js CHANGED
@@ -223,6 +223,7 @@ Object.assign(module.exports, {
223
223
  stockReceipts: require("./stock-receipts"),
224
224
  bannerABTests: require("./banner-ab-tests"),
225
225
  cartRecovery: require("./cart-recovery"),
226
+ cartRecoveryPass: require("./cart-recovery-pass"),
226
227
  sidebarWidgets: require("./sidebar-widgets"),
227
228
  consentLedger: require("./consent-ledger"),
228
229
  announcementBar: require("./announcement-bar"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
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": {