Appearance
<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /changelog/Changelog.4.120.md for this page in Markdown format</div>
Version 4
version 4.120
[4.120.0] fix(shopify): cap
BlogQuery's page size below Shopify's max query cost- Why.
Shopify\Query\BlogQuery::all()could not run against any store. Shopify prices a document before executing it and refuses anything over 1000 points, and this query's doubly-nestedarticles→commentsselection costs 1024 points at the page size inherited fromPaginatedQuery— so every call failed outright withMAX_COST_EXCEEDED. The existing tests drive a GuzzleMockHandler, which answers whatever the document asks and therefore could never surface it; it only showed up against the live store while implementing the blog import. - The change. New
BlogQuery::MAX_PAGE_SIZE = 20for callers to pass toall(), mirroring the explicit-ceiling pattern ofVendorQuery::MAX_PAGE_SIZE(which exists for the opposite reason), with the cost arithmetic documented on the constant. Measured against a live store: 20 blogs costs 732 points, 50 costs 1024 — 20 leaves room for the cost model to shift. A store with more than 20 blogs is normal and handled:all()paginates, and this only caps how many arrive per request. A test pins the constant under 50 and assertsall()sendsfirst: 20, so it cannot be quietly raised back. - Docs.
docs/flows/integration/IN-25-shopify-admin-graphql.md— the blogs/articles rows now map onto their AdvEshop targets in place of "(import not yet implemented)", plus a blog-import mapping-decisions table and a new business rule 16 on query cost (renumbering insert-don't-place to 17). - No DB migration, REST API, OpenAPI, or language-key changes.
- Why.
[4.120.0] fix(tests): restore a meaningful
composer testexit code (Advisable-com/ecommercen#521)- Why.
composer testexited1on a fully passing suite, so its exit status carried no signal —1on a clean run and1on a broken one, distinguishable only by reading the summary text. That makes it useless for gating a pre-push hook, a future CI test job, or an agent's "is the suite green?" check. - The change.
tests/Unit/Rest/Product/Resources/Vendor/MuiResourceTest.phpandMuiCollectionTest.phpaddmeta_title/meta_keywords/meta_descriptionto all four rawstdClassfixture blocks and assert them —MuiResourcegained those three reads indd5adcaff5(2026-06-12) alongside migration20260612121500, while the fixtures had not been touched since104344413b(2026-03-09), so each read warned on an undefined property. That is the change that gets the exit code to0.phpunit.xml.distgainsfailOnDeprecation="true"— a separate PHPUnit 10.5 attribute from the already-setfailOnWarning, defaulting to false — without which the attribute work below buys nothing durable and deprecations silently re-accumulate.src/Piraeus/PireausWsdlClass.phpgains#[\ReturnTypeWillChange]on its tenArrayAccess/Iterator/Countablemethods; see the regeneration warning in the notes below. - Deliberately untouched.
src/Rest/Product/Resources/Vendor/MuiResource.php— 8 of that method's 13 lines read properties unguarded, and the 3 guarded ones guard to supply non-null defaults, not for null-safety. It is also not a production bug:BaseEntity::__get()routes an absent property throughlazyLoadRelation()and returns null. Keeping the diff test-only makes this a true patch. - Verified.
vendor/bin/phpunit --testsuite=Unit→ OK (3846 tests, 10836 assertions), exit0, zero warnings and zero deprecations; the Integration and Legacy suites still exit0. - No DB migration, REST API, OpenAPI, or language-key changes.
- Why.
[4.120.0] fix(reviews): award loyalty points idempotently on review re-approval (#17)
- Why. Loyalty points were awarded repeatedly on review re-approval: a
pending → approved → pending → approvedcycle re-awarded every time, becauseis_email_sentguarded duplicate emails but nothing guarded the loyalty ledger —Adv_loyalty::savePointsToCustomer()(ecommercen/libraries/Adv_loyalty.php:236-244) runs a rawtotal_points = total_points + Nupdate with no guard. - The fix. Added
is_points_awarded TINYINT(1) NOT NULL DEFAULT 0toshop_product_reviews(migrationdatabase/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php), mirroring the existingis_email_sentcolumn. One new model method,claimReviewPointsAward(int $reviewId): bool(ecommercen/eshop/models/Adv_product_reviews_model.php:286-294) — a single conditionalUPDATE shop_product_reviews SET is_points_awarded = 1 WHERE id = ? AND is_points_awarded != 1, returningtrueonly when it changed a row, i.e. only when this call won the claim. Points are awarded if and only if the claim is won. The flag is written first, and claim plus award share one transaction, so a failed award rolls the claim back and the review stays claimable rather than being left flagged-but-unawarded. - Concurrency. The at-most-once guarantee is now enforced by the database, not by application-level check-then-act. The earlier design was only safe for sequential re-approvals — two overlapping approvals could both read
is_points_awarded = 0and both award. - Bulk path.
bulkSetStatus()claims one review at a time, in ascending review-id order, rather than with a batchedWHERE id IN (...)claim — a batched claim reports only how many rows it took, never which, so it cannot say whom to award. The ascending order makes each batch take itsshop_product_reviewsrow locks consistently, but this is not blanket deadlock immunity: each iteration also locks ashop_customerrow insidesavePointsToCustomer(), and those are not ordered, so two individually-sorted batches whose reviews map to customers in opposite relative order can still deadlock. - Error surfacing. Both
setStatus()andbulkSetStatus()now check$this->db->trans_status()aftertrans_complete()and, on a rolled-back award, surface the existingeshop.admin.product_reviews.set_status_errormessage instead of an unqualified success redirect (set_userdataon the single path,set_flashdataon bulk). No language file was touched. - Exception safety. Both paths wrap the claim+award in
try/catch (Throwable)with an explicittrans_rollback()and a rethrow — belt-and-suspenders against an unexpected non-DB exception between claim and award. AdvEshop4 runs the customadvmysqlidriver (application/config/database.php:12), whosedb_connect()setsMYSQLI_REPORT_OFFprocess-wide on every connect (system/database/drivers/advmysqli/advmysqli_driver.php:72-76), so a failing query — deadlock and lock-wait timeout included — returnsfalseand is already handled bytrans_complete()'s auto-rollback plus thetrans_status()check above. The still-true reason for rolling back explicitly: on a throw, CI never records a failure (_trans_statusis set false only whenquery()returns one), sotrans_complete()would COMMIT the claim — which underAPP_DB_DEFAULT_PCONNECT=truecan outlive the request. - Caveat. Forward-only, no data backfill: a review already approved and awarded before this deploys will award once more on its first re-approval afterward, then be guarded from then on.
- Scope. The guard sits at the two call sites, not inside the shared
savePointsToCustomer(), because non-review loyalty flows must not be constrained by review-specific state. - Tests.
tests/Legacy/Eshop/AdvProductReviewsAdminLoyaltyIdempotencyTest.php— 14 cases, including two re-entrant concurrency tests and two rollback-on-throw tests. - Docs.
docs/flows/admin/AD-52-review-moderation.mdupdated: Known Issue #7, Business Rule #5, the Loyalty Integration Gap section, the Data Model table, and the Legacy Model Methods table.
- Why. Loyalty points were awarded repeatedly on review re-approval: a
[4.120.0] docs(AD-53): correct the false claim that the email-template viewer and
Adv_mailerread from diverged template directories (#19)[4.120.0] fix(auth): escape task title/description on output to close stored XSS in the admin task list (#24)
application/views/admin/auth/tasks_list.phpechoeddescriptionraw into a double-quotedtitletooltip attribute (:226) — a stored"broke out of the attribute — and echoedtitleraw as HTML text in the modal heading (:154) and row link (:229), letting a stored<script>payload execute directly.content_shorten($task->description, 400)at:179already stripped tags viastrip_tags(), so that site was not exploitable, but it is now escaped too as defence-in-depth.- All four echo sites now wrap the value in
html_escape(); the two description sites (:179,:226) passfalseas the second (double_encode) argument so entities TinyMCE already stored ( ,&, …) aren't re-encoded into visible text, while the two title sites (:154,:229) keep the bare single-argument default.:179also passes the literal…character ascontent_shorten()'s third argument instead of the helper's'…'entity default, sohtml_escape()can't double-encode it into a visible…; the truncation marker is unchanged, and the helper's default is unchanged for every other caller. - Storage is unchanged —
Adv_auth::addTask()/editTask()still savepost('description')/post('title')raw, so existing TinyMCE descriptions keep round-tripping unchanged in the edit form (updateTask.php:28'sset_value()already escapes for that context). No sanitizer dependency was added and no input-filtering rules were introduced. - No DB migration, no config/language-key change, no behaviour change for existing rows.
[4.120.0] fix(boxnow): bounded 429/Retry-After retry on the BoxNow HTTP client + pace the status-poll loop (#443)
- Bug.
BoxNow::doRequest()(src/Transporters/BoxNow/BoxNow.php) treated an HTTP 429 (Too Many Requests) from the carrier the same as any other error — log aterrorand give up immediately — even though 429 is expected, transient throttling that a short wait usually clears. Worse, the transporter status-poll loop (getBoxNowTransporterStatus()) fired one BoxNow tracking call per parcel back-to-back with no pacing, so a batch of orders could burst the carrier API into 429s and flood theerrorlog with noise that wasn't an actual failure. Non-fatal and self-healing today — a throttled parcel takes theis_null($track)continuepath without writingshop_order, so it simply retries on the next cron run — this is a robustness + log-noise fix. - Fix.
doRequest()now runs a bounded attempt loop (one initial try plusMAX_RETRIES = 1): on a 429 it honours the carrier'sRetry-Afterheader via a new privateresolveRetryAfterSeconds(), sleeps, and retries once. Only the numeric-seconds form ofRetry-Afteris read; an absent header or the HTTP-date form falls back to a 1 second default. The value is floored at 0 (a negative header can't reachsleep(), which would throw an uncatchableValueError) and capped atRETRY_AFTER_CAP_S = 5seconds, so a large or misbehaving header can never stall the cron. A 429 that survives the retry is logged atwarninginstead oferror— it's expected throttling, not a real failure — while every other status code keeps logging aterrorwith identical message text. The success path and the['result' => …]return contract are unchanged. - Pacing.
getBoxNowTransporterStatus(), on both the modernAdvisable\Domains\Transporter\Jobs\GetOrdersTransferStatusand the legacyAdvGetOrdersTransferStatus, now callsusleep(self::BOXNOW_PARCEL_PACING_US)(250ms) as the first statement of each per-parcel loop iteration — so pacing applies on every iteration including the 429/continuepath — spacing out consecutive tracking calls to avoid tripping the carrier's rate limit in the first place. 0.25s matches the inter-provider pacing idiom already used in both jobs. - No REST API, DB migration, OpenAPI, or language-key changes. No new config surface — both constants are hardcoded.
- Known limitation, tracked separately as #493:
doRequest()'s pre-existingcatch (\Exception $e) { if ($e->hasResponse()) … }shape does not hold forConnectException(nohasResponse()), which the retry path makes marginally more likely to fire. Not a regression from this change.
- Bug.
[4.120.0] fix(admin): align VAT menu entry roles with controller RBAC (#47)
- The
eshop/vats_adminadmin menu entry (application/config/admin_menu.php) only listedAUTH_ROLE_ADVISABLEandAUTH_ROLE_ADMIN, while theAdv_vats_admincontroller'sallowRole()check (ecommercen/eshop/controllers/Adv_vats_admin.php:22-28) already granted access toAUTH_ROLE_PRODUCTStoo. PRODUCTS-role admins could reach the page directly by URL but had no visible nav link. AddedAUTH_ROLE_PRODUCTSto the menu entry'srolesarray so visibility matches the controller's existing RBAC — no controller change, no new access granted.
- The
[4.120.0] fix(cart): apply the catalogue discount to REST cart and checkout totals (#476)
- The bug.
CartTotalsCalculatorreturned the raw, pre-discountshop_product.price— both on the hydrated path (resolveItemPrice()) and on the repository fallback (getItemPrice()) — sodiscount_persentandspecial_discount_percentwere never applied. Every consumer of that subtotal quoted the undiscounted amount:GET /rest/cart,POST /rest/checkout/totals, and the order header total inPlaceOrderService. Reproduced live: a product atprice = 20.40withdiscount_persent = 40.75quoted20.40instead of12.09; a checkout-totals call for qty 2 of a 17.30 product at 36.18% returnedsubtotal: 34.60instead of22.08— the 12.52 difference is exactly the discount the customer was owed. - Two downstream bases were wrong for free, and are corrected by the same fix.
ShippingCalculator::calculate()andCouponValidator::validate()are both fed this subtotal, so the free-shipping threshold tripped early (an inflated basket looked like it had cleared the minimum) and a percentage coupon was computed off the inflated base. Neither class changed — they simply receive the correct number now. - The special-discount window semantics are settled. Four resolvers existed in the codebase and they disagreed. The checkout paths now follow the canonical legacy
new_discountrule that every storefront listing, search and sort path already prices against (ecommercen/eshop/models/Adv_product_model.php): theOTHER.ENABLE_SPECIAL_DISCOUNTSregistry flag is honoured; both window bounds must be non-NULL and non-empty, so a half-set window is not open-ended; the comparison is strict (special_from < now < special_to), so an instant exactly on a bound is outside; and inside an active window the swap tospecial_discount_percentis unconditional, so a 0% special overrides a non-zero regular discount and charges full price rather than falling through. The previousOrderBasketBuilderbranch got all four of these wrong. - One rule, one implementation. The rule now lives once, in
Advisable\Domains\Product\Pricing\DiscountResolver, returning aDiscountedPricevalue object (original price, effective percent, discounted unit price,-10%-style label). BothCartTotalsCalculatorandOrderBasketBuilderprice through it, so the subtotal the customer is quoted and the basket rows persisted at order time cannot disagree for any input — previously they were two independentif/elseifchains. No extra query: the discount columns are base columns on theshop_productrow both call sites already load.PriceCalculatorand theshop_prices_vieware also divergent and are deliberately left alone in this pass. - Scope — this is Phase 2 of epic #566, and the amount is still not final. Item prices are now the discounted NET (VAT-exclusive) figure, where the ratified checkout contract says GROSS. This entry does not claim the charged amount is correct: it is still understated by roughly the VAT rate until #563 (Phase 3) applies VAT. The relative order of the coupon and shipping steps is untouched and is tracked separately as #568.
- Tests. New
tests/Unit/Domains/Product/Pricing/DiscountResolverTest.phppins the rule directly against an injected$now— the only place the strict-boundary cases can be asserted exactly, since both call sites read the clock themselves. Coverage: regular discount, special inside/before/after its window, every invalid-bound combination (NULL and empty string, either end), the 0%-special override, bothENABLE_SPECIAL_DISCOUNTSstates, now-exactly-on-each-bound, and a guard that a product row missing the special columns resolves as "no window" instead of reachingBaseEntity's lazy-relation loader.CartTotalsCalculatorTestandOrderBasketBuilderTestre-assert the same semantics through their respective call sites, including both live repro figures.
- The bug.
[4.120.0] fix(checkout): source order-basket line price from the parent product (#477)
- The bug.
OrderBasketBuilder::buildRow()read the unit price from$productCode->price(src/Domains/Checkout/OrderBasketBuilder.php), but noproduct_code*table has apricecolumn —ProductCodedeclares onlyid,product_id,product_code,stock,soft_deletedandactive. The read evaluated tonull → 0.0, so every basket row persisted by the headless REST checkout storedprice,subtotal,original_priceanditem_pointsas zero, sitting under an order header total computed on a separate path — so it looked correct. - The fix. The base unit price is now sourced from the parent
shop_productrow, whichbuildRow()already loads for VAT resolution — no extra query. item_pointsgate.item_pointsis now also gated onPOINT_SYSTEM.IS_ENABLED, read lazily through the legacy Registry (mirroringPlaceOrderService::resolveGiftPackaging()). Safety-critical:Adv_order_modelsumsitem_points * qtyintopoints_to_addfor the award cron, so making the field live without this gate would have started awarding loyalty points on shops that have the point system switched off.- Scope — this is Phase 1 of epic #566, deliberately incomplete. Rows now carry a discounted NET (VAT-exclusive) unit price, where the ratified basket contract says GROSS. This entry does not claim the REST basket price is correct — the VAT-to-gross conversion is #563 (Phase 3) and the discount semantics are #476 (Phase 2); both land separately with their own fragments, in the same release as this one.
- Tests.
tests/Unit/Domains/Checkout/OrderBasketBuilderTest.phpandOrderBasketBuilderGiftTest.php— regression coverage pinning the price to the parent product (withproperty_exists($productCode, 'price') === falseguarding the fixture from regrowing the phantom column that masked the defect), both point-system-disabled paths, and a rule-13 cheapest-free deduction driven through the realbuildBasketRows()path.
- The bug.
[4.120.0] fix(rest/customer): stop
countryonCustomerResourcefrom serializing as the raw internalCountryentity when eager-loaded — restore the ISO alpha-2 scalar, addcountryDetails(Advisable-com/ecommercen#478)- Why.
GET /rest/customer/me?with=country(and the admin customer reads) no longer serialize thecountryfield as the raw internalCountryentity — which leaked the$repositoryclass path and broke the native session-refresh/checkout billing-country flow. Root cause:Advisable\Rest\Customer\Resources\Customer\Resource'scountryBELONGS_TOrelation shares its name with theshop_customer.countryforeign-key column, so the relation loader overwrote the scalar alpha-2 attribute in place with the joinedCountryentity on the hydrated entity —countrythen serialized as an object exposingAdvisable\Domains\Country\Country\Repository\Repository(an info-disclosure) plus raw snake_case columns (alpha_2/alpha_3/iso_cc/phone_code), instead of the documentedstring, nullablealpha-2 contract. - The change.
resource()now resolvescountryvia a newcountryAlpha2()helper that recovers the scalar alpha-2 string from the loaded entity when present. The loadedCountryrelation is embedded separately via the existingCountryResource, under a newcountryDetailsobject (nested?with=country.*relations are forwarded); it is emitted for every context, not just backend, since/rest/customer/meis a self-fetch endpoint.countryis now always the ISO alpha-2 string, whether or not the relation was requested. - REST API.
GET /rest/customer/me,GET /rest/customer/customer,GET /rest/customer/customer/{id}, andGET /rest/customer/customer/item—countryis now always the ISO alpha-2 scalar (ornull);countryDetailsis a new field, present only when?with=countryis requested. Recorded asrest_api_versions.php1.21. - Client forks. See the "Check for overrides" note below — this is a client-facing surface change (a new
countryDetailsfield plus a corrected, previously-buggycountryscalar), not purely additive. - No DB migration or language-key changes.
- Why.
[4.120.0] fix(rest/product): scope the
categoriesrelation to published-only for storefront callers, and sort it deterministically for everyone (Advisable-com/ecommercen#479)- Why.
GET /rest/product/product?with=categories— and every other embed of the relation, including nested product embeds — routed straight throughaddCollectionToData($data, 'categories', Category\Collection::class): no published scope, and no defaultORDER BY(an explicitly requested relation sort was honoured at the DB level; absent one, nothing ordered the rows). The pivot table (shop_product_category_lp) carries no ordering column, so links came back in DB-default order and could lead with an unpublished category. The storefront treatscategories[0]as the product's primary category (chip + product-detail breadcrumb), and the storefront payload never exposedpublished, so a consumer had no way to filter the links itself. Auditing production data: 1,678 of 20,325 active products affected; 26% carried ≥1 unpublished link. - The change. New protected
Advisable\Rest\Product\Resources\Product\Resource::addCategoriesToData()(src/Rest/Product/Resources/Product/Resource.php) replaces the directaddCollectionToData()call forcategoriesinaddRelationToData(). It reads the loadedcategoriesarray off the entity, filters to(int)($category->published ?? 0) === 1only when!$this->context?->isBackend(), then unconditionallyusort()s byorderthenid— the sort applies in every context, including backend, so admin responses gain deterministic ordering too even though nothing is filtered out for them. The scoped/sorted array is swapped onto the entity only for the duration of the embed and restored in afinallyblock, so it is fed through the existingaddCollectionToData()→Category\Collectionplumbing verbatim — context propagation and nested includes like?with=categories.translationskeep working unchanged — and the loaded entity is left untouched once serialization finishes. The method isprotected, not private, so client forks can delegate to it (see "Check for overrides" below). The resource'sOA\Propertydescription forcategorieswas updated to document the new scope/order contract;public/openapi.json/openapi-v1.jsonwere not regenerated in this change. - Residual gap — accepted. The filter tests each category's own
publishedflag only; it does not walk ancestors. A published category whose parent (or any ancestor) is unpublished still surfaces in the relation. This is narrower thanCategory\Service::getDescendantIds(), which prunes whole unpublished subtrees (new Filter('shop_product_category.published', [1])applied at every BFS level) — so the two are not equivalent, and this change does not make them so. Accepted for now; pruning by ancestry would need a reachability check the serialization layer cannot do without extra queries. - Unaffected.
Advisable\Domains\Support\Repository\RelationLoader\ManyToManyLoader(the shared relation loader) andAdvisable\Rest\Product\Resources\Category\Resource(ProductCategoryResource) are untouched —publishedstays backend-only on the category payload. The standalone/rest/product/categoryendpoints and the product's other many-to-many relations (tags,lines,videos,events,articles) are unaffected; onlyProductResource's embed ofcategorieschanges. - Contract change —
?sort=categories.*removed. The previously-whitelisted?sort=categories.id|orderrelation sort on the product endpoints is superseded by this fixed ordering —addCategoriesToData()always re-sorts byorderthenid, so honouring it would advertise an ordering the serializer immediately overrides. Thecategoriesentry has therefore been removed fromListRequest::setAllowedRelationSorts()(src/Domains/Product/Product/ListRequest.php) rather than left advertising a dead capability. Behaviour after removal: as with any unrecognised sort key,parseSortValue()returns null and the parameter is silently dropped — the request still succeeds with a 200 and the sort simply has no effect. There is deliberately no error response: the platform has no invalid-sort rejection path (validateRelationSorts()exists but is never called), so unknown sort keys have always been ignored rather than rejected. No repo-internal consumer used thecategoriessort. - REST API.
GET /rest/product/product,/rest/product/product/{id}, the item endpoint, and every nested embed of thecategoriesrelation on the product resource. Recorded asrest_api_versions.php1.X (2026-07-28) — the placeholder version the merge guard expects; the real number is settled at merge/release. Tightens behavior for storefront/non-backend callers — they stop receiving unpublished links they previously (unintentionally) got; additive for backend callers, who keep every link and gain deterministic ordering. - Tests. New coverage in
tests/Unit/Rest/Product/Resources/Product/ResourceTest.php(12 cases): published-only filtering plusorder/idsort forpublic/customer/no-context callers; backend context keeps unpublished links but still sorts them and still exposespublished; order-tie-break by ascendingid; thecategorieskey is absent when the relation isn't loaded, and serializes to[]when empty or when every link is unpublished; a non-arraycategoriespayload (string or object) delegates straight through without filtering or mutation; nested?with=categories.translationsstill embeds correctly; and the entity's loadedcategoriesproperty is unchanged after serialization (proving the swap-and-restore inaddCategoriesToData()). Newtests/Unit/Domains/Product/Product/ListRequestTest.php(4 cases) covers the relation-sort removal:categories.orderand-categories.idyield no sorts at all, whileproductCodes.codeand the rootpricesort still resolve. - Client forks. See the "Check for overrides" note below —
addRelationToData()'scategoriesline changed from a directaddCollectionToData()call to the new protectedaddCategoriesToData(); a fork that copied the wholeResourceclass won't inherit it automatically, and a fork that overridesaddRelationToData()should delegate to it. - No DB migration or language-key changes.
- Why.
[4.120.0] fix(eshop): route pharmacy ERP VAT auto-insert through the Vat Validator (#48)
[4.120.0] fix(rest/product): repoint six misrouted
GET /rest/product/tag*routes fromTagCategorytoTag(Advisable-com/ecommercen#481)- Why.
application/config/rest_routes.php:462-467mappedGET rest/product/tag,rest/product/tag/item,rest/product/tag/(:num)— and their(\w{2})/-prefixed locale twins — to\Advisable\Rest\Product\Controllers\TagCategory::class. Every GET call to/rest/product/tag*actually executed the tag-category controller: it returned the 18 tag categories instead of the 529 leaf product tags, withcatId/category— properties only theTagresource declares — left undefined on the response. ThePOST/PUT/DELETErows for the same paths were already correctly mapped toTag, so only the read side was affected. - The change. Repointed the six GET route rows to
\Advisable\Rest\Product\Controllers\Tag::class, matching the already-correct write mappings. Routing only — no controller, resource, or DI change. Both controllers already carry identical policies (guestfor index/show/item, backend + ADMIN/PRODUCTS for writes), so the repoint changes no auth behaviour. - REST API.
GET /rest/product/tag,/rest/product/tag/item,/rest/product/tag/{id}(and the locale-prefixed twins) now return the leafTagresource shape instead of theTagCategoryshape. Behavior correction: any caller depending on the buggy category-shaped payload from/rest/product/tagmust switch to/rest/product/tag-category, or adapt to the corrected leaf-tag payload. Recorded inrest_api_versions.php. - Docs.
docs/flows/admin/AD-15-attributes-tags.md,docs/flows/admin/AD-02-product-management-admin.md, anddocs/flows/customer/CF-18-product-tags.mdalready documented/rest/product/tag→Tagas the intended mapping — no doc drift found; the route config simply didn't match its own docs. - No DB migration, OpenAPI schema, or language-key changes.
- Why.
[4.120.0] fix(rest/slider): scope storefront/guest slider slides to active + audience-visible + ordered, restoring legacy
getFrontMasterRecordparity (#483)- Bug.
GET /rest/slider/slider?with=slides.translations(and theshow/itemsingle-record variants) served every hydrated slide to storefront/guest callers verbatim — including slides past theirdate_end, slides not yet at theirdate_start, and audience-targeted slides regardless of whether the caller belonged to that audience — in raw join order rather than byorder. Backend/admin context was and remains unaffected (intentionally sees every slide, including expired/scheduled/audience-targeted ones, for editing). - Fix. New
Advisable\Rest\Slider\SlideVisibilityFilter(constructor-injected intoRest\Slider\Controllers\Slider) is applied as a post-filter step inindex(),show(), anditem(), but only whenResourceContext::isBackend()is false: drops slides wherenow > date_endornow < date_start, hides audience-targeted slides the caller isn't a member of (via newAdvisable\Domains\Plus\Audience\Repository\Repository::getRestrictedAudienceIds()), and sorts the remainder byorderascending — reproducing legacyAdv_sliders_model::getFrontMasterRecord()behavior. Unlike legacy, expired slides are excluded from the response only — never deleted (this is a read path). - No contract change. Response field shape is unchanged — no fields added or removed, no OpenAPI diff. Only the returned data set and ordering of
slideschange, and only for non-backend consumers. Recorded asrest_api_versions.php1.22. - Tests:
tests/Unit/Rest/Slider/SlideVisibilityFilterTest.php— scheduling window, audience gating, and stable ordering.
- Bug.
[4.120.0] fix(rest/product): open Badge read endpoints to guest, matching sibling product-enrichment resources (Advisable-com/ecommercen#484)
- Why.
GET /rest/product/badge,GET /rest/product/badge/item, andGET /rest/product/badge/{id}(plus their locale-prefixed twins) required backend auth — a customer token got403, no token got401— unlike every sibling product-enrichment read endpoint (attribute,tag,tag-category, etc.), which are all guest-readable. TheBadge::classrow inapplication/config/rest_policies.phpcarried only adefaultsblock, soPolicyResolverresolved index/show/item to the backend default. This broke the storefront's/api/product/badgesfacet (403 in production, reproduced 2026-07-17). - The change.
Badge::classnow opensindex/show/itemto guest via amethodsoverride, matching theAttribute/Tag/TagCategory/CustomizationSchemaprecedent in the same section.store/update/destroyare unchanged — still backend-only (ADMIN/PRODUCTS/MEDIA). Config only: no controller, resource, or DI change. - Security — what widened, precisely. No new fields:
BadgeResourceemitsid,name,badge(image path) andtranslations[](badgeText/lang), and the already-guest-readableproduct.badgerelation serializes through the same Resource class, so a guest could already receive an identical badge object viaGET /rest/product?with=badge. What is new is enumeration — the standalone controller carries no storefront scope (nowithMandatoryFilter()), so a guest can list every row ofshop_product_badges, including badges not attached to any visible product, andListRequestallows partial-match filtering onname, onbadge(the image path) and onbadgeText.{locale}. Unannounced badge labels are therefore listable before launch. Accepted deliberately — badge names are storefront marketing copy; confirmed by the issue owner on Advisable-com/ecommercen#484. - REST API. Additive — no breaking change for existing callers; a previously-403 read now succeeds. Recorded in
rest_api_versions.php. - Docs.
docs/flows/admin/AD-25-badges.mddocumented the whole/rest/product/badgesurface as backend-JWT-only; it now splits guest reads from backend writes. - No DB migration, OpenAPI schema, or language-key changes.
- Why.
[4.120.0] fix(farmakon): compare VAT rates with numeric tolerance in
calculateOrderVats(), log unmatched rates (Advisable-com/ecommercen#49)ecommercen/helpers/farmakon_helper.php'scalculateOrderVats()bucketed each order item into one of three VAT rates using exact===string-literal comparisons —$item->product_vat === '6.00'/'13.00'/'24.00'. Any decimal-format mismatch on$item->product_vat(or a new, previously-unsupported VAT rate) silently matched none of the three buckets and contributed to none of the pharmacy accounting VAT totals, with zero error signal. Those totals feed the<FPA_POSOSTO_*>tags in the Farmakon accounting XML export (application/views/admin/farmakon/order.php), so a mismatch could silently under-report VAT in that export.- Replaced the three
===comparisons with a$vatBucketsmap (suffix => rate, e.g.'Six' => 6.00) iterated in a loop, matching each item via a numeric-tolerant check (abs($productVat - $rate) < 0.005). On a match, the loop builds the three$orderproperty names dynamically ("noVatTotal{$suffix}"/"vatValueTotal{$suffix}"/"totalWithVat{$suffix}") instead of repeating the same three-line update block once per rate — the property names themselves are unchanged and still read by the same callers and the XML export. A$matchedflag gates a newlog_message('error', ...)call fired only when no bucket matched, replacing the previous silent fallthrough. - Added a
'Zero' => 0.00bucket. 0%-VAT is a legitimate state for VAT-exempt pharmacy products, not an unrecognized rate — before this bucket was added, a'0.00'item would have hit the same silent-then-logged fallthrough as a truly unrecognized rate, false-alerting on a non-error case. - No behavior change for the four currently-valid rates (0%/6%/13%/24%) — regression-guard tests confirm identical output.
- New test file:
tests/Legacy/Helpers/FarmakonHelperTest.php(13 tests).
[4.120.0] fix(rest/checkout): honour
redeemPointsand charge the correct points discount (#495)- Bug. REST loyalty redemption was a silent no-op. Headless storefronts post
redeemPoints: 0|1, but the modern checkout only ever read an undocumentedpointsSpendinteger —redeemPointsappeared nowhere insrc/. Every redeeming customer therefore got no discount, no points debit, andshop_order.points_spend/points_rewardboth stored0, so admin, invoice, Klarna and Matomo consumers all read zero. Separately, thepointsSpendpath subtracted a point count straight off the money total — a latent ~1000x over-discount had any client ever sent it. - Fix. Redemption now follows the rendered storefront exactly. The customer can only ask to spend (a boolean, never a quantity); the server redeems the whole eligible balance, floored to a whole
ESHOP.SPEND_POINTSmultiple, and subtracts thepointsToCash()money value (ESHOP.REWARD_CASHper unit) — never the point count. Both columns are written with the correct half:points_spendthe point count,points_rewardthe cash value. Gated onPOINT_SYSTEM.IS_ENABLED; guests stay a silent no-op. The ratio is never 1:1, and nothing in the flow falls back to assuming it is — an unconfiguredSPEND_POINTSfails closed and redeems nothing. - One shared path. The quote (
POST /rest/checkout/totals) and the charge (PlaceOrderService) previously computed the total in two places. Both now resolve points through one collaborator, so the previewed total and the charged total cannot disagree. - Retired. The undocumented
pointsSpendinteger request field is removed. It was never advertised in theplace-orderOpenAPI request body, and it was the only thing in the codebase that let a client name a redemption amount — a partial-redemption capability the legacy storefront does not have. Its absence is the policy, not an oversight. - Atomic debit. The points debit moved from a read-modify-write to a single atomic SQL decrement, matching legacy
Adv_loyalty::savePointsToCustomer()and closing a lost-update race between concurrent checkouts by the same customer. - Two legacy quirks deliberately not ported (they are bugs): the self-zeroing points cache in
Adv_loyalty::getCustomersPoints(), and the unguarded divisor inAdv_loyalty::pointsToCash()that raises a PHP 8DivisionByZeroErrorwhenSPEND_POINTSis0. One legacy behaviour is ported verbatim on purpose: with the point system on butREWARD_CASH = 0, points are still spent and still debited while the discount is0— suppressing that in REST only would create the cross-channel divergence this fix exists to remove. - REST API.
POST /rest/checkout/place-orderhonoursredeemPoints(also accepted asredeem_points).POST /rest/checkout/totalsgains the same input pluspointsSpendandpointsCashresponse fields.GET /rest/storefront-configgains aloyaltysection (spendPoints,rewardCashPerUnit) exposing the ratio live from Registry, so a headless storefront can show what "spend my points" is worth. Recorded asrest_api_versions.php1.23.
- Bug. REST loyalty redemption was a silent no-op. Headless storefronts post
[4.120.0] fix(cache): use
isset()instead ofclass_exists()to detect an already-loaded Pscache library/model (#499)Pscache::library()andPscache::model()(application/libraries/Pscache.php) decided whether$this->ci->load->library()/load->model()still needed to run by testingclass_exists(ucfirst($name)). Becauseapplication/librariesandecommercenare Composer classmapped, merely naming the class satisfiesclass_exists()— the autoloaderrequires the file and defines the class without ever attaching an instance to the CI super object. The load call was therefore skipped, and the next line,call_user_func_array([$this->ci->$property, $method], $arguments), receivednulland died withTypeError: call_user_func_array(): Argument #1 ($callback) must be a valid callback, first array member is not a valid class name or object. Noteclass_exists()defaults to$autoload = true, so the guard autoloaded the class itself: for any classmap-resolvable target the condition was always satisfied on the first call, never contingent on something earlier in the request having loaded it.- Beyond the one live crash, this removes a footgun from a core caching primitive: every other
pscache->library()/model()call site was one forgotten preload away from the sameTypeError. The many existing model callers work only because their controllers happen toload->model()explicitly first ($autoload['model']is empty). - Both methods now test
isset($this->ci->$name)— whether the instance is actually attached — instead of whether the class definition happens to exist. This is CI core's own idiom for the same question:_ci_init_library()guards withisset($CI->$object_name)(system/core/Loader.php:1090), so the fix adopts the framework's convention rather than inventing one. - The live crash surface was exactly one caller: Settings → Google, via the Google Tag Gateway (Cloudflare) feature.
Adv_settings::loadGoogleTagGatewayConfig()callspscache->library('cloudflare', ...)(ecommercen/settings/controllers/Adv_settings.php:849,:854) fromgoogleRender(), which runs beforedefaultRender(), so nothing has attachedcloudflareyet —cloudflareis not in$autoload['libraries']. Blank page whenever Tag Manager was enabled with a tag ID.AdminMenu::parseCloudflareMenu()makes the same call but was never exposed:AdminMenu::__construct()preloads['registry', 'cloudflare'](application/libraries/AdminMenu.php:31) before reaching it. That preload is what kept the admin panel alive platform-wide, sincerenderAdminMenu()runs on every admin render — after this fix it is belt-and-braces rather than load-bearing, and should stay. - The crash was deterministic rather than intermittent, but gated on a cache miss:
_call()returns before the crash site on a cache hit (Pscache.php:102-104). Because theTypeErroraborts beforewrite(), the failing key never warmed, so the page stayed blank instead of self-healing. - Reproduced against a fresh
composer installbefore fixing, to rule out a stale or missing autoloader: the bug requires Composer's classmap to be working, not broken. - Regression coverage added to
tests/Unit/Pscache/PscacheTest.php(previously onlygetCacheFileName()), targeting a cold key with a class that is discoverable but not attached — plus the converse, that an already-attached instance is not reloaded. - Closes #499.
[4.120.0] fix(eshop): invalidate products pscache on legacy VAT admin writes (#50)
ecommercen/eshop/controllers/Adv_vats_admin.php'safterAdd($id),afterEdit($id), andafterDelete($id)hooks were empty stubs — adding, editing, or deleting a VAT rate through the legacy admin left theproductspscache bucket (which cachesvats_model.getRecords(), seeecommercen/eshop/models/Adv_product_parser_model.phpandecommercen/eshop/models/Adv_new_product_prices_model.php) untouched, so storefront prices kept reflecting the old VAT rate until the pscache TTL expired.- Each of the three hooks now calls
clearCache('products'), matching theproductsclearCachegroup declared inecommercen/helpers/pscache_helper.php. Storefront prices now reflect a VAT rate change immediately instead of after the TTL window. - Closes #50.
[4.120.0] fix(admin): enforce scoped, auto-disambiguating slug uniqueness for tags/tag-categories (Advisable-com/ecommercen#510)
- The bug. Neither
Adv_product_tags_adminnorAdv_product_tag_categories_adminchecked slug uniqueness on save — both called the barecreateSlug()helper directly. Two records whose names strip down to the same slug (e.g. "A1" and "A1+" both →a1) could silently end up sharing atag.slug(within the sametag_cat_id) or atag_category.slug(within the samelang, since categories have no parent partition). The storefront's facet resolution assumes a strict 1:1 slug-to-record mapping; a collision made the category/vendor listing page 404 as soon as a visitor applied whichever of the two colliding facets the query happened to resolve to second. - The fix.
Advisable\Domains\Support\Slug\SlugGenerator::generateUnique()/slugExists()(src/Domains/Support/Slug/SlugGenerator.php) gained three new optional parameters:?string $excludeColumn/mixed $excludeValue(excludes the row being edited so re-saving with an unchanged slug doesn't collide with itself) and?SlugMasterScope $masterScope(narrows the check by a column held on the MASTER table). Both admin controllers now resolveSlugGeneratorviadi()->get(SlugGenerator::class)and route everyslugassignment throughgenerateUnique()instead ofcreateSlug():Adv_product_tags_admin(ecommercen/eshop/controllers/Adv_product_tags_admin.php) — create and edit, including the admin-role-only manual slug field, the auto-slug-from-name fallback and the category-move re-check — scoped to the tag's category viaSlugMasterScope('shop_product_tags', 'tag_id', ['tag_cat_id' => $tagCatId]), excludingtag_idon edit.Adv_product_tag_categories_admin(ecommercen/eshop/controllers/Adv_product_tag_categories_admin.php) — same shape, no master scope (categories aren't partitioned by a parent) againstshop_product_tag_categories_mui, excludingtag_cat_idon edit.- Why a join and not one more
where().shop_product_tags_muiisid, tag_id, name, slug, content, lang— it has notag_cat_id, which lives on the mastershop_product_tags. A single-table scope condition therefore names a column the table does not have: MySQL 1054, and withdb_debugoff (the production setting) that surfaces asget() === false, i.e. "no collision", writing the colliding slug anyway.Advisable\Domains\Support\Slug\SlugMasterScopedescribes the master table, the MUI foreign key and the conditions to apply to it, soslugExists()canINNER JOINand express(tags.tag_cat_id, lang)honestly. Columns are qualified only when a master scope is present, so every other caller emits exactly the query it emitted before. - A failed lookup is now an error, not a pass.
slugExists()used to answer$result && $result->num_rows() > 0, which collapses a failed query into "the slug is free". It now throwsSlugUniquenessCheckExceptionwhenget()does not return a result set, so an unanswerable uniqueness check can never silently write a collision. - On a collision the slug is auto-disambiguated with a
-1,-2, … suffix rather than rejected — some admin roles can't edit the slug field directly to pick a different value themselves, so a hard validation error would leave them stuck. - Enforcement runs for every admin language (
$this->adminLanguagesloop) and every role, including the manual-slug path gated behindAUTH_ROLE_ADVISABLE. $excludeColumngiven with a null$excludeValuenow throwsInvalidArgumentExceptioninstead of building the clause. CodeIgniter rewrites a null!=comparison intoIS NOT NULL(system/database/DB_query_builder.php,_wh()), which is true for every row and therefore drops the exclusion rather than applying it — the row being edited would match its own slug, read as a collision, and be renamed (a1→a1-1) on every save, breaking an already-published facet URL. No current caller can hit this (edit($id)error_404()s on a falsy id), so this is a guard on a shared seam, not a behaviour change.
- Unaffected.
greek_lower()'s stripping/transliteration regex, the storefront's facet 404 guards, and the DB schema are all unchanged. - No DB migration. No unique index was added on
shop_product_tags_mui.slugorshop_product_tag_categories_mui.slug— deferred, see Notes below. - Tests.
tests/Unit/Domains/Support/Slug/SlugGeneratorTest.phpcoversgenerateUnique()/slugExists()with and without a master scope, with/without the exclude-own-row parameters, the null-$excludeValueguard, the category-move re-check, the failed-lookup exception, and the emitted SQL shape (joined + qualified vs unjoined + unqualified). 31 tests pass. The double behind them (tests/Support/Fakes/FakeQueryBuilder.php) validates every row and everywhere()/ONcolumn against the real column lists parsed fromdatabase/initial/initial.sql(tests/Support/Schema/InitialSchema.php), so a query MySQL would reject with 1054/1052 fails the test instead of quietly matching nothing.
- The bug. Neither
[4.120.0] feat(rest/product): default
GET /rest/product/bundle,/bundle/itemand/bundle/{id}to active-only bundles for storefront callers (#511)- Behaviour. Storefront callers — guest (no bearer token) and logged-in customer — now see only active bundles (
shop_product_bundles.is_active = 1) on all three read endpoints. Onindexanditemthefilter[isActive]value is server-forced to1, so a storefront-suppliedfilter[isActive]=0is replaced by the forced value rather than combined with it (the repository ANDs same-column specs, so keeping both would return nothing instead of the intended scope). Onshowan inactive bundle requested by ID now returns 404Entity not foundinstead of the row, becauseshow()fetches by primary key outside the filter pipeline and so gates per-row. - Backend callers are entirely unchanged and keep full visibility — they may still pass
filter[isActive]=0to list inactive bundles and may still fetch an inactive bundle by ID. OnlyResourceContext::SCOPE_BACKENDis exempt; bothSCOPE_PUBLICandSCOPE_CUSTOMERare scoped. - Implementation. New private
Advisable\Rest\Product\Controllers\Bundle::enforceStorefrontBundleScope()registers the forced filter viaHandlesRestfulActions::withMandatoryFilter('isActive', 1)and is called at the top ofindex()anditem();show()carries its own per-rowis_activegate. Deliberately a context-scoped default, not a global one — admin/back-office consumers legitimately need inactive bundles. Mirrors the establishedReview::enforceStorefrontReviewScope()pattern; unlike Review no filter key is denied, since Bundle's allowlist has nocustomerIdcounterpart. Recorded asrest_api_versions.php1.26. - No field-shape change — no fields added or removed. Only the returned data set (and the
showstatus code for an inactive bundle) changes, and only for non-backend consumers. - Tests:
tests/Unit/Rest/Product/Controllers/BundleScopeTest.php— forced-filter registration onindex/item, replace-not-AND over a clientfilter[isActive]=0, backend pass-through, customer-context scoping, and theshow()per-row 404.
- Behaviour. Storefront callers — guest (no bearer token) and logged-in customer — now see only active bundles (
[4.120.0] feat(api): serve customer order history to ContactPigeon / Menura via a read-only REST endpoint (#519)
New
GET /api/contact_pigeon/orders?mobphone=…&token=…andGET /{lang}/api/contact_pigeon/orders?…— the language prefix drives the response language. Read-only, keyed by mobile phone, returns{"orders": [...]}capped at the 50 most recent matching orders, newest first. Serves the ContactPigeon/Menura marketing-automation platform's need to look up a customer's own purchase history. Matching covers bothshop_order.pricing_mobileandshop_customer.mobilephonevia a LEFT JOIN, so guest orders (customer_id IS NULL) are included. Only the billing party is ever emitted or matched —shipping_*fields andshop_customer.sendto_mobilephoneare never touched, since the delivery recipient is frequently a non-consenting third party.[4.120.0] feat(settings): add a
CONTACTPIGEONregistry group + IP allowlist for the new endpoint (#519)New registry group
CONTACTPIGEON(IS_ENABLED,IS_PROTECTED,TOKEN), surfaced onsettings/third_party_providers. Deliberately not theXML_FEEDSgroup — the existing ContactPigeon product XML feed keeps its own separate flags and token, entirely unchanged. The token self-seeds withbin2hex(random_bytes(32))on first visit to that settings page and is rotated via a "Regenerate token on save" checkbox. A new.envkey,CONTACTPIGEON_IP_ALLOWLIST(comma-separated bare IPv4/IPv6 addresses and/or CIDR ranges), gates the endpoint by caller IP and fails closed: unset or empty denies every request, and a/0prefix is refused as a match-everything misconfiguration. Every guard rejection — disabled, IP not allowlisted, bad token — is an indistinguishable JSON 404, never 401/403, so a prober cannot learn the endpoint exists. WhenIS_PROTECTEDis off the token check is skipped by design (a product-owner-accepted fail-open toggle, consistent with the existing XML-feed admin UX); the IP allowlist is then the only always-on control.[4.120.0] fix(api): exempt the ContactPigeon order-history endpoint from site-mode redirects (#519)
Code review caught that
Adv_base_controller's constructor-timesiteModeGuard()(called right aftermaintenanceModeGuard()) also redirects, and the new endpoint matched none ofsiteModeAllowedControllers/siteModeAllowedNamespaces/siteModeAllowedRoutes. An operator settingGLOBAL.SITE_MODEto AdminOnly/ AdminFrontend during routine maintenance would silently 302 ContactPigeon's poller to/soonwith no error status and no log — the sync just stops.Api_contactpigeon_orders::classis now added tositeModeAllowedControllersinapplication/config/app.php, alongside the existingApi_services::classentry, so the endpoint is exempt from site-mode redirects. The pre-existing maintenance-mode 302 (MAINTENANCE_MODE=offline) is not exempted and still applies.[4.120.0] fix(job): stop one unparseable
entry_datetime/registry value from fataling the wholeAdvCancelIncompleteOrderscron run (Advisable-com/ecommercen#531)- The bug.
AdvCancelIncompleteOrdersfed$order->entry_datetimeintoDateTime::createFromFormat()at three call sites (cancelPendingDefaultCards(),cancelPendingPayByBank(),handlePendingXpayOrders()) and immediately called->add()on the result.shop_order.entry_datetimeisdatetime DEFAULT NULL, so aNULL(or empty/garbage) value is structurally possible; for those,createFromFormat()returnsfalse, andfalse->add()is a fatalErrorin PHP 8 — aborting the entireexecuteCommand()foreach. Every remaining PENDING order in that run went unprocessed (stock stayed reserved, coupons held, points withheld), re-fataling every 5 minutes (application/config/jobs.php:21) until someone hand-corrected the row. A legacy'0000-00-00 00:00:00'zero-date row (from imports/ERP sync/direct writes) is the other realistic bad value, but — see the fix below — it does not hit this samefalsepath; it needed its own handling. - Second hazard, same file.
cancelPendingPayByBank()interpolated the raw registry valuePAY_BY_BANK/EXPIRATIONstraight into aDateIntervalspec ('PT' . $value . 'S'). The admin field behind that key (ecommercen/settings/controllers/Adv_settings.php:1363) validates withtrimalone — norequired, nonumeric— and no shop ships a default row for it, so an empty/null/non-numeric value built an invalid spec (e.g.'PTS') that throws, the same batch-abort. - Third, folded in at triage. The gift-card sibling job
AdvCancelPendingGiftCardspassedconfig->item('giftCardDateTimeIntervalToDrop')unvalidated intonew \DateInterval(...). On this repo that key is always present (application/config/app.php:537=PT180M); the live vector is a client fork whoseapplication/config/app.phppredates the key, makingconfig->item()answernulland aborting that job. - The fix. New shared
orderEntryDate($order): ?DateTimeonAdvCancelIncompleteOrdersparsesentry_datetimeand additionally inspectsDateTime::getLastErrors(), treating a non-zerowarning_count/error_countas unparseable too —createFromFormat()does not returnfalsefor'0000-00-00 00:00:00'; it returns a validDateTimerolled back to-0001-11-30with a "parsed date was invalid" warning, which a naivefalse-only guard would have let through and silently auto-cancelled (a year-0001timestamp trivially clears every grace window). All three call sites now call it andreturn;onnull— the order is skipped, not cancelled, and logged at error level withorder_serial,payway, and the raw value; the batch continues to the next order. NewpayByBankExpirationSeconds()validates the registry value the same wayxPayExpirationSeconds()already did (added in #500), falling back to a newPAY_BY_BANK_DEFAULT_EXPIRATION_SECONDS = 86400(24h) constant.AdvCancelPendingGiftCardsgets the mirror-imagedateTimeIntervalToDrop()+DEFAULT_DATE_TIME_INTERVAL_TO_DROP = 'PT180M', guarding a missing/empty/non-string config value and a malformed interval spec, both logged at error level. - Operator-visible behaviour change. A shop with no
PAY_BY_BANK/EXPIRATIONregistry row previously fataled the cron on every PayByBank order; it now gets a 24-hour grace window instead. A shop that already has a valid value is unaffected. 86400s is deliberately not unified withXPAY_DEFAULT_EXPIRATION_SECONDS = 10800: PayByBank is a bank-transfer code with an hours-to-days lifetime, not a card redirect. - Tests:
tests/Unit/Jobs/AdvCancelIncompleteOrdersTest.php(+9, 18 total) andtests/Unit/Jobs/AdvCancelPendingGiftCardsTest.php(+5, 7 total).
- The bug.
[4.120.0] feat(ci): gate PRs on the changelog-fragment convention in both pipelines (#538)
[4.120.0] fix(core): correct operator precedence in
MY_Input::inputStream()memoisation (#545)application/core/MY_Input.php:65(before this change; now:69) read$this->rawInputStream = isset($this->rawInputStream) or $this->rawInputStream = file_get_contents(...). Because=binds tighter thanor, a second call that reached this line (only possible after a first call read an empty body) overwroterawInputStreamwith the boolean result ofisset()instead of testing it, soinputStream()could returntrueinstead of the cached string body. Removed the leading assignment so the line matches CI3's ownisset($x) or $x = ...;idiom —isset($this->rawInputStream) or $this->rawInputStream = file_get_contents('php://input');. The non-empty-body path (the only path exercised by any known caller today) is byte-identical before and after.- Also corrected the method's
@Deprecateddocblock note, which recommended CI3's owninput_stream()as the replacement — that method runsparse_str()on the body and returns a form-encoded array, not the raw string every one of this method's 18 callers needs forjson_decode(), so it is not a drop-in replacement.
[4.120.0] fix(rest): gate
?with=relations on the Customer endpoints at the policy layer (Advisable-com/ecommercen#551)- Why. The REST
relationsallow-list mechanism (RelationFilterMiddleware+PolicyResolver) has been fully wired into every REST request since Phase 2, but zero policies populated it — it was dormant platform-wide. Before this change, a customer token could still force DB batch-loads of its owncampaigns/messageHistory/smsMarketing/tags/audiencesrows via?with=onGET /rest/customer/me. No data reached the wire —CustomerResource::addRelationToData()'sisBackend()check already refused to serialize those five — so this closes attacker-chosen query cost as defense-in-depth, not an active data leak.countryproved the failure mode is real: it was loaded AND reached the wire un-gated until #478. - The change.
application/config/rest_policies.phpgains the first productionrelationsentry, for theCustomerpolicy:backendallows all six declared relations (country,campaigns,messageHistory,smsMarketing,tags,audiences);customerallows only['country'];defaultallows none ([]).?with=is now gated before the relation is ever loaded, instead of relying solely on the single Resource-levelisBackend()serialization gate. - REST API.
GET /rest/customer/me,GET /rest/customer/customer,GET /rest/customer/customer/{id},GET /rest/customer/customer/item— a customer-token caller sending?with=campaigns|messageHistory|smsMarketing|tags|audiencesnow has it silently stripped (the middleware only ever strips, never returns 400/403 — the caller just receives less data with no error).?with=countryis unaffected. Backend context is entirely unchanged, and no field shape or wire payload changes for customer/default context — those five relations were never serialized there. One genuine new edge:RelationFilterMiddlewareis not bracket-aware (naiveexplode(','), unlikeWithParser::splitTopLevel()), so a bracketed top-level param like?with=country[el]is now stripped and?with=tags[a,b]splits into garbage tokens, while a nested one like?with=country.translations[el]is unaffected (only the first dot-segment is matched). Real exposure is low — none of the six Customer relations is a locale-scopedtranslations-style relation. Recorded inrest_api_versions.php. - Client forks. See the "Check for overrides" note below — two distinct fork shapes, with opposite outcomes.
- No DB migration or language-key changes.
- Why. The REST
[4.120.0] fix(auth): escape remaining unescaped output in the admin task list to close session-persisted XSS (#552)
application/views/admin/auth/tasks_list.phpechoed ten more values raw: the search term (:21, double-quotedvalueattribute), the assignee/creator filter<option>usernames (:31,:44), the three date-filtervalueattributes (:73,:81,:89), and the creator/assignee usernames in the modal (:171,:175) and table row (:232,:233). All ten now wrap the value inhtml_escape()(bare single-argument form — these are literal-text values, not stored HTML liketitle/descriptionwere in #24).- The search term is not a plain reflected sink — it is session-persisted.
ecommercen/auth/controllers/Adv_auth.php:493captures the raw POST,:524writes it into thetskSearchsession key,:487re-reads it on every later request, and:529hands it to the view; it is cleared only viaauth/resetTasksIndex(:473). A planted payload therefore fired on every subsequent task-list load until the admin explicitly reset the search. Withcsrf_protection = false(application/config/config.php:131) and notaskCsrftoken on the search form, an attacker-hosted auto-submitting POST could plant it cross-origin into a visiting admin's session. - This completes the work begun in #24, which fixed only
$task->title/$task->descriptionin the same file; these ten sinks were explicitly scoped out of that PR. - User-visible impact is nil — escaping only changes the rendering of values that were already malformed (e.g. a stray
"or<in a search term or username). - The fix is output-only: no input sanitization was added, and how search terms are captured, stored in the session, and read back is unchanged. Storing/reading
tskSearchis still fully raw.
[4.120.0] fix(eshop): skip available-but-unpriced transporters instead of 500ing the whole shipping step (Advisable-com/ecommercen#558)
- Why.
AdvTransporters::transportCost(): floatreceived anullprice fromAdvTransporterPricing::price(): ?floatand fatalled with aTypeError, taking down the entiregetAvailableTransportersresponse — the customer saw zero shipping methods at checkout even when other transporters were perfectly valid and priced. Root cause: the transporter availability tables and pricing tables are independent data sets, so a transporter can be "available" for a county that has no pricing row — easy to hit from ordinary admin configuration. - The change. New
TransporterPriceUnavailableException(ecommercen/core/exceptions/TransporterPriceUnavailableException.php).AdvTransporters::getAvailable()now filters unofferable transporters via a newprotected isOfferable(); a newprotected canResolveTransportCost()client seam backs it.transportCost()resolves anullprice explicitly (log + throw) before its free-shipping / overweight branch table, instead of coercingnullinto afloatparameter.AdvTransporterPricinggains a publichasResolvablePrice().AdvApiTransportersController::getTransportersWithPrices()now wraps each transporter in its owntry/catchso one failure no longer aborts the response (result re-indexed so the JSON stays an array).Adv_order_model::create_order()/create_order_admin()catch the new exception and route into their existing null-serial failure contracts;Adv_order::checkoutView()catches it and reuses the controller's existingorder_error+redirect('preview_order')recovery. - Tests. New
tests/Legacy/Eshop/AdvTransportersTransportCostTest.php— first-ever test coverage oftransportCost().
- Why.
[4.120.0] fix(eshop): drop dead
$isUpdateparam and duplicatedcodecoalesce in shelf codes write path (#561)Adv_shelfcodes_admin::validation($isUpdate = false)never read$isUpdatein its body, soedit()'svalidation(true)andadd()'svalidation()already behaved identically; the parameter is now dropped and both call sites just callvalidation().Shelfcode\WriteData::fromArray()coalesced$data['code'] ?? $data['code'] ?? null— the same array key twice — collapsed to a single?? null. Both are code-generator artifacts: the duplicated coalesce fires whenever a DB column name is a single word, where snake_case equals camelCase and the generator's alias branch becomes redundant. No behaviour change in either case.- Scoped to the Shelfcode instance only. The same duplicated-coalesce shape recurs 254 times across ~100
WriteData/MuiWriteDatafiles; the durable fix belongs in the generator template, which lives in a separate repo. - Surfaced by an opus
doc-ba-proofreadsweep ofdocs/flows/admin/AD-51-shelf-codes.mdduring the delivery of #529, and split out here because #529 was docs-only.
[4.120.0] fix(checkout): charge VAT-inclusive totals on REST-placed orders (#563)
The bug.
PlaceOrderServicebuilt the order total — and the amount handed to the payment gateway — from a VAT-exclusive base.$totals['subtotal']came fromCartTotalsCalculatoras a net figure and was written straight toshop_order.total_vat, the column legacy defines as the grand total with VAT included (Adv_order_model::create_order()), and then passed astotal:to the payment adapter. Every order placed through the headless checkout was therefore undercharged by the VAT amount — on a Greek shop at the standard 24% rate, a €124.00 basket was charged €100.00.OrderBasketBuilderhad the same defect one level down, persistingprice,original_priceandsubtotalas net where legacy writes gross.The fix — the ratified basis table. Epic #566 settled the contract as legacy parity, confirmed with the client, changed in place with no new fields:
field basis GET /rest/cart→totals.subtotalGROSS POST /rest/checkout/totals→subtotalGROSS POST /rest/checkout/totals→totalGROSS shop_order.total_vatGROSS grand total — the amount charged shop_order_basket.price/original_price/subtotal/discount_priceGROSS shop_order.totalNET — the one exception shop_order.totalstays VAT-exclusive because legacy sets it to$cart['cart_total'], which issum(price_without_vat * paidQty)— items only, no shipping, coupon, points or gift packaging. It is an accounting figure and never a display subtotal; every customer-facing number in legacy is gross (there is no net figure anywhere in its displayed ledger).Order of operations is fixed: discount the net price first, then apply VAT. The two orderings are algebraically identical and differ only in where they round, which is exactly why a refactor can flip them unnoticed. Legacy computes
final_price = applyVatWithoutFormat($vat, $price_without_vat)(Adv_product_parser_model::setPrices()), so a 1.99 item at 7% with 24% VAT is 2.29 — VAT-first gives 2.30. A cent per unit against every legacy-placed order. Rounding happens at the unit, then the line multiplies, mirroring legacy'sfinal_price * $paidQty.And it rounds exactly once. Legacy feeds its unrounded
price_without_vatstraight intoapplyVatWithoutFormat(), so the discounted net is carried at full precision and rounded only where a figure is actually observed — the gross conversion, the net-basis accessors, and the net subtotal. Rounding the net first rounds twice:19.99at 33% with 24% VAT is16.61, but16.60if the net is pre-rounded to13.39. The net subtotal behindshop_order.totallikewise accumulates unrounded and rounds once for the whole cart, mirroring$totalWithoutVat/round($totalWithoutVat, 2)inAdv_order_model::baseParseCartContents().One resolve, both bases. The #476
Product\Pricingseam is extended rather than bolting a VAT call onto each call site — the same argument that collapsed four divergent discount resolvers into one.VatResolverowns the rate and the arithmetic (a byte-for-byte reproduction of the legacyapplyVatWithoutFormat()helper);PriceResolvercomposes it withDiscountResolverin the fixed order;UnitPricecarries the gross figures under the unqualified names and the net ones behind an explicitnethandle, so a call site cannot reach for the wrong basis by accident.CartTotalsCalculator::calculate()now returnssubtotal(gross) andnetSubtotalfrom a single traversal — the quoted cart, the persisted basket rows and the charged amount are the same numbers by construction.The VAT rate routes through the
VatForOrderclient-override seam.Adv_product_parser_model::setPrices()resolves its rate as$vatForOrder->vat($product->vat_value), andAdv_order_modelcaptures the adjusted rate asvat_rate_captured; the headless path now does the same, so a fork'sinvoiceVat()override (reverse-charge / intra-community / VIES / export policy) and core's non-EU zero-rating apply to REST orders exactly as they do to the storefront. Core shipsenableOrderVatManipulation = false, which makes the call an exact pass-through, so this is a no-op for a default install. Basket rows persist that adjusted rate asproduct_vat.Two downstream bases are corrected for free.
ShippingCalculatorandCouponValidatorare both fed this subtotal. Legacy deducts the coupon fromcart_total_vatand passes that same gross figure totransfer_cost_admin()/delivery_cost_admin(), so free-shipping thresholds and percentage coupons are both computed against the VAT-inclusive basket. A basket clearing a threshold only once VAT is added now correctly gets free shipping. Neither class changed.Rule-13 gifts follow the ORDER expression. Legacy's two gross-subtotal expressions disagree:
cart_total_vat()(cart_helper.php) sums the raw quantity, whileAdv_order_model::baseParseCartContents()sums a rule-13-adjusted$paidQtythat excludes the cheapest-free unit. We follow the order expression, because that is the one that has to equal the charge. Its modern form isapplyGiftOutcome()'s deduction, which now reports both a gross and a net figure so each order-header column is reduced on its own basis. The gross side — the amount charged — matches legacy exactly. The net side reaches legacy's figure on most carts but not all: legacy bakes$paidQtyinto a single accumulation from the start, whereas this path sums the full quantity and subtracts a separately-rounded deduction, soshop_order.totalcan still land a cent out. See the note below.item_pointsis deliberately unchanged, still computed off the net price. Which of legacy's four candidate bases applies is selected byESHOP.POINT_FACTOR_TYPE(setPoints()); honouring that switch is unfiled and out of scope, so the existing basis is held constant rather than silently moved to gross by this change. ThePOINT_SYSTEM.IS_ENABLEDgate added in #477 is untouched.Scope — this is Phase 3 of epic #566, and it completes the money. Unlike Phases 1 (#477, the price source) and 2 (#476, the discount), this one is not deliberately incomplete: with #477 and #476 already on the branch, the amount charged is now correct with respect to both the catalogue discount and VAT. Two known divergences from legacy remain and are tracked separately: the coupon is applied after shipping where legacy deducts it before computing transport (#568), and
points_spendis subtracted as a raw point count where legacy subtracts the cash value it stores inpoints_reward(#495). Deducting points from a gross total is correct under either unit, so #495 and this change are compatible; whichever lands second rebases onto the other. Repairing orders already placed on the wrong basis is #567 (header) and #492 (basket rows); customer remedy is #564.Tests. New
tests/Unit/Domains/Product/Pricing/PriceResolverTest.phppins the seam directly with both collaborators injected — the legacy VAT arithmetic against a table of rates, the discount-then-VAT ordering against the 1.99/7%/24% case where the two orderings disagree, both bases from one resolve, grosssave_pricereconciliation, theVatForOrderpolicy routing (including that the adjusted rate is the one reported), and a product row with novatrelation resolving to 0% rather than trippingBaseEntity's lazy-relation loader.CartTotalsCalculatorTestadds the two-basis result, per-unit rounding and the ordering pin;OrderBasketBuilderTestre-grounds every price assertion onto the gross contract;OrderBasketBuilderGiftTestpins the rule-13 deduction in both bases. A legacy-parity table pins five ordinary price/discount/VAT combinations that a double-rounded net gets wrong, each asserted twice — once against a hand-derived figure and once against a direct transcription of the legacy formula — alongside guards that the net subtotal is neither rounded per line nor truncated bybcscale(2).PlaceOrderServiceTestgains the headline guard (a non-zero rate changes both the grand total andPaymentContext::$total, the amount actually charged), a free-shipping threshold crossed only when VAT is included, and a rule-13 cart. Its two cases that positively asserted the defect —total_vat === 80.0and=== 90.0, each with the VAT-less formula written out in a comment as though it were the contract — are rewritten against a non-zero VAT rate.
[4.120.0] fix(checkout): apply the free-shipping threshold, overweight surcharge and delivery cost to REST shipping (#568)
POST /rest/checkout/shipping,POST /rest/checkout/totalsandPOST /rest/checkout/place-ordercharged the raw transporter pricing-row cost and ignored every threshold the legacy storefront applies to the same cart. Money moved in both directions: a cart aboveTRANS_COST_LIMITwas overcharged (legacy charges €0), and a cart aboveWEIGHT_LIMITwas undercharged (thePRICE_PER_KGtop-up was never added).ShippingCalculatornow portsAdvTransporters::transportCost()branch for branch —TRANS_COST_LIMIT,TRANS_FREE_ALL,WEIGHT_LIMIT/PRICE_PER_KGincluding the replace-vs-add distinction over the threshold — plusAdvTransporters::deliveryCost()and itsDELIVERY_COST_MIN_FREEwaiver. All three endpoints now return and charge the same figure.[4.120.0] fix(checkout): read the shipping threshold from the per-transporter option, never the global Registry key (#568)
Two independent stores share the name
TRANS_COST_LIMIT. The charged one is the per-transporter + per-country row intransporters_options_pricing;ESHOP.TRANS_COST_LIMITin the Registry is a display-only value powering the "free shipping over €X" banner. Only the former is read.[4.120.0] fix(checkout): deduct the coupon before evaluating the free-shipping threshold (#568)
Legacy order of operations (
Adv_order_model::create_order()deductscoupon_valuefromcart_total_vatbefore callingtransfer_cost_admin()). Applied inPlaceOrderServiceand in the/rest/checkout/totalspath. A coupon can therefore never earn free shipping, and can cost a customer free shipping they would otherwise have had.[4.120.0] fix(checkout): persist
shop_order.delivery_coston REST-placed orders and include it in the charged total (#568)The cash-on-delivery surcharge was never written and never charged by the REST flow. It is now applied only for
payWay === 'delivery'(gated server-side), waived atDELIVERY_COST_MIN_FREE, written toshop_order.delivery_cost, and added toshop_order.total_vat— never toshop_order.total, which stays the NET items-only accounting column.[4.120.0] fix(checkout): stop emitting transporter pricing-control keys as selectable shipping options (#568)
availableTransporters[].optionsserialized every row oftransporters_options_pricingonto the wire as a tickable{id, name, extraCost}— soTRANS_COST_LIMIT,WEIGHT_LIMIT,PRICE_PER_KG,TRANS_FREE_ALL,DELIVERY_COST,DELIVERY_COST_MIN_FREEandMIN_ORDER_AMOUNTwere reaching headless clients as purchasable pseudo-options. They are now consumed as configuration and filtered out; genuine shop-defined extras are unaffected.[4.120.0] feat(checkout): expose
overweightCostanddeliveryCoston the shipping and totals responses (#568)Display parity with the legacy
AdvApiTransportersController.overweightCostis read-only and already included incost/shippingCost— it exists so a client can render an "includes €X overweight" line, and summing it into a total double-charges the customer.[4.120.0] fix(mcp): return data for week/month sales buckets instead of an empty series (#569)
[4.120.0] fix(mcp): report cost as null when no supplier cost is recorded (#570)
- The
product_salesMCP tool reportedcost: 0for products with no supplier cost recorded (a storedacquisition_valueof0), whilemargin_pctfor the same product was alreadynull—Service::marginPct()has always returned null for a non-positive cost. The payload therefore carried two contradictory readings of the same underlying fact:cost: 0next tomargin_pct: null. Advisable\Domains\Order\SalesAnalytics\Service::productSales()now normalizes a non-positiveacquisition_value(null,0, or negative) tocost: null, matching whatmarginPct()already assumed. A negative stored value is normalized as well, so it can no longer produce amargin_pctabove 100%. A positive cost is unchanged — no rounding, no type change.
- The
[4.120.0] fix(checkout): refuse loyalty redemption that would drive the charged total to zero or below (#573)
The bug. Loyalty redemption is all-or-nothing by design — the customer can only say "spend my points", never how many, and the server always redeems the whole eligible balance floored to a whole
ESHOP.SPEND_POINTSmultiple. A customer whose balance converted to more cash than their cart was worth produced a negative charged total, written straight toshop_order.total_vatand handed to the payment gateway. Nothing clamped it on any path, and every downstream reader of that column — admin, invoice, Klarna, Matomo — inherited the bad figure. Two reachable cases:- REST (
POST /rest/checkout/place-order) — no UI in front of it, so a €100 cart against a balance worth €110 charged −€10. No storefront guard sits in front of this endpoint, so it was live. - Web storefront, with a coupon — the Vue guard that hides the redeem checkbox compared against a products-only subtotal and ignored the coupon, so €100 cart − €50 coupon − €90 points charged −€40 while the guard still rendered the checkbox (
90 < 100passed).
- REST (
The fix — ratified semantics (Option 3 of three considered, decided 2026-08-05). The order is now refused rather than capped or floored. This preserves the all-or-nothing invariant exactly and forfeits none of the customer's points. The rule, applied identically on all three paths:
payableBeforePoints = grossSubtotal − coupon − giftDiscount + transport + delivery + giftPackaging REFUSE when pointsCash > 0 AND ( pointsCash >= payableBeforePoints OR round(charged, 2) <= 0 )The comparison base is deliberately not
total_vat— that column is post-points, so comparing against it is circular. The>=boundary — refusing an exactly-€0 order, not only a negative one — is deliberate: there is no zero-total path anywhere in checkout, so permitting an exact-€0 order would trade one gateway failure for another.Three call sites, all changed.
- REST —
PlaceOrderServicerefuses before any side effect (no order row, no points debit, no cart clear, no gateway call, no stock decrement).Checkoutanswers HTTP 422 with error codeloyalty_redemption_exceeds_order_total. New exceptionAdvisable\Domains\Checkout\Exceptions\LoyaltyRedemptionExceedsOrderTotalException. - Legacy storefront —
Adv_orderrefuses at both preview and submit, critically before the points are debited (that debit previously ran before the order was even created). - Admin order builder —
Adv_orders_adminrefuses on add / edit / repeat, before the debit, with an admin-visible banner.
- REST —
Storefront UI.
assets/vue/mixins/checkoutPage.js'sshowPointsBlocksis now coupon-, delivery-, transport- and gift-packaging-aware, with its boundary provably equivalent to the server's, so the checkbox is never rendered for a redemption the server would refuse.New shared helper
ecommercen/helpers/loyalty_helper.phpdefines the boundary once for the legacy layer; a client fork can override it atapplication/helpers/loyalty_helper.php.
[4.120.0] fix(mcp): enforce meta_title / meta_description length limits as hard caps at the write boundary (#575)
- Bug. MCP content-write tools (
update_category,update_brand,update_product,update_product_content, and thecategories_batch_update/products_batch_updatebatch tools) previously accepted an over-lengthmeta_title(>65 chars) ormeta_description(>150 chars) and returned only a non-blockingwarningsentry — while the storefront hard-truncates a meta description at 150 characters. The over-length value was written successfully and then rendered cut, so the warning was informational only and easy to miss/ignore. - Fix.
meta_title/meta_descriptionlength is now enforced at the write boundary (Advisable\Mcp\Support\ToolResult::enforceLengths(), called from the sharedMergesTranslations::diffFields()seam every meta-writing tool passes through). An over-length value is REJECTED with aToolCallExceptionnaming the actual and permitted length, and nothing is written. It is never silently trimmed server-side — a trim would just write the same cut text the storefront produces, one layer earlier. - Tests:
tests/Unit/Mcp/Support/ToolResultTest.php,tests/Unit/Mcp/Tools/CategoryToolsTest.php,tests/Unit/Mcp/Tools/ProductToolsTest.php.
- Bug. MCP content-write tools (
[4.120.0] fix(job): reconcile stale Pending Iris gift-card orders per order instead of aborting the whole batch (Advisable-com/ecommercen#582)
- The bug.
AdvCancelPendingGiftCards::cancelPendingIrisOrders()— the 15-minute cron that reconciles stalePendingIris gift-card orders against the bank — had three defects in one loop body. Areturninside theforeachaborted the entire batch the moment one order came back unresolved; since the same row sorts first on every run, every gift card behind it was skipped on every run, indefinitely, and paying customers never received their coupon (executeCommand()'s bulk-cancel query explicitly excludespayway = 'iris', so nothing else ever swept those rows). Second, a missingcontinueaftercancelGiftCard()let control fall straight intoacceptGiftCard(), which has no status guard: a gift card the bank reported as CANCELLED/ABORTED/ERROR was cancelled and then immediately issued a valid five-year, full-face-value coupon, which was emailed to the customer — direct revenue loss. Third,getIrisRecordsByGiftCardOrderId()is declared?objectand returnsnullwhen the gift-card order has noiris_ordersrow, but$irisOrder->irisOrderIdwas dereferenced unguarded; that sentorderId => nullto the gateway, whose error reply resolved to an empty status, which tripped the batch-abortingreturn. That third path is the likely real-world trigger, and the first defect masked the second's blast radius. - The fix. Each order is now handled independently — an unresolvable order is skipped with
continue, neverreturn. A null-record guard short-circuits before the gateway is called. The decision is routed through a new pure static helper,AdvCancelPendingGiftCards::irisReconcileAction(string $orderStatus): string, returningnoop/cancel/accept:'PAID'is the only status that reachesacceptGiftCard(), an empty status leaves the orderPendingfor the next run, and anything else cancels. Accept is an allowlist rather than a default becauseacceptGiftCard()is not idempotent and issuing the coupon is a money action. The dead|| $orderStatus === 'PENDING'comparison was dropped here and in the siblingAdvCancelIncompleteOrders::handlePendingIrisOrders()—IrisHelper::interpretIrisResponse()never returns'PENDING'(that branch is commented out atsrc/PaymentGateways/Iris/IrisHelper.php:18-22), so it was unreachable in both. This is parity restoration: the sibling job already got all three behaviours right, and the gift-card version was a transplant of its per-order method into a loop body, which silently turned a per-orderreturninto a batch abort. A newprotected irisClient(): Iris\Irisseam replaces the inlinenew Iris\Iris(getIrisSettings())so the branch is unit-testable (mirrorsAdvCancelIncompleteOrders::xPay()). - Operator-visible behaviour change. The job now logs: an
errorline whenever aPendingIris gift card has noiris_ordersrow (needs a human — repair the row or resolve the order manually), andinfolines on cancel and on each still-unresolved skip. A never-resolving Iris order staysPendingforever and produces oneinfoline per run — intentional, not a leak. Shops sitting on a stranded backlog will see it drain on the first run after deploy: previously-blocked paid orders get their coupons issued and emailed in a burst, and bank-cancelled ones get cancelled. Conversely, gift cards that were wrongly issued a coupon before this fix are NOT retro-corrected; those need a manual audit. This query identifies the corrupted rows (they carry bothcanceled_atandcompleted_at, which makes them vanish from the adminCompletedfilter and show underCanceledwith a live coupon attached):sqlSELECT id, gift_card_status, canceled_at, completed_at, coupon_id FROM gift_card_orders WHERE payway = 'iris' AND canceled_at IS NOT NULL AND completed_at IS NOT NULL; - Tests:
tests/Unit/Jobs/AdvCancelPendingGiftCardsTest.php(+5, 12 total) andtests/Unit/Jobs/AdvCancelIncompleteOrdersTest.php(unchanged, 18 total — only edit is the dead-code line above).
- The bug.
[4.120.0] fix(i18n/greek): correct Greek task-completion strings (#584)
auth.task.success.completed— fixed a υ/η transposition inολοκλυρωθεί, corrected toολοκληρωθεί. This is the flash message shown after completing a task, emitted byAdv_auth::taskCompleted()(ecommercen/auth/controllers/Adv_auth.php:425).auth.page.task.tasksUnComplete.label— translated the untranslated English literal'Uncomplete'to'Αναίρεση ολοκλήρωσης', matching its siblingauth.page.task.tasksComplete.label(already'Ολοκλήρωση'). Greek was the only one of the 8 language files leaving this key untranslated. Renders as thetitle=tooltip on the un-complete button in the admin task list (application/views/admin/auth/tasks_list.php:206,:257).
[4.120.0] fix(domains/rest): hide product category links whose ancestor chain is unpublished, via a new fail-closed relation visibility scope (Advisable-com/ecommercen#588)
- Why. A product's
categoriesarray still leaked categories that are themselves published but sit under an unpublished ancestor, so storefront breadcrumbs and category chips linked into non-navigable sections of the catalogue. #479 filtered on each category's ownpublishedflag in the serializer and explicitly recorded the ancestor case as an accepted residual gap; this closes it. Measured incidence: 1.2% of wecare products, 3.7% of smile, 42% of pharm16. Two root causes: (a)ManyToManyLoadersilently ignoredRelation::$scope— its three sibling loaders honour it — so query-layer scoping of a many-to-many relation was impossible; (b) there was no channel at all for a context-dependent visibility rule on a relation. ManyToManyLoadernow honoursRelation::$scope.src/Domains/Support/Repository/RelationLoader/ManyToManyLoader.phpgained the sameif ($relation->scope) { call_user_func($relation->scope, $relatedRepo->getDb()); }block its three siblings already had. This is a no-op on current data: all 29MANY_TO_MANYrelations insrc/are declared without ascope, and the only relation in the tree that declares one — Articlecomments(src/Domains/Cms/Blog/Article/Repository/RepositoryConfigurator.php) — isONE_TO_MANYand was already honoured. Both facts are locked down by regression tests.- New
Relation::$visibilityScopeslot, applied by every loader. A tenth, trailing, optional constructor parameter onAdvisable\Domains\Support\Repository\Relation. Deliberately separate from$scoperather than an overload of it:$scopestays an always-on invariant applied unconditionally, while$visibilityScopeis applied by default but suppressible by an explicit exemption. Application lives in one place —AbstractRelationLoader::applyVisibilityScope()— and is called by all five loaders (the four inRelationLoader/plus the anonymousONE_TO_MANYoverride insrc/Domains/Product/Variation/Repository/Repository.php). Shipping a slot that only some loaders honoured would have recreated the exact defect this issue exists to fix. - Fail-closed, with a server-only exemption channel. The scope applies on every load path — including nested embeds such as
?with=basket.productCode.product.categories, at any depth, through the cyclic product↔{video,event,article} graph — unless the caller names the relation in a$visibilityExemptionslist. That list is threaded as an optional trailing parameter throughRelationLoaderInterface::load(), all five loaders and their recursion helpers,AbstractRelationLoader::loadNested()(so an exemption granted at the root survives to depth ≥2),BaseRepository::{get,loadRelations,loadRelation,executeQuery},WithRelations, andListRequest. It is never sourced from a client-controllable channel: relation$paramscarry values the client typed into?with=relation[…], so carrying an auth decision there would be a privilege-escalation shape. NewGenerateListRequest::exemptRelationVisibility()mirrors the existingforceFilter()pattern — the context-aware layer decides and pushes a trusted constraint down; the domain layer receives a decision, never theResourceContext. - "An admin sees every category on every route" is enforced as ONE universal rule. Backend callers are exempt from every relation visibility scope, granted once in
HandlesRestfulActions::buildListRequest()underResourceContext::isBackend()via the newRelation::VISIBILITY_EXEMPT_ALLsentinel andGenerateListRequest::exemptAllRelationVisibility(). Granting it per controller was tried and rejected: it is exactly the wiring that let admins silently receive the storefront-filtered set on nestedproduct.categoriesembeds through the ~16 controllers that embed a product, and it would leave the same trap armed for the next controller and the next visibility scope. Because the grant now lives in the shared seam, every REST route inherits it with no bespoke code —Rest/Product/Controllers/Product.phphas no visibility code at all and is back to plainparent::passthroughs. For the exemption to reach the query layer on every route, all 106 domain services that forward toBaseRepository::get()now implementExemptsRelationVisibilityand all 113buildSpecifications()sites pass the 4thWithRelationsargument; that uniform sweep closes the class of bug rather than the ~16 current instances. SevenTransporterservices are deliberately excluded from the interface — theirget()is a composite-PK stub that unconditionally returnsnulland can never load a relation — though they still forward the argument inbuildSpecifications()like every other service. - New
ExemptsRelationVisibilityinterface (src/Domains/Support/Service/ExemptsRelationVisibility.php).HandlesRestfulActions::show()callsReadService::get(), which has no exemption parameter. WideningReadService::get()itself is not source-compatible — PHP requires an implementation to accept every parameter its interface declares, so a trailing optional parameter there is a hardDeclaration … must be compatible with …fatal for every current implementor and for every client-fork service implementingReadServiceor overriding a service'sget(). A class may, however, declare extra trailing optional parameters beyond its interface, so a service implements both interfaces with one widenedget(), andshow()type-checks forExemptsRelationVisibilitybefore forwarding.ReadServiceitself is unchanged, which is what keeps every client-fork service that merely implements it compiling. Any service that does not implement the new interface — the sevenTransporterstubs upstream, and any fork service — simply keeps the scoped, fail-closed result. - The reachability rule. New
Advisable\Domains\Product\Category\CategoryVisibilityScope(src/Domains/Product/Category/CategoryVisibilityScope.php) reads the category tree once per request with a single flatSELECT id, parent_id, published FROM shop_product_category(908–1,704 rows in real datasets) and computes reachability in memory: a breadth-first descent from every published root (parent_id = 0) through published children only; anything not reached is hidden, applied aswhere_not_in('shop_product_category.id', $hidden)with the empty-array case guarded. No recursive CTE — the codebase contains zeroWITH RECURSIVEand it would be a novel idiom. Semantics deliberately matchCategory\Service::getDescendantIds()and the legacy front'sget_published_children_with_anchors()(parity restoration), but fix that walk's two defects: a parent cycle is bounded by a visited set instead of spinning forever, and a missing/dangling parent means HIDDEN rather than being treated as "root reached". Memoisation is per-request only; promoting it tocache.l2needs invalidation on category publish/unpublish/move and is deliberately left as follow-up work. - MCP keeps full visibility via an explicit exemption. REST backend callers are covered by the universal
buildListRequest()grant described above, so no controller carries visibility code. MCP is the one caller that cannot be: it has no REST layer and therefore noResourceContext, so it would otherwise land on the storefront default.src/Mcp/Tools/ProductTools.phpcarries an explicitVISIBILITY_EXEMPTIONSconstant applied at all sixREAD_RELATIONSload sites via two new private helpers (readListRequest(),readProduct()) — MCP has no REST layer and therefore noResourceContext, so it would otherwise land on the storefront default. That exemption is a correctness guard, not just a display one:updateProduct()derives the product's current category set from the loaded relation, diffs it against the incomingcategory_idsand writes the result, so a scoped read there would have silently deleted ancestor-hidden rows fromshop_product_category_lp. ProductResource::addCategoriesToData()reduced to ordering only.src/Rest/Product/Resources/Product/Resource.phpdrops theif (!$this->context?->isBackend())published-filter block — the loader now owns visibility. ThehasRelation()guard, the non-array delegation, theorder-then-idusort()and the swap-and-restoretry/finallyall stay, so #479's "categories[0]is a stable primary category" guarantee is preserved. The method staysprotected(client-fork override seam). ItsOA\Propertydescription was updated to state the ancestor rule;public/openapi.json/openapi-v1.jsonwere not regenerated in this change.- Unaffected.
src/Feeds/**,src/Jobs/**,Cart(fixedCART_RELATIONS, ignores?with=),CartTotalsCalculator::PRICING_RELATIONS,CartWeightCalculator::WEIGHT_RELATIONS, the legacyecommercen/+application/layers,Promotion/Coupon(itsproductsdelegates toCouponResource, notProductResource), andProduct/Media.Product\ListRequest's'relation' => 'categories'entries are filter metadata routed toFilterByCategorysubqueries, not a relation load. The three controllers that hand-roll(new $this->listRequestClass())->generate($this->input)instead ofbuildListRequest()—Rest/Admin/Controllers/Role.php,Rest/Order/Controllers/Order.php,Rest/Product/Controllers/Wishlist.php— were checked and deliberately left unchanged: theOrderandWishlistbranches are gated onisCustomer()/!isBackend()respectively (a backend caller falls through toparent::show(), which does route throughbuildListRequest()), andAdmin\RoleusesNullRelationConfiguratorand has no relations at all. No backend caller can reach acategoriesload through any of them. - REST API.
GET /rest/product/product,/rest/product/product/{id}, the item endpoint, and every nested embed of thecategoriesrelation on the product resource. Tightens behaviour for storefront/non-backend callers — they stop receiving links into unpublished catalogue sections; no change for backend callers, who keep every link. - Tests. New loader coverage for the
$scopefix and the new visibility slot, the exemption threading (including nested/depth ≥2), the reachability walk (cycle protection and the missing-parent-means-hidden polarity), an AC7 query-count assertion, the MCP exemption including theupdateProduct()diff path, and four new files:tests/Unit/Domains/Product/Category/CategoryVisibilityScopeTest.php,tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php(pins the 29 M2M relations, the single declared$scope, and the single declared$visibilityScope),tests/Unit/Domains/Support/Repository/RelationLoader/NestedVisibilityExemptionTest.php(exemption survival at depth ≥2), andtests/Unit/Rest/Product/Controllers/{ProductScopeTest,NestedCategoryVisibilityTest}.php(the latter asserting a non-product controller's nestedproduct.categoriesembed is filtered for storefront callers and unfiltered for backend ones). The published-filter cases intests/Unit/Rest/Product/Resources/Product/ResourceTest.phpmigrate to the layer that now owns the behaviour; its ordering/tie-break/absent/empty/non-array/restore cases stay. - No DB migration, no language-key changes, no new config keys.
- Why. A product's
[4.120.0] feat(reviews): grant
AUTH_ROLE_PRODUCTSreview moderation access on both layers (#59)- The legacy admin controller's
allowRole()check (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-27), the admin menu'sproduct_reviews_adminchild entryrolesarray (application/config/admin_menu.php:597), and the REST policydefaults.rolesfor bothReview::classandCustomerReview::class(application/config/rest_policies.php) previously allowedAUTH_ROLE_MARKETINGbut notAUTH_ROLE_PRODUCTSto moderate reviews — despite the admin menu always filing the entry under the PRODUCTS group. All four now includeAUTH_ROLE_PRODUCTS. The REST method overrides are unchanged:index/show/itemstayguest(public storefront reads),storestayscustomer(storefront submission),update/destroystaybackend. - This is a deliberate, product-owner-approved access widening, not a security fix or a "menu catches up to the controller" visibility restore. It intentionally supersedes #56, which had aligned both layers on MARKETING-only: PRODUCTS already owns the catalog, reviews hang off products, and the admin menu has always filed reviews under the PRODUCTS group, so the original MARKETING-only split was unintuitive and generated confusion.
- The regression guard in
tests/Unit/Rest/Middleware/PolicyResolverIntegrationTest.php:113-118, which pins the resolvedReviewREST policy so future drift fails CI, now encodes the #59 role set. - Docs:
docs/flows/admin/AD-52-review-moderation.mdupdated throughout (RBAC citations, Business Rules #7, Known Issues #5/#11, and a new Known Issue #13 on the loyalty consequence below).
- The legacy admin controller's
[4.120.0] fix(rest/checkout): resolve gifts and expose the loyalty decision in
/rest/checkout/totals(Advisable-com/ecommercen#595)- Bug 1 — no gift resolution in the quote.
POST /rest/checkout/totalswent straight from loading cart items to the totals calculator, skipping the gift stepPlaceOrderService::placeOrder()runs before charging. Consequences: the rule-13 "cheapest free"giftDiscountwas missing from the quoted total, free gift rows were missing so the quoteditemCountdiffered from the created order's basket, and gift packaging was not even an accepted input. On any gift-packaging or rule-13 cart, the total the customer was quoted was not the total they were charged. - Bug 2 — the #573 loyalty decision was invisible to headless clients. The all-or-nothing redemption refusal predicate lived only inside
PlaceOrderService. A headless client could only re-derive it from a quote that was already missing the gift terms above, so the "spend my points" toggle was hidden in a band where the server would actually have accepted the redemption. - The fix.
/rest/checkout/totalsnow runs the same gift resolutionplaceOrder()runs and acceptsgiftPackaging(bool) andselectedGifts(same shape as place-order), both in camelCase and snake_case.totalnow equals the placement path's charged figure for the same cart and request. Behaviour change: the quotedtotalmoves for gift-packaging carts and rule-13 gift carts. - Response gains four always-present fields:
giftPackagingCost,giftDiscount,payableBeforePoints, and aloyaltyobject (pointSystemEnabled,spendPoints,rewardCashPerUnit,balance,redeemablePoints,redeemableCash,canRedeem,wouldExceed).wouldExceedis evaluated through the same predicate that raises the #573 422 on/place-order, so the quote and the refusal agree on every input. - New shared service
Advisable\Domains\Checkout\GiftPackagingResolver—PlaceOrderService::resolveGiftPackaging()was a self-containedprivatemethod and is now a thin delegation to this one implementation, shared by both endpoints. Advisable\Domains\Checkout\LoyaltyRedemptiongained publicpreview()andexceedsPayable(); the #573 refusal predicate that was inline inPlaceOrderServicenow routes throughexceedsPayable(), so the quote'swouldExceedand the place-order 422 are the same predicate rather than two copies.PlaceOrderData::normalizeSelectedGifts()promoted fromprivate statictopublic static(no behaviour change) so both endpoints parseselectedGiftsidentically.- REST API: recorded in
rest_api_versions.php(1.Xplaceholder).
- Bug 1 — no gift resolution in the quote.
[4.120.0] fix(settings): stop mangling and mis-keying
EMAIL_SUBJECTSon language-suffix strip (#61)AdvEmailViewer::editEmailSubjects()derived the registry key from each POST field name withrtrim($postKey, "_$langAbbr")behind astrpos()substring guard.rtrim()'s second argument is a character mask, not a suffix, so a base name ending in one of the abbreviation's own letters was truncated past the intended cut (sample_el->samp), and the substring guard let a field with no language suffix at all enter the branch — withitconfigured,submitmatched and was written into the registry as a bogusEMAIL_SUBJECTS.submrow under languageit.- Both are closed by a literal-suffix strip: a new
AdvEmailViewer::stripLanguageSuffix(string $postKey, string $langAbbr): ?stringreturns the key with exactly_{$langAbbr}removed from the end, ornullwhen the field doesn't end in that suffix — the caller nowcontinues onnullinstead of falling through to a mangled key. - Regression coverage added in
tests/Legacy/Settings/AdvEmailViewerSubjectSuffixTest.php.
[4.120.0] fix(ui): admin notification bell no longer counts completed tasks as overdue (#71)
- The bug. The Vuex getter
getUserTasksWithDueDateExpired(assets/admin/js/tasks/tasks.js:25-27) filtered only ondue_datevs.Date.now(), so a completed task whose due date had passed still contributed to the overdue badge count until the next full page navigation dropped it out of the top 20 results. - Fix. Added a
!e.completed_atguard to the filter predicate, alongside the existinge.due_dateguard (the #70 fix, preserved unchanged). The getter now reads:e.due_date && !e.completed_at && new Date(e.due_date).getTime() < Date.now(). assets/admin/js/tasks/TasksBell.vuewas not changed — it holds no filter logic, only renderinggetUserTasksWithDueDateExpired.length(:4) and choosingmdi-bell-ringvsmdi-bell(:25-28), so the single getter fix corrects both the count and the icon.- No backend change was needed:
completed_atalready ships in the bell's JSON payload —Adv_tasks_model::fixSelect()(ecommercen/auth/models/Adv_tasks_model.php:11-14) selects{$this->table}.*, the column exists ontasks(datetime DEFAULT NULL), andAdv_auth::getUserTasks()(ecommercen/auth/controllers/Adv_auth.php:477-483)json_encodes the rows untouched.
- The bug. The Vuex getter
[4.120.0] fix(auth): fire dedicated hooks and flash message for task completion/un-completion (#73)
Adv_auth::taskCompleted()(Adv_auth.php:416-427) calledafterTaskDelete($taskId)instead of a dedicated hook — a copy-paste bug that made any client override ofafterTaskDeletealso run on task completions.Adv_auth::taskUncompleted()(Adv_auth.php:429-440) had the same copy-paste hook bug, and additionally flashed the wrong message:t('auth.task.success.completed', [$taskId])instead of an "uncompleted" message.- Two new empty
protectedhooks were added toAdv_auth—afterTaskCompleted($taskId): voidandafterTaskUncompleted($taskId): void(Adv_auth.php:693-701) — andtaskCompleted()/taskUncompleted()now call their own hook instead ofafterTaskDelete.taskDelete()is unchanged and still firesafterTaskDelete; there is no backwards-compat double-fire of the old hook from the complete/uncomplete paths. taskUncompleted()'s flash message now reads the newauth.task.success.uncompletedkey, added to all 8 shipped languages (ecommercen/language/{chinese,english,french,german,greek,italian,russian,spanish}/adv_advisable_lang.php).- Regression coverage added in
tests/Legacy/Auth/AdvAuthTaskHooksTest.php(9 reflection-based tests covering the hook contract and overridability of both new hooks).
[4.120.0] feat(shopify): read-only Shopify Admin GraphQL layer for a one-time store migration into AdvEshop (
docs/flows/integration/IN-25-shopify-admin-graphql.md)- Why. There was no way to pull an existing Shopify store's catalog, customers, orders and editorial content into AdvEshop. This adds a self-contained, read-only integration that extracts a store's data in one direction (Shopify → AdvEshop) over the Admin GraphQL API (version
2026-07). - The layer. New
src/Shopify/: an authenticatedGraphQlClientnormalising every failure to a typedShopify\Exceptions\*(Request / Response / GraphQl / Throttled / Configuration) and credential-leak-safe (http_errors=false, the access token is never logged or surfaced), over aPaginatedQuerybase doing generator-based cursor pagination with bounded throttle back-off. Entity queries:ProductQuery(+byId),CollectionQuery,VendorQuery,CustomerQuery,OrderQuery(optional$queryfilter for incremental re-runs),BlogQuery(blogs plus nested articles and comments, +byId) andShopQuery— the last reading a single object rather than a connection, so it deliberately does not extendPaginatedQueryand skips the throttle back-off, being a cost-1 query run once before any paging has drained the rate-limit bucket.ClientFactorybuilds the client at runtime so the compiled DI container never embeds the token;src/Shopify/container.php+application/config/container/modules.phpregister the factory, client and queries. - What is read, and why those fields. Product variants carry
compareAtPrice,taxableandtaxCodebecause AdvEshop inverts Shopify's price model —shop_product.priceholds the list price with the offer indiscount_persent(src/Domains/Product/PriceTracking/PriceCalculator.php) — so withoutcompareAtPricean importer can only store the discounted figure at zero discount, silently losing the list price and the offer.ShopQuerysuppliestaxesIncluded/taxShipping/currencyCode, without which whether any Shopify amount already contains VAT is a guess that skews every price by the VAT rate. Collections carryseo { title description }for theshop_product_category_muimeta columns. Customer and order addresses carry bothcountry(the display name,"Greece") andcountryCodeV2(the ISO code) plus the recipient'sfirstName/lastName/phonefor thesendto_*shipping block. Orders carrypaymentGatewayNames, theshippingLine, the fullshopMoneybreakdown (subtotal / tax / shipping / discounts / total / current total), per-linetaxLines(the rate is per line — a fee line can sit at 0% inside an otherwise-taxed order), lifecycle timestamps (processedAt,cancelledAt,closedAt) andfulfillments.trackingInfo. Every fixed-page nested connection that can truncate exposes a signal —pageInfo { hasNextPage }on a product'scollections(first: 50)and an order'slineItems(first: 100),articlesCount { count }on blogs — because counting edges cannot distinguish "exactly the page size" from "more than that". - Hard limits to know before trusting a migration (each recorded in IN-25).
read_ordersalone sees only a 60-day sliding window, and Shopify does not say so —ordersCountreports just what is visible, a lookup for an older order returns an empty result rather than an error, and orders drop out of reach as the window advances; confirmread_all_ordersvia{ currentAppInstallation { accessScopes { handle } } }before calling an order migration complete. Shopify exposes no category hierarchy — aCollectionhas no parent/child field and the navigation menu does not supply the tree either (validated against a 426-collection store: metafields, metaobject definitions, handle prefixes, the theme'ssettings_data.jsonand product-containment inference all came up empty or wrong), so imported categories are flat and the tree is arranged afterwards in the AdvEshop admin. A customer'sstate: DISABLEDis not a ban flag — it means "never set up an account", the normal state for a guest checkout, so mapping it onto an account-disabled column locks those customers out of password recovery. No password hash or salt is exportable at all, by design.taxablesays only whether a variant is taxed — the Admin API does not publish the per-product rate, andtaxCodeis normallynullunless the store runs a tax service such as Avalara. A line item'svariant/skuisnullonce the variant is deleted from Shopify, so a consumer keying on SKU must report misses rather than drop them. Writing an address's display name into a two-character country column is anERROR 1406underSTRICT_TRANS_TABLES, not a silent truncation — hence the code alongside the name. - Import mapping decisions recorded in IN-25. A historical order must be written through
Order\WriteService, neverPlaceOrderService, orOrderEventDispatcheremails customers about orders that shipped weeks ago and stock is deducted twice. Customers are matched on email, because a 13-digit Shopify id overflows the availableint(11)columns. A cash-on-delivery fee line becomesshop_order.delivery_costrather than a basket row; pickup meansstore_idset withtransport_idnull. A marketplace order is a third valid shape —transport_idandstore_idboth null with paywaybank_transfer(Adv_skroutz_orders_model.php:638,:640,:656) — so the real invariant is only that the two are never both set, and such an order must not be given a synthetic "Skroutz" transporter. An import keyed onorder_serialis idempotent, so a re-run after a scope is widened backfills history without disturbing what is already imported. - Credentials. The store domain and Admin API access token resolve straight from
.env(SHOPIFY_STORE_DOMAIN/SHOPIFY_ACCESS_TOKEN) — never the database or the admin UI, keeping the token out of both. Required Admin API read scopes includeread_content(blogs/articles) and, for full order history,read_all_orders. - Tests.
tests/Unit/Shopify/drives the transport and query layers over a shared GuzzleMockHandlerharness — pagination, throttle retry, error typing, per-entity documents, nested connections and truncation signals. 49 tests, 135 assertions. - Scope. Read/extraction only. No import/mapping service that writes pulled data into AdvEshop and no write-back mutations to Shopify. A client-specific importer consuming these fields lives in that client's fork (
application/controllers/data-imports/{Client}.php), not upstream. - No DB migration, REST API, OpenAPI, language-key, or client-override change —
src/Shopify/has no override surface and every query change is additive.
- Why. There was no way to pull an existing Shopify store's catalog, customers, orders and editorial content into AdvEshop. This adds a self-contained, read-only integration that extracts a store's data in one direction (Shopify → AdvEshop) over the Admin GraphQL API (version
[4.120.0] fix(checkout): persist PayPal Advanced
tran_ticketkeyed by order id, not order serial, and stop a resulting capture failure from surfacing as an opaque empty200(Advisable-com/ecommercen#542)- Why.
Webrun::handlePaypalOrder()(application/controllers/Webrun.php) persistedtran_ticketfor PayPal Advanced checkout orders by passing the order serial (e.g."EV1001974") intoAdv_order_model::update_order($orderId, $data), which filtersWHERE id = $orderId— a numeric primary key. MySQL coerces the non-numeric serial to0for that comparison, so the UPDATE silently matched zero rows andtran_ticketstayedNULL. Every PayPal Advanced card payment through the legacy storefront checkout then failed at capture time:PayPalRestApi::captureOrder(null)throws aTypeErroragainst its non-nullablestringparameter, and a separate defect in the exception handler (log_helper.php, tracked separately as #543, not part of this fix) turned thatTypeErrorinto an HTTP200with an empty body — so the customer, having already cleared 3-D Secure, saw an opaqueSyntaxError: Unexpected end of JSON inputand was left believing the payment might have gone through, while the PayPal order satAPPROVED/uncaptured with no funds taken. The gift-card branch of the same method was unaffected — it already converted the serial to an id viaserialToId(). Not client-specific — confirmed present ondevelop. - The change.
handlePaypalOrder()(application/controllers/Webrun.php) now receives the order object its caller already fetched and persiststran_ticketkeyed on$orderObj->idfor the checkout branch; the gift-card branch is unchanged.paypalOrderData()'s return type is corrected to?object(it assigns fromAdv_order_model::getOrder(): ?objectbut was declared as non-nullableobject), and both call sites —paypalAdvancedCreateOrder()andpaypalAdvancedCaptureOrder()— now return a JSON404instead of letting an uncaughtTypeErrorpropagate when the order can't be found.paypalAdvancedCaptureOrder()also guards an emptytran_ticketbefore callingPayPalRestApi::captureOrder(), returning a JSON422instead of throwing;Adv_checkout::paypalAdvancedSuccess()(ecommercen/checkout/controllers/Adv_checkout.php) gets the equivalent guard beforegetOrderDetails(), routed through its existingpaypalAdvancedFail()page-failure path since it renders a page rather than JSON.assets/main/js/paypal.jsandassets/main/js/googlePay.jsnow checkresponse.okbefore parsing the PayPal Advanced create/capture responses with.json(), so a failed backend call surfaces a clear error instead of the opaqueSyntaxError.googlePay.jsis confirmed not wired into any view in this repo currently; fixed anyway for correctness and for client-repo consumers. - Client forks. See the "Check for overrides" note below — a client fork that overrides
handlePaypalOrder(),paypalOrderData(),paypalAdvancedCreateOrder(),paypalAdvancedCaptureOrder(), orAdv_checkout::paypalAdvancedSuccess()keeps its own copy of the old logic and does not automatically inherit this fix. - No REST API, DB migration, OpenAPI, DI/container, or MUI changes — entirely within the legacy CI3 controller layer plus two storefront JS files.
- Why.
[4.120.0] fix(assets): content-hash storefront + admin JS entry bundles instead of relying on
?id=query-string busting (Advisable-com/ecommercen#504)- Why. JS entry bundles (
vendor.js,vendors.js,main.js,vueapp.js, themanifest.jswebpack runtime, and every per-page.js()output) were previously busted only by a?id=query string. A CDN that keys its cache on the path and ignores/strips the query string (e.g. Cloudflare) kept serving the stale JS after a deploy, causing a runtime/chunk mismatch that rendered a blank storefront page. - The change.
webpack.mix.front.jsandwebpack.mix.admin.jsnow extend the existingmix.then()hashing pass so every JS entry bundle gets a content-hashed filename (name.<hash>.js) written into the mix-manifest with no?id=query string — the same mechanism CSS bundles already use. The admin build previously had no JS hashing at all. - Progression. #379 path-hashed async code-split chunks (
chunkFilename [contenthash]) → #431 filename-hashed CSS bundles → this issue filename-hashes the remaining JS entry bundles, closing the last asset class that relied on query-string busting. - Behavior. Backwards-compatible. No view/template/PHP changes —
assetUrl()resolves bare manifest keys to the hashed paths unchanged. Barename.jscopies remain on disk for any direct-path consumer. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. JS entry bundles (
[4.120.0] feat(eshop): make the homepage video-showcase count configurable (Advisable-com/ecommercen#505)
- Why.
Adv_home::getStreamVideos()hardcoded the number of videos shown in the homepage video showcase to8, so changing it required a code edit (or a client override) instead of an admin setting. - The change.
Adv_home::getStreamVideos()(ecommercen/eshop/controllers/Adv_home.php) now reads the limit from a new registry key,VIDEOSHOWCASE.HOMEPAGE_LIMIT, falling back to8when unset. A new "Homepage videos limit" field on the Video Showcase settings form (application/views/admin/settings/video_showcase.php) lets admins set it, saved/rendered viaAdv_settings::videoShowcase()(ecommercen/settings/controllers/Adv_settings.php) with server-side validation requiring a positive integer. Fully backwards-compatible — the default of8preserves existing behavior for anyone who doesn't touch the new field. - No DB migration — the registry key is created lazily on first save, like other registry-backed settings. No REST API, OpenAPI, or language-key changes.
- Why.
[4.120.0] fix(vouchers): stop misclassifying
stripe,xpay, andethniki_nbgpayas paid-at-delivery, restoring voucher creation across all 15 carriers (Advisable-com/ecommercen#530)- Why.
isOrderPaidAtDeliveryByPayWay()(ecommercen/helpers/eshop_helper.php) omitted three online/prepaid payways --stripe,xpay,ethniki_nbgpay-- from its exclusion list, so it answeredtrue("paid at delivery") for all three, even though each actually reachesPAIDonline (Adv_checkout::stripeResponseSuccess(),::xPaySuccess(),::ethnikiNBGPayResponse()). Every caller pairs that answer with a specific status rather than treating the two as interchangeable:AdvSetPendingWithVoucher::canCreateVoucher()(ecommercen/libraries/vouchers/AdvSetPendingWithVoucher.php:1264-1273) requiresPENDING_ACCEPTEDwhen the helper saystrueandPAIDwhen it saysfalse. For these three payways only thePENDING_ACCEPTEDbranch was ever evaluated -- a status they never reach -- so voucher (shipping-label) creation returnedfalseacross all 15 carrier paths that guard on it (Geniki, GenikiV2, ACS, Elta, Center, Speedex, EasyMail, FIS, BoxNow, DHL, Taxydema, DailyCourier, Skroutz, Asap, TaxydemaV2). The same misclassification had two further consequences:orderGetSentStatusForPayWayVoucher()returnedSENTinstead ofPAID_SENTon shipment closure (5 call sites inAdv_orders_admin.php), andorderGetRevertedStatusForPayWayVoucher()returnedPENDING_ACCEPTEDinstead ofPAIDon voucher cancellation (14 call sites inAdvCancelVoucher.php). - The change. Added
stripe,xpay, andethniki_nbgpayto the exclusion list (17 entries total) and added a docblock recording what the helper actually answers: not "is this cash on delivery" but "does this payway'sAdv_checkouthandler writePENDING_ACCEPTEDrather thanPENDING" -- exactly three handlers do,_delivery(),_bank_transfer(), andpaidAtStore(). That is also whybank_transfercorrectly keeps answeringtrue: it is genuinely prepaid (the shopper wires money in advance), yet lands atPENDING_ACCEPTED, and 4.65.0 put it on this list deliberately for exactly that reason ("isOrderPaidAtDeliveryByPayWayshould not havebank_transferin list",docs/changelog/Changelog.4.65.md). Read as "is this COD?" that entry looks like a bug; read as landing status, it is correct -- don't put it back. - Tests. Extended
tests/Unit/Helpers/PayWayDebrisCoverageTest.php(already the #500 drift guard forgetCardPayWays()) with 5 new tests pinning this second, independent invariant: an equality/partition check thatisOrderPaidAtDeliveryByPayWay()returnstruefor exactlydelivery/bank_transfer/paid_at_storeandfalsefor every otherallPayWays()entry; the three regressed payways specifically returnfalse; thePENDING_ACCEPTEDtrio staystrue; both voucher-status wrappers resolve correctly for all six payways; andcanCreateVoucher()itself (invoked via reflection against the realAdvSetPendingWithVoucherclass) accepts aPAIDorder and rejects aPENDING_ACCEPTEDone for the three regressed payways, with the inverse unchanged fordelivery/bank_transfer. - Docs.
docs/flows/admin/AD-34-voucher-generation.md(the landing-status meaning of the helper, and thecanCreateVoucher()override point),docs/flows/admin/AD-03-order-management-admin.md(theSENT/PAID_SENTand revert-status resolution), anddocs/flows/system/SY-03-incomplete-order-cancellation.md(noting the drift-guard test now covers two independent invariants, and recording the set-identical convergence of the two payway lists as a deliberate observation, not a cue to unify them) updated. - Client forks. See the "Check for overrides" note below.
- No DB migration, REST API, OpenAPI, or language-key changes.
- Why.
[4.120.0] fix(jobs): sweep PENDING
xpay,klarna_payments, andethniki_nbgpayorders in the incomplete-order cancellation cron, add an XPay grace period, and delegate the deprecatedCronjob::order_debris()to the job (Advisable-com/ecommercen#500)- Why.
CancelIncompleteOrders(ecommercen/job/libraries/AdvCancelIncompleteOrders.php), scheduled every 5 minutes (application/config/jobs.php:21), draws its only order source fromAdv_order_model::getDebrisOrders(), which filtersWHERE payway IN (...)againstgetCardPayWays()(ecommercen/helpers/eshop_helper.php). Three live payways were absent from that list —xpay,klarna_payments,ethniki_nbgpay— so theirPENDINGorders were invisible to the cron and stayedPENDINGforever: no stock restore, no loyalty-points return, nomarkCouponUnused()coupon release, no ERP cancel hook, and no error raised anywhere to alert on it.xpay's handler,handlePendingXpayOrders(), already existed and worked correctly — it was unreachable dead code purely because its payway never appeared in the source list.klarna_paymentsmatters specifically on the REST checkout path, whereKlarnaAdapterwritesPENDINGby design and thefraud_statuswebhook is the single confirmation point — a lost webhook stranded the order with no fallback.ethniki_nbgpaywas not part of the original report; it surfaced during triage of the same list. - The change.
getCardPayWays()gainsxpay,klarna_payments, andethniki_nbgpay(17 entries total). Making thexpaybranch reachable meant it also needed a grace period:handlePendingXpayOrders()now age-checks the order against a new registry-configurable window (XPAY/EXPIRATION, in seconds, default10800= 180 minutes, mirroring the 180-minute default the plain-card branch already uses) before probing the Nexi API, and returns early inside the window so the next 5-minute run retries — without it, the newly-reachable branch would have started cancelling orders while the shopper was still on the Nexi hosted page. There is deliberately no admin settings field for this — it is registry-only. Separately,application/controllers/Cronjob::order_debris()— the manually-triggerable/cronjob/order_debrisroute,@deprecatedin favor of the job — duplicated the entire switch and five per-gateway handlers inline; it now delegates to(new CancelIncompleteOrders())->executeCommand([]), so the route and the scheduled job are guaranteed to behave identically going forward (net -181 lines). The route no longer needs its ownIris/XPay/PayPalRestApiimports, its$xPayproperty, or theOrderForErpHookFireTrait/OrderCancelHookFireTraittraits — all now unused there. - Tests. New
tests/Unit/Helpers/PayWayDebrisCoverageTest.phppins the coverage invariant directly: every payway inallPayWays()other than the three that writePENDING_ACCEPTED(delivery,bank_transfer,paid_at_store) must appear ingetCardPayWays(), so a future payway cannot silently repeat this drift.tests/Unit/Jobs/AdvCancelIncompleteOrdersTest.phpgained coverage for the grace window (inside/outside, a shortened registry override, and the fallback to the 180-minute default for every unusable registry value — unset/null/empty/zero/negative/non-numeric/false — sinceXPAY/EXPIRATIONhas no admin field and is unset on every existing shop) and the paid/unpaid outcomes once past the window. - Docs.
docs/flows/system/SY-03-incomplete-order-cancellation.mdupdated: thegetDebrisOrders()payway list (now 17 entries), the Default Card Gateways heading, the NexiXPay code-flow steps and registry table, the Business Rules timeout rows, the client-extension note ongetCardPayWays(), and the Known Issues list — two items resolved (the coverage gap and the duplicate handler), one narrowed to Iris-only (XPay's missing grace period is now fixed). - Client forks. See the "Check for overrides" note below.
- No DB migration, REST API, OpenAPI, or language-key changes.
- Why.
[4.120.0] fix(checkout): stop reporting durably-committed orders as failed under a read/write-split topology (Advisable-com/ecommercen#508)
- Why.
Adv_order_model::processOrder()(ecommercen/eshop/models/Adv_order_model.php) committed the order inside its transaction (added by #473) and then validated it by re-reading the row it had just written —getRecords(['conditions' => ['id' => $id], 'as_row' => true]). Under a read/write-split topology that SELECT could be routed to a replica that had not yet replicated the commit, come back empty, and make the callers'!$processedOrder || empty(...)guard (also from #473) abort checkout on an order that WAS durably persisted — the customer saw a failure, retried, and created a duplicate order. - The change.
processOrder()now returns the identity it already holds instead of re-reading it:insert_id()forid, and the singlecreateSerial()result — captured into a local before the serialUPDATE— fororder_serial.createSerial()is not idempotent: on collision it appends a random character and recurses, so a second call issued after the UPDATE would return a serial different from the one actually persisted. The method now returns a constructedstdClasscarrying only those two properties and issues no read aftertrans_commit(). The four rollback paths are unchanged and still returnfalse.create_order_admin()'s proxy path (_proccess_admin_order()) inherits the fix automatically — it is a bare pass-through toprocessOrder(). - Bug fix in passing — ApcoPay.
apcoPay()fetched a customer-joined order row (needed formail/mobilephone/landphone) and then overwrote it with a now-removed re-read of a plainshop_orderrow — which has none of those three columns — so ApcoPay was silently sentnullemail/mobile/phone on every checkout. Removing the re-read restores the join. This changes what is actually transmitted to a live payment gateway. - Bug fix in passing — Klarna confirmation mail.
klarnaCreateOrderSuccess()built its mailer payload withjson_decode(json_encode($orderData), true).json_encode()returnsfalseon any non-UTF-8 byte sequence, andjson_decode(false, true)yieldsnull— silently blanking the confirmation email for an order Klarna has already marked PAID. Replaced with a plain(array)cast, equivalent for a flat row and unable to fail this way. - Cleanup.
_eurobank()and_piraeus()each issued a redundantgetOrder()purely to obtain an id that$co_data['id']already carried; both now use$co_data['id']directly. - Tests. New
tests/Legacy/Eshop/AdvOrderModelProcessOrderCommitTest.php(10 cases): a committed order is never reported as failed even though a post-commit read would come back empty, no DB call occurs aftertrans_commit(), the returned serial is the one actually persisted,createSerial()'s collision probe runs exactly once, all four rollback paths still returnfalse, and the admin proxy (_proccess_admin_order()) passes both a committed order and a rollback through unchanged. - Client forks. See the "Check for overrides" note below.
- Scope. Loyalty-point award and coupon consumption still run outside
processOrder()'s transaction — a known gap, deferred with org-admin approval and tracked separately as #527. - Residual same-request reads, deliberately not converted. Three payment handlers still resolve the just-created order with a post-commit
getOrder(['order_serial' => …])and dereference the result unguarded:jcc()(Adv_checkout.php:3004) andiris()(:3138), where(int)($orderObj->total_vat * 100)would transmit an amount of0to the gateway, andklarnaPayments()(:3259), where the row reachesklarnaCreateOrderSuccess(object $orderData, …)— a non-nullable typed parameter, so a stale read is a fatalTypeErrorrather than a degraded render. (xpay()at:3470does the same read but guards it, and every gateway return handler reads in a later request, which is correct.) These are only reachable on a read/write-split deployment withoutcausal_reads; the converted form is preserved on thespike/508-carry-forward-defence-in-depthbranch. - Context. MaxScale
causal_reads: "local"(+causal_reads_timeout: "3s") was independently applied as incident response for this bug (wecare 2026-07-23; realm-1, seajets 2026-07-24) and closes the production exposure at the infrastructure layer; this change addresses the same root cause in code by not re-reading a row the request just wrote. - No DB migration, config key, or
application/config/autoload.phpchange.
- Why.
[4.120.0] fix(admin): remove deprecated, broken, orphaned
Scanaccessbarcode-scanner feature — closes a latent unguarded admin write path (Advisable-com/ecommercen#12)- Why.
Scanaccess(application/controllers/Scanaccess.php) was an orphaned barcode-wedge / mobile-scanner workflow: no admin menu entry anywhere inapplication/config/admin_menu.php, reachable only by typing/scanaccessdirectly. It was also broken —setup()called$this->shelfcodes_model->get_combo()(snake_case), a method that does not exist; the real method isgetCombo()(ecommercen/eshop/models/Adv_shelfcodes_model.php:55) — so scanning any valid product fatalled withCall to undefined methodand the scan-to-edit workflow could never complete. Its views were last touched 2015-2018, predating the current Vue admin. Separately, and more seriously:POST /scanaccess/updatewas an independent, still-functional, unguarded write path —ScanaccessextendsAdmin_cwith noallowRole()call, so any logged-in admin role (regardless of assigned permissions) could change a product'sshelfcode_idandpriceviaproduct_model->batchMasterUpdate(). - The change. Deleted the controller (
application/controllers/Scanaccess.php) and its view directory (application/views/scanaccess/:scanview.php,setup.php,update.php— the last was 0 bytes and never loaded by any action). Removed the$route['scanaccess']/$route['scanaccess/(.+)']route block fromapplication/config/routes.php(including its two commented-out locale-prefixed variants). Removed the now-orphaned language keyadmin.label.product_not_foundfrom all 8 localeadv_advisable_lang.phpfiles (english,greek,german,french,italian,spanish,russian,chinese) —Scanaccesswas verified to be its only consumer repo-wide. - Security. This closes the unguarded write path outright:
/scanaccess,/scanaccess/setup, and/scanaccess/updateno longer resolve to anything, so the role-check gap can no longer be exploited. This is a security-posture improvement, not just dead-code cleanup. - Unaffected. Shelf-code functionality itself is untouched — the shelf-code admin CRUD (
Adv_shelfcodes_admin), the modern Domain + REST layer (src/Domains/Product/Shelfcode/,src/Rest/Product/Controllers/Shelfcode.php), andgetCombo()all remain live and correct. Only the scanner entry point into that data is gone. - Docs.
docs/flows/admin/AD-51-shelf-codes.mdpruned: removed the "Barcode Scanner Workflow" and "Barcode Scanner Routes" sections and every Scanaccess reference in the Business Context, Architecture table, Configuration, and Known Issues sections; renumbered the "Known Issues & Security Gaps" list to close the gaps left by the two removed items. - Client forks. See the "Check for overrides" note below.
- No DB migration, REST API, or OpenAPI change.
- Why.
[4.120.0] fix(rest-auth): resolve
advauththrough CodeIgniter's Loader instead of autowiring it, restoring$CI->advauthon REST requests (Advisable-com/ecommercen#523)- Why. REST controllers extend
Base_cdirectly and never loadsession/cart/advauth— onlyAdv_admin_controller/Adv_front_controllerdo (ecommercen/core/Controller.php:12-15).src/Rest/Auth/container.phppreviously registered\Advauth::classas a plain autowire ($services->set(\Advauth::class, \Advauth::class)), so the instance injected intoAdminAuth/CustomerAuthvia constructor never touched the CI super-object. A clientCustomer_modeloverride calling$this->advauth->advAuthHash()/advAuthVerify()from theprotected generateEncryptedValues()/checkPassword()hooks fatalled with "on null", because those hooks read$this->advauthoff the CI super-object, not off the controller that had it injected. That broke three REST surfaces routed through those hooks: customer register, customer login, and password reset. - The change. New
Advisable\CodeIgniter\AdvauthFactory::create()(src/CodeIgniter/AdvauthFactory.php) does$CI =& get_instance(); $CI->load->library('advauth'); return $CI->advauth;— obtaining the library through CI's Loader is what registers it on the CI super-object. It's registered under the string service idrest.auth.advauth($services->set('rest.auth.advauth', \Advauth::class)->factory([AdvauthFactory::class, 'create']));AdminAuthandCustomerAuthnow wire their$authconstructor argument to that id explicitly instead of by type. The\AdvauthFQCN is deliberately not a service id or alias anymore: CI's Loader resolves libraries viadi()->has($className)(system/core/Loader.php:1109-1110), so keeping (or re-adding) anAdvauthid would make the factory's ownload->library('advauth')call re-enter this same definition, raising a SymfonyServiceCircularReferenceExceptionat request time. - Tests. New
tests/Integration/Auth/AdvauthDiSeamTest.php(4 cases) pins both halves of the fix directly againstsrc/Rest/Auth/container.php's definitions: the service is built via theAdvauthFactoryfactory (not autowired),\Advauthhas no service id or alias,AdminAuth/CustomerAuthwire$authtorest.auth.advauthby id, and — behaviourally — resolving the service actually populates$CI->advauth. Added to theIntegrationsuite inphpunit.xml.dist. - Client forks. See the "Check for overrides" note below.
- No DB migration, new config key, or
application/config/autoload.phpchange.
- Why. REST controllers extend
[4.120.0] feat(rest/order): add gift-card settings endpoint (Advisable-com/ecommercen#515)
- Why. The admin-facing gift-card settings (min/max amount, free-amount toggle, SMS delivery, order-serial prefix, enabled state, and the gift-card denomination catalog) only existed behind the legacy
AdvGiftCardSettingsadmin controller, reading/writing theGIFT_CARDSregistry group directly viaAdvGiftCardSettingsReader. There was no REST surface for it. - The change. New
Advisable\Domains\Order\GiftCardSetting\GiftCardSettingRegistry(src/Domains/Order/GiftCardSetting/GiftCardSettingRegistry.php) ports both halves of the legacy class:all()reads the 7GIFT_CARDSkeys (enabledGiftCards,freeAmount,minAmount,maxAmount,giftCards,enabledSms,orderPrefix), preserving the legacy bool casts and thegiftCardsarray's quirky self-referential storage (regKey/regGroupboth'GIFT_CARDS');save()persists the same 7 keys, sortinggiftCardsbefore write and applying the legacy defaults (minAmount→0,maxAmount→500) for omitted fields. Registry-backed key/value config, not a DB table, so — like siblingFeatures/StorefrontConfig— there is no Entity/Repository.\Registryisn't autowireable (same constraint asFeatures\FeatureRegistry), so it's resolved lazily from the CI super-object on first use rather than constructor-injected. NewValidator(minAmount/maxAmountrequired|numeric, matchingAdvGiftCardSettings::validation()) andWriteService(validate → persist → re-read) round out the write path. NewAdvisable\Rest\Order\Controllers\GiftCardSetting(src/Rest/Order/Controllers/GiftCardSetting.php) exposesGET/POST /rest/order/gift-card-settingas a singleton config resource (no/item,/{id}, or list/relations — same shape asRest\Features\Controllers\Features);enabledGiftCardsis only persisted when the caller holdsAUTH_ROLE_ADVISABLE(resolved viaResourceContext::hasRole()), silently ignored forADMIN/ORDERScallers, mirroring the legacy controller unsetting it beforesavePostData()for non-Advisable users. Registered insrc/Domains/Order/container.phpandsrc/Rest/Order/container.php; routed (locale-prefixed and bare) inapplication/config/rest_routes.php; RBAC inapplication/config/rest_policies.php(backendauth,AUTH_ROLE_ADMIN/AUTH_ROLE_ORDERS—AUTH_ROLE_ADVISABLEbypasses via superuser check, same as siblingGiftCardOrder). - REST API. New, purely additive endpoint —
GET/POST /rest/order/gift-card-setting. Backend auth;ADVISABLE/ADMIN/ORDERSroles. Recorded asrest_api_versions.php1.20. - Tests. New
tests/Unit/Domains/Order/GiftCardSetting/GiftCardSettingRegistryTest.php(7 cases): the 7-key read mapping and bool casts,orderPrefix/giftCardsdefaults on a null/empty registry, theENABLED-write RBAC gate (persisted for Advisable, skipped otherwise, defaulted tofalsewhen omitted),giftCardssort-before-write, and the full set of legacy write defaults for an entirely empty payload. - No DB migration, language-key changes, or client-override note — this is a brand-new endpoint with no existing override surface to break.
- Why. The admin-facing gift-card settings (min/max amount, free-amount toggle, SMS delivery, order-serial prefix, enabled state, and the gift-card denomination catalog) only existed behind the legacy
[4.120.0] fix(rest/product): serialize declared relations across 11 REST resources — bundle-family leaves, related products, reviews, waiting list, product meta, and downloads (Advisable-com/ecommercen#512)
- Why. Sibling of the same dead-code class as
ProductBundleResource(#482): eleven resources undersrc/Rest/Product/Resources/each already defined anaddRelationToData()method, butresource()built and returned the array directly (or, forReview, returned its local$datavariable) without ever routing it through that method — so relations the repository had already batch-loaded onto the entity were silently dropped from every?with=...response. - The change. Each resource's
resource()now wraps its return value in$this->addRelationToData([...])(Reviewwraps its existing$data), matching the idiom already used byBundle/Attribute/Variation/Tag. Every newly-serialized relation also gained anOA\Propertyentry on its resource's#[OA\Schema]— none of these were previously documented — andpublic/openapi.json/public/openapi-v1.jsonwere regenerated viaphp cli.php job/GenerateOpenApiJson. Affected resources and the relations each now embeds on?with=...:BundleDisplay(ProductBundleDisplayResource) →bundleBundleCriteria(ProductBundleCriteriaResource) →bundle,referencesBundlePricing(ProductBundlePricingResource) →bundle,criterionBundleBuilderReference(ProductBundleBuilderReferenceResource) →bundleBundleCriteriaReference(ProductBundleCriteriaReferenceResource) →criterionRelatedGroup(ProductRelatedGroupResource) →translationsRelated(ProductRelatedResource) →product,relatedProduct,groupReview(ProductReviewResource) →productWaitingList(ProductWaitingListResource) →productProductMeta(ProductProductMetaResource) →productDownload(ProductDownloadResource) →product
- REST API.
GET /rest/product/bundle-display(/{id}|/item),/bundle-criteria,/bundle-pricing,/bundle-builder-reference,/bundle-criteria-reference,/related-group,/related,/review,/waiting-list,/product-meta, and/downloadnow return the populated relations listed above when requested via?with=..., where they previously returned nothing — the OpenAPI schema for each simultaneously starts advertising the same properties it was silently missing, so the wire behavior and the published contract land together in this change (unlike #482/#513, which split the wire fix and the schema documentation across two PRs). Additive — recorded asrest_api_versions.php1.19. Same pattern as #482 (rest_api_versions.php1.18) and the #418ProductWishlistResourcefix (1.13). - Client forks. See the "Check for overrides" note below — only each resource's
resource()method body changed, no signature change, so BC; a fork's admin/storefront UI that worked around one of these resources' always-empty relations (e.g. fetching a bundle's criteria/pricing rows, a product's reviews, or its related-products group separately instead of via?with=...) can be simplified now that relation embedding works, but is not required to change. - No DB migration or language-key changes.
- Why. Sibling of the same dead-code class as
[4.120.0] fix(product-bundle): serialize
display/criteria/builderReferences/pricingrelations onProductBundleResource(Advisable-com/ecommercen#482)- Why.
Resource::resource()(src/Rest/Product/Resources/Bundle/Resource.php) built and returned a bare array, never calling theaddRelationToData()method already defined on the class.GET /rest/product/bundle/{id}?with=display,pricing,criteriareturned only the base scalar keys even though the repository's relation loader had already batch-fetcheddisplay/criteria/builderReferences/pricingonto the entity — the requested relations were silently dropped. - The change. Wrapped the return value in
$this->addRelationToData([...]), matching the idiom already used by theAttribute,Variation, andTagsibling resources. No relation-loading, DI, or route change. - REST API. The bundle collection, show, and item endpoints (
GET /rest/product/bundle,GET /rest/product/bundle/{id}) now return populateddisplay/criteria/builderReferences/pricingarrays when requested via?with=..., where they previously returned nothing. Additive — recorded asrest_api_versions.php1.18. Same pattern as the #418ProductWishlistResourcerelation fix (rest_api_versions.php1.13). - Client forks. See the "Check for overrides" note below — any fork's admin/storefront UI that worked around the always-empty bundle relations (e.g. fetching
BundleDisplay/BundlePricing/BundleCriteriaseparately per bundle instead of via?with=...) should re-check that call site now that relation embedding works. - No DB migration, OpenAPI schema, or language-key changes.
- Why.
[4.120.0] docs(rest/product): document
display/criteria/builderReferences/pricingrelations inProductBundleResource's OpenAPI schema (Advisable-com/ecommercen#513)- Why.
ProductBundleResource's#[OA\Schema](src/Rest/Product/Resources/Bundle/Resource.php) declared only the scalar base properties and never listed the four?with=relations the resource actually serializes. Since #482/#514,display,criteria,builderReferences, andpricingappear on the wire viaaddRelationToData(), but the published OpenAPI spec stayed silent on them — a working, public relation contract (already advertised by the controller'sOA\Tag x.relations) went undocumented. - The change. Added
OA\Propertyarray entries fordisplay,criteria,builderReferences, andpricingto the schema, mirroring theAttribute/Variation/Tagsibling idiom already used elsewhere on the resource, and regeneratedpublic/openapi.json/public/openapi-v1.json. Documentation-only — no behavioral, route, DI, or relation-loading change. - Scope.
Bundleparent resource only; the leaf-family back-relations are tracked separately under #512. - No
rest_api_versions.phpentry — no wire-contract change (the relations already serialized since #482/#514; this only brings the generated spec in line with that already-live contract). No DB migration, language-key, or behavioral change.
- Why.
[4.120.0] fix(validators): measure string-length limits in characters (
mb_strlen), not bytes (strlen), across four domains (Advisable-com/ecommercen#506)- Why — three different failure modes, not one.
strlen()counts bytes; every limit it was measuring is character-based, so any multibyte input diverges from the intended budget. (1)Seo\CustomMetaTag\ValidatorandSeo\DefaultMetaTag\Validator— over-rejection, the real user-facing impact.pageTitle(65),metaDescription(150/500),metaKeywords(1000),url(255), andmetaTitle(255) are policy limits onTEXT/VARCHARcolumns (not column caps), and crawler/browser copy budgets are counted in characters. A 150-character GreekmetaDescriptionis ~300 UTF-8 bytes, so it was silently rejected at the 150-byte mark — roughly 75 Greek characters, half the intended budget. This was live and silent on a Greek-market platform. (2)Admin\User\Validator— fails the opposite way, security-adjacent.strlen($password) < 8is a floor, so byte-counting weakened it: a 4-character Greek password is 8 bytes and passed bothvalidateForCreate()andvalidatePasswordChange(). The fix tightens this auth check — the one genuinely client-facing behavior change in this diff (see "Check for overrides" below). (3)Cart\Cart\Validator— no practical exposure.currencyCode/cartToken/couponCodeare ISO-4217 codes and generated tokens, ASCII by construction; converted for uniformity and given first-ever unit coverage. - The change. All 17 call sites across the four files now use
mb_strlen($value, 'UTF-8'). Docblocks on bothSeovalidators were also corrected — they described a "byte budget" while quoting character counts; they now say "character budget", matching what the code enforces. - REST API. All four validators sit behind REST write endpoints:
POST/PUT/rest/seo/custom-meta-tag(/{id})and/rest/seo/default-meta-tag(/{id})(backend,ADMIN+CMS);POST /rest/admin/users,POST /rest/admin/users/{id}/password,POST /rest/admin/users/change-password(backend);POST /rest/cart/items,/rest/cart/coupon,/rest/cart/claim(guest). TheSeo\*andCart\Cartfixes only loosen what's accepted — a request that used to succeed still succeeds, and some previously (wrongly) rejected requests now succeed — so no existing caller can break; that's additive/non-breaking and, consistent with how this changelog already treats widening fixes, does not warrant arest_api_versions.phpentry.Admin\User's password-floor fix tightens instead: a request that previously got a2xxwith a short multibyte password can now get a422on any of its three endpoints — the same shape of change that earned #502 its1.16entry, and is recorded here asrest_api_versions.php1.17. See "Check for overrides" below for the client-facing follow-up. - Tests. New multibyte-boundary coverage in
tests/Unit/Domains/Seo/CustomMetaTag/ValidatorTest.php(+6 cases),tests/Unit/Domains/Seo/DefaultMetaTag/ValidatorTest.php(+6 cases), andtests/Unit/Domains/Admin/User/ValidatorTest.php(+1 case); brand-newtests/Unit/Domains/Cart/Cart/ValidatorTest.php(28 cases) —Cart\Cart\Validatorpreviously had no unit coverage at all. - Precedent. The identical fix already landed on
Customer\CustomerMessageHistory\Validatorunder #502 (develop commit2cab14dab,rest_api_versions.php1.16); this is the systemic follow-up sweep across the remainingValidator.phpclasses. - No DB migration, OpenAPI schema, or language-key changes.
- Why — three different failure modes, not one.
[4.120.0] fix(customer-message-history): reject invalid REST writes with
422instead of a raw DB500, and correct inverted OpenAPItype/messageTypedescriptions (Advisable-com/ecommercen#502, Advisable-com/ecommercen#501)- Why.
Advisable\Domains\Customer\CustomerMessageHistory\Validator(src/Domains/Customer/CustomerMessageHistory/Validator.php) was an empty stub —validateForCreate()/validateForUpdate()built an$errorsarray that nothing ever populated, soPOST /rest/customer/customer-message-history(and its/{id}update) accepted any payload and forwarded it straight toWriteRepository. A payload missinguserId,type, ormessageTypereached the driver and failed on the underlyingshop_customer_message_historyNOT NULLconstraint, surfacing as a raw HTTP500that also echoed the driver's error text into the response body. Separately,WriteData's two#[OA\Property]descriptions fortypeandmessageTypewere swapped relative to what every writer (Adv_mailer::addEmailToCustomerHistory(), the SMS helpers) actually stores:typeholds the message category/purpose (e.g.ORDER_ON_STORE),messageTypeholds the delivery channel (EMAIL/SMS, per theMessageChannelenum, #468). - The change (#502).
Validatornow enforces:userId/type/messageTyperequired on create (non-empty on update, when present);userIdmust be a positive integer;typecapped at 255 chars andmessageTypeat 50 chars, matching theshop_customer_message_historycolumn definitions — measured viamb_strlen(..., 'UTF-8')rather thanstrlen(), so the caps count characters (matching MySQLvarchar(n)semantics) rather than bytes, since the table isutf8-charset. Violations throwValidationException, whichHandlesWriteActions::doStore()/doUpdate()already catch and translate into a422with a per-field errors map viaApiEndpointTrait::sendValidationErrors()— no controller or route change needed. - The change (#501). Corrected the
type/messageType#[OA\Property]description:strings onWriteDatato match actual write-time semantics, and regeneratedpublic/openapi.json/public/openapi-v1.json. Documentation-only — no schema, type, or required-ness change. - REST API.
POST /rest/customer/customer-message-historyandPOST /rest/customer/customer-message-history/{id}(backend-only,ADMIN+MARKETING—application/config/rest_policies.php:657) now reject a missing/invaliduserId/type/messageTypewith a422errors map instead of a500; well-formed requests are unaffected. Any caller that was (even inadvertently) relying on the old500-on-bad-input behavior will now see a422instead — recorded asrest_api_versions.php1.16. - Client forks. See the "Check for overrides" note below — a fork with its own
Custom\override or alias ofValidatorshould re-check it now that the base class actually enforces validation (previously a guaranteed no-op). - Tests. New
tests/Unit/Domains/Customer/CustomerMessageHistory/ValidatorTest.php(17 cases: required fields, positive-integeruserId, length caps at/over the boundary, the create-vs-update partial-payload distinction, and multibyte-safe length coverage —test_validate_for_update_rejects_overlength_type,test_validate_for_create_accepts_two_hundred_multibyte_characters,test_validate_for_create_rejects_two_hundred_fifty_six_multibyte_characters). - Docs.
docs/flows/admin/AD-41-customer-mail-history.mdanddocs/flows/system/SY-24-email-dispatch.mdupdated: themessage_typedata-model row no longer implies the REST write path performs no validation at all — it now describes the #502 baseline (required/positive-integer/length) while still correctly noting that enum-value enforcement (restrictingtype/messageTypetoMessageChannel's exact values) remains open as #497. - No DB migration or language-key changes.
- Why.
[4.120.0] feat(gift-rules): add a near-miss gift teaser (
giftsNearMiss) toGET /rest/cart(Advisable-com/ecommercen#446)- Why. The cart payload's
giftsblock (#44) only surfaces rules the cart has already earned. Storefronts also want an "add €X more / add N more to earn your free gift" prompt for rules the customer is close to — the near-miss teaser. - The change.
GET /rest/cart(and every cart-mutation response) gains a sibling top-levelgiftsNearMissarray, built byCartGiftNearMissPresenter(src/Domains/Checkout/Gift/CartGiftNearMissPresenter.php) over a new legacy engine methodAdv_gifts_model::getNearMissGiftsForProductsInCart()(discovered via a loosenedgetActiveGiftRulesForNearMiss()predicate that shares SQL with the earned path). Each row exposes only the allowlistedid,ruleId,remainingAmount,remainingQuantity,giftUserChoiceCount,choices,requirements,image,description— rawamount_from/amount_to, stock and date windows never cross the boundary. An empty cart returns[](no teaser noise). Amount distances are strictly-exceed (a cart resting exactly onamount_fromstill reports0.01, matching the validators'<=fail semantics); rule-10 combinations report the distance for the binding (lagging) required product to the shared nextgift_per_counttier. The product-code→product-id map is resolved once per cart render and shared withCartGiftPresenter(no double lookup). - Bug fix (earned path too).
Adv_gifts_model::buildActiveGiftRulesSql()— shared by the earned-gift discovery (getActiveGiftRulesForRequiredProducts()) as well as near-miss — calledcreateRequireVendorsSql([])unconditionally, emitting invalid... IN ()SQL that errored the whole discovery query for any non-empty cart whose products resolve to no vendor. Pre-existing (it predates this feature), so already-shipped earned-gift discovery was at risk, not just the new teaser. Now the vendor OR-branch is omitted when the vendor list is empty; behavior is unchanged when vendors are present. - REST API. Additive, non-breaking — recorded as
rest_api_versions.php1.15. Documented in OpenAPI via newCartGiftResource/CartGiftNearMissResourceschema components referenced from theGET /rest/cart200 response. - Client forks. Additive to the cart payload. A fork that fully overrides
Adv_gifts_model::buildActiveGiftRulesSql()(rather than inheriting it) must port the empty-vendor guard to avoid reintroducing theIN ()discovery error. - Docs.
docs/flows/customer/CF-14-gift-rules.mdupdated: the near-miss section, the rule-by-rule computability table, and the empty-cart / strictly-exceed behavior. - No DB migration or language-key changes.
- Why. The cart payload's
[4.120.0] refactor(view-metrics): remove dead
wipeMetricsBefore()/applyDeletionQuery()fromAdv_view_metrics_model(Advisable-com/ecommercen#393)- Why.
Adv_view_metrics_model::wipeMetricsBefore()(ecommercen/view_metrics/models/Adv_view_metrics_model.php) had zero callers and no cron/scheduled job wiring it up —view_metricsrows were never purged by it. Its sole caller-less helperapplyDeletionQuery()existed only to supportwipeMetricsBefore(). - The change. Removed both dead methods.
applyObjectWhereQuery()(still used byapplyLastRecordsQuery()andapplyMetricsInRangeQuery()) is untouched. No behavior change — neither method was reachable from any code path. - Retention decision. #393 also asked whether a retention/purge job should be built to replace the dead cleanup code. Decided not to build one:
view_metricsstores at most one row per object per active period bucket, so even a 10+ year-old project accumulates on the order of ~6k rows — not worth a scheduled purge. Revisit only if data volume ever becomes material. - Docs.
docs/flows/admin/AD-60-view-metrics-analytics-api.mdupdated: the code-map row and several code-flow/business-rule line citations that shifted after the removal, and Known Issue #3 rewritten from an open "dead code / unbounded growth" risk to a resolved, deliberate non-issue. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why.
Notes
[4.120.0] Regeneration warning:
src/Piraeus/PireausWsdlClass.phpnow carries ten hand-applied#[\ReturnTypeWillChange]attributes on itsArrayAccess/Iterator/Countablemethods (Advisable-com/ecommercen#521). The class is WsdlToPhp-generated and predates the^8.1requirement — real return types were deliberately not declared, sinceoffsetGet(): mixedand friends would need checking against every call site in a file that gets regenerated. If this file is ever regenerated from the WSDL, the attributes must be re-applied, orphpunit.xml.dist's newfailOnDeprecation="true"will fail the suite on ten deprecations. A note recording this is in the file itself.[4.120.0] REQUIRES
php migrator.php migrate:20260721120000_add_is_points_awarded_to_shop_product_reviews.php— addsis_points_awarded TINYINT(1) NOT NULL DEFAULT 0toshop_product_reviews, guarding the loyalty-point award per review (#17). Not backfilled for existing rows — see the Caveat note above.[4.120.0] Client forks: a fork that fully overrides
setStatus()orbulkSetStatus()(rather than inheriting the emptyProduct_reviews_adminsubclass) must port the atomic claim (claimReviewPointsAward()) — not anis_points_awardedread-check — to avoid reintroducing the double-award bug, including its concurrent-approval variant.[4.120.0] Why. AD-53 (and the claim it repeated into AD-52 and SY-24) asserted a viewer/runtime mail-template-directory divergence: the admin preview allegedly read
application/views/default/mail/(via the deprecatedapp.php:19client_viewsconfig) whileAdv_mailerreadapplication/views/main/mail/(viaemailViews.json), with 5 viewer-listed templates said to fail to load in preview. An independent verification pass traced the full resolution chain and found this false — both resolve to the same directory.[4.120.0] The change. Corrected
docs/flows/admin/AD-53-email-template-viewer.mdat seven sites (business-context overview, the code-flow prose, the "Two Mail Directories" table and its "files missing" subsection, the Configuration section, the coverage-gaps list, and the Known Issues list):$this->client_viewsresolves to"main"—Adv_base_controller's constructor (Adv_base_controller.php:104) overwrites it unconditionally fromTemplate::templateFolder(), which readsmainTemplate.json:3's"templateFolder": "main";AdvEmailViewerinherits that viaAdmin_c→Adv_admin_controller:29→Base_c→Adv_base_controller:104, and the admin view reads the property throughMX_Loader::__get()(application/third_party/MX/Loader.php:333-336). So the viewer resolves toapplication/views/main/mail/— the same directoryAdv_mailerreaches viaemailViews.json:3's"templateFolder": "main/mail". All 21 viewer-listed templates resolve; the 17-file/25-file directory counts stay correct as a stale-inventory fact, but the "5 templates fail to load" consequence drawn from it was removed. Known Issue #6 ("Viewer / runtime template-directory divergence") was removed and the list renumbered 9 → 8 items (old #7/#8/#9 → new #6/#7/#8); the pre-existing#5citation elsewhere in the doc was unaffected, since it sits above the removed item.[4.120.0] Proofread follow-up (round 2). A
doc-ba-proofreadpass independently re-traced the resolution chain, confirmed the core correction holds, but found two of the newly-written claims overstated the case. Both were narrowed: the deprecatedapp.php:19client_viewsvalue is read into properties —ecommercen/core/models/Adv_base_model.php:36(every model, every request),application/models/Adv_mailer.php:29, and the separately-@deprecated, zero-callerpreviewOrder_cart()(ecommercen/helpers/cart_helper.php:103) — it just never resolves a rendered view path on any live path, since only the controller property reaches a view and that one is overwritten atAdv_base_controller.php:104. The Configuration section and the "Two Mail Directories" table'sdefault/mail/row were reworded accordingly, and a one-paragraph latent-fragility note was added to the "Two Mail Directories" section: the viewer'smainTemplate.json+ hardcoded/mail/composition andAdv_mailer's wholeemailViews.json::templateFolderstring only agree today because"main" + "/mail" == "main/mail"— a fork changing either config independently would silently diverge, with no error raised.Last Updatedwas bumped to2026-07-27on all three touched flow docs.[4.120.0] Propagated claim. Also corrected the same refuted claim where it had been repeated:
docs/flows/admin/AD-52-review-moderation.md:245(dropped the "admin preview loads from default/mail" assertion, kept the cross-link) anddocs/flows/system/SY-24-email-dispatch.md(:65,:181,:195,:362— removed the divergence/missing-file claims and corrected a stale "23 templates" count to the actual 25; the round-2 pass then refined:65/:181further, from "25 templates" to "25 files (24 templates + 1 shared component)", since the 25th file,email_products_summary.php, is a shared component rather than a standalone template).[4.120.0] No code change, DB migration, config/language-key change, or REST/OpenAPI change — documentation-only.
[4.120.0] Check for overrides:
application/views/admin/auth/tasks_list.phpis a shared admin view a client fork may carry its own copy of. This is a security fix — a fork with its own copy of this template will not receive the escaping and remains exposed to the stored-XSS sinks described above; client maintainers should diff their copy against this version and reapply thehtml_escape()calls at:154,:179,:226, and:229.[4.120.0] Check for overrides (BoxNow 429/Retry-After fix — #443): informational, not a breaking change — no method signature changed — but the new behaviour funnels through
protectedmethods a client fork may have wholesale-overridden and would therefore NOT inherit:BoxNow::doRequest()(src/Transporters/BoxNow/BoxNow.php) — the bounded 429/Retry-After retry (capped at 5s) and the warn-vs-error logging split live here.GetOrdersTransferStatus::getBoxNowTransporterStatus()(src/Domains/Transporter/Jobs/GetOrdersTransferStatus.php) — the 250ms per-parcel pacing lives here.AdvGetOrdersTransferStatus::getBoxNowTransporterStatus()(legacy,ecommercen/job/libraries/AdvGetOrdersTransferStatus.php) — same pacing fix, applied identically to the legacy poll job.- A fork carrying its own override of any of these should reconcile with the base implementation to pick up the 429 handling and the pacing.
[4.120.0] UI Update: PRODUCTS-role admins now see the VAT rates link in the admin menu (Products group) — they already had page access via the controller, this only restores menu visibility.
[4.120.0] Check for overrides:
Advisable\Domains\Cart\CartTotalsCalculator::getItemPrice()— same signature, changed contract: it now returns the discounted unit price rather than the rawshop_product.price. A fork that compensated for the missing discount downstream (or that overrodeCartTotalsCalculator/OrderBasketBuilderto work around it) will now double-discount and must reconcile against the corrected upstream logic. New shared implementation to route through:Advisable\Domains\Product\Pricing\DiscountResolver.[4.120.0] Check for overrides:
Advisable\Domains\Checkout\OrderBasketBuilder— its private discount branch has been replaced by the shared resolver, so its special-discount behaviour changes even where the price was already discounted. Shops withENABLE_SPECIAL_DISCOUNTSswitched off previously still had active specials applied to REST-placed basket rows; they no longer do. Likewise a special with only one bound set no longer applies indefinitely, and a 0% special inside its window now suppresses the regular discount instead of being ignored.[4.120.0] UI Update: the
totals.subtotalvalue returned byGET /rest/cartandPOST /rest/checkout/totalschanges for any discounted basket (it falls by the discount). The response shape is unchanged — no field was added, renamed or removed — but a headless storefront pinning expected figures in fixtures or snapshot tests will see new numbers, and those numbers will change again when #563 converts the contract to gross.[4.120.0] Check for overrides:
Advisable\Domains\Checkout\OrderBasketBuilder—item_pointson REST-placed orders now honoursPOINT_SYSTEM.IS_ENABLED. Shops with the point system disabled were silently accruing zero points and will continue to accrue zero — no visible change for them. But any client fork that overrodeOrderBasketBuilder, or reimplementedbuildRow(), to work around the €0-price bug will now double-correct and must reconcile against the corrected upstream logic.[4.120.0] Check for overrides:
CustomerResource(Advisable-com/ecommercen#478) gains a newcountryDetailsproperty and corrects thecountryscalar:Advisable\Rest\Customer\Resources\Customer\Resource(src/Rest/Customer/Resources/Customer/Resource.php) — a client fork carrying its ownCustom\Rest\Customer\Resources\Customer\Resourceoverride must reconcile: reapply the scalar-recovery fix (countryAlpha2()) and thecountryDetailsembed (addCountryDetails()), or its/rest/customer/mekeeps leaking the rawCountryentity.- Velora angle. velora's native/Tauri client is the caller sending
?with=countryon session refresh — after this fix its scalarcountryreads work again with no frontend change required. Any velora code that had adapted to parse the previously-buggy raw object must switch to readingcountryDetailsinstead.
[4.120.0] Check for overrides:
Advisable\Rest\Product\Resources\Product\Resource::addRelationToData()changed (Advisable-com/ecommercen#479):- The
categoriesline switched fromaddCollectionToData($data, 'categories', Category\Collection::class)to the newaddCategoriesToData($data). Three fork shapes, three outcomes: - Copied the whole
Resourceclass undercustom/Rest(rather than inheriting it) — keeps the old unscoped/unordered call and must reapply the published-filter-plus-order/id-sort logic to its own copy. - Subclasses and overrides
addRelationToData()to add its own relations — keeps whatevercategoriescall it wrote, typically the old unscopedaddCollectionToData($data, 'categories', Category\Collection::class). Such a fork should now replace that line with$data = $this->addCategoriesToData($data);: the method was madeprotected(not private) precisely so an overriding subclass can delegate to it instead of reimplementing the scope/sort. - Only overrides
resource()(and calls the parent'saddRelationToData()) — unaffected, inherits this fix automatically. - Also check for a fork that re-adds
categoriestoProduct\ListRequest::setAllowedRelationSorts():?sort=categories.*is now removed upstream because the serializer fixes the order, so a fork that keeps it advertises a sort that no longer has any effect.
- The
[4.120.0] UI Update: Velora / other headless storefronts — the product
categoriesrelation is now published-only and deterministically ordered outside the admin (Advisable-com/ecommercen#479). Consumers can drop defensive client-side filtering/sorting of the product category list (primary-category chip, product-detail breadcrumb). A consumer that relied on receiving unpublished links must switch to a backend-authenticated token —publishedremains backend-only on the category payload.[4.120.0] Check for overrides: the
/rest/product/tag*GET route repoint (Advisable-com/ecommercen#481) touchesapplication/config/rest_routes.php, a file client repos commonly keep a local copy of:- A client fork with its own copy of
rest_routes.phpkeeps the buggyTagCategory-mapped GET rows after an upstream merge and must reapply the six-row repoint by hand. - Separately, any frontend/integration code consuming
GET /rest/product/tagand depending on the buggy category-shaped payload must switch to/rest/product/tag-category, or adapt to the corrected leaf-tag payload.
- A client fork with its own copy of
[4.120.0] Check for overrides (slider slide-scoping fix — #483):
Advisable\Rest\Slider\Controllers\Slidergained a new required constructor dependency and rewroteindex(),show(), anditem()to post-filter theslidesrelation for non-backend context.- A client fork's
Custom\Rest\Slider\Controllers\Slidermust addSlideVisibilityFilter $slideVisibilityto its own__construct()and forward it toparent::__construct(), and its DI wiring (container.phpoverride, if any) must supply the new$slideVisibilityarg — otherwise container compilation fails on the missing autowire. - If
Custom\Rest\Slider\Controllers\Slideroverridesindex(),show(int|string $id), oritem()instead of inheriting the base implementation, it keeps its own copy and will keep serving expired/audience-targeted slides in the old order. Both the injected filter and the post-filter step areprotected, notprivate—$this->slideVisibility(the constructor-promoted property) and$this->applySlideVisibility($slider)(the method) are both reachable from an overriding subclass, so a fork's override can simply call$this->applySlideVisibility($slider)on its own hydrated result — no need to duplicate the filtering logic — to inherit the fix. Advisable\Domains\Plus\Audience\Repository\Repository::getRestrictedAudienceIds()is purely additive — safe, no action needed even for a fork that overridesAudience\Repository.
- A client fork's
[4.120.0] Check for overrides: the Badge guest-read policy fix (Advisable-com/ecommercen#484) touches
application/config/rest_policies.php, a config file client forks commonly keep a local copy of:- A fork shipping its own
rest_policies.phpkeeps the admin-gatedBadge::classrow after an upstream merge and must reapply themethodsblock (index/show/item→guest) by hand, or its storefront badge facet keeps returning 401/403. - A fork that deliberately wants badges to stay backend-only needs no action — simply not reapplying the block preserves the old behaviour.
- A fork shipping its own
[4.120.0] Check for overrides: loyalty redemption starts actually working on REST checkout. A client fork that implemented its own points redemption on top of the modern checkout, or that compensated for today's no-op (e.g. applying the discount itself, or debiting points out of band), will now double-apply. Review those before deploying.
[4.120.0] Check for overrides:
Advisable\Domains\Checkout\PlaceOrderService::__construct()takes a new final argument (LoyaltyRedemption $loyaltyRedemption). ACustom\subclass that declares its own constructor must forward it, or container compilation fails.[4.120.0] Check for overrides:
Advisable\Domains\StorefrontConfig\StorefrontConfigProvider::__construct()takes a new final argument (LoyaltyConfigResolver $loyalty), andall()returns a newloyaltysection. A fork asserting the exact payload shape, or subclassing the provider, must reconcile.[4.120.0] Check for overrides:
Advisable\Domains\Checkout\PlaceOrderDatano longer has apointsSpendproperty — it is replaced by the booleanredeemPoints. Any fork constructingPlaceOrderDatadirectly with thepointsSpend:named argument, or reading$data->pointsSpend, will fatal.PlaceOrderService::resolvePointsSpend()and::debitCustomerPoints()(both private) are removed.[4.120.0] UI Update: headless storefronts should send
redeemPointsto/rest/checkout/totalsas well as/place-order, render the points line from the newpointsSpend/pointsCashfields instead of assuming zero, and read the redemption ratio from the newloyaltysection of/rest/storefront-configrather than hardcoding it. Any client still sending the removedpointsSpendinteger must switch to the booleanredeemPoints.[4.120.0] Check for overrides: no signature changed, so this is picked up automatically. But a client fork carrying its own copy of
application/libraries/Pscache.phpkeeps theclass_exists()check and does not inherit the fix — since the failure mode is an uncaughtTypeErrorrather than a degraded result, fork maintainers with a localPscachecopy should apply the same two-line change.[4.120.0] Check for overrides:
afterAdd($id),afterEdit($id),afterDelete($id)inAdv_vats_adminare documented client extension points —docs/flows/admin/AD-50-vat-management.md§ Client Extension Points explicitly recommends overriding them to "invalidate caches, emit audit events, or recompute prices." No method signature changed, but a client fork that already overrides one of these hooks and does not callparent::afterAdd($id)/parent::afterEdit($id)/parent::afterDelete($id)will not automatically pick up this new cache-clearing behavior — client maintainers should check their override.[4.120.0] Check for overrides: a client fork (e.g. Evripidis) that overrides
Adv_product_tags_admin/Adv_product_tags_modelorAdv_product_tag_categories_admin/Adv_product_tag_categories_modelneeds to re-apply this scoped-uniqueness logic in its own controller override — call the extendedAdvisable\Domains\Support\Slug\SlugGenerator::generateUnique()with the new$excludeColumn/$excludeValue/$masterScopeparameters — or it will keep inserting colliding slugs on save. For tags, pass the master scope (new SlugMasterScope('shop_product_tags', 'tag_id', ['tag_cat_id' => $tagCatId])); a plainwhere()ontag_cat_idcannot work, because that column is not onshop_product_tags_mui.[4.120.0] Known gap — accepted for now: no unique index/migration was added on
shop_product_tags_mui.slug(scoped totag_cat_id+lang) orshop_product_tag_categories_mui.slug(scoped tolang). Existing rows may already collide from before this fix, and a unique index would need a prior data-cleanup pass to disambiguate them first. The app-level check ingenerateUnique()is a backstop for new saves only, not a DB-enforced guarantee.[4.120.0] Known gap — deferred, tracked separately: the REST write endpoints
Advisable\Domains\Product\Tag\Tag\WriteServiceandAdvisable\Domains\Product\Tag\Category\WriteServiceshare the sameSlugGeneratorclass and the same underlying tables (shop_product_tags_mui,shop_product_tag_categories_mui) but were not updated by this fix. Theircreate()only callsgenerateMuiSlugs()/generateUnique()when the incomingslugfield is empty — an explicit colliding slug in the REST payload bypasses disambiguation entirely — and neither call passes a master scope, so even the empty-slug self-heal isn't category-scoped.update()doesn't call slug resolution at all, so a REST-driven update can freely write a colliding slug. This is a real path to reproduce the same storefront-404 symptom this issue fixes on the admin-save path, via the REST API instead. Tracked as Advisable-com/ecommercen#591: pass the master scope / exclude-id throughGeneratesSlugs::generateMuiSlugs()for both REST write services and run slug resolution onupdate()as well ascreate().[4.120.0] UI Update:
GET /rest/product/bundle,GET /rest/product/bundle/itemandGET /rest/product/bundle/{id}now return active bundles only for storefront/guest and customer callers. A storefront client that relied on receiving inactive bundles in the listing will see fewer rows, and one that deep-linked an inactive bundle by ID now gets a 404 — both deliberate. Backend callers are unchanged and may still filter byfilter[isActive]freely; the OpenAPI descriptions for the three endpoints now document the storefront default.[4.120.0] Check for overrides (bundle storefront active-only default — #511):
Advisable\Rest\Product\Controllers\Bundlegained the new scope method asprivate, so it is not overridable and adding it is signature-preserving and BC at the PHP level — no fork needs to change a constructor or DI wiring.- The real fork risk is behavioural: a client fork whose
Custom\Rest\Product\Controllers\Bundleoverrides the publicBundle::index(),Bundle::item()orBundle::show(int|string $id)keeps its own copy of that method body and therefore silently skips the new storefront default, continuing to leak inactive bundles to guests and customers. Reconcile each of those three overrides: the override cannot call the private scope method, so it must register the forced filter itself ($this->withMandatoryFilter('isActive', 1)guarded by$this->resourceContext && $this->resourceContext->isBackend()) forindex()/item(), and replicate the per-rowis_activegate forshow().
- The real fork risk is behavioural: a client fork whose
[4.120.0] Check for overrides: a client repo that overrides
application/views/admin/settings/third_party_providers.php,application/config/routes.php,application/config/app.php(thesiteModeAllowedControllersentry), orecommercen/settings/controllers/Adv_settings.phpwill not pick up the new ContactPigeon panel, routes, registry keys, or site-mode exemption automatically and must merge them in by hand.[4.120.0] Deployment action required: the feature is inert until both steps are done —
CONTACTPIGEON_IP_ALLOWLISTmust be populated in.env(shipped commented-out in.env.example) andIS_ENABLEDmust be checked onsettings/third_party_providers. Neither alone is sufficient.[4.120.0] Check for overrides:
AdvCancelIncompleteOrders::cancelPendingDefaultCards(),::cancelPendingPayByBank(),::handlePendingXpayOrders()(ecommercen/job/libraries/AdvCancelIncompleteOrders.php) — a client fork'sCancelIncompleteOrdersoverride of any of these threeprotectedmethods keeps its own unguardedDateTime::createFromFormat()call and stays fully exposed to the fatal documented above. AcancelPendingPayByBank()overrider additionally keeps the rawPAY_BY_BANK/EXPIRATIONinterpolation and the'PTS'hazard.AdvCancelPendingGiftCards::executeCommand()(ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php) — an overriding fork keeps the rawnew \DateInterval($this->ci->config->item('giftCardDateTimeIntervalToDrop'))call and its config-drift hazard.- New overridable surface a fork should know about:
AdvCancelIncompleteOrders::orderEntryDate(),::payByBankExpirationSeconds(), andAdvCancelPendingGiftCards::dateTimeIntervalToDrop().
[4.120.0] No REST/API contract changed and no new config/registry key is introduced (
PAY_BY_BANK/EXPIRATIONandgiftCardDateTimeIntervalToDropalready existed; only their handling is now defensive) — noUI Update:note needed.[4.120.0] Developer-facing behaviour change: a PR that touches source (
src/**,application/**,ecommercen/**,custom/**,assets/**,*.vue,*.scss, or the Phinx migrations dir resolved fromphinx.php) now requires adocs/changelog/unreleased/<issue>-<slug>.mdfragment, or CI fails with a message naming the exact path to create. A second check fails a PR that editsdocs/changelog/Changelog.4.*.mdordocs/changelog/Changelog.mdfrom a non-release branch — the release flow owns those files.[4.120.0] Skipping requires a commit, not a PR-title edit. The escape marker is
[skip changelog]in a commit message — not the PR title, since Bitbucket Pipelines exposes no PR-title variable and a title-based trigger would need a repo token there and would drift between the two hosts. For a change with nothing to commit:git commit --allow-empty -m "[skip changelog] <reason>".[4.120.0] A second, distinct marker,
[allow changelog edit], escapes the changelog-file-edit check only (a deliberate direct edit to a shipped changelog, e.g. a typo fix). The two markers are kept separate on purpose — a single marker disabling both checks would reopen the regression the gate exists to catch.[4.120.0] Client forks are exempt from the fragment-required check (it's skipped when
ECOMMERCEN_CLIENTis set) — a fork never writesdocs/changelog/**, it records notes indocs/client/Client.mdinstead.[4.120.0] Implemented via one shared script,
.docker/scripts/ci/check-changelog-fragment.sh, invoked by both the BitbucketChangelog Fragment Gatestep and the GitHubchangelog-gatejob, so the two hosts cannot drift apart. Seedocs/changelog/README.mdfor the developer-facing statement of the rule.[4.120.0] Check for overrides:
- A fork whose
Custom\Rest\Customer\Controllers\Customermerely extends the upstream controller needs no action — it inherits the new allow-list automatically viaPolicyResolver'sget_parent_class()fallback. That fallback is gated onclass_exists($controllerClass, false), butRouterDispatcheralways instantiates the controller before resolving the policy, so the class is already loaded by then and the fallback always fires. - A fork carrying its own diverged copy of
application/config/rest_policies.php— or one that lists its ownCustom\Rest\Customer\Controllers\Customersubclass as its own top-level policy key (which wins over the parent fallback) — inherits nothing from this change and must copy therelationsblock across by hand. No automated check catches this. Fail-open warning: if such a copy carries only'backend'and'customer'and drops'default' => [], itspublicscope reverts to completely unfiltered?with=—RelationFilterMiddlewarereads$relations[$scope] ?? $relations['default'] ?? nulland skips all filtering when that resolves tonull. An empty array ([]) means allow-none; omitting the key entirely means allow-all. Those are opposite outcomes and the distinction is invisible at a glance.
- A fork whose
[4.120.0] Velora angle. velora's native/Tauri client is the
?with=countrycaller on session refresh; it is explicitly unaffected —countrywas deliberately retained in thecustomerscope for exactly this reason.[4.120.0] Check for overrides:
application/views/admin/auth/tasks_list.phpis a shared admin view a client fork may carry its own copy of. This is a security fix — a fork with its own copy of this template will not receive the escaping by merging unrelated files, and remains exposed to the session-persisted XSS sinks described above; client maintainers should diff their copy against this version and reapply the tenhtml_escape()calls at:21,:31,:44,:73,:81,:89,:171,:175,:232, and:233.[4.120.0] New behaviour: an available transporter with no configured price for the requested destination is now excluded from
getAvailableTransporterson the eshop-calculated path instead of taking down the whole shipping step; the remaining available, priced transporters are still returned. Externally-priced transporters (DHL / CyprusPost / ASAP) are unaffected — their cost comes from the carrier, so they legitimately have no pricing rows.transportCost()now throwsTransporterPriceUnavailableExceptionrather than quoting a wrong price, so storefront checkout and admin order creation refuse the order (existingorder_error/ null-serial recovery) instead of booking one at the wrong total. Oneerrorlog line is written per skipped or dropped transporter, naming the transporter and the country/county/postal.[4.120.0] Check for overrides: client forks that redeclared
AdvTransporters::transportCost()wholesale (smile_v4, perdocs/changelog/Changelog.4.112.md:26) do not inherit the newnull-price handling and will still fatal / silently misprice on an available-but-unpriced transporter. Each such fork must add the same$price === nullguard immediately after itstransporterPricing->price()call, before its free-shipping / overweight branches. Forks that override thecustomTransportCost()seam to price destinations the standard pricing tables do not cover must also override the newprotected AdvTransporters::canResolveTransportCost(), or those destinations will be filtered out ofgetAvailable()as unpriced.[4.120.0] Build: the new exception class is loaded via the composer classmap (
composer.json's"ecommercen"classmap entry) —composer dump-autoloadis required.[4.120.0] Check for overrides:
Adv_shelfcodes_admin::validation()isprotectedand its signature changed (the unused$isUpdateparameter was dropped). The only subclass in this repo,application/modules/eshop/controllers/Shelfcodes_admin.php, is empty, and dropping an optional parameter is LSP-safe in PHP — a fork that still declaresvalidation($isUpdate = false)in its own override keeps working. No action expected, but a fork overridingvalidation()may want to drop the now-unused parameter too. A fork overridingvalidation()that itself consults$isUpdatefor edit-only logic will now always receive its own default value fromedit()'s call site (previouslytrue), since the base no longer passes an explicit argument — verify no such fork exists before syncing.[4.120.0] Check for overrides: the checkout money contract changed from NET to GROSS. This is a semantic change to existing fields — no field was added, renamed, removed or retyped — so a structural schema diff (
types.gen.ts/zod.gen.ts) cannot detect it. Every fork and headless client must reconcile by hand. Affected:shop_order.total_vat— now the VAT-inclusive grand total, i.e. the amount charged (it was VAT-exclusive);shop_order_basket.price,original_price,subtotalanddiscount_price— now gross.discount_priceis legacy'ssave_price, the difference of two gross figures, not the net saving;shop_order_basket.product_vat— now the adjusted rate fromVatForOrder::vat(), not the rawvat.value;GET /rest/cart→totals.subtotalandPOST /rest/checkout/totals→subtotal,total— now VAT-inclusive.shop_order.totalstays NET and is the exception most likely to trip a fork: it is an items-only accounting figure (no shipping, coupon, points or gift packaging) and must not be used as a display subtotal or reconciled againsttotal_vatminus extras.
[4.120.0] Known divergence —
shop_order.totalon rule-13 gift carts. On a cart that earns a rule-13 (cheapest-free) gift and whose discounted net unit price is not exact to 2 decimals,shop_order.totalcan differ from the legacy figure by one cent. The amount charged is unaffected —total_vatand the gateway amount come from the separate gross path, which matches legacy exactly — so this touches only the NET accounting column. The cause is structural:Adv_order_model::baseParseCartContents()restricts the accumulation to$paidQtyfrom the start and rounds once, whereas the modern path accumulates the full quantity and subtracts a separately-rounded deduction, giving two rounding points where legacy has one. Persisting the unrounded per-unit net (rather than the rounded one) cuts the divergence substantially but cannot remove it; closing it fully needsCartTotalsCalculator's net accumulation to be paid-quantity-aware, which the current architecture does not support without restructuring. Tracked alongside the coupon-vs-shipping ordering (#568) and the points unit (#495) as a known, deliberate gap rather than a silent one.[4.120.0] Check for overrides:
Advisable\Domains\Cart\CartTotalsCalculator—calculate()returns an additionalnetSubtotalkey and itssubtotalis now gross;getItemPrice()keeps its signature but returns the gross unit price. A fork overriding this class must returnnetSubtotalorPlaceOrderServicewill fall back to treatingsubtotalas net forshop_order.total. The eager-load graph it requests grew to['product' => ['vat' => []]]— an override pinning the old graph resolves every product at 0% VAT and silently reproduces the bug.[4.120.0] Check for overrides:
Advisable\Domains\Checkout\OrderBasketBuilder—applyGiftOutcome()returns an additionalnetGiftDiscountkey, and rows carry a non-persistedprice_without_vatkey (OrderBasketBuilder::PRICE_WITHOUT_VAT_KEY) thatOrderBasket\WriteDatadrops. That value is carried unrounded on purpose — it is subtracted from a net subtotal that is itself accumulated unrounded, so rounding it would put the two halves of one subtraction on different boundaries. A fork that hand-builds basket rows will see the net gift deduction degrade to the gross one.[4.120.0] Check for overrides:
application/modules/eshop/libraries/VatForOrder.php— a fork that overridesinvoiceVat()/receiptVat()will now have that policy applied to REST-placed orders as well as storefront ones. Verify the policy is correct for headless traffic: the REST checkout does not yet callsetInvoice()/setDeliverAreaType(), so the singleton is unconfigured and yields the pass-through receipt rate (see the open question on #563).[4.120.0] UI Update:
GET /rest/cartitems now include the product'svatrelation. Resolving the gross subtotal requires the VAT rate, sovatwas added to the cart controller's eager-load graph — which meansProductResource's pre-existinghasRelation('vat')gate now passes anditems[].productCode.product.vatis emitted as a nested object where it was previously omitted entirely. This is additive to an already-declared nullable schema property, so it is not breaking and needs no OpenAPI change, but it is wire-visible: clients doing exact-shape assertions on cart items, or snapshotting the payload, will see the extra object.[4.120.0] UI Update: headless storefronts must treat
totals.subtotalfromGET /rest/cartandsubtotal/totalfromPOST /rest/checkout/totalsas VAT-inclusive. The response shape is unchanged, so nothing breaks at the type level — but a UI that adds VAT itself before display will now double-count it, and one that labels the figure "excl. VAT" is now wrong. Fixture and snapshot tests pinning the old net figures will need new numbers. This also completes the change flagged in the #476 note: the figures that moved down by the discount there now move up by the VAT rate here.[4.120.0] UI Update:
POST /rest/checkout/shippingandPOST /rest/checkout/totalschange the money figures they return for any shop with transporter options configured, and gain three fields:overweightCost(read-only, already insidecost/shippingCost— never add it to a total),deliveryCost(a separate line, already inside/totalstotal), and an optionalpayWayrequest field.availableTransporters[].optionsno longer contains the pricing-controlreg_keys — a headless client that hardcoded them as selectable options must drop them.[4.120.0] Check for overrides:
Advisable\Domains\Checkout\ShippingCalculator::calculate()— signature extended with two optional trailing parameters,float $cartWeight = 0.0and?string $payWay = null. Existing 4-argument calls still work, but a fork that overridescalculate()must widen its own signature to match or PHP raises an LSP fatal. A fork that overrode it to implement its own threshold logic should now delete that override and use the two new hooks below instead.[4.120.0] Check for overrides:
Advisable\Domains\Checkout\ShippingCalculatorgains two newprotectedclient-extension seams, ports of the legacyAdvTransportershooks — override these instead of redeclaring the calculator (thesmile_v4fork currently redeclarestransportCost()wholesale):protected function customTransportCost(int $transporterId, string $countryAlpha2, ?string $countyAlpha, ?string $postalCode, float $cartWeight, float $cartTotal): ?float— return non-null to short-circuit the entire threshold/overweight block (port ofAdvTransporters::customTransportCost(), #389). The result still passes through the surcharge hook, so the two compose.protected function applyTransportSurcharge(float $price, int $transporterId, string $countryAlpha2, ?string $countyAlpha, ?string $postalCode, float $cartWeight, float $cartTotal): float— called once on every serviced path, including the free-shipping branches where$priceis 0 (port ofAdvTransporters::applyTransportSurcharge()).
[4.120.0] Check for overrides:
ShippingCalculator's constructor-promoted properties widened fromprivatetoprotected, so a subclass can reach the injected repositories. Constructor arity is unchanged — no call-site or DI change is required.[4.120.0] Check for overrides:
Advisable\Domains\Checkout\PlaceOrderService::__construct()takes one new trailing argument,Advisable\Domains\Cart\CartWeightCalculator $cartWeightCalculator. It is autowired, so no container edit is needed, but a fork that constructs the service by hand or overrides the constructor must add it.[4.120.0] Check for overrides:
Advisable\Rest\Checkout\Controllers\Checkout::__construct()takes a newCartWeightCalculator $weightCalculatorargument (inserted after$totalsCalculator). Autowired; same caveat as above for hand-constructed forks.[4.120.0] Check for overrides: new service
Advisable\Domains\Cart\CartWeightCalculator, registered insrc/Domains/Cart/container.php.CartTotalsCalculatoris deliberately unchanged — the cart weight is a separate collaborator so that a fork overriding the totals calculator cannot silently zero the overweight surcharge.[4.120.0] BEHAVIOR CHANGE — money-moving. Quoted and charged shipping costs change for any shop with transporter options configured. Verify a shop's
transporters_options_pricingrows before deploying: a transporter/country with no options row fails open to0on every key and therefore ships free — that is legacy parity, not a new bug, but REST now honours it where it previously charged the pricing-row cost.[4.120.0] Check for overrides:
Advisable\Domains\Order\SalesAnalytics\Repository\Repository::orderTotalsByBucket(),basketUnitsByBucket(),productSalesByBucket()— if aCustom\subclass has copied or overridden any of these methods, check itsorder_by($bucketExpr, 'ASC')call for the missingfalsethird argument (escape=false); without it, CodeIgniter'sorder_by()escaping splits the bucket expression on its comma and appends the sort direction inside the function's argument list, causing a MySQL 1064 error thatdb_debug=falsesilently degrades to an empty result.[4.120.0] Consumer-facing semantics: this is a published-contract change on the MCP surface. A consumer that read
cost: 0as "a real €0 acquisition cost" will now receivenulland must handle it. A consumer that already treated0as "unknown, skip the margin" was right by accident and is unaffected.[4.120.0] Check for overrides:
Advisable\Domains\Order\SalesAnalytics\Service::productSales()— no signature changed, but the fix lives inside the method body. A client fork that has copied or overridden this method still publishes the rawacquisition_valueand will keep emittingcost: 0.[4.120.0] UI Update:
POST /rest/checkout/place-ordercan now return 422 where it previously never did. A client that treats "not 201" as a flat 400, or switches on status code alone, must handle 422. Theerror.codefield is additive and the existing{success, message}shape is unchanged, so nothing breaks structurally. Velora is the live REST consumer and must handle the new 422.[4.120.0] Check for overrides:
PlaceOrderService::placeOrder()now throwsLoyaltyRedemptionExceedsOrderTotalException. It extends\RuntimeException, so an existingcatch (\RuntimeException)still catches it — but a fork overridingPlaceOrderService, or callingplaceOrder()outside the REST controller, will map it to its own generic handling and lose the 422. A fork overridingCheckout::placeOrder()silently keeps the old behaviour and should add the new catch arm above the generic one — ordering is load-bearing, since the new exception is aRuntimeExceptionsubclass.[4.120.0] Check for overrides:
Adv_order_modelgainedfrontOrderCostTerms(),couponValueDryRun(),payableBeforePointsForCheckout(),adminOrderGiftPackaging(),adminOrderCostTerms()andpayableBeforePointsForAdminOrder();create_order()andcreate_order_admin()now assembletotal_vatthrough the shared helper and can return the existing null-serial contract on refusal.Adv_orderandAdv_orders_adminare among the most-overridden classes in client forks — a fork overridingcheckout(),checkoutView(),redeemOtherPostElementsPointsAdd(),redeemOtherPostElementsPointsEdit(),create_order()orcreate_order_admin()will not get the refusal and must port it.[4.120.0] Check for overrides: two new language keys were added to all 8 language directories —
checkout.error.points.exceed.total(adv_theme_lang.php, customer-facing) andeshop.admin.order.error.points.exceed.total(adv_advisable_lang.php, admin-facing). Forks with their own language files must add both.[4.120.0] Known limitation. The storefront refusal uses the
order_errorsession key, which has no reader in this repository (10 writers, 0 readers — rendering is theme-side / client-fork). In stock views the customer is redirected back to the preview page without a visible message; forks that renderorder_errorwill show the new key correctly. The admin path deliberately usesSESS_KEY_ESHOP_ERRORinstead, which has a confirmed reader.[4.120.0] Check for overrides (meta-length hard cap — #575): a client fork subclassing or overriding any of these loses the enforcement (or the ability to compile against it) silently:
Advisable\Mcp\Support\ToolResult::lengthWarnings()— REMOVED, replaced byenforceLengths(): void, which THROWSMcp\Exception\ToolCallExceptioninstead of returningstring[].ToolResult::META_TITLE_SOFT/ToolResult::META_DESCRIPTION_SOFT— RENAMED toMETA_TITLE_MAX/META_DESCRIPTION_MAX. Values unchanged (65 / 150).Advisable\Mcp\Tools\MergesTranslations::diffFields()(protected) — now throws on over-length meta. A fork overriding this method loses the enforcement entirely.Advisable\Mcp\Tools\MergesTranslations::applyMergeUpdate()(protected) — return shape no longer contains awarningskey.
[4.120.0] MCP tool contract:
update_category,update_brand,update_product,update_product_contentand bothcategories_batch_update/products_batch_updatetools no longer returnwarnings, and now reject an over-lengthmeta_title(>65) ormeta_description(>150) instead of writing it. Inside a batch this surfaces as a per-rowstatus: "error", not a whole-call failure.[4.120.0] Check for overrides:
AdvCancelPendingGiftCards::cancelPendingIrisOrders()(ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php) isprotected. Any client fork overriding it — directly, or viaapplication/modules/gift_cards/jobs/CancelPendingGiftCards.php, which is an empty subclass in main — keeps all three bugs above after merging this release. Forks must be audited for an override of this method.- New overridable surface a fork should know about:
AdvCancelPendingGiftCards::irisReconcileAction()(public static — widening the accept allowlist reintroduces the payout risk) andAdvCancelPendingGiftCards::irisClient()(protected — an override must keep the return type covariant,Iris\Iris).
[4.120.0] No REST/API contract changed and no new config/registry key is introduced — no
UI Update:note needed.[4.120.0] Check for overrides: six client-fork override surfaces changed (Advisable-com/ecommercen#588) — the
buildListRequest()one is the only unconditional breakage, the rest fire only if the fork overrode the named member:Advisable\Domains\Support\Repository\Relation::__construct()gained a 10th parameter (?\Closure $visibilityScope = null). It is optional and trailing, so every existingnew Relation(...)call site — including named-argument ones usingscopableColumn:/scope:— compiles unchanged and gets the fail-closed default. A fork that subclassesRelationand overrides the constructor must add the parameter or it will drop the visibility scope for every relation it builds.- The relation loader
load()signature gained a 10th parameter (array $visibilityExemptions = []) onRelationLoaderInterfaceand all fourRelationLoader/*classes, plusAbstractRelationLoader::loadNested()(6th),BaseRepository::loadRelations()(6th),BaseRepository::loadRelation()(9th) andBaseRepository::get()(5th). A fork with a custom loader implementingRelationLoaderInterface, or one extendingAbstractRelationLoader(the pattern used upstream byProduct\Variation\Repository\Repository's anonymousONE_TO_MANYoverride), will fatal on an LSP incompatibility until itsload()signature is widened to match — and must forward the new argument intoloadNested()and any recursion helper, or nested embeds silently lose the exemption at depth ≥2. Advisable\Rest\Product\Resources\Product\Resource::addCategoriesToData()changed shape. It no longer filters bypublished— that moved to the relation loader — and is now ordering-only. A fork that copied the wholeResourceclass keeps #479's own-publishedfilter, which is now both redundant (the loader already excluded those rows for non-backend callers) and wrong for backend callers, who are exempted at the loader precisely so the admin can still see unpublished links: the fork's copy would filter them straight back out. Such a fork should delete its filter block and keep only theusort(). A fork that merely overridesaddRelationToData()and delegates toaddCategoriesToData()inherits the fix automatically.- Every upstream domain
Service::get()gained a 5th parameter (array $visibilityExemptions = []) and a second interface (ExemptsRelationVisibility), and everybuildSpecifications()now passes a 4thWithRelationsargument.ReadServiceitself is unchanged, so a fork's own service that merely implementsReadServicekeeps compiling and stays fail-closed. But a fork that overrides an upstream service'sget()with the old 4-parameter signature will fatal — the parent now declares 5. Once the signature is widened it must also forward$visibilityExemptionsinto$this->repository->get(), and any overriddenbuildSpecifications()must pass$listRequest->visibilityExemptionsas the 4thWithRelationsargument; otherwise that fork's admins silently lose category links on that route. A fork with its own service that wants admins to see everything should implementExemptsRelationVisibilityand mirror both forwards. - ⚠️
HandlesRestfulActions::buildListRequest()now calls$builder->exemptAllRelationVisibility()on the dynamically-resolved$listRequestClass. This is the only unconditional breakage in this list — the other surfaces all require a fork to have overridden a specific method, whereas this one fires on a plain configuration. A fork whose own list-request class does not extendGenerateListRequest, or which overridesgenerate()without chaining to the parent, getsCall to undefined method ::exemptAllRelationVisibility()— a fatal on every backend request to that controller, not a silent under-report. Every upstream$listRequestClasswired acrosssrc/Rest/**/container.phpwas verified to extendGenerateListRequest, so upstream is safe. Note the parameter is typedobjectprecisely because the class-string is dynamic, which removes the static type error and leaves only the runtime fatal — so a fork will not learn about this from a linter. - A fork that overrides
show()orindex()and calls$this->service->get(...)itself — with the old four arguments — silently loses the backend exemption on that route. Not a fatal (the 5th parameter is optional and trailing): the admin simply receives the storefront-filtered set, list-vs-detail asymmetrically. This is distinct from the bullet above, which covers a fork overridingget(); this one covers a fork overriding its caller. Three upstream controllers were found doing exactly this and fixed here —Slider::show(),Admin\Me::me()andAdmin\User::undelete()(the last found by a static guard test, not by inspection) — so a fork doing the same is likely. The fix is not to hand-roll the forward: this change addsprotected HandlesRestfulActions::fetchOneWithVisibility(), which owns theinstanceof ExemptsRelationVisibilitydance in one place and falls back to the plain four-argument call (fail-closed) for a non-participating service. A fork overridingshow()/index()should callfetchOneWithVisibility()instead of$this->service->get(). The method is purely additive, so nothing breaks by not adopting it — the cost of ignoring it is the silent under-report described above. A new upstream guard test (tests/Unit/Rest/Support/ServiceGetVisibilityGuardTest.php) fails anysrc/Rest/**/Controllers/*.phpthat passes relations to$this->service->get()without forwarding exemptions; forks carrying their own controllers may want to copy it.
[4.120.0] UI Update: Velora / other headless storefronts — the product
categoriesrelation is now filtered by ancestor reachability, not just the category's ownpublishedflag (Advisable-com/ecommercen#588). A published category under an unpublished parent no longer appears, so breadcrumbs and category chips can no longer link into a non-navigable section. Consumers that added defensive client-side handling for dead category links (the residual gap #479 documented) can drop it. As before, a consumer that needs every link — including into unpublished sections — must use a backend-authenticated token;publishedremains backend-only on the category payload.[4.120.0] UI Update:
AUTH_ROLE_PRODUCTS-only admins now see and can open the Product Reviews entry in the admin menu (Products group), and can moderate reviews (approve/reject, single or bulk) via both the legacy admin UI and the REST API — previously they had neither menu visibility nor backend access. The REST grant also coversCustomerReview::classwrites (store/update/destroy), not justReview::class.[4.120.0] Loyalty consequence: approving a review can award the customer loyalty points via
Adv_loyalty::savePointsToCustomer()(Adv_product_reviews_admin.php:89-142bulk,:209-222single), amount from registryECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD, gated by bothPOINT_SYSTEM/IS_ENABLEDandECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD_ENABLE(seeded'0', so off unless a client enabled it). Neither gate is role-scoped, so on any install where both flags are on, a PRODUCTS-only admin can now issue loyalty currency simply by approving a review. This was accepted by the product owner as part of #59's deliberate access widening; no additional guard was added.[4.120.0] CustomerReview write consequence: AC 4 also added
AUTH_ROLE_PRODUCTStoCustomerReview::class's REST policy (rest_policies.php:664), which carries nomethodsoverride, so the grant reachesstore/update/destroyonshop_customer_reviews(rest_routes.php:696-701) — the post-purchase prompt-email dedup log, not product reviews (see AD-52 Known Issue #3). Deleting a row there can re-expose a customer to a duplicate prompt email. This is mandated by AC 4; an accepted consequence, not a defect.[4.120.0] Check for overrides:
application/config/admin_menu.phpandapplication/config/rest_policies.phpboth live underapplication/, the path a client fork overrides with its own copy (percustom/**'s sibling convention — client customizations go incustom/orapplication/). A client fork carrying its own copy of either file will not pick up theAUTH_ROLE_PRODUCTSgrant automatically and must add it by hand to both the menu entry'srolesarray and theReview/CustomerReviewpolicydefaults.roles. A fork whoseProduct_reviews_adminsubclass re-declares__construct()with its ownallowRole()array must addAUTH_ROLE_PRODUCTSthere too — otherwise the config grant (menu visible, REST allows) leaves the legacy controller still returning 401.[4.120.0] Check for overrides:
PlaceOrderService::resolveGiftPackaging()changed from a self-containedprivatemethod to a delegation to the new injectedGiftPackagingResolver, andPlaceOrderService::__construct()gained a new final constructor argument (GiftPackagingResolver $giftPackagingResolver) — it is now the 20th required constructor parameter. A client fork that overridesPlaceOrderService, or that constructs it directly (rather than through the DI container), must add the new argument or container compilation / direct instantiation fails. Separately,LoyaltyRedemption's inline #573 refusal predicate moved intoexceedsPayable()— a fork that overrode or duplicated that inline check needs review, since the canonical predicate now lives in one place rather than being copied betweenPlaceOrderServiceand the quote endpoint.Advisable\Rest\Checkout\Controllers\Checkout::__construct()gained three new promotedprotectedconstructor parameters, appended last:GiftMatcher $giftMatcher,OrderBasketBuilder $orderBasketBuilder,GiftPackagingResolver $giftPackagingResolver. A client fork subclassing this controller fatals until its own constructor is updated to match; the three newprotectedproperties are also new override surface.PlaceOrderData::normalizeSelectedGifts()widened fromprivate statictopublic static— safe for callers in general, but a fork that redeclared a same-namedprivate staticmethod on a subclass now fatals: PHP rejects reducing visibility on override, andpublic→privateis a reduction.PlaceOrderService::resolveGiftPackaging()itself staysprivate, but the Registry reads it used to own moved out toGiftPackagingResolver. A fork that had copied (not overridden) this method to add its own gift-packaging logic now carries a second, drifting implementation of theGIFT_PACKAGINGreads — exactly the quote/charge drift this issue set out to eliminate. Such a fork should retarget its copy atGiftPackagingResolverinstead.Advisable\Domains\Checkout\GiftPackagingResolveris a new DI service (registered insrc/Domains/Checkout/container.phpwith$registrywired tonullfor lazy CI resolution) and therefore a new client-override/alias seam.- Test-fixture break on merge:
GiftPackagingResolver::$registryis a typed?Registryproperty, so the lazily-resolved$ci->registryis now type-checked on assignment — the inlined code it replaced assigned the same value to an untyped local and was not. Production is unaffected (the CI super-object'sregistryreally is a\Registry), but any fork's gift-packaging test fixture that supplies a duck-typed anonymous class as$ci->registrywill now fail with aTypeError. This hit our own suite (tests/Unit/Checkout/PlaceOrderServiceTest.php) and had to be fixed by mocking the real\Registryclass instead; forks should expect the same fixture fix.
[4.120.0] UI Update:
/rest/checkout/totalsresponse gained four fields (giftPackagingCost,giftDiscount,payableBeforePoints,loyalty), and itstotalnow moves on gift-packaging and rule-13 carts to match what/place-orderactually charges.itemCountis not just a value shift — its meaning changes on a gift cart: it is now gift-adjusted rather than a raw cart-line count, so a rule-13 cheapest-free unit is not double-counted and an earned free-gift row IS counted, matching the basket the order would actually be placed with. Headless consumers (Advisable-com/velora,Advisable-com/velora-wecare-experiment) should consume the server'sloyalty.wouldExceed/loyalty.canRedeeminstead of computing the redemption client-side from a hardcoded ratio, and should re-baseline any fixture or contract test pinningtotaloritemCountfor a gift-packaged or rule-13 cart. The redemption input contract is unchanged —redeemPointsboolean only, all-or-nothing, no quantity field on either endpoint.[4.120.0] No data migration ships, and existing
EMAIL_SUBJECTSrows are untouched. A shop that already accumulated junk rows from the old bug (a truncated key likesamp, or a bogus non-language row likesubm) will keep them — they continue to render in the email-subject editor, most visibly with a missing-translation label, until removed by hand. This fix stops new junk from being written; it does not clean up what is already there.[4.120.0] Upstream-conflict note for client forks: a fork that copied
editEmailSubjects()wholesale into its ownapplication/controller keeps the buggyrtrim()call and will not inherit this fix automatically — it needs the same change applied to its copy. The newprotectedseam,AdvEmailViewer::stripLanguageSuffix(string $postKey, string $langAbbr): ?string(ecommercen/settings/controllers/AdvEmailViewer.php), is available as an alternative for a fork that would rather call it than re-derive the key itself — the same override pattern this controller'ssampleCurrencyId()/sampleEmailData()already establish.[4.120.0] REQUIRES
npm run admin-production(ornpm run all-production): this fix edits the source bundleassets/admin/js/tasks/tasks.js. The admin bundle (public/ui/admin/dist/) must be rebuilt for the fix to take effect in production.[4.120.0] Check for overrides:
Auth::afterTaskDelete()(application/modules/auth/controllers/Auth.phpin the main repo — the empty client-override stub forAdv_auth). A fork that overrodeAuth::afterTaskDelete()was previously having it fire on task completions and un-completions too, in addition to deletions. After this fix it fires only ontaskDelete. Such a fork must add overrides for the newAuth::afterTaskCompleted()/Auth::afterTaskUncompleted()methods to preserve its previous behaviour on completion/un-completion.[4.120.0] Check for overrides: the PayPal Advanced
tran_ticketorder-id fix (Advisable-com/ecommercen#542) touches overridable methods in bothWebrunandAdv_checkout:Webrun::handlePaypalOrder()(application/controllers/Webrun.php) is overridable — a fork with its own override keeps its old serial-keyedtran_ticketpersistence for the checkout branch and does not inherit the id-keyed fix, so PayPal Advanced card captures on that fork keep failing with the opaque empty200.Webrun::paypalOrderData(),Webrun::paypalAdvancedCreateOrder(), andWebrun::paypalAdvancedCaptureOrder()are each independently overridable — a fork overriding any of these keeps its own copy of, respectively, the incorrect non-nullable return type, the missing404on an order that can't be found, and the missing guard against an emptytran_ticketbefore callingPayPalRestApi::captureOrder().Adv_checkout::paypalAdvancedSuccess()(ecommercen/checkout/controllers/Adv_checkout.php) is overridable — a fork overriding it keeps its own copy of the pre-guard logic and does not get the new empty-tran_ticketguard beforegetOrderDetails(), routed through the existingpaypalAdvancedFail()page-failure path in the fixed version.
[4.120.0] Check for overrides: the JS entry-bundle content-hashing change (Advisable-com/ecommercen#504) touches build config a client fork may maintain its own copy of:
- A client fork carrying a local
webpack.mix.front.js/webpack.mix.admin.jspatch for the stale-JS-behind-a-CDN problem — or a manual Cloudflare/CDN purge-on-deploy workaround — can drop it now that the platform build content-hashes JS entry bundles. A fork with a customized build config should reconcile it against the newmix.then()JS hashing loop.
- A client fork carrying a local
[4.120.0] Check for overrides: the configurable homepage video-showcase limit (Advisable-com/ecommercen#505) removes the reason for a whole-method override of
Adv_home::getStreamVideos():- A client fork that overrides
getStreamVideos()(ecommercen/eshop/controllers/Adv_home.php) solely to change the hardcoded8-video count should drop that override entirely and instead setVIDEOSHOWCASE.HOMEPAGE_LIMITvia the new admin field on the Video Showcase settings form (or directly in the registry) — no code override needed. - This matters in particular because a whole-method override of
getStreamVideos()is fragile: it broke in 4.119 (Advisable-com/ecommercen#383) whenBunnyStream::__construct()'s parameter order was reordered, and any fork copying the full method body silently kept constructingBunnyStreamwith the old, now-wrong argument order. Overriding only the limit via the registry key avoids that whole class of merge risk going forward.
- A client fork that overrides
[4.120.0] Check for overrides: the payway-misclassification fix (Advisable-com/ecommercen#530) touches two overridable seams:
isOrderPaidAtDeliveryByPayWay()(ecommercen/helpers/eshop_helper.php) sits behind afunction_exists()guard and is overridable inapplication/helpers/. A client that overrides this helper (or the whole file) keeps its own exclusion list and will still block voucher creation forstripe,xpay, andethniki_nbgpayuntil its own copy adds them. In the main repoapplication/helpers/eshop_helper.phpis a 7-byte<?phpstub, so no main-repo override exists.canCreateVoucher()(ecommercen/libraries/vouchers/AdvSetPendingWithVoucher.php:1264) is itself a documented client override point (docs/flows/admin/AD-34-voucher-generation.md:193). A fork overriding it keeps its own eligibility logic entirely, independent of the helper fix, and must separately verify its list matches whichAdv_checkouthandlers actually writePENDING_ACCEPTED.orderGetSentStatusForPayWayVoucher()andorderGetRevertedStatusForPayWayVoucher()(ecommercen/helpers/eshop_helper.php:186-198) are each independentlyfunction_exists()-guarded, so a fork can shadow either wrapper on its own. Both are thin readers ofisOrderPaidAtDeliveryByPayWay()and so inherit this fix for free in the main repo — correctly left untouched by this change. But a fork that has overridden one of the wrappers itself does not consult the fixed helper at all: it keeps its ownSENT/PAID_SENT(orPENDING_ACCEPTED/PAID) logic, and itsstripe/xpay/ethniki_nbgpayorders keep getting the wrong voucher status after this merge. The consumers sit outside this diff —ecommercen/eshop/controllers/Adv_orders_admin.php(5 sites, viaorderGetSentStatusForPayWayVoucher()) andecommercen/libraries/vouchers/AdvCancelVoucher.php(14 sites, viaorderGetRevertedStatusForPayWayVoucher()) — so the breakage is silent: wrong order statuses after shipment or voucher cancellation, not an error. A fork that has shadowed either wrapper needs the same 3-payway correction applied to its own copy.
[4.120.0] Check for overrides: the payway/order-source drift fix (Advisable-com/ecommercen#500) touches two overridable seams:
getCardPayWays()(ecommercen/helpers/eshop_helper.php) sits behind afunction_exists()guard and is overridable inapplication/helpers/. A client fork that redefines it does not inherit the three new payways (xpay,klarna_payments,ethniki_nbgpay) and will keep stranding those orders until its own copy is updated to include them — this is the highest-value check here.application/controllers/Cronjob.phpis itself the client-override location. A client fork with its ownCronjob.phpkeeps its duplicatedorder_debris()switch and does not get the delegation — so it also keeps the unreachablexpaybranch and any future drift between the switch and the job. Those forks should either adopt the delegation ((new CancelIncompleteOrders())->executeCommand([])) or apply the same three-payway fix to their own copy.- Removed from
Cronjob: theprivatemethodscancelPendingPayByBank(),handlePendingIrisOrders(),handlePendingXpayOrders(),cancelPendingDefaultCards(),returnPointsToCustomers(), andxPay()— not overridable — plus theprotectedmethodhandlePendingPaypalAdvancedOrders(), which is overridable; also removed: the$xPayproperty; theIris/XPay/PayPalRestApiimports; and theOrderForErpHookFireTrait/OrderCancelHookFireTraittraits. A fork that subclassedCronjoband overrodehandlePendingPaypalAdvancedOrders()silently loses that override and needs a deliberate reconciliation against the new delegatingorder_debris(); a fork that instead copy-pasted the whole file diverges regardless and should reconcile the same way.
[4.120.0] New registry key:
XPAY/EXPIRATION(seconds, default10800= 180 minutes) — the grace periodhandlePendingXpayOrders()waits before probing the Nexi API (Advisable-com/ecommercen#500). Deliberately has no admin settings field; set it directly in theregistrytable if a shop needs a different XPay grace window than the default.[4.120.0] Check for overrides:
Adv_order_model::processOrder()'s return shape changed (Advisable-com/ecommercen#508):processOrder()isprotected— a client-override seam. Old: the fullshop_orderrow, fetched viagetRecords(['conditions' => ['id' => $id], 'as_row' => true]). New: a constructedstdClasscarrying onlyidandorder_serial. A fork that overridesprocessOrder(), or that reads any column off its return value other than->id/->order_serial, will break. Rollback behavior is unchanged — stillfalsefrom all four rollback paths.- Audit forks for: (a) an override of
processOrder(); (b) any read of a column other than->id/->order_serialoffcreate_order()'s or_proccess_admin_order()'s intermediate result. - Builds on the #473 note below —
processOrder()'s override surface has now changed twice.
[4.120.0] Check for overrides: the
Scanaccessbarcode-scanner removal (Advisable-com/ecommercen#12) deletes routes and files a client fork may have its own copy of:- A client fork with its own
application/controllers/Scanaccess.phpoverride, or its own/scanaccessroute or a custom admin-menu/nav link pointing at it, will be left pointing at nothing once the main-repo routes are gone — that fork needs its own cleanup decision (keep the override standalone, or remove it too). - A client whose DB-backed language-override table holds a row for
admin.label.product_not_foundwill have a harmless orphaned override row after this merge — runtime data only, no repo-side action required.
- A client fork with its own
[4.120.0] Check for overrides: the
advauthREST DI seam (Advisable-com/ecommercen#523) changes how the legacyadvauthlibrary is obtained — four things for a fork to check:- Remove any
\Advauthservice id or alias from a fork's owncustom/Rest/**/container.php. This is the one item that can actively break a fork, but only in its alias form: aliasing\Advauthtorest.auth.advauth— or defining\Advauthwith theAdvauthFactoryfactory — does recurse.di()->get(\Advauth::class)resolves the alias into the factory, whose ownload->library('advauth')re-enters the same service while it is still being constructed, raising a SymfonyServiceCircularReferenceExceptionat request time (CI's Loader resolves libraries viadi()->has($className),system/core/Loader.php:1109-1110). It stays invisible until a request actually resolves the service, and it is what a fork would do if it tried to "restore autowiring by type." An independent plain re-registration —$services->set(\Advauth::class, \Advauth::class), a separate autowired definition — does not recurse: CI's Loader resolves that definition to a barenew Advauth(), never touching the factory, and still assigns$CI->advauth. It is redundant rather than breaking;v4-creamy(application/config/container/container.php:15) is the one known instance — recommend removing it for a single source of truth, but flag it as cleanup, not urgent. - Drop any local workaround for the underlying bug — e.g. a
$this->load->library('advauth')bolted into a model or controller, a forkedAdvauth.php, or anadvauthentry added toapplication/config/autoload.php. These become dead code after this fix, since the Loader early-returns when$CI->advauthis already set — this is cleanup, not a break. di()->get(\Advauth::class)no longer resolves — that service id is gone by design. A fork obtaining the library that way must switch to therest.auth.advauthservice id, or to CI's Loader ($CI->load->library('advauth')).- Heads-up, not a break: on the legacy web/admin path (
Adv_front_controller/Adv_admin_controller, which already loadsession/cart/advauththemselves),$CI->advauthis now constructed by the Loader rather than fetched from the container — both are a bare no-arg construction, so this is behaviourally identical. Only relevant to a fork that depended on container identity forAdvauth.
- Remove any
[4.120.0] Actions for the Shopify Admin GraphQL layer:
- New
.envvars: setSHOPIFY_STORE_DOMAINandSHOPIFY_ACCESS_TOKEN(stubs added to.env.example) for any environment that runs the Shopify import. The token needs Admin API read scopes includingread_content. Without them the layer throwsShopifyConfigurationExceptionat client build time. - Grant
read_all_ordersbefore migrating orders: withread_ordersalone the API silently exposes only the last 60 days and reports no error, so an order migration run without it produces a 60-day slice of the history. Verify with{ currentAppInstallation { accessScopes { handle } } }; the import is idempotent onorder_serial, so a re-run after the scope is granted backfills the rest. - Rebuild the DI container: new services are registered in
src/Shopify/container.php+application/config/container/modules.php, so deletecache/container.phpto force a recompile on deploy.
- New
[4.120.0] Check for overrides: eleven
src/Rest/Product/Resources/*/Resource.phpresources (Advisable-com/ecommercen#512) now actually return the relations they declare:- Each resource's
resource()built a bare array (or, forReview, returned its local$data) and never called the already-definedaddRelationToData(), so relations the repository had already batch-loaded onto the entity were silently dropped from every?with=...response. A client fork carrying its own override of any of these elevenResource::resource()methods (rather than inheriting the base class) keeps its own copy and does not automatically inherit this fix. Affected resources and the relations each now embeds:BundleDisplay→bundleBundleCriteria→bundle,referencesBundlePricing→bundle,criterionBundleBuilderReference→bundleBundleCriteriaReference→criterionRelatedGroup→translationsRelated→product,relatedProduct,groupReview→productWaitingList→productProductMeta→productDownload→product
- Any fork or frontend call site that worked around the always-empty relations (fetching them separately instead of via
?with=...) can be simplified now that relation embedding works, but is not required to change.
- Each resource's
[4.120.0] Check for overrides:
ProductBundleResource::resource()(Advisable-com/ecommercen#482) now actually returns the relations it declares:display,criteria,builderReferences, andpricingonGET /rest/product/bundle,/rest/product/bundle/{id}, and the item endpoint were silently dropped regardless of?with=...—Resource::resource()(src/Rest/Product/Resources/Bundle/Resource.php) built a bare array and never called the already-definedaddRelationToData(). A client fork carrying its own override ofResource::resource()(rather than inheriting the base class) keeps its own copy and does not automatically inherit this fix.- Any fork or frontend call site that worked around the gap — e.g. fetching
BundleDisplay/BundlePricing/BundleCriteriaseparately per bundle instead of relying on?with=...— can be simplified now that relation embedding works, but is not required to change.
[4.120.0] Check for overrides:
Admin\User\Validator's password-length floor (Advisable-com/ecommercen#506) now measures characters, not bytes — a genuine tightening:Advisable\Domains\Admin\User\Validator::validateForCreate()/::validatePasswordChange()(src/Domains/Admin/User/Validator.php) usedstrlen($password) < 8— a floor, so byte-counting made it weaker than documented: a 4-character multibyte (e.g. Greek) password is 8 bytes and passed. It now usesmb_strlen($password, 'UTF-8') < 8, so a short multibyte password that previously passedPOST /rest/admin/users(create),POST /rest/admin/users/{id}/password(admin reset), orPOST /rest/admin/users/change-password(self-service change) will now be rejected with a422.- A client fork carrying its own
Custom\Domains\Admin\User\Validatoroverride, or an admin-panel password form that pre-validates client-side against the old byte-based 8-character floor, should re-check it: a password that previously passed may now be rejected server-side. - The other three files touched by #506 (
Seo\CustomMetaTag\Validator,Seo\DefaultMetaTag\Validator,Cart\Cart\Validator) only loosen what's accepted, or have no practical exposure (Cart\Cart's fields are ASCII by construction;DefaultMetaTag'slangis always a 2-letter code in practice) — no override note is needed for those.
[4.120.0] Check for overrides:
Validator::validateForCreate()/::validateForUpdate()(Advisable-com/ecommercen#502) now actually enforce validation — previously a guaranteed no-op:Advisable\Domains\Customer\CustomerMessageHistory\Validator(src/Domains/Customer/CustomerMessageHistory/Validator.php) built an$errorsarray that was never populated, so any payload — including one missinguserId/type/messageType— passed through silently. A client fork carrying its ownCustom\override or alias of thisValidator(or ofWriteService, if it bypasses the base validator entirely) previously inherited that no-op behavior; it should now be re-checked against this baseline (required fields, positive-integeruserId,type<= 255 chars,messageType<= 50 chars) so it doesn't unexpectedly start rejecting payloads the fork's own callers assumed would always succeed, or — if the fork's override is itself still a no-op — so it isn't relying on the base class to catch bad input that it no longer will once overridden.- No such override or alias was found in this repo's own
application//custom/scan — this is a heads-up for downstream client-repo reconciliation, not an in-repo finding.
[4.120.0] Check for overrides: the dead-code removal on
Adv_view_metrics_model(Advisable-com/ecommercen#393) removed apublicand aprotectedmethod:wipeMetricsBefore()(waspublic) andapplyDeletionQuery()(wasprotected) were removed fromecommercen/view_metrics/models/Adv_view_metrics_model.php. A client fork'sapplication/layer could in theory have overridden either method or calledwipeMetricsBefore()directly (e.g. from a custom cron/job) — verify no override or call site references either removed method before merging, or it will fatal with an undefined-method error.applyObjectWhereQuery()was not removed and is still called byapplyLastRecordsQuery()/applyMetricsInRangeQuery()— no action needed there.