@blamejs/blamejs-shop 0.3.43 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.3.42 (2026-05-31) — **The blog now pages through every post, and the byline shows the shop name.** The blog index showed only the 12 most recent posts with no way to reach the rest, so older posts were a dead end for anyone browsing — even though they stayed in the sitemap. The index now has previous/next pagination, so every published post is reachable. The post byline and the article's structured data now show the shop name instead of an internal author identifier, and a missing blog post or product page no longer returns a body on a HEAD request. **Fixed:** *Blog index pagination* — The blog index listed only the 12 most recent posts and offered no way to reach older ones from /blog. It now has previous/next pagination — the same control used on collection and search pages — so every published post is reachable, with the next link appearing only when there is genuinely another page. · *Blog byline shows the shop name, not an internal id* — The blog post byline and the article's structured data displayed the raw internal author identifier. They now show the shop name, so the public byline and the author Google reads from the structured data are meaningful rather than an internal id. · *Missing pages don't return a body on HEAD* — A missing blog post or product page returned a full page body even for a HEAD request. Those 404 responses now omit the body on HEAD, matching the other pages and the HTTP spec.
package/lib/admin.js CHANGED
@@ -512,6 +512,7 @@ function mount(router, deps) {
512
512
  var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
513
513
  var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
514
514
  var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
515
+ var orderRatings = deps.orderRatings || null; // per-order rating moderation queue (flag/clear + public reply) disabled when absent
515
516
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
516
517
 
517
518
  // Which optional console sections are wired — gates their nav links so a
@@ -520,7 +521,7 @@ function mount(router, deps) {
520
521
  // `reports` is always present in the nav (read-only sales summary needs no
521
522
  // extra dep); its route mounts unconditionally and renders an unconfigured
522
523
  // notice when the salesReports primitive isn't wired.
523
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges, orderExport: !!orderExport };
524
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
524
525
 
525
526
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
526
527
 
@@ -6710,6 +6711,142 @@ function mount(router, deps) {
6710
6711
  ));
6711
6712
  }
6712
6713
 
6714
+ // ---- order ratings (moderation) -------------------------------------
6715
+ //
6716
+ // Surfaces (mounted only when the orderRatings primitive is wired):
6717
+ // GET /admin/ratings[?flagged=1] — screen: the flagged-comment
6718
+ // moderation queue first, then a list of recent ratings with
6719
+ // their scores + (escaped) comment. Bearer JSON returns both lists.
6720
+ // POST /admin/ratings/:id/flag — flag a comment for moderation.
6721
+ // POST /admin/ratings/:id/respond — post the operator's one public reply.
6722
+ //
6723
+ // Coded-error mapping mirrors the exchanges console: the primitive throws
6724
+ // plain Errors carrying `.code` (ORDER_RATING_NOT_FOUND / _ALREADY_FLAGGED
6725
+ // / _ALREADY_RESPONDED / _NO_COMMENT) AND TypeErrors for bad input, so the
6726
+ // `.code` checks MUST run before the generic `instanceof TypeError → 400`,
6727
+ // else a not-found / already-acted error would wrongly return 400.
6728
+ if (orderRatings) {
6729
+ // The operator-facing window for the recent-ratings list + the flagged
6730
+ // queue. A wide span (the primitive's read paths are date-windowed); the
6731
+ // list methods cap their own rows so this never streams unbounded.
6732
+ var RATINGS_WINDOW_MS = b.constants.TIME.days(365);
6733
+ function _ratingsWindow() {
6734
+ var to = Date.now();
6735
+ return { from: to - RATINGS_WINDOW_MS, to: to };
6736
+ }
6737
+
6738
+ // Map a thrown rating error to a problem-details status for the bearer
6739
+ // JSON path: missing row → 404, already-flagged / already-responded /
6740
+ // no-comment-to-flag → 409, bad shape → 400. The coded checks run BEFORE
6741
+ // the `instanceof TypeError` branch (the primitive's _NOT_FOUND etc. are
6742
+ // plain Errors with a .code; a malformed UUID is a TypeError).
6743
+ function _ratingApiError(res, e) {
6744
+ if (!e) return null;
6745
+ if (e.code === "ORDER_RATING_NOT_FOUND") return _problem(res, 404, "rating-not-found");
6746
+ if (e.code === "ORDER_RATING_ALREADY_FLAGGED" ||
6747
+ e.code === "ORDER_RATING_ALREADY_RESPONDED" ||
6748
+ e.code === "ORDER_RATING_NO_COMMENT") {
6749
+ return _problem(res, 409, "conflict", e.message);
6750
+ }
6751
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
6752
+ return null;
6753
+ }
6754
+ // The same classes for the browser path: a coded refusal / bad shape is a
6755
+ // correction notice (?err redirect), never a 500. Anything else propagates.
6756
+ function _ratingClientError(e) {
6757
+ if (!e) return false;
6758
+ if (e instanceof TypeError) return true;
6759
+ return e.code === "ORDER_RATING_NOT_FOUND" ||
6760
+ e.code === "ORDER_RATING_ALREADY_FLAGGED" ||
6761
+ e.code === "ORDER_RATING_ALREADY_RESPONDED" ||
6762
+ e.code === "ORDER_RATING_NO_COMMENT";
6763
+ }
6764
+
6765
+ // Resolve the flagged queue + the recent-ratings list for the screen /
6766
+ // the JSON API. The flagged queue comes from listFlagged (every flagged
6767
+ // comment in the window, most-recent-first, regardless of score) so a
6768
+ // flagged comment on an otherwise high rating is never hidden behind the
6769
+ // recent list's score cap; the recent list is the lowest-scored ratings
6770
+ // in the window (the moderation-priority view). Each read is best-effort
6771
+ // against an unmigrated table (degrades to an empty list, never a 500).
6772
+ async function _ratingsLists() {
6773
+ var win = _ratingsWindow();
6774
+ var recent = [], flagged = [];
6775
+ try { recent = await orderRatings.topNegativeRatings({ from: win.from, to: win.to, limit: 100 }); }
6776
+ catch (_e) { recent = []; }
6777
+ try { flagged = await orderRatings.listFlagged({ from: win.from, to: win.to }); }
6778
+ catch (_e2) { flagged = []; }
6779
+ return { recent: recent, flagged: flagged };
6780
+ }
6781
+
6782
+ router.get("/admin/ratings", _pageOrApi(true,
6783
+ R(async function (req, res) {
6784
+ var lists = await _ratingsLists();
6785
+ _json(res, 200, { flagged: lists.flagged, ratings: lists.recent });
6786
+ }),
6787
+ async function (req, res) {
6788
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6789
+ var onlyFlagged = !!(url && url.searchParams.get("flagged"));
6790
+ var lists = await _ratingsLists();
6791
+ _sendHtml(res, 200, renderAdminRatings({
6792
+ shop_name: deps.shop_name,
6793
+ nav_available: navAvailable,
6794
+ ratings: lists.recent,
6795
+ flagged: lists.flagged,
6796
+ only_flagged: onlyFlagged,
6797
+ moved: url && url.searchParams.get("moved"),
6798
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this rating." : null,
6799
+ }));
6800
+ },
6801
+ ));
6802
+
6803
+ // The browser side of a rating action: run opFn(id, body), then PRG back
6804
+ // to the queue. A bad shape / coded refusal / not-found becomes an ?err
6805
+ // notice, never a 500; anything else propagates.
6806
+ function _ratingAction(jsonHandler, auditEvent, opFn) {
6807
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
6808
+ var id = req.params.id;
6809
+ try { await opFn(id, req.body || {}); }
6810
+ catch (e) {
6811
+ if (_ratingClientError(e)) return _redirect(res, "/admin/ratings?err=1");
6812
+ throw e;
6813
+ }
6814
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
6815
+ _redirect(res, "/admin/ratings?moved=1");
6816
+ });
6817
+ }
6818
+
6819
+ // Flag a comment for moderation. The operator supplies a reason + their
6820
+ // own UUID (the single-bearer admin model has no per-operator session).
6821
+ router.post("/admin/ratings/:id/flag", _ratingAction(
6822
+ W("rating.flag", async function (req, res) {
6823
+ var body = req.body || {};
6824
+ var row;
6825
+ try { row = await orderRatings.flagComment({ rating_id: req.params.id, reason: body.reason, flagged_by: body.flagged_by }); }
6826
+ catch (e) { var mapped = _ratingApiError(res, e); if (mapped !== null) return mapped; throw e; }
6827
+ _json(res, 200, row);
6828
+ return { id: row.id };
6829
+ }),
6830
+ "rating.flag",
6831
+ function (id, body) { return orderRatings.flagComment({ rating_id: id, reason: body.reason, flagged_by: body.flagged_by }); },
6832
+ ));
6833
+
6834
+ // Post the operator's one public reply to a rating. The primitive
6835
+ // refuses a second reply (409 ORDER_RATING_ALREADY_RESPONDED).
6836
+ router.post("/admin/ratings/:id/respond", _ratingAction(
6837
+ W("rating.respond", async function (req, res) {
6838
+ var body = req.body || {};
6839
+ var row;
6840
+ try { row = await orderRatings.responseToCustomer({ rating_id: req.params.id, response: body.response, responded_by: body.responded_by }); }
6841
+ catch (e) { var mapped = _ratingApiError(res, e); if (mapped !== null) return mapped; throw e; }
6842
+ _json(res, 200, row);
6843
+ return { id: row.id };
6844
+ }),
6845
+ "rating.respond",
6846
+ function (id, body) { return orderRatings.responseToCustomer({ rating_id: id, response: body.response, responded_by: body.responded_by }); },
6847
+ ));
6848
+ }
6849
+
6713
6850
  // ---- analytics ------------------------------------------------------
6714
6851
 
6715
6852
  var analytics = deps.analytics || null;
@@ -9564,6 +9701,7 @@ var ADMIN_NAV_ITEMS = [
9564
9701
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
9565
9702
  { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
9566
9703
  { key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
9704
+ { key: "ratings", href: "/admin/ratings", label: "Ratings", requires: "orderRatings" },
9567
9705
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
9568
9706
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
9569
9707
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
@@ -11637,6 +11775,117 @@ function renderAdminExchange(opts) {
11637
11775
  return _renderAdminShell(opts.shop_name, "Exchange " + String(x.id).slice(0, 8), body, "exchanges", opts.nav_available);
11638
11776
  }
11639
11777
 
11778
+ // One rating card for the moderation queue / recent list. Shows the three
11779
+ // scores, the (escaped) comment, the flag state + reason, and the operator
11780
+ // reply — plus the flag / reply action forms.
11781
+ //
11782
+ // ESCAPE-BY-DEFAULT: the customer comment is rendered from the primitive's
11783
+ // pre-escaped `comment_html`, and the operator reply from `response_html`
11784
+ // — both already run through b.template.escapeHtml by the primitive's
11785
+ // render layer. They are spliced as-is, NEVER re-built from the raw
11786
+ // `comment` / `response_text`, so a `<script>`/`onerror` payload a customer
11787
+ // typed is inert on this screen. (`_htmlEscape` is NOT applied to the _html
11788
+ // fields — that would double-escape; it IS applied to every other field.)
11789
+ function _ratingCard(rt) {
11790
+ var shortId = _htmlEscape(String(rt.id).slice(0, 8));
11791
+ var shortOrd = _htmlEscape(String(rt.order_id).slice(0, 8));
11792
+ var scores =
11793
+ "<span class=\"meta\">Shipping " + _htmlEscape(String(rt.shipping_rating)) + "/5</span> · " +
11794
+ "<span class=\"meta\">Packaging " + _htmlEscape(String(rt.packaging_rating)) + "/5</span> · " +
11795
+ "<span class=\"meta\">Recommend " + _htmlEscape(String(rt.recommend_rating)) + "/5</span>";
11796
+ // comment_html / response_html are already escaped by the primitive —
11797
+ // splice them, never rt.comment / rt.response_text.
11798
+ var commentBlock = rt.comment_html
11799
+ ? "<p class=\"review-card__body\">" + rt.comment_html + "</p>"
11800
+ : "<p class=\"meta\">No comment.</p>";
11801
+ var flagState = rt.comment_flagged
11802
+ ? "<span class=\"status-pill cancelled\">Flagged</span>" +
11803
+ (rt.flag_reason ? " <span class=\"meta\">" + _htmlEscape(rt.flag_reason) + "</span>" : "")
11804
+ : "";
11805
+ var replyBlock = rt.response_html
11806
+ ? "<div class=\"panel\"><h4 class=\"subhead\">Operator reply</h4>" +
11807
+ "<p class=\"review-card__body\">" + rt.response_html + "</p></div>"
11808
+ : "";
11809
+
11810
+ // Flag form — only when there is a comment AND it isn't already flagged
11811
+ // (the primitive refuses both no-comment and already-flagged with a 409;
11812
+ // gating the form keeps the console from offering a doomed action).
11813
+ var flagForm = (rt.comment_html && !rt.comment_flagged)
11814
+ ? "<form method=\"post\" action=\"/admin/ratings/" + _htmlEscape(rt.id) + "/flag\" class=\"return-action\">" +
11815
+ "<h4>Flag comment</h4>" +
11816
+ _setupField("Reason", "reason", "", "text", "Why this comment is being flagged (shown in the audit log).", " maxlength=\"500\" required") +
11817
+ _setupField("Operator id (UUID)", "flagged_by", "", "text", "The operator placing the flag.", " required") +
11818
+ "<button class=\"btn btn--danger\" type=\"submit\">Flag</button>" +
11819
+ "</form>"
11820
+ : "";
11821
+ // Reply form — only when no reply exists yet (one reply per rating; the
11822
+ // primitive refuses a second with a 409).
11823
+ var replyForm = !rt.response_html
11824
+ ? "<form method=\"post\" action=\"/admin/ratings/" + _htmlEscape(rt.id) + "/respond\" class=\"return-action\">" +
11825
+ "<h4>Public reply</h4>" +
11826
+ _setupField("Reply", "response", "", "text", "Shown to the customer on their order page.", " maxlength=\"2000\" required") +
11827
+ _setupField("Operator id (UUID)", "responded_by", "", "text", "The operator authoring the reply.", " required") +
11828
+ "<button class=\"btn\" type=\"submit\">Post reply</button>" +
11829
+ "</form>"
11830
+ : "";
11831
+ var actions = (flagForm || replyForm)
11832
+ ? "<div class=\"return-actions\">" + flagForm + replyForm + "</div>"
11833
+ : "";
11834
+
11835
+ return "<div class=\"panel review-card\">" +
11836
+ "<div class=\"review-card__head\">" +
11837
+ "<code class=\"order-id\">" + shortId + "</code> " +
11838
+ "<span class=\"meta\">order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(rt.order_id) + "\">" + shortOrd + "</a></span> " +
11839
+ flagState +
11840
+ "</div>" +
11841
+ "<p>" + scores + "</p>" +
11842
+ commentBlock +
11843
+ replyBlock +
11844
+ actions +
11845
+ "</div>";
11846
+ }
11847
+
11848
+ // Order-ratings moderation console: the flagged-comment queue first, then a
11849
+ // list of recent ratings (lowest-scored first — the ones most likely to
11850
+ // need a reply). Each card carries the flag + public-reply forms.
11851
+ function renderAdminRatings(opts) {
11852
+ opts = opts || {};
11853
+ var recent = opts.ratings || [];
11854
+ var flagged = opts.flagged || [];
11855
+ var onlyFlagged = !!opts.only_flagged;
11856
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Rating updated.</div>" : "";
11857
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
11858
+
11859
+ var chips = "<div class=\"order-filters\">" +
11860
+ "<a class=\"chip" + (!onlyFlagged ? " chip--on" : "") + "\" href=\"/admin/ratings\">all recent</a>" +
11861
+ "<a class=\"chip" + (onlyFlagged ? " chip--on" : "") + "\" href=\"/admin/ratings?flagged=1\">flagged</a>" +
11862
+ "</div>";
11863
+
11864
+ var flaggedCards = flagged.map(_ratingCard).join("");
11865
+ var flaggedSection =
11866
+ "<section><h2>Flagged comments</h2>" +
11867
+ (flagged.length ? flaggedCards : "<p class=\"empty\">No flagged comments.</p>") +
11868
+ "</section>";
11869
+
11870
+ // The recent list excludes the ones already in the flagged queue above so
11871
+ // a flagged card isn't rendered twice on the default (unfiltered) view.
11872
+ var recentList = recent.filter(function (r) { return !r.comment_flagged; });
11873
+ var recentCards = recentList.map(_ratingCard).join("");
11874
+ var recentSection = onlyFlagged ? "" :
11875
+ "<section><h2>Recent ratings</h2>" +
11876
+ (recentList.length ? recentCards : "<p class=\"empty\">No ratings yet.</p>") +
11877
+ "</section>";
11878
+
11879
+ var body =
11880
+ "<section><h2>Ratings</h2>" + moved + notice +
11881
+ "<p class=\"meta\">Per-order shipping / packaging / recommend feedback. Flag a comment to suppress it on the storefront, or post one public reply the customer sees on their order page.</p>" +
11882
+ chips +
11883
+ "</section>" +
11884
+ flaggedSection +
11885
+ recentSection;
11886
+ return _renderAdminShell(opts.shop_name, "Ratings", body, "ratings", opts.nav_available);
11887
+ }
11888
+
11640
11889
  // The review states an operator can filter the moderation queue by.
11641
11890
  var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
11642
11891
 
@@ -15311,6 +15560,7 @@ module.exports = {
15311
15560
  renderAdminReturns: renderAdminReturns,
15312
15561
  renderAdminReturn: renderAdminReturn,
15313
15562
  renderAdminReviews: renderAdminReviews,
15563
+ renderAdminRatings: renderAdminRatings,
15314
15564
  renderAdminQuestions: renderAdminQuestions,
15315
15565
  renderAdminQuestion: renderAdminQuestion,
15316
15566
  renderAdminCollections: renderAdminCollections,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.43",
2
+ "version": "0.3.45",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -455,6 +455,41 @@ function create(opts) {
455
455
  return _topRatings(input, "topNegativeRatings", "negative");
456
456
  }
457
457
 
458
+ // ---- listFlagged -----------------------------------------------------
459
+
460
+ // The moderation queue: every rating whose comment an operator has
461
+ // flagged, most-recent-first, backed by the (comment_flagged,
462
+ // occurred_at) index. Unlike topNegativeRatings (score-ordered, capped
463
+ // at MAX_TOP_LIMIT), this surfaces ALL flagged comments regardless of
464
+ // their score — so a flagged comment on an otherwise high rating is
465
+ // never hidden behind the score cap. The from/to window is optional and
466
+ // applied only when supplied; the row count is capped by `limit`.
467
+ async function listFlagged(input) {
468
+ input = (input && typeof input === "object") ? input : {};
469
+ var limit = _limit(input.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, "limit");
470
+
471
+ var sql = "SELECT * FROM order_ratings WHERE comment_flagged = 1";
472
+ var params = [];
473
+ if (input.from != null || input.to != null) {
474
+ var from = _epoch(input.from, "from");
475
+ var to = _epoch(input.to, "to");
476
+ if (from > to) {
477
+ throw new TypeError("orderRatings.listFlagged: from must be <= to");
478
+ }
479
+ params.push(from);
480
+ sql += " AND occurred_at >= ?" + params.length;
481
+ params.push(to);
482
+ sql += " AND occurred_at <= ?" + params.length;
483
+ }
484
+ params.push(limit);
485
+ sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + params.length;
486
+
487
+ var r = await query(sql, params);
488
+ var out = [];
489
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
490
+ return out;
491
+ }
492
+
458
493
  return {
459
494
  RATING_AXES: RATING_AXES.slice(),
460
495
  MIN_RATING: MIN_RATING,
@@ -471,6 +506,7 @@ function create(opts) {
471
506
  responseToCustomer: responseToCustomer,
472
507
  topPositiveRatings: topPositiveRatings,
473
508
  topNegativeRatings: topNegativeRatings,
509
+ listFlagged: listFlagged,
474
510
  };
475
511
  }
476
512
 
@@ -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
package/lib/storefront.js CHANGED
@@ -6128,6 +6128,7 @@ var ORDER_PAGE =
6128
6128
  " </table>\n" +
6129
6129
  " </div>\n" +
6130
6130
  " RAW_ORDER_ACTIONS" +
6131
+ " RAW_ORDER_RATING" +
6131
6132
  " </div>\n" +
6132
6133
  " <aside class=\"order-page__totals\">\n" +
6133
6134
  " RAW_ORDER_TIMELINE" +
@@ -6335,6 +6336,134 @@ function _orderActionsBlock(o) {
6335
6336
  return "<div class=\"order-page__actions\">" + btns.join("") + "</div>";
6336
6337
  }
6337
6338
 
6339
+ // A post-purchase rating is offered on the same window a return is: a paid
6340
+ // (or further-along) order, never a still-pending (unpaid) one nor the
6341
+ // terminal off-ramps (cancelled / refunded). The order FSM has no
6342
+ // `fulfilled` status — the post-payment states are paid / fulfilling /
6343
+ // shipped / delivered — so the rating window mirrors _orderEligibleForReturn
6344
+ // rather than gating on a status the primitive doesn't expose.
6345
+ function _orderEligibleForRating(status) {
6346
+ return status === "paid" || status === "fulfilling" ||
6347
+ status === "shipped" || status === "delivered";
6348
+ }
6349
+
6350
+ // The three rating axes the customer scores, in display order. Labels are
6351
+ // static framework copy; the axis key matches the submitRating field name.
6352
+ var ORDER_RATING_AXES = [
6353
+ { key: "shipping", label: "Shipping", field: "shipping_rating" },
6354
+ { key: "packaging", label: "Packaging", field: "packaging_rating" },
6355
+ { key: "recommend", label: "Recommend us", field: "recommend_rating" },
6356
+ ];
6357
+
6358
+ // 1–5 selector for one rating axis. Rendered as native radio buttons so the
6359
+ // form works with no JavaScript and a screen reader announces each option.
6360
+ // `field` is the submitRating field name; nothing here is operator/customer
6361
+ // input, but the labels are escaped at the sink for consistency.
6362
+ function _ratingAxisField(axis) {
6363
+ var esc = b.template.escapeHtml;
6364
+ var opts = "";
6365
+ for (var v = 1; v <= 5; v += 1) {
6366
+ opts +=
6367
+ "<label class=\"order-rating__option\">" +
6368
+ "<input type=\"radio\" name=\"" + esc(axis.field) + "\" value=\"" + v + "\" required>" +
6369
+ "<span>" + v + "</span>" +
6370
+ "</label>";
6371
+ }
6372
+ return "<fieldset class=\"order-rating__axis\">" +
6373
+ "<legend>" + esc(axis.label) + "</legend>" +
6374
+ "<div class=\"order-rating__scale\">" + opts + "</div>" +
6375
+ "</fieldset>";
6376
+ }
6377
+
6378
+ // The rating form (eligible, not-yet-rated order). Posts to
6379
+ // /orders/:id/rate; the container CSRF injection tokens it automatically
6380
+ // (the action is not an EDGE_POST_PATHS prefix). A correction notice from a
6381
+ // rejected submit (bad value / over-length comment / duplicate) renders
6382
+ // above the form.
6383
+ function _orderRatingForm(o, notice) {
6384
+ var esc = b.template.escapeHtml;
6385
+ var noticeHtml = notice
6386
+ ? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(String(notice)) + "</p>"
6387
+ : "";
6388
+ var axes = ORDER_RATING_AXES.map(_ratingAxisField).join("");
6389
+ return "<section class=\"order-rating order-rating--form\">" +
6390
+ "<h2 class=\"pdp__variants-title\">Rate this order</h2>" +
6391
+ "<p class=\"order-rating__lede\">How did the delivery go? Your feedback helps us improve.</p>" +
6392
+ noticeHtml +
6393
+ "<form class=\"form-stack\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/rate\">" +
6394
+ axes +
6395
+ "<label class=\"form-field\"><span>Comment <small>(optional)</small></span>" +
6396
+ "<textarea name=\"comment\" rows=\"3\" maxlength=\"2000\" " +
6397
+ "placeholder=\"Tell us about your delivery experience\"></textarea></label>" +
6398
+ "<div class=\"order-page__actions\">" +
6399
+ "<button type=\"submit\" class=\"btn-primary\">Submit rating</button>" +
6400
+ "</div>" +
6401
+ "</form>" +
6402
+ "</section>";
6403
+ }
6404
+
6405
+ // The submitted rating (the three scores) plus, when present, the
6406
+ // customer's own comment and the operator's public reply. ESCAPE-BY-DEFAULT:
6407
+ // the comment and the reply are spliced from the primitive's pre-escaped
6408
+ // `comment_html` / `response_html` fields (escaped via b.template.escapeHtml
6409
+ // at the primitive's render layer), NEVER the raw `comment` / `response_text`
6410
+ // — so a `<script>`/`onerror` payload a customer typed is inert here. A
6411
+ // flagged comment is suppressed (operators moderate via the admin queue).
6412
+ function _orderRatingDisplay(rating) {
6413
+ var esc = b.template.escapeHtml;
6414
+ var scoreRows = ORDER_RATING_AXES.map(function (axis) {
6415
+ return "<div><dt>" + esc(axis.label) + "</dt>" +
6416
+ "<dd>" + esc(String(rating[axis.field])) + " / 5</dd></div>";
6417
+ }).join("");
6418
+ // comment_html is already escaped by the primitive — splice it, do not
6419
+ // re-escape (double-escaping would render visible entities) and never
6420
+ // reach for rating.comment (the raw, un-escaped string).
6421
+ var commentHtml = (rating.comment_html && !rating.comment_flagged)
6422
+ ? "<div class=\"order-rating__comment\"><h3>Your comment</h3>" +
6423
+ "<p>" + rating.comment_html + "</p></div>"
6424
+ : "";
6425
+ // response_html is the operator's public reply, also pre-escaped — splice
6426
+ // it, never rating.response_text.
6427
+ var responseHtml = rating.response_html
6428
+ ? "<div class=\"order-rating__response\"><h3>Our reply</h3>" +
6429
+ "<p>" + rating.response_html + "</p></div>"
6430
+ : "";
6431
+ return "<section class=\"order-rating order-rating--done\">" +
6432
+ "<h2 class=\"pdp__variants-title\">Your rating</h2>" +
6433
+ "<dl class=\"order-rating__scores\">" + scoreRows + "</dl>" +
6434
+ commentHtml +
6435
+ responseHtml +
6436
+ "</section>";
6437
+ }
6438
+
6439
+ // Resolve the rating panel for the order page: the submitted rating (read-
6440
+ // only display) when one exists, the submission form when the order is
6441
+ // eligible and unrated, and nothing otherwise (a pending / cancelled /
6442
+ // refunded order, or a renderer reached without the ratings primitive
6443
+ // wired). `opts.rating` is the getRating row (or null); `opts.rating_notice`
6444
+ // is a correction message echoed back onto a rejected submit.
6445
+ function _orderRatingBlock(opts) {
6446
+ var o = opts.order;
6447
+ if (opts.rating) return _orderRatingDisplay(opts.rating);
6448
+ if (opts.rating_eligible && _orderEligibleForRating(o.status)) {
6449
+ return _orderRatingForm(o, opts.rating_notice || null);
6450
+ }
6451
+ return "";
6452
+ }
6453
+
6454
+ // Map a ?rate_err= code (set by a rejected rating POST on its PRG redirect)
6455
+ // to a clean, operator-safe correction message rendered above the rating
6456
+ // form. Defensive request reader — an unknown / absent code yields no
6457
+ // notice, never a raw error or a leak. The duplicate / value / length codes
6458
+ // mirror the primitive's refusal classes without echoing its raw message.
6459
+ function _ratingNoticeFor(code) {
6460
+ if (code === "dupe") return "You've already rated this order.";
6461
+ if (code === "value") return "Please give each rating a score from 1 to 5.";
6462
+ if (code === "comment") return "Your comment couldn't be saved — please shorten it and try again.";
6463
+ if (code === "input") return "Please check your rating and try again.";
6464
+ return null;
6465
+ }
6466
+
6338
6467
  function renderOrder(opts) {
6339
6468
  if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
6340
6469
  var o = opts.order;
@@ -6376,6 +6505,10 @@ function renderOrder(opts) {
6376
6505
  var timelineHtml = _orderTimelineBlock(o.status);
6377
6506
  var trackingHtml = _orderTrackingBlock(shipments);
6378
6507
  var actionsHtml = _orderActionsBlock(o);
6508
+ // Post-purchase rating panel — container-only (session-gated). The route
6509
+ // passes the resolved getRating row (rating) + the eligibility/notice
6510
+ // flags; the edge render never does, so the panel is empty at the edge.
6511
+ var ratingHtml = _orderRatingBlock(opts);
6379
6512
  if (opts.theme) {
6380
6513
  return opts.theme.render("order", {
6381
6514
  title: "Order " + o.id,
@@ -6393,6 +6526,7 @@ function renderOrder(opts) {
6393
6526
  timeline_html: timelineHtml,
6394
6527
  tracking_html: trackingHtml,
6395
6528
  actions_html: actionsHtml,
6529
+ rating_html: ratingHtml,
6396
6530
  can_return: _orderEligibleForReturn(o.status),
6397
6531
  can_reorder: _orderEligibleForReorder(o.status),
6398
6532
  can_cancel: _orderEligibleForCancel(o.status),
@@ -6444,6 +6578,11 @@ function renderOrder(opts) {
6444
6578
  .replace("RAW_ORDER_TRACKING", trackingHtml)
6445
6579
  .replace("RAW_ORDER_ACTIONS", actionsHtml)
6446
6580
  .replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
6581
+ // The rating panel carries customer/operator free text (already escaped
6582
+ // into comment_html / response_html, but a `$` in that text would still
6583
+ // trip String.replace's dollar substitution) — splice it via the
6584
+ // replacer-function helper, never a replacement-string .replace.
6585
+ body = _spliceRaw(body, "RAW_ORDER_RATING", ratingHtml);
6447
6586
  // Post-purchase cross-sell rail — reuses the catalog grid + product-card
6448
6587
  // markup (so it inherits the storefront's card styling), rendered only
6449
6588
  // when the picker returned something.
@@ -10738,12 +10877,35 @@ function mount(router, deps) {
10738
10877
  }
10739
10878
  } catch (_e) { shipments = []; }
10740
10879
  }
10880
+ // Post-purchase fulfillment rating. Surfaced only on a signed-in
10881
+ // customer's OWN order (the IDOR gate above already proved
10882
+ // o.customer_id === orderAuth.customer_id when set) — a guest order
10883
+ // carries no owner, so the rating form/display is suppressed there.
10884
+ // Best-effort: an absent ratings primitive (or its table unmigrated)
10885
+ // degrades to "no rating panel" rather than 500-ing the order page.
10886
+ var ratingRow = null;
10887
+ var ratingEligible = false;
10888
+ if (deps.orderRatings && o.customer_id && orderAuth && o.customer_id === orderAuth.customer_id) {
10889
+ // Offer the rating surface only AFTER a successful read: an absent /
10890
+ // unmigrated ratings table degrades to "no rating panel" rather than
10891
+ // rendering a form whose POST would then 500 against the same missing
10892
+ // table. Eligibility also tracks the order-status window so the form
10893
+ // shows only when a submit would actually be accepted (the POST gates
10894
+ // on the same _orderEligibleForRating check).
10895
+ try {
10896
+ ratingRow = await deps.orderRatings.getRating({ order_id: o.id });
10897
+ ratingEligible = _orderEligibleForRating(o.status);
10898
+ } catch (_e) { ratingRow = null; ratingEligible = false; }
10899
+ }
10741
10900
  var ordUrl = req.url ? new URL(req.url, "http://localhost") : null;
10742
10901
  _send(res, 200, renderOrder({
10743
10902
  order: o,
10744
10903
  product_lookup: productLookup,
10745
10904
  recommendations: recommendations,
10746
10905
  shipments: shipments,
10906
+ rating: ratingRow,
10907
+ rating_eligible: ratingEligible,
10908
+ rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
10747
10909
  reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
10748
10910
  cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
10749
10911
  shop_name: shopName,
@@ -14661,6 +14823,97 @@ function mount(router, deps) {
14661
14823
  res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?cancelled=1");
14662
14824
  return res.end ? res.end() : res.send("");
14663
14825
  });
14826
+
14827
+ // POST /orders/:id/rate — a customer rates one of their OWN orders
14828
+ // (shipping / packaging / recommend, plus an optional comment). The
14829
+ // rating's customer_id is pinned to the SESSION customer — never a
14830
+ // form/query field — and the order_id is verified to belong to that
14831
+ // session before submitRating runs. Two refusals guard against IDOR: an
14832
+ // unauthenticated POST goes to login (never near an order), and a
14833
+ // foreign / guest-owned / unknown / malformed order is a clean 404
14834
+ // (never rated, never leaked). Bad input (rating out of [1,5], over-
14835
+ // length comment) and a duplicate submission map to a clean correction
14836
+ // redirect, never a 500 or a raw-error leak. Mounts only when the
14837
+ // ratings primitive is wired.
14838
+ if (deps.orderRatings) {
14839
+ router.post("/orders/:order_id/rate", async function (req, res) {
14840
+ var orderId = req.params && req.params.order_id;
14841
+ // Rating is a logged-in-customer action — resolve the session first
14842
+ // so an unauthenticated POST goes to login, never near the order.
14843
+ var rateAuth = _currentCustomerEnv(req);
14844
+ if (!rateAuth) {
14845
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
14846
+ return res.end ? res.end() : res.send("");
14847
+ }
14848
+ var o;
14849
+ try { o = orderId ? await deps.order.get(orderId) : null; }
14850
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
14851
+ // Ownership gate against IDOR: a missing order, a malformed id, an
14852
+ // order owned by another customer, OR a guest order with no owner
14853
+ // all 404 — the rating never touches an order the caller doesn't own.
14854
+ if (!o || !o.customer_id || o.customer_id !== rateAuth.customer_id) {
14855
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
14856
+ }
14857
+ // Eligibility gate mirrors the rating window (paid → delivered). A
14858
+ // pending / cancelled / refunded order bounces back unchanged.
14859
+ if (!_orderEligibleForRating(o.status)) {
14860
+ res.status(303);
14861
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
14862
+ return res.end ? res.end() : res.send("");
14863
+ }
14864
+ var body = req.body || {};
14865
+ // Defensive request-shape readers: the rating axes arrive as form
14866
+ // strings — coerce to integers so the primitive's strict [1,5]
14867
+ // integer validator sees a number (a blank / garbage field becomes
14868
+ // NaN, which the primitive refuses as a clean TypeError → "value").
14869
+ function _scoreField(raw) {
14870
+ // Whole-string integer only. parseInt would silently accept a
14871
+ // crafted "5abc" (→5) or "1.9" (→1) and persist a value the
14872
+ // shopper never chose; require the entire field to be digits so
14873
+ // anything else becomes NaN, which the primitive's strict [1,5]
14874
+ // validator refuses as a clean "value" correction.
14875
+ var s = String(raw == null ? "" : raw).trim();
14876
+ if (!/^-?\d+$/.test(s)) return NaN;
14877
+ var n = Number(s);
14878
+ return Number.isInteger(n) ? n : NaN;
14879
+ }
14880
+ function _rateRedirect(code) {
14881
+ res.status(303);
14882
+ res.setHeader && res.setHeader("location",
14883
+ "/orders/" + encodeURIComponent(o.id) + (code ? "?rate_err=" + code : ""));
14884
+ return res.end ? res.end() : res.send("");
14885
+ }
14886
+ var comment = typeof body.comment === "string" && body.comment.length ? body.comment : undefined;
14887
+ try {
14888
+ await deps.orderRatings.submitRating({
14889
+ order_id: o.id,
14890
+ customer_id: rateAuth.customer_id, // session-pinned, never from the form
14891
+ shipping_rating: _scoreField(body.shipping_rating),
14892
+ packaging_rating: _scoreField(body.packaging_rating),
14893
+ recommend_rating: _scoreField(body.recommend_rating),
14894
+ comment: comment,
14895
+ });
14896
+ } catch (e) {
14897
+ // Coded errors first (a plain Error with .code), then the
14898
+ // primitive's TypeErrors — so a duplicate surfaces its own notice
14899
+ // rather than the generic bad-input one. Any of these is a clean
14900
+ // correction redirect, never a 500.
14901
+ if (e && e.code === "ORDER_RATING_ALREADY_EXISTS") return _rateRedirect("dupe");
14902
+ if (e instanceof TypeError) {
14903
+ // The comment validator's message names "comment"; everything
14904
+ // else is a rating-value problem. Either way, no raw leak.
14905
+ var isComment = /comment/.test(String(e.message || ""));
14906
+ return _rateRedirect(isComment ? "comment" : "value");
14907
+ }
14908
+ throw e;
14909
+ }
14910
+ // PRG back to the order page — the submitted rating now renders in
14911
+ // place of the form (a refresh doesn't re-submit).
14912
+ res.status(303);
14913
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
14914
+ return res.end ? res.end() : res.send("");
14915
+ });
14916
+ }
14664
14917
  }
14665
14918
 
14666
14919
  // POST /cart/lines — add a line. Reads variant_id + qty from the
@@ -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.43",
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": {