Changelog
Current version: 1.0.0
Every module in the suite is versioned and released together. Entries below are merged from each module's own changelog, newest first.
Qoliber_AdvancedOptionsInventoryStaging is the one exception: it is an Adobe
Commerce bridge that is not part of the 1.0.0 release and carries its own
0.1.0 pre-release line at the bottom of this page.
1.0.0 — 2026-08-22
Advanced Options (core)
First stable release of the Qoliber Advanced Options suite for Magento Open Source / Mage-OS, on both the Luma and Hyvä front ends.
Known theme differences, scheduled for 1.1.0. Neither changes what a customer is charged, and both are listed here rather than left to be discovered:
- Per-value image roles are partly inert on Luma. The gallery swap matches
by filename root against images already attached to the product, so "Replace
main image" does nothing unless the merchant also uploads that file to the
product gallery, and "Add to gallery" never adds.
role=tooltiprenders on neither theme. (The security half of this item — hidden images' URLs being published to every visitor — is fixed in 1.0.0.) - The excl-tax PDP price does not follow option selection on Hyvä. Stock Hyvä keeps both an incl-tax and an excl-tax price map; ACO keeps only the incl-tax one, so a store displaying prices excluding tax sees the base price stand still on the PDP while the option is selected. The cart, quote and order totals are correct throughout. Luma updates only its first price wrapper.
Before publishing, 1.0.0 was hardened for CE production through nine external
review rounds and a rehearsed, documented go-live. The last of those rounds is
grouped first, then the earlier hardening, then the feature history that
preceded it. (The suite-level overview lives in the metapackage CHANGELOG.md;
each capability module keeps its own grouped CHANGELOG.md.)
Release readiness
The last review rounds found no defect in the shipped runtime. Everything below is about the things that decide whether a release is trustworthy — whether the tests prove what they claim, and whether the published artifacts contain the reviewed code.
Fixed
- Nine value-dependency cascade tests were skipped as "missing fixture". The
fixture was never missing. Two id bugs hid behind that label: both render
modes of
seed-value-dependency-showcase.phppublishedPW_VALUE_DEP_DROPDOWN_PRODUCT_ID, so whichever seeder ran last won and the key normally addressed the SWATCH product; and the specs still carried option ids323/324/325and value ids477..492from a database that no longer exists. Each render mode now owns an env namespace (PW_VD_*for swatches,PW_VD_DD_*for dropdowns) and publishes its own option and value ids, since the specs address#aco-native-<optionId>directly and cannot discover them from the DOM.provision.shseeds both shapes. All nine now pass — 4 Luma, 5 Hyvä — covering initial gating, reveal-on-select, re-cascade with required auto-select, the optional-clear path and add-to-cart. - The GraphQL surface had never been exercised over HTTP. Coverage was unit
and integration only, and
M9GraphQlEntryPointTestsays so in its own header: "NOT exercised are the outer GraphQL schema/HTTP/resolver-context plumbing". A field declared on the wrongCustomizable*Optiontype, a resolver never wired intodi.xml, or a schema typo all survived a green PHP suite.dev/e2e/specs/graphql-api.spec.tsPOSTs to/graphqlas an unauthenticated consumer and asserts render fields across three concrete option types, the per-value stock ledger (requiring BOTH anIN_STOCKand anOUT_OF_STOCKvalue, with the qty agreeing), and dependency rules checked against ids resolved from the same response. It is proven load-bearing by deleting a schema field. - The changelog claimed theme parity it did not have. "Production-ready … full Luma and Hyvä parity" was written while two theme defects were knowingly deferred. Both are now named at the top of this release with the fact that neither changes what a customer is charged.
Internal (tests, tooling, release)
dev/gates/integration.sh— ONE canonical integration command. Two reviews reported this gate under the same name with different totals (206/996 and 239/1002) and both were right: they ran different collections. The 33-test difference is Magento's ownAclConfigFilesTest,EventConfigFilesTestandLayoutFilesTest, whose per-module data-provider labels match "Qoliber" — they validate this suite'sacl.xml,events.xmland layout XML, so the filtered run is a strict superset and is the gate. It previously exited 1 on a perfectly green run, because PHPUnit 10 fails on PHPUnit-level warnings; it now suppresses the exit code for exactly those two categories and nothing else, and still exits 1 on a real failure.dev/gates/release-tags.sh+bump-version.sh— preflighted tag publishing. A tag is only a pointer, so publishing a new version against unbumped manifests yields a metapackage whose exact requirements resolve to the OLD module tags; the script now refuses unless every shipped manifest and every metapackage requirement already equals the requested version. It force-pushes rather than delete-then-recreate (a ref is never momentarily absent), touches only refs behind HEAD, records rollback SHAs, enforces retirement of the excluded staging module's tag, and verifies using the peeled^{}commit — readingls-remote's first line returns the tag-object SHA for an annotated tag and reports correct tags as stale.provision.shnow preflights the configured search engine before seeding. Magento saves products through the realtime fulltext indexer, so an unreachable engine turns every save intoCouldNotSaveException; that once surfaced as five admin specs timing out and three GraphQL specs seeingtotal_count 0— eight failures that read as eight product defects and were one dead container.- Every browser fixture is provisioned, and the fee fixture publishes its own ids resolved BY TITLE rather than recording constants that drift on reseed.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- The admin product form discarded every value-level save.
Option::getValues()returns null on that path —Helper::fillProductOptions()builds values as raw arrays inside the option's_data— so every plugin iteratinggetValues()iterated nothing. Linked products, value images and value-level dependency rules were all silently dropped when saved from the form, the only path a merchant uses.Model\Option\OptionValues+ValueHandleread either shape, with write-through, and the four affected plugins now go through them. - Extension-attribute booleans were written as an empty string. A blanket
(string)cast turnedfalseinto'', which PDO bound into atinyint(1) NOT NULL— the right answer for the wrong reason — and would have written''into the nullableqty_*decimals, where MySQL stores 0.0000 rather than NULL. - "Layout: Grid" did nothing on either theme. Nothing emitted
aco-layout-gridor--aco-per-row, so the LESS rule consuming them was dead code and the shipped control had no visible effect. per_rowandshow_selected_labelhad no admin control at all, while being readable over REST and GraphQL and honoured by the storefront.- Two
<type>elements for one class in a single di.xml silently last-wins; the second element's plugins were never registered. - The option's description/tooltip was dropped by six renderers — date, file, textarea, text, qty and typed-input — so a merchant filling in "Description / tooltip" saw it on some option types and silently vanish on others.
show_selected_labeldid nothing on Luma: the target span rendered unconditionally, so the chosen value's label appeared whether or not the merchant asked for it. Hyvä already honoured the flag.show_value_pricesis a store setting Luma read straight off the options block, which Hyvä has no access to — so turning value-price badges off did nothing there. It is now emitted into the config both themes read.- "Text area" is offered in the admin renderer dropdown and had no arm in the partial resolver, so it fell through to the plain text input: a merchant choosing it got a single-line field, and a per-character-billed one got no counter and no indication it was billed by the letter. A test now asserts every offered render type resolves to a real partial, since that is how this went unnoticed.
oos_mode = hidedid nothing on Luma. The mode was never emitted into the config, so no partial could honour it and sold-out values stayed visible, merely disabled. The out-of-stock message and the remaining-qty caption also rendered on swatches only, so a sold-out value on any other renderer showed as an unexplained faded row.- Value descriptions were styled only inside
.aco-swatch, so on the four other renderers that emit them the tooltip had no CSS at all and rendered as permanent inline text in the middle of the control. They were also hover-only, which made every description unreadable on touch;:focus-withinnow covers keyboard and tap.
Changed
PriceScope::PER_CARTremoved. Every branch in the suite already read it as a synonym forPER_LINE, so the label promised a semantic that did not exist.AdvancedOptionsPricingships the data patch that migrates existing rows.Cart\BreakdownRowcarries the marker for native cart rows the price breakdown already renders. It lives in the base module because the two sides of that contract — LinkedProducts writes it, Pricing reads it — do not depend on each other.FieldFactory::number()accepts a helper notice, matchingtext().
Final readiness review
Fixed
-
A newly created option value lost everything set on it in the same save.
Value::saveValues()reuses ONE model instance for the whole loop, copying each row in withsetData()and saving that shared instance, so the Value objects the caller populated were never told which row they became. Every value-level concern keys on that id — linked products and value-level dependency rules onoption_type_id, per-value images ongetId()— so on a value's FIRST save all three hit their "no id yet" guard and silently wrote nothing. A merchant added a swatch value with an image and a linked product, the form reported the product was saved, and both were gone; saving a second time worked, which is why it never looked like a bug in the modules that lost the data.StampSavedValueIdsgives each id-less value its persisted id before any save-side plugin runs, matching by title and falling back to insertion order, and never touching or reusing an id a value already had.This fixed the repository path only. On the admin product form the values never reach that plugin as
Valueobjects at all —Option::getValues()returns null there — so the same data kept being lost on the one path merchants actually use. See "The admin product form discarded every value-level save" under Pre-release defect remediation above, which is what closed it. -
Inventory — one option's input could act on another option's stock. The buy-request option key was discarded and every payload cast to an
option_type_id, so a text or date option holding numeric customer input ("42") resolved to value 42 on an unrelated option — possibly another product — which the cart guard checked and order placement decremented.ValueStockLookupnow returns the owningoption_idandValueAvailability::belongsTo()gates both the guard and the decrement path. -
Templates — editing a template detached every per-product override. Saving deleted and re-inserted all child rows, so each option and value got a new auto-increment id; overrides are a JSON diff keyed by those ids. Children are now reconciled by id (update / insert / delete) and the admin form round-trips
template_option_id/template_value_id. A submitted id that is not already a child of that template is inserted as new, never used to overwrite another template's row. -
Hyvä rendered unstyled on Hyvä 1.3. The module shipped no
view/frontend/tailwind/, so on Tailwind 3 — where a module contributes content paths only via its own config — every class used solely in its templates was purged. Addedtailwind.config.js; deliberately nomodule.css, which would suppress Tailwind 4's automatic@source. -
Escaping in Hyvä option templates. 56
Magento2.Security.XssTemplateerrors, previously invisible because the phpcs gate scannedphponly. -
swatch_type/swatch_valuewere advertised where they cannot exist. Declared as option-level extension attributes and typed on all eight option-level GraphQL types, though their columns are oncatalog_product_option_type_value. REST reads returned null forever and REST writes were silently dropped. Both surfaces are deprecated and kept until 2.0.0: the GraphQL fields so existing query documents keep validating, and the REST extension attributes because removing them deletes public accessors from the generatedProductCustomOptionExtensionInterface— which makes any third-party implementor abstract — and narrows accepted REST input. An earlier revision of this release removed the REST attributes; that was a breaking change under a minor and has been reverted. The per-value read path and the admin write path are unchanged. They are deliberately not re-declared as value-level extension attributes:ProductCustomOptionValuesInterfaceis not extensible, so Magento would never serialise them. -
ItemBreakdowntyped on an interface lacking the methods it called.CartItemInterface|Itemwhile callinggetProduct(),getStore(),getOptionByCode()andgetBuyRequest()— none declared on the interface. Narrowed toQuote\Item. -
Undeclared dependencies. 19
magento/*packages the code references were missing fromrequireacross nine modules — an absent-module fatal on a customer install with a different module set.scripts/check-composer-requires.phpnow gates this in CI;composer validatecannot detect it. -
PHP 8.5. Removed
Reflection*::setAccessible()calls (deprecated in 8.5, no-ops since the 8.1 floor) that broke the suite under Magento's strict unit bootstrap. -
Linked Products — a number typed into a free-text option bought a free product. A buy-request is
options[<option_id>] => <value id(s) or free text>. Both the linked-value cart guard and the child synchronizer discarded the option key and cast every payload to anoption_type_id, then looked it up globally — with no check that the value belonged to that option, or that the option belonged to the product being added. A customer entering a number that collided with a linked value's id therefore spawned that value's $0 child product into their cart.AdvancedOptionsInventoryhad the same defect on its own stock path and fixed it withValueAvailability::belongsTo();AdvancedOptionsLinkedProductsnever received the parallel change.A submitted value now has to be one the product genuinely offers under the option it arrived under. The check lives in one place (
Cart\LinkedSelectionResolver) and both consumers read through it, and it fails closed. Values thatAdvancedOptionsTemplatesmerges onto a product live are still honoured. -
Linked Products — two options sharing one linked product could oversell it. The cart guard validated each selected value independently while the synchronizer merges values by linked product and sums their quantities. Two values each requiring 3 units of one product both passed against a salable qty of 5, then produced a single child line requiring 6. Demand is now summed per linked product and checked once — the same number the cart will actually reserve.
-
A value could be tracked by two stock systems at once.
AdvancedOptionsInventoryhad no awareness that a value might be backed by a real product. The only thing preventing double-tracking was a plugin inAdvancedOptionsLinkedProductsthat rewroteValueStockLookup::get()'s return value to look unmanaged — so "another system owns this" and "nobody tracks this" were the same state, and when that rewrite droppedoption_idan ownership check silently stopped running instead of failing loudly.Ownership is now explicit.
ValueStockModelResolveranswersunmanaged | ledger | externalonce per value, and capability modules claim what they own through a di.xml pool. Every ledger seam — availability, the cart guards, order-placement deduction, and the storefront render config — consults it and returns early for values it does not own. The rewriting plugin is deleted.Behaviour change on existing data. A value carrying BOTH a linked product and a leftover Advanced Options stock quantity was previously decremented in both systems. It is now decremented only by the owning system. Stores with such values will see their ACO ledger stop moving for them — that is the fix, but it changes live numbers. Existing rows are left readable and are not migrated. The admin refuses to create new conflicts, while still permitting the save that clears an existing one (empty quantity, zero deduct qty).
Upgrade note — deleting a plugin requires DI regeneration. Removing
ExcludeLinkedValueFromLedgerDeductionleaves any installation whosegenerated/predates this release with an interceptor still referencing the deleted class, which fatals withClass "…\ExcludeLinkedValueFromLedgerDeduction" does not existon the first add-to-cart. Observed on this repo's own integration run.Production mode — the whole upgrade window, in this order:
Bashbin/magento maintenance:enable # deploy the 1.0.0 code bin/magento setup:upgrade # schema + module state bin/magento setup:di:compile # regenerate interceptors bin/magento setup:static-content:deploy bin/magento cache:flush bin/magento qoliber:aco:audit-dual-stock # only exists after the two steps above # resolve, or explicitly accept, every value it lists bin/magento maintenance:disableThe audit cannot run any earlier: it ships in this release, and a production store does not see a new command until DI is recompiled. It is read-only and the rows it reports are not migrated, so the same set is listed either side of the upgrade — running it here, before traffic resumes, is what keeps customers from ordering against numbers still under review.
setup:upgradealone is not sufficient: it does not rebuildgenerated/in production mode, so the stale interceptor survives. Do not pass--keep-generated— that is precisely the flag that preserves the stale class this change invalidates. A cache flush alone does not help; the DI plugin-list cache must also be cleared, whichcache:flushdoes cover. -
Nothing a merchant configured in the product form was saved by five of the suite's save plugins. Dependency rules, per-value images, linked products, the template assignment and the synthetic-option strip were all registered on
Magento\Catalog\Model\ProductRepository. The admin product controller saves through$product->save()on the model and never calls the repository, so every one of them was inert on the only path a merchant uses: the form accepted the input, reported "You saved the product", and wrote nothing. It read as a UI bug because seeded and API fixtures — which do go through the repository — always worked. All five moved toMagento\Catalog\Model\ResourceModel\Product::save(), which both paths reach exactly once, and a unit test now reads the shippeddi.xmlfiles so the pattern cannot come back. -
Four option-level fields never rendered in the admin form. A meta array KEY becomes the UI component's NAME, and when the option container and the value record nested inside it declare the same name, the option-level component is dropped from the form outright — silently.
price_scope,is_setup_fee,dependenciesanddescriptionexisted at both levels, so none of the four could be set on an option. The option-level keys are now prefixed (option_price_scope, …); each keeps its plaindataScope, so the posted columns and stored data are unchanged. -
Every option added in the admin form was saved as a colour swatch. The
render_typeselect had no empty entry, and Magento's UI select shows and posts its first choice when a field has no value — so an untouched option came outswatch_colorand a plain text field rendered as a swatch. The value-levelswatch_typeselect had the same defect. Both source models now lead with an explicit empty choice, which is what "let ACO render the native control" has always meant. -
Option values created in the admin form were born out of stock. An untouched "Stock qty" posts an empty string, which a decimal column stores as
0— and0means tracked, none left, not unmanaged. Every value saved through the form was therefore permanently unbuyable and renderedaco-swatch--oos, which looked like a stock-rendering bug rather than a save one. An empty submission now persists asNULL; an explicit0still means sold out, and an absent field still leaves an existing baseline alone. -
The dependency builder marked the product form dirty on load. It serialized its model into the bound field during initialization, so a merchant who only looked at a product with ACO options was warned about unsaved changes on the way out — and the write published an authoritative
{"combine":"all","groups":[]}before the data provider had hydrated the saved value, which is exactly the payload that means "delete this dependent's rules". It now writes on real edits only.
Added
- "Price type" on the option row.
qoliber_price_typeselects how an option's price is derived — absolute, per character, or per option quantity. The column, its GraphQL field, its REST extension attribute and the storefront price maths all shipped in 1.0.0, but no admin control was ever rendered for it, so per-character and per-quantity pricing could only be set through the API or a data patch.
Changed
- CI. The monorepo workflow is now the authoritative gate — composer canon,
phpcs, the PHP 8.1 floor guard, PHPStan level 8 and the unit suite across
8.1–8.5. Per-module GitLab pipelines run phpcs only: those repos require
private
qoliber/*siblings that cannot be resolved unauthenticated. PHP 8.5 is advisory until mage-os core is 8.5-clean. - Integration and E2E suites remain a manual pre-tag step; they need a full Magento install and are not covered by a green CI run.
CE production hardening (external review rounds + go-live)
Added
- Native custom-option partials +
OptionPartialResolver—render_type-less native options (drop-down, radio, checkbox, multiselect, date, date-time, time, file, textarea, text) now render and behave through the ACO option block, so a product mixing native and advanced options renders through one path. - Visible Luma "Options Fee" totals row — cart and checkout show an "Options Fee" segment with the correct amount, currency, and sort order; hidden when zero.
- MSI/legacy stock adapter —
ProductStockAdapterInterfacewith a resolver that picks MSI (MsiStockAdapter) or legacy CatalogInventory (LegacyStockAdapter), so per-value stock, deduct/restore, and the add-to-cart guard work with and without MSI. - Value-level dependencies — filter the values inside a dropdown/swatch by other options' selections (e.g. Brand → Model cascade), on both storefronts and guarded in the cart.
- Linked Products (
Qoliber_AdvancedOptionsLinkedProducts) — back an option value with a real product:$0child order line carrying the linked product's SKU and MSI stock. dev/DEPLOYMENT.md— DB-backup step, the mandatorysetup:db-schema:upgrade→setup:di:compile→ cache-flush sequence, a 6-point smoke checklist, and rollback.
Fixed
- Native select / multiselect / checkbox state restored on cart-item "Configure" — editing a cart line re-selects the previously chosen native-option values (rendering and the JS interaction were both restored, verified in-browser via Playwright).
- Checkout "Options Fee" row survives the
CartTotalRepositoryquote reload — the transient quote-address total was lost when checkout re-fetches totals without re-collecting;fetch()now reconstructs the row from the persisted per-item fee. A live checkout smoke caught the row missing on the checkout step. - Multishipping fee scoping — the reconstructed fee sums only the fetched shipping
address's items (
address_quote_items, the mechanism Weee/FPT uses), not the whole quote. - Canceled-invoice fee accounting — the invoice/credit-memo fee ledger excludes canceled parent documents and clamps the remainder at zero, so partial invoices, cancellations, and partial refunds reconcile exactly (base + tax) with no negative remainder.
- Oversell guard on
Quote::addProduct— out-of-stock option values are rejected at the shared quote chokepoint (every add-to-cart entry point), so GraphQL / REST / admin-order paths cannot oversell an option value; last unit is sellable (>=). - Template / product resave — the template-assignment save reads the original
product and presence-guards on
qoliber_template_id, so resaving a product with options + a live template link no longer wipes the link or throws thecatalog_product_option_titleFK error; synthetic negative-id options are stripped before save and restored after. - Per-value image hydration —
AddVisualFields/SaveValueImagesre-hydrate each descriptor's image (role + sort) on the admin form without resetting the gallery. - Admin numeric value fields no longer render as falsely required.
- Auto-selected dependency values reconcile only on an invalidated pick, not on initial page load.
Changed
- Option fees modeled as native extra-taxables — a per-line / setup fee is taxed by
CommonTaxCollectorlike any associated taxable and matches Magento's default discountability on Open Source (not cart-discountable there).
Internal (tooling & release)
- PHPStan gate raised to level 8 (no suppression) across the suite.
- PHPCS gate uses
--warning-severity=0(error-severity) on both GitHub and GitLab CI. - The EE-only Content Staging bridge (
Qoliber_AdvancedOptionsInventoryStaging) is kept out of the CE metapackage and disabled on CE; its remaining Adobe-Commerce work is tracked in that module'sTODO_AC.md. Note:setup:di:compilescans every module on the filesystem, so the bridge directory must be excluded/moved for a source-based CE compile (seedev/DEPLOYMENT.md).
Added
-
LICENSE.txtin every module + the metapackage — Qoliber Extensions User License (proprietary commercial), © Fiero Group Sp. z o.o.; authoritative terms at <https://qoliber.com/license>. Composerlicenseisproprietary. -
Translation baseline —
i18n/en_US.csvgenerated for all nine modules, giving merchants a complete dictionary of every__()phrase to localise from. -
@apiannotations on the public extension surface —ConfigContributorInterface(the cross-module contributor pool) and the Inventory/VisualApiservice + data contracts — so the supported, semver-stable surface is explicit. -
Vanilla-JS storefront controller (Luma) — replaced the Knockout-based renderer with a pre-rendered
<div id="qoliber-advanced-options">wrapper bootstrapped via a singletext/x-magento-initblock. All options are server-rendered (visible + dependency-hidden), with hidden ones toggled via theaco-option-hiddenCSS class. Zero layout-shift on PDP load. -
Add-to-cart validation guard — capture-phase click listener on the ATC button runs before Magento's
catalogAddToCartwidget, blocks submission on missing required options, flags offenders with.aco-required-error, scrolls the first into view, emitsaco:validation-errorfor extensions. -
"Show price badges on option values" admin toggle — new store config field
qoliber_advanced_options/storefront/show_value_prices(default Off) controls whether per-value+$X.XXprice badges render in the swatch cells. Website-scoped, admin opt-in. -
Demo category + audit script —
dev/demo/seed-demo-category.phpcreates an "Advanced Options Demo" top-nav category and assigns 10 demo fixtures (5 curated + 5 e2e visual demos), with per-product option/ dependency audit output. -
Hyvä swatch design parity with Luma —
aco-swatch-cellwrapper with the colour/image tile + price caption below, blue ring on selection (.aco-selected), tooltips and OOS captions. -
Theme-neutral
.aco-selectedhook — both Luma and Hyvä now expose the selected state via the same class so cross-theme CSS and e2e selectors work without theme-detection. -
Per-option
stock_oos_message/stock_show_qtyexposure — Inventory contributor injects the configured caption strings onto every option so Luma's swatch.phtml renders the "Out of stock" +[N]qty badges that Hyvä already had. -
Dependency
containsoperator — substring / array-membership check added to the PHP server-side evaluator and the Luma JS evaluator (Hyvä already supported it). -
Hyvä e2e coverage parity — three new spec files (
hyva-demo-smoke,hyva-addtocart,hyva-templates) bringing Hyvä's storefront suite to 23 tests covering every demo product flow.
Changed
- Per-cart + setup fee pricing pivot —
per_cartvalue-prices andis_setup_feeflags now land in the LINE subtotal exactly once per line (viaAdjustPerLineScope/AdjustSelectPerLineScope) instead of being removed from the line and re-added at quote-total level viaPerCartFeeCollector. End-to-end consequence: PDP headline price == cart line subtotal == order total for the common single-line scenario. The collector is preserved but neutered (emits 0.0) to keep any back-compat consumers from breaking. Trade-off: two separate cart lines that each carry the same per_cart fee each pay it (no cross-line dedup); acceptable for the common case. - Absolute pricing on storefront preview — picking an
absolute-typed value REPLACES the base price in the live PDP preview on both themes (was: base + absolute, off by 2×). Hyvä preview readsinitialFinalPricefrom the price-init Alpine root and adjusts thecustomOptionPricesmap so the add-to-base contract yields the absolute value's literal price. - Percent pricing on storefront preview —
percent-typed values now contributeamount/100 × baseto the live preview on both themes (was: treated as flat dollars on Luma; was: treated as flat dollars on Hyvä). - Dependency combine-mode aliasing — Hyvä Alpine evaluator now accepts
both
'or'(what the admin UI emits) and'any'(legacy Hyvä spelling). Previously only'any'was recognised so every OR rule silently became AND on Hyvä. - Dependency operator aliasing — all three evaluators accept both the
user-facing names (
is/is_not/is_empty/is_not_empty) and the legacy names (equals/not_equals/empty/not_empty). - Luma swatch grid — color/image tiles are fixed 48×48 squares; text
swatches keep responsive width. Each value renders inside an
.aco-swatch-cellflex column with the price caption below the tile (was: price overlay inside the tile, crowding the visual). Required-asterisk is bold red. - Luma engraving (per-char) counter — surfaces the per-letter rate
before and during typing: empty →
0 chars · +$0.00 ($2.00 per letter), typed →5 chars · +$10.00 ($2.00 per letter). - Demo seed — iPad's Finish option now has 3 distinct colours
(Space Black / Silver / Gold) with proper hex backgrounds and value
descriptions; apparel image swatches ship 3 generated JPGs at
pub/media/qoliber/aco-demo/. - Magento2 static URL signing — enabled
dev/static/sign=1so future asset changes auto-bust browser caches via theversion<timestamp>/URL prefix.
Fixed
- Swatch colour escaping — replaced
$escaper->escapeCss($swatchValue)(which encoded#1d1d1fto\23 1d1d1f, a CSS identifier escape that never parses as a colour value) with a hex / rgb(a) / named-colour allowlist regex. Color swatches now render with their actual backgrounds instead of transparent tiles. - Hyvä native-wrapper preservation — moved Luma's
product.info.options.wrapperremoval from sharedcatalog_product_view.xml(which permanently removed it for both themes) intoOptionsBlock::_prepareLayout()so it only fires when our Luma block actually renders. Hyvä's Alpine partials now render into the intact native wrapper. - Hyvä qty-option serialization — text.phtml emitted
name="options[ID][qty]"forrender_type=qtyoptions, which PHP form parsing collapsed to an array → native Text option validation 500'd on add-to-cart. Usename="options[ID]"(scalar) for qty options; keep the separateaco_qty[ID]namespace for the qty-MULTIPLIER case. - Hyvä variant class suffix — was
aco-swatch--swatch_color/--swatch_image(full render_type); now--color/--image/--text/--checkboxmatching Luma's contract so theme-neutral CSS + e2e selectors target both themes. - Visual gallery-hook crashes —
visual-gallery-hook.jsnow guards against undefinedfluentApi.first()returns and missingevent.detail.viewModel(previously hard-crashed when Fotorama hadn't finished hydrating before the first selection-change event). - Luma BUTTON render type — dispatcher and JS handler now recognise
buttonas a swatch variant (was falling through to native text input, with clicks doing nothing). Renders as a pill-style tile reusing 100% of the swatch DOM + handlers. - Luma dropdown render type — admin/seed-stored
dropdownenum value now maps to the styled-dropdown partial (was onlystyled_dropdownmatched; pricing-demo dropdowns rendered as native text inputs). - Demo category seed — broadened the reindex chain to include
catalog_product_price,catalog_product_attribute,cataloginventory_stock,inventoryso the category listing query doesn't silently filter products out after re-seeding. - Required-asterisk colour —
.aco-required-markernow renders bold red (#e02b27) instead of body colour. pw-xss-productstorefront visibility — fixture's intentional XSS payloads in option titles render inertly (good — that's the test); the product is nowVISIBILITY_NOT_VISIBLE_INDIVIDUALLYso direct URL discovery 404s. Integration tests that look it up by SKU still work.- Inventory order-placement race — option-stock ledger decrement now runs
before native order persistence and restores any applied ledger deductions if
Magento order placement fails. A concurrent checkout that drains a value
between cart validation and placement now throws
InsufficientStockExceptionbefore an order is persisted instead of placing the order and logging an oversell event. - Live-template multi-select add-to-cart — selecting a checkbox/multiple
value from a live-linked template no longer fails with "Some of the selected
item options are not currently available." The synthetic negative-id values
exist only in memory, but Magento validated a multi-select by counting DB rows;
SyntheticOption::getOptionValuesByOptionId()now supplies the in-memory values so multi-select validates exactly like single-select. A genuinely unknown value is still rejected. - Dependency builder "+ Condition" — clicking it now adds a condition row.
Each group's
conditionsis a KnockoutobservableArray(the old plain-array push never re-rendered the inner list); also added a per-condition Remove button. - Inventory partial-cancel restore — cancelling an order line restores the cancelled quantity, not the full ordered quantity, so a partially-invoiced line no longer over-credits the per-value stock ledger.
- Setup-fee charged once —
is_setup_feeis now authoritative across the pricing pipeline: a setup fee lands once per line regardless of price scope, matching the PDP preview to the cart/order line. - phpcs XSS-sniff gate — escaped
$optionIdin the typed-input renderer template (the value is an int, so this is a gate fix, not a real XSS).
Security
- Dependency tamper-guard now covers every add-to-cart path. The guard that
rejects a value submitted for a dependency-hidden option previously ran only on
the Luma
Checkout\Model\Cartpath; GraphQL, REST and admin-order creation add through the sharedQuote::addProductand bypassed it (with no order-place backstop). A guard onQuote::addProduct— the chokepoint every entry point shares — now closes that bypass. Covered by an integration test that adds a tampered selection via the non-Luma path and asserts rejection.
Removed
Grouped (separate line items)SKU policy from the admin dropdown — the constantSkuPolicy::GROUPEDis kept for v2's stable name to land on; the entry is omitted fromtoOptionArray()until the cart-item splitter + order-line aggregation + refund-per-split runtime ships (tracked as item #9 indocs/ROADMAP.md).- Dead code in
PerCartFeeCollector—toDisplayCurrency()andextractFees()removed (orphaned by the per_cart pivot); the existing fee-extraction behaviour is now covered end-to-end by the integration suite. - Dead per-value absolute-price branch —
ApplyAbsolutePriceread a value-levelqoliber_price_typethat has no column/field and was always null; simplified to the authoritative option-level check (absolute is option-wide).
Known limitations (1.0.0)
-
Swatch colour/image is not yet settable from the admin option form — the
swatch_valueis stored and rendered, but the admin custom-options form has no input for it (swatches configured via the demo seeds / Web API render correctly). Use a color picker / image field is tracked as admin finding A1 indocs/ROADMAP.md; configure via API in the meantime. -
Admin UX polish — the 2026-06-09 admin review logged findings A1–A9 (dependency-builder dropdowns, stock-grid filters, conditional field visibility, render-type guarding, template store-scope, menu grouping). None affect storefront/checkout correctness; all tracked in
docs/ROADMAP.md. -
Bulk text-option per_cart/setup fees require the subtotal-stage residual correction added in 1.0.0 so Magento's unit-price rounding cannot erase small divided fees at large line quantities.
-
GraphQL
images+stockfields per value are not exposed; the data is in the storefront JSON config payload but not in the GraphQL value types. -
6 of the 28 Luma storefront e2e specs require selectors / rewrites the vanilla pivot changed; functional tests pass, those 6 need test-side updates.
All limitations are tracked in docs/ROADMAP.md.
Cost Weight
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Added
- feat(aco-cost-weight): carve Qoliber_AdvancedOptionsCostWeight out of base (no behavior change)
- feat: ship i18n en_US.csv translation baseline
Fixed
- fix(costweight): null-guard getOptionByCode for items without custom options
- fix(cost-weight): phpstan FieldFactory in bootstrapFiles; commit prior WIP (reproducibility)
Internal (tests, docs, tooling)
- chore: drop module- prefix from composer package names
- test: raise PHPStan gate to level 8 (no suppression)
- chore: declare php 8.4 / 8.5 compatibility
- ci(phpstan): treatPhpDocTypesAsCertain:false for nullable Quote\Item\Option
- build: add .gitattributes with export-ignore for dev tooling
- docs: add LICENSE.txt (Qoliber Extensions User License)
Dependencies
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- Value-level dependency rules were dropped when saved from the admin product
form. The plugin iterated
Option::getValues(), which returns null on that path, so it silently wrote nothing (see the base module's value-shape fix).
Added
- feat(aco-dependencies): carve Qoliber_AdvancedOptionsDependencies out of base (no behavior change)
- feat: ship i18n en_US.csv translation baseline
- feat(deps): add RuleSerializer as single rule-JSON producer/parser
- feat(deps): persist value-level dependency rules on product save
- feat(deps): inject dependency builder into value rows
- feat(deps): hydrate saved dependency rules back into the product form
- feat(deps): reject tampered hidden values at add-to-cart
Fixed
- fix(admin): route dependency-builder events through named viewModel methods
- fix(deps): contains evaluator parity + whitelist QLB_ referenceIds
- fix(deps): hidden required option no longer re-blocks at cart/checkout
- fix(security): close dependency tamper-guard bypass on non-Luma add paths
- fix(admin): '+ Condition' now adds a row in the dependency builder
- fix(deps): add copyright header to RuleSerializerTest; rename mock var
- fix(deps): render dependency rules that hydrate after component init
- fix(deps): place value-row dependency builder after value fields (sortOrder base+20)
- fix(deps): version-safe getByOptionIds for EE Content Staging
Changed
- refactor(deps): drop legacy JS dependency-evaluator; base owns it now
- refactor(deps): replace DependencyMapper with RuleSerializer across consumers
Internal (tests, docs, tooling)
- chore: drop module- prefix from composer package names
- test: raise PHPStan gate to level 8 (no suppression)
- build: add .gitattributes with export-ignore for dev tooling
- test(deps): cart-lifecycle hardening matrix (8 scenarios)
- test(deps): add chained (A->B->C) hidden-required cart-revalidation case
- test(deps): general contract — add-to-cart validates ONLY visible options
- docs: add LICENSE.txt (Qoliber Extensions User License)
- test(deps): integration coverage for value-level dependency round-trip + cart guard
- test(deps): update SaveDependencies unit mocks for the 3-arg afterSave fix
Hyva
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Release readiness
Fixed
- An untouched optional range with a positive minimum still charged. The
template defaulted a missing
qty.minto 1 and bound the proxies to the slider's displayed value, so an optional range the customer never moved posted its minimum and was billed for it. The control now tracks explicit interaction (touched) and submits nothing until the customer actually moves it, while still DISPLAYING the configured minimum. Both positive-minimum E2E cases pass.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- A dependency-hidden REQUIRED file option blocked add-to-cart completely. The
dependency JS disabled inputs by matching
options[ID], which a file option'soptions_ID_fileinputs never match, so the hidden input kept its HTML5requiredattribute and the browser silently refused to submit — no message, no request, no sale. - Price badges had no currency:
number_format()with hard separators and no conversion, wrong for every non-USD and multi-currency store. They now use the store-currency string the config already emitted and Luma already used. - "Layout: Grid" did nothing — the template computed the grid values and never used them.
- The cart dedupe
<style>hid the whole native option list on any line with a breakdown, taking downloadable links, other modules'additional_optionsand the file download link with it, and emitted one<style>per cart line. - The option's description/tooltip was dropped by every renderer except select.
show_value_priceswas ignored entirely — the store setting reached Luma through the options block, which this theme's templates have no access to.- Required swatch / button / multicheckbox groups had no client-side validation.
Stock Hyvä performs it in the options wrapper this module replaces, so the
data-requiredmarker was emitted and nothing consumed it: the server still refused the add, but the customer got a page reload and a generic error rather than the field telling them. A required MULTI group never carried the marker at all.
Changed
- The cart-line "Edit" link is suppressed for products with custom options, as a 1.0.0 mitigation: Hyvä's option templates emit no selected state, so Edit reopened the product blank and "Update Cart" persisted the loss. Real preselection is a 1.1.0 feature. Luma preselects correctly and is unaffected.
Added
- feat: Hyvä (Alpine/Tailwind) frontend for Qoliber Advanced Custom Options
- feat(stock): Hyva templates hide/disable OOS option values with message
- feat(visual): Hyva — value description/tooltip + gallery replace/overlay via update-gallery/reset-gallery events
- feat(aco-hyva): sequence Hyvä module after the capability modules
- feat(renderers): render date/datetime/time/color/range typed inputs (Hyvä)
- feat(hyva): show per-unit surcharge on the range readout
- feat: ship i18n en_US.csv translation baseline
- feat: Hyvä per-value dependency removal + selection reconcile
Fixed
- fix(visual): Hyva — pass acoImages via data attribute (raw JSON breaks the double-quoted x-data attr)
- fix(hyva): point StockConfig block arg/var at Qoliber_AdvancedOptionsInventory after Inventory carve
- Fix CSP for inline acoOptions() script + CSS-context swatch escaping
- fix(hyva): remove the new qoliber.aco.options block, keep template overrides
- fix(hyva): variant-suffix matches Luma + qty render_type emits scalar name
- fix(hyva): absolute + percent value prices respect their semantics
- fix(deps): accept both 'or' + 'any' as the OR-combine mode
- fix(packaging): declare Hyva module's actual runtime deps in composer.json
- fix(packaging): make all module.xml-sequenced modules required, drop suggest
- fix(hyva): contains evaluator empty-expected guard (parity with base)
- fix(hyva): colour hidden-proxy + range default priced on load (PDP==cart)
- fix(hyva): qty renderer emits aco_qty bridge + seeds surcharge on load
- fix(hyva): optional per_qty range/qty is $0 + absent from cart until used
- fix: Hyva reconcileValue matches value-visibility (no load-time auto-pick)
- fix(hyva): optional native <select> clears (not auto-selects) on invalidated pick
- fix(hyva): value-level swatches were ALL hidden (broken x-data attribute)
- fix(hyva): reconcile writes the corrected value to the real radio/checkbox
Changed
- ux(hyva): swatches match Luma quality — colored tiles + price caption below
- ux(hyva): selected swatches show a visible blue ring + border
Internal (tests, docs, tooling)
- docs(aco-hyva): point dependency-evaluator doc comment at the carved Dependencies module
- chore: drop module- prefix from composer package names
- test: add PHPStan level 8 gate
- chore: declare php 8.4 / 8.5 compatibility
- ci: add local phpcs.xml to the Hyva module (PHP-only, view/ excluded)
- build: add .gitattributes with export-ignore for dev tooling
- docs: add LICENSE.txt (Qoliber Extensions User License)
- chore(hyva): commit prior uncommitted WIP (reproducibility)
Inventory
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Release readiness
Fixed
- The composite child filter never fired on the real path. The double-deduct
guard tested
parent_item_id, but that column is derived at SAVE time bySales\Model\ResourceModel\Order\Item::_beforeSave(), while the deduction runs in anaroundplugin BEFOREOrderManagement::place()persists anything. At decision time the id is still null and only the in-memoryPARENT_ITEMlink exists, so the filter matched nothing and the fix it belonged to was inert on every real order.isChildOfComposite()now checks both. - The upgrade command told merchants it ships in 1.1.0.
qoliber:aco:audit-dual-stockships in 1.0.0, but its docblock and its user-visible--helpboth said 1.1.0 — including "This command ships IN 1.1.0, so it cannot be run on 1.0.x", read by a merchant holding the tool during the upgrade window.
Internal (tests)
AuditCompositeLedgerTest— the gate for the composite double-deduct — is now hermetic. It resolved the Luma sample-data skuMH01, which nothing guaranteed was present, so it passed under--filterand errored inside the full suite withNoSuchEntityException. A gate that only holds when run alone is not a gate, and this one was reporting green for the release's most consequential fix. It now builds on Magento's own configurable fixture, which ships a rollback and is order-independent, and still catches the defect: neutralising the guard gives ledger 50 -> 46 instead of 50 -> 48.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- A composite line consumed option stock twice.
AbstractType::_prepareProduct()stamps the sameinfo_buyRequeston both halves of a configurable line, so an unfiltered walk decremented the ledger twice per ordered unit: values hit their out-of-stock threshold at half their configured quantity, and checkout threwInsufficientStockExceptionfor stock that was really there. Refunds double-restored for the same reason. - The product form's "Stock qty" never moved live stock. The form writes a
baseline; the storefront reads a separate ledger, and nothing connected them —
so restocking from the form did nothing, correcting downward left the
storefront selling the old quantity, and
restore()'s cap could then destroy live stock. Changing the field now sets the live quantity, and only an actual change writes. - The out-of-stock guard ignored what was already in the cart, so two adds of qty 2 against a value with 3 both passed and merged into a line placement then refused — the customer only found out at checkout.
stock_oos_modewas never emitted into the storefront config, so no Luma template could honour the "hide sold-out values" setting.
Added
aco_stock_status/aco_salable_qtyon the customizable-option value types, so a headless storefront can grey out a sold-out value. LinkedProducts already exposed the linked equivalent; the gap was an inconsistency.
Final readiness review
Added
- Explicit value stock ownership.
ValueStockModelResolveranswersunmanaged | ledger | externalfor an option value, and capability modules claim the values they own viaValueStockOwnerInterfaceregistered into a di.xml pool. Every ledger seam — availability, both cart guards, order- placement deduction and the storefront render config — consults it and returns early for values it does not own.
Fixed
- One option's input could act on another option's stock. The buy-request
option key was discarded and every payload cast to an
option_type_id, so a text or date option holding numeric customer input resolved to a value on an unrelated option.ValueStockLookupnow returns the owningoption_idandValueAvailability::belongsTo()gates both the guard and the decrement path. - The admin could create a double-tracked value. Setting an Advanced Options quantity on a value another module owns is now refused, while the save that CLEARS such a conflict is still allowed.
Changed
ValueStockOwnerInterfaceanswers for a BATCH of value ids. A per-value contract made a product page issue one query per owner per value; a product with many option values generated roughly two extra queries per value on every view.ValueStockModelResolver::prime()resolves a whole option list in one owner call plus one baseline read.
Added
- feat(aco-inventory): carve Qoliber_AdvancedOptionsInventory out of base (no behavior change)
- feat(inventory): L1 OOS-annotation listener
- feat(inventory): expose stock_oos_message + stock_show_qty per option
- feat: i18n en_US.csv baseline + @api on the Api service/data contracts
Fixed
- fix(aco-inventory): own the option_stock admin menu entry
- fix(requirejs): auto-load hook via deps; drop layout XML <script src> (Mismatched anonymous define)
- fix(inventory): qty-aware OOS guard + atomic, race-safe decrement
- fix(inventory): honour AdapterInterface|false return type from getConnection
- fix(inventory): throw InsufficientStockException instead of silent clamp
- fix(inventory): Stock/Save POST-only (CSRF) + system.xml section + whitelist
- fix(inventory): pre-place reservation + threshold-aware, race-safe ledger
- fix(inventory): cancel restores qtyToCancel, not qtyOrdered
- fix(inventory): MSI/non-MSI ProductStockAdapter; last-unit-sellable off-by-one (#4); Quote::addProduct OOS guard
Changed
- perf(inventory): batched read-only ledger lookup on PDP render (no write, no N+1)
Internal (tests, docs, tooling)
- chore: drop module- prefix from composer package names
- test: raise PHPStan gate to level 8 (no suppression)
- build: add .gitattributes with export-ignore for dev tooling
- test(integration): InsufficientStockException afterPlace path
- docs: add LICENSE.txt (Qoliber Extensions User License)
- test(stock): integration coverage for credit-memo restore (full + partial)
- test(stock): integration coverage for add-to-cart OOS block
- test(stock): integration coverage for restore idempotency (no double-restore)
Linked Products
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- Linked children were invisible across the entire admin. The summary plugin was
registered on the storefront
AbstractItems, and the admin blocks descend from an unrelated class — worst on the invoice and credit-memo CREATE screens, where a merchant was deciding what to bill or refund without being able to see the child lines their decision covered. - Linked products were dropped when a value was saved from the admin product form (see the base module's value-shape fix).
Changed
- The native "Includes" cart row is tagged so the cart page can drop it where the price breakdown already renders it; the minicart, which has no breakdown, keeps it.
Final readiness review
Fixed
- A number typed into a free-text option bought a free product. A
buy-request is
options[<option_id>] => <value id(s) or free text>. Both the linked-value cart guard and the child synchronizer discarded the option key and cast every payload to anoption_type_id, then looked it up globally — with no check that the value belonged to that option, or the option to the product being added. A customer entering a number that collided with a linked value's id spawned that value's $0 child into their cart. Identity is now proven once, inCart\LinkedSelectionResolver, and fails closed. Values thatAdvancedOptionsTemplatesmerges onto a product live are still honoured. - Two options sharing one linked product could oversell it. The cart guard validated each selected value independently while the synchronizer merges values by linked product and sums their quantities. Two values each requiring 3 units of one product both passed against a salable qty of 5, then produced a single child line requiring 6. Demand is now summed per linked product and checked once.
- Stock was validated one request at a time, not per cart. Two adds each demanding three units of a target with five available both passed, and the cart ended up needing six. Native MSI refused the order at placement, so this was never a true oversell — but the customer only discovered it at checkout, and GraphQL/REST callers got a success for a cart that could not be ordered. The guard now counts what the quote already demands of each linked product. Demand is PROJECTED before the add rather than observed after it, so a refusal cannot leave a half-added line behind; editing a line is not blocked by its own existing demand.
- Non-Luma entry points had no linked-value guard. The guard was registered
only on
Magento\Checkout\Model\Cart, so GraphQL, the REST cart-item API and admin order creation — all of which add throughMagento\Quote\Model\Quote:: addProduct— bypassed it. The same check now runs at that shared chokepoint, matching whatAdvancedOptionsInventoryhas always done. - A deleted linked product failed open. Salable-qty resolution returned null for a missing product exactly as it does for an MSI outage, and null means "unknown, do not block". The synchronizer then skipped the child silently, so a parent could be ordered with the option selected and nothing behind it to fulfil. Absence and unknown stock are now distinguished: a missing linked product blocks the add with its own message and renders out of stock, while a genuine stock-lookup failure still fails soft.
Changed
- Requires
qoliber/advanced-options-inventory^1.0: this module implementsValueStockOwnerInterface, which ships in Inventory 1.0.0. (An earlier draft pinned^1.1, because the interface post-dated a 1.0.0 that had been tagged but never released; the re-cut 1.0.0 contains it.) - Stock ownership is declared through Inventory's owner pool instead of a plugin
that rewrote
ValueStockLookup::get()'s return value.
Added
- feat: scaffold Qoliber_AdvancedOptionsLinkedProducts (M1 foundation)
- feat(lp): resolve + validate linked-product SKUs
- feat(lp): inject linked-product admin fields into value rows
- feat(lp): persist value-level linked-product columns on product save
- feat(lp): hydrate linked_product_id back into the admin form as a SKU
- feat(lp): resolve linked-product columns for submitted option_type_ids
- feat(lp): soft bridge into AdvancedOptionsDependencies to filter hidden values
- feat(lp): spawn $0 linked-product child quote items at Quote::addProduct
- feat(lp): resolve a linked product's native MSI salable qty (fail-soft)
- feat(lp): single source of truth for linked-value MSI stock availability
- feat(lp): source linked-value stock config from MSI + block OOS add-to-cart
- feat(lp): M4 order conversion, GROUPED fulfilment, and native shipment fix
- feat(lp): M5 refund/cancel restore for linked children + partial-qty fix
- feat(lp): M6 GraphQL linked-value fields + storefront badge verification
- feat(lp): M8 render $0 linked children in the cart page + minicart
Fixed
- fix(lp): store linked-child qty as the per-parent-unit ratio, not the total
- fix(lp): stop the legacy stock ledger double-tracking a linked value + M10 tests
- fix(lp): MSI adapter; declare Dependencies (#5); EE-staging link-field keying
Internal (tests, docs, tooling)
- test(lp): M1 integration coverage for the save->reload->hydrate round-trip
- test(lp): M2 integration coverage for the cart-split lifecycle
- test(lp): M3 integration coverage for MSI-sourced stock + comes-free reservation
- test(lp): M7 interaction & edge hardening — qty matrix, merge, live-template, re-derivation
- test(lp): M9 alternate add-to-cart entry points — GraphQL/REST/admin order-create
- test(lp): M11 variation hardening — virtual target, multi-currency, concurrent oversell
Pricing
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- Nothing rendered the option fee on a placed order.
etc/sales.xmlregisters quote, invoice and credit-memo collectors, but order totals rows come from layout child blocks withinitTotals()— so a real order renderedsubtotal + shippingagainst a larger grand total, on the order view, the customer's account, the emails and the PDFs.Block\Sales\Order\Totalsnow renders it across all 16 layouts. - The billing side and the removal side disagreed about what a once-per-line fee
is. Four classes each re-derived it from raw option/value data and drifted, so
a value-level fee was removed from the line price once and billed twice. All
four now share
Pricing\OptionAmountCalculator. - A percent fee ignored special prices — the base came from the raw
priceattribute rather than the price model, so a 10% option on a half-price product charged 10% of the pre-discount price. - A negative fee was discarded: the zero checks were
<= 0, which threw away discounts. - Blank qty bounds persisted as 0 rather than NULL.
qty_max = 0clamps everyper_qty_inputsurcharge to zero and rendersmax="0", so the customer cannot enter a quantity — 411 of 690 options on the reference install were already written that way.Setup\Patch\Data\NormaliseZeroQtyBoundsclears them. - A file option printed its upload descriptor into the cart — the internal
quote_path, the server-sidefullpathand the downloadsecret_key. It now renders the file title. - The cart dedupe CSS hid the entire native option list on any line with a
breakdown, taking downloadable links, other modules'
additional_optionsand the file download link with it.Plugin\Checkout\DropDuplicatedOptionRowsremoves exactly the rows the breakdown reproduces; CSS could not tell them apart. - The cart breakdown reported absolute pricing as always false, so an option that REPLACED the base price rendered as a surcharge and the rows summed to more than the line.
per_qty_inputis clamped to qty_min / qty_max / qty_step on add-to-cart, not only in the pricing multiplier.- A percent-priced option displayed as a currency amount. The value-price badge
formatted every price through
priceCurrency->format()with no branch onprice_type, so a 15% option on a 349.00 product rendered "+$15.00" while the customer was charged 52.35 — the storefront understating the surcharge by three and a half times, on both themes and all nine partials that read the field. Percentages now format through NumberFormatter's PERCENT style, so the symbol and separator follow the store locale.
Changed
price_scope = 'per_cart'migrated to'per_line'by data patch.
Added
- feat(aco-pricing): carve Qoliber_AdvancedOptionsPricing out of base (no behavior change)
- feat(admin): expose qty_min/qty_max/qty_step alongside the qty_input toggle
- feat(pricing): per_qty_input multiplies by entered aco_qty (audit #4)
- feat: ship i18n en_US.csv translation baseline
- feat(pricing): per-line/setup fees as native extra-taxables (review #3)
Fixed
- fix(pricing): per_cart + setup_fee now in LINE subtotal (not quote-level)
- fix(pricing): per_qty_input survives quote reload + server-side bounds
- fix(pricing): make is_setup_fee authoritative (once per line, any scope)
- fix(pricing): phpstan bootstrapFiles FieldFactory (false-positive gate)
- fix(pricing): fee totals-segment fetch() (review-2 #3) + prorate fee base/tax across partial invoices+refunds (review-2 #4)
- fix(pricing): visible Luma 'Options Fee' totals row (review-3 #2) + exclude canceled invoices from fee ledger, clamp remainder (review-3 #3)
- fix(pricing): checkout 'Options Fee' row survives the CartTotalRepository quote reload (reconstruct fetch() from persisted per-item fee; live smoke found the row missing on checkout)
- fix(pricing): scope reloaded fee reconstruction to the fetched address's items (address_quote_items) for multishipping (was summing the whole quote)
Changed
- Convert per-cart fee to display currency + document tax behavior; add taxable-surcharge integration test
- refactor(collector): remove dead methods orphaned by the per_cart-in-line pivot
- refactor(pricing): drop dead per-value absolute branch
Internal (tests, docs, tooling)
- chore: drop module- prefix from composer package names
- test: raise PHPStan gate to level 8 (no suppression)
- test: setup-fee charged once on bulk 6000-unit candy order
- test(pricing): update assertions to the new per_cart-in-line semantic
- docs(pricing): clarify PerCartFeeCollector is now a residual zero collector
- build: add .gitattributes with export-ignore for dev tooling
- test(integration): mixed native + ACO option pricing on same product
- test(integration): multi-currency line-bake base-column coverage
- docs(pricing): document text-option per_cart fee rounding gap as 1.0.x followup
- test(pricing): per_qty multiplier + mixed-native coverage; defer bulk per_cart residual to 1.1.0
- docs: add LICENSE.txt (Qoliber Extensions User License)
- test(pricing): broaden canceled-invoice fee accounting (full-cancel→replacement, multi-partial one-canceled base+tax, no-negative-remainder, canceled-creditmemo)
Sku
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Release readiness
Internal (docs)
- Comments no longer date the GROUPED policy to 1.1.0.
Qoliber_AdvancedOptionsLinkedProductsactivates it and ships in 1.0.0, so "pre-1.1.0 behaviour" and "GROUPED is now activated ... (1.1.0)" dated the change to a release it is not in.
Added
- feat(aco-sku): carve Qoliber_AdvancedOptionsSku out of base (no behavior change)
- feat(sku): hide unimplemented "Grouped" policy from admin dropdown
- feat: ship i18n en_US.csv translation baseline
- feat(sku): activate the GROUPED SKU policy (M4)
Fixed
- fix(sku): phpstan FieldFactory in bootstrapFiles (false-positive gate)
Internal (tests, docs, tooling)
- chore: drop module- prefix from composer package names
- test: raise PHPStan gate to level 8 (no suppression)
- chore: declare php 8.4 / 8.5 compatibility
- test(sku): SkuPolicyTest matches the no-Grouped dropdown contract
- build: add .gitattributes with export-ignore for dev tooling
- docs: README 0.9.0 beta -> 1.0.0; grouped mode note moved to 1.2.0 roadmap
- docs: add LICENSE.txt (Qoliber Extensions User License)
- chore: add Qoliber copyright headers project-wide; cap composed SKU at varchar(255)
Templates
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- Every product save wiped the per-product override. The read path was complete and the only writer passed an unconditional empty diff, which DELETES the override row — so an override set by API or data patch survived exactly until someone edited the product's name. The diff is now three-way: absent leaves it alone, empty clears it, populated replaces it.
- Every blank field on a template was stored as 0. Template rows go in through
raw
insert(), and with SQL_MODE emptied — which the framework does on every connection — MySQL writes''as 0.qty_max = 0then clamps every per_qty_input surcharge built from the template to zero. Coercion is driven bydescribeTable()so it cannot drift from the schema. - The template form persisted 21 columns it never exposed, so an option inside a template could not have features the same option has on a product. 26 of 27 merchant-editable columns now have controls.
render_typewas a free-text input, so a typo persisted and silently fell back to the default renderer.price_scopewas a hardcoded list still offering "Per Cart" after the constant was removed. Every select is now backed by a source model.
Added
- Admin control for a live-linked template's per-product override.
Added
- feat(aco-templates): carve Qoliber_AdvancedOptionsTemplates out of base (no behavior change)
- feat(templates): L2 synthetic-option extender registered via extension-registry
- feat: ship i18n en_US.csv translation baseline
Fixed
- fix(requirejs): auto-load hook via deps; drop layout XML <script src> (Mismatched anonymous define)
- fix(templates): nested value rows in admin form + required_options reflects truth
- fix(templates): whitelist option/value insert columns
- fix(templates): CSRF POST-only controllers + flag reset on unlink + whitelist
- fix(templates): live-template multi-select add-on adds to cart + form ACL
- fix(templates): save-persistence via original product + hydration (#2); synthetic-option FK guard (#7); staging link-field keying
Changed
- refactor(aco-templates): move RelaxHiddenOptions to Qoliber_AdvancedOptionsDependencies
Internal (tests, docs, tooling)
- chore: drop module- prefix from composer package names
- test: raise PHPStan gate to level 8 (no suppression)
- chore: declare php 8.4 / 8.5 compatibility
- build: add .gitattributes with export-ignore for dev tooling
- test: whitelist negative tests + required_options truth-flag integration
- test(phpstan): fix whitelist test input array key-type widening
- test(integration): live-template synthetic option survives to the PLACED order
- docs: add LICENSE.txt (Qoliber Extensions User License)
Visual
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through nine external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
Pre-release defect remediation
A final audit of the unreleased 1.0.0 found defects across money, stock, cart display, theme parity and admin controls. All are fixed below; the audit's own executable reproductions ship with them.
Fixed
- Value-image alt text was destroyed on every save. The descriptor omitted it and
the save side never read it, and because
replaceForValue()deletes and re-inserts, alt set by API or data patch was wiped by the next product save on any path — including one that never touched the images. - Value images were dropped entirely when saved from the admin product form (see the base module's value-shape fix).
- A HIDDEN value image had its URL published to every visitor in the page's
options JSON, fetchable straight from the source despite the merchant marking
it not to be shown. It also reached the Luma gallery hook, which reads
images[0]without checking role, so a hidden image sorting first was swapped into the main product gallery. Hidden images are now omitted from the config;tooltipis kept, since it is a display role whose renderer is still to come.
Added
aco_imageson the customizable-option value types. Without them a headless storefront cannot render an ACO swatch at all — the images are the swatch.
Added
- feat(aco-visual): carve Qoliber_AdvancedOptionsVisual out of base (no behavior change)
- feat(visual): L1 gallery-swap listener for aco:selection-change
- feat: i18n en_US.csv baseline + @api on the Api service/data contracts
- feat(visual): staging-safe image hydration + per-descriptor role/sort (no-wipe)
Fixed
- fix(requirejs): auto-load hook via deps; drop layout XML <script src> (Mismatched anonymous define)
- fix(visual): guard against undefined fotorama instance + missing viewModel
- fix(visual): gallery swap uses fluentApi.seek + rootname URL match
- fix(security): drop SVG from value-image upload controller allow-list
- fix(security): post-save MIME check on value-image upload (defense in depth)
- fix(security): replace @unlink with checked delete + structured logging
- fix(visual): Upload POST-only (CSRF) + whitelist QLB_ referenceIds
- fix(visual): value-image save reads original product (#2)
Internal (tests, docs, tooling)
- chore: drop module- prefix from composer package names
- test: raise PHPStan gate to level 8 (no suppression)
- build: add .gitattributes with export-ignore for dev tooling
- docs: add LICENSE.txt (Qoliber Extensions User License)
0.9.0 — 2026-05-26
Advanced Options (core)
First public beta of the Qoliber Advanced Options suite for Magento Open Source / Mage-OS. Modular: a base module plus seven capability modules, a Hyvä frontend module and a suite metapackage.
Added
- Renderers — swatch, button, multi-checkbox, styled dropdown, text and qty, on both Luma and Hyvä (two known differences, listed at the top of 1.0.0).
- Dependencies — AND/OR option/value show-hide engine with an admin builder, a storefront evaluator and a server-side add-to-cart guard.
- Templates — reusable option templates in live-merge and snapshot modes, admin grid/form, and a uses-template GraphQL flag.
- Pricing — extended pricing engine: fixed / percent / absolute price types across per-unit / per-line / per-cart scopes, per-character and per-qty surcharges, and a per-cart setup-fee collector, priced cart → quote → order.
- Inventory — per-value stock ledger with MSI reservation/deduction and an out-of-stock guard.
- SKU policy — order-line SKU modes: standard / replacement / independent / grouped / disabled.
- Cost / weight — per-value cost (margin) and weight (shipping) contributions via a quote total collector.
- Visual — per-value images (gallery swap / overlay) with an admin uploader, plus per-value descriptions and tooltips.
- GraphQL / REST — read the advanced-option configuration over GraphQL (query) and REST.
- Quality gate — phpcs (Magento2), PHPStan (level 5) and unit suites per module; cross-module integration and Playwright e2e (both themes) in the base.
Known limitations
per_qty_inputmultiplier was completed in 1.0.0; older 0.9.0 installs need the 1.0.0 upgrade for theaco_qtyquote bridge.- Per-cart fee / setup fee collect into the grand total but are not yet itemized on the order / invoice / credit-memo.
- The "grouped" SKU policy currently degrades to "independent".
- Purely programmatic value-level saves may not persist every extended value column on all paths; the admin form is the supported route.
See README.md → "Known limitations (0.9.0 beta)" and the roadmap in the
install-root docs/ROADMAP.md.
0.1.0 — unreleased (pre-release, not part of 1.0.0)
Inventory Staging
This module is deliberately NOT part of the 1.0.0 suite release. It carries a 0.x
version so no ^1.0 constraint can resolve to it, and neither metapackage requires it.
Its one job — carrying the per-value stock running balance across an Adobe Commerce staged
version publish — is known-incomplete: Adobe's own unordered CatalogStaging plugin sorts
as PHP_INT_MIN and nulls the old option_type_ids before this module's beforeSave can read
them, and no plugin can be ordered earlier than PHP_INT_MIN. The seam is unfixable as designed
and the rework must be done and verified on a real Adobe Commerce install. See TODO_AC.md.
Until then the module reports the failure as a critical log entry rather than losing
balances silently. LedgerVersionMigrator and StagedOptionValueReader are correct and reusable.
The change history accumulated so far is grouped below.
Added
- feat: Qoliber_AdvancedOptionsInventoryStaging bridge — carry per-value stock across EE staged version publish
Fixed
- fix(staging-bridge): re-design against Adobe's real staging flow — array/null-id shape-agnostic + stable sort_order|sku|title pairing (review-2 #2)
Internal (tests, docs, tooling)
- docs(staging-bridge): mark AC staging migration known-incomplete (plugin ordering); defer to AC copy — see TODO_AC.md
- docs(staging-bridge): commit TODO_AC.md into the module (release-controlled; was outside git) + fix plugin reference