@blamejs/blamejs-shop 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.4 (2026-05-30) — **Sales-tax filings and remittance tracking in the admin console.** The admin console gains a Tax filings section for tracking sales-tax remittance across reporting periods. Open a filing period for a jurisdiction, compute the tax collected from completed orders in that window, then record the submission and the payment as you remit. Each filing carries a per-rate breakdown and an audit trail, and the list surfaces what's due soon or already overdue. A per-jurisdiction remittance report totals the tax owed across a date range. Every figure derives from completed orders — nothing in the storefront, cart, or checkout changes. **Added:** *Tax filings admin console* — A new Tax filings section tracks sales-tax remittance: open a filing period for a jurisdiction and reporting kind, compute the tax collected from completed orders in the window with a per-rate breakdown, then record submission and payment through the filing lifecycle. The list surfaces filings due soon or overdue, and a remittance report totals tax owed per jurisdiction across a date range. This is reporting only — it reads completed orders and changes nothing in checkout.
12
+
13
+ - v0.3.3 (2026-05-30) — **Manage quantity-break (volume) pricing from the admin console.** The admin console gains a Quantity breaks section for creating and managing volume-pricing tier sets — buy-more-save-more rules scoped to a SKU, product, collection, vendor, category, or the whole store. Each set defines one or more quantity thresholds (e.g. buy 5, buy 10) with a percentage off, an amount off each unit, an amount off the line, or a fixed per-unit price, and can be marked exclusive or left to stack with others. A sample-price preview shows the discounted unit at each threshold before you commit, and sets archive and restore. The tiers take effect immediately on the product page, in the cart, and at checkout. **Added:** *Quantity Breaks admin console* — A new Quantity breaks section manages volume-pricing tier sets: create a set scoped to a SKU, product, collection, vendor, category, or globally; define one or more quantity thresholds with a percent-off, amount-off-each, amount-off-line, or fixed-per-unit discount; mark a set exclusive or let it stack. A sample-price preview shows the discounted unit at each threshold, and sets archive and restore. The tiers apply automatically on the product page, the cart, and at checkout.
14
+
11
15
  - v0.3.2 (2026-05-30) — **Creating a product lands you on its detail screen to finish setup.** Creating a product from the admin console now takes you straight to that product's detail screen — where you add a variant, set its price, and add stock — instead of returning you to the product list. A banner on the new product names the remaining steps to make it sellable. This removes the hunt-for-the-product-you-just-made step from the catalog setup flow. **Changed:** *Product create flows straight into editing* — Submitting the new-product form now redirects to the product's detail screen with a banner naming the next steps — add a variant with a SKU, set its price, and add stock — rather than bouncing back to the product list. Configuring a just-created product no longer requires finding it again first.
12
16
 
13
17
  - v0.3.1 (2026-05-30) — **Fix CSRF origin check refusing authenticated forms behind a TLS-terminating proxy.** The token-based CSRF protection added in 0.3.0 runs an Origin/Referer pre-check on every state-changing request. When the app is served behind a proxy that terminates TLS and forwards plain HTTP to the application — a CDN or load balancer, the standard production topology — the application saw the connection as HTTP and rebuilt its own origin as `http://<host>`, while the browser's request carried `Origin: https://<host>`. That bare scheme mismatch made the check refuse every legitimate same-origin POST as cross-origin, blocking sign-in and every account and admin form. The CSRF gate now trusts the forwarded protocol header — the same proxy stance the session cookies already use — so it derives the real public origin, and it matches against an explicit allowlist of the public origin(s), configurable via SHOP_PUBLIC_ORIGINS. The token cookie also regains its `__Host-` prefix over the forwarded HTTPS connection. No operator action is required for the default origin. **Fixed:** *Authenticated forms no longer refused behind a TLS-terminating proxy* — The CSRF origin pre-check derived the application origin from the raw proxied connection scheme, so a same-origin browser POST carrying `Origin: https://<host>` was refused as cross-origin under a CDN that forwards plain HTTP. The gate now trusts the forwarded `x-forwarded-proto` and matches against an explicit public-origin allowlist — `SHOP_PUBLIC_ORIGINS` (comma-separated), defaulting to the canonical origin — so sign-in and the account and admin forms submit correctly. The double-submit token cookie also regains its `__Host-` prefix over the forwarded HTTPS connection.
package/lib/admin.js CHANGED
@@ -32,6 +32,7 @@
32
32
 
33
33
  var pricing = require("./pricing");
34
34
  var collectionsModule = require("./collections");
35
+ var quantityDiscountsModule = require("./quantity-discounts");
35
36
  var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
36
37
 
37
38
  var b = require("./vendor/blamejs");
@@ -437,6 +438,7 @@ function mount(router, deps) {
437
438
  var pickLists = deps.pickLists || null; // warehouse pick-list console disabled when absent
438
439
  var shippingLabels = deps.shippingLabels || null; // per-shipment carrier-label record disabled when absent
439
440
  var splitShipments = deps.splitShipments || null; // order split-shipment planner disabled when absent
441
+ var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
440
442
 
441
443
  // Which optional console sections are wired — gates their nav links so a
442
444
  // signed-in admin is never sent to a route that wasn't mounted. Passed
@@ -444,7 +446,7 @@ function mount(router, deps) {
444
446
  // `reports` is always present in the nav (read-only sales summary needs no
445
447
  // extra dep); its route mounts unconditionally and renders an unconfigured
446
448
  // notice when the salesReports primitive isn't wired.
447
- 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, pickLists: !!pickLists };
449
+ 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 };
448
450
 
449
451
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
450
452
 
@@ -4581,6 +4583,252 @@ function mount(router, deps) {
4581
4583
  ));
4582
4584
  }
4583
4585
 
4586
+ // ---- sales tax filings ----------------------------------------------
4587
+ //
4588
+ // Post-checkout remittance bookkeeping. Each row aggregates completed
4589
+ // orders that fell inside a filing window for one jurisdiction, then
4590
+ // walks the lifecycle the authority expects: draft → computed (the
4591
+ // aggregation snapshot) → submitted (filed) → paid (remittance cleared),
4592
+ // with an amend path for a correction. The console never touches cart /
4593
+ // checkout / order pricing — it reads orders the storefront already
4594
+ // wrote. A missing sales_tax_filings table only surfaces when a route
4595
+ // reads it (degrades to a notice), never at boot.
4596
+ if (salesTaxFilings) {
4597
+ // Translate a create form / JSON body into a defineFilingPeriod input.
4598
+ // The four window fields are epoch-ms; strict integer parsing so a
4599
+ // typo is a 400, never a silent zero.
4600
+ function _filingPeriodInput(body) {
4601
+ return {
4602
+ jurisdiction: typeof body.jurisdiction === "string" ? body.jurisdiction.trim().toUpperCase() : body.jurisdiction,
4603
+ kind: typeof body.kind === "string" ? body.kind.trim() : body.kind,
4604
+ period_start: _strictMinorInt(body.period_start, "salesTaxFilings", "period_start (epoch-ms)"),
4605
+ period_end: _strictMinorInt(body.period_end, "salesTaxFilings", "period_end (epoch-ms)"),
4606
+ due_date: _strictMinorInt(body.due_date, "salesTaxFilings", "due_date (epoch-ms)"),
4607
+ };
4608
+ }
4609
+
4610
+ // Build the list-screen model: the filtered filings, the upcoming-due
4611
+ // strip, and the filter values echoed back into the form. A malformed
4612
+ // filter (bad jurisdiction / status) throws TypeError in the primitive
4613
+ // — map it to an empty list so the page renders the notice, never 500.
4614
+ async function _filingsForBrowser(filter) {
4615
+ try { return await salesTaxFilings.listFilings(filter); }
4616
+ catch (e) { if (e instanceof TypeError) return []; throw e; }
4617
+ }
4618
+ async function _upcomingForBrowser() {
4619
+ try { return await salesTaxFilings.upcomingDue({ days_ahead: 30 }); }
4620
+ catch (e) { if (e instanceof TypeError) return []; throw e; }
4621
+ }
4622
+
4623
+ router.get("/admin/tax-filings", _pageOrApi(true,
4624
+ R(async function (req, res) {
4625
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4626
+ var filter = {};
4627
+ var j = url && url.searchParams.get("jurisdiction");
4628
+ var s = url && url.searchParams.get("status");
4629
+ if (j) filter.jurisdiction = String(j).trim().toUpperCase();
4630
+ if (s) filter.status = String(s).trim();
4631
+ var rows;
4632
+ try { rows = await salesTaxFilings.listFilings(filter); }
4633
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4634
+ _json(res, 200, { rows: rows });
4635
+ }),
4636
+ async function (req, res) {
4637
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4638
+ var j = url && url.searchParams.get("jurisdiction");
4639
+ var s = url && url.searchParams.get("status");
4640
+ var jurisdiction = j ? String(j).trim().toUpperCase() : null;
4641
+ var status = s ? String(s).trim() : null;
4642
+ var filter = {};
4643
+ if (jurisdiction) filter.jurisdiction = jurisdiction;
4644
+ if (status) filter.status = status;
4645
+ var rows = await _filingsForBrowser(filter);
4646
+ var upcoming = await _upcomingForBrowser();
4647
+ _sendHtml(res, 200, renderAdminTaxFilings({
4648
+ shop_name: deps.shop_name, nav_available: navAvailable,
4649
+ filings: rows, upcoming: upcoming,
4650
+ jurisdiction: jurisdiction, status_filter: status,
4651
+ kinds: salesTaxFilings.KINDS, statuses: salesTaxFilings.STATUSES,
4652
+ created: url && url.searchParams.get("created"),
4653
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the filing." : null,
4654
+ }));
4655
+ },
4656
+ ));
4657
+
4658
+ // Open a filing period → draft row. A duplicate (jurisdiction, kind,
4659
+ // period_start) is refused by the primitive's UNIQUE index; surface it
4660
+ // as a 400 notice rather than a 500.
4661
+ router.post("/admin/tax-filings", _pageOrApi(false,
4662
+ W("tax_filing.create", async function (req, res) {
4663
+ var filing;
4664
+ try { filing = await salesTaxFilings.defineFilingPeriod(_filingPeriodInput(req.body || {})); }
4665
+ catch (e) {
4666
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4667
+ if (e.code === "SALES_TAX_FILING_DUPLICATE") return _problem(res, 409, "filing-duplicate", e.message);
4668
+ throw e;
4669
+ }
4670
+ _json(res, 201, filing);
4671
+ return { id: filing.id };
4672
+ }),
4673
+ async function (req, res) {
4674
+ var filing;
4675
+ try { filing = await salesTaxFilings.defineFilingPeriod(_filingPeriodInput(req.body || {})); }
4676
+ catch (e) {
4677
+ if (!(e instanceof TypeError) && e.code !== "SALES_TAX_FILING_DUPLICATE") throw e;
4678
+ var rows = await _filingsForBrowser({});
4679
+ var upcoming = await _upcomingForBrowser();
4680
+ return _sendHtml(res, 400, renderAdminTaxFilings({
4681
+ shop_name: deps.shop_name, nav_available: navAvailable,
4682
+ filings: rows, upcoming: upcoming,
4683
+ kinds: salesTaxFilings.KINDS, statuses: salesTaxFilings.STATUSES,
4684
+ notice: (e && e.message || "").replace(/^salesTaxFilings[.:]\s*/, ""),
4685
+ }));
4686
+ }
4687
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_filing.create", outcome: "success", metadata: { id: filing.id } });
4688
+ _redirect(res, "/admin/tax-filings/" + encodeURIComponent(filing.id) + "?created=1");
4689
+ },
4690
+ ));
4691
+
4692
+ // Per-jurisdiction remittance report over a [from, to] window. GET so
4693
+ // the window lives in the URL (bookmarkable). A bad / missing range is
4694
+ // a notice on the list page, not a 500.
4695
+ router.get("/admin/tax-filings/report", _pageOrApi(true,
4696
+ R(async function (req, res) {
4697
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4698
+ var j = url && url.searchParams.get("jurisdiction");
4699
+ if (!j) return _problem(res, 400, "bad-request", "jurisdiction required");
4700
+ var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
4701
+ var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
4702
+ if (from == null || to == null) return _problem(res, 400, "bad-request", "from and to (epoch-ms) required");
4703
+ var report;
4704
+ try { report = await salesTaxFilings.auditReportForJurisdiction({ jurisdiction: String(j).trim().toUpperCase(), from: from, to: to }); }
4705
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4706
+ _json(res, 200, report);
4707
+ }),
4708
+ async function (req, res) {
4709
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4710
+ var j = url && url.searchParams.get("jurisdiction");
4711
+ var jurisdiction = j ? String(j).trim().toUpperCase() : null;
4712
+ // Parse the window defensively — a present-but-malformed epoch throws
4713
+ // TypeError; treat it as unset so the window notice guides the operator
4714
+ // (a bad range is a notice on this page, never a 500).
4715
+ var from = null, to = null;
4716
+ try {
4717
+ from = _parseEpochMs(url && url.searchParams.get("from"), "from");
4718
+ to = _parseEpochMs(url && url.searchParams.get("to"), "to");
4719
+ } catch (e) { if (!(e instanceof TypeError)) throw e; }
4720
+ var report = null, notice = null;
4721
+ if (!jurisdiction) {
4722
+ notice = "Enter a jurisdiction and a window to run a remittance report.";
4723
+ } else if (from == null || to == null) {
4724
+ notice = "Enter both a from and a to date (epoch-ms) for the report window.";
4725
+ } else {
4726
+ try { report = await salesTaxFilings.auditReportForJurisdiction({ jurisdiction: jurisdiction, from: from, to: to }); }
4727
+ catch (e) { if (!(e instanceof TypeError)) throw e; notice = (e.message || "").replace(/^salesTaxFilings[.:]\s*/, ""); }
4728
+ }
4729
+ _sendHtml(res, 200, renderAdminTaxFilingReport({
4730
+ shop_name: deps.shop_name, nav_available: navAvailable,
4731
+ report: report, jurisdiction: jurisdiction, notice: notice,
4732
+ }));
4733
+ },
4734
+ ));
4735
+
4736
+ async function _filingDetailModel(id) {
4737
+ return await salesTaxFilings.getFiling(id);
4738
+ }
4739
+
4740
+ router.get("/admin/tax-filings/:id", _pageOrApi(true,
4741
+ R(async function (req, res) {
4742
+ var filing;
4743
+ try { filing = await _filingDetailModel(req.params.id); }
4744
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "filing-not-found", e.message); throw e; }
4745
+ if (!filing) return _problem(res, 404, "filing-not-found");
4746
+ _json(res, 200, filing);
4747
+ }),
4748
+ async function (req, res) {
4749
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4750
+ var filing;
4751
+ try { filing = await _filingDetailModel(req.params.id); }
4752
+ catch (e) { if (!(e instanceof TypeError)) throw e; filing = null; }
4753
+ if (!filing) return _sendHtml(res, 404, renderAdminTaxFilings({
4754
+ shop_name: deps.shop_name, nav_available: navAvailable,
4755
+ filings: [], upcoming: [], kinds: salesTaxFilings.KINDS, statuses: salesTaxFilings.STATUSES,
4756
+ notice: "Filing not found.",
4757
+ }));
4758
+ _sendHtml(res, 200, renderAdminTaxFiling({
4759
+ shop_name: deps.shop_name, nav_available: navAvailable, filing: filing,
4760
+ computed: url && url.searchParams.get("computed"),
4761
+ created: url && url.searchParams.get("created"),
4762
+ submitted: url && url.searchParams.get("submitted"),
4763
+ paid: url && url.searchParams.get("paid"),
4764
+ amended: url && url.searchParams.get("amended"),
4765
+ notice: (url && url.searchParams.get("err"))
4766
+ ? String(url.searchParams.get("err_msg") || "That action couldn't be completed for the filing.")
4767
+ : null,
4768
+ }));
4769
+ },
4770
+ ));
4771
+
4772
+ // Lifecycle transitions — each is a browser POST that PRGs back to the
4773
+ // detail. A bad transition (wrong status) or bad input is a ?err notice
4774
+ // on the detail page, never a 500. The bearer JSON contract returns the
4775
+ // updated filing (or a problem document).
4776
+ function _filingActionRoute(suffix, audit, okParam, run) {
4777
+ router.post("/admin/tax-filings/:id/" + suffix, _pageOrApi(false,
4778
+ W("tax_filing." + audit, async function (req, res) {
4779
+ var filing;
4780
+ try { filing = await run(req.params.id, req.body || {}); }
4781
+ catch (e) {
4782
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4783
+ if (e.code === "SALES_TAX_FILING_NOT_FOUND") return _problem(res, 404, "filing-not-found", e.message);
4784
+ if (e.code === "SALES_TAX_FILING_BAD_TRANSITION") return _problem(res, 409, "filing-bad-transition", e.message);
4785
+ throw e;
4786
+ }
4787
+ _json(res, 200, filing);
4788
+ return { id: filing.id };
4789
+ }),
4790
+ async function (req, res) {
4791
+ var enc = encodeURIComponent(req.params.id);
4792
+ try {
4793
+ await run(req.params.id, req.body || {});
4794
+ } catch (e) {
4795
+ if (!(e instanceof TypeError) &&
4796
+ e.code !== "SALES_TAX_FILING_NOT_FOUND" &&
4797
+ e.code !== "SALES_TAX_FILING_BAD_TRANSITION") throw e;
4798
+ var msg = (e && e.message || "").replace(/^salesTaxFilings[.:]\s*/, "");
4799
+ return _redirect(res, "/admin/tax-filings/" + enc + "?err=1&err_msg=" + encodeURIComponent(msg));
4800
+ }
4801
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_filing." + audit, outcome: "success", metadata: { id: req.params.id } });
4802
+ _redirect(res, "/admin/tax-filings/" + enc + "?" + okParam + "=1");
4803
+ },
4804
+ ));
4805
+ }
4806
+
4807
+ _filingActionRoute("compute", "compute", "computed", function (id) {
4808
+ return salesTaxFilings.computeFiling({ filing_id: id });
4809
+ });
4810
+ _filingActionRoute("submit", "submit", "submitted", function (id, body) {
4811
+ return salesTaxFilings.recordSubmission({
4812
+ filing_id: id,
4813
+ submission_ref: typeof body.submission_ref === "string" ? body.submission_ref.trim() : body.submission_ref,
4814
+ submitted_by: typeof body.submitted_by === "string" ? body.submitted_by.trim() : body.submitted_by,
4815
+ });
4816
+ });
4817
+ _filingActionRoute("pay", "pay", "paid", function (id, body) {
4818
+ return salesTaxFilings.recordPayment({
4819
+ filing_id: id,
4820
+ payment_minor: _strictMinorInt(body.payment_minor, "salesTaxFilings", "payment_minor"),
4821
+ payment_ref: typeof body.payment_ref === "string" ? body.payment_ref.trim() : body.payment_ref,
4822
+ });
4823
+ });
4824
+ _filingActionRoute("amend", "amend", "amended", function (id, body) {
4825
+ return salesTaxFilings.markAmended({
4826
+ filing_id: id,
4827
+ reason: typeof body.reason === "string" ? body.reason.trim() : body.reason,
4828
+ });
4829
+ });
4830
+ }
4831
+
4584
4832
  // Read a jurisdiction's rate history for the browser list. A null /
4585
4833
  // unset jurisdiction yields an empty list (the page shows the picker
4586
4834
  // form). A malformed jurisdiction throws TypeError in the primitive —
@@ -4965,6 +5213,254 @@ function mount(router, deps) {
4965
5213
  }
4966
5214
  }
4967
5215
 
5216
+ // ---- quantity discounts ---------------------------------------------
5217
+ // Tier-set CRUD for automatic per-line quantity breaks ("buy 5, save
5218
+ // 10%"). The pricing engine already applies these at PDP / cart /
5219
+ // checkout; this console manages the schedules. Content-negotiated like
5220
+ // the other screens: bearer → the JSON contract; signed-in browser →
5221
+ // the HTML table + create form. The detail screen renders the schedule,
5222
+ // a rewrite form, archive/unarchive, and a sample tierBreakdown preview.
5223
+ if (deps.quantityDiscounts) {
5224
+ var quantityDiscounts = deps.quantityDiscounts;
5225
+
5226
+ // The engine has no get(id) — fetch a single set by walking the
5227
+ // both-states list (operators have tens of sets, not thousands).
5228
+ async function _qdSetById(id) {
5229
+ var rows = await quantityDiscounts.list({ archived: null, limit: 200 });
5230
+ for (var i = 0; i < rows.length; i += 1) {
5231
+ if (rows[i].id === id) return rows[i];
5232
+ }
5233
+ return null;
5234
+ }
5235
+
5236
+ // Map the ?archived= query to a list filter: 1/true → archived-only,
5237
+ // 0/false → active-only, absent → both. The list view defaults to
5238
+ // showing everything so a freshly-archived set stays visible.
5239
+ function _qdFilter(archivedS) {
5240
+ if (archivedS === "1" || archivedS === "true") return { archived: true };
5241
+ if (archivedS === "0" || archivedS === "false") return { archived: false };
5242
+ return { archived: null };
5243
+ }
5244
+
5245
+ // Parse the create / edit form's flat tier fields into the engine's
5246
+ // tiers array. The form posts parallel arrays tier_min[] /
5247
+ // tier_kind[] / tier_value[]; a row with every field blank is
5248
+ // dropped (the spare append row), a row with any field set is kept
5249
+ // and validated by the engine. min_quantity + value go through the
5250
+ // strict integer reader so "", floats, and "12abc" are refused as a
5251
+ // 4xx rather than coerced.
5252
+ function _qdAsArray(v) {
5253
+ if (v == null) return [];
5254
+ return Array.isArray(v) ? v : [v];
5255
+ }
5256
+ function _qdTiersFromForm(body) {
5257
+ var mins = _qdAsArray(body.tier_min);
5258
+ var kinds = _qdAsArray(body.tier_kind);
5259
+ var values = _qdAsArray(body.tier_value);
5260
+ var n = Math.max(mins.length, kinds.length, values.length);
5261
+ var tiers = [];
5262
+ for (var i = 0; i < n; i += 1) {
5263
+ var minRaw = mins[i] == null ? "" : String(mins[i]).trim();
5264
+ var kindRaw = kinds[i] == null ? "" : String(kinds[i]).trim();
5265
+ var valueRaw = values[i] == null ? "" : String(values[i]).trim();
5266
+ if (minRaw === "" && kindRaw === "" && valueRaw === "") continue; // spare blank row
5267
+ tiers.push({
5268
+ min_quantity: _strictMinorInt(minRaw, "quantityDiscounts", "min_quantity"),
5269
+ discount_kind: kindRaw,
5270
+ value: _strictMinorInt(valueRaw, "quantityDiscounts", "value"),
5271
+ });
5272
+ }
5273
+ return tiers;
5274
+ }
5275
+
5276
+ // Translate the create form / JSON body into a defineTier input. A
5277
+ // body already carrying a tiers array (bearer JSON client) passes
5278
+ // through untouched; the browser form is flattened first.
5279
+ function _qdDefineInput(body) {
5280
+ if (Array.isArray(body.tiers)) return body;
5281
+ var scope = typeof body.scope === "string" ? body.scope.trim() : body.scope;
5282
+ var input = {
5283
+ scope: scope,
5284
+ exclusive: (body.exclusive === "on" || body.exclusive === "1" || body.exclusive === true),
5285
+ tiers: _qdTiersFromForm(body),
5286
+ };
5287
+ // scope_id is null exactly for global; the engine enforces the
5288
+ // pairing. Trim + omit for global so a stray blank field doesn't
5289
+ // trip the "must be null when global" check.
5290
+ if (scope !== "global") {
5291
+ input.scope_id = typeof body.scope_id === "string" ? body.scope_id.trim() : body.scope_id;
5292
+ }
5293
+ return input;
5294
+ }
5295
+
5296
+ function _qdCleanMessage(e) {
5297
+ return (e && e.message || "Couldn't save that tier set.").replace(/^quantityDiscounts[.:]\s*/, "");
5298
+ }
5299
+
5300
+ async function _renderQdList(flags) {
5301
+ flags = flags || {};
5302
+ var rows = await quantityDiscounts.list(_qdFilter(flags.archived_filter));
5303
+ return renderAdminQuantityDiscounts(Object.assign({
5304
+ shop_name: deps.shop_name, nav_available: navAvailable,
5305
+ scopes: quantityDiscountsModule.VALID_SCOPES, kinds: quantityDiscountsModule.VALID_KINDS,
5306
+ tier_sets: rows,
5307
+ }, flags));
5308
+ }
5309
+
5310
+ router.get("/admin/quantity-discounts", _pageOrApi(true,
5311
+ R(async function (req, res) {
5312
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5313
+ var archivedS = url && url.searchParams.get("archived");
5314
+ _json(res, 200, { rows: await quantityDiscounts.list(_qdFilter(archivedS)) });
5315
+ }),
5316
+ async function (req, res) {
5317
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5318
+ var archivedS = url && url.searchParams.get("archived");
5319
+ _sendHtml(res, 200, await _renderQdList({
5320
+ archived_filter: archivedS,
5321
+ created: url && url.searchParams.get("created"),
5322
+ saved: url && url.searchParams.get("saved"),
5323
+ archived: url && url.searchParams.get("archived_ok"),
5324
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the tier set." : null,
5325
+ }));
5326
+ },
5327
+ ));
5328
+
5329
+ router.post("/admin/quantity-discounts", _pageOrApi(false,
5330
+ W("quantity_discount.create", async function (req, res) {
5331
+ var set = await quantityDiscounts.defineTier(_qdDefineInput(req.body || {}));
5332
+ _json(res, 201, set);
5333
+ return { id: set.id };
5334
+ }),
5335
+ async function (req, res) {
5336
+ try {
5337
+ await quantityDiscounts.defineTier(_qdDefineInput(req.body || {}));
5338
+ } catch (e) {
5339
+ if (!(e instanceof TypeError)) throw e;
5340
+ return _sendHtml(res, 400, await _renderQdList({ notice: _qdCleanMessage(e) }));
5341
+ }
5342
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.create", outcome: "success" });
5343
+ _redirect(res, "/admin/quantity-discounts?created=1");
5344
+ },
5345
+ ));
5346
+
5347
+ // Detail: the tier set + its schedule, a rewrite form, archive /
5348
+ // unarchive, and a sample-price tierBreakdown preview. A bad / unknown
5349
+ // id is a 404 page, never a 500.
5350
+ async function _qdDetailModel(id, sampleMinor) {
5351
+ var set = await _qdSetById(id);
5352
+ if (!set) return null;
5353
+ var breakdown = null;
5354
+ try {
5355
+ breakdown = await quantityDiscounts.tierBreakdown({
5356
+ scope: set.scope,
5357
+ scope_id: set.scope_id,
5358
+ sample_unit_price_minor: (sampleMinor == null ? 1000 : sampleMinor),
5359
+ });
5360
+ } catch (_e) { breakdown = null; }
5361
+ return { set: set, breakdown: breakdown, sample_minor: (sampleMinor == null ? 1000 : sampleMinor) };
5362
+ }
5363
+
5364
+ router.get("/admin/quantity-discounts/:id", _pageOrApi(true,
5365
+ R(async function (req, res) {
5366
+ var set = await _qdSetById(req.params.id);
5367
+ if (!set) return _problem(res, 404, "quantity-discount-not-found");
5368
+ _json(res, 200, set);
5369
+ }),
5370
+ async function (req, res) {
5371
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5372
+ var sampleS = url && url.searchParams.get("sample");
5373
+ var sampleMinor = null;
5374
+ if (sampleS != null && /^\d+$/.test(sampleS)) sampleMinor = Number(sampleS);
5375
+ var model = await _qdDetailModel(req.params.id, sampleMinor);
5376
+ if (!model) return _sendHtml(res, 404, renderAdminQuantityDiscount({
5377
+ shop_name: deps.shop_name, nav_available: navAvailable, tier_set: null,
5378
+ }));
5379
+ _sendHtml(res, 200, renderAdminQuantityDiscount({
5380
+ shop_name: deps.shop_name, nav_available: navAvailable,
5381
+ kinds: quantityDiscountsModule.VALID_KINDS,
5382
+ tier_set: model.set, breakdown: model.breakdown, sample_minor: model.sample_minor,
5383
+ saved: url && url.searchParams.get("saved"),
5384
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
5385
+ }));
5386
+ },
5387
+ ));
5388
+
5389
+ router.post("/admin/quantity-discounts/:id/edit", _pageOrApi(false,
5390
+ W("quantity_discount.update", async function (req, res) {
5391
+ var set;
5392
+ try { set = await quantityDiscounts.update(req.params.id, req.body || {}); }
5393
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5394
+ if (!set) return _problem(res, 404, "quantity-discount-not-found");
5395
+ _json(res, 200, set);
5396
+ return { id: set.id };
5397
+ }),
5398
+ async function (req, res) {
5399
+ var id = req.params.id;
5400
+ var body = req.body || {};
5401
+ var enc = encodeURIComponent(id);
5402
+ try {
5403
+ var patch = {};
5404
+ // exclusive_present marks the checkbox was rendered, so an
5405
+ // unchecked box reads as false rather than "leave unchanged".
5406
+ if (body.exclusive_present === "1") {
5407
+ patch.exclusive = (body.exclusive === "on" || body.exclusive === "1");
5408
+ }
5409
+ var tiers = _qdTiersFromForm(body);
5410
+ if (tiers.length > 0) patch.tiers = tiers;
5411
+ if (Object.keys(patch).length === 0) return _redirect(res, "/admin/quantity-discounts/" + enc + "?err=1");
5412
+ var set = await quantityDiscounts.update(id, patch);
5413
+ if (!set) return _redirect(res, "/admin/quantity-discounts/" + enc + "?err=1");
5414
+ } catch (e) {
5415
+ if (!(e instanceof TypeError)) throw e;
5416
+ return _redirect(res, "/admin/quantity-discounts/" + enc + "?err=1");
5417
+ }
5418
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.update", outcome: "success", metadata: { id: id } });
5419
+ _redirect(res, "/admin/quantity-discounts/" + enc + "?saved=1");
5420
+ },
5421
+ ));
5422
+
5423
+ router.post("/admin/quantity-discounts/:id/archive", _pageOrApi(false,
5424
+ W("quantity_discount.archive", async function (req, res) {
5425
+ var ok;
5426
+ try { ok = await quantityDiscounts.archive(req.params.id); }
5427
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5428
+ if (!ok) return _problem(res, 404, "quantity-discount-not-found");
5429
+ _json(res, 200, { ok: true });
5430
+ return { id: req.params.id };
5431
+ }),
5432
+ async function (req, res) {
5433
+ var id = req.params.id;
5434
+ var ok = false;
5435
+ try { ok = await quantityDiscounts.archive(id); }
5436
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
5437
+ if (!ok) return _redirect(res, "/admin/quantity-discounts?err=1");
5438
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.archive", outcome: "success", metadata: { id: id } });
5439
+ _redirect(res, "/admin/quantity-discounts?archived_ok=1");
5440
+ },
5441
+ ));
5442
+
5443
+ router.post("/admin/quantity-discounts/:id/unarchive", _pageOrApi(false,
5444
+ W("quantity_discount.unarchive", async function (req, res) {
5445
+ var ok;
5446
+ try { ok = await quantityDiscounts.unarchive(req.params.id); }
5447
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5448
+ if (!ok) return _problem(res, 404, "quantity-discount-not-found");
5449
+ _json(res, 200, { ok: true });
5450
+ return { id: req.params.id };
5451
+ }),
5452
+ async function (req, res) {
5453
+ var id = req.params.id;
5454
+ var ok = false;
5455
+ try { ok = await quantityDiscounts.unarchive(id); }
5456
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
5457
+ if (!ok) return _redirect(res, "/admin/quantity-discounts?err=1");
5458
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.unarchive", outcome: "success", metadata: { id: id } });
5459
+ _redirect(res, "/admin/quantity-discounts?saved=1");
5460
+ },
5461
+ ));
5462
+ }
5463
+
4968
5464
  // Render the discounts screen — gathers rules + (optional) stacking
4969
5465
  // policies, then hands them to the renderer with whatever banner flags
4970
5466
  // the caller passed.
@@ -5394,7 +5890,9 @@ var ADMIN_NAV_ITEMS = [
5394
5890
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
5395
5891
  { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
5396
5892
  { key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
5893
+ { key: "quantity-discounts", href: "/admin/quantity-discounts", label: "Quantity breaks", requires: "quantityDiscounts" },
5397
5894
  { key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
5895
+ { key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
5398
5896
  { key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
5399
5897
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
5400
5898
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
@@ -6916,6 +7414,280 @@ function renderAdminTaxRates(opts) {
6916
7414
  return _renderAdminShell(opts.shop_name, "Tax rates", body, "tax", opts.nav_available);
6917
7415
  }
6918
7416
 
7417
+ // Money for the filings screens. A filing aggregates orders that may span
7418
+ // currencies, so the row stores raw minor-unit totals with no single
7419
+ // currency column. Display them through the same pricing formatter the rest
7420
+ // of the console uses, in the operator's display currency (defaults to USD)
7421
+ // — the number is the reconciliation figure regardless of the symbol.
7422
+ var TAX_FILING_DISPLAY_CURRENCY = "USD";
7423
+ function _filingMoney(minor) {
7424
+ return pricing.format(Number(minor) || 0, TAX_FILING_DISPLAY_CURRENCY);
7425
+ }
7426
+
7427
+ // Sales-tax-filings list screen — open filings (filterable by jurisdiction
7428
+ // + status), an upcoming-due strip, and the open-a-period form. Read-heavy:
7429
+ // the lifecycle actions live on each filing's detail page.
7430
+ function renderAdminTaxFilings(opts) {
7431
+ opts = opts || {};
7432
+ var filings = opts.filings || [];
7433
+ var upcoming = opts.upcoming || [];
7434
+ var kinds = opts.kinds || ["monthly", "quarterly", "annual"];
7435
+ var statuses = opts.statuses || ["draft", "computed", "submitted", "paid", "amended"];
7436
+ var jurisdiction = opts.jurisdiction || "";
7437
+ var statusFilter = opts.status_filter || "";
7438
+ var created = opts.created ? "<div class=\"banner banner--ok\">Filing period opened.</div>" : "";
7439
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
7440
+
7441
+ // Status pill class reuse: map filing status onto the order pill palette so
7442
+ // the visual language matches the rest of the console.
7443
+ function _statusPill(s) {
7444
+ var cls = s === "paid" ? "paid"
7445
+ : s === "submitted" ? "shipped"
7446
+ : s === "computed" ? "fulfilling"
7447
+ : s === "amended" ? "cancelled"
7448
+ : "pending";
7449
+ return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(s) + "</span>";
7450
+ }
7451
+
7452
+ // Filter form — jurisdiction text + status select. GET so the filter
7453
+ // lives in the URL.
7454
+ var statusOpts = [{ value: "", label: "All statuses" }].concat(statuses.map(function (s) { return { value: s, label: s }; }));
7455
+ var filterForm =
7456
+ "<form method=\"get\" action=\"/admin/tax-filings\" class=\"order-filters\">" +
7457
+ "<label class=\"form-field\"><span>Jurisdiction</span>" +
7458
+ "<input type=\"text\" name=\"jurisdiction\" value=\"" + _htmlEscape(jurisdiction) + "\" placeholder=\"US or US-CA\" maxlength=\"6\" class=\"input-code\"></label>" +
7459
+ _selectField("Status", "status", statusOpts, statusFilter, "", "") +
7460
+ "<button class=\"btn\" type=\"submit\">Filter</button>" +
7461
+ "<a class=\"btn btn--ghost\" href=\"/admin/tax-filings\">Clear</a>" +
7462
+ "</form>";
7463
+
7464
+ // Due/overdue strip — open filings due within the next 30 days OR already
7465
+ // past their due date (upcomingDue filters due_date <= now+30d, no lower bound).
7466
+ var dueRows = upcoming.map(function (f) {
7467
+ return "<tr>" +
7468
+ "<td><a class=\"order-id\" href=\"/admin/tax-filings/" + _htmlEscape(f.id) + "\">" + _htmlEscape(f.jurisdiction) + "</a></td>" +
7469
+ "<td>" + _htmlEscape(f.kind) + "</td>" +
7470
+ "<td>" + _statusPill(f.status) + "</td>" +
7471
+ "<td>" + _htmlEscape(_fmtDate(f.due_date)) + "</td>" +
7472
+ "</tr>";
7473
+ }).join("");
7474
+ var dueBlock = upcoming.length
7475
+ ? "<section><h2>Due soon or overdue</h2><div class=\"panel\">" +
7476
+ "<table><thead><tr><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Period</th><th scope=\"col\">Status</th><th scope=\"col\">Due</th></tr></thead><tbody>" + dueRows + "</tbody></table>" +
7477
+ "</div></section>"
7478
+ : "";
7479
+
7480
+ // Filings table.
7481
+ var rows = filings.map(function (f) {
7482
+ return "<tr>" +
7483
+ "<td><a class=\"order-id\" href=\"/admin/tax-filings/" + _htmlEscape(f.id) + "\">" + _htmlEscape(f.jurisdiction) + "</a></td>" +
7484
+ "<td>" + _htmlEscape(f.kind) + "</td>" +
7485
+ "<td>" + _htmlEscape(_fmtDate(f.period_start)) + " → " + _htmlEscape(_fmtDate(f.period_end)) + "</td>" +
7486
+ "<td>" + _htmlEscape(_fmtDate(f.due_date)) + "</td>" +
7487
+ "<td>" + _statusPill(f.status) + "</td>" +
7488
+ "<td class=\"num\">" + _htmlEscape(_filingMoney(f.tax_collected_minor)) + "</td>" +
7489
+ "<td class=\"num\">" + _htmlEscape(_filingMoney(f.tax_owed_minor)) + "</td>" +
7490
+ "</tr>";
7491
+ }).join("");
7492
+ var table = filings.length
7493
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Kind</th><th scope=\"col\">Period</th><th scope=\"col\">Due</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Collected</th><th scope=\"col\" class=\"num\">Owed</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
7494
+ : "<p class=\"empty\">No filings" + (jurisdiction ? " for " + _htmlEscape(jurisdiction) : "") + " yet. Open a period below.</p>";
7495
+
7496
+ // Open-a-period form.
7497
+ var kindOpts = kinds.map(function (k) { return { value: k, label: k }; });
7498
+ var createForm =
7499
+ "<div class=\"panel mt mw-34\">" +
7500
+ "<h3 class=\"subhead\">Open a filing period</h3>" +
7501
+ "<form method=\"post\" action=\"/admin/tax-filings\">" +
7502
+ _setupField("Jurisdiction", "jurisdiction", "", "text", "ISO 3166-1 country, optionally -subdivision (e.g. US, US-CA, DE-BY).", " maxlength=\"6\" class=\"input-code\" required") +
7503
+ _selectField("Kind", "kind", kindOpts, "quarterly", "Filing cadence the authority expects.", " required") +
7504
+ _setupField("Period start (epoch-ms)", "period_start", "", "number", "First instant of the filing window.", " min=\"0\" required") +
7505
+ _setupField("Period end (epoch-ms)", "period_end", "", "number", "Exclusive end of the window (must be after start).", " min=\"0\" required") +
7506
+ _setupField("Due date (epoch-ms)", "due_date", "", "number", "When the filing is due (must be on or after the period end).", " min=\"0\" required") +
7507
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Open period</button></div>" +
7508
+ "</form>" +
7509
+ "</div>";
7510
+
7511
+ // Report shortcut.
7512
+ var reportForm =
7513
+ "<div class=\"panel mt mw-34\">" +
7514
+ "<h3 class=\"subhead\">Remittance report</h3>" +
7515
+ "<form method=\"get\" action=\"/admin/tax-filings/report\">" +
7516
+ _setupField("Jurisdiction", "jurisdiction", jurisdiction, "text", "Roll up every filing whose period intersects the window.", " maxlength=\"6\" class=\"input-code\" required") +
7517
+ _setupField("From (epoch-ms)", "from", "", "number", "Window start.", " min=\"0\" required") +
7518
+ _setupField("To (epoch-ms)", "to", "", "number", "Window end.", " min=\"0\" required") +
7519
+ "<div class=\"actions-row\"><button class=\"btn btn--ghost\" type=\"submit\">Run report</button></div>" +
7520
+ "</form>" +
7521
+ "</div>";
7522
+
7523
+ var body =
7524
+ "<section><h2>Sales tax filings</h2>" + created + notice + filterForm + table + "</section>" +
7525
+ dueBlock +
7526
+ "<section><div class=\"two-col\">" + createForm + reportForm + "</div></section>";
7527
+ return _renderAdminShell(opts.shop_name, "Tax filings", body, "tax-filings", opts.nav_available);
7528
+ }
7529
+
7530
+ // Sales-tax-filing detail — the snapshot totals, the per-rate breakdown, the
7531
+ // audit trail, and the lifecycle action forms gated on the filing's status.
7532
+ function renderAdminTaxFiling(opts) {
7533
+ opts = opts || {};
7534
+ var f = opts.filing;
7535
+ var banners =
7536
+ (opts.created ? "<div class=\"banner banner--ok\">Filing period opened.</div>" : "") +
7537
+ (opts.computed ? "<div class=\"banner banner--ok\">Snapshot computed.</div>" : "") +
7538
+ (opts.submitted ? "<div class=\"banner banner--ok\">Submission recorded.</div>" : "") +
7539
+ (opts.paid ? "<div class=\"banner banner--ok\">Payment recorded.</div>" : "") +
7540
+ (opts.amended ? "<div class=\"banner banner--ok\">Filing amended.</div>" : "") +
7541
+ (opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "");
7542
+ if (!f) {
7543
+ return _renderAdminShell(opts.shop_name, "Filing", "<section><h2>Filing</h2>" + banners + "<p class=\"empty\">Filing not found.</p></section>", "tax-filings", opts.nav_available);
7544
+ }
7545
+ var enc = encodeURIComponent(f.id);
7546
+
7547
+ var meta =
7548
+ "<section><h2>" + _htmlEscape(f.jurisdiction) + " · " + _htmlEscape(f.kind) + "</h2>" + banners +
7549
+ "<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(f.period_start)) + " → " + _htmlEscape(_fmtDate(f.period_end)) +
7550
+ " · Due " + _htmlEscape(_fmtDate(f.due_date)) +
7551
+ " · Status <span class=\"status-pill " + _htmlEscape(f.status) + "\">" + _htmlEscape(f.status) + "</span></p>" +
7552
+ "</section>";
7553
+
7554
+ var stats =
7555
+ "<section><h2>Snapshot</h2><div class=\"stat-grid\">" +
7556
+ _statCard("Gross revenue", _filingMoney(f.gross_revenue_minor)) +
7557
+ _statCard("Taxable revenue", _filingMoney(f.taxable_revenue_minor)) +
7558
+ _statCard("Exempt revenue", _filingMoney(f.exempt_revenue_minor)) +
7559
+ _statCard("Tax collected", _filingMoney(f.tax_collected_minor)) +
7560
+ _statCard("Tax owed", _filingMoney(f.tax_owed_minor), true) +
7561
+ "</div>" +
7562
+ (f.computed_at == null ? "<p class=\"meta\">Not computed yet — run the snapshot to aggregate orders in the window.</p>"
7563
+ : "<p class=\"meta\">Computed " + _htmlEscape(_fmtDate(f.computed_at)) + ".</p>") +
7564
+ "</section>";
7565
+
7566
+ // Per-rate breakdown table — one row per rate bucket the snapshot produced.
7567
+ var breakdown = f.by_rate_breakdown || {};
7568
+ var bkeys = Object.keys(breakdown);
7569
+ var breakdownRows = bkeys.map(function (k) {
7570
+ var bkt = breakdown[k] || {};
7571
+ var label = k === "__none__" ? "no rate matched"
7572
+ : k === "__exempt__" ? "exempt"
7573
+ : k === "__unknown__" ? "unknown rate"
7574
+ : _fmtBps(k);
7575
+ return "<tr>" +
7576
+ "<td>" + _htmlEscape(label) + (k === "__none__" || k === "__exempt__" || k === "__unknown__" ? "" : " <span class=\"meta\">(" + _htmlEscape(String(k)) + " bps)</span>") + "</td>" +
7577
+ "<td class=\"num\">" + _htmlEscape(String(bkt.order_count == null ? 0 : bkt.order_count)) + "</td>" +
7578
+ "<td class=\"num\">" + _htmlEscape(_filingMoney(bkt.taxable_minor)) + "</td>" +
7579
+ "<td class=\"num\">" + _htmlEscape(_filingMoney(bkt.tax_minor)) + "</td>" +
7580
+ "</tr>";
7581
+ }).join("");
7582
+ var breakdownBlock = bkeys.length
7583
+ ? "<section><h2>By rate</h2><div class=\"panel\"><table><thead><tr><th scope=\"col\">Rate</th><th scope=\"col\" class=\"num\">Orders</th><th scope=\"col\" class=\"num\">Taxable</th><th scope=\"col\" class=\"num\">Tax</th></tr></thead><tbody>" + breakdownRows + "</tbody></table></div></section>"
7584
+ : "";
7585
+
7586
+ // Audit trail — submission + payment + amendment columns once recorded.
7587
+ var trail = "";
7588
+ if (f.submission_ref || f.payment_ref || f.amended_reason) {
7589
+ var trailRows = "";
7590
+ if (f.submission_ref) {
7591
+ trailRows += "<tr><td>Submitted</td><td>" + _htmlEscape(f.submission_ref) +
7592
+ (f.submitted_by ? " <span class=\"meta\">by " + _htmlEscape(f.submitted_by) + "</span>" : "") +
7593
+ (f.submitted_at != null ? " <span class=\"meta\">" + _htmlEscape(_fmtDate(f.submitted_at)) + "</span>" : "") + "</td></tr>";
7594
+ }
7595
+ if (f.payment_ref || f.payment_minor != null) {
7596
+ trailRows += "<tr><td>Paid</td><td>" + _htmlEscape(_filingMoney(f.payment_minor)) +
7597
+ (f.payment_ref ? " <span class=\"meta\">ref " + _htmlEscape(f.payment_ref) + "</span>" : "") +
7598
+ (f.paid_at != null ? " <span class=\"meta\">" + _htmlEscape(_fmtDate(f.paid_at)) + "</span>" : "") + "</td></tr>";
7599
+ }
7600
+ if (f.amended_reason) {
7601
+ trailRows += "<tr><td>Amended</td><td>" + _htmlEscape(f.amended_reason) +
7602
+ (f.amended_at != null ? " <span class=\"meta\">" + _htmlEscape(_fmtDate(f.amended_at)) + "</span>" : "") + "</td></tr>";
7603
+ }
7604
+ trail = "<section><h2>Audit trail</h2><div class=\"panel\"><table><tbody>" + trailRows + "</tbody></table></div></section>";
7605
+ }
7606
+
7607
+ // Lifecycle actions — gated on the FSM. draft → compute; computed →
7608
+ // submit or amend; submitted → pay or amend; paid → amend.
7609
+ var actions = "";
7610
+ if (f.status === "draft" || f.status === "computed") {
7611
+ actions +=
7612
+ "<form method=\"post\" action=\"/admin/tax-filings/" + enc + "/compute\" class=\"form-inline\">" +
7613
+ "<button class=\"btn\" type=\"submit\">" + (f.status === "computed" ? "Recompute snapshot" : "Compute snapshot") + "</button>" +
7614
+ "</form>";
7615
+ }
7616
+ if (f.status === "computed") {
7617
+ actions +=
7618
+ "<form method=\"post\" action=\"/admin/tax-filings/" + enc + "/submit\" class=\"stack\">" +
7619
+ _setupField("Submission reference", "submission_ref", "", "text", "The authority's confirmation number (e.g. DR-123-456).", " maxlength=\"200\" required") +
7620
+ _setupField("Submitted by", "submitted_by", "", "text", "Who filed it.", " maxlength=\"200\" required") +
7621
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Record submission</button></div>" +
7622
+ "</form>";
7623
+ }
7624
+ if (f.status === "submitted") {
7625
+ actions +=
7626
+ "<form method=\"post\" action=\"/admin/tax-filings/" + enc + "/pay\" class=\"stack\">" +
7627
+ _setupField("Payment (minor units)", "payment_minor", "", "number", "Remitted amount in minor units — may be a partial / installment payment.", " min=\"0\" required") +
7628
+ _setupField("Payment reference", "payment_ref", "", "text", "The authority's payment confirmation.", " maxlength=\"200\" required") +
7629
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Record payment</button></div>" +
7630
+ "</form>";
7631
+ }
7632
+ if (f.status === "computed" || f.status === "submitted" || f.status === "paid") {
7633
+ actions +=
7634
+ "<form method=\"post\" action=\"/admin/tax-filings/" + enc + "/amend\" class=\"stack\">" +
7635
+ _setupField("Amendment reason", "reason", "", "text", "Why this filing is being corrected. The snapshot stays for the audit trail.", " maxlength=\"1000\" required") +
7636
+ "<div class=\"actions-row\"><button class=\"btn btn--danger\" type=\"submit\">Mark amended</button></div>" +
7637
+ "</form>";
7638
+ }
7639
+ var actionsBlock = actions
7640
+ ? "<section><h2>Actions</h2><div class=\"panel\">" + actions + "</div></section>"
7641
+ : "<section><h2>Actions</h2><p class=\"meta\">This filing is in a terminal state; re-open the period after amending to file again.</p></section>";
7642
+
7643
+ var back = "<p class=\"mt\"><a class=\"btn btn--ghost\" href=\"/admin/tax-filings\">Back to filings</a></p>";
7644
+ return _renderAdminShell(opts.shop_name, "Filing", meta + stats + breakdownBlock + trail + actionsBlock + back, "tax-filings", opts.nav_available);
7645
+ }
7646
+
7647
+ // Per-jurisdiction remittance report — the totals across every filing whose
7648
+ // period intersects the window. Read-only.
7649
+ function renderAdminTaxFilingReport(opts) {
7650
+ opts = opts || {};
7651
+ var report = opts.report;
7652
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
7653
+ if (!report) {
7654
+ return _renderAdminShell(opts.shop_name, "Remittance report",
7655
+ "<section><h2>Remittance report</h2>" + notice +
7656
+ "<p class=\"mt\"><a class=\"btn btn--ghost\" href=\"/admin/tax-filings\">Back to filings</a></p></section>",
7657
+ "tax-filings", opts.nav_available);
7658
+ }
7659
+ var stats =
7660
+ "<div class=\"stat-grid\">" +
7661
+ _statCard("Filings", String(report.filing_count)) +
7662
+ _statCard("Gross revenue", _filingMoney(report.total_gross_revenue_minor)) +
7663
+ _statCard("Taxable revenue", _filingMoney(report.total_taxable_revenue_minor)) +
7664
+ _statCard("Exempt revenue", _filingMoney(report.total_exempt_revenue_minor)) +
7665
+ _statCard("Tax collected", _filingMoney(report.total_tax_collected_minor)) +
7666
+ _statCard("Tax owed", _filingMoney(report.total_tax_owed_minor), true) +
7667
+ _statCard("Tax paid", _filingMoney(report.total_tax_paid_minor)) +
7668
+ "</div>";
7669
+ var rows = (report.filings || []).map(function (f) {
7670
+ return "<tr>" +
7671
+ "<td><a class=\"order-id\" href=\"/admin/tax-filings/" + _htmlEscape(f.id) + "\">" + _htmlEscape(f.kind) + "</a></td>" +
7672
+ "<td>" + _htmlEscape(_fmtDate(f.period_start)) + " → " + _htmlEscape(_fmtDate(f.period_end)) + "</td>" +
7673
+ "<td><span class=\"status-pill " + _htmlEscape(f.status) + "\">" + _htmlEscape(f.status) + "</span></td>" +
7674
+ "<td class=\"num\">" + _htmlEscape(_filingMoney(f.tax_collected_minor)) + "</td>" +
7675
+ "<td class=\"num\">" + _htmlEscape(_filingMoney(f.tax_owed_minor)) + "</td>" +
7676
+ "</tr>";
7677
+ }).join("");
7678
+ var table = (report.filings || []).length
7679
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Period</th><th scope=\"col\">Window</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Collected</th><th scope=\"col\" class=\"num\">Owed</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
7680
+ : "<p class=\"empty\">No filings intersect this window.</p>";
7681
+
7682
+ var body =
7683
+ "<section><h2>Remittance report · " + _htmlEscape(report.jurisdiction) + "</h2>" + notice +
7684
+ "<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(report.from)) + " → " + _htmlEscape(_fmtDate(report.to)) + "</p>" +
7685
+ stats + table +
7686
+ "<p class=\"mt\"><a class=\"btn btn--ghost\" href=\"/admin/tax-filings\">Back to filings</a></p>" +
7687
+ "</section>";
7688
+ return _renderAdminShell(opts.shop_name, "Remittance report", body, "tax-filings", opts.nav_available);
7689
+ }
7690
+
6919
7691
  function renderAdminShipping(opts) {
6920
7692
  opts = opts || {};
6921
7693
  var rows = opts.zones || [];
@@ -7920,6 +8692,210 @@ function renderAdminCollection(opts) {
7920
8692
  return _renderAdminShell(opts.shop_name, "Collection " + col.slug, body, "collections", opts.nav_available);
7921
8693
  }
7922
8694
 
8695
+ // One labelled <option> list for the discount_kind dropdown, with the
8696
+ // kind whose value is a fixed money amount annotated so the operator
8697
+ // knows whether `value` is basis points or minor units.
8698
+ function _qdKindLabel(kind) {
8699
+ if (kind === "percent_off") return "percent_off (value = basis points; 1000 = 10%)";
8700
+ if (kind === "amount_off_each") return "amount_off_each (value = minor units off each unit)";
8701
+ if (kind === "amount_off_total") return "amount_off_total (value = minor units off the line)";
8702
+ if (kind === "fixed_each_price") return "fixed_each_price (value = new unit price in minor units)";
8703
+ return kind;
8704
+ }
8705
+
8706
+ function _qdKindOptions(kinds, selected) {
8707
+ return (kinds || []).map(function (k) {
8708
+ return "<option value=\"" + _htmlEscape(k) + "\"" + (k === selected ? " selected" : "") + ">" +
8709
+ _htmlEscape(_qdKindLabel(k)) + "</option>";
8710
+ }).join("");
8711
+ }
8712
+
8713
+ // A single editable tier row — min_quantity + discount_kind + value.
8714
+ // `tier` may be a stored row (edit) or {} for a spare append row.
8715
+ function _qdTierRow(kinds, tier) {
8716
+ tier = tier || {};
8717
+ var minVal = tier.min_quantity == null ? "" : String(tier.min_quantity);
8718
+ var valVal = tier.value == null ? "" : String(tier.value);
8719
+ return "<div class=\"actions-row m-04\">" +
8720
+ "<input type=\"number\" name=\"tier_min\" value=\"" + _htmlEscape(minVal) + "\" placeholder=\"min qty\" min=\"1\" step=\"1\" class=\"input-code\">" +
8721
+ "<select name=\"tier_kind\"><option value=\"\">kind…</option>" + _qdKindOptions(kinds, tier.discount_kind) + "</select>" +
8722
+ "<input type=\"number\" name=\"tier_value\" value=\"" + _htmlEscape(valVal) + "\" placeholder=\"value\" min=\"0\" step=\"1\" class=\"input-code\">" +
8723
+ "</div>";
8724
+ }
8725
+
8726
+ // Quantity-discount tier-set list — a table of tier sets (scope,
8727
+ // scope_id, #tiers, exclusive, status) plus a create form. The create
8728
+ // form's scope dropdown + each tier row's kind dropdown use the engine's
8729
+ // exact enums so a console-built set always validates. Mirrors
8730
+ // renderAdminCollections.
8731
+ function renderAdminQuantityDiscounts(opts) {
8732
+ opts = opts || {};
8733
+ var rows = opts.tier_sets || [];
8734
+ var scopes = opts.scopes || quantityDiscountsModule.VALID_SCOPES;
8735
+ var kinds = opts.kinds || quantityDiscountsModule.VALID_KINDS;
8736
+ var created = opts.created ? "<div class=\"banner banner--ok\">Tier set created.</div>" : "";
8737
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
8738
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Tier set archived.</div>" : "";
8739
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
8740
+
8741
+ var af = opts.archived_filter;
8742
+ var chips = "<div class=\"order-filters\">" +
8743
+ "<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/quantity-discounts\">All</a>" +
8744
+ "<a class=\"chip" + (af === "0" ? " chip--on" : "") + "\" href=\"/admin/quantity-discounts?archived=0\">Active</a>" +
8745
+ "<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/quantity-discounts?archived=1\">Archived</a>" +
8746
+ "</div>";
8747
+
8748
+ var bodyRows = rows.map(function (s) {
8749
+ var isArchived = s.archived_at != null;
8750
+ var enc = encodeURIComponent(s.id);
8751
+ return "<tr>" +
8752
+ "<td><a href=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(s.scope) + "</strong></a></td>" +
8753
+ "<td><code class=\"order-id\">" + _htmlEscape(s.scope_id == null ? "—" : s.scope_id) + "</code></td>" +
8754
+ "<td class=\"num\">" + _htmlEscape(String((s.tiers || []).length)) + "</td>" +
8755
+ "<td><span class=\"status-pill " + (s.exclusive ? "pending" : "paid") + "\">" + (s.exclusive ? "exclusive" : "stacks") + "</span></td>" +
8756
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
8757
+ "<td><div class=\"actions-row\">" +
8758
+ "<a class=\"btn btn--ghost\" href=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "\">Manage</a>" +
8759
+ (isArchived
8760
+ ? "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
8761
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
8762
+ : "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
8763
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
8764
+ "</div></td>" +
8765
+ "</tr>";
8766
+ }).join("");
8767
+
8768
+ var table = rows.length
8769
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Scope</th><th scope=\"col\">Scope id</th><th scope=\"col\" class=\"num\">Tiers</th><th scope=\"col\">Stacking</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
8770
+ : "<p class=\"empty\">No quantity breaks" + (af === "1" ? " archived" : af === "0" ? " active" : " yet") + ".</p>";
8771
+
8772
+ var scopeOpts = scopes.map(function (sc) {
8773
+ return "<option value=\"" + _htmlEscape(sc) + "\"" + (sc === "sku" ? " selected" : "") + ">" + _htmlEscape(sc) + "</option>";
8774
+ }).join("");
8775
+
8776
+ // Three starter tier rows on create — enough for a typical "5 / 10 /
8777
+ // 20" schedule without a separate add-row round-trip. Blank rows are
8778
+ // dropped server-side; the operator fills as many as they need.
8779
+ var starterRows = _qdTierRow(kinds, {}) + _qdTierRow(kinds, {}) + _qdTierRow(kinds, {});
8780
+
8781
+ var createForm =
8782
+ "<div class=\"panel mt mw-40\">" +
8783
+ "<h3 class=\"subhead\">Create a tier set</h3>" +
8784
+ "<p class=\"meta\">A quantity break attaches to a scope (one SKU, product, collection, vendor, category, or everything) and a schedule of (min quantity, kind, value) rules. The pricing engine applies the best matching rule per cart line automatically.</p>" +
8785
+ "<form method=\"post\" action=\"/admin/quantity-discounts\">" +
8786
+ "<label class=\"form-field\"><span>Scope</span><select name=\"scope\">" + scopeOpts + "</select></label>" +
8787
+ _setupField("Scope id", "scope_id", "", "text", "The SKU / product id / collection slug / vendor / category. Leave blank only when scope = global.", " maxlength=\"256\"") +
8788
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"exclusive\" value=\"on\"> Exclusive — when this set's best rule applies, no other tier set may stack on the same line.</label>" +
8789
+ "<fieldset class=\"box\">" +
8790
+ "<legend class=\"legend-sm\">Tiers (min quantity → discount)</legend>" +
8791
+ starterRows +
8792
+ "<small class=\"u-mute\">Leave a row blank to drop it. min quantity is a positive integer; value's meaning depends on the kind (percent_off = basis points, the amount/fixed kinds = minor units). Duplicate min quantities in one set are refused.</small>" +
8793
+ "</fieldset>" +
8794
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create tier set</button></div>" +
8795
+ "</form>" +
8796
+ "</div>";
8797
+
8798
+ var bodyHtml = "<section><h2>Quantity breaks</h2>" + created + saved + archived + notice + chips + table + createForm + "</section>";
8799
+ return _renderAdminShell(opts.shop_name, "Quantity breaks", bodyHtml, "quantity-discounts", opts.nav_available);
8800
+ }
8801
+
8802
+ // Quantity-discount tier-set detail — the set's scope + schedule, a
8803
+ // rewrite form (rewrite the tier rows / toggle exclusive), archive or
8804
+ // unarchive, and a sample-price tierBreakdown preview. Mirrors
8805
+ // renderAdminCollection.
8806
+ function renderAdminQuantityDiscount(opts) {
8807
+ opts = opts || {};
8808
+ var s = opts.tier_set;
8809
+ if (!s) {
8810
+ var nf = "<section><h2>Quantity break</h2><p class=\"empty\">Tier set not found.</p>" +
8811
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/quantity-discounts\">Back to quantity breaks</a></div></section>";
8812
+ return _renderAdminShell(opts.shop_name, "Quantity break", nf, "quantity-discounts", opts.nav_available);
8813
+ }
8814
+ var kinds = opts.kinds || quantityDiscountsModule.VALID_KINDS;
8815
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
8816
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
8817
+ var enc = encodeURIComponent(s.id);
8818
+ var isArchived = s.archived_at != null;
8819
+
8820
+ // Render the stored tiers plus one spare empty row so the operator can
8821
+ // append without a separate round-trip. The edit POST rewrites the
8822
+ // whole schedule (the engine's update replaces tiers wholesale).
8823
+ var tierRows = (s.tiers || []).map(function (t) { return _qdTierRow(kinds, t); }).join("") + _qdTierRow(kinds, {});
8824
+
8825
+ var editForm =
8826
+ "<div class=\"panel mw-40\">" +
8827
+ "<h3 class=\"subhead\">Schedule</h3>" +
8828
+ "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/edit\">" +
8829
+ "<input type=\"hidden\" name=\"exclusive_present\" value=\"1\">" +
8830
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"exclusive\" value=\"on\"" + (s.exclusive ? " checked" : "") + "> Exclusive — no other tier set may stack on the same line.</label>" +
8831
+ "<fieldset class=\"box\">" +
8832
+ "<legend class=\"legend-sm\">Tiers (rewrites the whole schedule)</legend>" +
8833
+ tierRows +
8834
+ "<small class=\"u-mute\">Leave a row blank to drop it. Rewriting replaces every rule in this set. Duplicate min quantities are refused.</small>" +
8835
+ "</fieldset>" +
8836
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save schedule</button></div>" +
8837
+ "</form>" +
8838
+ "</div>";
8839
+
8840
+ // tierBreakdown preview — the active schedule at a sample unit price so
8841
+ // the operator can sanity-check the discounted unit / line at each
8842
+ // threshold. The sample defaults to 1000 minor (e.g. $10.00); the
8843
+ // operator can re-sample via the query string.
8844
+ var sampleMinor = opts.sample_minor == null ? 1000 : opts.sample_minor;
8845
+ var breakdown = opts.breakdown;
8846
+ var previewRows = (breakdown && Array.isArray(breakdown.rows) ? breakdown.rows : []).map(function (r) {
8847
+ var du = r.sample_discounted_unit_minor;
8848
+ var ld = r.sample_line_discount_minor;
8849
+ var ls = r.sample_line_subtotal_minor;
8850
+ return "<tr>" +
8851
+ "<td class=\"num\">" + _htmlEscape(String(r.min_quantity)) + "</td>" +
8852
+ "<td>" + _htmlEscape(r.discount_kind) + "</td>" +
8853
+ "<td class=\"num\">" + _htmlEscape(String(r.value)) + "</td>" +
8854
+ "<td class=\"num\">" + _htmlEscape(du == null ? "—" : String(du)) + "</td>" +
8855
+ "<td class=\"num\">" + _htmlEscape(ld == null ? "—" : String(ld)) + "</td>" +
8856
+ "<td class=\"num\">" + _htmlEscape(ls == null ? "—" : String(ls)) + "</td>" +
8857
+ "</tr>";
8858
+ }).join("");
8859
+ var previewTable = previewRows
8860
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\" class=\"num\">Min qty</th><th scope=\"col\">Kind</th><th scope=\"col\" class=\"num\">Value</th><th scope=\"col\" class=\"num\">Unit @ min</th><th scope=\"col\" class=\"num\">Line saved</th><th scope=\"col\" class=\"num\">Line total</th></tr></thead><tbody>" + previewRows + "</tbody></table></div>"
8861
+ : "<p class=\"empty\">No active rules to preview for this scope.</p>";
8862
+ var previewForm =
8863
+ "<form method=\"get\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "\" class=\"actions-row m-04\">" +
8864
+ "<label class=\"form-field\"><span>Sample unit price (minor units)</span>" +
8865
+ "<input type=\"number\" name=\"sample\" value=\"" + _htmlEscape(String(sampleMinor)) + "\" min=\"0\" step=\"1\" class=\"input-code\"></label>" +
8866
+ "<button class=\"btn btn--ghost\" type=\"submit\">Preview</button>" +
8867
+ "</form>";
8868
+ // An archived set is not active, so tierBreakdown (which filters to
8869
+ // active rules at this scope) would preview OTHER sets' rules — or
8870
+ // nothing. Show a clear restore prompt instead of a foreign/empty table.
8871
+ var previewSection = isArchived
8872
+ ? "<section class=\"mt\"><h3 class=\"fs-105\">Schedule preview</h3>" +
8873
+ "<p class=\"empty\">This tier set is archived, so its schedule isn't active. Restore the set to preview it.</p>" +
8874
+ "</section>"
8875
+ : "<section class=\"mt\"><h3 class=\"fs-105\">Schedule preview</h3>" +
8876
+ "<p class=\"meta\">The active rules for this scope at a sample unit price (all values in minor units). Helps sanity-check a schedule before it goes live.</p>" +
8877
+ previewForm + previewTable +
8878
+ "</section>";
8879
+
8880
+ var archiveBlock = isArchived
8881
+ ? "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
8882
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore tier set</button></form>"
8883
+ : "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
8884
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive tier set</button></form>";
8885
+
8886
+ var head =
8887
+ "<p class=\"meta\"><a href=\"/admin/quantity-discounts\">&larr; Quantity breaks</a> · " +
8888
+ "<span class=\"status-pill " + (s.exclusive ? "pending" : "paid") + "\">" + (s.exclusive ? "exclusive" : "stacks") + "</span>" +
8889
+ (isArchived ? " · <span class=\"status-pill cancelled\">archived</span>" : "") +
8890
+ " · <code class=\"order-id\">" + _htmlEscape(s.scope_id == null ? "global" : s.scope_id) + "</code></p>";
8891
+
8892
+ var body = "<section><h2>" + _htmlEscape(s.scope) +
8893
+ (s.scope_id == null ? "" : " · " + _htmlEscape(s.scope_id)) + "</h2>" +
8894
+ saved + notice + head + editForm +
8895
+ "<div class=\"actions-row mt-1\">" + archiveBlock + "</div></section>" + previewSection;
8896
+ return _renderAdminShell(opts.shop_name, "Quantity break", body, "quantity-discounts", opts.nav_available);
8897
+ }
8898
+
7923
8899
  // Product detail / management screen — the console's full editor for a
7924
8900
  // single catalog product: its fields (slug / title / description /
7925
8901
  // status), its variants (create / edit / delete), each variant's price
@@ -8166,5 +9142,7 @@ module.exports = {
8166
9142
  renderAdminShipping: renderAdminShipping,
8167
9143
  renderAdminShippingZone: renderAdminShippingZone,
8168
9144
  renderAdminDiscounts: renderAdminDiscounts,
9145
+ renderAdminQuantityDiscounts: renderAdminQuantityDiscounts,
9146
+ renderAdminQuantityDiscount: renderAdminQuantityDiscount,
8169
9147
  renderAdminConfirm: renderAdminConfirm,
8170
9148
  };
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.2",
2
+ "version": "0.3.4",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {