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.
1.0.0 — unreleased
Advanced Options (core)
First stable release of the Qoliber Advanced Options suite. Production- ready on Magento Open Source / Mage-OS for both Luma and Hyvä themes.
Before publishing, 1.0.0 was hardened for CE production through six 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.)
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. -
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.
Pricing
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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)
Dependencies
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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
Templates
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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)
Inventory
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through six external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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)
SKU Policy
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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)
Cost & Weight
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five 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)
Visual
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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)
Linked Products
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through six external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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
Hyvä
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 is grouped below.
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 Staging
Module changelog dates this release 2026-07-14.
First public release. This module ships as part of the Qoliber Advanced Options suite, CE-production-hardened through five external review rounds and a rehearsed go-live. The complete change history that went into 1.0.0 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
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, with full Luma and Hyvä parity.
- 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.