@blamejs/blamejs-shop 0.4.26 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.27 (2026-06-11) — **Stale quotes expire on schedule, operators can reprice an open quote or convert a verbally-approved one to an order, and customers see the operator's per-line notes and the validity window.** A quote-lifecycle release. Quotes whose validity window has elapsed now transition to expired on a scheduled sweep instead of lingering as open work — the accept-time guard already refused stale prices; now the console's queue and the customer's account list reflect that reality, and the admin list gains an expired filter. Operators can reprice a quote that is awaiting the customer's answer (the customer's existing link immediately shows the new pricing) and can convert a quote the customer approved outside the site — by phone or email — directly to an order, with a required reason recorded in the audit log. The customer-facing quote view now renders the per-line notes the operator wrote alongside each price and shows the validity date in the account list. Upgrade applies one D1 migration. **Added:** *Quotes past their validity window expire on a scheduled sweep* — A scheduled tick transitions responded quotes whose valid-until date has elapsed to expired, in one bounded pass per fire. The transition is race-safe: each row re-checks its status and validity inside a conditional update, so a customer accepting at the same moment wins, a reprice that extends validity rescues the quote, and overlapping ticks never double-transition. The sweep rides the same shared-secret internal endpoint discipline as the other scheduled tasks — secret-checked at the edge and again in the container. The per-pass batch size is tunable with QUOTE_EXPIRY_BATCH (validated at boot). · *Operators can reprice a quote awaiting the customer's answer* — A responded quote that the customer has not yet accepted can be repriced from the console — new per-line pricing, shipping, tax, and validity window through the same validation as the original response, with a version counter recording each revision. The customer's existing quote link keeps working and renders the new pricing; no new email is sent, so the link the customer already holds stays valid. Repricing a quote in any other state is refused with a conflict. · *Operators can convert a verbally-approved quote to an order* — When a customer approves a quote outside the site — by phone or email — the operator can convert it to an order directly from the console. The action requires a written reason, records an audit row with the operator, the reason, and the minted order id, and runs through the same conversion path customer acceptance uses: inventory holds are placed first and released if order creation fails, and the accept-time expiry guard still refuses a quote past its validity window. The action requires order-write permission. **Changed:** *The customer quote view shows per-line notes and the validity window* — Notes the operator writes against individual quote lines now render on the customer's quote page under the line they describe, and the account quote list shows the validity date for open quotes — so the customer sees the full offer, not just the numbers, and knows how long it stands. · *The admin quote list gains an expired filter* — The console's quote list accepts a status filter and ships an Expired view, so quotes the sweep transitioned are reviewable rather than invisible. An unrecognized status value falls back to the default queue.
12
+
11
13
  - v0.4.26 (2026-06-06) — **Guest orders attach to an account on verified sign-in; privacy exports and erasures now cover suggestions, saved-for-later, and store credit; HSTS ships on container responses behind the CDN; and internal cron POSTs are secret-checked at the edge.** A guest-order-claim, privacy-completeness, and edge-hardening release. When a shopper who checked out as a guest later signs in or registers with a verified email that matches the order, those orders now attach to their account and appear in their order history. A subject-access export now includes the customer's suggestion-box submissions, their save-for-later list, and their store-credit ledger, and an erasure anonymizes the suggestions, deletes the saved list, and retains the store-credit ledger under its accounting basis — a domain whose reader isn't wired still shows in the export manifest rather than being silently dropped. Strict-Transport-Security now ships on responses served directly by the container behind the Cloudflare proxy, not only on edge-rendered pages. The worker now verifies the shared secret on internal cron and event POSTs before forwarding them to the container. Upgrade applies one D1 migration. **Added:** *Guest orders reconcile to an account on verified sign-in* — An order placed as a guest carries the buyer's email as a one-way hash. When that buyer later signs in or registers — including through Sign in with Google — and their verified email hashes to the same value, the matching guest orders attach to their account and show up under their order history. Attachment is driven only by verified-email ownership, never by unauthenticated email knowledge, and is idempotent: re-signing-in attaches nothing new, a non-matching email attaches nothing, and the placing-browser cookie and emailed-access-token routes to a guest order keep working unchanged. **Changed:** *Privacy export and erasure cover suggestions, saved-for-later, and store credit* — A full subject-access export now includes three more customer-keyed domains: the customer's suggestion-box submissions (product ideas and complaints, matched on the account id or the hashed email the submission carried), their save-for-later list, and their store-credit balance and ledger history. Erasure anonymizes each suggestion in place — severing both identity keys so the row can no longer be traced to the person while leaving the de-identified roadmap signal — deletes the save-for-later list outright, and retains the store-credit ledger under the same accounting / legal-obligation basis the loyalty ledger and gift cards already use. Each domain reports its effect in the request's completeness manifest, so a domain whose reader isn't wired is visible as absent rather than silently omitted. **Security:** *HSTS ships on container responses behind the proxy* — Strict-Transport-Security is emitted on responses served directly by the application container, not only on pages rendered at the edge. Behind the Cloudflare proxy the container connection is plain HTTP and the real scheme arrives in the forwarded-proto header; the security-headers middleware now trusts that header, so a direct-to-container or edge-render-off response carries the same two-year, includeSubDomains, preload HSTS value the edge sends. Plain-HTTP local and direct connections still omit the header, which user agents ignore over HTTP anyway. · *Internal cron and event POSTs are secret-checked at the edge* — The worker now verifies the shared secret on its internal cron and event POSTs (cart-recovery, stock-alert and wishlist sweeps, the stale-order reap, portal-session expiry, and campaign sends) before forwarding them to the container, refusing a forged public request at the edge instead of relying solely on the container's own check. The check is skipped when the secret isn't configured, so a deployment that hasn't set it still forwards rather than refusing every scheduled task.
12
14
 
13
15
  - v0.4.25 (2026-06-06) — **The admin role gate fails closed, payment-processor calls are host-pinned, the public catalog API stops leaking unpublished products, and privacy requests show their statutory deadline.** A security-hardening release. The staff role matrix now denies an unmapped admin action by default (owner-only) instead of leaving it manager-reachable; outbound Stripe and PayPal calls are pinned to the configured processor host with their idempotency keys validated before they leave; the public catalog API no longer honors a caller-supplied status filter, so draft and archived products can't be enumerated; the privacy-request console surfaces each request's statutory response deadline; and the Apple Pay well-known bot-guard exemption is tightened to an exact-path match. No migrations. **Changed:** *Privacy-request console shows the statutory response deadline* — Each export and erasure request now displays its statutory response deadline — one month under GDPR, 45 days under CCPA, 15 days under LGPD — derived from the request's recorded jurisdiction and timestamp, and flags an open request whose deadline has passed. Requests under no statutory regime show no deadline rather than an invented one. **Security:** *Admin role gate fails closed on unmapped actions* — The operator role matrix is now a declarative registry with role inheritance (owner ⊃ manager ⊃ viewer) validated at boot. A mutating admin action that isn't explicitly mapped to a permission now requires the owner role, instead of falling through to a manager-grantable default — so a newly added admin route can't be reached by a manager or viewer by accident. Existing mapped routes keep their grants. · *Public catalog API no longer leaks unpublished products* — GET /api/catalog/products accepted a caller-supplied ?status= filter and passed it straight through, so an unauthenticated request could list draft or archived products by asking for them. The endpoint now always lists only published products; operators browse other statuses through the authenticated admin catalog screens. · *Payment-processor calls are host-pinned with validated idempotency keys* — Every outbound Stripe and PayPal call is now pinned to the configured processor host (defense-in-depth over the existing private-address and metadata-endpoint blocking), and a malformed or non-HTTPS processor base URL fails at the first call rather than dialing in the clear. The idempotency key each call carries is validated before it leaves, refusing path-traversal, slash, and control-character shapes. · *Apple Pay well-known bot-guard exemption is exact-match* — The bot-guard skip for the Apple Pay domain-association path was matched as a prefix, which would also have exempted any sibling path beneath it. It is now an exact-path match, so only the single static association file skips the guard.
package/README.md CHANGED
@@ -78,7 +78,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
78
78
  | **`lib/product-qa.js`** | Customer questions and operator/customer answers per product, operator-moderated, distinct from the rating-based reviews. A signed-in shopper asks at `/products/:slug/question`; questions land `pending` and surface only after approval. Author identity is the customer id (verified against the customers primitive) or a hash-only email — the raw address is never stored. The product page renders approved questions with their approved answers (seller / customer / system badge, pinned 'top answer' first) in both the edge and container paths. `/admin/questions` is the moderation console: the cross-product queue (`listQuestionsByStatus`), and a per-question detail to approve / reject the question, post the seller answer (`submitAnswer`), approve / reject / pin answers. |
79
79
  | **`lib/wishlist.js`** + **`lib/wishlist-alerts.js`** + **`lib/wishlist-digest.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived) and carries a per-customer opt-in panel for **sale + restock alerts** (price-drop / back-in-stock, event-driven) and the **periodic digest** (the saved-items rollup on a weekly / monthly schedule). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. Both notification paths are off by default and require a configured mailer plus an email-address resolver to actually send (the customer store keeps only an email hash) — see *Optional integrations*. UUID-shape-validated ids, `b.pagination` HMAC cursors; prices rendered through `pricing.format` (locale + zero-decimal-currency correct). |
80
80
  | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
81
- | **`lib/quotes.js`** | Request-for-quote negotiation. A signed-in customer requests a quote from the cart (line quantities + an optional message); the operator responds from the console with per-line pricing and a validity window; the customer reviews and accepts or declines from `/account/quotes` or through a single-use capability link (`/quote/:token` — the token is stored only as a namespaced hash and compared timing-safe). Accepting converts through the normal order path — inventory holds are placed first and rolled back if order creation fails — and every status change replays a `b.fsm` lifecycle (requested → responded → accepted / declined / expired / withdrawn / converted), with expiry enforced at accept time so a stale price is never honored. |
81
+ | **`lib/quotes.js`** | Request-for-quote negotiation. A signed-in customer requests a quote from the cart (line quantities + an optional message); the operator responds from the console with per-line pricing and a validity window; the customer reviews and accepts or declines from `/account/quotes` or through a single-use capability link (`/quote/:token` — the token is stored only as a namespaced hash and compared timing-safe). Accepting converts through the normal order path — inventory holds are placed first and rolled back if order creation fails — and every status change replays a `b.fsm` lifecycle (requested → responded → accepted / declined / expired / withdrawn / converted), with expiry enforced at accept time so a stale price is never honored. A scheduled sweep also transitions quotes past their validity window to `expired` (so the console's expired filter reflects the real lifecycle), operators can reprice an open quote (the customer's existing link shows the new pricing) or convert a verbally-approved one to an order with a recorded reason, and the customer view renders the operator's per-line notes and the validity date. |
82
82
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
83
83
  | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
84
84
  | **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
package/lib/admin.js CHANGED
@@ -396,6 +396,21 @@ function _dollarsToMinor(value, label, currency) {
396
396
  return Number(minor);
397
397
  }
398
398
 
399
+ // Render an integer minor-unit amount back into the major-unit decimal
400
+ // string a money form field expects ("1999" → "19.99"), honouring the
401
+ // currency's exponent the same way _dollarsToMinor does on the way in.
402
+ // Returns "" for a NULL / malformed amount so an unpriced field renders
403
+ // empty rather than crashing the form. The Money toString is
404
+ // "<decimal> <CUR>"; the field wants just the decimal.
405
+ function _minorToMajorInput(minor, currency) {
406
+ if (minor == null) return "";
407
+ var n = Number(minor);
408
+ if (!Number.isSafeInteger(n) || n < 0) return "";
409
+ var cur = typeof currency === "string" && /^[A-Z]{3}$/.test(currency) ? currency : "USD";
410
+ try { return b.money.fromMinorUnits(BigInt(n), cur).toString().split(" ")[0]; }
411
+ catch (_e) { return ""; }
412
+ }
413
+
399
414
  // Strict non-negative integer for a form field (money minor units,
400
415
  // dimensions). Refuses "", floats, and parseInt's loose-prefix "12abc"
401
416
  // → 12 — the /^\d+$/ test is anchored so the whole string must be
@@ -867,7 +882,8 @@ function mount(router, deps) {
867
882
  var inventoryReceive = deps.inventoryReceive || null; // inbound-stock receive console disabled when absent
868
883
  var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
869
884
  var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
870
- var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/withdraw/convert) disabled when absent
885
+ var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/reprice/withdraw/convert) disabled when absent
886
+ var convertQuoteToOrder = deps.convertQuoteToOrder || null; // quote → pending-order converter (server.js composition); the console convert action disabled when absent
871
887
  var emailCampaigns = deps.emailCampaigns || null; // consent-gated broadcast/campaign console disabled when absent
872
888
  var mailingAudiences = deps.mailingAudiences || null; // audience picker for the campaign console (target-an-audience dropdown)
873
889
  // Read-only activity log at /admin/audit. Defaults ON — the framework
@@ -8112,13 +8128,16 @@ function mount(router, deps) {
8112
8128
 
8113
8129
  // ---- quotes (B2B request-for-quote negotiation) ---------------------
8114
8130
  // The operator side of the RFQ lifecycle. The list is the response queue
8115
- // (oldest-waiting requests first) plus a recent-activity view; the detail
8116
- // screen shows the requested lines + the customer message, and for a
8117
- // still-requested quote a per-line pricing form that responds with a
8118
- // priced quote + validity window. Responded/accepted quotes show the
8119
- // quoted totals; an operator can withdraw a quote that hasn't been
8120
- // accepted, or convert an accepted one into a pending order. Content-
8121
- // negotiated like the other consoles (bearer JSON, browser HTML).
8131
+ // (oldest-waiting requests first) plus per-status views (the expired
8132
+ // filter shows what the expiry cron transitioned) and a per-customer
8133
+ // view; the detail screen shows the requested lines + the customer
8134
+ // message, and for a still-requested quote a per-line pricing form
8135
+ // that responds with a priced quote + validity window. A responded quote
8136
+ // can be REPRICED (revised offer, fresh window the customer's existing
8137
+ // link shows the new pricing) or CONVERTED to a pending order against a
8138
+ // recorded out-of-band approval; an operator can withdraw a quote that
8139
+ // hasn't been accepted. Content-negotiated like the other consoles
8140
+ // (bearer → JSON, browser → HTML).
8122
8141
  if (quotes) {
8123
8142
  // Build the respondToQuote line_prices array from the per-line
8124
8143
  // `price_<sku>` dollar fields the detail form posts, converting each to
@@ -8139,28 +8158,46 @@ function mount(router, deps) {
8139
8158
  return out;
8140
8159
  }
8141
8160
 
8161
+ // Status filter for the list — a defensive request-shape reader: an
8162
+ // unknown / absent value falls back to the default response queue
8163
+ // rather than erroring a bookmarked link. `expired` is the one the
8164
+ // chips surface (what the expiry cron transitioned); any lifecycle
8165
+ // status is accepted so tooling can read the others.
8166
+ function _quoteStatusFilter(url) {
8167
+ var s = url && url.searchParams.get("status");
8168
+ return (s && quotes.QUOTE_STATUSES.indexOf(s) !== -1) ? s : null;
8169
+ }
8170
+
8142
8171
  router.get("/admin/quotes", _pageOrApi(true,
8143
8172
  R(async function (req, res) {
8144
8173
  var url = req.url ? new URL(req.url, "http://localhost") : null;
8145
8174
  var cid = url && url.searchParams.get("customer_id");
8175
+ var sf = _quoteStatusFilter(url);
8146
8176
  var rows = cid
8147
8177
  ? await quotes.quotesForCustomer(cid, { limit: 200 })
8148
- : await quotes.pendingResponse({ limit: 200 });
8178
+ : sf
8179
+ ? await quotes.listByStatus({ status: sf, limit: 200 })
8180
+ : await quotes.pendingResponse({ limit: 200 });
8149
8181
  _json(res, 200, { rows: rows });
8150
8182
  }),
8151
8183
  async function (req, res) {
8152
8184
  var url = req.url ? new URL(req.url, "http://localhost") : null;
8153
8185
  var cid = url && url.searchParams.get("customer_id");
8186
+ var sf = _quoteStatusFilter(url);
8154
8187
  var rows = [];
8155
8188
  try {
8156
8189
  rows = cid
8157
8190
  ? await quotes.quotesForCustomer(cid, { limit: 200 })
8158
- : await quotes.pendingResponse({ limit: 200 });
8191
+ : sf
8192
+ ? await quotes.listByStatus({ status: sf, limit: 200 })
8193
+ : await quotes.pendingResponse({ limit: 200 });
8159
8194
  } catch (e) { if (!(e instanceof TypeError)) throw e; }
8160
8195
  _sendHtml(res, 200, renderAdminQuotes({
8161
8196
  shop_name: deps.shop_name, nav_available: navAvailable, quotes: rows,
8162
8197
  customer_filter: cid,
8198
+ status_filter: sf,
8163
8199
  responded: url && url.searchParams.get("responded"),
8200
+ repriced: url && url.searchParams.get("repriced"),
8164
8201
  withdrawn: url && url.searchParams.get("withdrawn"),
8165
8202
  converted: url && url.searchParams.get("converted"),
8166
8203
  notice: (url && url.searchParams.get("err"))
@@ -8189,6 +8226,8 @@ function mount(router, deps) {
8189
8226
  }));
8190
8227
  _sendHtml(res, 200, renderAdminQuoteDetail({
8191
8228
  shop_name: deps.shop_name, nav_available: navAvailable, quote: row,
8229
+ can_convert: !!convertQuoteToOrder,
8230
+ repriced: url && url.searchParams.get("repriced"),
8192
8231
  notice: (url && url.searchParams.get("err"))
8193
8232
  ? "That action couldn't be completed for the quote." : null,
8194
8233
  }));
@@ -8254,6 +8293,190 @@ function mount(router, deps) {
8254
8293
  },
8255
8294
  ));
8256
8295
 
8296
+ // Reprice: revise a still-responded quote the customer hasn't settled —
8297
+ // improved line prices, fresh shipping / tax / validity, an updated
8298
+ // note. Same payload contract as respond (the browser form posts dollar
8299
+ // amounts + validity-in-days; the bearer JSON contract takes minor units
8300
+ // + an absolute valid_until). The FSM's responded -> responded reprice
8301
+ // edge gates it, surfacing the same statuses the other quote actions
8302
+ // use: wrong state → 409, unknown quote → 404, bad shape → 400. The
8303
+ // quote-responded email is NOT re-fired here: the notifier rotates the
8304
+ // customer's view token, and the reprice contract is that the link the
8305
+ // customer already holds keeps working and shows the new pricing.
8306
+ router.post("/admin/quotes/:id/reprice", _pageOrApi(false,
8307
+ W("quote.reprice", async function (req, res) {
8308
+ var row;
8309
+ try { row = await quotes.repriceQuote(Object.assign({}, req.body || {}, { quote_id: req.params.id })); }
8310
+ catch (e) {
8311
+ if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
8312
+ if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
8313
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
8314
+ throw e;
8315
+ }
8316
+ _json(res, 200, row);
8317
+ return { id: row.id };
8318
+ }),
8319
+ async function (req, res) {
8320
+ var id = req.params.id;
8321
+ var enc = encodeURIComponent(id);
8322
+ var body = req.body || {};
8323
+ var current = null;
8324
+ try { current = await quotes.getQuote(id); } catch (_e) { current = null; }
8325
+ if (!current) return _redirect(res, "/admin/quotes?err=1");
8326
+ try {
8327
+ var validDays = _strictNonNegIntField(body.valid_days, "valid_days");
8328
+ if (validDays <= 0) throw new TypeError("admin: valid_days must be at least 1");
8329
+ var quoteCurrency = typeof body.currency === "string" && body.currency
8330
+ ? body.currency.toUpperCase() : (current.currency || "USD");
8331
+ await quotes.repriceQuote({
8332
+ quote_id: id,
8333
+ line_prices: _quoteLinePricesFromForm(body, current.lines, quoteCurrency),
8334
+ shipping_minor: body.shipping == null || body.shipping === "" ? 0 : _dollarsToMinor(body.shipping, "shipping", quoteCurrency),
8335
+ tax_minor: body.tax == null || body.tax === "" ? 0 : _dollarsToMinor(body.tax, "tax", quoteCurrency),
8336
+ valid_until: Date.now() + b.constants.TIME.days(validDays),
8337
+ currency: quoteCurrency,
8338
+ operator_notes: body.operator_notes || null,
8339
+ });
8340
+ } catch (e) {
8341
+ if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
8342
+ var msg = _safeNotice(e, "quote.reprice");
8343
+ var fresh = await quotes.getQuote(id);
8344
+ return _sendHtml(res, msg.status, renderAdminQuoteDetail({
8345
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: fresh,
8346
+ can_convert: !!convertQuoteToOrder,
8347
+ notice: msg.message.replace(/^(quotes|admin)[.:]\s*/, ""),
8348
+ }));
8349
+ }
8350
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.reprice", outcome: "success", metadata: { quote_id: id } });
8351
+ _redirect(res, "/admin/quotes/" + enc + "?repriced=1");
8352
+ },
8353
+ ));
8354
+
8355
+ // Convert to order without the customer's online acceptance — the
8356
+ // verbal-approval case (a phone / email yes the operator is recording).
8357
+ // Requires a reason naming that approval; the reason is captured on the
8358
+ // quote's acceptance attribution AND the chained operator audit log. A
8359
+ // responded quote is first accepted through the same customerAccept verb
8360
+ // the storefront uses — so the accept-time expiry guard still refuses a
8361
+ // stale price — then converted through the shared server.js composition
8362
+ // (inventory holds first; a hold/order failure releases the convert
8363
+ // claim back to accepted for a retry). An already-accepted quote (e.g.
8364
+ // the customer accepted online but conversion wasn't possible then)
8365
+ // skips straight to the conversion.
8366
+ if (convertQuoteToOrder) {
8367
+ // Shared by the bearer-JSON and browser-form branches. Throws coded
8368
+ // errors the branches map to their surfaces.
8369
+ var _operatorConvertQuote = async function (req, id, reasonRaw) {
8370
+ if (typeof reasonRaw !== "string" || !reasonRaw.trim().length) {
8371
+ throw new TypeError("admin: a reason is required to convert a quote on the customer's behalf");
8372
+ }
8373
+ var reason = reasonRaw.trim();
8374
+ if (reason.length > 200) {
8375
+ throw new TypeError("admin: reason must be <= 200 characters");
8376
+ }
8377
+ var current = await quotes.getQuote(id); // TypeError on a malformed id → 400
8378
+ if (!current) {
8379
+ var miss = new Error("quote " + id + " not found");
8380
+ miss.code = "QUOTE_NOT_FOUND";
8381
+ throw miss;
8382
+ }
8383
+ if (current.status === "responded") {
8384
+ await quotes.customerAccept({
8385
+ quote_id: id,
8386
+ accepted_by_customer: "operator-recorded: " + reason,
8387
+ });
8388
+ }
8389
+ // Any other non-accepted state falls through to the converter,
8390
+ // whose own FSM claim refuses it — one error shape per state class.
8391
+ var convertedQuote = await convertQuoteToOrder(id);
8392
+ if (!convertedQuote || !convertedQuote.converted_order_id) {
8393
+ // The only null path after a successful accept: no resolvable
8394
+ // ship-to (the customer has no default shipping address) — or the
8395
+ // quote wasn't accepted (wrong state).
8396
+ var blocked = new Error("the quote could not be converted — it must be accepted and " +
8397
+ "the customer needs a default shipping address on file");
8398
+ blocked.code = "QUOTE_NOT_CONVERTIBLE";
8399
+ throw blocked;
8400
+ }
8401
+ // Chained operator-audit row: WHO converted, WHY, and the order it
8402
+ // minted. Drop-silent — a recording failure must never unwind the
8403
+ // conversion the operator just watched succeed.
8404
+ if (operatorAuditLog && typeof operatorAuditLog.record === "function") {
8405
+ try {
8406
+ await operatorAuditLog.record({
8407
+ actor_type: "operator",
8408
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
8409
+ action: "quote.convert_to_order",
8410
+ resource_kind: "quote",
8411
+ resource_id: id,
8412
+ before: { status: current.status },
8413
+ after: { reason: reason, order_id: convertedQuote.converted_order_id },
8414
+ });
8415
+ } catch (_e) { /* drop-silent */ }
8416
+ }
8417
+ return convertedQuote;
8418
+ };
8419
+
8420
+ router.post("/admin/quotes/:id/convert-to-order", _pageOrApi(false,
8421
+ W("quote.convert", async function (req, res) {
8422
+ var row;
8423
+ try { row = await _operatorConvertQuote(req, req.params.id, req.body && req.body.reason); }
8424
+ catch (e) {
8425
+ if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
8426
+ if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
8427
+ if (e && e.code === "QUOTE_EXPIRED") return _problem(res, 409, "quote-expired", e.message);
8428
+ if (e && e.code === "QUOTE_INSUFFICIENT_STOCK") return _problem(res, 409, "quote-insufficient-stock", e.message);
8429
+ if (e && e.code === "QUOTE_NOT_CONVERTIBLE") return _problem(res, 409, "quote-not-convertible", e.message);
8430
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
8431
+ throw e;
8432
+ }
8433
+ _json(res, 200, row);
8434
+ return { id: row.id };
8435
+ }),
8436
+ async function (req, res) {
8437
+ var id = req.params.id;
8438
+ try {
8439
+ await _operatorConvertQuote(req, id, req.body && req.body.reason);
8440
+ } catch (e) {
8441
+ // Per-code operator-safe banner copy (the coded refusals carry no
8442
+ // secrets, but the banner uses authored copy rather than echoing
8443
+ // the thrown text — same no-leak guarantee as _safeNotice, with
8444
+ // a more actionable message per refusal). Anything un-coded
8445
+ // routes through _safeNotice's classifier.
8446
+ var codedCopy = {
8447
+ QUOTE_TRANSITION_REFUSED: "The quote is no longer in a state that can be converted.",
8448
+ QUOTE_EXPIRED: "The quote's validity window has passed — reprice it before converting.",
8449
+ QUOTE_INSUFFICIENT_STOCK: "Not enough stock is available to fulfil the quote.",
8450
+ QUOTE_NOT_CONVERTIBLE: "The quote couldn't be converted — it must be accepted and the customer needs a default shipping address on file.",
8451
+ QUOTE_NOT_FOUND: "Quote not found.",
8452
+ };
8453
+ var codedNotice = e && e.code ? codedCopy[e.code] : null;
8454
+ if (!(e instanceof TypeError) && !codedNotice) throw e;
8455
+ var status, noticeText;
8456
+ if (codedNotice) {
8457
+ status = e.code === "QUOTE_NOT_FOUND" ? 404 : 409;
8458
+ noticeText = codedNotice;
8459
+ } else {
8460
+ // TypeError (validation) — _safeNotice surfaces its operator-
8461
+ // safe message verbatim and never records it as a 5xx.
8462
+ var msg = _safeNotice(e, "quote.convert");
8463
+ status = msg.status;
8464
+ noticeText = msg.message.replace(/^(quotes|admin)[.:]\s*/, "");
8465
+ }
8466
+ var fresh = null;
8467
+ try { fresh = await quotes.getQuote(id); } catch (_e2) { fresh = null; }
8468
+ return _sendHtml(res, status, renderAdminQuoteDetail({
8469
+ shop_name: deps.shop_name, nav_available: navAvailable, quote: fresh,
8470
+ can_convert: true,
8471
+ notice: noticeText,
8472
+ }));
8473
+ }
8474
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.convert", outcome: "success", metadata: { quote_id: id } });
8475
+ _redirect(res, "/admin/quotes?converted=1");
8476
+ },
8477
+ ));
8478
+ }
8479
+
8257
8480
  // Withdraw: cancel a quote that hasn't been accepted yet (requested or
8258
8481
  // responded). Accepted / terminal quotes refuse — the FSM gate is the
8259
8482
  // single source of truth, surfaced as a 409.
@@ -21667,14 +21890,21 @@ function renderAdminQuotes(opts) {
21667
21890
  opts = opts || {};
21668
21891
  var rows = opts.quotes || [];
21669
21892
  var responded = opts.responded ? "<div class=\"banner banner--ok\">Quote sent to the customer.</div>" : "";
21893
+ var repriced = opts.repriced ? "<div class=\"banner banner--ok\">Quote repriced — the customer's existing link shows the new pricing.</div>" : "";
21670
21894
  var withdrawn = opts.withdrawn ? "<div class=\"banner banner--ok\">Quote withdrawn.</div>" : "";
21671
21895
  var converted = opts.converted ? "<div class=\"banner banner--ok\">Quote converted to an order.</div>" : "";
21672
21896
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
21673
21897
 
21674
21898
  var cf = opts.customer_filter;
21675
- var heading = cf ? "Quotes for this customer" : "Quotes awaiting a response";
21899
+ var sf = opts.status_filter;
21900
+ var heading = cf ? "Quotes for this customer"
21901
+ : sf === "expired" ? "Expired quotes"
21902
+ : sf ? "Quotes — " + sf
21903
+ : "Quotes awaiting a response";
21676
21904
  var chips = "<div class=\"order-filters\">" +
21677
- "<a class=\"chip" + (cf == null ? " chip--on" : "") + "\" href=\"/admin/quotes\">Response queue</a>" +
21905
+ "<a class=\"chip" + (cf == null && sf == null ? " chip--on" : "") + "\" href=\"/admin/quotes\">Response queue</a>" +
21906
+ "<a class=\"chip" + (sf === "expired" ? " chip--on" : "") + "\" href=\"/admin/quotes?status=expired\">Expired</a>" +
21907
+ (sf && sf !== "expired" ? "<a class=\"chip chip--on\" href=\"/admin/quotes?status=" + _htmlEscape(encodeURIComponent(sf)) + "\">" + _htmlEscape(sf) + "</a>" : "") +
21678
21908
  (cf ? "<a class=\"chip chip--on\" href=\"/admin/quotes?customer_id=" + _htmlEscape(encodeURIComponent(cf)) + "\">This customer</a>" : "") +
21679
21909
  "</div>";
21680
21910
 
@@ -21696,9 +21926,11 @@ function renderAdminQuotes(opts) {
21696
21926
 
21697
21927
  var table = rows.length
21698
21928
  ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Quote</th><th scope=\"col\">Customer</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Lines</th><th scope=\"col\" class=\"num\">Total</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
21699
- : "<p class=\"empty\">" + (cf ? "No quotes for this customer." : "No quotes are waiting for a response.") + "</p>";
21929
+ : "<p class=\"empty\">" + (cf ? "No quotes for this customer."
21930
+ : sf ? "No " + _htmlEscape(sf) + " quotes."
21931
+ : "No quotes are waiting for a response.") + "</p>";
21700
21932
 
21701
- var bodyHtml = "<section><h2>Quotes</h2>" + responded + withdrawn + converted + notice +
21933
+ var bodyHtml = "<section><h2>Quotes</h2>" + responded + repriced + withdrawn + converted + notice +
21702
21934
  "<p class=\"meta\">Request-for-quote negotiations. The response queue lists the requests waiting on you, oldest first — open one to price its lines and send the customer a quote.</p>" +
21703
21935
  chips + "<h3 class=\"subhead\">" + _htmlEscape(heading) + "</h3>" + table + "</section>";
21704
21936
  return _renderAdminShell(opts.shop_name, "Quotes", bodyHtml, "quotes", opts.nav_available);
@@ -21713,6 +21945,8 @@ function renderAdminQuoteDetail(opts) {
21713
21945
  return _renderAdminShell(opts.shop_name, "Quote", nf, "quotes", opts.nav_available);
21714
21946
  }
21715
21947
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
21948
+ var repricedBanner = opts.repriced
21949
+ ? "<div class=\"banner banner--ok\">Quote repriced — the customer's existing link shows the new pricing.</div>" : "";
21716
21950
  var enc = _htmlEscape(encodeURIComponent(q.id));
21717
21951
  var currency = q.currency || "USD";
21718
21952
 
@@ -21725,6 +21959,8 @@ function renderAdminQuoteDetail(opts) {
21725
21959
  (q.payment_terms ? "<p class=\"meta\">Payment terms: " + _htmlEscape(q.payment_terms) + "</p>" : "") +
21726
21960
  (q.message ? "<p class=\"meta\">Customer message: <q>" + _htmlEscape(q.message) + "</q></p>" : "") +
21727
21961
  (q.valid_until ? "<p class=\"meta\">Valid until: " + _htmlEscape(new Date(Number(q.valid_until)).toISOString()) + "</p>" : "") +
21962
+ (q.response_version != null && Number(q.response_version) > 1
21963
+ ? "<p class=\"meta\">Pricing revision: " + _htmlEscape(String(q.response_version)) + "</p>" : "") +
21728
21964
  (q.total_minor != null ? "<p class=\"meta\">Quoted total: <strong>" + _htmlEscape(pricing.format(q.total_minor, currency)) + "</strong></p>" : "") +
21729
21965
  (q.converted_order_id ? "<p class=\"meta\">Converted to order: <a href=\"/admin/orders/" + _htmlEscape(encodeURIComponent(q.converted_order_id)) + "\"><code class=\"order-id\">" + _htmlEscape(String(q.converted_order_id).slice(0, 8)) + "</code></a></p>" : "") +
21730
21966
  "</div>";
@@ -21775,6 +22011,57 @@ function renderAdminQuoteDetail(opts) {
21775
22011
  "</div>";
21776
22012
  }
21777
22013
 
22014
+ // Reprice form — only for a still-responded quote (the customer hasn't
22015
+ // settled it). Same fields as the respond form, prefilled with the
22016
+ // current pricing so the operator edits an offer rather than retyping
22017
+ // it. Posting re-runs the full pricing math server-side and bumps the
22018
+ // revision counter; the customer's existing link shows the new pricing.
22019
+ var repriceForm = "";
22020
+ if (q.status === "responded") {
22021
+ var repriceFields = (q.lines || []).map(function (l) {
22022
+ return _setupField(l.sku + " — unit price (" + currency + ")", "price_" + l.sku,
22023
+ _minorToMajorInput(l.unit_price_minor, l.currency || currency), "text",
22024
+ "Quantity " + l.quantity + ".", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\" required");
22025
+ }).join("");
22026
+ repriceForm =
22027
+ "<div class=\"panel mt mw-40\">" +
22028
+ "<h3 class=\"subhead\">Reprice this quote</h3>" +
22029
+ "<p class=\"meta\">Revise the offer before the customer settles it — new unit prices, shipping, tax, and a fresh validity window. The link the customer already has keeps working and shows the new pricing.</p>" +
22030
+ "<form method=\"post\" action=\"/admin/quotes/" + enc + "/reprice\">" +
22031
+ repriceFields +
22032
+ _setupField("Shipping (" + currency + ")", "shipping", _minorToMajorInput(q.shipping_minor, currency), "text", "Optional. Leave blank for free shipping.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
22033
+ _setupField("Tax (" + currency + ")", "tax", _minorToMajorInput(q.tax_minor, currency), "text", "Optional.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
22034
+ _setupField("Valid for (days)", "valid_days", "14", "number", "How many days the customer has to accept the revised quote.", " min=\"1\" max=\"365\" required") +
22035
+ _setupField("Currency", "currency", currency, "text", "ISO-4217, e.g. USD.", " maxlength=\"3\" pattern=\"[A-Za-z]{3}\"") +
22036
+ "<label class=\"form-field\"><span>Note to the customer</span><textarea name=\"operator_notes\" maxlength=\"4000\" rows=\"3\">" + _htmlEscape(q.operator_notes || "") + "</textarea></label>" +
22037
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Send revised quote</button></div>" +
22038
+ "</form>" +
22039
+ "</div>";
22040
+ }
22041
+
22042
+ // Convert to order — the verbal-approval path. Rendered for a responded
22043
+ // quote (the operator records the customer's out-of-band yes) and for an
22044
+ // accepted one (the customer accepted online but conversion wasn't
22045
+ // possible then — e.g. no address yet). Requires a reason; the reason
22046
+ // lands on the acceptance attribution and the operator audit log. Only
22047
+ // rendered when the conversion composition is wired.
22048
+ var convertForm = "";
22049
+ if (opts.can_convert && (q.status === "responded" || q.status === "accepted")) {
22050
+ convertForm =
22051
+ "<div class=\"panel mt mw-40\">" +
22052
+ "<h3 class=\"subhead\">Convert to order</h3>" +
22053
+ "<p class=\"meta\">" + (q.status === "responded"
22054
+ ? "Records the customer's out-of-band approval (a phone or email yes) and lands this quote as a pending order at the quoted prices, reserving the stock. An expired quote refuses — reprice it first."
22055
+ : "The customer accepted this quote; convert it into a pending order at the accepted prices.") + "</p>" +
22056
+ "<form method=\"post\" action=\"/admin/quotes/" + enc + "/convert-to-order\">" +
22057
+ _setupField("Reason / approval record", "reason", "", "text",
22058
+ "Required. Who approved and how — e.g. “verbal approval, J. Doe, phone 2026-06-10”.",
22059
+ " maxlength=\"200\" required") +
22060
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Convert to order</button></div>" +
22061
+ "</form>" +
22062
+ "</div>";
22063
+ }
22064
+
21778
22065
  // Withdraw — available while the quote hasn't been accepted (requested or
21779
22066
  // responded). The FSM refuses it for accepted/terminal quotes; we only
21780
22067
  // render the button when it would succeed.
@@ -21792,7 +22079,7 @@ function renderAdminQuoteDetail(opts) {
21792
22079
  "</div>";
21793
22080
 
21794
22081
  var bodyHtml = "<section><h2>Quote " + _htmlEscape(String(q.id).slice(0, 8)) + "</h2>" +
21795
- notice + summary + linesPanel + respondForm + actions + "</section>";
22082
+ notice + repricedBanner + summary + linesPanel + respondForm + repriceForm + convertForm + actions + "</section>";
21796
22083
  return _renderAdminShell(opts.shop_name, "Quote " + String(q.id).slice(0, 8), bodyHtml, "quotes", opts.nav_available);
21797
22084
  }
21798
22085
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.26",
2
+ "version": "0.4.27",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/quotes.js CHANGED
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * requested -> responded -> accepted -> converted (happy path)
24
24
  * |
25
+ * +-> responded (reprice — operator revises the offer)
25
26
  * +-> rejected (customer says no)
26
27
  * +-> expired (valid_until passed)
27
28
  * +-> cancelled (cancelled before accept)
@@ -34,8 +35,17 @@
34
35
  * expired -> * (terminal)
35
36
  *
36
37
  * `valid_until` is the operator-set expiry timestamp (ms). After
37
- * it elapses, `listExpired({ as_of })` surfaces the row so a
38
- * cron / dunning job can transition it to `expired`.
38
+ * it elapses, `listExpired({ as_of })` surfaces the row and
39
+ * `expireDue({ as_of })` driven by the worker cron's
40
+ * `/_/quote-expiry-tick` — transitions each due row to `expired`
41
+ * through the same atomic status claim every other verb uses, so
42
+ * a concurrent accept / reprice and the sweep can never both win.
43
+ *
44
+ * `response_version` counts the operator's pricing passes:
45
+ * `respondToQuote` stamps 1, each `repriceQuote` on a
46
+ * still-responded quote increments it. The customer's view-token
47
+ * link is NOT rotated by a reprice — the link they already hold
48
+ * resolves the same row and renders the new pricing.
39
49
  *
40
50
  * `total_minor` is the operator-quoted grand total — the sum of
41
51
  * the quote_lines line-totals plus `shipping_minor` + `tax_minor`.
@@ -78,6 +88,10 @@
78
88
  * tax_minor?, valid_until, currency,
79
89
  * operator_notes?, delivery_terms?,
80
90
  * payment_terms? })
91
+ * repriceQuote({ quote_id, line_prices, shipping_minor?,
92
+ * tax_minor?, valid_until, currency,
93
+ * operator_notes?, delivery_terms?,
94
+ * payment_terms? })
81
95
  * customerAccept({ quote_id, accepted_by_customer })
82
96
  * customerReject({ quote_id, reject_reason? })
83
97
  * cancelQuote({ quote_id, cancel_reason })
@@ -85,10 +99,15 @@
85
99
  * getQuote(quote_id)
86
100
  * quotesForCustomer(customer_id, { status?, limit? })
87
101
  * pendingResponse({ limit? })
88
- * listExpired({ as_of })
102
+ * listByStatus({ status, limit? })
103
+ * listExpired({ as_of, limit? })
104
+ * expireDue({ as_of, limit? })
105
+ * markExpired({ quote_id, as_of })
89
106
  *
90
107
  * Storage: `migrations-d1/0102_quotes.sql` — two tables, `quotes`
91
108
  * + `quote_lines`. ON DELETE CASCADE from quote -> lines.
109
+ * `0227_quote_response_version.sql` adds the response_version
110
+ * revision counter.
92
111
  *
93
112
  * @primitive quotes
94
113
  * @related b.fsm, b.uuid, b.guardUuid, shop.cart, shop.order
@@ -152,6 +171,7 @@ var b = require("./vendor/blamejs");
152
171
  // fires it:
153
172
  //
154
173
  // respond requested -> responded (operator prices the quote)
174
+ // reprice responded -> responded (operator revises the offer)
155
175
  // accept responded -> accepted (customer accepts)
156
176
  // reject responded -> rejected (customer declines)
157
177
  // cancel requested|responded -> cancelled (either side pulls it)
@@ -163,6 +183,7 @@ var b = require("./vendor/blamejs");
163
183
  var QUOTE_TRANSITIONS = Object.freeze([
164
184
  { from: "requested", to: "responded", on: "respond" },
165
185
  { from: "requested", to: "cancelled", on: "cancel" },
186
+ { from: "responded", to: "responded", on: "reprice" },
166
187
  { from: "responded", to: "accepted", on: "accept" },
167
188
  { from: "responded", to: "rejected", on: "reject" },
168
189
  { from: "responded", to: "cancelled", on: "cancel" },
@@ -454,6 +475,7 @@ function _hydrateQuote(row) {
454
475
  tax_minor: row.tax_minor == null ? null : Number(row.tax_minor),
455
476
  total_minor: row.total_minor == null ? null : Number(row.total_minor),
456
477
  currency: row.currency == null ? null : row.currency,
478
+ response_version: row.response_version == null ? null : Number(row.response_version),
457
479
  valid_until: row.valid_until == null ? null : Number(row.valid_until),
458
480
  accepted_at: row.accepted_at == null ? null : Number(row.accepted_at),
459
481
  accepted_by_customer: row.accepted_by_customer == null ? null : row.accepted_by_customer,
@@ -590,6 +612,101 @@ function create(opts) {
590
612
  }
591
613
  }
592
614
 
615
+ // Shared body of respondToQuote (event "respond", requested -> responded)
616
+ // and repriceQuote (event "reprice", responded -> responded). Validates the
617
+ // full pricing payload, asks the FSM whether the edge is legal for the
618
+ // row's CURRENT status, recomputes the totals server-side, then claims the
619
+ // transition atomically (`AND status = <the legal from-state>`) so a
620
+ // concurrent accept / reject / cancel / expire sweep and this write can
621
+ // never both commit. respond stamps response_version = 1; reprice
622
+ // increments it (COALESCE'd so a row priced before the column shipped
623
+ // lands on 2). Neither path touches view_token_hash — the customer's
624
+ // existing link keeps working and renders the latest pricing.
625
+ async function _applyQuoteResponse(input, event, verbLabel) {
626
+ if (!input || typeof input !== "object") {
627
+ throw new TypeError("quotes." + verbLabel + ": input object required");
628
+ }
629
+ var quoteId = _id(input.quote_id, "quote_id");
630
+ var currency = _currency(input.currency);
631
+ var shipping = input.shipping_minor == null ? 0 : input.shipping_minor;
632
+ var tax = input.tax_minor == null ? 0 : input.tax_minor;
633
+ _moneyMinor(shipping, "shipping_minor");
634
+ _moneyMinor(tax, "tax_minor");
635
+ var validUntil = _ts(input.valid_until, "valid_until");
636
+ var operatorNotes = _optLongText(input.operator_notes, "operator_notes", MAX_OPERATOR_NOTES_LEN);
637
+ var delivery = _optShortText(input.delivery_terms, "delivery_terms", MAX_TERMS_LEN);
638
+ var payment = _optShortText(input.payment_terms, "payment_terms", MAX_TERMS_LEN);
639
+
640
+ var current = await _getQuoteRaw(quoteId);
641
+ if (!current) {
642
+ var miss = new Error("quotes." + verbLabel + ": quote " + quoteId + " not found");
643
+ miss.code = "QUOTE_NOT_FOUND";
644
+ throw miss;
645
+ }
646
+ _assertTransition(current.status, event, verbLabel);
647
+ // The FSM only declares this event from one source state (respond:
648
+ // requested, reprice: responded); having passed the assert, the row's
649
+ // current status IS that state — it pins the atomic claim below.
650
+ var fromStatus = current.status;
651
+
652
+ var lines = await _getLinesRaw(quoteId);
653
+ var lineSkus = lines.map(function (r) { return r.sku; });
654
+ var pricesBySku = _validateLinePrices(input.line_prices, lineSkus);
655
+
656
+ // valid_until must be strictly in the future. A past expiry on
657
+ // a fresh response / revision is operator error — the quote would
658
+ // be expired the moment it lands.
659
+ var ts = _now();
660
+ if (validUntil <= ts) {
661
+ throw new TypeError("quotes." + verbLabel + ": valid_until must be in the future");
662
+ }
663
+
664
+ // Compute totals from the priced lines + shipping + tax. Math
665
+ // is integer-only; sum is bounded by MAX_LINES *
666
+ // MAX_QUANTITY * MAX_UNIT_PRICE_MINOR which fits Number.
667
+ var subtotal = 0;
668
+ for (var i = 0; i < lines.length; i += 1) {
669
+ var qty = Number(lines[i].quantity);
670
+ var price = pricesBySku[lines[i].sku];
671
+ subtotal += qty * price;
672
+ }
673
+ var total = subtotal + shipping + tax;
674
+ _moneyMinor(total, "total_minor");
675
+
676
+ // Update header + each line. The line update sets unit_price_minor +
677
+ // currency per-line. The header UPDATE claims the transition atomically
678
+ // (`AND status = ?`) so a concurrent settle can't both commit against
679
+ // the same row — for reprice that same clause also means an accept that
680
+ // lands first wins and the revision refuses instead of clobbering the
681
+ // accepted price.
682
+ var versionSql = event === "respond" ? "1" : "COALESCE(response_version, 1) + 1";
683
+ var claim = await query(
684
+ "UPDATE quotes SET status = 'responded', shipping_minor = ?1, " +
685
+ "tax_minor = ?2, total_minor = ?3, currency = ?4, valid_until = ?5, " +
686
+ "operator_notes = ?6, " +
687
+ "response_version = " + versionSql + ", " +
688
+ "delivery_terms = COALESCE(?7, delivery_terms), " +
689
+ "payment_terms = COALESCE(?8, payment_terms), " +
690
+ "updated_at = ?9 WHERE id = ?10 AND status = ?11",
691
+ [shipping, tax, total, currency, validUntil, operatorNotes,
692
+ delivery, payment, ts, quoteId, fromStatus],
693
+ );
694
+ if (Number(claim.rowCount || 0) !== 1) {
695
+ var raced = new Error("quotes." + verbLabel + ": refused — quote " + quoteId +
696
+ " is no longer in the " + fromStatus + " state (settled by a concurrent call)");
697
+ raced.code = "QUOTE_TRANSITION_REFUSED";
698
+ throw raced;
699
+ }
700
+ for (var j = 0; j < lines.length; j += 1) {
701
+ var line = lines[j];
702
+ await query(
703
+ "UPDATE quote_lines SET unit_price_minor = ?1, currency = ?2 WHERE id = ?3",
704
+ [pricesBySku[line.sku], currency, line.id],
705
+ );
706
+ }
707
+ return await _hydrated(quoteId);
708
+ }
709
+
593
710
  return {
594
711
  QUOTE_STATUSES: QUOTE_STATUSES.slice(),
595
712
  TERMINAL_STATUSES: TERMINAL_STATUSES.slice(),
@@ -714,84 +831,25 @@ function create(opts) {
714
831
  // sets shipping + tax + grand total currency, and stamps an
715
832
  // expiry. The total_minor is computed from the priced lines +
716
833
  // shipping_minor + tax_minor; the caller doesn't pass it (and
717
- // can't override it — the math is the contract).
834
+ // can't override it — the math is the contract). Stamps
835
+ // response_version = 1 (the first pricing pass).
718
836
  respondToQuote: async function (input) {
719
- if (!input || typeof input !== "object") {
720
- throw new TypeError("quotes.respondToQuote: input object required");
721
- }
722
- var quoteId = _id(input.quote_id, "quote_id");
723
- var currency = _currency(input.currency);
724
- var shipping = input.shipping_minor == null ? 0 : input.shipping_minor;
725
- var tax = input.tax_minor == null ? 0 : input.tax_minor;
726
- _moneyMinor(shipping, "shipping_minor");
727
- _moneyMinor(tax, "tax_minor");
728
- var validUntil = _ts(input.valid_until, "valid_until");
729
- var operatorNotes = _optLongText(input.operator_notes, "operator_notes", MAX_OPERATOR_NOTES_LEN);
730
- var delivery = _optShortText(input.delivery_terms, "delivery_terms", MAX_TERMS_LEN);
731
- var payment = _optShortText(input.payment_terms, "payment_terms", MAX_TERMS_LEN);
732
-
733
- var current = await _getQuoteRaw(quoteId);
734
- if (!current) {
735
- var miss = new Error("quotes.respondToQuote: quote " + quoteId + " not found");
736
- miss.code = "QUOTE_NOT_FOUND";
737
- throw miss;
738
- }
739
- _assertTransition(current.status, "respond", "respondToQuote");
740
-
741
- var lines = await _getLinesRaw(quoteId);
742
- var lineSkus = lines.map(function (r) { return r.sku; });
743
- var pricesBySku = _validateLinePrices(input.line_prices, lineSkus);
744
-
745
- // valid_until must be strictly in the future. A past expiry on
746
- // a fresh response is operator error — the quote would be
747
- // expired the moment it lands.
748
- var ts = _now();
749
- if (validUntil <= ts) {
750
- throw new TypeError("quotes.respondToQuote: valid_until must be in the future");
751
- }
837
+ return await _applyQuoteResponse(input, "respond", "respondToQuote");
838
+ },
752
839
 
753
- // Compute totals from the priced lines + shipping + tax. Math
754
- // is integer-only; sum is bounded by MAX_LINES *
755
- // MAX_QUANTITY * MAX_UNIT_PRICE_MINOR which fits Number.
756
- var subtotal = 0;
757
- for (var i = 0; i < lines.length; i += 1) {
758
- var qty = Number(lines[i].quantity);
759
- var price = pricesBySku[lines[i].sku];
760
- subtotal += qty * price;
761
- }
762
- var total = subtotal + shipping + tax;
763
- _moneyMinor(total, "total_minor");
764
-
765
- // Update header + each line. The line update sets
766
- // unit_price_minor + currency per-line so a future single-line
767
- // re-quote (out of scope here) has a place to land. The header
768
- // UPDATE claims the requested -> responded transition atomically
769
- // (`AND status = 'requested'`) so a concurrent cancel/respond can't
770
- // both commit against the same row.
771
- var claim = await query(
772
- "UPDATE quotes SET status = 'responded', shipping_minor = ?1, " +
773
- "tax_minor = ?2, total_minor = ?3, currency = ?4, valid_until = ?5, " +
774
- "operator_notes = ?6, " +
775
- "delivery_terms = COALESCE(?7, delivery_terms), " +
776
- "payment_terms = COALESCE(?8, payment_terms), " +
777
- "updated_at = ?9 WHERE id = ?10 AND status = 'requested'",
778
- [shipping, tax, total, currency, validUntil, operatorNotes,
779
- delivery, payment, ts, quoteId],
780
- );
781
- if (Number(claim.rowCount || 0) !== 1) {
782
- var raced = new Error("quotes.respondToQuote: refused — quote " + quoteId +
783
- " is no longer in the requested state (settled by a concurrent call)");
784
- raced.code = "QUOTE_TRANSITION_REFUSED";
785
- throw raced;
786
- }
787
- for (var j = 0; j < lines.length; j += 1) {
788
- var line = lines[j];
789
- await query(
790
- "UPDATE quote_lines SET unit_price_minor = ?1, currency = ?2 WHERE id = ?3",
791
- [pricesBySku[line.sku], currency, line.id],
792
- );
793
- }
794
- return await _hydrated(quoteId);
840
+ // FSM: responded -> responded (the reprice edge). Operator revises
841
+ // a quote the customer hasn't settled yet — improved line prices,
842
+ // fresh shipping / tax / validity window, an updated note. Same
843
+ // payload contract as respondToQuote (every line re-priced; the
844
+ // math is the contract), refused with QUOTE_TRANSITION_REFUSED on
845
+ // any other state so a settled (accepted / declined / expired /
846
+ // withdrawn) quote can never be silently re-opened. Increments
847
+ // response_version so the revision is visible on the row, and
848
+ // deliberately leaves view_token_hash untouched — the link the
849
+ // customer already holds keeps resolving and renders the new
850
+ // pricing.
851
+ repriceQuote: async function (input) {
852
+ return await _applyQuoteResponse(input, "reprice", "repriceQuote");
795
853
  },
796
854
 
797
855
  // FSM: responded -> accepted. Customer accepts the operator's
@@ -1176,12 +1234,39 @@ function create(opts) {
1176
1234
  return out;
1177
1235
  },
1178
1236
 
1237
+ // List quotes in one lifecycle state, newest activity first — the
1238
+ // operator console's status filter (e.g. the expired view that shows
1239
+ // what the cron sweep transitioned). Validated against QUOTE_STATUSES
1240
+ // so a garbage status throws rather than silently matching nothing.
1241
+ listByStatus: async function (listOpts) {
1242
+ if (!listOpts || typeof listOpts !== "object") {
1243
+ throw new TypeError("quotes.listByStatus: input object required");
1244
+ }
1245
+ var status = _status(listOpts.status);
1246
+ var limit = listOpts.limit == null ? DEFAULT_LIMIT : listOpts.limit;
1247
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
1248
+ throw new TypeError("quotes.listByStatus: limit must be 1..." + MAX_LIMIT);
1249
+ }
1250
+ var r = await query(
1251
+ "SELECT * FROM quotes WHERE status = ?1 " +
1252
+ "ORDER BY updated_at DESC, id DESC LIMIT ?2",
1253
+ [status, limit],
1254
+ );
1255
+ var out = [];
1256
+ for (var i = 0; i < r.rows.length; i += 1) {
1257
+ var hydrated = _hydrateQuote(r.rows[i]);
1258
+ hydrated.lines = (await _getLinesRaw(r.rows[i].id)).map(_hydrateLine);
1259
+ out.push(hydrated);
1260
+ }
1261
+ return out;
1262
+ },
1263
+
1179
1264
  // List responded quotes whose `valid_until` has elapsed. A cron
1180
1265
  // job walks the result and either fires `customerAccept` (rare —
1181
1266
  // requires the customer-side timing) or transitions each row to
1182
- // expired via an operator-side step (out of scope here; the
1183
- // operator updates the row directly to expired via
1184
- // `markExpired` when they own the cron).
1267
+ // expired via an operator-side step (`expireDue` is that sweep;
1268
+ // `markExpired` is the single-row flip when the operator owns the
1269
+ // cron).
1185
1270
  listExpired: async function (input) {
1186
1271
  if (!input || typeof input !== "object") {
1187
1272
  throw new TypeError("quotes.listExpired: input object required");
@@ -1206,6 +1291,55 @@ function create(opts) {
1206
1291
  return out;
1207
1292
  },
1208
1293
 
1294
+ // One bounded expiry sweep — the worker cron's `/_/quote-expiry-tick`
1295
+ // drives this once a minute. Scans up to `limit` responded quotes whose
1296
+ // valid_until has elapsed as of `as_of` and flips each to expired
1297
+ // through an atomic conditional UPDATE that re-checks BOTH the status
1298
+ // AND the elapsed expiry, so:
1299
+ // - two overlapping ticks can't double-transition a row (the loser's
1300
+ // UPDATE matches nothing and is counted as skipped, not an error);
1301
+ // - a customer accept / reject / operator cancel that lands between
1302
+ // the scan and the flip wins — the flip refuses;
1303
+ // - a reprice that lands in that window and pushes valid_until back
1304
+ // into the future also wins — the `valid_until <= as_of` re-check
1305
+ // keeps a freshly revised quote alive.
1306
+ // Per-row failures never abort the pass; the summary reports what
1307
+ // happened. Input validation throws (a cron caller with a bad clock /
1308
+ // batch size is a config bug to surface, not to swallow).
1309
+ expireDue: async function (input) {
1310
+ if (!input || typeof input !== "object") {
1311
+ throw new TypeError("quotes.expireDue: input object required");
1312
+ }
1313
+ var asOf = _ts(input.as_of, "as_of");
1314
+ var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
1315
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
1316
+ throw new TypeError("quotes.expireDue: limit must be 1..." + MAX_LIMIT);
1317
+ }
1318
+ // The machine is the single source of truth for the expire edge — the
1319
+ // sweep's `status = 'responded'` claims below encode the same source
1320
+ // state, and this assert keeps them honest if the FSM ever changes.
1321
+ _assertTransition("responded", "expire", "expireDue");
1322
+ var r = await query(
1323
+ "SELECT id FROM quotes WHERE status = 'responded' " +
1324
+ "AND valid_until IS NOT NULL AND valid_until <= ?1 " +
1325
+ "ORDER BY valid_until ASC, id ASC LIMIT ?2",
1326
+ [asOf, limit],
1327
+ );
1328
+ var expired = 0;
1329
+ var skipped = 0;
1330
+ for (var i = 0; i < r.rows.length; i += 1) {
1331
+ var claim = await query(
1332
+ "UPDATE quotes SET status = 'expired', updated_at = ?1 " +
1333
+ "WHERE id = ?2 AND status = 'responded' " +
1334
+ "AND valid_until IS NOT NULL AND valid_until <= ?3",
1335
+ [asOf, r.rows[i].id, asOf],
1336
+ );
1337
+ if (Number(claim.rowCount || 0) === 1) expired += 1;
1338
+ else skipped += 1;
1339
+ }
1340
+ return { scanned: r.rows.length, expired: expired, skipped: skipped };
1341
+ },
1342
+
1209
1343
  // Operator-side expiry flip. Walks a single quote whose
1210
1344
  // valid_until has elapsed and moves it from responded -> expired.
1211
1345
  // Refuses if the quote is not in the responded state or if the
@@ -101,6 +101,7 @@ var INTERNAL_BRIDGE_PATHS = [
101
101
  "/_/campaign-send-tick",
102
102
  "/_/customer-portal-expire",
103
103
  "/_/stale-order-reap",
104
+ "/_/quote-expiry-tick",
104
105
  ];
105
106
 
106
107
  // Public well-known paths fetched by third-party verification crawlers
package/lib/storefront.js CHANGED
@@ -10930,7 +10930,13 @@ function renderQuotePage(opts) {
10930
10930
  var lineRows = (q.lines || []).map(function (l) {
10931
10931
  var unit = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor, l.currency || currency);
10932
10932
  var total = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor * l.quantity, l.currency || currency);
10933
- return "<tr><td>" + esc(l.sku) + "</td><td class=\"num\">" + esc(String(l.quantity)) + "</td>" +
10933
+ // Per-line note free text captured on the RFQ line, rendered under
10934
+ // the item so the priced offer reads against what was asked for.
10935
+ // Escaped like every other free-text field on this page.
10936
+ var note = l.notes
10937
+ ? "<div class=\"quote-line__note\">" + esc(l.notes) + "</div>"
10938
+ : "";
10939
+ return "<tr><td>" + esc(l.sku) + note + "</td><td class=\"num\">" + esc(String(l.quantity)) + "</td>" +
10934
10940
  "<td class=\"num\">" + esc(unit) + "</td><td class=\"num\">" + esc(total) + "</td></tr>";
10935
10941
  }).join("");
10936
10942
  var rowsHtml =
@@ -11019,11 +11025,17 @@ function renderQuoteList(opts) {
11019
11025
  var currency = q.currency || "USD";
11020
11026
  var total = q.total_minor == null ? "Awaiting price" : pricing.format(q.total_minor, currency);
11021
11027
  var statusLabel = q.status === "responded" ? "ready to review" : q.status;
11028
+ // Make the acceptance window visible from the list — a plain
11029
+ // server-rendered date on quotes that are still open to accept.
11030
+ var valid = (q.status === "responded" && q.valid_until)
11031
+ ? "<span class=\"quote-list__valid\">Valid until " + esc(new Date(Number(q.valid_until)).toUTCString()) + "</span>"
11032
+ : "";
11022
11033
  return "<li class=\"quote-list__item\">" +
11023
11034
  "<a href=\"/account/quotes/" + esc(q.id) + "\">" +
11024
11035
  "<span class=\"quote-list__id\">Quote " + esc(String(q.id).slice(0, 8)) + "</span>" +
11025
11036
  "<span class=\"quote-list__status\">" + esc(statusLabel) + "</span>" +
11026
11037
  "<span class=\"quote-list__total\">" + esc(total) + "</span>" +
11038
+ valid +
11027
11039
  "</a></li>";
11028
11040
  }).join("");
11029
11041
  body =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.26",
3
+ "version": "0.4.27",
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": {