@blamejs/blamejs-shop 0.0.53 → 0.0.56
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 +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.taxExempt
|
|
4
|
+
* @title Tax-exemption certificates — B2B / non-profit operator workflow
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Buyers who hold a resale / non-profit / government / agricultural /
|
|
8
|
+
* manufacturer / direct-pay / foreign-diplomat exemption submit a
|
|
9
|
+
* certificate; an operator reviews the uploaded scan against the
|
|
10
|
+
* buyer-declared certificate number and approves or rejects the
|
|
11
|
+
* submission. Approved + non-expired certificates suppress tax at
|
|
12
|
+
* checkout for the matching jurisdiction.
|
|
13
|
+
*
|
|
14
|
+
* The `certificate_number` is operator-display sensitive data: the
|
|
15
|
+
* operator needs to read it back from the row to verify against the
|
|
16
|
+
* uploaded scan. Dedup runs against the SHA3-512 namespaceHash of
|
|
17
|
+
* the uppercase-trimmed number — repeat submissions of the same
|
|
18
|
+
* cert by the same customer for the same jurisdiction collapse to a
|
|
19
|
+
* single row via the UNIQUE(customer_id, jurisdiction,
|
|
20
|
+
* certificate_number_hash) index.
|
|
21
|
+
*
|
|
22
|
+
* Lifecycle:
|
|
23
|
+
* submit → status='pending-review'
|
|
24
|
+
* approve → 'pending-review' → 'approved'
|
|
25
|
+
* reject → 'pending-review' → 'rejected'
|
|
26
|
+
* revoke → 'approved' → 'revoked'
|
|
27
|
+
* expireScan(now) → 'approved' && now>=exp → 'expired'
|
|
28
|
+
*
|
|
29
|
+
* `isExempt({ customer_id, jurisdiction })` is the fast-path
|
|
30
|
+
* checkout helper — exact jurisdiction match wins; failing that, a
|
|
31
|
+
* regional certificate (e.g. 'US' covering 'US-CA') matches; failing
|
|
32
|
+
* that, false. VIES live-check for EU VAT IDs is out of scope here
|
|
33
|
+
* — that lives on `tax.applyReverseCharge`.
|
|
34
|
+
*
|
|
35
|
+
* Composition:
|
|
36
|
+
* var te = bShop.taxExempt.create({ query: q });
|
|
37
|
+
* var s = await te.submit({
|
|
38
|
+
* customer_id: custId,
|
|
39
|
+
* certificate_type: "resale",
|
|
40
|
+
* jurisdiction: "US-CA",
|
|
41
|
+
* certificate_number: "SR-CA-12345678",
|
|
42
|
+
* legal_name: "Acme Industrial Supply LLC",
|
|
43
|
+
* issued_at: Date.now(),
|
|
44
|
+
* });
|
|
45
|
+
* await te.approve(s.id, { reviewed_by: "ops@example.com" });
|
|
46
|
+
* var exempt = await te.isExempt({ customer_id: custId, jurisdiction: "US-CA" });
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
var bShop;
|
|
50
|
+
function _b() {
|
|
51
|
+
if (!bShop) bShop = require("./index");
|
|
52
|
+
return bShop.framework;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
var CERT_NUMBER_NAMESPACE = "tax-cert-number";
|
|
56
|
+
|
|
57
|
+
var CERT_TYPES = [
|
|
58
|
+
"resale", "nonprofit", "government", "agricultural",
|
|
59
|
+
"manufacturer", "direct-pay", "foreign-diplomat", "other",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
var STATUSES = ["pending-review", "approved", "rejected", "expired", "revoked"];
|
|
63
|
+
|
|
64
|
+
// Jurisdiction format: an ISO 3166-1 alpha-2 region (e.g. "US", "EU",
|
|
65
|
+
// "CA") optionally followed by a "-" and 1-3 alphanumeric subdivision
|
|
66
|
+
// chars. Examples that match: "US", "US-CA", "CA-ON", "EU-DE",
|
|
67
|
+
// "GB-ENG". Examples refused: lowercase, four-char top-level,
|
|
68
|
+
// punctuation other than the single dash.
|
|
69
|
+
var JURISDICTION_RE = /^[A-Z]{2}(-[A-Z0-9]{1,3})?$/;
|
|
70
|
+
|
|
71
|
+
// Operator-display cert number — printable ASCII, 4-64 chars. We
|
|
72
|
+
// canonicalize (trim + uppercase) before hashing so casings collapse
|
|
73
|
+
// for dedup, but persist the operator-typed form so they can verify
|
|
74
|
+
// against the scan.
|
|
75
|
+
var CERT_NUMBER_RE = /^[\x20-\x7E]+$/;
|
|
76
|
+
var CERT_NUMBER_MIN = 4;
|
|
77
|
+
var CERT_NUMBER_MAX = 64;
|
|
78
|
+
|
|
79
|
+
var LEGAL_NAME_MAX = 256;
|
|
80
|
+
var NOTES_MAX = 4096;
|
|
81
|
+
var REJECTION_MAX = 512;
|
|
82
|
+
var R2_KEY_MAX = 512;
|
|
83
|
+
var REVIEWER_MAX = 256;
|
|
84
|
+
|
|
85
|
+
// ---- validators ---------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
function _uuid(s, label) {
|
|
88
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
89
|
+
catch (e) { throw new TypeError("taxExempt: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _certType(t) {
|
|
93
|
+
if (typeof t !== "string" || CERT_TYPES.indexOf(t) === -1) {
|
|
94
|
+
throw new TypeError("taxExempt: certificate_type must be one of " + CERT_TYPES.join(", "));
|
|
95
|
+
}
|
|
96
|
+
return t;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _jurisdiction(j) {
|
|
100
|
+
if (typeof j !== "string" || !JURISDICTION_RE.test(j)) {
|
|
101
|
+
throw new TypeError(
|
|
102
|
+
"taxExempt: jurisdiction must match /^[A-Z]{2}(-[A-Z0-9]{1,3})?$/ " +
|
|
103
|
+
"(e.g. 'US', 'US-CA', 'EU-DE'), got " + JSON.stringify(j)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return j;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _certNumber(n) {
|
|
110
|
+
if (typeof n !== "string") {
|
|
111
|
+
throw new TypeError("taxExempt: certificate_number must be a string");
|
|
112
|
+
}
|
|
113
|
+
var trimmed = n.trim();
|
|
114
|
+
if (trimmed.length < CERT_NUMBER_MIN || trimmed.length > CERT_NUMBER_MAX) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
"taxExempt: certificate_number must be " + CERT_NUMBER_MIN + "-" +
|
|
117
|
+
CERT_NUMBER_MAX + " printable-ASCII chars (post-trim)"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!CERT_NUMBER_RE.test(trimmed)) {
|
|
121
|
+
throw new TypeError("taxExempt: certificate_number must be printable ASCII (no control bytes)");
|
|
122
|
+
}
|
|
123
|
+
return trimmed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function _legalName(s) {
|
|
127
|
+
if (typeof s !== "string") {
|
|
128
|
+
throw new TypeError("taxExempt: legal_name must be a string");
|
|
129
|
+
}
|
|
130
|
+
var trimmed = s.trim();
|
|
131
|
+
if (!trimmed.length) {
|
|
132
|
+
throw new TypeError("taxExempt: legal_name must be non-empty after trim");
|
|
133
|
+
}
|
|
134
|
+
if (trimmed.length > LEGAL_NAME_MAX) {
|
|
135
|
+
throw new TypeError("taxExempt: legal_name must be <=" + LEGAL_NAME_MAX + " chars");
|
|
136
|
+
}
|
|
137
|
+
// Refuse control bytes (CR/LF/NUL/etc.) — operator-facing UIs and
|
|
138
|
+
// log lines render the legal_name verbatim; embedded newlines would
|
|
139
|
+
// be a log-injection vector. Single-line field; no whitespace
|
|
140
|
+
// beyond a single inner space run.
|
|
141
|
+
if (/[\x00-\x1F\x7F]/.test(trimmed)) {
|
|
142
|
+
throw new TypeError("taxExempt: legal_name must not contain control bytes");
|
|
143
|
+
}
|
|
144
|
+
return trimmed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _epochMs(n, label, opts) {
|
|
148
|
+
opts = opts || {};
|
|
149
|
+
if (n == null && opts.optional) return null;
|
|
150
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
151
|
+
throw new TypeError("taxExempt: " + label + " must be a positive integer epoch-ms" + (opts.optional ? " or null" : ""));
|
|
152
|
+
}
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _shortString(s, label, max) {
|
|
157
|
+
if (s == null) return null;
|
|
158
|
+
if (typeof s !== "string") {
|
|
159
|
+
throw new TypeError("taxExempt: " + label + " must be a string");
|
|
160
|
+
}
|
|
161
|
+
var trimmed = s.trim();
|
|
162
|
+
if (!trimmed.length) {
|
|
163
|
+
throw new TypeError("taxExempt: " + label + " must be non-empty when provided");
|
|
164
|
+
}
|
|
165
|
+
if (trimmed.length > max) {
|
|
166
|
+
throw new TypeError("taxExempt: " + label + " must be <=" + max + " chars");
|
|
167
|
+
}
|
|
168
|
+
if (/[\x00-\x08\x0B-\x1F\x7F]/.test(trimmed)) {
|
|
169
|
+
throw new TypeError("taxExempt: " + label + " must not contain control bytes");
|
|
170
|
+
}
|
|
171
|
+
return trimmed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _notes(s) {
|
|
175
|
+
if (s == null) return "";
|
|
176
|
+
if (typeof s !== "string") {
|
|
177
|
+
throw new TypeError("taxExempt: notes must be a string");
|
|
178
|
+
}
|
|
179
|
+
if (s.length > NOTES_MAX) {
|
|
180
|
+
throw new TypeError("taxExempt: notes must be <=" + NOTES_MAX + " chars");
|
|
181
|
+
}
|
|
182
|
+
if (/[\x00-\x08\x0E-\x1F\x7F]/.test(s)) {
|
|
183
|
+
// Permit \t (0x09), \n (0x0A), \r (0x0D), \v (0x0B), \f (0x0C)?
|
|
184
|
+
// Operators paste structured notes; keep \t \n \r \v \f, refuse
|
|
185
|
+
// NUL + most C0 controls + DEL.
|
|
186
|
+
throw new TypeError("taxExempt: notes must not contain disallowed control bytes");
|
|
187
|
+
}
|
|
188
|
+
return s;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _hashCertNumber(canonical) {
|
|
192
|
+
return _b().crypto.namespaceHash(CERT_NUMBER_NAMESPACE, canonical);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _canonicalCertNumber(n) {
|
|
196
|
+
return n.trim().toUpperCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _now() { return Date.now(); }
|
|
200
|
+
|
|
201
|
+
// Region match: an exact match wins, otherwise the region prefix of a
|
|
202
|
+
// hyphenated jurisdiction matches a region-only certificate. So a
|
|
203
|
+
// 'US' certificate covers 'US-CA', 'US-NY', etc. A 'US-CA' certificate
|
|
204
|
+
// does NOT cover 'US-NY' (state-specific certificates are scoped).
|
|
205
|
+
function _coversJurisdiction(certJurisdiction, queryJurisdiction) {
|
|
206
|
+
if (certJurisdiction === queryJurisdiction) return true;
|
|
207
|
+
// Region-cert covers subdivisions when the query starts with
|
|
208
|
+
// `${cert}-`. e.g. cert='US' covers query='US-CA'.
|
|
209
|
+
if (queryJurisdiction.indexOf(certJurisdiction + "-") === 0) return true;
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function _activeRowQuery(includeJurisdictionFilter) {
|
|
214
|
+
// "Active" = approved AND (expires_at IS NULL OR expires_at > now).
|
|
215
|
+
// Note: lazily-transitioned rows are caught by `.expireScan(now)`
|
|
216
|
+
// — but we ALSO filter on expires_at at read time so a slow
|
|
217
|
+
// scheduler doesn't honor a stale-approved row at checkout.
|
|
218
|
+
var sql =
|
|
219
|
+
"SELECT * FROM tax_exempt_certificates " +
|
|
220
|
+
"WHERE customer_id = ?1 AND status = 'approved' " +
|
|
221
|
+
" AND (expires_at IS NULL OR expires_at > ?2)";
|
|
222
|
+
if (includeJurisdictionFilter) sql += " AND jurisdiction = ?3";
|
|
223
|
+
sql += " ORDER BY issued_at DESC";
|
|
224
|
+
return sql;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- factory ------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function create(opts) {
|
|
230
|
+
opts = opts || {};
|
|
231
|
+
var query = opts.query;
|
|
232
|
+
if (!query) {
|
|
233
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function _getRow(id) {
|
|
237
|
+
var r = await query(
|
|
238
|
+
"SELECT * FROM tax_exempt_certificates WHERE id = ?1",
|
|
239
|
+
[id],
|
|
240
|
+
);
|
|
241
|
+
return r.rows.length ? r.rows[0] : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
CERT_NUMBER_NAMESPACE: CERT_NUMBER_NAMESPACE,
|
|
246
|
+
CERT_TYPES: CERT_TYPES,
|
|
247
|
+
STATUSES: STATUSES,
|
|
248
|
+
|
|
249
|
+
submit: async function (input) {
|
|
250
|
+
if (!input || typeof input !== "object") {
|
|
251
|
+
throw new TypeError("taxExempt.submit: input object required");
|
|
252
|
+
}
|
|
253
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
254
|
+
var certificateType = _certType(input.certificate_type);
|
|
255
|
+
var jurisdiction = _jurisdiction(input.jurisdiction);
|
|
256
|
+
var certificateNum = _certNumber(input.certificate_number);
|
|
257
|
+
var legalName = _legalName(input.legal_name);
|
|
258
|
+
var issuedAt = _epochMs(input.issued_at, "issued_at");
|
|
259
|
+
var expiresAt = _epochMs(input.expires_at, "expires_at", { optional: true });
|
|
260
|
+
var documentR2Key = _shortString(input.document_r2_key, "document_r2_key", R2_KEY_MAX);
|
|
261
|
+
var notes = _notes(input.notes);
|
|
262
|
+
|
|
263
|
+
if (expiresAt != null && expiresAt <= issuedAt) {
|
|
264
|
+
throw new TypeError("taxExempt.submit: expires_at must be > issued_at when provided");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
var canonical = _canonicalCertNumber(certificateNum);
|
|
268
|
+
var hash = _hashCertNumber(canonical);
|
|
269
|
+
|
|
270
|
+
// Idempotent on (customer_id, jurisdiction, certificate_number_hash).
|
|
271
|
+
// If a row already exists, return its current state without a
|
|
272
|
+
// status change — re-submitting an already-approved certificate
|
|
273
|
+
// must not re-open it for review.
|
|
274
|
+
var existing = await query(
|
|
275
|
+
"SELECT id, status FROM tax_exempt_certificates " +
|
|
276
|
+
"WHERE customer_id = ?1 AND jurisdiction = ?2 AND certificate_number_hash = ?3",
|
|
277
|
+
[customerId, jurisdiction, hash],
|
|
278
|
+
);
|
|
279
|
+
if (existing.rows.length) {
|
|
280
|
+
return { id: existing.rows[0].id, status: existing.rows[0].status };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
var id = _b().uuid.v7();
|
|
284
|
+
var ts = _now();
|
|
285
|
+
|
|
286
|
+
await query(
|
|
287
|
+
"INSERT INTO tax_exempt_certificates (" +
|
|
288
|
+
" id, customer_id, certificate_type, jurisdiction, certificate_number, certificate_number_hash, " +
|
|
289
|
+
" legal_name, issued_at, expires_at, status, document_r2_key, notes, created_at, updated_at" +
|
|
290
|
+
") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending-review', ?10, ?11, ?12, ?12)",
|
|
291
|
+
[
|
|
292
|
+
id, customerId, certificateType, jurisdiction, certificateNum, hash,
|
|
293
|
+
legalName, issuedAt, expiresAt, documentR2Key, notes, ts,
|
|
294
|
+
],
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
return { id: id, status: "pending-review" };
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
approve: async function (certId, input) {
|
|
301
|
+
_uuid(certId, "cert_id");
|
|
302
|
+
if (!input || typeof input !== "object") {
|
|
303
|
+
throw new TypeError("taxExempt.approve: input object required");
|
|
304
|
+
}
|
|
305
|
+
var reviewedBy = _shortString(input.reviewed_by, "reviewed_by", REVIEWER_MAX);
|
|
306
|
+
if (!reviewedBy) {
|
|
307
|
+
throw new TypeError("taxExempt.approve: reviewed_by is required");
|
|
308
|
+
}
|
|
309
|
+
var notes = _notes(input.notes);
|
|
310
|
+
|
|
311
|
+
var row = await _getRow(certId);
|
|
312
|
+
if (!row) return null;
|
|
313
|
+
if (row.status !== "pending-review") {
|
|
314
|
+
var e = new Error("taxExempt.approve: certificate is " + row.status + ", only 'pending-review' may be approved");
|
|
315
|
+
e.code = "TAXEXEMPT_INVALID_TRANSITION";
|
|
316
|
+
throw e;
|
|
317
|
+
}
|
|
318
|
+
var ts = _now();
|
|
319
|
+
// Preserve operator-supplied notes when the submitter left
|
|
320
|
+
// their own — append rather than overwrite.
|
|
321
|
+
var mergedNotes = row.notes && notes
|
|
322
|
+
? (row.notes + "\n---\n" + notes)
|
|
323
|
+
: (notes || row.notes || "");
|
|
324
|
+
|
|
325
|
+
await query(
|
|
326
|
+
"UPDATE tax_exempt_certificates SET status = 'approved', " +
|
|
327
|
+
" reviewed_at = ?1, reviewed_by = ?2, notes = ?3, updated_at = ?1 " +
|
|
328
|
+
"WHERE id = ?4 AND status = 'pending-review'",
|
|
329
|
+
[ts, reviewedBy, mergedNotes, certId],
|
|
330
|
+
);
|
|
331
|
+
return await _getRow(certId);
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
reject: async function (certId, input) {
|
|
335
|
+
_uuid(certId, "cert_id");
|
|
336
|
+
if (!input || typeof input !== "object") {
|
|
337
|
+
throw new TypeError("taxExempt.reject: input object required");
|
|
338
|
+
}
|
|
339
|
+
var rejectionReason = _shortString(input.rejection_reason, "rejection_reason", REJECTION_MAX);
|
|
340
|
+
if (!rejectionReason) {
|
|
341
|
+
throw new TypeError("taxExempt.reject: rejection_reason is required");
|
|
342
|
+
}
|
|
343
|
+
var reviewedBy = _shortString(input.reviewed_by, "reviewed_by", REVIEWER_MAX);
|
|
344
|
+
if (!reviewedBy) {
|
|
345
|
+
throw new TypeError("taxExempt.reject: reviewed_by is required");
|
|
346
|
+
}
|
|
347
|
+
var notes = _notes(input.notes);
|
|
348
|
+
|
|
349
|
+
var row = await _getRow(certId);
|
|
350
|
+
if (!row) return null;
|
|
351
|
+
if (row.status !== "pending-review") {
|
|
352
|
+
var e = new Error("taxExempt.reject: certificate is " + row.status + ", only 'pending-review' may be rejected");
|
|
353
|
+
e.code = "TAXEXEMPT_INVALID_TRANSITION";
|
|
354
|
+
throw e;
|
|
355
|
+
}
|
|
356
|
+
var ts = _now();
|
|
357
|
+
var mergedNotes = row.notes && notes
|
|
358
|
+
? (row.notes + "\n---\n" + notes)
|
|
359
|
+
: (notes || row.notes || "");
|
|
360
|
+
|
|
361
|
+
await query(
|
|
362
|
+
"UPDATE tax_exempt_certificates SET status = 'rejected', " +
|
|
363
|
+
" reviewed_at = ?1, reviewed_by = ?2, rejection_reason = ?3, notes = ?4, updated_at = ?1 " +
|
|
364
|
+
"WHERE id = ?5 AND status = 'pending-review'",
|
|
365
|
+
[ts, reviewedBy, rejectionReason, mergedNotes, certId],
|
|
366
|
+
);
|
|
367
|
+
return await _getRow(certId);
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
revoke: async function (certId, input) {
|
|
371
|
+
_uuid(certId, "cert_id");
|
|
372
|
+
if (!input || typeof input !== "object") {
|
|
373
|
+
throw new TypeError("taxExempt.revoke: input object required");
|
|
374
|
+
}
|
|
375
|
+
var reviewedBy = _shortString(input.reviewed_by, "reviewed_by", REVIEWER_MAX);
|
|
376
|
+
if (!reviewedBy) {
|
|
377
|
+
throw new TypeError("taxExempt.revoke: reviewed_by is required");
|
|
378
|
+
}
|
|
379
|
+
var reason = _shortString(input.reason, "reason", REJECTION_MAX);
|
|
380
|
+
if (!reason) {
|
|
381
|
+
throw new TypeError("taxExempt.revoke: reason is required");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
var row = await _getRow(certId);
|
|
385
|
+
if (!row) return null;
|
|
386
|
+
if (row.status !== "approved") {
|
|
387
|
+
var e = new Error("taxExempt.revoke: certificate is " + row.status + ", only 'approved' may be revoked");
|
|
388
|
+
e.code = "TAXEXEMPT_INVALID_TRANSITION";
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
391
|
+
var ts = _now();
|
|
392
|
+
// Revocation reason lands in `rejection_reason` (the row's
|
|
393
|
+
// catch-all "why is this no longer honored" field) plus a notes
|
|
394
|
+
// line tagged 'revoked:' so the operator can distinguish review
|
|
395
|
+
// rejection from post-approval revocation in the audit trail.
|
|
396
|
+
var revokeNote = "revoked: " + reason;
|
|
397
|
+
var mergedNotes = row.notes
|
|
398
|
+
? (row.notes + "\n---\n" + revokeNote)
|
|
399
|
+
: revokeNote;
|
|
400
|
+
|
|
401
|
+
await query(
|
|
402
|
+
"UPDATE tax_exempt_certificates SET status = 'revoked', " +
|
|
403
|
+
" reviewed_at = ?1, reviewed_by = ?2, rejection_reason = ?3, notes = ?4, updated_at = ?1 " +
|
|
404
|
+
"WHERE id = ?5 AND status = 'approved'",
|
|
405
|
+
[ts, reviewedBy, reason, mergedNotes, certId],
|
|
406
|
+
);
|
|
407
|
+
return await _getRow(certId);
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
expireScan: async function (ts) {
|
|
411
|
+
var now = ts == null ? _now() : _epochMs(ts, "ts");
|
|
412
|
+
var r = await query(
|
|
413
|
+
"UPDATE tax_exempt_certificates SET status = 'expired', updated_at = ?1 " +
|
|
414
|
+
"WHERE status = 'approved' AND expires_at IS NOT NULL AND expires_at <= ?1",
|
|
415
|
+
[now],
|
|
416
|
+
);
|
|
417
|
+
return r.rowCount;
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
get: async function (certId) {
|
|
421
|
+
_uuid(certId, "cert_id");
|
|
422
|
+
return await _getRow(certId);
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
activeForCustomer: async function (customerId, opts2) {
|
|
426
|
+
_uuid(customerId, "customer_id");
|
|
427
|
+
opts2 = opts2 || {};
|
|
428
|
+
var now = _now();
|
|
429
|
+
if (opts2.jurisdiction != null) {
|
|
430
|
+
var j = _jurisdiction(opts2.jurisdiction);
|
|
431
|
+
// Match exact jurisdiction OR the region-only ancestor
|
|
432
|
+
// (e.g. query 'US-CA' returns rows with jurisdiction 'US-CA'
|
|
433
|
+
// or 'US'). The query splits by the dash; a hyphen-free
|
|
434
|
+
// jurisdiction has no region ancestor to widen against.
|
|
435
|
+
var dash = j.indexOf("-");
|
|
436
|
+
var region = dash > 0 ? j.slice(0, dash) : null;
|
|
437
|
+
if (region) {
|
|
438
|
+
var r = await query(
|
|
439
|
+
"SELECT * FROM tax_exempt_certificates " +
|
|
440
|
+
"WHERE customer_id = ?1 AND status = 'approved' " +
|
|
441
|
+
" AND (expires_at IS NULL OR expires_at > ?2) " +
|
|
442
|
+
" AND (jurisdiction = ?3 OR jurisdiction = ?4) " +
|
|
443
|
+
"ORDER BY issued_at DESC",
|
|
444
|
+
[customerId, now, j, region],
|
|
445
|
+
);
|
|
446
|
+
return r.rows;
|
|
447
|
+
}
|
|
448
|
+
var r2 = await query(_activeRowQuery(true), [customerId, now, j]);
|
|
449
|
+
return r2.rows;
|
|
450
|
+
}
|
|
451
|
+
var rAll = await query(_activeRowQuery(false), [customerId, now]);
|
|
452
|
+
return rAll.rows;
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
listPendingReview: async function (opts2) {
|
|
456
|
+
opts2 = opts2 || {};
|
|
457
|
+
var limit = 100;
|
|
458
|
+
if (opts2.limit != null) {
|
|
459
|
+
if (!Number.isInteger(opts2.limit) || opts2.limit <= 0 || opts2.limit > 1000) {
|
|
460
|
+
throw new TypeError("taxExempt.listPendingReview: limit must be an integer 1..1000");
|
|
461
|
+
}
|
|
462
|
+
limit = opts2.limit;
|
|
463
|
+
}
|
|
464
|
+
var r = await query(
|
|
465
|
+
"SELECT * FROM tax_exempt_certificates WHERE status = 'pending-review' " +
|
|
466
|
+
"ORDER BY created_at ASC LIMIT ?1",
|
|
467
|
+
[limit],
|
|
468
|
+
);
|
|
469
|
+
return r.rows;
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
isExempt: async function (input) {
|
|
473
|
+
if (!input || typeof input !== "object") {
|
|
474
|
+
throw new TypeError("taxExempt.isExempt: input object required");
|
|
475
|
+
}
|
|
476
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
477
|
+
var jurisdiction = _jurisdiction(input.jurisdiction);
|
|
478
|
+
var now = _now();
|
|
479
|
+
|
|
480
|
+
// Two-row read: exact match OR region-only ancestor. The
|
|
481
|
+
// SQL filter trims the result set; final coverage check uses
|
|
482
|
+
// `_coversJurisdiction` so the rule lives in one place.
|
|
483
|
+
var dash = jurisdiction.indexOf("-");
|
|
484
|
+
var region = dash > 0 ? jurisdiction.slice(0, dash) : null;
|
|
485
|
+
var r;
|
|
486
|
+
if (region) {
|
|
487
|
+
r = await query(
|
|
488
|
+
"SELECT jurisdiction FROM tax_exempt_certificates " +
|
|
489
|
+
"WHERE customer_id = ?1 AND status = 'approved' " +
|
|
490
|
+
" AND (expires_at IS NULL OR expires_at > ?2) " +
|
|
491
|
+
" AND (jurisdiction = ?3 OR jurisdiction = ?4) " +
|
|
492
|
+
"LIMIT 1",
|
|
493
|
+
[customerId, now, jurisdiction, region],
|
|
494
|
+
);
|
|
495
|
+
} else {
|
|
496
|
+
r = await query(
|
|
497
|
+
"SELECT jurisdiction FROM tax_exempt_certificates " +
|
|
498
|
+
"WHERE customer_id = ?1 AND status = 'approved' " +
|
|
499
|
+
" AND (expires_at IS NULL OR expires_at > ?2) " +
|
|
500
|
+
" AND jurisdiction = ?3 " +
|
|
501
|
+
"LIMIT 1",
|
|
502
|
+
[customerId, now, jurisdiction],
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (!r.rows.length) return false;
|
|
506
|
+
// Belt-and-braces: re-check the coverage rule in JS so any
|
|
507
|
+
// future SQL refactor that widens the WHERE accidentally is
|
|
508
|
+
// still gated by the canonical rule.
|
|
509
|
+
return _coversJurisdiction(r.rows[0].jurisdiction, jurisdiction);
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
module.exports = {
|
|
515
|
+
create: create,
|
|
516
|
+
CERT_NUMBER_NAMESPACE: CERT_NUMBER_NAMESPACE,
|
|
517
|
+
CERT_TYPES: CERT_TYPES,
|
|
518
|
+
STATUSES: STATUSES,
|
|
519
|
+
};
|