@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.
@@ -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
+ };