@blamejs/blamejs-shop 0.0.66 → 0.0.72

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +36 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/loyalty-earn-rules.js +786 -0
  19. package/lib/operator-roles.js +768 -0
  20. package/lib/order-escalation.js +951 -0
  21. package/lib/order-ratings.js +495 -0
  22. package/lib/order-tags.js +944 -0
  23. package/lib/packing-slips.js +810 -0
  24. package/lib/pixel-events.js +995 -0
  25. package/lib/print-queue.js +681 -0
  26. package/lib/product-qa.js +749 -0
  27. package/lib/promo-bundles.js +835 -0
  28. package/lib/push-notifications.js +937 -0
  29. package/lib/refund-automation.js +853 -0
  30. package/lib/reorder-reminders.js +798 -0
  31. package/lib/robots-config.js +753 -0
  32. package/lib/seller-signup.js +1052 -0
  33. package/lib/sitemap-generator.js +717 -0
  34. package/lib/split-shipments.js +7 -1
  35. package/lib/subscription-gifts.js +710 -0
  36. package/lib/tax-cert-renewals.js +632 -0
  37. package/lib/tier-benefits.js +776 -0
  38. package/lib/vendor/MANIFEST.json +2 -2
  39. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  40. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  41. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  42. package/lib/vendor/blamejs/package.json +1 -1
  43. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  44. package/lib/wishlist-alerts.js +842 -0
  45. package/lib/wishlist-sharing.js +718 -0
  46. package/package.json +1 -1
@@ -0,0 +1,632 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.taxCertRenewals
4
+ * @title Tax-exemption certificate renewal scheduler
5
+ *
6
+ * @intro
7
+ * Companion to `taxExempt`. Approved exemption certificates carry a
8
+ * real-world `expires_at`; once that wall-clock moment passes, the
9
+ * checkout path stops honoring the row. Buyers (especially B2B
10
+ * buyers with dozens of jurisdiction-specific certificates) rarely
11
+ * notice the lapse on their own — the operator owns the reminder
12
+ * loop.
13
+ *
14
+ * The operator defines a per-jurisdiction `schedule`: a
15
+ * `lead_time_days` window and a `reminder_template_slug` to render.
16
+ * On a scheduler tick the operator calls `scanAndEnqueue({ now })`;
17
+ * the primitive walks approved certs whose `expires_at` lands in
18
+ * `[now, now + lead_time_days]`, composes `taxExempt` to look up
19
+ * the certificate detail, and writes one reminder row per (cert,
20
+ * channel) tuple in `queued` state. A dispatcher fan-out hands the
21
+ * row to `notifications.enqueue` (when injected) and the primitive
22
+ * flips `status` to `sent` via `recordReminderSent`.
23
+ *
24
+ * FSM:
25
+ *
26
+ * queued -> sent (recordReminderSent)
27
+ * sent -> escalated (markEscalated) — past escalate_after_days
28
+ * sent -> renewed (markRenewed) — buyer submits a fresh cert
29
+ * sent -> expired (markExpired) — cert lapsed, no renewal
30
+ * queued -> renewed (markRenewed) — early renewal before send
31
+ * queued -> expired (markExpired) — operator closes a stale queue
32
+ *
33
+ * Monotonic-clock pattern:
34
+ * Two reminders for the same (cert_id, channel) in the same
35
+ * millisecond would tie on `created_at` and make the "latest
36
+ * reminder" ambiguous when the dispatcher reads back. The
37
+ * primitive resolves each enqueue against the previous row's
38
+ * `created_at` for the same (cert_id, channel) and bumps the new
39
+ * row's timestamp to `prior_created_at + 1` on a tie. The result
40
+ * is a strictly-monotonic per-(cert,channel) reminder sequence
41
+ * no matter how fast a scanner tick runs.
42
+ *
43
+ * Composition:
44
+ * var tcr = bShop.taxCertRenewals.create({
45
+ * query: q,
46
+ * taxExempt: bShop.taxExempt.create({ query: q }),
47
+ * notifications: bShop.notifications.create({ query: q }),
48
+ * });
49
+ * await tcr.defineSchedule({
50
+ * jurisdiction: "US-CA",
51
+ * lead_time_days: 60,
52
+ * reminder_template_slug: "tax-cert-renewal-us-ca",
53
+ * escalate_after_days: 14,
54
+ * });
55
+ * var r = await tcr.scanAndEnqueue({ now: Date.now() });
56
+ * // r.enqueued — count of reminder rows written this tick
57
+ *
58
+ * Composes:
59
+ * - `taxExempt` — lookups for expiring certs by jurisdiction.
60
+ * - `notifications` — channel fan-out (in-app / email / webhook)
61
+ * when the operator injects the handle.
62
+ * - `b.guardUuid` — cert_id sanitization on every transition.
63
+ * - `b.uuid.v7` — reminder row ids (lexicographic + monotonic).
64
+ *
65
+ * @primitive taxCertRenewals
66
+ * @related taxExempt, notifications, b.guardUuid, b.uuid
67
+ */
68
+
69
+ var bShop;
70
+ function _b() {
71
+ if (!bShop) bShop = require("./index");
72
+ return bShop.framework;
73
+ }
74
+
75
+ var JURISDICTION_RE = /^[A-Z]{2}(-[A-Z0-9]{1,3})?$/;
76
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
77
+ var CHANNEL_RE = /^[a-z][a-z0-9._-]{0,62}[a-z0-9]$/;
78
+
79
+ var MAX_LEAD_TIME_DAYS = 365;
80
+ var MAX_ESCALATE_DAYS = 365;
81
+ var MAX_ESCALATED_TO_LEN = 256;
82
+ var DEFAULT_CHANNELS = Object.freeze(["email"]);
83
+
84
+ var DAY_MS = 86400000;
85
+
86
+ var STATUSES = Object.freeze(["queued", "sent", "escalated", "renewed", "expired"]);
87
+
88
+ // ---- validators ---------------------------------------------------------
89
+
90
+ function _jurisdiction(j) {
91
+ if (typeof j !== "string" || !JURISDICTION_RE.test(j)) {
92
+ throw new TypeError(
93
+ "taxCertRenewals: jurisdiction must match /^[A-Z]{2}(-[A-Z0-9]{1,3})?$/ " +
94
+ "(e.g. 'US', 'US-CA', 'EU-DE'), got " + JSON.stringify(j)
95
+ );
96
+ }
97
+ return j;
98
+ }
99
+
100
+ function _leadTimeDays(n) {
101
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_LEAD_TIME_DAYS) {
102
+ throw new TypeError(
103
+ "taxCertRenewals: lead_time_days must be an integer 1.." + MAX_LEAD_TIME_DAYS
104
+ );
105
+ }
106
+ return n;
107
+ }
108
+
109
+ function _escalateAfterDays(n) {
110
+ if (n == null) return null;
111
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_ESCALATE_DAYS) {
112
+ throw new TypeError(
113
+ "taxCertRenewals: escalate_after_days must be a positive integer <= " +
114
+ MAX_ESCALATE_DAYS + " or null"
115
+ );
116
+ }
117
+ return n;
118
+ }
119
+
120
+ function _slug(s, label) {
121
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
122
+ throw new TypeError(
123
+ "taxCertRenewals: " + label + " must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/ " +
124
+ "(1-128 chars, lowercase)"
125
+ );
126
+ }
127
+ return s;
128
+ }
129
+
130
+ function _channel(s) {
131
+ if (typeof s !== "string" || !CHANNEL_RE.test(s)) {
132
+ throw new TypeError(
133
+ "taxCertRenewals: channel must match /[a-z][a-z0-9._-]*[a-z0-9]/ (2-64 chars)"
134
+ );
135
+ }
136
+ return s;
137
+ }
138
+
139
+ function _certId(s) {
140
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
141
+ catch (e) { throw new TypeError("taxCertRenewals: cert_id — " + (e && e.message || "invalid UUID")); }
142
+ }
143
+
144
+ function _customerId(s) {
145
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
146
+ catch (e) { throw new TypeError("taxCertRenewals: customer_id — " + (e && e.message || "invalid UUID")); }
147
+ }
148
+
149
+ function _epochMs(n, label) {
150
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
151
+ throw new TypeError("taxCertRenewals: " + label + " must be a positive integer epoch-ms");
152
+ }
153
+ return n;
154
+ }
155
+
156
+ function _escalatedTo(s) {
157
+ if (typeof s !== "string") {
158
+ throw new TypeError("taxCertRenewals: escalated_to must be a string");
159
+ }
160
+ var trimmed = s.trim();
161
+ if (!trimmed.length) {
162
+ throw new TypeError("taxCertRenewals: escalated_to must be non-empty after trim");
163
+ }
164
+ if (trimmed.length > MAX_ESCALATED_TO_LEN) {
165
+ throw new TypeError("taxCertRenewals: escalated_to must be <= " + MAX_ESCALATED_TO_LEN + " chars");
166
+ }
167
+ if (/[\x00-\x1F\x7F]/.test(trimmed)) {
168
+ throw new TypeError("taxCertRenewals: escalated_to must not contain control bytes");
169
+ }
170
+ return trimmed;
171
+ }
172
+
173
+ function _channels(arr) {
174
+ if (arr == null) return DEFAULT_CHANNELS.slice();
175
+ if (!Array.isArray(arr) || !arr.length) {
176
+ throw new TypeError("taxCertRenewals: channels must be a non-empty array of channel strings");
177
+ }
178
+ if (arr.length > 8) {
179
+ throw new TypeError("taxCertRenewals: channels must be <=8 entries");
180
+ }
181
+ var seen = {};
182
+ var out = [];
183
+ for (var i = 0; i < arr.length; i += 1) {
184
+ var c = _channel(arr[i]);
185
+ if (seen[c]) continue;
186
+ seen[c] = true;
187
+ out.push(c);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function _now() { return Date.now(); }
193
+
194
+ // ---- factory ------------------------------------------------------------
195
+
196
+ function create(opts) {
197
+ opts = opts || {};
198
+ var query = opts.query;
199
+ if (!query) {
200
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
201
+ }
202
+ // Optional composition handles. When absent, the primitive falls
203
+ // back to direct SQL lookups against the shared `query` (the cert-
204
+ // expiry scan reads the `tax_exempt_certificates` table itself) and
205
+ // refuses to write through the notifications fan-out (the operator
206
+ // is responsible for picking the queued rows up themselves).
207
+ var taxExempt = opts.taxExempt || null;
208
+ var notifications = opts.notifications || null;
209
+
210
+ // O(1) read: the most-recent reminder row for a (cert_id, channel)
211
+ // tuple. The write path bumps `created_at` against this row to keep
212
+ // the per-tuple sequence strictly monotonic — see header notes.
213
+ async function _latestReminderTs(certId, channel) {
214
+ var r = await query(
215
+ "SELECT created_at FROM tax_cert_renewal_reminders " +
216
+ "WHERE cert_id = ?1 AND channel = ?2 " +
217
+ "ORDER BY created_at DESC LIMIT 1",
218
+ [certId, channel],
219
+ );
220
+ return r.rows.length ? r.rows[0].created_at : null;
221
+ }
222
+
223
+ // Returns the timestamp to stamp on a new reminder row. If the
224
+ // requested `now` would tie or land older than the prior row's
225
+ // `created_at`, bump to `prior + 1`. Operators that call
226
+ // `scanAndEnqueue` from a tight loop hit this path; the bump keeps
227
+ // the dispatcher's "latest reminder" read deterministic.
228
+ function _resolveCreatedAt(requestedTs, latestTs) {
229
+ if (latestTs == null) return requestedTs;
230
+ if (requestedTs > latestTs) return requestedTs;
231
+ return latestTs + 1;
232
+ }
233
+
234
+ async function _getScheduleRow(jurisdiction) {
235
+ var r = await query(
236
+ "SELECT * FROM tax_cert_renewal_schedules WHERE jurisdiction = ?1",
237
+ [jurisdiction],
238
+ );
239
+ return r.rows.length ? r.rows[0] : null;
240
+ }
241
+
242
+ async function _openReminderForCert(certId, channel) {
243
+ // "Open" reminders are queued OR sent — a single in-flight reminder
244
+ // per (cert, channel) prevents the scanner from re-enqueuing on
245
+ // every tick while a previous reminder is still working its way
246
+ // through the dispatcher.
247
+ var r = await query(
248
+ "SELECT id, status, created_at FROM tax_cert_renewal_reminders " +
249
+ "WHERE cert_id = ?1 AND channel = ?2 AND status IN ('queued', 'sent') " +
250
+ "ORDER BY created_at DESC LIMIT 1",
251
+ [certId, channel],
252
+ );
253
+ return r.rows.length ? r.rows[0] : null;
254
+ }
255
+
256
+ async function _findExpiringCerts(jurisdiction, fromTs, toTs) {
257
+ // The scanner reads the shared `tax_exempt_certificates` table
258
+ // directly — composing `taxExempt.activeForCustomer` would require
259
+ // a customer-id-first index, which is the wrong shape for a
260
+ // window-bounded jurisdiction scan. The read filters to approved
261
+ // rows with `expires_at` in the lead-time window so a row that
262
+ // already expired (and should be `expired` after the next
263
+ // `expireScan`) doesn't leak into the renewal queue.
264
+ var r = await query(
265
+ "SELECT id, customer_id, jurisdiction, certificate_number, expires_at " +
266
+ "FROM tax_exempt_certificates " +
267
+ "WHERE status = 'approved' AND jurisdiction = ?1 " +
268
+ " AND expires_at IS NOT NULL AND expires_at >= ?2 AND expires_at <= ?3 " +
269
+ "ORDER BY expires_at ASC",
270
+ [jurisdiction, fromTs, toTs],
271
+ );
272
+ return r.rows;
273
+ }
274
+
275
+ return {
276
+ STATUSES: STATUSES.slice(),
277
+
278
+ defineSchedule: async function (input) {
279
+ if (!input || typeof input !== "object") {
280
+ throw new TypeError("taxCertRenewals.defineSchedule: input object required");
281
+ }
282
+ var jurisdiction = _jurisdiction(input.jurisdiction);
283
+ var leadTime = _leadTimeDays(input.lead_time_days);
284
+ var slug = _slug(input.reminder_template_slug, "reminder_template_slug");
285
+ var escalate = _escalateAfterDays(input.escalate_after_days);
286
+ var ts = _now();
287
+
288
+ var existing = await _getScheduleRow(jurisdiction);
289
+ if (existing) {
290
+ // Re-define = upsert. Operators iterate on cadence + template
291
+ // mid-flight; an already-archived schedule un-archives on
292
+ // re-define so the operator doesn't have to clear a tombstone.
293
+ await query(
294
+ "UPDATE tax_cert_renewal_schedules SET " +
295
+ " lead_time_days = ?2, reminder_template_slug = ?3, " +
296
+ " escalate_after_days = ?4, archived_at = NULL, updated_at = ?5 " +
297
+ "WHERE jurisdiction = ?1",
298
+ [jurisdiction, leadTime, slug, escalate, ts],
299
+ );
300
+ return await _getScheduleRow(jurisdiction);
301
+ }
302
+ await query(
303
+ "INSERT INTO tax_cert_renewal_schedules " +
304
+ "(jurisdiction, lead_time_days, reminder_template_slug, escalate_after_days, " +
305
+ " archived_at, created_at, updated_at) " +
306
+ "VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
307
+ [jurisdiction, leadTime, slug, escalate, ts],
308
+ );
309
+ return await _getScheduleRow(jurisdiction);
310
+ },
311
+
312
+ archiveSchedule: async function (jurisdiction) {
313
+ var j = _jurisdiction(jurisdiction);
314
+ var row = await _getScheduleRow(j);
315
+ if (!row) return null;
316
+ if (row.archived_at != null) return row;
317
+ var ts = _now();
318
+ await query(
319
+ "UPDATE tax_cert_renewal_schedules SET archived_at = ?1, updated_at = ?1 " +
320
+ "WHERE jurisdiction = ?2",
321
+ [ts, j],
322
+ );
323
+ return await _getScheduleRow(j);
324
+ },
325
+
326
+ listSchedules: async function (input) {
327
+ input = input || {};
328
+ var sql = "SELECT * FROM tax_cert_renewal_schedules";
329
+ var where = [];
330
+ var params = [];
331
+ if (input.include_archived !== true) {
332
+ where.push("archived_at IS NULL");
333
+ }
334
+ if (input.jurisdiction != null) {
335
+ params.push(_jurisdiction(input.jurisdiction));
336
+ where.push("jurisdiction = ?" + params.length);
337
+ }
338
+ if (where.length) sql += " WHERE " + where.join(" AND ");
339
+ sql += " ORDER BY jurisdiction ASC";
340
+ var r = await query(sql, params);
341
+ return r.rows;
342
+ },
343
+
344
+ scanAndEnqueue: async function (input) {
345
+ if (!input || typeof input !== "object") {
346
+ throw new TypeError("taxCertRenewals.scanAndEnqueue: input object required");
347
+ }
348
+ var now = _epochMs(input.now, "now");
349
+ var channels = _channels(input.channels);
350
+ // Optional jurisdiction filter — operators with a sharded
351
+ // scheduler (one cron per region) pass the region filter to
352
+ // narrow the scan to their shard.
353
+ var jurisdictionFilter = null;
354
+ if (input.jurisdiction != null) {
355
+ jurisdictionFilter = _jurisdiction(input.jurisdiction);
356
+ }
357
+
358
+ var schedSql = "SELECT * FROM tax_cert_renewal_schedules WHERE archived_at IS NULL";
359
+ var schedParams = [];
360
+ if (jurisdictionFilter) {
361
+ schedParams.push(jurisdictionFilter);
362
+ schedSql += " AND jurisdiction = ?1";
363
+ }
364
+ var schedR = await query(schedSql, schedParams);
365
+ var schedules = schedR.rows;
366
+
367
+ var enqueued = 0;
368
+ var skippedExisting = 0;
369
+ var skippedOptedOut = 0;
370
+ var reminderIds = [];
371
+
372
+ for (var i = 0; i < schedules.length; i += 1) {
373
+ var sched = schedules[i];
374
+ var toTs = now + sched.lead_time_days * DAY_MS;
375
+ var certs;
376
+ if (taxExempt && typeof taxExempt.expiringInWindow === "function") {
377
+ // Future-proofing: when taxExempt grows a first-class window
378
+ // helper, prefer it over the direct SQL fallback below.
379
+ certs = await taxExempt.expiringInWindow({
380
+ jurisdiction: sched.jurisdiction,
381
+ from: now,
382
+ to: toTs,
383
+ });
384
+ } else {
385
+ certs = await _findExpiringCerts(sched.jurisdiction, now, toTs);
386
+ }
387
+
388
+ for (var j2 = 0; j2 < certs.length; j2 += 1) {
389
+ var cert = certs[j2];
390
+ for (var k = 0; k < channels.length; k += 1) {
391
+ var channel = channels[k];
392
+
393
+ var open = await _openReminderForCert(cert.id, channel);
394
+ if (open) { skippedExisting += 1; continue; }
395
+
396
+ // Compose notifications when the operator wired it in.
397
+ // An opt-out response counts as "skipped" — the row is not
398
+ // written, the dispatcher won't try, and the metric
399
+ // surfaces in `metricsForSchedule` so operators can see
400
+ // their reminder reach.
401
+ if (notifications && typeof notifications.enqueue === "function") {
402
+ var notifRes = await notifications.enqueue({
403
+ recipient_id: cert.customer_id,
404
+ channel: channel,
405
+ event_type: "tax.cert.renewal",
406
+ title: "Tax exemption certificate renewal",
407
+ body: sched.reminder_template_slug,
408
+ payload: {
409
+ cert_id: cert.id,
410
+ jurisdiction: sched.jurisdiction,
411
+ expires_at: cert.expires_at,
412
+ reminder_template_slug: sched.reminder_template_slug,
413
+ },
414
+ scheduled_at: now,
415
+ });
416
+ if (notifRes && notifRes.ok === false) {
417
+ skippedOptedOut += 1;
418
+ continue;
419
+ }
420
+ }
421
+
422
+ var latest = await _latestReminderTs(cert.id, channel);
423
+ var createdAt = _resolveCreatedAt(now, latest);
424
+ var id = _b().uuid.v7();
425
+ await query(
426
+ "INSERT INTO tax_cert_renewal_reminders " +
427
+ "(id, cert_id, jurisdiction, channel, status, created_at) " +
428
+ "VALUES (?1, ?2, ?3, ?4, 'queued', ?5)",
429
+ [id, cert.id, sched.jurisdiction, channel, createdAt],
430
+ );
431
+ reminderIds.push(id);
432
+ enqueued += 1;
433
+ }
434
+ }
435
+ }
436
+
437
+ return {
438
+ enqueued: enqueued,
439
+ skipped_existing: skippedExisting,
440
+ skipped_opted_out: skippedOptedOut,
441
+ reminder_ids: reminderIds,
442
+ };
443
+ },
444
+
445
+ recordReminderSent: async function (input) {
446
+ if (!input || typeof input !== "object") {
447
+ throw new TypeError("taxCertRenewals.recordReminderSent: input object required");
448
+ }
449
+ var certId = _certId(input.cert_id);
450
+ var channel = _channel(input.channel);
451
+ var ts = input.ts != null ? _epochMs(input.ts, "ts") : _now();
452
+
453
+ var row = await query(
454
+ "SELECT id, status FROM tax_cert_renewal_reminders " +
455
+ "WHERE cert_id = ?1 AND channel = ?2 AND status = 'queued' " +
456
+ "ORDER BY created_at DESC LIMIT 1",
457
+ [certId, channel],
458
+ );
459
+ if (!row.rows.length) {
460
+ return { ok: false, error: "no-queued-reminder" };
461
+ }
462
+ var id = row.rows[0].id;
463
+ await query(
464
+ "UPDATE tax_cert_renewal_reminders SET status = 'sent', sent_at = ?1 " +
465
+ "WHERE id = ?2 AND status = 'queued'",
466
+ [ts, id],
467
+ );
468
+ return { ok: true, id: id, sent_at: ts };
469
+ },
470
+
471
+ markEscalated: async function (input) {
472
+ if (!input || typeof input !== "object") {
473
+ throw new TypeError("taxCertRenewals.markEscalated: input object required");
474
+ }
475
+ var certId = _certId(input.cert_id);
476
+ var escalatedTo = _escalatedTo(input.escalated_to);
477
+ var ts = input.ts != null ? _epochMs(input.ts, "ts") : _now();
478
+
479
+ // Escalation operates on the most-recent `sent` reminder across
480
+ // any channel — an operator who escalated by email shouldn't
481
+ // also be forced to escalate the webhook + in-app fan-outs
482
+ // separately. The first matching row wins; sibling channel rows
483
+ // remain `sent` so the audit shows the operator's chosen path.
484
+ var r = await query(
485
+ "SELECT id, jurisdiction, channel FROM tax_cert_renewal_reminders " +
486
+ "WHERE cert_id = ?1 AND status = 'sent' " +
487
+ "ORDER BY sent_at DESC LIMIT 1",
488
+ [certId],
489
+ );
490
+ if (!r.rows.length) {
491
+ return { ok: false, error: "no-sent-reminder" };
492
+ }
493
+ // Refuse escalation when the schedule for this jurisdiction
494
+ // didn't define `escalate_after_days` — operators have to opt
495
+ // in to the escalation surface explicitly.
496
+ var jurisdiction = r.rows[0].jurisdiction;
497
+ var sched = await _getScheduleRow(jurisdiction);
498
+ if (!sched || sched.escalate_after_days == null) {
499
+ return { ok: false, error: "no-escalation-policy" };
500
+ }
501
+ var id = r.rows[0].id;
502
+ await query(
503
+ "UPDATE tax_cert_renewal_reminders SET " +
504
+ " status = 'escalated', escalated_at = ?1, escalated_to = ?2 " +
505
+ "WHERE id = ?3 AND status = 'sent'",
506
+ [ts, escalatedTo, id],
507
+ );
508
+ return { ok: true, id: id, escalated_at: ts };
509
+ },
510
+
511
+ markRenewed: async function (input) {
512
+ if (!input || typeof input !== "object") {
513
+ throw new TypeError("taxCertRenewals.markRenewed: input object required");
514
+ }
515
+ var certId = _certId(input.cert_id);
516
+ var newExpiry = _epochMs(input.new_expiry, "new_expiry");
517
+ var ts = input.ts != null ? _epochMs(input.ts, "ts") : _now();
518
+
519
+ // Close every open reminder for this cert — once the buyer
520
+ // submits a fresh certificate, every channel's reminder loop
521
+ // is satisfied. Returns the count of rows transitioned so the
522
+ // operator's UI can confirm the close-out.
523
+ var r = await query(
524
+ "UPDATE tax_cert_renewal_reminders SET " +
525
+ " status = 'renewed', renewed_at = ?1, new_expiry = ?2 " +
526
+ "WHERE cert_id = ?3 AND status IN ('queued', 'sent', 'escalated')",
527
+ [ts, newExpiry, certId],
528
+ );
529
+ return { ok: true, closed: r.rowCount, renewed_at: ts, new_expiry: newExpiry };
530
+ },
531
+
532
+ markExpired: async function (input) {
533
+ if (!input || typeof input !== "object") {
534
+ throw new TypeError("taxCertRenewals.markExpired: input object required");
535
+ }
536
+ var certId = _certId(input.cert_id);
537
+ var ts = input.ts != null ? _epochMs(input.ts, "ts") : _now();
538
+
539
+ var r = await query(
540
+ "UPDATE tax_cert_renewal_reminders SET status = 'expired', expired_at = ?1 " +
541
+ "WHERE cert_id = ?2 AND status IN ('queued', 'sent', 'escalated')",
542
+ [ts, certId],
543
+ );
544
+ return { ok: true, closed: r.rowCount, expired_at: ts };
545
+ },
546
+
547
+ certificationsDueSoon: async function (input) {
548
+ if (!input || typeof input !== "object") {
549
+ throw new TypeError("taxCertRenewals.certificationsDueSoon: input object required");
550
+ }
551
+ var from = _epochMs(input.from, "from");
552
+ var to = _epochMs(input.to, "to");
553
+ if (to <= from) {
554
+ throw new TypeError("taxCertRenewals.certificationsDueSoon: to must be > from");
555
+ }
556
+ var sql =
557
+ "SELECT id, customer_id, jurisdiction, certificate_number, expires_at " +
558
+ "FROM tax_exempt_certificates " +
559
+ "WHERE status = 'approved' " +
560
+ " AND expires_at IS NOT NULL AND expires_at >= ?1 AND expires_at <= ?2";
561
+ var params = [from, to];
562
+ if (input.jurisdiction != null) {
563
+ params.push(_jurisdiction(input.jurisdiction));
564
+ sql += " AND jurisdiction = ?" + params.length;
565
+ }
566
+ if (input.customer_id != null) {
567
+ params.push(_customerId(input.customer_id));
568
+ sql += " AND customer_id = ?" + params.length;
569
+ }
570
+ sql += " ORDER BY expires_at ASC";
571
+ var r = await query(sql, params);
572
+ return r.rows;
573
+ },
574
+
575
+ metricsForSchedule: async function (input) {
576
+ if (!input || typeof input !== "object") {
577
+ throw new TypeError("taxCertRenewals.metricsForSchedule: input object required");
578
+ }
579
+ var jurisdiction = _jurisdiction(input.jurisdiction);
580
+ var from = _epochMs(input.from, "from");
581
+ var to = _epochMs(input.to, "to");
582
+ if (to <= from) {
583
+ throw new TypeError("taxCertRenewals.metricsForSchedule: to must be > from");
584
+ }
585
+ var r = await query(
586
+ "SELECT status, COUNT(*) AS n FROM tax_cert_renewal_reminders " +
587
+ "WHERE jurisdiction = ?1 AND created_at >= ?2 AND created_at <= ?3 " +
588
+ "GROUP BY status",
589
+ [jurisdiction, from, to],
590
+ );
591
+ var counts = { queued: 0, sent: 0, escalated: 0, renewed: 0, expired: 0 };
592
+ for (var i = 0; i < r.rows.length; i += 1) {
593
+ counts[r.rows[i].status] = Number(r.rows[i].n);
594
+ }
595
+ var total = counts.queued + counts.sent + counts.escalated + counts.renewed + counts.expired;
596
+ // Rates are guarded against div-by-zero — an operator with no
597
+ // reminders in the window sees `total: 0` and every rate at 0.
598
+ function _rate(n) { return total === 0 ? 0 : n / total; }
599
+ // Renewal rate is the share of reminders that closed via
600
+ // `markRenewed`. Escalation rate captures the share that
601
+ // required operator follow-up. Expiry rate is the share that
602
+ // lapsed without renewal — the metric an operator watches to
603
+ // catch a broken template or channel.
604
+ return {
605
+ jurisdiction: jurisdiction,
606
+ from: from,
607
+ to: to,
608
+ total: total,
609
+ counts: counts,
610
+ renewal_rate: _rate(counts.renewed),
611
+ escalation_rate: _rate(counts.escalated),
612
+ expiry_rate: _rate(counts.expired),
613
+ send_rate: _rate(counts.sent + counts.escalated + counts.renewed + counts.expired),
614
+ };
615
+ },
616
+
617
+ listRemindersForCert: async function (certId) {
618
+ var id = _certId(certId);
619
+ var r = await query(
620
+ "SELECT * FROM tax_cert_renewal_reminders WHERE cert_id = ?1 " +
621
+ "ORDER BY created_at ASC",
622
+ [id],
623
+ );
624
+ return r.rows;
625
+ },
626
+ };
627
+ }
628
+
629
+ module.exports = {
630
+ create: create,
631
+ STATUSES: STATUSES.slice(),
632
+ };