51 min readOct 16, 2026by jakub

Changelog

Current version: 1.0.3

Every module in the suite is versioned together. Each release below lists, per module, what that module's own changelog records for it.

1.0.3 — 2026-10-09

Core

qoliber/quote-request-core

Fixed

  • Preserve the quote request's currency conversion rate when a negotiated total is reduced to zero and subsequently increased. Creation stores the cart's base_to_quote_rate; recomputation uses that stored rate and saves a rate derived from older non-zero totals before it can be lost.
  • Report stale versions consistently before validating status changes. An acceptance or revision submitted after another action commits remains a version conflict, with the quote left untouched.

Changed

  • Add nullable base_to_quote_rate storage to the quote request schema and service contract. Run bin/magento setup:upgrade when updating an existing installation.
  • Add a database- and table-prefix-scoped mutex for guest account upgrades and RFQ order placement. Release the lock after success or failure so a retry can proceed safely.

Admin UI

qoliber/quote-request-admin-ui

Changed

  • Extract posted-action/ACL validation from the Save action to bring its complexity below the PHPMD threshold. Save, Send Proposal and comment behavior remain covered by integration and browser tests.

B2B Approval Workflow

qoliber/quote-request-b2b

Fixed

  • Require approval when the negotiated per-unit discount exceeds the threshold even if increasing quantities makes the negotiated subtotal larger than the original subtotal. The policy uses the stricter of the original-subtotal comparison and list-versus-quoted prices at current quantities.

  • Bind each approval to the quote version it approves. A customer revision, subsequent negotiation or other version change invalidates that approval for a later proposal. Existing unversioned decisions do not authorize a new send. The nullable request_version column requires bin/magento setup:upgrade on an existing installation.

  • The approval guard could be bypassed. It let pending_approval through, on the reasoning that only ApprovalWorkflowManagement::approve() reaches sendProposal() from that status — but the admin screen's own Send Proposal button and the REST route call the same service, so a sales rep with send permission and no approval permission could send the proposal and skip the decision. The guard now refuses from both under_review and pending_approval unless the quote's LATEST recorded decision is an approval (a quote sent back for rework and resubmitted cannot ride on an older one), and approve() records its decision before sending so the trail exists when the guard reads it — both inside one transaction, so a proposal that fails to send takes its approval record with it. Raised in review.

Quote Cart

qoliber/quote-request-cart

Changed

  • Extract product addition/eligibility and shopping-cart transfer collaborators, reducing the management constructor from 14 dependencies to seven without weakening validation, quantity bounds or rollback.

Fixed

  • A refused add-to-quote was remembered as a success. The click id that makes an add repeatable without being repeated was recorded as soon as the request arrived, before the add was attempted — so a refused add was recorded too, and its retry was answered "added to your quote request cart" with nothing in the cart. The id is now recorded only after the add has landed. Raised in review.

  • A refused hand-off left copies on the quote cart. "Request a Quote" from the shopping cart copies each line onto the quote cart and then removes it from the shopping cart through core's own Checkout\Model\Cart, so a module guarding the cart still gets its say — and qoliber/quote-request-checkout refuses every edit to a cart locked to an accepted quote. Meeting that refusal used to leave the copies behind, the originals in place, and quantities climbing with every retry. The hand-off is now all-or-nothing: the quote cart's quantities are snapshotted before anything moves and restored if the removal is refused, with the lock's own message still reaching the customer. The button is no longer offered on a cart that cannot give its lines up, asked of the quote's own lock column so this module keeps no dependency on that one. Raised in review.

  • "Max Qty Per Line" bounded each click rather than the line. Quote::addProduct() merges an add into an identical existing line, so 3 + 3 was one line of 6 and only the 3 was ever checked. The limit is now measured after the merge, and an over-limit add is undone in memory before it is refused — so a refused line cannot ride along with a later successful one. Topping a line up to exactly the maximum stays allowed. Raised in review.

  • A lost add-to-quote response could add the product twice. The script answers a transport failure by submitting the product form natively, but a lost response does not mean the add did not happen: the server may have committed and had its answer dropped. Each click now carries an id, kept across the retry, and the controller recognises an id it has already applied and answers it as the success it was. A request with no id is treated as a fresh add, exactly as before. Raised in review.

Changed

  • The quote cart's snapshot-and-undo lives in Model\QuoteCartSnapshot, shared by the per-line limit and the hand-off rollback.

Hyvä Templates

qoliber/quote-request-cart-hyva

Fixed

  • Show configurable option pickers and a quantity input for a hide-price product whose native Hyvä product form is suppressed. The CTA uses the theme's own option markup and posts the buy request to the hide-price controller. The replacement form uses multipart encoding, and browser journeys verify configurable selections and stored file-upload bytes.

  • The Hyvä twin of the cart-lock notice offered only one of its two actions. The notice now carries Reload the quote next to Leave quote checkout — the action qoliber/quote-request-checkout 1.0.3's pre-order refusal names — and the line saying how many of the shopper's own items are set aside behind the lock, with the same class names a test or a merchant's CSS hooks (qoliber-quote-request-leave-button, qoliber-quote-request-reload-button).

  • Add to Quote stopped working after a validation failure. The Alpine busy latch was taken on the first click whatever happened next, including when the browser then refused to submit the form — a required swatch or custom option left empty. The page stayed where it was with the latch still held, so the shopper's CORRECTED click was swallowed by the second-click guard and the button appeared broken until the page was reloaded. Both buttons were affected (qoliberAddToQuote and the hide-price qoliberHidePriceCta). The latch is now only taken when the form is actually going to submit (checkValidity()), and releases itself if nothing navigates — a submission that does navigate takes the page and the timer with it. Raised in review, with the mechanism reproduced; now covered by a browser regression (dev/tests/playwright/tests/storefront/hyva-add-to-quote-validation.spec.ts), which fails against the old script with aria-busy="true" still set.

Added

  • A Hyvä twin of the new "your cart is checking out a quote" page (quote/locked.phtml), where a shopper lands when a locked cart refuses an add-to-cart: Tailwind presentation, both answers, no Alpine and no inline script, matching the Luma template's behaviour exactly.

Checkout

qoliber/quote-request-checkout

Fixed

  • Restore the checkout session's pointer to the shopper's parked basket after an RFQ order, including guest baskets. Customer-owned baskets are located by customer id; guests retain their session's own basket rather than relying on a customer lookup.

  • Keep the restored basket through Magento's success-page clearQuote() call. Recovery remembers the restored cart and pins it again on checkout_onepage_controller_success_action, so the next page does not create an empty guest basket. Covered by the guest parked-basket browser regression.

  • Leaving a quote checkout twice emptied the shopper's cart. leave() unlocked and emptied unconditionally, so a second submission of the same action — a stale tab, a double click, the back button — threw away the basket the first call had just handed back. It now returns immediately when the cart is not locked. Raised in review.

  • A failed reload discarded a basket it had not parked, and left the cart unlocked. Reloading a quote drops the lock for the duration of the rebuild, and the failure path discarded "the" parked cart — written for a row the same call had created, but it also deleted one parked earlier at sign-in, which is the shopper's. The failure path now discards only a row this call created and restores the previous lock, so a cart still checking out a quote is never left editable. Raised in review.

  • The after-order restoration never ran for an ordinary Luma order. It was registered in the frontend area, and Magento's one-page checkout places the order through POST /rest/V1/carts/mine/payment-information — the webapi_rest area — so a basket set aside at sign-in stayed set aside for good after a purchase. Registered globally now; the observer does nothing unless the ordered cart was locked with something parked behind it. Raised in review, and now asserted in the browser after a real Luma order.

  • An accepted quote could refuse its own checkout with "This order can no longer be placed because the cart no longer matches the accepted quote", against a cart that matched it perfectly — and the cart then refused every add-to-cart. Reported by a partner on a fresh install. There were three independent causes, all of them now closed:

    • The lock could land on a cart the shopper was not using. QuoteToCartManagement::load() built an owned quote into CartManagementInterface::getCartForCustomer()'s cart and never told the session about it. That call resolves "the customer's active cart" by a query with no deterministic order (Magento\Quote\Model\ResourceModel\Quote::loadByCustomerId()), so a customer owning more than one active cart could be shown one cart and have another locked: the guards did not apply to the cart they were adding to, and the pre-order check compared the quote against a cart that had never been built from it. The quote is now built into the cart the shopper is actually using, and the session is left pointing at the cart that was locked.
    • Signing in folded a second cart into a locked one. Magento\Checkout\Model\Session::loadCustomerQuote() merges the visitor's cart into the customer's with Quote::merge(), which no guard covered — so a customer who loaded a quote, was signed out (a session expires in an hour by default; a second device or an explicit sign-out does the same) and added anything as a visitor came back to a locked cart carrying a line their quote did not have. That merge is now refused, and the items the shopper was carrying are set aside rather than lost (Magento deletes the row it merged from): they come back when the shopper leaves the quote checkout, or become their cart again once the order is placed. The cart notice says how many are waiting.
    • Headless add-to-cart went straight through. Magento\QuoteGraphQl\Model\Cart\AddSimpleProductToCart calls Quote::addProduct() on the quote itself, under every per-channel guard. It is now guarded too, so a PWA or app storefront cannot add lines to a locked cart.
  • The mirror case, which leaked negotiated prices. Merging a LOCKED cart into an unlocked one (a shopper who loaded a guest quote and then signed in) carried negotiated custom_price lines into a cart nothing was guarding, where a coupon or a cart rule could discount them a second time. Refused, with a message saying the quote is still accepted and can be checked out from the account.

  • Loading a quote destroyed whatever was in the shopper's cart, silently, and leaving the quote checkout emptied it again — so looking at a quote cost a shopper their basket. Those lines are now set aside and handed back the same way parked lines always are.

  • A cart could report itself empty while holding the product just added to it. Magento totals a cart once per request (Quote::collectTotals() returns early on getTotalsCollectedFlag()) and each address caches the item list it totals, and every action here replaces a cart's contents wholesale — so items_count (what Magento\Checkout\Block\Cart decides "you have no items in your shopping cart" on) and the subtotal could describe the basket that was gone. All three paths now re-total explicitly; see Model\Cart\CartTotals.

  • An order could be placed against a quote that was no longer a deal. Status and expiry were checked when the cart was built and never again, and the pre-order check compares SKU, quantity and price only — so a cart that still matched its quote line for line went through even when the quote had been cancelled by the merchant, had expired, or had already been ordered from another session (a guest magic link opened in two browsers gives each one its own cart, and both can reach the payment step). All three are now refused at checkout_submit_before by the same guard the cart load uses (Model\Cart\AcceptedQuoteGuard), so "still buyable" means one thing in this module. The already-ordered refusal says so plainly and points at the order history rather than claiming the quote cannot be checked out. Raised in review.

Added

  • A way out of both refusals, where the shopper meets them. A refused add-to-cart now leads to a page that says what is in the way and offers both answers — leave the quote checkout and add this product, which completes the add the shopper was making with the options and quantity they chose, or keep checking out my quote. Before this, the refusal named an action ("leave the quote checkout") whose control lived on a page the shopper had no reason to visit, and taking it threw their click away. The partner's words: "I don't think it's clear at all that this is what the user has to do."
  • Reload the quote, on the cart and checkout lock notice. The pre-order refusal tells the customer to reload the quote, and nothing could. It is also the right answer to the one source of drift that cannot be guarded away: a rep improving a price on an already-accepted quote makes the customer's cart stale through no fault of theirs, and reloading brings the new agreement in rather than losing the deal.
  • The refusal is now diagnosable. CartIntegrityVerifier logs the quote request, the cart, both sides of the comparison and the two differences (a line the quote has that the cart does not, and the reverse) at warning level. The partner could only report that the cart looked right to him, because the refusal named nothing and the log held nothing.

Tests

  • dev/tests/playwright/tests/storefront/locked-cart-integrity.spec.ts — three browser journeys, written before the fixes and failing against 1.0.2: signing in while holding a cart must not cost the customer their accepted quote (it did: the extra product landed in the locked cart and the order was refused); a refused add-to-cart must offer the way out and complete the add; and a quote re-priced after the cart was built must be reloadable, then ordered at the new price.
  • AcceptedQuoteStillBuyableTest — checkout_submit_before on an untouched cart whose quote has been cancelled, has expired, or is already ordered; the accepted control that must still place; and the same refusal when such a quote is loaded into a cart in the first place.
  • CartMergeGuardTest — the sign-in merge refused and parked, parked items returned on leave, the shopper's own basket set aside by loading a quote and handed back, Quote::addProduct() refused (the GraphQL hole), and a locked cart not merged into an unlocked one.

Order serialization

  • Serialize placement per RFQ using a database mutex before payment. Save the RFQ binding with the sales order and enforce its uniqueness, so a second cart cannot order the same RFQ even if the post-commit quote-status update failed. A failed placement releases the mutex for retry.
  • Test separate database-session contention, two matching carts, durable order binding and failure recovery.

Storefront

qoliber/quote-request-frontend

Fixed

  • Quote-detail formatting honours an optional price-disclosure policy shared by customer/guest screens and both themes. Hidden original prices, derived totals and savings are suppressed while negotiated offers remain visible, including zero-price offers. Non-priced composite children do not suppress totals.

Changed

  • Extract money/quantity presentation from QuoteView to reduce complexity and keep optional hide-price dependencies out of the required frontend module.

GraphQL API

qoliber/quote-request-graph-ql

Fixed

  • A retried mutation is answered with what the first one answered. Sending a mutation again with the same idempotency_key — the ordinary thing a client does when a response is lost — used to be answered with the quote request as it stood at that moment. If anything had happened in between, the answer described it: accepting a quote that was then cancelled, and retrying the accept, replied CANCELLED. The whole payload the first call returned is now kept beside the key, written in the same transaction as the change itself, and a retry is given that payload back — totals, version, every line and every comment, so a line re-priced in the meantime cannot show through.

Changed

  • Store the complete mutation payload in nullable response_state storage in the adapter's own replay table. Run bin/magento setup:upgrade when updating an existing installation.
  • Older records, unreadable snapshots and responses exceeding the storage limit retain the current-state fallback; no original response can be recovered for those records.

Guest Quote Requests

qoliber/quote-request-guest

Fixed

  • The login merge could happen twice. Merging a guest quote cart into the customer's is two writes — save the survivor, then delete the row it came from — and the guest id was only forgotten after both. A failed delete (or a request that died between them) left the lines on the customer's cart with the id still in the session, so the next login merged them again and the quantities climbed; the observer swallows failures, so it happened silently. The session clear now sits between the two writes: the id is spent the moment the lines are durably saved, and the worst a failed delete leaves is an unreferenced quote row for Magento's own clean-up. Raised in review.
  • Merged quantities ignored "Max Qty Per Line". Two carts of three made six. Merged lines are now held to the maximum the merchant set — cut rather than refused, because refusing would lose the guest's cart at the moment they signed in, and said out loud, so a reduced quantity is not something the customer discovers on the quote they receive. Raised in review.

Tests

  • The failed-deletion case, with a repository decorator that counts its refusals so the test cannot pass vacuously, and the per-line maximum case. Plus a browser journey (dev/tests/playwright/tests/storefront/guest-quote-cart-merge-on-login.spec.ts) that signs a real guest in — cookies, session regeneration, header badge — and asserts a second sign-in adds nothing.

Recovery

  • Persist the account created by an upgrade in the same transaction as customer creation. Link saves reload and retry up to three times; exhausted attempts preserve the guest token and recovery marker for a later request. Existing accounts are still never linked solely from a guest token.
  • Add regression tests for transient conflicts and recovery on a fresh request after all attempts fail.

Hide Price

qoliber/quote-request-hide-price

Fixed

  • Honour the shared add-to-quote click id, so a lost response followed by a native retry cannot add a hide-price product twice. Only successful adds are remembered.
  • Forward configurable selections, custom options and the rest of the buy request to the quote cart, alongside the posted quantity. Accept Magento's product form field and retain the existing product_id fallback.

Changed

  • Declare the existing dependency on qoliber/quote-request-cart in Composer and module sequencing.
  • Correct the README's persisted configuration path and product attribute name; remove the nonexistent hide-price master switch from its configuration table.
  • The Luma CTA submits Magento's product form. The Hyvä rendering supplies a form with option pickers and quantity when the theme suppresses the native form for a non-salable product.

Fixed presentation

  • Hide Hyva's standalone product price block using the same product/group policy as Luma's price boxes. The dedicated Request a Quote action previously rendered beside a visible catalog price on Hyva product pages. Add rendered-block and browser regressions for this separate rendering path.

  • Apply original-price disclosure to quote-list totals as well as detail screens. Unquoted hidden lines cannot disclose a catalog total; explicit offers, including zero-price offers, remain visible.

  • Render Luma's configurable, bundle and custom-option controls for hidden products inside a quoting form, keeping normal purchase controls suppressed. Both theme forms use multipart encoding.

  • Apply group/product hide-price policy to customer and guest quote-detail prices and totals on both themes. Negotiated offers, including zero, remain visible; hidden catalog totals and savings do not.

  • Add browser regressions for configurable selections and actual uploaded file bytes on both themes.

Notifications

qoliber/quote-request-notifications

Changed

  • No changes in this package. Released as 1.0.3 so the suite's versions stay aligned with the packages that carry this round's fixes: qoliber/quote-request-checkout (a cart locked to an accepted quote could break its own checkout, with no way out of either refusal), qoliber/quote-request-cart (an all-or-nothing hand-off from the shopping cart, a per-line maximum that bounds the line, an add that cannot be applied twice), qoliber/quote-request-guest (a login merge that happens exactly once), qoliber/quote-request-b2b (an approval guard that cannot be bypassed) and qoliber/quote-request-cart-hyva (the Hyvä twins, and Add to Quote after a validation failure).

REST API

qoliber/quote-request-web-api

Fixed

  • A retried call is answered with what the first call answered. Sending a mutation again with the same idempotency key — the ordinary thing a client does when a response is lost — used to be answered with the quote request as it stood at that moment. If anything had happened in between, the answer described it: accepting a quote that was then cancelled, and retrying the accept, replied cancelled. The whole answer the first call gave is now kept beside the key, written in the same transaction as the change itself, and a retry is given that answer back — totals, version, every line and every comment, so a line re-priced in the meantime cannot show through.

Changed

  • Store the complete customer/admin mutation DTO in nullable response_state storage in the adapter's own replay table. Run bin/magento setup:upgrade when updating an existing installation.
  • Older records, unreadable snapshots and responses exceeding the storage limit retain the current-state fallback; no original response can be recovered for those records.

1.0.2 — 2026-09-14

Core

qoliber/quote-request-core

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

Admin UI

qoliber/quote-request-admin-ui

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

B2B Approval Workflow

qoliber/quote-request-b2b

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

Quote Cart

qoliber/quote-request-cart

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

Hyvä Templates

qoliber/quote-request-cart-hyva

Added

  • Hyvä template for the customer's Proceed to Checkout button (Qoliber_QuoteRequestCheckout::quote/proceed-to-checkout.phtml): a plain POST form with the form key — no Alpine, no inline script — styled like the guest page's checkout button.

Checkout

qoliber/quote-request-checkout

Fixed

  • A customer who accepted a quote had no way to buy it. The controller that loads an accepted quote into a locked cart shipped, but nothing on the customer quote page reached it: once a quote was accepted the page offered no action at all. The page now shows Proceed to Checkout for an accepted quote that has not passed its expiry — exactly what QuoteToCartManagement::load() accepts — posting with the form key to quoterequest/quote/checkout. The button is this module's own block after the quote view, so qoliber/quote-request-frontend is unchanged.

Tests

  • ProceedToCheckoutButtonTest renders the real customer quote page: the button for an accepted quote; none for proposal_sent, rejected, cancelled, ordered, expired, or an accepted quote past its expiry.
  • A Playwright spec, on Luma and Hyvä, that accepts a quote, clicks Proceed to Checkout, asserts the locked cart carries the negotiated price, then places the order — Magento's checkout on Luma, Hyvä Checkout on Hyvä — and asserts the order was charged the negotiated price and the quote is ordered. No earlier customer test went past Accept.

Storefront

qoliber/quote-request-frontend

Fixed

  • My Quote Requests listed the oldest quote requests first. ViewModel\QuoteList never gave the repository an order, so the database's natural order won and page one held a customer's twenty oldest quote requests. A customer with more than twenty could not see the one they had just submitted — or the proposal they had just been emailed about — without paging to the end. The list now shows the newest first (created_at descending, entity_id as the tie-break), as Magento's own My Orders does.

Tests

  • QuoteListOrderTest: the newest quote request is listed first, and is on page one for a customer with more than one page of them. Found by the 1.0.2 storefront coverage audit.

GraphQL API

qoliber/quote-request-graph-ql

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

Guest Quote Requests

qoliber/quote-request-guest

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

Hide Price

qoliber/quote-request-hide-price

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

Notifications

qoliber/quote-request-notifications

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

REST API

qoliber/quote-request-web-api

Changed

  • No changes in this package. Released as 1.0.2 so the suite's versions stay aligned with qoliber/quote-request-checkout 1.0.2 (the customer's Proceed to Checkout button for an accepted quote) and qoliber/quote-request-frontend 1.0.2 (My Quote Requests lists the newest first).

1.0.1 — 2026-09-14

Core

qoliber/quote-request-core

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-core points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Admin UI

qoliber/quote-request-admin-ui

Fixed

  • Send Proposal, Cancel Quote Request and Reopen did nothing but save. Each button asked the edit form to save with an action_url, but Magento_Ui/js/form/form::save() merges that argument into the posted data instead of following it, so every click ran the Save controller — the merchant was told "The quote request has been saved." and no proposal (and no proposal email) was ever sent. Send Proposal now submits the form flagged as a proposal: Save stores the typed prices first, then posts the comment and sends the proposal as one unit, checks Qoliber_QuoteRequest::send_proposal itself, and refuses an action it does not recognise instead of falling back to a plain save.
  • A refused proposal keeps the rep's changes. When sending is refused — for example by the B2B approval guard, which points the rep to Request Approval — the typed prices stay saved and the rep is told the proposal was not sent; the comment written for it is not posted.
  • Cancel Quote Request never asked for confirmation. Its confirmation was declared as a data attribute nothing reads. Cancel and Reopen now open Magento's confirm dialog and post straight to their own controllers with the quote version, so Core's compare-and-set still guards them.

Changed

  • composer.json now declares support.security (https://security.qoliber.com).

Tests

  • Integration tests for the flagged Save path: the proposal is sent at the typed price and snapshotted after it, the comment travels with it, the send_proposal resource is enforced, an unrecognised action saves nothing, and a refused proposal keeps the price but posts no comment.
  • A Playwright spec that clicks the real Send Proposal, Cancel and Reopen buttons and asserts the stored status and the proposal email leaving the outbox. Every earlier test reached these controllers by URL, which is how the broken buttons went unnoticed.

B2B Approval Workflow

qoliber/quote-request-b2b

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-b2b points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Quote Cart

qoliber/quote-request-cart

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-cart points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Hyvä Templates

qoliber/quote-request-cart-hyva

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-cart-hyva points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Checkout

qoliber/quote-request-checkout

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-checkout points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Storefront

qoliber/quote-request-frontend

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-frontend points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

GraphQL API

qoliber/quote-request-graph-ql

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-graph-ql points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Guest Quote Requests

qoliber/quote-request-guest

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-guest points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Hide Price

qoliber/quote-request-hide-price

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-hide-price points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Notifications

qoliber/quote-request-notifications

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-notifications points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

Fixed

  • composer.json now requires magento/module-config. The admin configuration (etc/adminhtml/system.xml) uses Magento_Config's email identity and email template source models and its schema, but the dependency was used without being declared.

REST API

qoliber/quote-request-web-api

Changed

  • composer.json now declares support.security (https://security.qoliber.com), so composer show qoliber/quote-request-web-api points customers to the security disclosure route. No code changes in this package — released alongside qoliber/quote-request-admin-ui 1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.

1.0.0 — 2026-08-11

Core

qoliber/quote-request-core

Initial release.

  • Four-table schema (qoliber_quote_request, _item, _comment, _history) with an extensible aggregate + repository pattern; money columns are decimal(20,4).
  • DI-configured state machine and the generic transition primitive (Api\State\TransitionServiceInterface) every caller — admin, customer, or cron — transitions through.
  • Api\QuoteRequestManagementInterface: createFromCart, review, sendProposal, negotiate, accept, reject, requestRevision, cancel, reopen, markOrdered, each guarded by optimistic concurrency (expectedVersion).
  • Api\Security\OwnershipGuardInterface: fail-closed ownership/visibility guard used by every consuming module.
  • Customer-safe and admin read-model DTOs plus their assemblers — the only shapes ever handed across the Core boundary.
  • Item, comment, and history repositories; history is read-only and recorded automatically by every transition.
  • Customer-deletion anonymization (Api\QuoteRequestCustomerDetacherInterface): nulls customer_id, preserves the row, the submission-time email/name/company snapshot, and the status.
  • Per-store increment-ID sequencing on Magento's own SalesSequence machinery, configurable prefix (default Q-).
  • Quotes do not reserve, hold, or decrement stock at any stage (see README).

Quality gate: phpcs (Magento2 + PSR-12 + PHPCompatibility 8.1–8.4), phpstan level 8 with no baseline, phpmd — all exit 0. Unit and integration suites green (see the family-wide totals in the metapackage release-hardening report).

Fixed

  • negotiate() is now atomic: item-price/qty edits, the header recompute, and the CAS-guarded save run inside a single DB transaction, and the caller's version/status is checked before the first item write. A stale (conflicted) call can no longer leave item prices committed against an unchanged header.
  • negotiate() now enforces a status policy: line items may be edited only while a quote is under_review. Editing an accepted, ordered, rejected, cancelled, expired, proposal_sent, submitted, or customer_revision quote is refused with a clear error.
  • The remaining read-then-write transitions that span more than one persistent write (createFromCart, sendProposal, accept, reject, requestRevision, reopen, markOrdered) now run their full aggregate mutation inside one transaction via the new Model\AtomicOperationRunner, so a CAS conflict on any step rolls back the whole unit rather than leaving a partially applied aggregate.

Admin UI

qoliber/quote-request-admin-ui

Initial release.

  • Admin grid over Core's admin read-model DTO.
  • Negotiation form and controllers (review, send proposal, cancel, reopen), each calling Core's QuoteRequestManagementInterface — no local transition logic.
  • Per-resource ACL, declared independently by every controller (Qoliber_QuoteRequest::view, ::send_proposal, ::cancel, ::reopen); denying one leaf leaves every other resource reachable.

Quality gate: phpcs, phpstan level 8 (no baseline), phpmd — all exit 0. Unit and integration suites green (see the family-wide totals in the metapackage release-hardening report).

B2B Approval Workflow

qoliber/quote-request-b2b

Initial release. Optional B2B add-on for Qoliber Quote Request: sales-rep assignment and an approval workflow injected into Core's state machine.

Quote Cart

qoliber/quote-request-cart

Initial release.

Changed

  • PDP "Add to Quote" now has a proper in-flight loading state (Luma). Product-owner request: the click needs a visible "it's working" signal, not a button that looks inert for however long the round trip takes. js/add-to-quote.js mirrors core's own catalogAddToCart.disableAddToCartButton()/enableAddToCartButton() (vendor/mage-os/module-catalog/view/frontend/web/js/catalog-add-to-cart.js) rather than a bespoke widget: on click the button is REALLY disabled (.prop('disabled', true) — a stronger guard than core's own class-only toggle, since a disabled <button> cannot dispatch a second click at all), gets aria-busy="true", and its label swaps to "Adding..."; on a successful add it flashes "Added" for one second (aria-busy already back to "false" at that point — the request itself is over) before reverting; on a refusal it reverts immediately and shows the server's own error, unchanged. Both new labels are ViewModel-sourced (AddToQuoteButton::getAddingLabel()/getAddedLabel(), i18n/en_US.csv), matching this module's existing no-English-in-JS discipline. A CSS spinner (new in _module.less, gated on the same aria-busy attribute) backs off under prefers-reduced-motion: reduce, leaving the label-text change as the sole busy signal. A 20s watchdog timer guards against a stuck spinner if a resolution path is ever missed. None of the existing fallback chain changed: JS-off/Hyvä still uses the native form/formaction submit untouched, and a transport/parse failure still falls back to nativeSubmit() verbatim — the page navigation that follows is what resolves the busy state in that case, and the watchdog covers the edge case where it doesn't. Presentation/interaction only: no PHP, JSON contract, private-content or layout changes.

  • The header quote-cart link now gets the same visual treatment as Luma's minicart (presentation only — no template logic, JS or PHP changed). New view/frontend/web/css/source/_module.less reuses Luma's own theme variables rather than hard-coded values, so a re-skin of Luma re-skins this too: the icon is sized and coloured off @minicart-icons-color / @minicart-icons-color-hover (the same grey/hover pair every other header icon uses), and the count renders as the identical orange-on-white pill — @active__color background, @page__background-color text, @minicart-qty__height (24px) height/line-height — computed from vendor/mage-os/theme-frontend-luma/Magento_Checkout/web/css/ source/module/_minicart.less's .action.showcart .counter.qty. border-radius: 2px, min-width: 18px, padding: 0 3px and the link's ~28px row height are matched as literals because Luma itself hard-codes them (no named variable exists for those). The glyph stays the template's own clipboard/list SVG — deliberately not the cart icon — so the two links read as a matched PAIR while remaining tellable apart at a glance. A 13px margin (the same value Luma's own .minicart-wrapper uses for its neighbour spacing) keeps the pair evenly spaced from the minicart, and the numeric pill now hides at a zero count exactly like the minicart's own .counter.qty.empty, via the .empty class js/quote-cart-count.js already toggled (unchanged JS, new CSS hook).

  • "Add to Quote" no longer navigates away from the product page (Luma). The click used to submit the product form for real, so the customer was thrown onto the quote-cart page mid-browse. view/frontend/web/js/add-to-quote.js now serializes the SAME #product_addtocart_form with FormData and POSTs it in the background, so the whole buy request (super_attribute, bundle_option, super_group, options, qty, form key — file-type custom options included, which is why FormData and not $.serialize()) still travels. The customer stays put, the header quote-cart badge updates immediately, and the outcome is confirmed inline. Magento's own product validation still runs first where the theme provides it, so a configurable with no option chosen is still blocked client-side. Nothing became a hard JavaScript dependency: the button keeps its HTML5 form/formaction attributes, so with JavaScript off, on a theme without RequireJS, or when the request itself fails, the click performs the previous native submit and lands on the quote-cart page exactly as before. Hyvä stays on that native path — it ships no RequireJS, so the component never runs there; a Hyvä-native version needs the Alpine/app/design layer this module has never had (see README, "Requirements and scope").

  • Controller\Cart\Add answers an XMLHttpRequest with JSON — {success, message, count}, including count so the header badge repaints without a second customer/section/load round trip, and including a 200-with-success: false payload for every refusal (out of stock, disabled, not signed in) instead of a redirect the browser cannot show. The AJAX branch deliberately queues NO session message: the message travels in the payload, and a queued copy would resurface on the customer's next ordinary page load as a phantom duplicate. The non-AJAX branch is byte-for-byte unchanged — same redirect targets, same session messages — because it is simultaneously the no-JavaScript fallback, the component's own failure fallback and the only path Hyvä has; Test\Integration\Controller\AddAjaxControllerTest locks both branches.

  • Controller\Cart\Transfer no longer gates on being signed in. Its guard is now the single QuoteCartAccessInterface::isAvailable() question; isLoggedIn() is still consulted, but only to choose which refusal the visitor sees ("Please sign in…" + the login page when signing in could plausibly help, "The quote cart is not available." + home otherwise). Behaviour for every visitor is byte-for-byte unchanged with only this module installed, because isAvailable() is false for exactly the visitors the old login gate rejected. The point is that a module which relaxes that seam — Qoliber_QuoteRequestGuest — no longer has a login check standing in front of the permission it just granted. Guests do have ordinary shopping carts, so nothing else in this route needs an account. QuoteCartManagementInterface::transferFromCheckoutCart()'s documented precondition is restated accordingly: "the visitor may use the quote cart", not "a customer is logged in".

  • The header quote-cart link is now a real header icon, in the header-wrapper container next to the minicart, instead of hanging off top.links. On Luma, top.links is moved into the account dropdown, whose block renders nothing unless customerLoggedIn() is true — and that is false by construction on a full-page-cached page, so the link never reached the storefront at all. header-wrapper is declared by the Magento_Theme module (not by Luma) and is where core's own minicart lives, so the icon renders for every theme. Visibility and the badge number remain pure private content (qoliber-quote-cart customer-data section); no server-side isLoggedIn() gate was reintroduced. The icon is now icon-only with an aria-label accessible name, and the badge stays aria-hidden so it is not announced as a stray digit.

  • Out-of-stock products can no longer be added to the quote cart (product-owner policy reversal — a quote cart used to treat "out of stock" the same as hide-price, as legitimately quotable; it no longer does). QuoteCartManagement::addProduct() now rejects with an explicit LocalizedException ("This product is out of stock and cannot be added to a quote request.") checked BEFORE the product ever reaches Quote::addProduct(). The check reads MSI's Magento\InventorySalesApi\Api\IsProductSalableInterface for the current website's stock — deliberately NOT Magento\Catalog\Api\Data\ProductInterface::isSalable(), which Qoliber_QuoteRequestHidePrice forces false on purpose for hide-price products. A hide-price product that is genuinely in stock remains fully quotable; only real stock-status is now enforced.

Added

  • submit_form_fields — an extension slot inside the quote-cart page's submit form. Any block another module registers under that alias on qoliber.quoterequestcart.cart.index renders between the form key and the Submit button, so its inputs are POSTed to quoterequest/cart/submit with the form key already in place. Deliberately anonymous: this module gains no knowledge of who fills it, and with nothing there the rendered markup is unchanged.

  • "Request a Quote" on the shopping-cart page. A logged-in customer can build an ordinary shopping cart and hand it over to the quote cart in one click (quoterequest/cart/transfer, POST + form key). The hand-off moves the lines: every quotable line is re-added to the quote cart through QuoteCartManagementInterface::addProduct() — carrying its own recovered info_buyRequest, so configurable/bundle/grouped/custom-option selections and quantities survive, and every validation rule (disabled, wrong website, out of stock, max qty, config gates) is enforced in exactly one place — and is then removed from the shopping cart. The customer lands on the quote cart to review and submit: this route never creates a quote request, so the quote cart remains the single submission path. A partial hand-off is reported honestly instead of being hidden: a line that can no longer be quoted is skipped, left in the shopping cart, and named to the customer in a warning message; only the lines that really moved are removed. When nothing can be moved, the shopping cart is left completely untouched and the customer gets an error rather than a misleading success.

  • Api\QuoteCartManagementInterface::transferFromCheckoutCart() and the Api\Data\CartTransferResultInterface result contract (getMovedItemCount(), getSkippedProductNames()) that backs it. Existing method signatures are unchanged.

  • The quote cart: a dedicated basket (a Magento\Quote\Model\Quote flagged qoliber_is_quote_cart and kept is_active = 0) that a logged-in customer fills and submits as a single quote request. Replaces the previous "convert your shopping cart into a quote" flow.

  • "Add to Quote" button on the product page for salable products, submitting Magento's real #product_addtocart_form so configurable, bundle, grouped and custom-option selections post correctly.

  • Quote-cart page (quoterequest/cart/index) with line list, qty update, remove and Submit; storefront controllers for add / updateqty / remove / submit, all POST + form key.

  • Header link with item count, served as private content through the qoliber-quote-cart customer-data section and invalidated by every mutating route.

  • Api\QuoteCartManagementInterface and Api\QuoteCartResolverInterface as the module's public contracts.

  • Admin configuration under Qoliber → Quote Request → Quote Cart: enable/disable, allowed customer groups (empty = all), Add-to-Quote button label, and max qty per line. All entry points also honour the family's qoliber_quote_request/general/enabled master switch.

Fixed

  • The "Add to Quote" button now really reaches quoterequest/cart/add on Luma. Its HTML5 form/formaction association was silently defeated by core Catalog's own Magento_Catalog/js/validate-product widget, which hands the product form's submit to the AJAX add-to-cart widget; that widget posts to the FORM's action and ignores the submitter's formaction, so every click quietly added the product to the ordinary shopping cart. A new view/frontend/web/js/add-to-quote.js cancels the click and re-submits the same form (so the full buy request still travels) with the native DOM submit(), which fires no submit event and therefore cannot be intercepted. The form/formaction attributes are kept: themes without RequireJS (Hyvä) never load the component and rely on the native path, which works there.
  • Login-gated quote-cart markup no longer disappears when the full-page cache is enabled. Magento clears the customer session before blocks render on every FPC-cacheable GET (Magento\Customer\Model\Layout\DepersonalizePlugin), so CustomerSession::isLoggedIn() is false by construction there and both the PDP button and the header link vanished for genuinely logged-in customers. ViewModel\AddToQuoteButton now reads Magento\Framework\App\Http\Context's CONTEXT_AUTH (part of the FPC cache key, and unaffected by depersonalization), and the header templates no longer gate themselves server-side at all — they render for everyone and the qoliber-quote-cart customer-data section's new available flag decides visibility.
  • The header badge no longer goes stale after "Add to Quote". The native form submit above bypasses Magento_Customer's customer-data submit hook, so the component invalidates the affected sections itself, resolved through Magento_Customer/js/section-config.
  • The header item count is no longer server-rendered into a full-page-cached block. Magento's FPC varies on customer group, not customer id, so a per-customer count baked into a cached page was served to every other customer in that group.
  • Read paths (header, customer-data section, cart page) no longer create a quote cart. They use findExisting(), so a page view can no longer insert a quote row for a customer who never used the feature — previously every logged-in visitor got one on their first uncached page view.
  • addProduct() now refuses disabled products and products not assigned to the current website, matching core's own add-to-cart validation. Hide-price products remain accepted (see "Changed" above for the separate, later out-of-stock policy reversal).
  • The quote cart is now scoped to the current website's shared store ids and re-pinned to the active store view, so a cart built on one store can no longer produce a quote request priced and scoped to another (wrong currency, wrong admin scope, silently dropped lines).
  • submit() is guarded by a compare-and-set on the quote-cart row: concurrent submissions from two sessions can no longer create two quote requests from the same items. A failure before the quote request is committed releases the claim, so a cart is never stranded.
  • The header link is attached to top.links (declared by Magento_Theme) instead of the Luma-only header.links, so non-Luma themes get an entry point.

Hyvä Templates

qoliber/quote-request-cart-hyva

Initial release.

Added

  • This module is now the Hyvä compat module for FIVE original modules. etc/frontend/di.xml gains a fifth registration, against Qoliber_QuoteRequestCheckout, plus a new template, quote/leave-checkout.phtml — the "this cart is locked to an accepted quote" notice on the cart and checkout pages. Checked for a relative-path collision with the other four originals (none: quote/leave-checkout.phtml is distinct from Qoliber_QuoteRequestFrontend's quote/* paths), so it sits at the flat path like everything else here.

  • cart/request-quote-button.phtml for Qoliber_QuoteRequestCart — the shopping-cart page's "Request a Quote" button in checkout.cart.methods, next to "Proceed to Checkout". This module was already the compat module for Qoliber_QuoteRequestCart; this closes the one template of that original it had not yet covered. No Alpine, matching the Luma original — the button is an ordinary form POST.

  • This module is now the Hyvä compat module for the WHOLE Quote Request storefront. etc/frontend/di.xml gains a fourth registration, against Qoliber_QuoteRequestFrontend, so the "My Quote Requests" list and the quote-detail screens render Hyvä markup through the same mechanism as the rest — Cart + Guest + HidePrice + Frontend. This closes the last release's "known gap": the guest quote page's items, totals and comments are no longer Luma tables.

    Six templates migrated from Qoliber_QuoteRequestHyva, rewritten rather than copied (see below): quote/list.phtml, quote/view.phtml, and quote/view/{items,totals,comments,actions}.phtml. Each preserves its Luma original's ViewModel call set, conditional branches, form actions, form keys, expected_version fields, escaping and hook class names exactly; only presentation changed.

    Why the migration was necessary, and not just cosmetic: Qoliber_QuoteRequestHyva was wired through theme-scoped layout/override/base/ XML keyed on the two customer page handles. The items/totals/comments blocks are declared on the guest handle too, by Qoliber_QuoteRequestGuest, so those overrides never reached the guest page — a guest opening their tokenized quote link on a Hyvä store view saw Luma data table table-order-items tables inside an otherwise-Hyvä frame. The compat registry keys on the template path, so one registration covers every handle.

  • qoliberQuoteRequestActions in view/frontend/web/js/quote-cart.js — a fourth Alpine.data() component, and the CSP-safe replacement for the jQuery widget the Luma actions template boots through data-mage-init (which can never run on a Hyvä store view: no RequireJS). Same two behaviours, nothing more — window.confirm() before a reject POST with the same message, and disabling the submit buttons once a form is on its way. Unlike the two PDP components it may set disabled, because it hangs off the form's submit event rather than a click that has to reach a native submit; README.md explains the distinction. If Alpine never loads, all three forms submit natively — the Luma behaviour with JavaScript disabled.

  • This module is now the Hyvä compat module for the whole quote-cart storefront FAMILY, not just the quote cart. etc/frontend/di.xml registers it against three original modules — Qoliber_QuoteRequestCart (as before), plus Qoliber_QuoteRequestGuest and Qoliber_QuoteRequestHidePrice. One compat module for several originals is exactly what Hyva\CompatModuleFallback\Model\CompatModuleRegistry is built for: it keys on the original module name and appends, so the same compat module may appear under any number of keys. The module was not renamed — the name is a composer package, a registration.php key, an app/etc/config.php entry and a view-path prefix all at once, so renaming it would be a breaking change for cosmetic gain; README.md says so up front instead.

    The registry's per-module fallback folders (view/frontend/templates/<Orig_Module>/…, which exist precisely for one compat module serving several originals) are not used, because the eight relative template paths this module now serves are pairwise distinct. The templates therefore sit at the flat path, mirroring their originals exactly. README.md records what to do if that ever stops being true.

    Qoliber_QuoteRequestGuest and Qoliber_QuoteRequestHidePrice are suggest, not require: registering an original module that is not installed is inert.

  • Four Hyvä templates for Qoliber_QuoteRequestGuest. Until now the guest half of the storefront fell back to Luma markup on a Hyvä store view — including the tokenized guest quote-request page a guest reaches from their email, which rendered essentially unstyled.

    • guest/view.phtml — the page wrapper: title, intro, status/expiry, all five children in order (items, totals, comments, actions, account_upgrade), "Continue Shopping".
    • guest/actions.phtml — proceed-to-checkout / accept / reject. Three separate POST forms, three separate form keys, the access token in a hidden field on all three (the action URLs are token-less on purpose), expected_version on accept and reject and deliberately not on checkout. Nothing about those guards was relaxed; they are the reason the page is safe.
    • guest/create-account.phtml — the optional account upgrade. An unauthenticated POST that creates a customer account: the email stays text, never an input, the hidden token and the form key stay, and both field names still come from the controller's own constants via the ViewModel.
    • cart/contact-fields.phtml — the email/name/company fieldset, still rendered by Qoliber_QuoteRequestCart's anonymous submit_form_fields slot and therefore still POSTed inside the quote cart's own submit form, with its form key. (This was listed as a "known gap" in the previous release; it is closed.)
  • A Hyvä template for Qoliber_QuoteRequestHidePrice — hideprice/cta.phtml, the PDP "Request a Quote" call to action. Both branches survive: the logged-in POST form and the sign-in link for a visitor who cannot use it. Auth still comes from Qoliber\QuoteRequestHidePrice\Model\CustomerContext (the FPC-safe CONTEXT_AUTH read), never from CustomerSession — a server-side login gate silently vanishes under full-page cache, and that bug already shipped once here. The <script type="text/x-magento-init"> block is dropped on purpose: a Hyvä store view ships no RequireJS, so the component it boots can never run, while the <form action=…> underneath it was always the complete mechanism.

  • qoliberHidePriceCta in view/frontend/web/js/quote-cart.js — a third Alpine.data() component, a busy state only, on the same terms as qoliberAddToQuote (never preventDefault() on the first click, never disabled, because either cancels the native submit). One JS file still serves all three original modules; the Guest templates need no JavaScript at all.

  • First release — Hyvä (Tailwind/Alpine) storefront templates for Qoliber_QuoteRequestCart. Until now the Hyvä store view fell back to that module's theme-agnostic, Luma-styled markup for everything quote-cart related: the PDP "Add to Quote" button, the header quote-cart icon and the quote-cart page. All three now have real Hyvä twins.

    Wired through Hyvä's own compatibility-module view fallback, not through layout XML and not through files shipped into a merchant's theme: etc/frontend/di.xml registers this module with Hyva\CompatModuleFallback\Model\CompatModuleRegistry against Qoliber_QuoteRequestCart, and a .phtml at the same relative path then wins — but only while a Hyvä theme is active (Hyva\Theme\Service\CurrentTheme::isHyva()). Luma and every other non-Hyvä theme are provably unaffected; there is a Playwright test asserting exactly that. The same registration is what adds this module to app/etc/hyva-themes.json, which is what gets its templates into the Tailwind content scan — load-bearing twice over, for the override and for the CSS.

    Nothing about Qoliber_QuoteRequestCart changed: same blocks, same layout, same ViewModels, same form actions, same form keys, same submit_form_fields extension slot inside the submit form.

  • view/frontend/web/js/quote-cart.js — the two Alpine components (qoliberQuoteCartHeader, qoliberAddToQuote), registered with Alpine.data().

Removed

  • Qoliber_QuoteRequestCart's dead view/frontend/templates/header/link_hyva.phtml — never wired into any layout, and fully superseded by this module's own header/link.phtml. See that module's README.md for the (now corrected) note.

Notes on how it is written (please preserve)

  • CSP-safe by construction. No inline <script>, no inline <style> and no inline Alpine expression anywhere: components are registered with Alpine.data() in an external file, x-data names a registered component, handlers are bare method references, and every server-side value reaches a component through a data-* attribute read in init(). That is the only form Alpine's CSP build can evaluate, and it behaves identically on a non-CSP Hyvä theme, so the templates are portable in one direction: written CSP-safe, they run everywhere. See README.md.
  • Tailwind 3 and 4. The class subset was chosen so every utility compiles to the same declarations under [email protected] and [email protected] (verified by compiling every class this module emits under both and diffing). The concrete rules — rounded-md over rounded-sm, shadow-md over shadow-sm, ring-2 with an explicit ring colour, border/divide-y always with an explicit colour, shrink-0/text-ellipsis, slash opacity, no outline-none, explicit cursor-pointer on buttons, and no theme component classes — are documented in README.md.
  • The header icon's count AND its visibility are private content, from the qoliber-quote-cart customer-data section over Hyvä's private-content-loaded event. Nothing about it is server-rendered: the block sits on every full-page-cached page, where Magento depersonalizes the customer session before blocks render.
  • The PDP button submits Magento's real #product_addtocart_form via the HTML5 form/formaction attributes, so a configurable/bundle/grouped buy request travels. The Alpine component is a busy state only — it never calls preventDefault() on the first click and never sets disabled, because either one cancels the native submit.

Checkout

qoliber/quote-request-checkout

Initial release.

  • Negotiated-price survival through Magento's totals/shipping recalculation, both tax-inclusive and tax-exclusive tax bases.
  • Cart lock: blocks cart-rule/coupon application on a cart bound to an accepted quote request.
  • Availability\ItemAvailabilityChecker: deleted/disabled/out-of-stock/qty-short precedence, aborts the cart load on any changed line rather than proceeding with stale availability.
  • Full accept → cart → order lifecycle, driven entirely through Core's QuoteRequestManagementInterface transition primitives — no local transition logic.
  • Quotes never reserve, hold, or decrement stock while under negotiation (see README).

Quality gate: phpcs, phpstan level 8 (no baseline), phpmd — all exit 0. Unit and integration suites green (see the family-wide totals in the metapackage release-hardening report).

Changed

  • Model\QuoteToCartManagement::load() no longer refuses a quote request that has no owning customer. It used to throw "Quote request checkout requires a signed-in customer." the moment customer_id was NULL — a value Core allows and QuoteRequestManagement::createFromCart() produces for any cart that had none. It now branches on who owns the quote request: an owned one is built into that customer's cart exactly as before (byte-for-byte unchanged), an unowned one into the cart the presenting visitor's own session already carries (Magento\Checkout\Model\Session::getQuote()), whose checkout quote id is then pointed at it because the service deliberately never saves through Magento\Checkout\Model\Cart.
  • The buyer's identity from the quote request (email, and name when present) is stamped onto that cart only when the cart has no customer id, so a signed-in visitor's cart can never be relabelled with a quote request's email. The condition is on the CART, not on the quote request: "who owns this quote" and "who is shopping right now" are different questions and only the second may decide what the cart says about its buyer.
  • This is one rule with two arms, not a mode: nothing here asks whether the visitor is signed in, and every later step — negotiated price, availability, the lock, the integrity net, the accepted → ordered observer — is the same code either way. It is what lets Qoliber_QuoteRequestGuest offer a token-scoped checkout without reimplementing any of it.

Storefront

qoliber/quote-request-frontend

Initial release.

  • Cart-page "Request a Quote" button submitting the current cart via Core's createFromCart.
  • My Quotes list + detail views over Core's customer-safe DTO, with Accept / Reject / Request Revision customer controllers, each calling Core's transition primitives.
  • Theme-agnostic ViewModels — all storefront logic lives in PHP, never .phtml, so the optional Hyvä package can be templates-only.
  • Ownership-guard coverage on every storefront controller (fail-closed); CSRF-safe state-changing actions; output-escaping on every customer-controlled field (customer_note, comment bodies, name, company).

Quality gate: phpcs, phpstan level 8 (no baseline), phpmd — all exit 0. Unit and integration suites green (see the family-wide totals in the metapackage release-hardening report).

Removed

  • The cart-page "Request a Quote" button and its Controller/Quote/Submit hand-off, which quoted the checkout-session (shopping) cart into an RFQ. Superseded by the dedicated quote cart (qoliber/quote-request-cart): quoting now happens only through quoterequest/cart/*, never through the shopping cart. The .qoliber-quote-request-button-container LESS rule, the orphan "Your cart is empty." i18n string, and the now-unused Magento_Checkout module dependency (its only consumer was the removed controller) were removed along with it.

GraphQL API

qoliber/quote-request-graph-ql

Initial release. Customer-safe GraphQl schema and resolvers for Qoliber Quote Request.

Guest Quote Requests

qoliber/quote-request-guest

Initial release.

Added

  • A guest is told their emailed link is personal to them. Plugin\GuestNotificationVars (global etc/di.xml, like its URL sibling — emails render in the crontab area) sets the is_guest_link template variable Qoliber_QuoteRequestNotifications publishes as false, and that module's customer-facing templates gate a "please do not forward it" line on it. A logged-in customer never sees it. The flag is derived from Model\Url\GuestQuoteRequestUrl::getViewUrl() — the same call that decides whether the link is tokenized at all — rather than from a customer_id === null guess, which would be wrong for an anonymized customer and for a guest on a store with the feature switched off. All three branches asserted in Test/Integration/GuestSubmissionEmailTest.php.

Fixed

  • A guest's submission email now carries their tokenized link. Plugin\GuestNotificationUrl rewrote the one view_url variable correctly all along, but the submission confirmation template rendered no link at all — so the first and (until a merchant responds) only email a guest receives stranded them, while the page it should have pointed at tells them to keep the link. The fix is in Qoliber_QuoteRequestNotifications and is generic; nothing guest-specific was added there and no code in this module changed. Pinned here by Test/Integration/GuestSubmissionEmailTest.php, which asserts the RENDERED message rather than the template variable — a variable no template renders reaches nobody, which is exactly how this survived the existing variable-level tests. It covers both audiences of that shared template: the guest gets their own token, a customer-owned quote request keeps the ordinary customer link and receives no token of any kind, and with the merchant's switch at its shipped default no dead magic link is sent.

Added

  • Browser coverage for guest checkout (dev/tests/playwright/tests/storefront/guest-quote-checkout.spec.ts): a guest builds a quote cart, submits it, returns from the emailed link in a fresh cookieless browser context, accepts the proposal, clicks Proceed to Checkout and completes Magento's own guest checkout to a placed order. It then reads back, from the database, that the quote request reached ordered with its order_id/ordered_at stamped and that the ORDER charged the negotiated price. That last part is what the integration suite structurally cannot prove: sales_order_save_commit_after is never dispatched inside the integration harness, so GuestCheckoutTest has to invoke Observer\SetQuoteRequestOrdered by hand.

  • Guest quote carts, off by default. With the new Allow Guest Quote Requests switch (Stores → Configuration → Qoliber → Quote Request → Guest Quote Requests, default No) a visitor who is not signed in can build a quote cart. With the switch at its shipped default the module is completely inert: a guest sees no "Add to Quote" button and no header link, QuoteCartResolverInterface::resolve() still throws "Please sign in to use a quote request cart." and findExisting() still returns null — byte-for-byte today's behaviour.

  • Model\Access\GuestQuoteCartAccess — decorates Qoliber_QuoteRequestCart's Api\QuoteCartAccessInterface. A guest is allowed only when this module's switch AND every gate that already existed (Core's master switch, the quote cart's own switch, and the allowed-customer- groups list applied to group 0 / NOT LOGGED IN) all say yes. A signed-in customer's verdict is delegated verbatim to the wrapped implementation.

  • Model\GuestQuoteCartResolver — decorates Api\QuoteCartResolverInterface. Serves a guest a quote cart keyed by Model\GuestQuoteCartSession; delegates every signed-in customer, and every guest while the feature is off, to the wrapped resolver. A guest quote cart is still qoliber_is_quote_cart = 1 and is_active = 0, with customer_is_guest = 1, customer group 0 and no customer id. It reuses the customer path's store scoping exactly: shared across a website's store views, never across websites, re-pinned to the active store view.

  • Model\GuestQuoteCartSession — a Magento\Framework\Session\SessionManager with its own storage namespace holding the guest's quote-cart id (guest carts therefore last exactly as long as the session). A stored id is re-verified on every load — still an active quote cart, still guest-owned, still on the current website — so a stale or planted id can never hand over someone else's cart; it simply reads as "no cart". findExisting() never creates a row.

  • Both decorators are wired in etc/frontend/di.xml, so adminhtml, CLI/cron and the WebApi/GraphQL areas keep the un-decorated behaviour even with the switch on.

  • Guests can SUBMIT their quote cart. Core's createFromCart() refuses a cart with no customer email, so ViewModel\GuestContactFields + cart/contact-fields.phtml render an email (required) plus optional first name, last name and company into Qoliber_QuoteRequestCart's new, deliberately anonymous submit_form_fields slot — INSIDE the existing submit form, so they are POSTed to quoterequest/cart/submit with its form key. Plugin\GuestQuoteCartSubmit applies them to the guest's cart in a beforeSubmit() and forgets the guest session id in an afterSubmit(). There is no second submit route and no fork of the Cart module's template. The resulting quote request has customer_id NULL, customer group 0 (NOT LOGGED IN) and the guest's email.

  • Server-side email validation with Magento\Framework\Validator\EmailAddress (core's own storefront check; no DNS/MX lookup, so a submission cannot hang on a network call). A missing or malformed address is refused BEFORE anything is claimed or created — no quote request, no state change, and the guest's cart is left intact so they can correct it and retry. The required /type="email" attributes on the field are a convenience for the visitor, never the check.

  • Observer\MergeGuestQuoteCartOnLogin on customer_login (frontend only), mirroring Magento\Checkout\Model\Session::loadCustomerQuote(): a customer with no quote cart ADOPTS the guest row in place (re-owned via assignCustomer(), customer_is_guest cleared — nothing copied, so nothing can be duplicated); a customer who already has one gets the guest lines folded in with Quote::merge(), which SUMS an identical product+buy-request instead of duplicating it, after which the emptied guest row is deleted. Either way the guest session id is consumed — the gap the previous release deliberately left open. A failure is logged and never breaks the login.

  • The shopping-cart hand-off ("Request a Quote", quoterequest/cart/transfer) now works for guests when the switch is on, with unchanged move semantics and partial-transfer reporting. This needed one change in Qoliber_QuoteRequestCart — its standalone "is signed in" gate became a plain QuoteCartAccessInterface::isAvailable() gate — and no guest-specific code there.

  • Model\GuestVisitor — the single answer to "is this request a guest this module should act for", extracted so the resolver, the submit plugin and the contact-fields ViewModel cannot drift apart.

  • Model\GuestQuoteCartLocator — the read-only, re-verifying lookup of the stored guest cart id, extracted out of the resolver so the login-merge observer runs exactly the same checks (it cannot use the resolver: by then the visitor is no longer a guest).

  • Tokenized, login-free access to a submitted guest quote request. A guest can now open, accept and reject their proposal from the emailed link — quoterequest/guest/{view,accept,reject}, joined to the shared qoliber_quoterequest route rather than adding a second frontName. Accept and reject are POST + form key and go through Core's QuoteRequestManagementInterface with the same expected_version optimistic lock the customer controllers use; the status column is never written here. Requesting a revision is deliberately not offered to a guest.

  • A nullable, indexed access_token column on qoliber_quote_request, declared by THIS module's etc/db_schema.xml — Core knows nothing about guest links, and a merchant who never installs this module never gets the column. bin/magento setup:upgrade is required.

  • Model\Token\TokenGenerator / TokenIssuer / AccessTokenStorage — bin2hex(random_bytes(32)) (256 bits, 64 URL-safe hex characters), minted on the existing guest submit path the moment Core has durably created the quote request, scoped to that one quote request, never issued to a customer-owned one and never re-issued. Stored with a single targeted one-column UPDATE rather than a repository save, so Core's optimistic-lock version and its model events are untouched.

  • Model\Security\GuestAccessGuard — the single entry point all three controllers use, and the guest counterpart of Core's OwnershipGuard. Constant-time hash_equals() (never ==/===), and no oracle: a wrong token, an absent token, a token belonging to another quote request and a non-existent quote-request id all leave through one return null and become the same 404 — with no early exit anywhere between the load and the comparison, so a refusal cannot be identified by the work that was skipped. A CUSTOMER-owned quote request is unreachable through these routes however the token parameter is supplied, including not at all: the classic null-equals-null trap is closed three independent ways (a same-shaped decoy is compared when there is no stored token, an empty stored token is an explicit separate refusal, and a row with a customer id reads as tokenless).

  • ViewModel\GuestQuoteView EXTENDS Qoliber_QuoteRequestFrontend's QuoteView, and the guest page reuses that module's items, totals and comments templates unmodified — only the page wrapper and the action buttons are forked, because only their links differ. The rendered DTO is Core's own customer-safe one, which carries no token.

  • Guest notification emails carry the tokenized link, via one after plugin on Qoliber_QuoteRequestNotifications' Model\Url\QuoteRequestUrl::getViewUrl() — the single place that module builds a deep link. No forked email template, no second outbox/sender/cron, and a customer-owned quote request's emails are returned byte for byte unchanged.

  • Fixed: after submitting, a guest was redirected to the customer-only quoterequest/quote/view and landed on the login page holding a success message (the gap the previous release documented). Plugin\GuestSubmitRedirect now rewrites that redirect to the guest's own tokenized view. A signed-in customer's redirect is untouched.

  • The optional account upgrade. The guest quote-request page now offers Create an account (quoterequest/guest/createaccount, POST + form key, token in a hidden field, authorized by the same GuestAccessGuard — so it is a 404 while the merchant's switch is off). Model\AccountUpgrade creates a password-less customer for the address already on the quote request, in that quote request's website/store, and links the existing quote request to it: same entity id, same increment id, same items, same comments and the same history, and no second quote request. Magento's own set-password email is what the visitor receives (AccountManagementInterface::createAccount() with a NULL password). This module never sets a password, never generates one, never emails one, and never signs the visitor in — it redirects to the login page. The address is read off the quote request, never from the POST body.

  • An address that already has an account is deliberately NOT linked. Holding a guest token proves only that somebody typed that address into the submit form — the link is handed back in the browser redirect, with no email round trip — so it is not proof of mailbox control, and attaching an RFQ to a real customer's account on that evidence would be impersonation. Creating a brand-new, password-less account for the same address is safe by contrast, because nobody can sign into it until the mail is received. See the module README for the full argument.

  • No account-existence disclosure, enforced structurally: AccountUpgrade::upgrade() returns void, so the controller has nothing to branch on — same message, same redirect, same status code either way. Both paths also send exactly one email to the address itself (a set-password link, or core's own password-reset link), so "check your email" is true in both.

  • Token policy — the emailed link is RETIRED by an upgrade, in both cases: access_token goes back to NULL. Keeping it would be a permanent, unrevokable bypass of Core's OwnershipGuard for a row the account's owner now owns, and retiring it in only one of the two cases would itself be an account-existence oracle (a visitor could just re-open their link). Links already sent stop working — accepted knowingly; every later notification email falls back to the ordinary customer link. A refused upgrade (blank name, system failure) retires nothing and changes nothing.

  • ViewModel\GuestAccountUpgrade + guest/create-account.phtml — the offer itself, rendered into the guest view page. The email is shown as text, not as an input.

  • Model\Token\AccessTokenStorage::retire() — clears the column AND blanks the in-memory aggregate, so a later repository save of the same object cannot resurrect a retired link.

  • A guest can BUY the quote they accepted — as a guest, with no forced account. The tokenized page now offers Proceed to Checkout (quoterequest/guest/checkout, POST + form key, token in a hidden field, authorized by the same GuestAccessGuard — so it is a 404 while the merchant's switch is off), shown only while the quote request is accepted and not past expires_at. Controller\Guest\Checkout delegates the entire conversion to Qoliber_QuoteRequestCheckout's Api\QuoteToCartManagementInterface::load() — the same service the customer route calls — so the negotiated custom_price handling, the checkout-time availability re-check, the expiry/currency assertions, the cart lock and the pre-placeOrder integrity net are literally the same code for both audiences. It restates no rule of its own: even "only an accepted quote may be checked out" is asserted by that service, not here. Requires Allow Guest Checkout (Stores → Configuration → Sales → Checkout → Checkout Options) so Magento's own checkout can finish the order.

  • The cart it builds is an ordinary guest cart — customer_is_guest = 1, no customer id, customer group 0, carrying the quote request's email (and name, when it has one) — and it is the cart the presenting visitor's own session already carries, with the checkout session's quote id set to it. A cart with no customer id cannot be found by looking anyone up, so that session handle is the only one that exists and a second visitor cannot inherit it.

  • A quote request the visitor genuinely holds the token for but which is not accepted is refused with the service's own message and a redirect back to their page — deliberately not a 404, because the guard has already said they may see that row. Everything the guard refuses stays a 404, byte-identically, exactly as before.

  • Qoliber_QuoteRequestCheckout's order observer needed no change: it keys off the cart lock, which is customer-independent, so a guest's placed order transitions the quote request accepted → ordered and stamps order_id/ordered_at through Core's markOrdered() as usual.

  • End-to-end browser coverage for the whole guest journey (dev/tests/playwright/tests/storefront/guest-quote-request.spec.ts): nobody signed in, PDP → quote cart → submit with an email → tokenized view → accept, and a second run that upgrades to an account and proves the emailed link then 404s. Nothing about the quote request is pre-seeded; only the merchant's half of the negotiation is driven through Core's own management service.

Changed

  • Now depends on Qoliber_QuoteRequestNotifications (a required member of the qoliber/quote-request metapackage) because of the email-link plugin above.
  • Now also depends on Qoliber_QuoteRequestCheckout (likewise a required metapackage member), because the guest checkout route delegates the whole conversion to its Api\QuoteToCartManagementInterface and reads its cart lock.
  • Model\Token\AccessTokenStorage::write() now accepts null (that is how a link is retired).

Hide Price

qoliber/quote-request-hide-price

Initial release. Hide-price policy for Qoliber Quote Request: hides price/add-to-cart per customer group or per-SKU, and lets a hide-price product start a quote request through its own Add to Quote hand-off.

Notifications

qoliber/quote-request-notifications

Initial release.

  • Transactional outbox: transition-event observers enqueue synchronously in the same transaction as the transition — a send failure never rolls back a transition.
  • Drain cron with retry/backoff over the outbox.
  • Expiry cron transitioning quotes past their expiry window through Core's transition primitive; terminal states are never re-expired.
  • Email templates audited for raw-modifier XSS on customer-controlled variables — none found/used.

Quality gate: phpcs, phpstan level 8 (no baseline), phpmd — all exit 0. Unit and integration suites green (see the family-wide totals in the metapackage release-hardening report).

Added

  • All nine notification emails rewritten to say what actually happened. Every template had been a greeting, one sentence and (sometimes) a link — 16 to 41 lines, none of which had ever been read by a human, because Magento's transport on the dev box had no working SMTP until now. Each one now states which quote request, whom it concerns, what changed, the relevant amounts and the next action.
  • Quoted line items and totals in every email, via a layout handle + block, the way core's order emails do it: view/frontend/layout/qoliber_quote_request_email_items.xml, Block\Email\Items and view/frontend/templates/email/items.phtml, pulled in with {{layout handle="qoliber_quote_request_email_items" request_id=$request_id audience="…" area="frontend"}}. Each line shows product name, SKU, qty, the price being offered and the row total, plus the catalog price where it differs; the totals follow. request_id is passed as a scalar (not the aggregate) so a merchant-edited copy of a template in the email_template table keeps working.
  • Model\Email\MoneyFormatter — formats in the quote request's own currency AND its store view's locale. Template variables are built by the drain cron, i.e. outside the mail template's own store emulation, so both had to be made explicit; the same machinery as the storefront quote page, so an amount reads identically in both.
  • Model\Email\DateFormatter — store locale and store timezone, reading the UTC columns explicitly.
  • Model\Email\TransitionContext — recovers the customer's rejection reason / revision request (a customer-visible comment) and the checkout-time drift reason (the history row's snapshot) at render time, since an outbox row carries only the quote request id.
  • Model\Email\OriginalPriceDisclosure + Model\Email\PriceVisibility — a catalog price is never printed to a customer whose group has price display hidden, nor for a hide-price SKU; the negotiated price always is. Reads the optional Qoliber_QuoteRequestHidePrice module's PERSISTED signals through etc/di.xml strings rather than taking a compile-time dependency on a module ADR-000 lists as optional; inert when it is not installed.
  • New template variables: request_id, customer_email, customer_message, drift_reason, currency_code, subtotal, negotiated_subtotal, savings, has_savings, tax_amount, shipping_amount, grand_total, expires_at, submitted_at, accepted_at, rejected_at, ordered_at, order_id, and is_guest_link (defaulted to false here, flipped by Qoliber_QuoteRequestGuest). The @vars annotation of every template lists what it actually uses, so the admin template editor stops advertising variables that were never rendered.
  • Test/Integration/Email/EmailContentTest — asserts the RENDERED MESSAGE BODY (items, totals, expiry, the customer's reason, the drift detail) for the proposal, the customer submission and three admin emails, plus that money is formatted in the quote's currency and not the store default. Renders in the crontab area with the crontab DI configuration, which is where the drain cron really renders them.
  • magento/module-directory and magento/module-catalog declared in composer.json/etc/module.xml for the two capabilities above.

Fixed

  • The proposal email never mentioned that the offer expires. Quote requests expire — expires_at drives the expiry cron and the storefront's "can this still be accepted" rule — and no email had ever carried the date. quote_request_proposal_sent now states it, formatted in the store's locale and timezone, and the expiry notice tells the customer what expired, when and what it was worth.

  • quote_request_submitted_admin no longer reaches through the aggregate with {{var quote_request.getCustomerEmail()}} — a method call that only works while a template is file-based and non-strict. It reads the scalar customer_email variable instead.

  • The submission confirmation now carries a link back to the quote request it is confirming. quote_request_submitted_customer.html rendered no {{var view_url}} at all, so the first — and, until a merchant responds, the only — email a buyer receives had no way back into their own quote request. It now renders the same single view_url variable Model\Email\TemplateVars::build() already published for every template and Model\Url\QuoteRequestUrl::getViewUrl() already built, so this is one template line and no new plumbing: no second URL builder, no email_templates.xml change, and a merchant's overridden template keeps working. Any module that resolves that one seam differently for its own audience is served correctly here too, without this module knowing about it. Found by an end-to-end mailbox run; regression test in Test/Integration/Email/EmailTemplateTest.php::testSubmissionConfirmationDeepLinkResolvesToQuoteDetailUrl, which asserts the customer route, the quote request's own id, and the absence of an SID or admin secret key.

  • New string View your quote request in i18n/en_US.csv.

REST API

qoliber/quote-request-web-api

Initial release. REST DTO exposure, webapi.xml routes, and idempotent mutation adapters for Qoliber Quote Request.

Changelog — Quote Request — Sales & Payments — Extensions | qoliber Docs