@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 +2 -0
- package/README.md +1 -1
- package/lib/admin.js +302 -15
- package/lib/asset-manifest.json +1 -1
- package/lib/quotes.js +216 -82
- package/lib/security-middleware.js +1 -0
- package/lib/storefront.js +13 -1
- package/package.json +1 -1
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
|
|
8116
|
-
//
|
|
8117
|
-
//
|
|
8118
|
-
//
|
|
8119
|
-
//
|
|
8120
|
-
//
|
|
8121
|
-
//
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
|
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."
|
|
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
|
|
package/lib/asset-manifest.json
CHANGED
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
|
|
38
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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 (
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
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
|
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
|
-
|
|
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