@blamejs/blamejs-shop 0.0.66 → 0.0.70
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 +8 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +35 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- 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
|
+
};
|