@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.
- package/CHANGELOG.md +4 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|