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_ratestorage to the quote request schema and service contract. Runbin/magento setup:upgradewhen 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_versioncolumn requiresbin/magento setup:upgradeon an existing installation. -
The approval guard could be bypassed. It let
pending_approvalthrough, on the reasoning that onlyApprovalWorkflowManagement::approve()reachessendProposal()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 bothunder_reviewandpending_approvalunless the quote's LATEST recorded decision is an approval (a quote sent back for rework and resubmitted cannot ride on an older one), andapprove()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 — andqoliber/quote-request-checkoutrefuses 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-checkout1.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 (
qoliberAddToQuoteand the hide-priceqoliberHidePriceCta). 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 witharia-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 oncheckout_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
frontendarea, and Magento's one-page checkout places the order throughPOST /rest/V1/carts/mine/payment-information— thewebapi_restarea — 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 intoCartManagementInterface::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 withQuote::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\AddSimpleProductToCartcallsQuote::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 lock could land on a cart the shopper was not using.
-
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_pricelines 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 ongetTotalsCollectedFlag()) and each address caches the item list it totals, and every action here replaces a cart's contents wholesale — soitems_count(whatMagento\Checkout\Block\Cartdecides "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; seeModel\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_beforeby 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.
CartIntegrityVerifierlogs 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_beforeon an untouched cart whose quote has been cancelled, has expired, or is alreadyordered; 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, repliedCANCELLED. 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_statestorage in the adapter's own replay table. Runbin/magento setup:upgradewhen 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
productform field and retain the existingproduct_idfallback.
Changed
- Declare the existing dependency on
qoliber/quote-request-cartin 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) andqoliber/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_statestorage in the adapter's own replay table. Runbin/magento setup:upgradewhen 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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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 toquoterequest/quote/checkout. The button is this module's own block after the quote view, soqoliber/quote-request-frontendis unchanged.
Tests
ProceedToCheckoutButtonTestrenders the real customer quote page: the button for an accepted quote; none forproposal_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\QuoteListnever 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_atdescending,entity_idas 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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.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-checkout1.0.2 (the customer's Proceed to Checkout button for an accepted quote) andqoliber/quote-request-frontend1.0.2 (My Quote Requests lists the newest first).
1.0.1 — 2026-09-14
Core
qoliber/quote-request-core
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-corepoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.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
savewith anaction_url, butMagento_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, checksQoliber_QuoteRequest::send_proposalitself, 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.jsonnow declaressupport.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_proposalresource 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.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-b2bpoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Quote Cart
qoliber/quote-request-cart
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-cartpoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Hyvä Templates
qoliber/quote-request-cart-hyva
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-cart-hyvapoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Checkout
qoliber/quote-request-checkout
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-checkoutpoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Storefront
qoliber/quote-request-frontend
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-frontendpoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
GraphQL API
qoliber/quote-request-graph-ql
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-graph-qlpoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Guest Quote Requests
qoliber/quote-request-guest
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-guestpoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Hide Price
qoliber/quote-request-hide-price
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-hide-pricepoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Notifications
qoliber/quote-request-notifications
Changed
composer.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-notificationspoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.0.1, which fixes the admin Send Proposal, Cancel Quote Request and Reopen buttons.
Fixed
composer.jsonnow requiresmagento/module-config. The admin configuration (etc/adminhtml/system.xml) usesMagento_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.jsonnow declaressupport.security(https://security.qoliber.com), socomposer show qoliber/quote-request-web-apipoints customers to the security disclosure route. No code changes in this package — released alongsideqoliber/quote-request-admin-ui1.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 aredecimal(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): nullscustomer_id, preserves the row, the submission-time email/name/company snapshot, and the status. - Per-store increment-ID sequencing on Magento's own
SalesSequencemachinery, configurable prefix (defaultQ-). - 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 isunder_review. Editing anaccepted,ordered,rejected,cancelled,expired,proposal_sent,submitted, orcustomer_revisionquote 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 newModel\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.jsmirrors core's owncatalogAddToCart.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 secondclickat all), getsaria-busy="true", and its label swaps to "Adding..."; on a successful add it flashes "Added" for one second (aria-busyalready 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 samearia-busyattribute) backs off underprefers-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 nativeform/formactionsubmit untouched, and a transport/parse failure still falls back tonativeSubmit()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.lessreuses 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__colorbackground,@page__background-colortext,@minicart-qty__height(24px) height/line-height — computed fromvendor/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 3pxand 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. A13pxmargin (the same value Luma's own.minicart-wrapperuses 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.emptyclassjs/quote-cart-count.jsalready 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.jsnow serializes the SAME#product_addtocart_formwithFormDataand 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 whyFormDataand 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 HTML5form/formactionattributes, 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/designlayer this module has never had (see README, "Requirements and scope"). -
Controller\Cart\Addanswers an XMLHttpRequest with JSON —{success, message, count}, includingcountso the header badge repaints without a secondcustomer/section/loadround trip, and including a 200-with-success: falsepayload 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\AddAjaxControllerTestlocks both branches. -
Controller\Cart\Transferno longer gates on being signed in. Its guard is now the singleQuoteCartAccessInterface::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, becauseisAvailable()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-wrappercontainer next to the minicart, instead of hanging offtop.links. On Luma,top.linksismoved into the account dropdown, whose block renders nothing unlesscustomerLoggedIn()is true — and that is false by construction on a full-page-cached page, so the link never reached the storefront at all.header-wrapperis 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-cartcustomer-data section); no server-sideisLoggedIn()gate was reintroduced. The icon is now icon-only with anaria-labelaccessible name, and the badge staysaria-hiddenso 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 explicitLocalizedException("This product is out of stock and cannot be added to a quote request.") checked BEFORE the product ever reachesQuote::addProduct(). The check reads MSI'sMagento\InventorySalesApi\Api\IsProductSalableInterfacefor the current website's stock — deliberately NOTMagento\Catalog\Api\Data\ProductInterface::isSalable(), whichQoliber_QuoteRequestHidePriceforces 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 onqoliber.quoterequestcart.cart.indexrenders between the form key and the Submit button, so its inputs are POSTed toquoterequest/cart/submitwith 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 throughQuoteCartManagementInterface::addProduct()— carrying its own recoveredinfo_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 theApi\Data\CartTransferResultInterfaceresult contract (getMovedItemCount(),getSkippedProductNames()) that backs it. Existing method signatures are unchanged. -
The quote cart: a dedicated basket (a
Magento\Quote\Model\Quoteflaggedqoliber_is_quote_cartand keptis_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_formso 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-cartcustomer-data section and invalidated by every mutating route. -
Api\QuoteCartManagementInterfaceandApi\QuoteCartResolverInterfaceas 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/enabledmaster switch.
Fixed
- The "Add to Quote" button now really reaches
quoterequest/cart/addon Luma. Its HTML5form/formactionassociation was silently defeated by core Catalog's ownMagento_Catalog/js/validate-productwidget, which hands the product form's submit to the AJAX add-to-cart widget; that widget posts to the FORM'sactionand ignores the submitter'sformaction, so every click quietly added the product to the ordinary shopping cart. A newview/frontend/web/js/add-to-quote.jscancels the click and re-submits the same form (so the full buy request still travels) with the native DOMsubmit(), which fires nosubmitevent and therefore cannot be intercepted. Theform/formactionattributes 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), soCustomerSession::isLoggedIn()is false by construction there and both the PDP button and the header link vanished for genuinely logged-in customers.ViewModel\AddToQuoteButtonnow readsMagento\Framework\App\Http\Context'sCONTEXT_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 theqoliber-quote-cartcustomer-data section's newavailableflag decides visibility. - The header badge no longer goes stale after "Add to Quote". The native form submit above bypasses
Magento_Customer's customer-data
submithook, so the component invalidates the affected sections itself, resolved throughMagento_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 aquoterow 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 byMagento_Theme) instead of the Luma-onlyheader.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.xmlgains a fifth registration, againstQoliber_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.phtmlis distinct fromQoliber_QuoteRequestFrontend'squote/*paths), so it sits at the flat path like everything else here. -
cart/request-quote-button.phtmlforQoliber_QuoteRequestCart— the shopping-cart page's "Request a Quote" button incheckout.cart.methods, next to "Proceed to Checkout". This module was already the compat module forQoliber_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.xmlgains a fourth registration, againstQoliber_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, andquote/view/{items,totals,comments,actions}.phtml. Each preserves its Luma original's ViewModel call set, conditional branches, form actions, form keys,expected_versionfields, escaping and hook class names exactly; only presentation changed.Why the migration was necessary, and not just cosmetic:
Qoliber_QuoteRequestHyvawas wired through theme-scopedlayout/override/base/XML keyed on the two customer page handles. Theitems/totals/commentsblocks are declared on the guest handle too, byQoliber_QuoteRequestGuest, so those overrides never reached the guest page — a guest opening their tokenized quote link on a Hyvä store view saw Lumadata table table-order-itemstables inside an otherwise-Hyvä frame. The compat registry keys on the template path, so one registration covers every handle. -
qoliberQuoteRequestActionsinview/frontend/web/js/quote-cart.js— a fourthAlpine.data()component, and the CSP-safe replacement for the jQuery widget the Luma actions template boots throughdata-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 setdisabled, 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.xmlregisters it against three original modules —Qoliber_QuoteRequestCart(as before), plusQoliber_QuoteRequestGuestandQoliber_QuoteRequestHidePrice. One compat module for several originals is exactly whatHyva\CompatModuleFallback\Model\CompatModuleRegistryis 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, aregistration.phpkey, anapp/etc/config.phpentry 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_QuoteRequestGuestandQoliber_QuoteRequestHidePricearesuggest, notrequire: 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_versionon 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 byQoliber_QuoteRequestCart's anonymoussubmit_form_fieldsslot 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 fromQoliber\QuoteRequestHidePrice\Model\CustomerContext(the FPC-safeCONTEXT_AUTHread), never fromCustomerSession— 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. -
qoliberHidePriceCtainview/frontend/web/js/quote-cart.js— a thirdAlpine.data()component, a busy state only, on the same terms asqoliberAddToQuote(neverpreventDefault()on the first click, neverdisabled, 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.xmlregisters this module withHyva\CompatModuleFallback\Model\CompatModuleRegistryagainstQoliber_QuoteRequestCart, and a.phtmlat 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 toapp/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_QuoteRequestCartchanged: same blocks, same layout, same ViewModels, same form actions, same form keys, samesubmit_form_fieldsextension slot inside the submit form. -
view/frontend/web/js/quote-cart.js— the two Alpine components (qoliberQuoteCartHeader,qoliberAddToQuote), registered withAlpine.data().
Removed
Qoliber_QuoteRequestCart's deadview/frontend/templates/header/link_hyva.phtml— never wired into any layout, and fully superseded by this module's ownheader/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 withAlpine.data()in an external file,x-datanames a registered component, handlers are bare method references, and every server-side value reaches a component through adata-*attribute read ininit(). 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-mdoverrounded-sm,shadow-mdovershadow-sm,ring-2with an explicit ring colour,border/divide-yalways with an explicit colour,shrink-0/text-ellipsis, slash opacity, nooutline-none, explicitcursor-pointeron 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-cartcustomer-data section over Hyvä'sprivate-content-loadedevent. 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_formvia the HTML5form/formactionattributes, so a configurable/bundle/grouped buy request travels. The Alpine component is a busy state only — it never callspreventDefault()on the first click and never setsdisabled, 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
QuoteRequestManagementInterfacetransition 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 momentcustomer_idwas NULL — a value Core allows andQuoteRequestManagement::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 throughMagento\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 → orderedobserver — is the same code either way. It is what letsQoliber_QuoteRequestGuestoffer 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/Submithand-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 throughquoterequest/cart/*, never through the shopping cart. The.qoliber-quote-request-button-containerLESS rule, the orphan"Your cart is empty."i18n string, and the now-unusedMagento_Checkoutmodule 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(globaletc/di.xml, like its URL sibling — emails render in the crontab area) sets theis_guest_linktemplate variableQoliber_QuoteRequestNotificationspublishes asfalse, 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 fromModel\Url\GuestQuoteRequestUrl::getViewUrl()— the same call that decides whether the link is tokenized at all — rather than from acustomer_id === nullguess, which would be wrong for an anonymized customer and for a guest on a store with the feature switched off. All three branches asserted inTest/Integration/GuestSubmissionEmailTest.php.
Fixed
- A guest's submission email now carries their tokenized link.
Plugin\GuestNotificationUrlrewrote the oneview_urlvariable 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 inQoliber_QuoteRequestNotificationsand is generic; nothing guest-specific was added there and no code in this module changed. Pinned here byTest/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 reachedorderedwith itsorder_id/ordered_atstamped and that the ORDER charged the negotiated price. That last part is what the integration suite structurally cannot prove:sales_order_save_commit_afteris never dispatched inside the integration harness, soGuestCheckoutTesthas to invokeObserver\SetQuoteRequestOrderedby 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." andfindExisting()still returnsnull— byte-for-byte today's behaviour. -
Model\Access\GuestQuoteCartAccess— decoratesQoliber_QuoteRequestCart'sApi\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— decoratesApi\QuoteCartResolverInterface. Serves a guest a quote cart keyed byModel\GuestQuoteCartSession; delegates every signed-in customer, and every guest while the feature is off, to the wrapped resolver. A guest quote cart is stillqoliber_is_quote_cart = 1andis_active = 0, withcustomer_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— aMagento\Framework\Session\SessionManagerwith 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, soViewModel\GuestContactFields+cart/contact-fields.phtmlrender an email (required) plus optional first name, last name and company intoQoliber_QuoteRequestCart's new, deliberately anonymoussubmit_form_fieldsslot — INSIDE the existing submit form, so they are POSTed toquoterequest/cart/submitwith its form key.Plugin\GuestQuoteCartSubmitapplies them to the guest's cart in abeforeSubmit()and forgets the guest session id in anafterSubmit(). There is no second submit route and no fork of the Cart module's template. The resulting quote request hascustomer_idNULL, 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. Therequired/type="email"attributes on the field are a convenience for the visitor, never the check. -
Observer\MergeGuestQuoteCartOnLoginoncustomer_login(frontend only), mirroringMagento\Checkout\Model\Session::loadCustomerQuote(): a customer with no quote cart ADOPTS the guest row in place (re-owned viaassignCustomer(),customer_is_guestcleared — nothing copied, so nothing can be duplicated); a customer who already has one gets the guest lines folded in withQuote::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 inQoliber_QuoteRequestCart— its standalone "is signed in" gate became a plainQuoteCartAccessInterface::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 sharedqoliber_quoterequestroute rather than adding a second frontName. Accept and reject are POST + form key and go through Core'sQuoteRequestManagementInterfacewith the sameexpected_versionoptimistic 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_tokencolumn onqoliber_quote_request, declared by THIS module'setc/db_schema.xml— Core knows nothing about guest links, and a merchant who never installs this module never gets the column.bin/magento setup:upgradeis 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-columnUPDATErather 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'sOwnershipGuard. Constant-timehash_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 onereturn nulland 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\GuestQuoteViewEXTENDSQoliber_QuoteRequestFrontend'sQuoteView, 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
afterplugin onQoliber_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/viewand landed on the login page holding a success message (the gap the previous release documented).Plugin\GuestSubmitRedirectnow 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 sameGuestAccessGuard— so it is a 404 while the merchant's switch is off).Model\AccountUpgradecreates 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()returnsvoid, 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_tokengoes back to NULL. Keeping it would be a permanent, unrevokable bypass of Core'sOwnershipGuardfor 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 sameGuestAccessGuard— so it is a 404 while the merchant's switch is off), shown only while the quote request isacceptedand not pastexpires_at.Controller\Guest\Checkoutdelegates the entire conversion toQoliber_QuoteRequestCheckout'sApi\QuoteToCartManagementInterface::load()— the same service the customer route calls — so the negotiatedcustom_pricehandling, the checkout-time availability re-check, the expiry/currency assertions, the cart lock and the pre-placeOrderintegrity 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
acceptedis 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 requestaccepted → orderedand stampsorder_id/ordered_atthrough Core'smarkOrdered()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 theqoliber/quote-requestmetapackage) 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 itsApi\QuoteToCartManagementInterfaceand reads its cart lock. Model\Token\AccessTokenStorage::write()now acceptsnull(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\Itemsandview/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_idis passed as a scalar (not the aggregate) so a merchant-edited copy of a template in theemail_templatetable 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 optionalQoliber_QuoteRequestHidePricemodule's PERSISTED signals throughetc/di.xmlstrings 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, andis_guest_link(defaulted to false here, flipped byQoliber_QuoteRequestGuest). The@varsannotation 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-directoryandmagento/module-catalogdeclared incomposer.json/etc/module.xmlfor the two capabilities above.
Fixed
-
The proposal email never mentioned that the offer expires. Quote requests expire —
expires_atdrives the expiry cron and the storefront's "can this still be accepted" rule — and no email had ever carried the date.quote_request_proposal_sentnow 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_adminno 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 scalarcustomer_emailvariable instead. -
The submission confirmation now carries a link back to the quote request it is confirming.
quote_request_submitted_customer.htmlrendered 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 singleview_urlvariableModel\Email\TemplateVars::build()already published for every template andModel\Url\QuoteRequestUrl::getViewUrl()already built, so this is one template line and no new plumbing: no second URL builder, noemail_templates.xmlchange, 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 inTest/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 requestini18n/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.