@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 +4 -0
- package/lib/admin.js +251 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/order-ratings.js +36 -0
- package/lib/product-bulk-ops.js +10 -6
- package/lib/sales-tax-filings.js +8 -12
- package/lib/storefront.js +253 -0
- package/lib/subscription-analytics.js +30 -17
- package/lib/tax.js +20 -25
- package/lib/wishlist-alerts.js +3 -2
- package/lib/wishlist-digest.js +4 -1
- package/package.json +1 -1
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,
|
package/lib/asset-manifest.json
CHANGED
package/lib/order-ratings.js
CHANGED
|
@@ -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
|
|
package/lib/product-bulk-ops.js
CHANGED
|
@@ -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-
|
|
20
|
-
*
|
|
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
|
|
493
|
-
//
|
|
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 =
|
|
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,
|
package/lib/sales-tax-filings.js
CHANGED
|
@@ -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
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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.
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
344
|
-
if (
|
|
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
|
-
|
|
348
|
-
|
|
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,
|
|
125
|
-
//
|
|
126
|
-
|
|
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 =
|
|
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 =
|
|
235
|
+
var tax = _mulRational(net, rate, 10000);
|
|
241
236
|
return {
|
|
242
237
|
net_minor: net,
|
|
243
238
|
tax_minor: tax,
|
package/lib/wishlist-alerts.js
CHANGED
|
@@ -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
|
-
?
|
|
774
|
+
? pricing.format(v.payload.old_amount_minor, v.payload.currency)
|
|
774
775
|
: "—";
|
|
775
776
|
var newPriceStr = v.payload.new_amount_minor != null
|
|
776
|
-
?
|
|
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)) + "%"
|
package/lib/wishlist-digest.js
CHANGED
|
@@ -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
|
-
|
|
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