@blamejs/blamejs-shop 0.3.8 → 0.3.10
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 +110 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +210 -1
- package/lib/storefront.js +1 -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.10 (2026-05-30) — **Order discounts are split across lines for accurate partial refunds.** When an order carries a cart-level discount, the admin console now records how that discount split across the order's lines — proportional to each line's value — so a later partial refund of one line knows that line's discounted share rather than the whole-order amount. A new read-only Discount splits screen shows the per-line breakdown for an order. This is back-office bookkeeping recorded after the order is placed; it has no effect on what the shopper is charged, and an order with no discount records nothing. **Added:** *Per-line discount allocation + admin view* — An order that carried an automatic cart discount now gets a recorded breakdown of how that discount split across its lines — proportional to each line's subtotal, summing exactly to the discount. A read-only Discount splits admin screen shows the breakdown for an order. The recording runs after the order is placed and never affects the charged amount; an order with no discount records nothing.
|
|
12
|
+
|
|
13
|
+
- v0.3.9 (2026-05-30) — **Automatic discounts now apply at checkout.** The automatic-discount rules you define in the admin console — an amount or percentage off a cart that meets a threshold — now reduce the order total at checkout. When a rule matches, the discount is applied (capped so it never exceeds the order subtotal) and shown in the quote the shopper pays; when no rule matches, or none are defined, the total is exactly as before, so a store that has not set up rules is unaffected. The admin screen for managing rules already shipped; this wires it through to the checkout quote and the charge. **Added:** *Automatic cart discounts at checkout* — When a cart matches a configured automatic-discount rule, the checkout reduces the order total by the rule's saving — capped to the order subtotal so the total is never negative — and the reduced amount is what the shopper is charged. With no matching rule, or no rules defined, the order total is unchanged, so the change stays invisible until you set rules up; any rule-engine error degrades to no discount rather than blocking checkout.
|
|
14
|
+
|
|
11
15
|
- v0.3.8 (2026-05-30) — **Update the vendored blamejs runtime to v0.14.6.** Refreshes the vendored blamejs runtime from v0.14.5 to v0.14.6, a maintenance update. The createApp request-lifecycle security stack the shop composes — CSRF, fetch-metadata, body-parser, cookies, rate limiting — is unchanged in default behavior. v0.14.6 adds opt-in RFC 9457 problem+json and onDeny response hooks to the access-refusal middleware (defaults unchanged, so nothing the shop relies on shifts) and corrects a few internal export and bearer-scope paths. No operator action is required. **Changed:** *Vendored blamejs runtime updated to v0.14.6* — The pinned blamejs runtime moves from v0.14.5 to v0.14.6. The middleware the storefront and admin compose keeps its current default behavior; the release adds opt-in problem-details / onDeny response hooks to the refusal middleware and fixes internal export and bearer-scope paths. No behavior change for the shop.
|
|
12
16
|
|
|
13
17
|
- v0.3.7 (2026-05-30) — **Shipping zones now apply at checkout.** The shipping zones you define in the admin console — per-region rates by destination, parcel weight, and order value — now drive the shipping options shown at checkout. When a cart's destination matches a zone, the shopper sees that zone's rates; when no zone matches, or none are defined, checkout falls back to the existing flat-rate table exactly as before, so a store that has not configured zones is entirely unaffected. A fully digital order is never quoted physical shipping. The admin screen for managing zones already shipped; this wires it through to the checkout quote. **Added:** *Zone-based shipping rates at checkout* — When a cart's destination matches a configured shipping zone, the checkout shipping quote uses that zone's rates — resolved by destination country and region, parcel weight, and order subtotal — instead of the flat-rate table. With no matching zone, or no zones defined, checkout returns the existing flat rates unchanged, so the change stays invisible until you set zones up; any lookup error degrades to the same flat fallback. A fully digital order (no shipping-requiring item) is never quoted physical shipping.
|
package/lib/admin.js
CHANGED
|
@@ -451,7 +451,7 @@ function mount(router, deps) {
|
|
|
451
451
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
452
452
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
453
453
|
// notice when the salesReports primitive isn't wired.
|
|
454
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
|
|
454
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
|
|
455
455
|
|
|
456
456
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
457
457
|
|
|
@@ -3741,6 +3741,59 @@ function mount(router, deps) {
|
|
|
3741
3741
|
));
|
|
3742
3742
|
}
|
|
3743
3743
|
|
|
3744
|
+
// ---- discount allocations -------------------------------------------
|
|
3745
|
+
|
|
3746
|
+
// Read-only console for the per-line discount-allocation audit trail.
|
|
3747
|
+
// When a placed order carried a cart-level discount, checkout records
|
|
3748
|
+
// (post-commit) how that discount split across the order's lines, so a
|
|
3749
|
+
// later partial refund knows each line's discounted share. Allocations
|
|
3750
|
+
// are SYSTEM-WRITTEN — there's no create/edit here; the operator looks
|
|
3751
|
+
// up an order id and reads back the recorded breakdown. Endpoints are
|
|
3752
|
+
// omitted entirely when no discountAllocation primitive is wired.
|
|
3753
|
+
var discountAllocation = deps.discountAllocation || null;
|
|
3754
|
+
if (discountAllocation) {
|
|
3755
|
+
// Look an order's recorded allocations up by id. Content-negotiates:
|
|
3756
|
+
// bearer → JSON (the raw rows, unchanged for tooling); signed-in
|
|
3757
|
+
// browser → the lookup form plus the rendered breakdown tables.
|
|
3758
|
+
//
|
|
3759
|
+
// The order id is a defensive request-shape reader — an empty / over-
|
|
3760
|
+
// long / control-byte id makes allocationsForOrder throw a TypeError,
|
|
3761
|
+
// which surfaces as an empty result + a notice, never a 500.
|
|
3762
|
+
async function _lookupAllocations(orderId) {
|
|
3763
|
+
if (typeof orderId !== "string" || orderId === "") return [];
|
|
3764
|
+
try {
|
|
3765
|
+
return await discountAllocation.allocationsForOrder(orderId);
|
|
3766
|
+
} catch (e) {
|
|
3767
|
+
if (e instanceof TypeError) return []; // malformed id — treat as "nothing recorded"
|
|
3768
|
+
throw e;
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
router.get("/admin/discount-allocation", _pageOrApi(true,
|
|
3773
|
+
R(async function (req, res) {
|
|
3774
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3775
|
+
var orderId = (url && url.searchParams.get("order_id")) || "";
|
|
3776
|
+
var rows = await _lookupAllocations(orderId);
|
|
3777
|
+
_json(res, 200, { order_id: orderId, rows: rows });
|
|
3778
|
+
}),
|
|
3779
|
+
async function (req, res) {
|
|
3780
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3781
|
+
var orderId = (url && url.searchParams.get("order_id")) || "";
|
|
3782
|
+
var rows = orderId ? await _lookupAllocations(orderId) : [];
|
|
3783
|
+
var notice = (orderId && rows.length === 0)
|
|
3784
|
+
? "No discount allocations recorded for that order."
|
|
3785
|
+
: null;
|
|
3786
|
+
_sendHtml(res, 200, renderAdminDiscountAllocation({
|
|
3787
|
+
shop_name: deps.shop_name,
|
|
3788
|
+
nav_available: navAvailable,
|
|
3789
|
+
order_id: orderId,
|
|
3790
|
+
allocations: rows,
|
|
3791
|
+
notice: notice,
|
|
3792
|
+
}));
|
|
3793
|
+
},
|
|
3794
|
+
));
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3744
3797
|
// ---- announcements --------------------------------------------------
|
|
3745
3798
|
// Sitewide promo/notice strip. Content-negotiated like the other console
|
|
3746
3799
|
// screens: bearer → the JSON contract; signed-in browser → the HTML
|
|
@@ -6135,6 +6188,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
6135
6188
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
6136
6189
|
{ key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
|
|
6137
6190
|
{ key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
|
|
6191
|
+
{ key: "discount-allocation", href: "/admin/discount-allocation", label: "Discount splits", requires: "discountAllocation" },
|
|
6138
6192
|
{ key: "quantity-discounts", href: "/admin/quantity-discounts", label: "Quantity breaks", requires: "quantityDiscounts" },
|
|
6139
6193
|
{ key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
|
|
6140
6194
|
{ key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
|
|
@@ -8674,6 +8728,60 @@ function renderAdminCollections(opts) {
|
|
|
8674
8728
|
return _renderAdminShell(opts.shop_name, "Collections", bodyHtml, "collections", opts.nav_available);
|
|
8675
8729
|
}
|
|
8676
8730
|
|
|
8731
|
+
// Read-only view of the per-line discount-allocation audit trail for one
|
|
8732
|
+
// order. The operator looks an order up by id; each recorded allocation
|
|
8733
|
+
// renders as a header (source, kind, total, recorded-at) plus a table of
|
|
8734
|
+
// the per-line breakdown. Allocations are system-written by checkout —
|
|
8735
|
+
// there's no create/edit affordance here. Amounts are exact minor-unit
|
|
8736
|
+
// integers (the audit row carries no currency; the figures are the same
|
|
8737
|
+
// integers the refund math reads back), so the operator sees the precise
|
|
8738
|
+
// recorded split rather than a rounded display value.
|
|
8739
|
+
function renderAdminDiscountAllocation(opts) {
|
|
8740
|
+
opts = opts || {};
|
|
8741
|
+
var orderId = typeof opts.order_id === "string" ? opts.order_id : "";
|
|
8742
|
+
var allocations = opts.allocations || [];
|
|
8743
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
8744
|
+
|
|
8745
|
+
var lookupForm =
|
|
8746
|
+
"<div class=\"panel mw-40\">" +
|
|
8747
|
+
"<h3 class=\"subhead\">Look up an order</h3>" +
|
|
8748
|
+
"<p class=\"meta\">When an order carried a cart-level discount, the split across its lines is recorded here for refund precision. Enter an order id to see its recorded allocations.</p>" +
|
|
8749
|
+
"<form method=\"get\" action=\"/admin/discount-allocation\">" +
|
|
8750
|
+
_setupField("Order id", "order_id", orderId, "text", "The order's id (shown on the order detail page).", " maxlength=\"200\"") +
|
|
8751
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Look up</button></div>" +
|
|
8752
|
+
"</form>" +
|
|
8753
|
+
"</div>";
|
|
8754
|
+
|
|
8755
|
+
var results = "";
|
|
8756
|
+
if (allocations.length) {
|
|
8757
|
+
results = allocations.map(function (a) {
|
|
8758
|
+
var breakdown = Array.isArray(a.breakdown) ? a.breakdown : [];
|
|
8759
|
+
var lineRows = breakdown.map(function (l) {
|
|
8760
|
+
return "<tr>" +
|
|
8761
|
+
"<td><code class=\"order-id\">" + _htmlEscape(String(l.line_id)) + "</code></td>" +
|
|
8762
|
+
"<td class=\"num\">" + _htmlEscape(String(l.allocated_minor)) + "</td>" +
|
|
8763
|
+
"<td class=\"num\">" + _htmlEscape(String(l.remaining_minor)) + "</td>" +
|
|
8764
|
+
"</tr>";
|
|
8765
|
+
}).join("");
|
|
8766
|
+
var lineTable = breakdown.length
|
|
8767
|
+
? "<table><thead><tr><th scope=\"col\">Line</th><th scope=\"col\" class=\"num\">Allocated (minor)</th><th scope=\"col\" class=\"num\">Remaining (minor)</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
|
|
8768
|
+
: "<p class=\"empty\">This allocation has no recorded lines.</p>";
|
|
8769
|
+
return "<div class=\"panel mt\">" +
|
|
8770
|
+
"<h3 class=\"subhead\">" + _htmlEscape(a.source || "—") + "</h3>" +
|
|
8771
|
+
"<p class=\"meta\">" +
|
|
8772
|
+
"Split: <strong>" + _htmlEscape(a.kind || "—") + "</strong>" +
|
|
8773
|
+
" · Total: <strong>" + _htmlEscape(String(a.total_minor)) + "</strong> minor units" +
|
|
8774
|
+
" · Recorded: " + _htmlEscape(_fmtDate(a.applied_at)) +
|
|
8775
|
+
"</p>" +
|
|
8776
|
+
lineTable +
|
|
8777
|
+
"</div>";
|
|
8778
|
+
}).join("");
|
|
8779
|
+
}
|
|
8780
|
+
|
|
8781
|
+
var bodyHtml = "<section><h2>Discount splits</h2>" + notice + lookupForm + results + "</section>";
|
|
8782
|
+
return _renderAdminShell(opts.shop_name, "Discount splits", bodyHtml, "discount-allocation", opts.nav_available);
|
|
8783
|
+
}
|
|
8784
|
+
|
|
8677
8785
|
// A <datetime-local> form value → epoch ms (integer) for the lib's
|
|
8678
8786
|
// starts_at/expires_at, or null when blank/unparseable (open-ended). The
|
|
8679
8787
|
// announcement primitive validates the result; a blank schedule field just
|
|
@@ -9564,6 +9672,7 @@ module.exports = {
|
|
|
9564
9672
|
renderAdminShipping: renderAdminShipping,
|
|
9565
9673
|
renderAdminShippingZone: renderAdminShippingZone,
|
|
9566
9674
|
renderAdminDiscounts: renderAdminDiscounts,
|
|
9675
|
+
renderAdminDiscountAllocation: renderAdminDiscountAllocation,
|
|
9567
9676
|
renderAdminQuantityDiscounts: renderAdminQuantityDiscounts,
|
|
9568
9677
|
renderAdminQuantityDiscount: renderAdminQuantityDiscount,
|
|
9569
9678
|
renderAdminConfirm: renderAdminConfirm,
|
package/lib/asset-manifest.json
CHANGED
package/lib/checkout.js
CHANGED
|
@@ -146,6 +146,27 @@ function create(deps) {
|
|
|
146
146
|
// server-authoritative computation the cart page renders, so the
|
|
147
147
|
// "review your order" subtotal and the charged amount agree.
|
|
148
148
|
var quantityDiscounts = deps.quantityDiscounts || null;
|
|
149
|
+
// Optional automatic-discount engine. When wired, the quote consults
|
|
150
|
+
// every operator-authored auto-discount rule against the resolved
|
|
151
|
+
// cart and reduces the subtotal by the matched savings (clamped to
|
|
152
|
+
// [0, subtotal] so a rule can never drive the order negative or
|
|
153
|
+
// charge more than the cart is worth). Disabled when absent — the
|
|
154
|
+
// discount is zero and every total is identical to the un-wired
|
|
155
|
+
// flow, so production stays unchanged until a rule is defined. Only
|
|
156
|
+
// subtotal-reducing savings feed `discount_minor`; a `free_shipping`
|
|
157
|
+
// rule's savings are a shipping concern and are left to the shipping
|
|
158
|
+
// primitive, not folded into the subtotal discount here.
|
|
159
|
+
var autoDiscount = deps.autoDiscount || null;
|
|
160
|
+
// Optional discount-allocation recorder. When wired, a placed order
|
|
161
|
+
// that carried a cart-level discount gets a per-line breakdown of how
|
|
162
|
+
// that discount split across the order lines, recorded as a back-office
|
|
163
|
+
// audit row so a later partial refund knows each line's discounted
|
|
164
|
+
// share. POST-COMMIT + drop-silent: it runs after the order exists, in
|
|
165
|
+
// its own try/catch, so a recording failure can never fail, reverse, or
|
|
166
|
+
// change the charge of a placed order. No discount -> nothing recorded.
|
|
167
|
+
// Disabled when absent — the buy path is byte-identical to the un-wired
|
|
168
|
+
// flow.
|
|
169
|
+
var discountAllocation = deps.discountAllocation || null;
|
|
149
170
|
|
|
150
171
|
// Reprice a list of cart lines through the quantity-discount engine.
|
|
151
172
|
// Returns a shallow copy with `unit_amount_minor` overwritten by the
|
|
@@ -179,6 +200,72 @@ function create(deps) {
|
|
|
179
200
|
return out;
|
|
180
201
|
}
|
|
181
202
|
|
|
203
|
+
// Resolve the automatic-discount effect for a priced cart. Builds
|
|
204
|
+
// the engine's cart shape from the resolved lines + subtotal, asks
|
|
205
|
+
// the engine which operator-authored rules apply, and returns the
|
|
206
|
+
// subtotal-reducing savings (clamped to [0, subtotal]) plus the
|
|
207
|
+
// per-rule breakdown the post-commit recorder replays.
|
|
208
|
+
//
|
|
209
|
+
// FALLBACK (zero regression): no engine wired, a non-finite or
|
|
210
|
+
// negative aggregate, an empty result, or ANY throw collapses to
|
|
211
|
+
// `{ discount_minor: 0, applied: [] }` — byte-identical to the
|
|
212
|
+
// un-wired flow. The engine is consulted but never trusted to keep
|
|
213
|
+
// the buy path alive.
|
|
214
|
+
//
|
|
215
|
+
// PAYMENT SAFETY: the returned discount is clamped to
|
|
216
|
+
// [0, subtotalMinor] so it can never be negative and never exceeds
|
|
217
|
+
// the subtotal — the order total can therefore never go negative,
|
|
218
|
+
// and a customer can never be charged less than zero or credited
|
|
219
|
+
// more than the cart is worth.
|
|
220
|
+
async function _resolveAutoDiscount(lines, subtotalMinor, customerId) {
|
|
221
|
+
if (!autoDiscount || typeof autoDiscount.evaluate !== "function") {
|
|
222
|
+
return { discount_minor: 0, applied: [] };
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
var evalLines = [];
|
|
226
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
227
|
+
var l = lines[i];
|
|
228
|
+
evalLines.push({
|
|
229
|
+
sku: l.sku,
|
|
230
|
+
quantity: l.qty,
|
|
231
|
+
unit_price_minor: l.unit_amount_minor,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
var res = await autoDiscount.evaluate({
|
|
235
|
+
cart: {
|
|
236
|
+
subtotal_minor: subtotalMinor,
|
|
237
|
+
lines: evalLines,
|
|
238
|
+
},
|
|
239
|
+
customer_id: customerId || undefined,
|
|
240
|
+
});
|
|
241
|
+
var appliedRaw = res && Array.isArray(res.applied) ? res.applied : [];
|
|
242
|
+
var total = 0;
|
|
243
|
+
var applied = [];
|
|
244
|
+
for (var j = 0; j < appliedRaw.length; j += 1) {
|
|
245
|
+
var entry = appliedRaw[j];
|
|
246
|
+
// A `free_shipping` rule's savings are a shipping reduction,
|
|
247
|
+
// not a subtotal discount — excluded so the subtotal-level
|
|
248
|
+
// `discount_minor` stays a pure subtotal reduction (the
|
|
249
|
+
// shipping primitive owns the shipping side).
|
|
250
|
+
if (!entry || entry.value_kind === "free_shipping") continue;
|
|
251
|
+
var s = entry.savings_minor;
|
|
252
|
+
if (!Number.isInteger(s) || s <= 0) continue;
|
|
253
|
+
total += s;
|
|
254
|
+
applied.push({ rule_slug: entry.rule_slug, savings_minor: s });
|
|
255
|
+
}
|
|
256
|
+
if (!Number.isFinite(total) || total <= 0) {
|
|
257
|
+
return { discount_minor: 0, applied: [] };
|
|
258
|
+
}
|
|
259
|
+
// Clamp to the subtotal floor: the discount can reduce the
|
|
260
|
+
// subtotal to zero but never past it.
|
|
261
|
+
if (total > subtotalMinor) total = subtotalMinor;
|
|
262
|
+
return { discount_minor: total, applied: applied };
|
|
263
|
+
} catch (_e) {
|
|
264
|
+
// Any engine failure -> no discount, never a 5xx on the buy path.
|
|
265
|
+
return { discount_minor: 0, applied: [] };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
182
269
|
// Validate a gift-card code against a priced quote: the card exists,
|
|
183
270
|
// is active, not expired, and matches the order currency. Returns
|
|
184
271
|
// the credit to apply (capped at the grand total so an order total
|
|
@@ -336,6 +423,99 @@ function create(deps) {
|
|
|
336
423
|
});
|
|
337
424
|
}
|
|
338
425
|
|
|
426
|
+
// Record each applied auto-discount rule against a committed order
|
|
427
|
+
// so the engine's redemption counter + event log reflect the use.
|
|
428
|
+
// Best-effort / drop-silent: the customer has already been charged
|
|
429
|
+
// (or the order is otherwise placed) by the time this runs, so a
|
|
430
|
+
// recording failure must NEVER fail or reverse the order. Each
|
|
431
|
+
// record is isolated in its own try/catch so one bad row can't stop
|
|
432
|
+
// the rest. No-op when the engine isn't wired or nothing applied.
|
|
433
|
+
async function _recordAutoDiscounts(quote, orderId, customerId) {
|
|
434
|
+
if (!autoDiscount || typeof autoDiscount.recordApplication !== "function") return;
|
|
435
|
+
var applied = quote && Array.isArray(quote.auto_discounts) ? quote.auto_discounts : [];
|
|
436
|
+
for (var i = 0; i < applied.length; i += 1) {
|
|
437
|
+
var a = applied[i];
|
|
438
|
+
if (!a || !a.rule_slug) continue;
|
|
439
|
+
try {
|
|
440
|
+
await autoDiscount.recordApplication({
|
|
441
|
+
rule_slug: a.rule_slug,
|
|
442
|
+
order_id: orderId,
|
|
443
|
+
savings_minor: a.savings_minor,
|
|
444
|
+
customer_id: customerId || undefined,
|
|
445
|
+
});
|
|
446
|
+
} catch (_e) {
|
|
447
|
+
// drop-silent — by design: the order is already placed; a
|
|
448
|
+
// failed redemption record must not surface to the buyer.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Record HOW a cart-level order discount split across the order lines,
|
|
454
|
+
// for accounting + partial-refund precision. The recorded breakdown's
|
|
455
|
+
// per-line `allocated_minor` values sum exactly to the discount, so a
|
|
456
|
+
// later partial refund of one line can remove that line's share rather
|
|
457
|
+
// than the whole-order amount.
|
|
458
|
+
//
|
|
459
|
+
// POST-COMMIT + drop-silent — by design: the order is already placed
|
|
460
|
+
// (and, in the charged path, the PaymentIntent already exists) by the
|
|
461
|
+
// time this runs. The entire body is wrapped in one try/catch so a
|
|
462
|
+
// recording failure (bad input, DB error, an unmigrated table) can
|
|
463
|
+
// NEVER fail, reverse, or change the charge of a placed order. This is
|
|
464
|
+
// pure back-office bookkeeping with zero impact on the charged amount.
|
|
465
|
+
//
|
|
466
|
+
// No-op when: the recorder isn't wired, the order carried no discount
|
|
467
|
+
// (discount_minor <= 0), or the order has no lines to split across.
|
|
468
|
+
// The default split is `proportional` (by line subtotal) — the
|
|
469
|
+
// operator-readable "each line absorbs its share of the savings"
|
|
470
|
+
// distribution.
|
|
471
|
+
async function _recordDiscountAllocation(quote, orderLines, orderId) {
|
|
472
|
+
if (!discountAllocation ||
|
|
473
|
+
typeof discountAllocation.allocate !== "function" ||
|
|
474
|
+
typeof discountAllocation.recordAllocation !== "function") return;
|
|
475
|
+
try {
|
|
476
|
+
var discount = quote && quote.totals ? quote.totals.discount_minor : 0;
|
|
477
|
+
if (!Number.isInteger(discount) || discount <= 0) return; // no discount -> record nothing
|
|
478
|
+
if (!Array.isArray(orderLines) || orderLines.length === 0) return;
|
|
479
|
+
|
|
480
|
+
// Build the allocate() line shape from the placed order lines. The
|
|
481
|
+
// line_id is the PERSISTED order-line id, so a later partial refund —
|
|
482
|
+
// which operates on order lines — can apply the breakdown directly
|
|
483
|
+
// without re-mapping by (variant, sku). The per-line subtotal is
|
|
484
|
+
// unit_amount_minor * qty — the same per-line
|
|
485
|
+
// figure pricing.subtotal summed into quote.totals.subtotal_minor,
|
|
486
|
+
// so the discount (clamped to <= subtotal upstream) never exceeds
|
|
487
|
+
// the total line weight.
|
|
488
|
+
var lines = [];
|
|
489
|
+
for (var i = 0; i < orderLines.length; i += 1) {
|
|
490
|
+
var l = orderLines[i];
|
|
491
|
+
var lineId = l.line_id != null ? l.line_id : l.id;
|
|
492
|
+
if (lineId == null) return; // no stable id to key the breakdown on
|
|
493
|
+
lines.push({
|
|
494
|
+
line_id: String(lineId),
|
|
495
|
+
subtotal_minor: l.unit_amount_minor * l.qty,
|
|
496
|
+
quantity: l.qty,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
var breakdown = discountAllocation.allocate({
|
|
501
|
+
lines: lines,
|
|
502
|
+
discount_minor: discount,
|
|
503
|
+
kind: "proportional",
|
|
504
|
+
});
|
|
505
|
+
await discountAllocation.recordAllocation({
|
|
506
|
+
order_id: orderId,
|
|
507
|
+
discount_source: "auto_discount",
|
|
508
|
+
kind: "proportional",
|
|
509
|
+
breakdown: breakdown,
|
|
510
|
+
total_minor: discount,
|
|
511
|
+
});
|
|
512
|
+
} catch (_e) {
|
|
513
|
+
// drop-silent — by design: the order is already placed; a failed
|
|
514
|
+
// allocation record is back-office bookkeeping and must never
|
|
515
|
+
// surface to the buyer or affect the charge.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
339
519
|
// Compose a quote from a cart + ship-to + (optional) selected
|
|
340
520
|
// shipping service. Pure read — no DB writes.
|
|
341
521
|
async function _buildQuote(input) {
|
|
@@ -390,10 +570,18 @@ function create(deps) {
|
|
|
390
570
|
}
|
|
391
571
|
|
|
392
572
|
var shippingMinor = selected ? selected.amount_minor : 0;
|
|
573
|
+
|
|
574
|
+
// Automatic discounts: consult the operator-authored rule engine
|
|
575
|
+
// (when wired) for the subtotal reduction this cart earns. The
|
|
576
|
+
// resolver clamps the result to [0, subtotal] and falls back to 0
|
|
577
|
+
// on any failure, so the total math below is identical to the
|
|
578
|
+
// un-wired flow whenever no rule applies.
|
|
579
|
+
var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null);
|
|
580
|
+
|
|
393
581
|
var totals = pricing.totals(c, lines, {
|
|
394
582
|
tax_minor: taxRow.tax_minor,
|
|
395
583
|
shipping_minor: shippingMinor,
|
|
396
|
-
discount_minor:
|
|
584
|
+
discount_minor: autoDisc.discount_minor,
|
|
397
585
|
});
|
|
398
586
|
|
|
399
587
|
return {
|
|
@@ -405,6 +593,10 @@ function create(deps) {
|
|
|
405
593
|
shipping_rates: ratesRow.services,
|
|
406
594
|
selected_shipping: selected,
|
|
407
595
|
totals: totals,
|
|
596
|
+
// Per-rule breakdown that fed `totals.discount_minor`, replayed
|
|
597
|
+
// by confirm()'s post-commit recorder. Empty when no rule
|
|
598
|
+
// applied (or no engine wired).
|
|
599
|
+
auto_discounts: autoDisc.applied,
|
|
408
600
|
};
|
|
409
601
|
}
|
|
410
602
|
|
|
@@ -499,6 +691,14 @@ function create(deps) {
|
|
|
499
691
|
});
|
|
500
692
|
if (gc) await _redeemGiftCard(gc, paidOrder.id);
|
|
501
693
|
if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
|
|
694
|
+
// Best-effort: record the auto-discount redemptions against the
|
|
695
|
+
// placed order. Runs post-commit so a recording failure can't
|
|
696
|
+
// reverse an order that's already paid.
|
|
697
|
+
await _recordAutoDiscounts(quote, paidOrder.id, cartRow.customer_id || null);
|
|
698
|
+
// Best-effort: record how the cart-level discount split across the
|
|
699
|
+
// order lines (accounting + refund precision). Post-commit +
|
|
700
|
+
// drop-silent — no impact on the already-settled order.
|
|
701
|
+
await _recordDiscountAllocation(quote, paidOrder.lines, paidOrder.id);
|
|
502
702
|
await cart.setStatus(quote.cart_id, "converted");
|
|
503
703
|
var settled = await order.transition(paidOrder.id, "mark_paid", {
|
|
504
704
|
reason: loy && !gc ? "loyalty:full" : "gift_card:full",
|
|
@@ -552,6 +752,15 @@ function create(deps) {
|
|
|
552
752
|
// guard.
|
|
553
753
|
if (gc) await _redeemGiftCard(gc, createdOrder.id);
|
|
554
754
|
if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id);
|
|
755
|
+
// Best-effort: record the auto-discount redemptions against the
|
|
756
|
+
// placed order. Runs post-commit so a recording failure can't
|
|
757
|
+
// reverse or fail an order whose PaymentIntent already exists.
|
|
758
|
+
await _recordAutoDiscounts(quote, createdOrder.id, cartRow.customer_id || null);
|
|
759
|
+
// Best-effort: record how the cart-level discount split across the
|
|
760
|
+
// order lines (accounting + refund precision). Post-commit +
|
|
761
|
+
// drop-silent — the PaymentIntent already exists; this never
|
|
762
|
+
// changes the charged amount.
|
|
763
|
+
await _recordDiscountAllocation(quote, createdOrder.lines, createdOrder.id);
|
|
555
764
|
|
|
556
765
|
// Mark the cart converted so a refresh of the storefront
|
|
557
766
|
// doesn't accidentally re-quote the same cart.
|
package/lib/storefront.js
CHANGED
|
@@ -7139,7 +7139,7 @@ function mount(router, deps) {
|
|
|
7139
7139
|
|
|
7140
7140
|
// Compute the cart/checkout totals the shopper sees BEFORE paying.
|
|
7141
7141
|
// Composes the SAME tax + shipping primitives the charge runs through
|
|
7142
|
-
// (via checkout.quote, which prices tax against the
|
|
7142
|
+
// (via checkout.quote, which prices tax against the pre-discount
|
|
7143
7143
|
// subtotal + picks shipping rates for the destination), so the
|
|
7144
7144
|
// displayed grand total agrees with what Stripe is later asked to
|
|
7145
7145
|
// charge for that destination. Returns:
|
package/package.json
CHANGED