@blamejs/blamejs-shop 0.0.59 → 0.0.61

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,559 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.taxRates
4
+ * @title Tax rates primitive — operator-managed per-jurisdiction rate
5
+ * table with scheduled effective dates
6
+ *
7
+ * @intro
8
+ * Distinct from the `tax` primitive (math + adapter surface +
9
+ * reverse-charge logic). This module is the SOURCE OF TRUTH for
10
+ * "what rate applies in jurisdiction X on date Y for category Z" —
11
+ * the rate table the operator's tax-update process writes into when
12
+ * a statutory change is published. A `rateFor` lookup returns the
13
+ * active rate (or null if none exists for that jurisdiction); the
14
+ * caller composes that with `tax.calculateExclusive` /
15
+ * `calculateInclusive` to actually compute the tax amount.
16
+ *
17
+ * Rate selection at read time:
18
+ *
19
+ * 1. Most recent live row where
20
+ * effective_from <= on_date
21
+ * AND (effective_until IS NULL OR effective_until > on_date)
22
+ * AND archived_at IS NULL
23
+ * AND category = <requested category>
24
+ * 2. If no category-specific row matches, repeat with category IS NULL
25
+ * (the jurisdiction's fallback rate).
26
+ * 3. If still no row, return null. The caller decides whether to
27
+ * fall back to a zero rate or refuse the sale.
28
+ *
29
+ * Overlap rule: two rows for the same (jurisdiction, category)
30
+ * whose [effective_from, effective_until) intervals intersect are
31
+ * refused at `defineRate` time. Operators superseding a future-dated
32
+ * row archive the existing row first (or supply a tighter
33
+ * `effective_until` on the existing row before defining the
34
+ * replacement).
35
+ *
36
+ * Composes:
37
+ * - `b.guardUuid` — UUID-shape validation for ids
38
+ * - `b.uuid.v7` — row ids
39
+ *
40
+ * Surface:
41
+ * defineRate({ jurisdiction, category?, rate_bps, effective_from,
42
+ * effective_until?, source })
43
+ * rateFor({ jurisdiction, category?, on_date })
44
+ * listForJurisdiction({ jurisdiction, include_expired? })
45
+ * updateRate(rate_id, patch)
46
+ * archiveRate(rate_id, { reason? })
47
+ * bulkImport(rows)
48
+ * scheduledChanges({ from, to })
49
+ *
50
+ * Storage:
51
+ * - `tax_rates` (migration `0058_tax_rates.sql`).
52
+ *
53
+ * @primitive taxRates
54
+ * @related b.guardUuid, b.uuid, shop.tax
55
+ */
56
+
57
+ var JURISDICTION_RE = /^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/;
58
+ var MAX_CATEGORY_LEN = 64;
59
+ var CATEGORY_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
60
+ var MAX_BPS = 10000;
61
+ var MAX_REASON_LEN = 280;
62
+
63
+ var SOURCES = ["manual", "vies", "state_dept", "avalara"];
64
+
65
+ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
66
+ "rate_bps", "effective_until", "source",
67
+ ]);
68
+
69
+ // Lazy framework handle — matches the pattern used by every other
70
+ // shop primitive; avoids the require cycle that would arise from
71
+ // importing `./index` at module-eval time.
72
+ var bShop;
73
+ function _b() {
74
+ if (!bShop) bShop = require("./index");
75
+ return bShop.framework;
76
+ }
77
+
78
+ // ---- validators ---------------------------------------------------------
79
+
80
+ function _uuid(s, label) {
81
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
82
+ catch (e) { throw new TypeError("taxRates: " + label + " — " + (e && e.message || "invalid UUID")); }
83
+ }
84
+
85
+ function _jurisdiction(s) {
86
+ if (typeof s !== "string" || !JURISDICTION_RE.test(s)) {
87
+ throw new TypeError(
88
+ "taxRates: jurisdiction must match /^[A-Z]{2}(-[A-Z0-9]{1,3})?$/ " +
89
+ "(ISO 3166-1 alpha-2 + optional ISO 3166-2 subdivision), got " +
90
+ JSON.stringify(s)
91
+ );
92
+ }
93
+ return s;
94
+ }
95
+
96
+ function _category(c) {
97
+ if (c == null) return null;
98
+ if (typeof c !== "string") {
99
+ throw new TypeError("taxRates: category must be a string or null");
100
+ }
101
+ if (!c.length) return null;
102
+ if (c.length > MAX_CATEGORY_LEN) {
103
+ throw new TypeError("taxRates: category must be <= " + MAX_CATEGORY_LEN + " characters");
104
+ }
105
+ if (!CATEGORY_RE.test(c)) {
106
+ throw new TypeError(
107
+ "taxRates: category must match /^[a-z0-9][a-z0-9_-]{0,63}$/ — " +
108
+ "lowercase alphanumerics with `_`/`-`, must not start with separator"
109
+ );
110
+ }
111
+ return c;
112
+ }
113
+
114
+ function _rateBps(n) {
115
+ if (!Number.isInteger(n) || n < 0 || n > MAX_BPS) {
116
+ throw new TypeError(
117
+ "taxRates: rate_bps must be an integer 0.." + MAX_BPS + " (1 bp = 0.01%), got " +
118
+ JSON.stringify(n)
119
+ );
120
+ }
121
+ return n;
122
+ }
123
+
124
+ function _timestamp(n, label) {
125
+ if (!Number.isInteger(n) || n < 0) {
126
+ throw new TypeError(
127
+ "taxRates: " + label + " must be a non-negative integer (ms epoch), got " +
128
+ JSON.stringify(n)
129
+ );
130
+ }
131
+ return n;
132
+ }
133
+
134
+ function _effectiveUntil(n, effFrom) {
135
+ if (n == null) return null;
136
+ _timestamp(n, "effective_until");
137
+ if (n <= effFrom) {
138
+ throw new TypeError(
139
+ "taxRates: effective_until must be strictly greater than effective_from"
140
+ );
141
+ }
142
+ return n;
143
+ }
144
+
145
+ function _source(s) {
146
+ if (typeof s !== "string" || SOURCES.indexOf(s) === -1) {
147
+ throw new TypeError("taxRates: source must be one of " + SOURCES.join(", "));
148
+ }
149
+ return s;
150
+ }
151
+
152
+ function _reason(r) {
153
+ if (r == null) return null;
154
+ if (typeof r !== "string") {
155
+ throw new TypeError("taxRates: reason must be a string or null");
156
+ }
157
+ if (!r.length) return null;
158
+ if (r.length > MAX_REASON_LEN) {
159
+ throw new TypeError("taxRates: reason must be <= " + MAX_REASON_LEN + " characters");
160
+ }
161
+ return r;
162
+ }
163
+
164
+ function _now() { return Date.now(); }
165
+
166
+ // ---- factory ------------------------------------------------------------
167
+
168
+ function create(opts) {
169
+ opts = opts || {};
170
+ var query = opts.query;
171
+ if (!query) {
172
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
173
+ }
174
+
175
+ async function _getRaw(id) {
176
+ var r = await query("SELECT * FROM tax_rates WHERE id = ?1", [id]);
177
+ return r.rows[0] || null;
178
+ }
179
+
180
+ // Overlap check for (jurisdiction, category, effective_from,
181
+ // effective_until). Two live (non-archived) rows for the same
182
+ // (jurisdiction, category) intersect when:
183
+ //
184
+ // existing.effective_from < new.effective_until
185
+ // AND (existing.effective_until IS NULL OR existing.effective_until > new.effective_from)
186
+ //
187
+ // `new.effective_until == null` is treated as +infinity. The
188
+ // `ignoreId` parameter skips a row by id so `updateRate` can
189
+ // re-check overlap against the row it's mutating.
190
+ async function _findOverlap(j, c, effFrom, effUntil, ignoreId) {
191
+ var newUntil = effUntil == null ? Number.MAX_SAFE_INTEGER : effUntil;
192
+ var where = ["jurisdiction = ?1", "archived_at IS NULL"];
193
+ var params = [j];
194
+ var idx = 2;
195
+ if (c == null) {
196
+ where.push("category IS NULL");
197
+ } else {
198
+ where.push("category = ?" + idx);
199
+ params.push(c);
200
+ idx += 1;
201
+ }
202
+ where.push("effective_from < ?" + idx);
203
+ params.push(newUntil);
204
+ idx += 1;
205
+ where.push("(effective_until IS NULL OR effective_until > ?" + idx + ")");
206
+ params.push(effFrom);
207
+ idx += 1;
208
+ if (ignoreId) {
209
+ where.push("id != ?" + idx);
210
+ params.push(ignoreId);
211
+ idx += 1;
212
+ }
213
+ var sql = "SELECT id, effective_from, effective_until FROM tax_rates WHERE " +
214
+ where.join(" AND ") + " LIMIT 1";
215
+ var r = await query(sql, params);
216
+ return r.rows[0] || null;
217
+ }
218
+
219
+ async function _insertOne(row) {
220
+ await query(
221
+ "INSERT INTO tax_rates " +
222
+ "(id, jurisdiction, category, rate_bps, effective_from, effective_until, " +
223
+ " source, archived_at, created_at, updated_at) " +
224
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
225
+ [
226
+ row.id, row.jurisdiction, row.category, row.rate_bps,
227
+ row.effective_from, row.effective_until, row.source, row.created_at,
228
+ ],
229
+ );
230
+ }
231
+
232
+ return {
233
+ JURISDICTION_RE: JURISDICTION_RE,
234
+ SOURCES: SOURCES.slice(),
235
+ MAX_BPS: MAX_BPS,
236
+ MAX_CATEGORY_LEN: MAX_CATEGORY_LEN,
237
+
238
+ // Write one rate. Refuses if the new row would overlap an
239
+ // existing live (non-archived) row for the same (jurisdiction,
240
+ // category). The caller archives or shortens the existing row
241
+ // first when superseding.
242
+ defineRate: async function (input) {
243
+ if (!input || typeof input !== "object") {
244
+ throw new TypeError("taxRates.defineRate: input object required");
245
+ }
246
+ var jurisdiction = _jurisdiction(input.jurisdiction);
247
+ var category = _category(input.category);
248
+ var rateBps = _rateBps(input.rate_bps);
249
+ var effFrom = _timestamp(input.effective_from, "effective_from");
250
+ var effUntil = _effectiveUntil(input.effective_until, effFrom);
251
+ var source = _source(input.source);
252
+
253
+ var overlap = await _findOverlap(jurisdiction, category, effFrom, effUntil, null);
254
+ if (overlap) {
255
+ var err = new Error(
256
+ "taxRates.defineRate: refused — overlaps existing rate " +
257
+ overlap.id + " (effective_from=" + overlap.effective_from +
258
+ ", effective_until=" + (overlap.effective_until == null ? "null" : overlap.effective_until) +
259
+ "). Archive or shorten the existing row before defining the replacement."
260
+ );
261
+ err.code = "TAX_RATE_OVERLAP";
262
+ err.overlap_id = overlap.id;
263
+ throw err;
264
+ }
265
+
266
+ var id = _b().uuid.v7();
267
+ var ts = _now();
268
+ await _insertOne({
269
+ id: id,
270
+ jurisdiction: jurisdiction,
271
+ category: category,
272
+ rate_bps: rateBps,
273
+ effective_from: effFrom,
274
+ effective_until: effUntil,
275
+ source: source,
276
+ created_at: ts,
277
+ });
278
+ return await _getRaw(id);
279
+ },
280
+
281
+ // Resolve the active rate for a (jurisdiction, category, on_date)
282
+ // tuple. Returns the row (or null when no live row covers the
283
+ // requested moment). Category-specific rows take precedence over
284
+ // the jurisdiction's NULL-category fallback.
285
+ rateFor: async function (input) {
286
+ if (!input || typeof input !== "object") {
287
+ throw new TypeError("taxRates.rateFor: input object required");
288
+ }
289
+ var jurisdiction = _jurisdiction(input.jurisdiction);
290
+ var category = _category(input.category);
291
+ var onDate = _timestamp(input.on_date, "on_date");
292
+
293
+ // Phase 1: category-specific lookup (only when category is
294
+ // supplied). The ORDER BY effective_from DESC + LIMIT 1 picks
295
+ // the most recently-effective live row that covers on_date.
296
+ if (category != null) {
297
+ var rSpec = await query(
298
+ "SELECT * FROM tax_rates " +
299
+ "WHERE jurisdiction = ?1 AND category = ?2 " +
300
+ " AND archived_at IS NULL " +
301
+ " AND effective_from <= ?3 " +
302
+ " AND (effective_until IS NULL OR effective_until > ?3) " +
303
+ "ORDER BY effective_from DESC LIMIT 1",
304
+ [jurisdiction, category, onDate],
305
+ );
306
+ if (rSpec.rows.length) return rSpec.rows[0];
307
+ }
308
+
309
+ // Phase 2: NULL-category fallback for the jurisdiction.
310
+ var rFall = await query(
311
+ "SELECT * FROM tax_rates " +
312
+ "WHERE jurisdiction = ?1 AND category IS NULL " +
313
+ " AND archived_at IS NULL " +
314
+ " AND effective_from <= ?2 " +
315
+ " AND (effective_until IS NULL OR effective_until > ?2) " +
316
+ "ORDER BY effective_from DESC LIMIT 1",
317
+ [jurisdiction, onDate],
318
+ );
319
+ return rFall.rows[0] || null;
320
+ },
321
+
322
+ // List every rate ever written for a jurisdiction (default skips
323
+ // archived + expired rows; `include_expired: true` returns the
324
+ // full history). Ordered most-recent first by effective_from for
325
+ // operator dashboards.
326
+ listForJurisdiction: async function (input) {
327
+ if (!input || typeof input !== "object") {
328
+ throw new TypeError("taxRates.listForJurisdiction: input object required");
329
+ }
330
+ var jurisdiction = _jurisdiction(input.jurisdiction);
331
+ var includeExpired = input.include_expired === true;
332
+ var sql, params;
333
+ if (includeExpired) {
334
+ sql = "SELECT * FROM tax_rates WHERE jurisdiction = ?1 " +
335
+ "ORDER BY effective_from DESC, id DESC";
336
+ params = [jurisdiction];
337
+ } else {
338
+ var now = _now();
339
+ sql = "SELECT * FROM tax_rates WHERE jurisdiction = ?1 " +
340
+ " AND archived_at IS NULL " +
341
+ " AND (effective_until IS NULL OR effective_until > ?2) " +
342
+ "ORDER BY effective_from DESC, id DESC";
343
+ params = [jurisdiction, now];
344
+ }
345
+ var r = await query(sql, params);
346
+ return r.rows;
347
+ },
348
+
349
+ // Patch-style update — only ALLOWED_UPDATE_COLUMNS can be set.
350
+ // jurisdiction + category + effective_from are immutable post-
351
+ // creation (changing them would re-aim a historical attribution
352
+ // row at a different cell and break the audit trail). To "move" a
353
+ // row to a different jurisdiction or window, archive the existing
354
+ // row and define a replacement.
355
+ //
356
+ // Mutating `effective_until` re-checks the overlap rule against
357
+ // the row's effective_from + the new effective_until (with this
358
+ // row excluded so it doesn't overlap itself).
359
+ updateRate: async function (rateId, patch) {
360
+ var id = _uuid(rateId, "rate_id");
361
+ if (!patch || typeof patch !== "object") {
362
+ throw new TypeError("taxRates.updateRate: patch object required");
363
+ }
364
+ var keys = Object.keys(patch);
365
+ if (!keys.length) {
366
+ throw new TypeError("taxRates.updateRate: patch must contain at least one column");
367
+ }
368
+ for (var i = 0; i < keys.length; i += 1) {
369
+ if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
370
+ throw new TypeError("taxRates.updateRate: column '" + keys[i] + "' not updatable");
371
+ }
372
+ }
373
+ var current = await _getRaw(id);
374
+ if (!current) return null;
375
+ if (current.archived_at != null) {
376
+ var refused = new Error("taxRates.updateRate: refused — rate is archived");
377
+ refused.code = "TAX_RATE_ARCHIVED";
378
+ throw refused;
379
+ }
380
+
381
+ var sets = [];
382
+ var params = [];
383
+ var idx = 1;
384
+ function _set(col, val) {
385
+ sets.push(col + " = ?" + idx);
386
+ params.push(val);
387
+ idx += 1;
388
+ }
389
+ if (patch.rate_bps != null) _set("rate_bps", _rateBps(patch.rate_bps));
390
+ if (Object.prototype.hasOwnProperty.call(patch, "effective_until")) {
391
+ var newUntil = patch.effective_until == null
392
+ ? null
393
+ : _effectiveUntil(patch.effective_until, Number(current.effective_from));
394
+ // Re-check overlap with the row excluded from the search.
395
+ var overlap = await _findOverlap(
396
+ current.jurisdiction,
397
+ current.category,
398
+ Number(current.effective_from),
399
+ newUntil,
400
+ id
401
+ );
402
+ if (overlap) {
403
+ var err = new Error(
404
+ "taxRates.updateRate: refused — new effective_until overlaps existing rate " +
405
+ overlap.id
406
+ );
407
+ err.code = "TAX_RATE_OVERLAP";
408
+ err.overlap_id = overlap.id;
409
+ throw err;
410
+ }
411
+ _set("effective_until", newUntil);
412
+ }
413
+ if (patch.source != null) _set("source", _source(patch.source));
414
+
415
+ var ts = _now();
416
+ _set("updated_at", ts);
417
+ params.push(id);
418
+ var sql = "UPDATE tax_rates SET " + sets.join(", ") + " WHERE id = ?" + idx;
419
+ await query(sql, params);
420
+ return await _getRaw(id);
421
+ },
422
+
423
+ // Soft-delete — stamp archived_at so the row stops participating
424
+ // in `rateFor` lookups and in overlap checks for future
425
+ // `defineRate` calls. The row stays on disk for audit.
426
+ archiveRate: async function (rateId, archiveOpts) {
427
+ var id = _uuid(rateId, "rate_id");
428
+ archiveOpts = archiveOpts || {};
429
+ // _reason validates shape; the value isn't persisted on the
430
+ // tax_rates row itself (no column for it) — the operator's
431
+ // audit log captures the reason out-of-band. Validating here
432
+ // refuses oversized / non-string input at the door instead of
433
+ // silently dropping it.
434
+ _reason(archiveOpts.reason);
435
+ var current = await _getRaw(id);
436
+ if (!current) return null;
437
+ if (current.archived_at != null) return current;
438
+ var ts = _now();
439
+ await query(
440
+ "UPDATE tax_rates SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
441
+ [ts, id],
442
+ );
443
+ return await _getRaw(id);
444
+ },
445
+
446
+ // Operator-bulk write — typically called by an import script
447
+ // hydrating a paid-feed snapshot. Each row goes through the same
448
+ // validation + overlap check as `defineRate`; the operation is
449
+ // all-or-nothing per row (a row that fails validation throws
450
+ // immediately and the caller decides whether to retry the
451
+ // remaining rows). Returns the array of inserted row ids in input
452
+ // order. Overlap detection runs against ALL prior rows, including
453
+ // earlier rows in the same `bulkImport` call — two same-window
454
+ // rows in the same payload are refused at the second row.
455
+ bulkImport: async function (rows) {
456
+ if (!Array.isArray(rows)) {
457
+ throw new TypeError("taxRates.bulkImport: rows must be an array");
458
+ }
459
+ var ids = [];
460
+ for (var i = 0; i < rows.length; i += 1) {
461
+ var input = rows[i];
462
+ if (!input || typeof input !== "object") {
463
+ throw new TypeError("taxRates.bulkImport: rows[" + i + "] must be an object");
464
+ }
465
+ var jurisdiction = _jurisdiction(input.jurisdiction);
466
+ var category = _category(input.category);
467
+ var rateBps = _rateBps(input.rate_bps);
468
+ var effFrom = _timestamp(input.effective_from, "rows[" + i + "].effective_from");
469
+ var effUntil = _effectiveUntil(input.effective_until, effFrom);
470
+ var source = _source(input.source);
471
+
472
+ var overlap = await _findOverlap(jurisdiction, category, effFrom, effUntil, null);
473
+ if (overlap) {
474
+ var err = new Error(
475
+ "taxRates.bulkImport: rows[" + i + "] refused — overlaps existing rate " +
476
+ overlap.id
477
+ );
478
+ err.code = "TAX_RATE_OVERLAP";
479
+ err.overlap_id = overlap.id;
480
+ err.row_index = i;
481
+ throw err;
482
+ }
483
+
484
+ var id = _b().uuid.v7();
485
+ var ts = _now();
486
+ await _insertOne({
487
+ id: id,
488
+ jurisdiction: jurisdiction,
489
+ category: category,
490
+ rate_bps: rateBps,
491
+ effective_from: effFrom,
492
+ effective_until: effUntil,
493
+ source: source,
494
+ created_at: ts,
495
+ });
496
+ ids.push(id);
497
+ }
498
+ return ids;
499
+ },
500
+
501
+ // Return every live (non-archived) rate whose `effective_from`
502
+ // OR `effective_until` falls inside [from, to]. Operator
503
+ // scheduler hint: "what rate changes hit the till in this
504
+ // window?". Each row is annotated with a `change_kind` of
505
+ // `starts` (effective_from in window) or `ends`
506
+ // (effective_until in window); a row whose both endpoints land
507
+ // in the window appears twice (once per kind) so an operator's
508
+ // calendar shows both bell-ringings.
509
+ scheduledChanges: async function (input) {
510
+ if (!input || typeof input !== "object") {
511
+ throw new TypeError("taxRates.scheduledChanges: input object required");
512
+ }
513
+ var from = _timestamp(input.from, "from");
514
+ var to = _timestamp(input.to, "to");
515
+ if (from > to) {
516
+ throw new TypeError("taxRates.scheduledChanges: from must be <= to");
517
+ }
518
+ var r = await query(
519
+ "SELECT * FROM tax_rates " +
520
+ "WHERE archived_at IS NULL " +
521
+ " AND ( (effective_from >= ?1 AND effective_from <= ?2) " +
522
+ " OR (effective_until IS NOT NULL AND effective_until >= ?1 AND effective_until <= ?2) ) " +
523
+ "ORDER BY effective_from ASC, id ASC",
524
+ [from, to],
525
+ );
526
+ var out = [];
527
+ for (var i = 0; i < r.rows.length; i += 1) {
528
+ var row = r.rows[i];
529
+ var startsIn = Number(row.effective_from) >= from && Number(row.effective_from) <= to;
530
+ var endsIn = row.effective_until != null &&
531
+ Number(row.effective_until) >= from &&
532
+ Number(row.effective_until) <= to;
533
+ if (startsIn) {
534
+ out.push(Object.assign({ change_kind: "starts", change_at: Number(row.effective_from) }, row));
535
+ }
536
+ if (endsIn) {
537
+ out.push(Object.assign({ change_kind: "ends", change_at: Number(row.effective_until) }, row));
538
+ }
539
+ }
540
+ // Stable secondary sort by change_at so the operator calendar
541
+ // reads chronologically across both kinds.
542
+ out.sort(function (a, b) {
543
+ if (a.change_at !== b.change_at) return a.change_at - b.change_at;
544
+ if (a.id < b.id) return -1;
545
+ if (a.id > b.id) return 1;
546
+ return a.change_kind < b.change_kind ? -1 : (a.change_kind > b.change_kind ? 1 : 0);
547
+ });
548
+ return out;
549
+ },
550
+ };
551
+ }
552
+
553
+ module.exports = {
554
+ create: create,
555
+ JURISDICTION_RE: JURISDICTION_RE,
556
+ SOURCES: SOURCES.slice(),
557
+ MAX_BPS: MAX_BPS,
558
+ MAX_CATEGORY_LEN: MAX_CATEGORY_LEN,
559
+ };