@blamejs/blamejs-shop 0.4.25 → 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 +4 -0
- package/README.md +2 -2
- package/SECURITY.md +33 -1
- package/lib/admin.js +302 -15
- package/lib/asset-manifest.json +3 -3
- package/lib/compliance-export.js +18 -7
- package/lib/order.js +96 -12
- package/lib/quotes.js +216 -82
- package/lib/save-for-later.js +46 -0
- package/lib/security-middleware.js +20 -1
- package/lib/store-credit.js +41 -0
- package/lib/storefront.js +30 -4
- package/lib/suggestion-box.js +124 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.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
|
+
|
|
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.
|
|
14
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
17
|
- v0.4.24 (2026-06-06) — **Order timelines, in-console new-order alerts, partial refunds, a customer idea board, storefront sidebar widgets, and Stripe wallet checkout — alongside refund-accounting, inventory-race, and access-control hardening.** This release adds operator and storefront surfaces that compose primitives already in the tree, and closes a set of value-conservation, concurrency, and access-control gaps in the same areas. Operators get a single order-story timeline, an in-console inbox that flags new paid orders with an unread badge, a browser partial-refund control, and curated storefront sidebar widgets. Shoppers get a receipt download on their orders, a suggestion board, and Stripe wallet buttons (Apple Pay, Google Pay, Link) at checkout. The fixes make partial refunds return gift-card and loyalty value in exact proportion, make inventory and quote state transitions race-proof under concurrency, gate the privacy export/erasure routes behind the operator role matrix, and harden the audit log, gift-card ledger, session revocation, and magic-link throttling. Upgrade applies six new D1 migrations and adds one optional environment variable for Apple Pay. **Added:** *Order timeline on the admin order screen* — /admin/orders/:id now shows one chronological feed of the whole order story — state changes, payments and refunds, shipments and carrier tracking events, customer-service notes, returns, and shipping labels — instead of separate panels. Each source is optional; an unwired source simply drops out of the feed. Wire orderTimeline into admin.mount to enable it. · *New-order inbox with an unread badge* — When an order is paid, an entry lands in a new in-console inbox at /admin/inbox and the admin navigation shows an unread count, so a sale is visible on the next page load without polling. Mark a message read once actioned, or archive it. The inbox is addressed to a role broadcast, so a single shared-credential console still sees the badge. Wire operatorInbox into admin.mount to enable it. · *Partial refunds from the browser* — The order screen gains a Refunds panel: enter an amount and the framework charges it back through the payment provider, validates it server-side, and caps it at the order's remaining balance — an over-refund is refused before any money moves. The provider call is keyed so a double-click cannot refund twice. A refund that clears the balance moves the order to refunded; a smaller refund leaves it on its current track and records the slice. · *Customer receipt download* — A customer can download a receipt for their own order from the order page. The document streams in chunks rather than buffering, and the route is gated to the order's owner (and guest-order token holders, matching the existing order-page guard). · *Customer suggestion board* — Shoppers can submit and vote on product and feature ideas at /suggestions (linked from the footer). Operators triage from a new admin console: respond, move ideas along a status flow, merge duplicates, flag spam, or archive. Submissions are rate-limited and bot-guarded, the submitter's email is hashed before storage, and all free text is escaped on both the public board and the admin screen. · *Operator-curated storefront sidebar widgets* — Operators can place an ordered set of sidebar content blocks (newsletter signup, trust badges, social proof, size chart, countdown, featured collection, and more) per page across product, cart, search, and collection pages. The content is operator-authored and global, so it renders identically from the edge cache and the container. Manage widgets and per-page placement from the new Sidebar widgets admin screen. · *Stripe wallet checkout (Apple Pay, Google Pay, Link)* — The pay page now offers the Stripe Express Checkout Element, surfacing the wallet buttons a shopper's browser supports against the existing PaymentIntent. It degrades to the card form when no wallet is available or when payments are unconfigured. Apple Pay additionally needs the domain-association file served (see Migration) and each web domain registered with Stripe. · *Email-change guidance on the profile screen* — The profile screen now explains that a sign-in email cannot be changed in place: addresses are stored as a hash by design, so changing one means re-registering. The copy states what is and is not possible and why, rather than leaving the field unexplained. **Fixed:** *Partial refunds return gift-card and loyalty value in proportion* — A partial refund previously re-minted the entire gift-card spend and clawed back 100% of earned loyalty points the moment the order reached the refunded state, and restored nothing on a smaller slice — so a buyer could keep goods plus the full re-minted gift-card balance. Refunds now re-mint gift-card spend, claw earned loyalty, and restore redeemed loyalty points in exact proportion to the amount refunded, and a partial-then-final refund sequence converges on exactly the amount returned. · *Redeemed loyalty points are restored on refund* — Loyalty points spent as a checkout tender were never returned when the order was refunded, even though gift-card spend was — an inconsistency that cost the customer. Redeemed points are now restored on refund, in proportion to the refunded amount. · *Referral rewards reverse on refund or cancel* — A referral's qualifying first order being refunded or cancelled now rolls back the both-rewarded completion and decrements the referrer's leaderboard count, closing a buy-then-refund reward-farming path. The reversal is claimed once so a re-delivered refund webhook cannot double-reverse. · *Inventory and quote state transitions are race-proof* — Stock-transfer reconcile, inventory receive and reverse, write-off reversal, allocation commit, cycle-count finalize, and quote accept and convert all read state and then mutated it without an atomic guard, so two concurrent calls could both proceed — double-crediting a destination shelf, double-restocking, or minting two orders from one quote. Each transition now claims its state atomically and proceeds only if it won the claim. The stock-transfer and inventory-receipt lifecycles are additionally validated against a declared state machine. **Security:** *Privacy export and erasure now enforce the operator role matrix* — The data-subject export (full customer PII) and erasure routes did not pass through the role check every other mutating admin route uses, so a read-only viewer could export a customer's data and run an irreversible erasure. Both are now gated by the role matrix, and the mutating admin routes were swept to confirm each maps to a required permission. · *Operator audit log no longer self-reports tampering under concurrency* — Two operator actions recorded at the same instant could each read the same chain head and append rows sharing one previous-hash, forking the tamper-evident chain so verification reported a break where no tampering occurred. Audit appends are now serialized per chain, so the hash chain stays linear under concurrent writes. · *Gift-card ledger is now tamper-evident* — The gift-card balance ledger carried no cryptographic linkage, so a direct database edit that inflated a balance or dropped a debit passed balance reads undetected. Each ledger row now carries a per-card hash chain (the same construction the operator audit log uses), and a verification pass surfaces any insert, rewrite, reorder, or deletion. · *Erasure, passkey-revoke, and sign-out terminate live sessions* — The sealed sign-in cookie is self-validating for up to 14 days, so an erased or anonymized account stayed usable until the cookie expired. A per-customer session boundary now lets erasure, passkey revocation, and explicit sign-out invalidate every cookie minted before the boundary. A customer with no boundary is unaffected, so upgrading does not sign anyone out. · *Marketing email honors the suppression list everywhere* — Wishlist price-drop and back-in-stock alerts did not consult the marketing suppression list, and a newsletter one-click unsubscribe recorded the opt-out without adding the address to suppressions — so a hard-bounced, complained, or unsubscribed address could still be mailed by another flow. Both paths now consult and feed the suppression list, matching the transactional and campaign paths. · *Per-recipient throttle on magic-link sign-in* — Magic-link minting was throttled per source address only, so a distributed source could email-bomb one victim's inbox. A per-recipient limit now caps how often a link can be sent to a single address, alongside the existing per-source limit. · *Strict validation of inbound webhook idempotency keys* — The inbound webhook idempotency key was validated against a broad printable-ASCII pattern that accepted path-traversal shapes. It now uses the framework's strict idempotency-key guard, which refuses slash, backslash, and dot-dot sequences. The strict profile permits a space (harmless in the parameterized lookup) while refusing the traversal shapes. **Migration:** *Six new D1 migrations* — 0220 adds the gift-card ledger hash-chain columns; 0221, 0223, 0224, and 0225 add proportional-reversal tracking to gift-card redemptions, loyalty transactions, the loyalty earn log, and referral invitations; 0222 adds the per-customer session-revocation boundary table. All apply cleanly over existing rows (new columns default to nothing-reversed-yet; the revocation table is empty, which is the default-allow state). Run pending migrations and sync theme assets as usual. · *Optional APPLE_PAY_DOMAIN_ASSOCIATION environment variable* — To show the Apple Pay wallet button, set APPLE_PAY_DOMAIN_ASSOCIATION to the file contents Stripe provides (Stripe owns Apple merchant validation, so no Apple Developer account is needed) and register each web domain with Stripe. The shop serves the value verbatim at /.well-known/apple-developer-merchantid-domain-association on both the edge and container. Unset, the path returns 404 and the Apple Pay button stays hidden; card, Google Pay, Link, and PayPal are unaffected.
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
69
69
|
| **`lib/delivery-estimate.js`** | "Get it by <date>" promises. Operators define carrier transit times, warehouse cutoffs, holidays, and postal-prefix zones at `/admin/delivery-estimates`; the product and cart pages then show a signed-in customer a delivery date computed against their saved shipping address and the configured origin (`SHOP_ESTIMATE_ORIGIN` or the `shop.estimate_origin` config row). Anonymous, edge-cached pages deliberately render no date — estimates are destination-specific and never bake into the shared cache. Unconfigured stores render nothing, never an error. |
|
|
70
70
|
| **`lib/promo-banners.js`** | Placement-targeted marketing banners (top strip, homepage hero, PDP-side, cart-side, empty-search, footer) with schedule windows, audience targeting, themes, priorities, and click/impression counts. Authored at `/admin/promo-banners`; rendered on both substrates with edge-cache-safe resolution. |
|
|
71
71
|
| **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
|
|
72
|
-
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
|
|
72
|
+
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. Guest orders carry the buyer's email as a one-way hash and attach to a customer account when a verified sign-in (passkey / Google / Apple) proves ownership of the same address — never from email knowledge alone — after which they appear in the account's order history. |
|
|
73
73
|
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
|
|
74
74
|
| **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation, wishlist price-drop, abandoned-cart, review request, back-in-stock, **wishlist digest** (the periodic saved-items rollup, rendered per-line from the structured digest so every title / price is independently escaped), and **email magic-link sign-in**. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
|
|
75
75
|
| **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, quote review / accept, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
|
|
@@ -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/SECURITY.md
CHANGED
|
@@ -142,7 +142,12 @@ node -e "
|
|
|
142
142
|
ticks): those paths skip the bot guard's browser-fingerprint
|
|
143
143
|
heuristics — the Worker's machine-to-machine calls carry no browser
|
|
144
144
|
headers — so the constant-time secret check is the deciding gate,
|
|
145
|
-
and an unauthenticated caller gets a 401.
|
|
145
|
+
and an unauthenticated caller gets a 401. The Worker also verifies
|
|
146
|
+
the same secret on these cron/event POSTs before forwarding them, so
|
|
147
|
+
a forged public request is refused at the edge before it reaches the
|
|
148
|
+
container (the edge check is skipped when the secret is unset, so a
|
|
149
|
+
deployment that hasn't wired it still forwards rather than dropping
|
|
150
|
+
every scheduled task). Generate ≥ 32 bytes of OS
|
|
146
151
|
randomness and set the value identically on the Worker (`wrangler
|
|
147
152
|
secret put D1_BRIDGE_SECRET`) and the container env. Rotate
|
|
148
153
|
quarterly or after any Worker-credential compromise.
|
|
@@ -293,6 +298,33 @@ node -e "
|
|
|
293
298
|
(success / failure / denied) and paginated. Opening the log is itself
|
|
294
299
|
recorded (an `audit.read` row), so reviewing the audit trail leaves
|
|
295
300
|
its own forensic mark.
|
|
301
|
+
- **Privacy exports hold the whole record; erasure states a basis per
|
|
302
|
+
domain.** A subject-access export walks every table that keys a row
|
|
303
|
+
by the customer — identity, orders, subscriptions, addresses, saved
|
|
304
|
+
payment-method metadata, support tickets, loyalty, reviews, the
|
|
305
|
+
consent ledger, wishlist, surveys, recently-viewed, suggestion-box
|
|
306
|
+
submissions, the save-for-later list, and the store-credit ledger —
|
|
307
|
+
so the bundle is the full record, not just the order/identity core.
|
|
308
|
+
The bundle carries a completeness manifest: every in-scope domain is
|
|
309
|
+
marked exported, empty, or absent, so a domain whose reader isn't
|
|
310
|
+
wired is visible rather than silently dropped. Erasure deletes the
|
|
311
|
+
pure-personalization domains (wishlist, recently-viewed, save-for-
|
|
312
|
+
later), anonymizes the suggestion-box rows in place (both identity
|
|
313
|
+
keys cleared, the de-identified text left as roadmap signal), revokes
|
|
314
|
+
every sign-in path and anonymizes the customer profile, and RETAINS
|
|
315
|
+
the records a controller keeps under a legal-obligation / accounting
|
|
316
|
+
basis (orders, loyalty ledger, store-credit ledger, consent
|
|
317
|
+
evidence, published reviews) — each with a stated basis in the
|
|
318
|
+
per-domain result. Preview a deletion with the dry-run flag to see
|
|
319
|
+
the blast radius before the irreversible call.
|
|
320
|
+
- **HSTS on every TLS response, edge and container.** Both substrates
|
|
321
|
+
send `Strict-Transport-Security: max-age=63072000; includeSubDomains;
|
|
322
|
+
preload` (two years, above the preload-list minimum). The container
|
|
323
|
+
emits it behind the Cloudflare proxy by trusting the Worker-set
|
|
324
|
+
`X-Forwarded-Proto`, so a direct-to-container or edge-render-off
|
|
325
|
+
response carries the same header the edge sends; a plain-HTTP request
|
|
326
|
+
(local dev, direct non-TLS) omits it, which user agents ignore over
|
|
327
|
+
HTTP anyway.
|
|
296
328
|
- **Email deliverability — SPF / DKIM / DMARC + one-click unsubscribe.**
|
|
297
329
|
Transactional and broadcast mail is composed on `b.mail`, which signs
|
|
298
330
|
each message with DKIM, but the receiving server still rejects or
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.27",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fingerprinted": "js/passkey-register.02b0e196fb9608d8.js"
|
|
39
39
|
},
|
|
40
40
|
"js/pay-trusted-types.js": {
|
|
41
|
-
"integrity": "sha384-
|
|
42
|
-
"fingerprinted": "js/pay-trusted-types.
|
|
41
|
+
"integrity": "sha384-h+/EjwX7ee88MouudamS26SgRg1bUXasRs9qD0Wcg6bGkK51CluebA3iATzedwdz",
|
|
42
|
+
"fingerprinted": "js/pay-trusted-types.08dc910a5c35d38a.js"
|
|
43
43
|
},
|
|
44
44
|
"js/pay.js": {
|
|
45
45
|
"integrity": "sha384-W11JVQhv1RZq4WhsAOglu56gTZTDz1ByLd+b1HFQBCi8xGoEhJ0PZ2yI3FqbYzt6",
|