@blamejs/blamejs-shop 0.0.64 → 0.0.66

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,525 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.currencyRounding
4
+ * @title Per-currency display rounding rules applied at checkout total
5
+ *
6
+ * @intro
7
+ * Switzerland retired the one- and two-rappen coins; CHF retail
8
+ * totals round to the nearest 0.05 ("Rappenrundung"). Sweden
9
+ * retired the öre coins; SEK totals round to the nearest 0.10. JPY
10
+ * has no minor unit at all (ISO 4217 exponent 0) — every total is
11
+ * already a whole yen, but operators selling JPY cross-border
12
+ * sometimes want a 100-yen rounding step for psychological pricing.
13
+ *
14
+ * This primitive owns the *rounding* leg of multi-currency
15
+ * storefronts. Distinct from `currencyDisplay` (migration 0044),
16
+ * which caches FX rates and converts amounts between ISO 4217
17
+ * codes. FX answers "how much EUR is one USD"; rounding answers
18
+ * "what increment do I snap a final total to before I show it to
19
+ * the customer." A real Swiss checkout typically needs both — the
20
+ * storefront converts the catalog's USD price to CHF, then rounds
21
+ * the CHF total to the nearest 0.05 before display.
22
+ *
23
+ * Rules are keyed by ISO 4217 currency code. Each rule names:
24
+ *
25
+ * - `increment_minor` — the smallest unit step in the currency's
26
+ * OWN minor units. CHF nearest-0.05 → 5 (5 rappen). SEK
27
+ * nearest-0.10 → 10. JPY 100-yen step → 100. The identity rule
28
+ * (no rounding) is `increment_minor: 1`.
29
+ *
30
+ * - `mode` — one of `half_up` / `half_even` / `half_down` /
31
+ * `ceiling` / `floor`. Most retail laws prescribe `half_up`
32
+ * (CHF Rappenrundung is half-up to the nearest 5 rappen).
33
+ * `half_even` is the "banker's rounding" default that matches
34
+ * the framework's `b.money` conversion path; available for
35
+ * operators who want both stages on the same convention.
36
+ *
37
+ * - `applies_to` — one of `display_only` / `cart_total` /
38
+ * `line_total` / `all`. The `display_only` mode leaves the
39
+ * persisted order totals unrounded (so downstream FX / refund
40
+ * / tax math stays exact), and only the rendered figure carries
41
+ * the rounding. The other modes commit the rounding into the
42
+ * cart total, the per-line totals, or every monetary figure
43
+ * the rendering layer pulls.
44
+ *
45
+ * Surface:
46
+ * - `defineRule({ currency, increment_minor, mode, applies_to })`
47
+ * - `getRule(currency)` / `listRules({ active_only? })`
48
+ * - `roundFor({ amount_minor, currency, kind })` →
49
+ * `{ original_minor, rounded_minor, adjustment_minor }`
50
+ * The `kind` argument names the call-site context
51
+ * (`display_only` / `cart_total` / `line_total` / `all`); the
52
+ * rule applies only when its `applies_to` matches the requested
53
+ * `kind` or is `all`. Mis-matched calls return the original
54
+ * amount untouched (adjustment 0) — operators can wire the
55
+ * function in at every render site without re-checking the
56
+ * active rule's scope.
57
+ * - `updateRule(currency, patch)` / `archiveRule(currency)`
58
+ * - `historyForCurrency({ currency, from?, to?, limit? })`
59
+ *
60
+ * Composition:
61
+ * var rounding = bShop.currencyRounding.create({ query: q });
62
+ * await rounding.defineRule({
63
+ * currency: "CHF",
64
+ * increment_minor: 5,
65
+ * mode: "half_up",
66
+ * applies_to: "cart_total",
67
+ * });
68
+ * var out = rounding.roundFor({
69
+ * amount_minor: 1287, // CHF 12.87
70
+ * currency: "CHF",
71
+ * kind: "cart_total",
72
+ * });
73
+ * // { original_minor: 1287, rounded_minor: 1285, adjustment_minor: -2 }
74
+ *
75
+ * Storage:
76
+ * - currency_rounding_rules + currency_rounding_log
77
+ * (migration 0132).
78
+ *
79
+ * @primitive currencyRounding
80
+ * @related shop.currencyDisplay, shop.pricing, b.money
81
+ */
82
+
83
+ var bShop;
84
+ function _b() {
85
+ if (!bShop) bShop = require("./index");
86
+ return bShop.framework;
87
+ }
88
+
89
+ // ---- constants ----------------------------------------------------------
90
+
91
+ var MODES = ["half_up", "half_even", "half_down", "ceiling", "floor"];
92
+ var APPLIES_TO = ["display_only", "cart_total", "line_total", "all"];
93
+ var MAX_LIST_LIMIT = 500;
94
+
95
+ // ---- validators ---------------------------------------------------------
96
+
97
+ // Compose b.money's ISO 4217 catalog so the validator stays in sync
98
+ // with the framework's currency surface. A code the catalog refuses
99
+ // here would never round-trip through `b.money.fromMinorUnits`
100
+ // downstream either; throwing at the rule-define step surfaces the
101
+ // typo at config-time rather than at the first checkout that touches
102
+ // the bad code.
103
+ function _currency(code) {
104
+ if (typeof code !== "string" || !/^[A-Z]{3}$/.test(code)) {
105
+ throw new TypeError(
106
+ "currencyRounding: currency must be a 3-letter uppercase ISO 4217 code, got " +
107
+ JSON.stringify(code)
108
+ );
109
+ }
110
+ var catalog = _b().money.CURRENCIES;
111
+ if (!Object.prototype.hasOwnProperty.call(catalog, code)) {
112
+ throw new TypeError(
113
+ "currencyRounding: currency " + JSON.stringify(code) +
114
+ " is not in the ISO 4217 catalog"
115
+ );
116
+ }
117
+ return code;
118
+ }
119
+
120
+ function _incrementMinor(n) {
121
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
122
+ throw new TypeError(
123
+ "currencyRounding: increment_minor must be a positive integer, got " +
124
+ JSON.stringify(n)
125
+ );
126
+ }
127
+ return n;
128
+ }
129
+
130
+ function _mode(m) {
131
+ if (typeof m !== "string" || MODES.indexOf(m) === -1) {
132
+ throw new TypeError(
133
+ "currencyRounding: mode must be one of " + MODES.join(", ") + ", got " +
134
+ JSON.stringify(m)
135
+ );
136
+ }
137
+ return m;
138
+ }
139
+
140
+ function _appliesTo(a) {
141
+ if (typeof a !== "string" || APPLIES_TO.indexOf(a) === -1) {
142
+ throw new TypeError(
143
+ "currencyRounding: applies_to must be one of " + APPLIES_TO.join(", ") +
144
+ ", got " + JSON.stringify(a)
145
+ );
146
+ }
147
+ return a;
148
+ }
149
+
150
+ function _amountMinor(n, label) {
151
+ // amount_minor accepts zero and positive integers. A negative
152
+ // amount (refund preview, adjustment) is uncommon at the rounding
153
+ // step but not refused — operators occasionally preview rounding
154
+ // for a credit memo before it lands.
155
+ if (typeof n !== "number" || !Number.isInteger(n)) {
156
+ throw new TypeError(
157
+ "currencyRounding: " + label + " must be an integer (minor units), got " +
158
+ JSON.stringify(n)
159
+ );
160
+ }
161
+ return n;
162
+ }
163
+
164
+ function _epochMs(ts, label) {
165
+ if (ts == null) return null;
166
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
167
+ throw new TypeError(
168
+ "currencyRounding: " + label + " must be a non-negative integer epoch-ms, got " +
169
+ JSON.stringify(ts)
170
+ );
171
+ }
172
+ return ts;
173
+ }
174
+
175
+ function _limit(n, label) {
176
+ if (n == null) return 100;
177
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_LIST_LIMIT) {
178
+ throw new TypeError(
179
+ "currencyRounding: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]"
180
+ );
181
+ }
182
+ return n;
183
+ }
184
+
185
+ // ---- rounding math ------------------------------------------------------
186
+
187
+ // Round `amount` (an integer in minor units, possibly negative) to
188
+ // the nearest multiple of `step` (positive integer) under the
189
+ // requested mode. Pure integer math — no float intermediates, no
190
+ // precision loss for any amount the storage layer can carry.
191
+ function _round(amount, step, mode) {
192
+ if (step === 1) return amount;
193
+ // Decompose into (quotient, remainder) with remainder always
194
+ // non-negative in [0, step). JavaScript `%` mirrors the sign of
195
+ // the dividend, which produces an off-by-one for negatives at the
196
+ // tie boundary — normalise explicitly.
197
+ var quot = Math.trunc(amount / step);
198
+ var rem = amount - quot * step;
199
+ if (rem < 0) { quot -= 1; rem += step; }
200
+ // `lower` is the largest multiple of step <= amount.
201
+ var lower = quot * step;
202
+ var upper = lower + step;
203
+ if (rem === 0) return amount;
204
+
205
+ if (mode === "floor") return lower;
206
+ if (mode === "ceiling") return upper;
207
+
208
+ // Compare to the half-step. Use integer doubling to avoid a
209
+ // fractional comparison: rem*2 vs step.
210
+ var doubled = rem * 2;
211
+ if (doubled < step) {
212
+ // Below the half — every half-mode collapses to round-toward-lower.
213
+ return lower;
214
+ }
215
+ if (doubled > step) {
216
+ // Above the half — every half-mode collapses to round-toward-upper.
217
+ return upper;
218
+ }
219
+ // Exactly on the half-step.
220
+ if (mode === "half_up") return amount >= 0 ? upper : lower;
221
+ if (mode === "half_down") return amount >= 0 ? lower : upper;
222
+ // half_even — pick whichever multiple is even.
223
+ var lowerMultiple = quot; // lower / step
224
+ if (lowerMultiple % 2 === 0) return lower;
225
+ return upper;
226
+ }
227
+
228
+ // ---- factory ------------------------------------------------------------
229
+
230
+ function create(opts) {
231
+ opts = opts || {};
232
+ var query = opts.query;
233
+ if (!query) {
234
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
235
+ }
236
+ if (typeof query !== "function") {
237
+ throw new TypeError("currencyRounding.create: query must be a function");
238
+ }
239
+
240
+ // Last-issued timestamp seen on a rule write, per currency. The
241
+ // primitive guarantees a strictly-monotonic per-currency
242
+ // updated_at sequence so a tied-millisecond update doesn't lose
243
+ // ordering against the prior write. Same shape as the gift-card
244
+ // ledger + store-credit ledger _resolveOccurredAt clamp.
245
+ var lastTs = Object.create(null);
246
+
247
+ function _now() { return Date.now(); }
248
+
249
+ function _clamp(currency, requested) {
250
+ var prior = lastTs[currency];
251
+ var t = requested;
252
+ if (prior != null && t <= prior) t = prior + 1;
253
+ lastTs[currency] = t;
254
+ return t;
255
+ }
256
+
257
+ async function _readByCurrency(currency) {
258
+ var r = await query(
259
+ "SELECT currency, increment_minor, mode, applies_to, active, " +
260
+ "archived_at, created_at, updated_at " +
261
+ "FROM currency_rounding_rules WHERE currency = ?1 LIMIT 1",
262
+ [currency]
263
+ );
264
+ return r.rows[0] || null;
265
+ }
266
+
267
+ function _shapeRow(row) {
268
+ if (!row) return null;
269
+ return {
270
+ currency: row.currency,
271
+ increment_minor: Number(row.increment_minor),
272
+ mode: row.mode,
273
+ applies_to: row.applies_to,
274
+ active: Number(row.active) === 1,
275
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
276
+ created_at: Number(row.created_at),
277
+ updated_at: Number(row.updated_at),
278
+ };
279
+ }
280
+
281
+ async function defineRule(input) {
282
+ if (!input || typeof input !== "object") {
283
+ throw new TypeError("currencyRounding.defineRule: input object required");
284
+ }
285
+ var currency = _currency(input.currency);
286
+ var increment = _incrementMinor(input.increment_minor);
287
+ var mode = _mode(input.mode);
288
+ var appliesTo = _appliesTo(input.applies_to);
289
+
290
+ var existing = await _readByCurrency(currency);
291
+ if (existing && Number(existing.active) === 1) {
292
+ throw new TypeError(
293
+ "currencyRounding.defineRule: a rule for " + currency +
294
+ " is already active; update or archive it first"
295
+ );
296
+ }
297
+ var ts = _clamp(currency, _now());
298
+ if (existing) {
299
+ // A previously-archived rule for this currency exists. Refresh
300
+ // it in place — the primary key is the currency code, so re-
301
+ // defining is the operator's way of saying "re-activate with
302
+ // these new parameters."
303
+ await query(
304
+ "UPDATE currency_rounding_rules SET increment_minor = ?1, mode = ?2, " +
305
+ "applies_to = ?3, active = 1, archived_at = NULL, updated_at = ?4 " +
306
+ "WHERE currency = ?5",
307
+ [increment, mode, appliesTo, ts, currency]
308
+ );
309
+ } else {
310
+ await query(
311
+ "INSERT INTO currency_rounding_rules " +
312
+ "(currency, increment_minor, mode, applies_to, active, archived_at, created_at, updated_at) " +
313
+ "VALUES (?1, ?2, ?3, ?4, 1, NULL, ?5, ?6)",
314
+ [currency, increment, mode, appliesTo, ts, ts]
315
+ );
316
+ }
317
+ return _shapeRow(await _readByCurrency(currency));
318
+ }
319
+
320
+ async function getRule(currency) {
321
+ var c = _currency(currency);
322
+ return _shapeRow(await _readByCurrency(c));
323
+ }
324
+
325
+ async function listRules(input) {
326
+ input = input || {};
327
+ var activeOnly = input.active_only == null ? false : input.active_only;
328
+ if (typeof activeOnly !== "boolean") {
329
+ throw new TypeError("currencyRounding.listRules: active_only must be a boolean");
330
+ }
331
+ var limit = _limit(input.limit, "limit");
332
+ var sql = "SELECT currency, increment_minor, mode, applies_to, active, " +
333
+ "archived_at, created_at, updated_at FROM currency_rounding_rules";
334
+ var params = [];
335
+ if (activeOnly) {
336
+ sql += " WHERE active = 1";
337
+ }
338
+ sql += " ORDER BY currency ASC LIMIT ?" + (params.length + 1);
339
+ params.push(limit);
340
+ var r = await query(sql, params);
341
+ var out = [];
342
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_shapeRow(r.rows[i]));
343
+ return out;
344
+ }
345
+
346
+ async function updateRule(currency, patch) {
347
+ var c = _currency(currency);
348
+ if (!patch || typeof patch !== "object") {
349
+ throw new TypeError("currencyRounding.updateRule: patch object required");
350
+ }
351
+ var existing = await _readByCurrency(c);
352
+ if (!existing) {
353
+ throw new TypeError(
354
+ "currencyRounding.updateRule: no rule exists for " + c
355
+ );
356
+ }
357
+ if (Number(existing.active) !== 1) {
358
+ throw new TypeError(
359
+ "currencyRounding.updateRule: rule for " + c +
360
+ " is archived; re-define it instead"
361
+ );
362
+ }
363
+ var fields = [];
364
+ var params = [];
365
+ var idx = 1;
366
+ if (Object.prototype.hasOwnProperty.call(patch, "increment_minor")) {
367
+ fields.push("increment_minor = ?" + idx); idx += 1;
368
+ params.push(_incrementMinor(patch.increment_minor));
369
+ }
370
+ if (Object.prototype.hasOwnProperty.call(patch, "mode")) {
371
+ fields.push("mode = ?" + idx); idx += 1;
372
+ params.push(_mode(patch.mode));
373
+ }
374
+ if (Object.prototype.hasOwnProperty.call(patch, "applies_to")) {
375
+ fields.push("applies_to = ?" + idx); idx += 1;
376
+ params.push(_appliesTo(patch.applies_to));
377
+ }
378
+ if (fields.length === 0) {
379
+ throw new TypeError(
380
+ "currencyRounding.updateRule: patch must set at least one of " +
381
+ "increment_minor / mode / applies_to"
382
+ );
383
+ }
384
+ var ts = _clamp(c, _now());
385
+ fields.push("updated_at = ?" + idx); idx += 1;
386
+ params.push(ts);
387
+ params.push(c);
388
+ await query(
389
+ "UPDATE currency_rounding_rules SET " + fields.join(", ") +
390
+ " WHERE currency = ?" + idx,
391
+ params
392
+ );
393
+ return _shapeRow(await _readByCurrency(c));
394
+ }
395
+
396
+ async function archiveRule(currency) {
397
+ var c = _currency(currency);
398
+ var existing = await _readByCurrency(c);
399
+ if (!existing) {
400
+ throw new TypeError(
401
+ "currencyRounding.archiveRule: no rule exists for " + c
402
+ );
403
+ }
404
+ if (Number(existing.active) !== 1) {
405
+ // Idempotent: an already-archived rule re-archives as a no-op.
406
+ return _shapeRow(existing);
407
+ }
408
+ var ts = _clamp(c, _now());
409
+ await query(
410
+ "UPDATE currency_rounding_rules SET active = 0, archived_at = ?1, " +
411
+ "updated_at = ?2 WHERE currency = ?3",
412
+ [ts, ts, c]
413
+ );
414
+ return _shapeRow(await _readByCurrency(c));
415
+ }
416
+
417
+ async function roundFor(input) {
418
+ if (!input || typeof input !== "object") {
419
+ throw new TypeError("currencyRounding.roundFor: input object required");
420
+ }
421
+ var amount = _amountMinor(input.amount_minor, "amount_minor");
422
+ var currency = _currency(input.currency);
423
+ var kind = _appliesTo(input.kind);
424
+
425
+ var row = await _readByCurrency(currency);
426
+ if (!row || Number(row.active) !== 1) {
427
+ // No active rule — pass through with adjustment zero. The
428
+ // call-site can wire `roundFor` in at every render point
429
+ // without re-checking the rule registry first.
430
+ return {
431
+ original_minor: amount,
432
+ rounded_minor: amount,
433
+ adjustment_minor: 0,
434
+ };
435
+ }
436
+ var appliesTo = row.applies_to;
437
+ if (appliesTo !== "all" && appliesTo !== kind) {
438
+ // Active rule does not cover this call site — pass through.
439
+ return {
440
+ original_minor: amount,
441
+ rounded_minor: amount,
442
+ adjustment_minor: 0,
443
+ };
444
+ }
445
+ var step = Number(row.increment_minor);
446
+ var mode = row.mode;
447
+ var rounded = _round(amount, step, mode);
448
+ var adjust = rounded - amount;
449
+
450
+ // Audit-log the applied rounding. Drop-silent on a write failure
451
+ // would lose the operator's reconciliation trail; let the error
452
+ // bubble — the caller decides whether checkout still proceeds.
453
+ var id = _b().uuid.v7();
454
+ var ts = _clamp(currency, _now());
455
+ await query(
456
+ "INSERT INTO currency_rounding_log " +
457
+ "(id, currency, original_minor, rounded_minor, adjustment_minor, kind, occurred_at) " +
458
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
459
+ [id, currency, amount, rounded, adjust, kind, ts]
460
+ );
461
+ return {
462
+ original_minor: amount,
463
+ rounded_minor: rounded,
464
+ adjustment_minor: adjust,
465
+ };
466
+ }
467
+
468
+ async function historyForCurrency(input) {
469
+ if (!input || typeof input !== "object") {
470
+ throw new TypeError("currencyRounding.historyForCurrency: input object required");
471
+ }
472
+ var currency = _currency(input.currency);
473
+ var from = _epochMs(input.from, "from");
474
+ var to = _epochMs(input.to, "to");
475
+ var limit = _limit(input.limit, "limit");
476
+
477
+ var sql = "SELECT id, currency, original_minor, rounded_minor, adjustment_minor, " +
478
+ "kind, occurred_at FROM currency_rounding_log WHERE currency = ?1";
479
+ var params = [currency];
480
+ if (from != null) {
481
+ sql += " AND occurred_at >= ?" + (params.length + 1);
482
+ params.push(from);
483
+ }
484
+ if (to != null) {
485
+ sql += " AND occurred_at <= ?" + (params.length + 1);
486
+ params.push(to);
487
+ }
488
+ sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + (params.length + 1);
489
+ params.push(limit);
490
+ var r = await query(sql, params);
491
+ var rows = [];
492
+ for (var i = 0; i < r.rows.length; i += 1) {
493
+ var row = r.rows[i];
494
+ rows.push({
495
+ id: row.id,
496
+ currency: row.currency,
497
+ original_minor: Number(row.original_minor),
498
+ rounded_minor: Number(row.rounded_minor),
499
+ adjustment_minor: Number(row.adjustment_minor),
500
+ kind: row.kind,
501
+ occurred_at: Number(row.occurred_at),
502
+ });
503
+ }
504
+ return rows;
505
+ }
506
+
507
+ return {
508
+ MODES: MODES.slice(),
509
+ APPLIES_TO: APPLIES_TO.slice(),
510
+ defineRule: defineRule,
511
+ getRule: getRule,
512
+ listRules: listRules,
513
+ roundFor: roundFor,
514
+ updateRule: updateRule,
515
+ archiveRule: archiveRule,
516
+ historyForCurrency: historyForCurrency,
517
+ };
518
+ }
519
+
520
+ module.exports = {
521
+ create: create,
522
+ MODES: MODES,
523
+ APPLIES_TO: APPLIES_TO,
524
+ round: _round, // pure rounding math exposed for direct composition
525
+ };