@blamejs/blamejs-shop 0.3.44 → 0.3.45

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.45 (2026-06-01) — **Tax, VAT, bulk price changes, and customer emails now compute money in exact integer arithmetic.** Several money calculations were performed with floating-point intermediates. On a large order subtotal the sales-tax math could lose a minor unit, and the customer-facing wishlist emails formatted prices by dividing by 100 — which is wrong for zero-decimal currencies like JPY (¥1,050 rendered as 10.5) and three-decimal currencies like BHD, and dropped trailing zeros ($10.00 shown as 10). All of these now run through the framework's exact BigInt money primitive: sales-tax and VAT/GST extraction, the per-rate owed figure on a tax filing, the admin bulk price-adjust, the wishlist price-drop and digest emails, and subscription monthly-revenue normalization. Prices in customer emails are now rendered with the correct currency exponent and symbol for every currency. **Changed:** *Bulk price-adjust rounds to even on exact half-unit ties* — The admin bulk price-adjust now rounds a price that lands exactly halfway between two minor units to the nearest even unit, matching the rounding used everywhere else in the money surface, instead of always rounding the half up. This only changes the result on an exact tie and by at most one minor unit (for example a price computed as 200.5 now becomes 200 rather than 201). If a pricing regime requires rounding halves up, that is a one-line option change. **Fixed:** *Exact sales-tax and VAT computation* — Sales tax, VAT-inclusive extraction, and VAT-exclusive addition are computed in exact integer arithmetic rather than with a floating-point intermediate, so the tax on a large order total is no longer off by a minor unit. The per-rate owed figure on a tax filing is likewise computed exactly, which matters when a filing window aggregates a very large taxable total. · *Currency-correct prices in wishlist emails* — Price-drop alert emails and the wishlist digest now format prices with each currency's own decimal places and symbol. Zero-decimal currencies (such as JPY) and three-decimal currencies (such as BHD) render correctly, and whole amounts keep their trailing zeros — for example $10.00 instead of 10. · *Exact subscription monthly-revenue normalization* — Normalizing a weekly or annual plan to a monthly figure for revenue reporting now uses exact rational arithmetic instead of a floating-point cadence factor, so the dashboard total is stable and does not drift by a cent between refreshes.
12
+
11
13
  - v0.3.44 (2026-05-31) — **Customers can rate a delivered order, and the admin console moderates and replies.** There was no way for a customer to give feedback on how an order was fulfilled, and no way for an operator to act on it. Customers can now rate one of their own delivered orders on shipping, packaging, and how likely they are to recommend you, with an optional comment. The admin console gets a Ratings screen with a moderation queue of every flagged comment plus the most recent ratings, where an operator can flag a comment or post one public reply that the customer then sees on their order page. A customer can only rate an order they own, and every comment and reply is HTML-escaped wherever it is shown, so feedback text can't inject markup into another shopper's or an operator's page. **Added:** *Order ratings* — A customer viewing one of their own orders can rate it on three axes — shipping, packaging, and likelihood to recommend (1 to 5) — and optionally leave a comment. Each order can be rated once. The rating, and any public reply from the store, appear on the customer's order page. A customer can only rate an order tied to their own account; the rating is always recorded against the signed-in customer, never a value taken from the request. · *Ratings moderation in the admin console* — A new Ratings screen lists recent ratings with their scores and comments, alongside a moderation queue that surfaces every flagged comment regardless of its score, so a flagged comment on an otherwise high rating is never hidden. An operator can flag a comment for moderation and post a single public reply to a rating, which is then shown to the customer. Comments and replies are HTML-escaped everywhere they are rendered.
12
14
 
13
15
  - v0.3.43 (2026-05-31) — **Export orders for a date range as CSV or NDJSON, with scheduled exports.** There was no way to pull a bulk export of orders over a date range. A new Exports screen in the admin console previews how many orders and how much revenue a date range covers, then downloads them as a CSV or NDJSON file. The export can also be scheduled and managed as a queued job. Customer email addresses are exported only as a hash, never in the clear, and any field that could be interpreted as a spreadsheet formula is neutralized so an exported file can't run code when opened. This release also updates the vendored blamejs runtime to v0.14.16. **Added:** *Order export* — An Exports screen in the admin console exports orders over a chosen date range. It first shows a preview — the order count, revenue, and average order value for the range — then downloads the orders as a CSV or NDJSON file with a standard set of columns. Exports can also be queued as scheduled jobs and cancelled before they run. Customer email is exported as a hash rather than in the clear, and any cell that begins with a character a spreadsheet would treat as a formula is escaped, so an exported file can't execute anything when opened in Excel or Sheets. **Changed:** *Vendored blamejs runtime updated to v0.14.16* — The bundled blamejs runtime is updated to v0.14.16, which validates connection ports at configuration time — a malformed port (a non-integer or out-of-range value) is now rejected with a clear error at startup instead of silently falling back to a default. No operator action is required.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.44",
2
+ "version": "0.3.45",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -16,8 +16,8 @@
16
16
  * bulkAdjustPrice — apply a percent delta (e.g. -1500 bps for a
17
17
  * 15% markdown) to every matched variant's
18
18
  * current price in the given currency. The new
19
- * amount is rounded half-away-from-zero in
20
- * minor units; the floor is 0 (no negative
19
+ * amount is rounded half-even in minor units
20
+ * via b.money; the floor is 0 (no negative
21
21
  * prices). Variants without a current price in
22
22
  * the target currency are skipped.
23
23
  * bulkArchive — set products.status='archived' on every
@@ -489,8 +489,9 @@ function create(opts) {
489
489
  // Apply a percent_bps delta to every matched variant's CURRENT
490
490
  // price in the given currency. Variants without a current price
491
491
  // in the target currency are skipped (they don't have a "before"
492
- // amount to multiply against). The new amount is rounded with
493
- // Math.round and floored at 0.
492
+ // amount to multiply against). The new amount = current ×
493
+ // (1 + percent_bps/10000), rounded half-even in minor units via
494
+ // b.money, and floored at 0.
494
495
  bulkAdjustPrice: async function (input) {
495
496
  if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkAdjustPrice: input object required");
496
497
  var filter = input.filter;
@@ -501,11 +502,14 @@ function create(opts) {
501
502
  var variants = await _variantsForProducts(productIds);
502
503
  var affected = 0;
503
504
  var skipped = 0;
504
- var multiplier = (10000 + input.percent_bps) / 10000;
505
505
  for (var i = 0; i < variants.length; i += 1) {
506
506
  var current = await catalog.prices.current(variants[i].id, input.currency);
507
507
  if (!current) { skipped += 1; continue; }
508
- var next = Math.round(current.amount_minor * multiplier);
508
+ var next = Number(
509
+ b.money.fromMinorUnits(BigInt(current.amount_minor), input.currency)
510
+ .multiply([BigInt(10000 + input.percent_bps), 10000n], { rounding: "half-even" })
511
+ .toMinorUnits()
512
+ );
509
513
  if (next < 0) next = 0;
510
514
  await catalog.prices.set(variants[i].id, {
511
515
  currency: input.currency,
@@ -559,10 +559,9 @@ function create(opts) {
559
559
  }
560
560
 
561
561
  // Owed: re-derive from per-rate taxable totals using rate_bps.
562
- // Integer math: taxable * bps / 10000, banker's-rounded so
563
- // half-cent ties don't drift the sum. (Same rounding rule as
564
- // b.tax banker's rounding is the conservative choice when the
565
- // primitive may aggregate millions of orders.)
562
+ // owed = taxable × bps / 10000 per rate, half-even via b.money
563
+ // (BigInt taxable_minor sums every order in the window and can
564
+ // exceed 2^53, so the multiply must not pass through a float).
566
565
  var owed = 0;
567
566
  var rateKeys = Object.keys(breakdown);
568
567
  for (var rk = 0; rk < rateKeys.length; rk += 1) {
@@ -571,14 +570,11 @@ function create(opts) {
571
570
  var bps = parseInt(key, 10);
572
571
  if (!Number.isFinite(bps)) continue;
573
572
  var bucket = breakdown[key];
574
- var rawOwed = bucket.taxable_minor * bps / 10000;
575
- // Banker's round
576
- var floored = Math.floor(rawOwed);
577
- var frac = rawOwed - floored;
578
- var rounded;
579
- if (frac > 0.5) rounded = floored + 1;
580
- else if (frac < 0.5) rounded = floored;
581
- else rounded = (floored % 2 === 0) ? floored : floored + 1;
573
+ var rounded = Number(
574
+ b.money.fromMinorUnits(BigInt(bucket.taxable_minor), "USD")
575
+ .multiply([BigInt(bps), 10000n], { rounding: "half-even" })
576
+ .toMinorUnits()
577
+ );
582
578
  owed += rounded;
583
579
  }
584
580
  // When no rate rows resolved (taxRatesApi absent / no rates
@@ -185,6 +185,18 @@ var CADENCE_TO_MONTHLY = {
185
185
  year: 1 / 12,
186
186
  };
187
187
 
188
+ // Exact rational [numerator, denominator] forms of the cadence factors
189
+ // above, used by _monthlyMinor so the monthly-normalized minor-unit
190
+ // figure is computed in BigInt (half-even) with no float intermediate.
191
+ // The float CADENCE_TO_MONTHLY view above is retained only for the
192
+ // display-only dashboard export.
193
+ var CADENCE_TO_MONTHLY_RATIO = {
194
+ day: [30n, 1n],
195
+ week: [30n, 7n],
196
+ month: [1n, 1n],
197
+ year: [1n, 12n],
198
+ };
199
+
188
200
  // Active = (status in active/trialing) AND not cancelled AND not paused.
189
201
  var ACTIVE_STATUSES = ["active", "trialing"];
190
202
 
@@ -325,27 +337,28 @@ function _canonicalJson(value) {
325
337
 
326
338
  // ---- cadence normalization ---------------------------------------------
327
339
  //
328
- // Plan amount × quantity normalized to a single monthly figure. Uses
329
- // banker's rounding (round-half-to-even) on the final minor-unit total
330
- // so two interval combinations that produce e.g. 4499.5 minor units
331
- // don't oscillate the dashboard total by one cent between refreshes.
332
-
333
- function _bankersRound(x) {
334
- var floor = Math.floor(x);
335
- var diff = x - floor;
336
- if (diff < 0.5) return floor;
337
- if (diff > 0.5) return floor + 1;
338
- // diff === 0.5 — round to even
339
- return floor % 2 === 0 ? floor : floor + 1;
340
- }
340
+ // Plan amount × quantity normalized to a single monthly figure. The
341
+ // cadence factor (e.g. week → 30/7, year → 1/12) is applied as an
342
+ // exact rational in BigInt half-even via b.money, so the monthly
343
+ // minor-unit total carries no float rounding and two interval
344
+ // combinations producing e.g. 4499.5 minor units settle on a stable
345
+ // even value rather than oscillating the dashboard by a cent.
341
346
 
342
347
  function _monthlyMinor(amountMinor, interval, intervalCount, quantity) {
343
- var factor = CADENCE_TO_MONTHLY[interval];
344
- if (factor == null) return 0;
348
+ var ratio = CADENCE_TO_MONTHLY_RATIO[interval];
349
+ if (ratio == null) return 0;
350
+ if (!Number.isInteger(amountMinor)) return 0;
345
351
  var qty = Number.isInteger(quantity) && quantity > 0 ? quantity : 1;
346
352
  var ic = Number.isInteger(intervalCount) && intervalCount > 0 ? intervalCount : 1;
347
- var raw = (amountMinor * qty * factor) / ic;
348
- return _bankersRound(raw);
353
+ // monthly = amount × qty × (num/den) / ic
354
+ // = amount × qty × num / (den × ic)
355
+ var num = BigInt(amountMinor) * BigInt(qty) * ratio[0];
356
+ var den = ratio[1] * BigInt(ic);
357
+ return Number(
358
+ b.money.fromMinorUnits(num >= 0n ? num : 0n, "USD")
359
+ .multiply([1n, den], { rounding: "half-even" })
360
+ .toMinorUnits()
361
+ );
349
362
  }
350
363
 
351
364
  // ---- active-at predicate (in SQL) --------------------------------------
package/lib/tax.js CHANGED
@@ -37,6 +37,8 @@
37
37
  * additive `lines[].tax_class` field.
38
38
  */
39
39
 
40
+ var b = require("./vendor/blamejs");
41
+
40
42
  var COUNTRY_RE = /^[A-Z]{2}$/;
41
43
  var STATE_RE = /^[A-Z0-9]{1,5}$/;
42
44
  var POSTAL_RE = /^[A-Za-z0-9 -]{1,16}$/;
@@ -72,6 +74,19 @@ function _nonNegInt(n, label) {
72
74
  }
73
75
  }
74
76
 
77
+ // Multiply a non-negative minor-unit integer by the rational num/den
78
+ // using the framework's BigInt money primitive (half-even). The result
79
+ // is an integer minor-unit count in the SAME unit as `minor` — the
80
+ // divisor is currency-exponent-independent, so a fixed carrier currency
81
+ // is correct (see lib/quantity-discounts.js for the same technique).
82
+ function _mulRational(minor, num, den) {
83
+ return Number(
84
+ b.money.fromMinorUnits(BigInt(minor), "USD")
85
+ .multiply([BigInt(num), BigInt(den)], { rounding: "half-even" })
86
+ .toMinorUnits()
87
+ );
88
+ }
89
+
75
90
  function _validateRule(rule, i) {
76
91
  if (!rule || typeof rule !== "object") throw new TypeError("tax: rule[" + i + "] must be an object");
77
92
  _country(rule.country, "rule[" + i + "].country");
@@ -121,17 +136,9 @@ function operatorTable(opts) {
121
136
  if (_matches(rules[i], ctx.shipTo)) { matched = rules[i]; break; }
122
137
  }
123
138
  var rateBps = matched ? matched.rate_bps : 0;
124
- // tax = subtotal × bps / 10000, rounded to nearest minor unit.
125
- // Round-half-to-even (banker's) avoids systematic upward bias
126
- // across many small carts; Math.round in JS is round-half-up,
127
- // so we implement banker's by hand.
128
- var raw = ctx.subtotal_minor * rateBps / 10000;
129
- var floor = Math.floor(raw);
130
- var frac = raw - floor;
131
- var tax;
132
- if (frac < 0.5) tax = floor;
133
- else if (frac > 0.5) tax = floor + 1;
134
- else tax = (floor % 2 === 0) ? floor : floor + 1; // even
139
+ // tax = subtotal × bps / 10000, half-even via b.money (BigInt
140
+ // no float intermediate, exact for large subtotals).
141
+ var tax = _mulRational(ctx.subtotal_minor, rateBps, 10000);
135
142
  return {
136
143
  tax_minor: tax,
137
144
  rate_bps: rateBps,
@@ -171,18 +178,6 @@ function create(opts) {
171
178
  // primitives compose against. The operator-table adapter still owns
172
179
  // jurisdiction-rule selection.
173
180
 
174
- // Banker's rounding (round-half-to-even) for non-negative `raw`.
175
- // JS Math.round is round-half-up, which biases tax totals upward
176
- // across many small carts; this matches the rounding mode the
177
- // existing operatorTable adapter uses.
178
- function _bankersRound(raw) {
179
- var floor = Math.floor(raw);
180
- var frac = raw - floor;
181
- if (frac < 0.5) return floor;
182
- if (frac > 0.5) return floor + 1;
183
- return (floor % 2 === 0) ? floor : floor + 1;
184
- }
185
-
186
181
  function _currency(c) {
187
182
  if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
188
183
  throw new TypeError("tax: currency must be a 3-letter ISO 4217 code (uppercase), got " + JSON.stringify(c));
@@ -210,7 +205,7 @@ function calculateInclusive(args) {
210
205
  _currency(args.currency);
211
206
  var gross = args.amount_minor;
212
207
  var rate = args.rate_bps;
213
- var tax = _bankersRound(gross * rate / (10000 + rate));
208
+ var tax = _mulRational(gross, rate, 10000 + rate);
214
209
  return {
215
210
  net_minor: gross - tax,
216
211
  tax_minor: tax,
@@ -237,7 +232,7 @@ function calculateExclusive(args) {
237
232
  _currency(args.currency);
238
233
  var net = args.amount_minor;
239
234
  var rate = args.rate_bps;
240
- var tax = _bankersRound(net * rate / 10000);
235
+ var tax = _mulRational(net, rate, 10000);
241
236
  return {
242
237
  net_minor: net,
243
238
  tax_minor: tax,
@@ -89,6 +89,7 @@
89
89
  */
90
90
 
91
91
  var b = require("./vendor/blamejs");
92
+ var pricing = require("./pricing");
92
93
 
93
94
  var C = b.constants;
94
95
 
@@ -770,10 +771,10 @@ function create(opts) {
770
771
  var dispatchOk = false;
771
772
  try {
772
773
  var oldPriceStr = v.payload.old_amount_minor != null
773
- ? String(v.payload.old_amount_minor / 100) + " " + v.payload.currency
774
+ ? pricing.format(v.payload.old_amount_minor, v.payload.currency)
774
775
  : "—";
775
776
  var newPriceStr = v.payload.new_amount_minor != null
776
- ? String(v.payload.new_amount_minor / 100) + " " + v.payload.currency
777
+ ? pricing.format(v.payload.new_amount_minor, v.payload.currency)
777
778
  : "—";
778
779
  var pctStr = v.payload.discount_bps != null
779
780
  ? String(Math.floor(v.payload.discount_bps / 100)) + "%"
@@ -111,6 +111,7 @@
111
111
  */
112
112
 
113
113
  var b = require("./vendor/blamejs");
114
+ var pricing = require("./pricing");
114
115
 
115
116
  var C = b.constants;
116
117
 
@@ -781,7 +782,9 @@ function create(opts) {
781
782
  }
782
783
  var priceStr = "—";
783
784
  if (price && typeof price.amount_minor === "number") {
784
- priceStr = (price.amount_minor / 100).toFixed(2) + " " + (price.currency || currency);
785
+ try {
786
+ priceStr = pricing.format(price.amount_minor, price.currency || currency);
787
+ } catch (_efmt) { priceStr = "—"; }
785
788
  }
786
789
 
787
790
  // Stock change — best-effort. The catalog dep doesn't guarantee
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.44",
3
+ "version": "0.3.45",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {