Skip to content

<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>

Home | Changelog

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-nested articles → comments selection costs 1024 points at the page size inherited from PaginatedQuery — so every call failed outright with MAX_COST_EXCEEDED. The existing tests drive a Guzzle MockHandler, 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 = 20 for callers to pass to all(), mirroring the explicit-ceiling pattern of VendorQuery::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 asserts all() sends first: 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.
  • [4.120.0] fix(tests): restore a meaningful composer test exit code (Advisable-com/ecommercen#521)

    • Why. composer test exited 1 on a fully passing suite, so its exit status carried no signal — 1 on a clean run and 1 on 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.php and MuiCollectionTest.php add meta_title/meta_keywords/meta_description to all four raw stdClass fixture blocks and assert them — MuiResource gained those three reads in dd5adcaff5 (2026-06-12) alongside migration 20260612121500, while the fixtures had not been touched since 104344413b (2026-03-09), so each read warned on an undefined property. That is the change that gets the exit code to 0. phpunit.xml.dist gains failOnDeprecation="true" — a separate PHPUnit 10.5 attribute from the already-set failOnWarning, defaulting to false — without which the attribute work below buys nothing durable and deprecations silently re-accumulate. src/Piraeus/PireausWsdlClass.php gains #[\ReturnTypeWillChange] on its ten ArrayAccess/Iterator/Countable methods; 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 through lazyLoadRelation() and returns null. Keeping the diff test-only makes this a true patch.
    • Verified. vendor/bin/phpunit --testsuite=Unit → OK (3846 tests, 10836 assertions), exit 0, zero warnings and zero deprecations; the Integration and Legacy suites still exit 0.
    • No DB migration, REST API, OpenAPI, or language-key changes.
  • [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 → approved cycle re-awarded every time, because is_email_sent guarded duplicate emails but nothing guarded the loyalty ledger — Adv_loyalty::savePointsToCustomer() (ecommercen/libraries/Adv_loyalty.php:236-244) runs a raw total_points = total_points + N update with no guard.
    • The fix. Added is_points_awarded TINYINT(1) NOT NULL DEFAULT 0 to shop_product_reviews (migration database/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php), mirroring the existing is_email_sent column. One new model method, claimReviewPointsAward(int $reviewId): bool (ecommercen/eshop/models/Adv_product_reviews_model.php:286-294) — a single conditional UPDATE shop_product_reviews SET is_points_awarded = 1 WHERE id = ? AND is_points_awarded != 1, returning true only 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 = 0 and both award.
    • Bulk path. bulkSetStatus() claims one review at a time, in ascending review-id order, rather than with a batched WHERE 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 its shop_product_reviews row locks consistently, but this is not blanket deadlock immunity: each iteration also locks a shop_customer row inside savePointsToCustomer(), 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() and bulkSetStatus() now check $this->db->trans_status() after trans_complete() and, on a rolled-back award, surface the existing eshop.admin.product_reviews.set_status_error message instead of an unqualified success redirect (set_userdata on the single path, set_flashdata on bulk). No language file was touched.
    • Exception safety. Both paths wrap the claim+award in try/catch (Throwable) with an explicit trans_rollback() and a rethrow — belt-and-suspenders against an unexpected non-DB exception between claim and award. AdvEshop4 runs the custom advmysqli driver (application/config/database.php:12), whose db_connect() sets MYSQLI_REPORT_OFF process-wide on every connect (system/database/drivers/advmysqli/advmysqli_driver.php:72-76), so a failing query — deadlock and lock-wait timeout included — returns false and is already handled by trans_complete()'s auto-rollback plus the trans_status() check above. The still-true reason for rolling back explicitly: on a throw, CI never records a failure (_trans_status is set false only when query() returns one), so trans_complete() would COMMIT the claim — which under APP_DB_DEFAULT_PCONNECT=true can 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.md updated: Known Issue #7, Business Rule #5, the Loyalty Integration Gap section, the Data Model table, and the Legacy Model Methods table.
  • [4.120.0] docs(AD-53): correct the false claim that the email-template viewer and Adv_mailer read 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.php echoed description raw into a double-quoted title tooltip attribute (:226) — a stored " broke out of the attribute — and echoed title raw 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 :179 already stripped tags via strip_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) pass false as the second (double_encode) argument so entities TinyMCE already stored (&nbsp;, &amp;, …) aren't re-encoded into visible text, while the two title sites (:154, :229) keep the bare single-argument default. :179 also passes the literal … character as content_shorten()'s third argument instead of the helper's '&hellip;' entity default, so html_escape() can't double-encode it into a visible &hellip;; the truncation marker is unchanged, and the helper's default is unchanged for every other caller.
    • Storage is unchanged — Adv_auth::addTask() / editTask() still save post('description') / post('title') raw, so existing TinyMCE descriptions keep round-tripping unchanged in the edit form (updateTask.php:28's set_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 at error and 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 the error log with noise that wasn't an actual failure. Non-fatal and self-healing today — a throttled parcel takes the is_null($track) continue path without writing shop_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 plus MAX_RETRIES = 1): on a 429 it honours the carrier's Retry-After header via a new private resolveRetryAfterSeconds(), sleeps, and retries once. Only the numeric-seconds form of Retry-After is 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 reach sleep(), which would throw an uncatchable ValueError) and capped at RETRY_AFTER_CAP_S = 5 seconds, so a large or misbehaving header can never stall the cron. A 429 that survives the retry is logged at warning instead of error — it's expected throttling, not a real failure — while every other status code keeps logging at error with identical message text. The success path and the ['result' => …] return contract are unchanged.
    • Pacing. getBoxNowTransporterStatus(), on both the modern Advisable\Domains\Transporter\Jobs\GetOrdersTransferStatus and the legacy AdvGetOrdersTransferStatus, now calls usleep(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/continue path — 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-existing catch (\Exception $e) { if ($e->hasResponse()) … } shape does not hold for ConnectException (no hasResponse()), which the retry path makes marginally more likely to fire. Not a regression from this change.
  • [4.120.0] fix(admin): align VAT menu entry roles with controller RBAC (#47)

    • The eshop/vats_admin admin menu entry (application/config/admin_menu.php) only listed AUTH_ROLE_ADVISABLE and AUTH_ROLE_ADMIN, while the Adv_vats_admin controller's allowRole() check (ecommercen/eshop/controllers/Adv_vats_admin.php:22-28) already granted access to AUTH_ROLE_PRODUCTS too. PRODUCTS-role admins could reach the page directly by URL but had no visible nav link. Added AUTH_ROLE_PRODUCTS to the menu entry's roles array so visibility matches the controller's existing RBAC — no controller change, no new access granted.
  • [4.120.0] fix(cart): apply the catalogue discount to REST cart and checkout totals (#476)

    • The bug. CartTotalsCalculator returned the raw, pre-discount shop_product.price — both on the hydrated path (resolveItemPrice()) and on the repository fallback (getItemPrice()) — so discount_persent and special_discount_percent were never applied. Every consumer of that subtotal quoted the undiscounted amount: GET /rest/cart, POST /rest/checkout/totals, and the order header total in PlaceOrderService. Reproduced live: a product at price = 20.40 with discount_persent = 40.75 quoted 20.40 instead of 12.09; a checkout-totals call for qty 2 of a 17.30 product at 36.18% returned subtotal: 34.60 instead of 22.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() and CouponValidator::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_discount rule that every storefront listing, search and sort path already prices against (ecommercen/eshop/models/Adv_product_model.php): the OTHER.ENABLE_SPECIAL_DISCOUNTS registry 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 &lt; now &lt; special_to), so an instant exactly on a bound is outside; and inside an active window the swap to special_discount_percent is unconditional, so a 0% special overrides a non-zero regular discount and charges full price rather than falling through. The previous OrderBasketBuilder branch got all four of these wrong.
    • One rule, one implementation. The rule now lives once, in Advisable\Domains\Product\Pricing\DiscountResolver, returning a DiscountedPrice value object (original price, effective percent, discounted unit price, -10%-style label). Both CartTotalsCalculator and OrderBasketBuilder price 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 independent if/elseif chains. No extra query: the discount columns are base columns on the shop_product row both call sites already load. PriceCalculator and the shop_prices_view are 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.php pins 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, both ENABLE_SPECIAL_DISCOUNTS states, now-exactly-on-each-bound, and a guard that a product row missing the special columns resolves as "no window" instead of reaching BaseEntity's lazy-relation loader. CartTotalsCalculatorTest and OrderBasketBuilderTest re-assert the same semantics through their respective call sites, including both live repro figures.
  • [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 no product_code* table has a price column — ProductCode declares only id, product_id, product_code, stock, soft_deleted and active. The read evaluated to null → 0.0, so every basket row persisted by the headless REST checkout stored price, subtotal, original_price and item_points as 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_product row, which buildRow() already loads for VAT resolution — no extra query.
    • item_points gate. item_points is now also gated on POINT_SYSTEM.IS_ENABLED, read lazily through the legacy Registry (mirroring PlaceOrderService::resolveGiftPackaging()). Safety-critical: Adv_order_model sums item_points * qty into points_to_add for 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.php and OrderBasketBuilderGiftTest.php — regression coverage pinning the price to the parent product (with property_exists($productCode, 'price') === false guarding 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 real buildBasketRows() path.
  • [4.120.0] fix(rest/customer): stop country on CustomerResource from serializing as the raw internal Country entity when eager-loaded — restore the ISO alpha-2 scalar, add countryDetails (Advisable-com/ecommercen#478)

    • Why. GET /rest/customer/me?with=country (and the admin customer reads) no longer serialize the country field as the raw internal Country entity — which leaked the $repository class path and broke the native session-refresh/checkout billing-country flow. Root cause: Advisable\Rest\Customer\Resources\Customer\Resource's country BELONGS_TO relation shares its name with the shop_customer.country foreign-key column, so the relation loader overwrote the scalar alpha-2 attribute in place with the joined Country entity on the hydrated entity — country then serialized as an object exposing Advisable\Domains\Country\Country\Repository\Repository (an info-disclosure) plus raw snake_case columns (alpha_2/alpha_3/iso_cc/phone_code), instead of the documented string, nullable alpha-2 contract.
    • The change. resource() now resolves country via a new countryAlpha2() helper that recovers the scalar alpha-2 string from the loaded entity when present. The loaded Country relation is embedded separately via the existing CountryResource, under a new countryDetails object (nested ?with=country.* relations are forwarded); it is emitted for every context, not just backend, since /rest/customer/me is a self-fetch endpoint. country is 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}, and GET /rest/customer/customer/item — country is now always the ISO alpha-2 scalar (or null); countryDetails is a new field, present only when ?with=country is requested. Recorded as rest_api_versions.php 1.21.
    • Client forks. See the "Check for overrides" note below — this is a client-facing surface change (a new countryDetails field plus a corrected, previously-buggy country scalar), not purely additive.
    • No DB migration or language-key changes.
  • [4.120.0] fix(rest/product): scope the categories relation 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 through addCollectionToData($data, 'categories', Category\Collection::class): no published scope, and no default ORDER 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 treats categories[0] as the product's primary category (chip + product-detail breadcrumb), and the storefront payload never exposed published, 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 direct addCollectionToData() call for categories in addRelationToData(). It reads the loaded categories array off the entity, filters to (int)($category->published ?? 0) === 1 only when !$this->context?->isBackend(), then unconditionally usort()s by order then id — 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 a finally block, so it is fed through the existing addCollectionToData() → Category\Collection plumbing verbatim — context propagation and nested includes like ?with=categories.translations keep working unchanged — and the loaded entity is left untouched once serialization finishes. The method is protected, not private, so client forks can delegate to it (see "Check for overrides" below). The resource's OA\Property description for categories was updated to document the new scope/order contract; public/openapi.json/openapi-v1.json were not regenerated in this change.
    • Residual gap — accepted. The filter tests each category's own published flag only; it does not walk ancestors. A published category whose parent (or any ancestor) is unpublished still surfaces in the relation. This is narrower than Category\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) and Advisable\Rest\Product\Resources\Category\Resource (ProductCategoryResource) are untouched — published stays backend-only on the category payload. The standalone /rest/product/category endpoints and the product's other many-to-many relations (tags, lines, videos, events, articles) are unaffected; only ProductResource's embed of categories changes.
    • Contract change — ?sort=categories.* removed. The previously-whitelisted ?sort=categories.id|order relation sort on the product endpoints is superseded by this fixed ordering — addCategoriesToData() always re-sorts by order then id, so honouring it would advertise an ordering the serializer immediately overrides. The categories entry has therefore been removed from ListRequest::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 the categories sort.
    • REST API. GET /rest/product/product, /rest/product/product/{id}, the item endpoint, and every nested embed of the categories relation on the product resource. Recorded as rest_api_versions.php 1.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 plus order/id sort for public/customer/no-context callers; backend context keeps unpublished links but still sorts them and still exposes published; order-tie-break by ascending id; the categories key is absent when the relation isn't loaded, and serializes to [] when empty or when every link is unpublished; a non-array categories payload (string or object) delegates straight through without filtering or mutation; nested ?with=categories.translations still embeds correctly; and the entity's loaded categories property is unchanged after serialization (proving the swap-and-restore in addCategoriesToData()). New tests/Unit/Domains/Product/Product/ListRequestTest.php (4 cases) covers the relation-sort removal: categories.order and -categories.id yield no sorts at all, while productCodes.code and the root price sort still resolve.
    • Client forks. See the "Check for overrides" note below — addRelationToData()'s categories line changed from a direct addCollectionToData() call to the new protected addCategoriesToData(); a fork that copied the whole Resource class won't inherit it automatically, and a fork that overrides addRelationToData() should delegate to it.
    • No DB migration or language-key changes.
  • [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 from TagCategory to Tag (Advisable-com/ecommercen#481)

    • Why. application/config/rest_routes.php:462-467 mapped GET 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, with catId/category — properties only the Tag resource declares — left undefined on the response. The POST/PUT/DELETE rows for the same paths were already correctly mapped to Tag, 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 (guest for 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 leaf Tag resource shape instead of the TagCategory shape. Behavior correction: any caller depending on the buggy category-shaped payload from /rest/product/tag must switch to /rest/product/tag-category, or adapt to the corrected leaf-tag payload. Recorded in rest_api_versions.php.
    • Docs. docs/flows/admin/AD-15-attributes-tags.md, docs/flows/admin/AD-02-product-management-admin.md, and docs/flows/customer/CF-18-product-tags.md already documented /rest/product/tag → Tag as 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.
  • [4.120.0] fix(rest/slider): scope storefront/guest slider slides to active + audience-visible + ordered, restoring legacy getFrontMasterRecord parity (#483)

    • Bug. GET /rest/slider/slider?with=slides.translations (and the show/item single-record variants) served every hydrated slide to storefront/guest callers verbatim — including slides past their date_end, slides not yet at their date_start, and audience-targeted slides regardless of whether the caller belonged to that audience — in raw join order rather than by order. 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 into Rest\Slider\Controllers\Slider) is applied as a post-filter step in index(), show(), and item(), but only when ResourceContext::isBackend() is false: drops slides where now > date_end or now &lt; date_start, hides audience-targeted slides the caller isn't a member of (via new Advisable\Domains\Plus\Audience\Repository\Repository::getRestrictedAudienceIds()), and sorts the remainder by order ascending — reproducing legacy Adv_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 slides change, and only for non-backend consumers. Recorded as rest_api_versions.php 1.22.
    • Tests: tests/Unit/Rest/Slider/SlideVisibilityFilterTest.php — scheduling window, audience gating, and stable ordering.
  • [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, and GET /rest/product/badge/{id} (plus their locale-prefixed twins) required backend auth — a customer token got 403, no token got 401 — unlike every sibling product-enrichment read endpoint (attribute, tag, tag-category, etc.), which are all guest-readable. The Badge::class row in application/config/rest_policies.php carried only a defaults block, so PolicyResolver resolved index/show/item to the backend default. This broke the storefront's /api/product/badges facet (403 in production, reproduced 2026-07-17).
    • The change. Badge::class now opens index/show/item to guest via a methods override, matching the Attribute/Tag/TagCategory/CustomizationSchema precedent in the same section. store/update/destroy are unchanged — still backend-only (ADMIN/PRODUCTS/MEDIA). Config only: no controller, resource, or DI change.
    • Security — what widened, precisely. No new fields: BadgeResource emits id, name, badge (image path) and translations[] (badgeText/lang), and the already-guest-readable product.badge relation serializes through the same Resource class, so a guest could already receive an identical badge object via GET /rest/product?with=badge. What is new is enumeration — the standalone controller carries no storefront scope (no withMandatoryFilter()), so a guest can list every row of shop_product_badges, including badges not attached to any visible product, and ListRequest allows partial-match filtering on name, on badge (the image path) and on badgeText.{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.md documented the whole /rest/product/badge surface as backend-JWT-only; it now splits guest reads from backend writes.
    • No DB migration, OpenAPI schema, or language-key changes.
  • [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's calculateOrderVats() 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 &lt;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 $vatBuckets map (suffix => rate, e.g. 'Six' => 6.00) iterated in a loop, matching each item via a numeric-tolerant check (abs($productVat - $rate) &lt; 0.005). On a match, the loop builds the three $order property 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 $matched flag gates a new log_message('error', ...) call fired only when no bucket matched, replacing the previous silent fallthrough.
    • Added a 'Zero' => 0.00 bucket. 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 redeemPoints and 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 undocumented pointsSpend integer — redeemPoints appeared nowhere in src/. Every redeeming customer therefore got no discount, no points debit, and shop_order.points_spend / points_reward both stored 0, so admin, invoice, Klarna and Matomo consumers all read zero. Separately, the pointsSpend path 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_POINTS multiple, and subtracts the pointsToCash() money value (ESHOP.REWARD_CASH per unit) — never the point count. Both columns are written with the correct half: points_spend the point count, points_reward the cash value. Gated on POINT_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 unconfigured SPEND_POINTS fails 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 pointsSpend integer request field is removed. It was never advertised in the place-order OpenAPI 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 in Adv_loyalty::pointsToCash() that raises a PHP 8 DivisionByZeroError when SPEND_POINTS is 0. One legacy behaviour is ported verbatim on purpose: with the point system on but REWARD_CASH = 0, points are still spent and still debited while the discount is 0 — suppressing that in REST only would create the cross-channel divergence this fix exists to remove.
    • REST API. POST /rest/checkout/place-order honours redeemPoints (also accepted as redeem_points). POST /rest/checkout/totals gains the same input plus pointsSpend and pointsCash response fields. GET /rest/storefront-config gains a loyalty section (spendPoints, rewardCashPerUnit) exposing the ratio live from Registry, so a headless storefront can show what "spend my points" is worth. Recorded as rest_api_versions.php1.23.
  • [4.120.0] fix(cache): use isset() instead of class_exists() to detect an already-loaded Pscache library/model (#499)

    • Pscache::library() and Pscache::model() (application/libraries/Pscache.php) decided whether $this->ci->load->library() / load->model() still needed to run by testing class_exists(ucfirst($name)). Because application/libraries and ecommercen are Composer classmapped, merely naming the class satisfies class_exists() — the autoloader requires 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), received null and died with TypeError: call_user_func_array(): Argument #1 ($callback) must be a valid callback, first array member is not a valid class name or object. Note class_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 same TypeError. The many existing model callers work only because their controllers happen to load->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 with isset($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() calls pscache->library('cloudflare', ...) (ecommercen/settings/controllers/Adv_settings.php:849, :854) from googleRender(), which runs before defaultRender(), so nothing has attached cloudflare yet — cloudflare is 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, since renderAdminMenu() 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 the TypeError aborts before write(), the failing key never warmed, so the page stayed blank instead of self-healing.
    • Reproduced against a fresh composer install before 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 only getCacheFileName()), 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's afterAdd($id), afterEdit($id), and afterDelete($id) hooks were empty stubs — adding, editing, or deleting a VAT rate through the legacy admin left the products pscache bucket (which caches vats_model.getRecords(), see ecommercen/eshop/models/Adv_product_parser_model.php and ecommercen/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 the products clearCache group declared in ecommercen/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_admin nor Adv_product_tag_categories_admin checked slug uniqueness on save — both called the bare createSlug() helper directly. Two records whose names strip down to the same slug (e.g. "A1" and "A1+" both → a1) could silently end up sharing a tag.slug (within the same tag_cat_id) or a tag_category.slug (within the same lang, 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 resolve SlugGenerator via di()->get(SlugGenerator::class) and route every slug assignment through generateUnique() instead of createSlug():
      • 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 via SlugMasterScope('shop_product_tags', 'tag_id', ['tag_cat_id' => $tagCatId]), excluding tag_id on 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) against shop_product_tag_categories_mui, excluding tag_cat_id on edit.
      • Why a join and not one more where(). shop_product_tags_mui is id, tag_id, name, slug, content, lang — it has no tag_cat_id, which lives on the master shop_product_tags. A single-table scope condition therefore names a column the table does not have: MySQL 1054, and with db_debug off (the production setting) that surfaces as get() === false, i.e. "no collision", writing the colliding slug anyway. Advisable\Domains\Support\Slug\SlugMasterScope describes the master table, the MUI foreign key and the conditions to apply to it, so slugExists() can INNER JOIN and 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 throws SlugUniquenessCheckException when get() 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->adminLanguages loop) and every role, including the manual-slug path gated behind AUTH_ROLE_ADVISABLE.
      • $excludeColumn given with a null $excludeValue now throws InvalidArgumentException instead of building the clause. CodeIgniter rewrites a null != comparison into IS 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.slug or shop_product_tag_categories_mui.slug — deferred, see Notes below.
    • Tests. tests/Unit/Domains/Support/Slug/SlugGeneratorTest.php covers generateUnique()/slugExists() with and without a master scope, with/without the exclude-own-row parameters, the null-$excludeValue guard, 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 every where()/ON column against the real column lists parsed from database/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.
  • [4.120.0] feat(rest/product): default GET /rest/product/bundle, /bundle/item and /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. On index and item the filter[isActive] value is server-forced to 1, so a storefront-supplied filter[isActive]=0 is 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). On show an inactive bundle requested by ID now returns 404 Entity not found instead of the row, because show() 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]=0 to list inactive bundles and may still fetch an inactive bundle by ID. Only ResourceContext::SCOPE_BACKEND is exempt; both SCOPE_PUBLIC and SCOPE_CUSTOMER are scoped.
    • Implementation. New private Advisable\Rest\Product\Controllers\Bundle::enforceStorefrontBundleScope() registers the forced filter via HandlesRestfulActions::withMandatoryFilter('isActive', 1) and is called at the top of index() and item(); show() carries its own per-row is_active gate. Deliberately a context-scoped default, not a global one — admin/back-office consumers legitimately need inactive bundles. Mirrors the established Review::enforceStorefrontReviewScope() pattern; unlike Review no filter key is denied, since Bundle's allowlist has no customerId counterpart. Recorded as rest_api_versions.php 1.26.
    • No field-shape change — no fields added or removed. Only the returned data set (and the show status code for an inactive bundle) changes, and only for non-backend consumers.
    • Tests: tests/Unit/Rest/Product/Controllers/BundleScopeTest.php — forced-filter registration on index/item, replace-not-AND over a client filter[isActive]=0, backend pass-through, customer-context scoping, and the show() per-row 404.
  • [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=… and GET /{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 both shop_order.pricing_mobile and shop_customer.mobilephone via a LEFT JOIN, so guest orders (customer_id IS NULL) are included. Only the billing party is ever emitted or matched — shipping_* fields and shop_customer.sendto_mobilephone are never touched, since the delivery recipient is frequently a non-consenting third party.

  • [4.120.0] feat(settings): add a CONTACTPIGEON registry group + IP allowlist for the new endpoint (#519)

    New registry group CONTACTPIGEON (IS_ENABLED, IS_PROTECTED, TOKEN), surfaced on settings/third_party_providers. Deliberately not the XML_FEEDS group — the existing ContactPigeon product XML feed keeps its own separate flags and token, entirely unchanged. The token self-seeds with bin2hex(random_bytes(32)) on first visit to that settings page and is rotated via a "Regenerate token on save" checkbox. A new .env key, 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 /0 prefix 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. When IS_PROTECTED is 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-time siteModeGuard() (called right after maintenanceModeGuard()) also redirects, and the new endpoint matched none of siteModeAllowedControllers / siteModeAllowedNamespaces / siteModeAllowedRoutes. An operator setting GLOBAL.SITE_MODE to AdminOnly/ AdminFrontend during routine maintenance would silently 302 ContactPigeon's poller to /soon with no error status and no log — the sync just stops. Api_contactpigeon_orders::class is now added to siteModeAllowedControllers in application/config/app.php, alongside the existing Api_services::class entry, 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 whole AdvCancelIncompleteOrders cron run (Advisable-com/ecommercen#531)

    • The bug. AdvCancelIncompleteOrders fed $order->entry_datetime into DateTime::createFromFormat() at three call sites (cancelPendingDefaultCards(), cancelPendingPayByBank(), handlePendingXpayOrders()) and immediately called ->add() on the result. shop_order.entry_datetime is datetime DEFAULT NULL, so a NULL (or empty/garbage) value is structurally possible; for those, createFromFormat() returns false, and false->add() is a fatal Error in PHP 8 — aborting the entire executeCommand() 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 same false path; it needed its own handling.
    • Second hazard, same file. cancelPendingPayByBank() interpolated the raw registry value PAY_BY_BANK/EXPIRATION straight into a DateInterval spec ('PT' . $value . 'S'). The admin field behind that key (ecommercen/settings/controllers/Adv_settings.php:1363) validates with trim alone — no required, no numeric — 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 AdvCancelPendingGiftCards passed config->item('giftCardDateTimeIntervalToDrop') unvalidated into new \DateInterval(...). On this repo that key is always present (application/config/app.php:537 = PT180M); the live vector is a client fork whose application/config/app.php predates the key, making config->item() answer null and aborting that job.
    • The fix. New shared orderEntryDate($order): ?DateTime on AdvCancelIncompleteOrders parses entry_datetime and additionally inspects DateTime::getLastErrors(), treating a non-zero warning_count/error_count as unparseable too — createFromFormat() does not return false for '0000-00-00 00:00:00'; it returns a valid DateTime rolled back to -0001-11-30 with a "parsed date was invalid" warning, which a naive false-only guard would have let through and silently auto-cancelled (a year -0001 timestamp trivially clears every grace window). All three call sites now call it and return; on null — the order is skipped, not cancelled, and logged at error level with order_serial, payway, and the raw value; the batch continues to the next order. New payByBankExpirationSeconds() validates the registry value the same way xPayExpirationSeconds() already did (added in #500), falling back to a new PAY_BY_BANK_DEFAULT_EXPIRATION_SECONDS = 86400 (24h) constant. AdvCancelPendingGiftCards gets the mirror-image dateTimeIntervalToDrop() + 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/EXPIRATION registry 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 with XPAY_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) and tests/Unit/Jobs/AdvCancelPendingGiftCardsTest.php (+5, 7 total).
  • [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 than or, a second call that reached this line (only possible after a first call read an empty body) overwrote rawInputStream with the boolean result of isset() instead of testing it, so inputStream() could return true instead of the cached string body. Removed the leading assignment so the line matches CI3's own isset($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 @Deprecated docblock note, which recommended CI3's own input_stream() as the replacement — that method runs parse_str() on the body and returns a form-encoded array, not the raw string every one of this method's 18 callers needs for json_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 relations allow-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 own campaigns / messageHistory / smsMarketing / tags / audiences rows via ?with= on GET /rest/customer/me. No data reached the wire — CustomerResource::addRelationToData()'s isBackend() check already refused to serialize those five — so this closes attacker-chosen query cost as defense-in-depth, not an active data leak. country proved the failure mode is real: it was loaded AND reached the wire un-gated until #478.
    • The change. application/config/rest_policies.php gains the first production relations entry, for the Customer policy: backend allows all six declared relations (country, campaigns, messageHistory, smsMarketing, tags, audiences); customer allows only ['country']; default allows none ([]). ?with= is now gated before the relation is ever loaded, instead of relying solely on the single Resource-level isBackend() 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|audiences now has it silently stripped (the middleware only ever strips, never returns 400/403 — the caller just receives less data with no error). ?with=country is 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: RelationFilterMiddleware is not bracket-aware (naive explode(','), unlike WithParser::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-scoped translations-style relation. Recorded in rest_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.
  • [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.php echoed ten more values raw: the search term (:21, double-quoted value attribute), the assignee/creator filter &lt;option> usernames (:31, :44), the three date-filter value attributes (:73, :81, :89), and the creator/assignee usernames in the modal (:171, :175) and table row (:232, :233). All ten now wrap the value in html_escape() (bare single-argument form — these are literal-text values, not stored HTML like title/description were in #24).
    • The search term is not a plain reflected sink — it is session-persisted. ecommercen/auth/controllers/Adv_auth.php:493 captures the raw POST, :524 writes it into the tskSearch session key, :487 re-reads it on every later request, and :529 hands it to the view; it is cleared only via auth/resetTasksIndex (:473). A planted payload therefore fired on every subsequent task-list load until the admin explicitly reset the search. With csrf_protection = false (application/config/config.php:131) and no taskCsrf token 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->description in 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 &lt; 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 tskSearch is 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(): float received a null price from AdvTransporterPricing::price(): ?float and fatalled with a TypeError, taking down the entire getAvailableTransporters response — 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 new protected isOfferable(); a new protected canResolveTransportCost() client seam backs it. transportCost() resolves a null price explicitly (log + throw) before its free-shipping / overweight branch table, instead of coercing null into a float parameter. AdvTransporterPricing gains a public hasResolvablePrice(). AdvApiTransportersController::getTransportersWithPrices() now wraps each transporter in its own try/catch so 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 existing order_error + redirect('preview_order') recovery.
    • Tests. New tests/Legacy/Eshop/AdvTransportersTransportCostTest.php — first-ever test coverage of transportCost().
  • [4.120.0] fix(eshop): drop dead $isUpdate param and duplicated code coalesce in shelf codes write path (#561)

    • Adv_shelfcodes_admin::validation($isUpdate = false) never read $isUpdate in its body, so edit()'s validation(true) and add()'s validation() already behaved identically; the parameter is now dropped and both call sites just call validation(). 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/MuiWriteData files; the durable fix belongs in the generator template, which lives in a separate repo.
    • Surfaced by an opus doc-ba-proofread sweep of docs/flows/admin/AD-51-shelf-codes.md during 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. PlaceOrderService built the order total — and the amount handed to the payment gateway — from a VAT-exclusive base. $totals['subtotal'] came from CartTotalsCalculator as a net figure and was written straight to shop_order.total_vat, the column legacy defines as the grand total with VAT included (Adv_order_model::create_order()), and then passed as total: 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. OrderBasketBuilder had the same defect one level down, persisting price, original_price and subtotal as 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:

      fieldbasis
      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.total stays VAT-exclusive because legacy sets it to $cart['cart_total'], which is sum(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's final_price * $paidQty.

    • And it rounds exactly once. Legacy feeds its unrounded price_without_vat straight into applyVatWithoutFormat(), 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.99 at 33% with 24% VAT is 16.61, but 16.60 if the net is pre-rounded to 13.39. The net subtotal behind shop_order.total likewise accumulates unrounded and rounds once for the whole cart, mirroring $totalWithoutVat / round($totalWithoutVat, 2) in Adv_order_model::baseParseCartContents().

    • One resolve, both bases. The #476 Product\Pricing seam is extended rather than bolting a VAT call onto each call site — the same argument that collapsed four divergent discount resolvers into one. VatResolver owns the rate and the arithmetic (a byte-for-byte reproduction of the legacy applyVatWithoutFormat() helper); PriceResolver composes it with DiscountResolver in the fixed order; UnitPrice carries the gross figures under the unqualified names and the net ones behind an explicit net handle, so a call site cannot reach for the wrong basis by accident. CartTotalsCalculator::calculate() now returns subtotal (gross) and netSubtotal from 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 VatForOrder client-override seam.Adv_product_parser_model::setPrices() resolves its rate as $vatForOrder->vat($product->vat_value), and Adv_order_model captures the adjusted rate as vat_rate_captured; the headless path now does the same, so a fork's invoiceVat() 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 ships enableOrderVatManipulation = 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 as product_vat.

    • Two downstream bases are corrected for free. ShippingCalculator and CouponValidator are both fed this subtotal. Legacy deducts the coupon from cart_total_vat and passes that same gross figure to transfer_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, while Adv_order_model::baseParseCartContents() sums a rule-13-adjusted $paidQty that excludes the cheapest-free unit. We follow the order expression, because that is the one that has to equal the charge. Its modern form is applyGiftOutcome()'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 $paidQty into a single accumulation from the start, whereas this path sums the full quantity and subtracts a separately-rounded deduction, so shop_order.total can still land a cent out. See the note below.

    • item_points is deliberately unchanged, still computed off the net price. Which of legacy's four candidate bases applies is selected by ESHOP.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. The POINT_SYSTEM.IS_ENABLED gate 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_spend is subtracted as a raw point count where legacy subtracts the cash value it stores in points_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.php pins 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, gross save_price reconciliation, the VatForOrder policy routing (including that the adjusted rate is the one reported), and a product row with no vat relation resolving to 0% rather than tripping BaseEntity's lazy-relation loader. CartTotalsCalculatorTest adds the two-basis result, per-unit rounding and the ordering pin; OrderBasketBuilderTest re-grounds every price assertion onto the gross contract; OrderBasketBuilderGiftTest pins 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 by bcscale(2). PlaceOrderServiceTest gains the headline guard (a non-zero rate changes both the grand total and PaymentContext::$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.0 and === 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/totals and POST /rest/checkout/place-order charged 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 above TRANS_COST_LIMIT was overcharged (legacy charges €0), and a cart above WEIGHT_LIMIT was undercharged (the PRICE_PER_KG top-up was never added). ShippingCalculator now ports AdvTransporters::transportCost() branch for branch — TRANS_COST_LIMIT, TRANS_FREE_ALL, WEIGHT_LIMIT / PRICE_PER_KG including the replace-vs-add distinction over the threshold — plus AdvTransporters::deliveryCost() and its DELIVERY_COST_MIN_FREE waiver. 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 in transporters_options_pricing; ESHOP.TRANS_COST_LIMIT in 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() deducts coupon_value from cart_total_vat before calling transfer_cost_admin()). Applied in PlaceOrderService and in the /rest/checkout/totals path. 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_cost on 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 at DELIVERY_COST_MIN_FREE, written to shop_order.delivery_cost, and added to shop_order.total_vat — never to shop_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[].options serialized every row of transporters_options_pricing onto the wire as a tickable {id, name, extraCost} — so TRANS_COST_LIMIT, WEIGHT_LIMIT, PRICE_PER_KG, TRANS_FREE_ALL, DELIVERY_COST, DELIVERY_COST_MIN_FREE and MIN_ORDER_AMOUNT were 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 overweightCost and deliveryCost on the shipping and totals responses (#568)

    Display parity with the legacy AdvApiTransportersController. overweightCost is read-only and already included in cost / 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_sales MCP tool reported cost: 0 for products with no supplier cost recorded (a stored acquisition_value of 0), while margin_pct for the same product was already null — Service::marginPct() has always returned null for a non-positive cost. The payload therefore carried two contradictory readings of the same underlying fact: cost: 0 next to margin_pct: null.
    • Advisable\Domains\Order\SalesAnalytics\Service::productSales() now normalizes a non-positive acquisition_value (null, 0, or negative) to cost: null, matching what marginPct() already assumed. A negative stored value is normalized as well, so it can no longer produce a margin_pct above 100%. A positive cost is unchanged — no rounding, no type change.
  • [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_POINTS multiple. A customer whose balance converted to more cash than their cart was worth produced a negative charged total, written straight to shop_order.total_vat and 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 &lt; 100 passed).
    • 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 — PlaceOrderService refuses before any side effect (no order row, no points debit, no cart clear, no gateway call, no stock decrement). Checkout answers HTTP 422 with error code loyalty_redemption_exceeds_order_total. New exception Advisable\Domains\Checkout\Exceptions\LoyaltyRedemptionExceedsOrderTotalException.
      • Legacy storefront — Adv_order refuses 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_admin refuses on add / edit / repeat, before the debit, with an admin-visible banner.
    • Storefront UI. assets/vue/mixins/checkoutPage.js's showPointsBlocks is 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.php defines the boundary once for the legacy layer; a client fork can override it at application/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 the categories_batch_update / products_batch_update batch tools) previously accepted an over-length meta_title (>65 chars) or meta_description (>150 chars) and returned only a non-blocking warnings entry — 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_description length is now enforced at the write boundary (Advisable\Mcp\Support\ToolResult::enforceLengths(), called from the shared MergesTranslations::diffFields() seam every meta-writing tool passes through). An over-length value is REJECTED with a ToolCallException naming 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.
  • [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 stale Pending Iris gift-card orders against the bank — had three defects in one loop body. A return inside the foreach aborted 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 excludes payway = 'iris', so nothing else ever swept those rows). Second, a missing continue after cancelGiftCard() let control fall straight into acceptGiftCard(), 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 ?object and returns null when the gift-card order has no iris_orders row, but $irisOrder->irisOrderId was dereferenced unguarded; that sent orderId => null to the gateway, whose error reply resolved to an empty status, which tripped the batch-aborting return. 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, never return. 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, returning noop / cancel / accept: 'PAID' is the only status that reaches acceptGiftCard(), an empty status leaves the order Pending for the next run, and anything else cancels. Accept is an allowlist rather than a default because acceptGiftCard() is not idempotent and issuing the coupon is a money action. The dead || $orderStatus === 'PENDING' comparison was dropped here and in the sibling AdvCancelIncompleteOrders::handlePendingIrisOrders() — IrisHelper::interpretIrisResponse() never returns 'PENDING' (that branch is commented out at src/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-order return into a batch abort. A new protected irisClient(): Iris\Iris seam replaces the inline new Iris\Iris(getIrisSettings()) so the branch is unit-testable (mirrors AdvCancelIncompleteOrders::xPay()).
    • Operator-visible behaviour change. The job now logs: an error line whenever a Pending Iris gift card has no iris_orders row (needs a human — repair the row or resolve the order manually), and info lines on cancel and on each still-unresolved skip. A never-resolving Iris order stays Pending forever and produces one info line 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 both canceled_at and completed_at, which makes them vanish from the admin Completed filter and show under Canceled with a live coupon attached):
      sql
      SELECT 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) and tests/Unit/Jobs/AdvCancelIncompleteOrdersTest.php (unchanged, 18 total — only edit is the dead-code line above).
  • [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 by Adv_auth::taskCompleted() (ecommercen/auth/controllers/Adv_auth.php:425).
    • auth.page.task.tasksUnComplete.label — translated the untranslated English literal 'Uncomplete' to 'Αναίρεση ολοκλήρωσης', matching its sibling auth.page.task.tasksComplete.label (already 'Ολοκλήρωση'). Greek was the only one of the 8 language files leaving this key untranslated. Renders as the title= 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 categories array 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 own published flag 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) ManyToManyLoader silently ignored Relation::$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.
    • ManyToManyLoader now honours Relation::$scope. src/Domains/Support/Repository/RelationLoader/ManyToManyLoader.php gained the same if ($relation->scope) { call_user_func($relation->scope, $relatedRepo->getDb()); } block its three siblings already had. This is a no-op on current data: all 29 MANY_TO_MANY relations in src/ are declared without a scope, and the only relation in the tree that declares one — Article comments (src/Domains/Cms/Blog/Article/Repository/RepositoryConfigurator.php) — is ONE_TO_MANY and was already honoured. Both facts are locked down by regression tests.
    • New Relation::$visibilityScope slot, applied by every loader. A tenth, trailing, optional constructor parameter on Advisable\Domains\Support\Repository\Relation. Deliberately separate from $scope rather than an overload of it: $scope stays an always-on invariant applied unconditionally, while $visibilityScope is 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 in RelationLoader/ plus the anonymous ONE_TO_MANY override in src/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 $visibilityExemptions list. That list is threaded as an optional trailing parameter through RelationLoaderInterface::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, and ListRequest. It is never sourced from a client-controllable channel: relation $params carry values the client typed into ?with=relation[…], so carrying an auth decision there would be a privilege-escalation shape. New GenerateListRequest::exemptRelationVisibility() mirrors the existing forceFilter() pattern — the context-aware layer decides and pushes a trusted constraint down; the domain layer receives a decision, never the ResourceContext.
    • "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() under ResourceContext::isBackend() via the new Relation::VISIBILITY_EXEMPT_ALL sentinel and GenerateListRequest::exemptAllRelationVisibility(). Granting it per controller was tried and rejected: it is exactly the wiring that let admins silently receive the storefront-filtered set on nested product.categories embeds 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.php has no visibility code at all and is back to plain parent:: passthroughs. For the exemption to reach the query layer on every route, all 106 domain services that forward to BaseRepository::get() now implement ExemptsRelationVisibility and all 113 buildSpecifications() sites pass the 4th WithRelations argument; that uniform sweep closes the class of bug rather than the ~16 current instances. Seven Transporter services are deliberately excluded from the interface — their get() is a composite-PK stub that unconditionally returns null and can never load a relation — though they still forward the argument in buildSpecifications() like every other service.
    • New ExemptsRelationVisibility interface (src/Domains/Support/Service/ExemptsRelationVisibility.php). HandlesRestfulActions::show() calls ReadService::get(), which has no exemption parameter. Widening ReadService::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 hard Declaration … must be compatible with … fatal for every current implementor and for every client-fork service implementing ReadService or overriding a service's get(). A class may, however, declare extra trailing optional parameters beyond its interface, so a service implements both interfaces with one widened get(), and show() type-checks for ExemptsRelationVisibility before forwarding. ReadService itself 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 seven Transporter stubs 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 flat SELECT 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 as where_not_in('shop_product_category.id', $hidden) with the empty-array case guarded. No recursive CTE — the codebase contains zero WITH RECURSIVE and it would be a novel idiom. Semantics deliberately match Category\Service::getDescendantIds() and the legacy front's get_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 to cache.l2 needs 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 no ResourceContext, so it would otherwise land on the storefront default. src/Mcp/Tools/ProductTools.php carries an explicit VISIBILITY_EXEMPTIONS constant applied at all six READ_RELATIONS load sites via two new private helpers (readListRequest(), readProduct()) — MCP has no REST layer and therefore no ResourceContext, 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 incoming category_ids and writes the result, so a scoped read there would have silently deleted ancestor-hidden rows from shop_product_category_lp.
    • ProductResource::addCategoriesToData() reduced to ordering only. src/Rest/Product/Resources/Product/Resource.php drops the if (!$this->context?->isBackend()) published-filter block — the loader now owns visibility. The hasRelation() guard, the non-array delegation, the order-then-id usort() and the swap-and-restore try/finally all stay, so #479's "categories[0] is a stable primary category" guarantee is preserved. The method stays protected (client-fork override seam). Its OA\Property description was updated to state the ancestor rule; public/openapi.json / openapi-v1.json were not regenerated in this change.
    • Unaffected. src/Feeds/**, src/Jobs/**, Cart (fixed CART_RELATIONS, ignores ?with=), CartTotalsCalculator::PRICING_RELATIONS, CartWeightCalculator::WEIGHT_RELATIONS, the legacy ecommercen/ + application/ layers, Promotion/Coupon (its products delegates to CouponResource, not ProductResource), and Product/Media. Product\ListRequest's 'relation' => 'categories' entries are filter metadata routed to FilterByCategory subqueries, not a relation load. The three controllers that hand-roll (new $this->listRequestClass())->generate($this->input) instead of buildListRequest() — Rest/Admin/Controllers/Role.php, Rest/Order/Controllers/Order.php, Rest/Product/Controllers/Wishlist.php — were checked and deliberately left unchanged: the Order and Wishlist branches are gated on isCustomer() / !isBackend() respectively (a backend caller falls through to parent::show(), which does route through buildListRequest()), and Admin\Role uses NullRelationConfigurator and has no relations at all. No backend caller can reach a categories load through any of them.
    • REST API. GET /rest/product/product, /rest/product/product/{id}, the item endpoint, and every nested embed of the categories relation 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 $scope fix 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 the updateProduct() 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), and tests/Unit/Rest/Product/Controllers/{ProductScopeTest,NestedCategoryVisibilityTest}.php (the latter asserting a non-product controller's nested product.categories embed is filtered for storefront callers and unfiltered for backend ones). The published-filter cases in tests/Unit/Rest/Product/Resources/Product/ResourceTest.php migrate 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.
  • [4.120.0] feat(reviews): grant AUTH_ROLE_PRODUCTS review 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's product_reviews_admin child entry roles array (application/config/admin_menu.php:597), and the REST policy defaults.roles for both Review::class and CustomerReview::class (application/config/rest_policies.php) previously allowed AUTH_ROLE_MARKETING but not AUTH_ROLE_PRODUCTS to moderate reviews — despite the admin menu always filing the entry under the PRODUCTS group. All four now include AUTH_ROLE_PRODUCTS. The REST method overrides are unchanged: index/show/item stay guest (public storefront reads), store stays customer (storefront submission), update/destroy stay backend.
    • 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 resolved Review REST policy so future drift fails CI, now encodes the #59 role set.
    • Docs: docs/flows/admin/AD-52-review-moderation.md updated throughout (RBAC citations, Business Rules #7, Known Issues #5/#11, and a new Known Issue #13 on the loyalty consequence below).
  • [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/totals went straight from loading cart items to the totals calculator, skipping the gift step PlaceOrderService::placeOrder() runs before charging. Consequences: the rule-13 "cheapest free" giftDiscount was missing from the quoted total, free gift rows were missing so the quoted itemCount differed 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/totals now runs the same gift resolution placeOrder() runs and accepts giftPackaging (bool) and selectedGifts (same shape as place-order), both in camelCase and snake_case. total now equals the placement path's charged figure for the same cart and request. Behaviour change: the quoted total moves for gift-packaging carts and rule-13 gift carts.
    • Response gains four always-present fields: giftPackagingCost, giftDiscount, payableBeforePoints, and a loyalty object (pointSystemEnabled, spendPoints, rewardCashPerUnit, balance, redeemablePoints, redeemableCash, canRedeem, wouldExceed). wouldExceed is 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-contained private method and is now a thin delegation to this one implementation, shared by both endpoints.
    • Advisable\Domains\Checkout\LoyaltyRedemption gained public preview() and exceedsPayable(); the #573 refusal predicate that was inline in PlaceOrderService now routes through exceedsPayable(), so the quote's wouldExceed and the place-order 422 are the same predicate rather than two copies.
    • PlaceOrderData::normalizeSelectedGifts() promoted from private static to public static (no behaviour change) so both endpoints parse selectedGifts identically.
    • REST API: recorded in rest_api_versions.php (1.X placeholder).
  • [4.120.0] fix(settings): stop mangling and mis-keying EMAIL_SUBJECTS on language-suffix strip (#61)

    • AdvEmailViewer::editEmailSubjects() derived the registry key from each POST field name with rtrim($postKey, "_$langAbbr") behind a strpos() 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 — with it configured, submit matched and was written into the registry as a bogus EMAIL_SUBJECTS.subm row under language it.
    • Both are closed by a literal-suffix strip: a new AdvEmailViewer::stripLanguageSuffix(string $postKey, string $langAbbr): ?string returns the key with exactly _{$langAbbr} removed from the end, or null when the field doesn't end in that suffix — the caller now continues on null instead 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 on due_date vs. 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_at guard to the filter predicate, alongside the existing e.due_date guard (the #70 fix, preserved unchanged). The getter now reads: e.due_date && !e.completed_at && new Date(e.due_date).getTime() &lt; Date.now().
    • assets/admin/js/tasks/TasksBell.vue was not changed — it holds no filter logic, only rendering getUserTasksWithDueDateExpired.length (:4) and choosing mdi-bell-ring vs mdi-bell (:25-28), so the single getter fix corrects both the count and the icon.
    • No backend change was needed: completed_at already 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 on tasks (datetime DEFAULT NULL), and Adv_auth::getUserTasks() (ecommercen/auth/controllers/Adv_auth.php:477-483) json_encodes the rows untouched.
  • [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) called afterTaskDelete($taskId) instead of a dedicated hook — a copy-paste bug that made any client override of afterTaskDelete also 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 protected hooks were added to Adv_auth — afterTaskCompleted($taskId): void and afterTaskUncompleted($taskId): void (Adv_auth.php:693-701) — and taskCompleted() / taskUncompleted() now call their own hook instead of afterTaskDelete. taskDelete() is unchanged and still fires afterTaskDelete; there is no backwards-compat double-fire of the old hook from the complete/uncomplete paths.
    • taskUncompleted()'s flash message now reads the new auth.task.success.uncompleted key, 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 authenticated GraphQlClient normalising every failure to a typed Shopify\Exceptions\* (Request / Response / GraphQl / Throttled / Configuration) and credential-leak-safe (http_errors=false, the access token is never logged or surfaced), over a PaginatedQuery base doing generator-based cursor pagination with bounded throttle back-off. Entity queries: ProductQuery (+byId), CollectionQuery, VendorQuery, CustomerQuery, OrderQuery (optional $query filter for incremental re-runs), BlogQuery (blogs plus nested articles and comments, +byId) and ShopQuery — the last reading a single object rather than a connection, so it deliberately does not extend PaginatedQuery and skips the throttle back-off, being a cost-1 query run once before any paging has drained the rate-limit bucket. ClientFactory builds the client at runtime so the compiled DI container never embeds the token; src/Shopify/container.php + application/config/container/modules.php register the factory, client and queries.
    • What is read, and why those fields. Product variants carry compareAtPrice, taxable and taxCode because AdvEshop inverts Shopify's price model — shop_product.price holds the list price with the offer in discount_persent (src/Domains/Product/PriceTracking/PriceCalculator.php) — so without compareAtPrice an importer can only store the discounted figure at zero discount, silently losing the list price and the offer. ShopQuery supplies taxesIncluded / taxShipping / currencyCode, without which whether any Shopify amount already contains VAT is a guess that skews every price by the VAT rate. Collections carry seo { title description } for the shop_product_category_mui meta columns. Customer and order addresses carry both country (the display name, "Greece") and countryCodeV2 (the ISO code) plus the recipient's firstName / lastName / phone for the sendto_* shipping block. Orders carry paymentGatewayNames, the shippingLine, the full shopMoney breakdown (subtotal / tax / shipping / discounts / total / current total), per-line taxLines (the rate is per line — a fee line can sit at 0% inside an otherwise-taxed order), lifecycle timestamps (processedAt, cancelledAt, closedAt) and fulfillments.trackingInfo. Every fixed-page nested connection that can truncate exposes a signal — pageInfo { hasNextPage } on a product's collections(first: 50) and an order's lineItems(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_orders alone sees only a 60-day sliding window, and Shopify does not say so — ordersCount reports 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; confirm read_all_orders via { currentAppInstallation { accessScopes { handle } } } before calling an order migration complete. Shopify exposes no category hierarchy — a Collection has 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's settings_data.json and 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's state: DISABLED is 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. taxable says only whether a variant is taxed — the Admin API does not publish the per-product rate, and taxCode is normally null unless the store runs a tax service such as Avalara. A line item's variant/sku is null once 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 an ERROR 1406 under STRICT_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, never PlaceOrderService, or OrderEventDispatcher emails 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 available int(11) columns. A cash-on-delivery fee line becomes shop_order.delivery_cost rather than a basket row; pickup means store_id set with transport_id null. A marketplace order is a third valid shape — transport_id and store_id both null with payway bank_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 on order_serial is 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 include read_content (blogs/articles) and, for full order history, read_all_orders.
    • Tests. tests/Unit/Shopify/ drives the transport and query layers over a shared Guzzle MockHandler harness — 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.
  • [4.120.0] fix(checkout): persist PayPal Advanced tran_ticket keyed by order id, not order serial, and stop a resulting capture failure from surfacing as an opaque empty 200 (Advisable-com/ecommercen#542)

    • Why. Webrun::handlePaypalOrder() (application/controllers/Webrun.php) persisted tran_ticket for PayPal Advanced checkout orders by passing the order serial (e.g. "EV1001974") into Adv_order_model::update_order($orderId, $data), which filters WHERE id = $orderId — a numeric primary key. MySQL coerces the non-numeric serial to 0 for that comparison, so the UPDATE silently matched zero rows and tran_ticket stayed NULL. Every PayPal Advanced card payment through the legacy storefront checkout then failed at capture time: PayPalRestApi::captureOrder(null) throws a TypeError against its non-nullable string parameter, and a separate defect in the exception handler (log_helper.php, tracked separately as #543, not part of this fix) turned that TypeError into an HTTP 200 with an empty body — so the customer, having already cleared 3-D Secure, saw an opaque SyntaxError: Unexpected end of JSON input and was left believing the payment might have gone through, while the PayPal order sat APPROVED/uncaptured with no funds taken. The gift-card branch of the same method was unaffected — it already converted the serial to an id via serialToId(). Not client-specific — confirmed present on develop.
    • The change. handlePaypalOrder() (application/controllers/Webrun.php) now receives the order object its caller already fetched and persists tran_ticket keyed on $orderObj->id for the checkout branch; the gift-card branch is unchanged. paypalOrderData()'s return type is corrected to ?object (it assigns from Adv_order_model::getOrder(): ?object but was declared as non-nullable object), and both call sites — paypalAdvancedCreateOrder() and paypalAdvancedCaptureOrder() — now return a JSON 404 instead of letting an uncaught TypeError propagate when the order can't be found. paypalAdvancedCaptureOrder() also guards an empty tran_ticket before calling PayPalRestApi::captureOrder(), returning a JSON 422 instead of throwing; Adv_checkout::paypalAdvancedSuccess() (ecommercen/checkout/controllers/Adv_checkout.php) gets the equivalent guard before getOrderDetails(), routed through its existing paypalAdvancedFail() page-failure path since it renders a page rather than JSON. assets/main/js/paypal.js and assets/main/js/googlePay.js now check response.ok before parsing the PayPal Advanced create/capture responses with .json(), so a failed backend call surfaces a clear error instead of the opaque SyntaxError. googlePay.js is 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(), or Adv_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.
  • [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, the manifest.js webpack 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.js and webpack.mix.admin.js now extend the existing mix.then() hashing pass so every JS entry bundle gets a content-hashed filename (name.&lt;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. Bare name.js copies remain on disk for any direct-path consumer.
    • No REST API, DB migration, OpenAPI, or language-key changes.
  • [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 to 8, 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 to 8 when 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 via Adv_settings::videoShowcase() (ecommercen/settings/controllers/Adv_settings.php) with server-side validation requiring a positive integer. Fully backwards-compatible — the default of 8 preserves 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.
  • [4.120.0] fix(vouchers): stop misclassifying stripe, xpay, and ethniki_nbgpay as 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 answered true ("paid at delivery") for all three, even though each actually reaches PAID online (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) requires PENDING_ACCEPTED when the helper says true and PAID when it says false. For these three payways only the PENDING_ACCEPTED branch was ever evaluated -- a status they never reach -- so voucher (shipping-label) creation returned false across 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() returned SENT instead of PAID_SENT on shipment closure (5 call sites in Adv_orders_admin.php), and orderGetRevertedStatusForPayWayVoucher() returned PENDING_ACCEPTED instead of PAID on voucher cancellation (14 call sites in AdvCancelVoucher.php).
    • The change. Added stripe, xpay, and ethniki_nbgpay to 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's Adv_checkout handler write PENDING_ACCEPTED rather than PENDING" -- exactly three handlers do, _delivery(), _bank_transfer(), and paidAtStore(). That is also why bank_transfer correctly keeps answering true: it is genuinely prepaid (the shopper wires money in advance), yet lands at PENDING_ACCEPTED, and 4.65.0 put it on this list deliberately for exactly that reason ("isOrderPaidAtDeliveryByPayWay should not have bank_transfer in 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 for getCardPayWays()) with 5 new tests pinning this second, independent invariant: an equality/partition check that isOrderPaidAtDeliveryByPayWay() returns true for exactly delivery/bank_transfer/paid_at_store and false for every other allPayWays() entry; the three regressed payways specifically return false; the PENDING_ACCEPTED trio stays true; both voucher-status wrappers resolve correctly for all six payways; and canCreateVoucher() itself (invoked via reflection against the real AdvSetPendingWithVoucher class) accepts a PAID order and rejects a PENDING_ACCEPTED one for the three regressed payways, with the inverse unchanged for delivery/bank_transfer.
    • Docs. docs/flows/admin/AD-34-voucher-generation.md (the landing-status meaning of the helper, and the canCreateVoucher() override point), docs/flows/admin/AD-03-order-management-admin.md (the SENT/PAID_SENT and revert-status resolution), and docs/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.
  • [4.120.0] fix(jobs): sweep PENDING xpay, klarna_payments, and ethniki_nbgpay orders in the incomplete-order cancellation cron, add an XPay grace period, and delegate the deprecated Cronjob::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 from Adv_order_model::getDebrisOrders(), which filters WHERE payway IN (...) against getCardPayWays() (ecommercen/helpers/eshop_helper.php). Three live payways were absent from that list — xpay, klarna_payments, ethniki_nbgpay — so their PENDING orders were invisible to the cron and stayed PENDING forever: no stock restore, no loyalty-points return, no markCouponUnused() 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_payments matters specifically on the REST checkout path, where KlarnaAdapter writes PENDING by design and the fraud_status webhook is the single confirmation point — a lost webhook stranded the order with no fallback. ethniki_nbgpay was not part of the original report; it surfaced during triage of the same list.
    • The change. getCardPayWays() gains xpay, klarna_payments, and ethniki_nbgpay (17 entries total). Making the xpay branch reachable meant it also needed a grace period: handlePendingXpayOrders() now age-checks the order against a new registry-configurable window (XPAY/EXPIRATION, in seconds, default 10800 = 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_debris route, @deprecated in 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 own Iris/XPay/PayPalRestApi imports, its $xPay property, or the OrderForErpHookFireTrait/OrderCancelHookFireTrait traits — all now unused there.
    • Tests. New tests/Unit/Helpers/PayWayDebrisCoverageTest.php pins the coverage invariant directly: every payway in allPayWays() other than the three that write PENDING_ACCEPTED (delivery, bank_transfer, paid_at_store) must appear in getCardPayWays(), so a future payway cannot silently repeat this drift. tests/Unit/Jobs/AdvCancelIncompleteOrdersTest.php gained 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 — since XPAY/EXPIRATION has 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.md updated: the getDebrisOrders() 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 on getCardPayWays(), 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.
  • [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() for id, and the single createSerial() result — captured into a local before the serial UPDATE — for order_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 constructed stdClass carrying only those two properties and issues no read after trans_commit(). The four rollback paths are unchanged and still return false. create_order_admin()'s proxy path (_proccess_admin_order()) inherits the fix automatically — it is a bare pass-through to processOrder().
    • Bug fix in passing — ApcoPay. apcoPay() fetched a customer-joined order row (needed for mail/mobilephone/landphone) and then overwrote it with a now-removed re-read of a plain shop_order row — which has none of those three columns — so ApcoPay was silently sent null email/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 with json_decode(json_encode($orderData), true). json_encode() returns false on any non-UTF-8 byte sequence, and json_decode(false, true) yields null — 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 redundant getOrder() 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 after trans_commit(), the returned serial is the one actually persisted, createSerial()'s collision probe runs exactly once, all four rollback paths still return false, 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) and iris() (:3138), where (int)($orderObj->total_vat * 100) would transmit an amount of 0 to the gateway, and klarnaPayments() (:3259), where the row reaches klarnaCreateOrderSuccess(object $orderData, …) — a non-nullable typed parameter, so a stale read is a fatal TypeError rather than a degraded render. (xpay() at :3470 does 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 without causal_reads; the converted form is preserved on the spike/508-carry-forward-defence-in-depth branch.
    • 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.php change.
  • [4.120.0] fix(admin): remove deprecated, broken, orphaned Scanaccess barcode-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 in application/config/admin_menu.php, reachable only by typing /scanaccess directly. It was also broken — setup() called $this->shelfcodes_model->get_combo() (snake_case), a method that does not exist; the real method is getCombo() (ecommercen/eshop/models/Adv_shelfcodes_model.php:55) — so scanning any valid product fatalled with Call to undefined method and 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/update was an independent, still-functional, unguarded write path — Scanaccess extends Admin_c with no allowRole() call, so any logged-in admin role (regardless of assigned permissions) could change a product's shelfcode_id and price via product_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 from application/config/routes.php (including its two commented-out locale-prefixed variants). Removed the now-orphaned language key admin.label.product_not_found from all 8 locale adv_advisable_lang.php files (english, greek, german, french, italian, spanish, russian, chinese) — Scanaccess was verified to be its only consumer repo-wide.
    • Security. This closes the unguarded write path outright: /scanaccess, /scanaccess/setup, and /scanaccess/update no 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), and getCombo() all remain live and correct. Only the scanner entry point into that data is gone.
    • Docs. docs/flows/admin/AD-51-shelf-codes.md pruned: 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.
  • [4.120.0] fix(rest-auth): resolve advauth through CodeIgniter's Loader instead of autowiring it, restoring $CI->advauth on REST requests (Advisable-com/ecommercen#523)

    • Why. REST controllers extend Base_c directly and never load session/cart/advauth — only Adv_admin_controller/Adv_front_controller do (ecommercen/core/Controller.php:12-15). src/Rest/Auth/container.php previously registered \Advauth::class as a plain autowire ($services->set(\Advauth::class, \Advauth::class)), so the instance injected into AdminAuth/CustomerAuth via constructor never touched the CI super-object. A client Customer_model override calling $this->advauth->advAuthHash() / advAuthVerify() from the protected generateEncryptedValues() / checkPassword() hooks fatalled with "on null", because those hooks read $this->advauth off 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 id rest.auth.advauth ($services->set('rest.auth.advauth', \Advauth::class)->factory([AdvauthFactory::class, 'create'])); AdminAuth and CustomerAuth now wire their $auth constructor argument to that id explicitly instead of by type. The \Advauth FQCN is deliberately not a service id or alias anymore: CI's Loader resolves libraries via di()->has($className) (system/core/Loader.php:1109-1110), so keeping (or re-adding) an Advauth id would make the factory's own load->library('advauth') call re-enter this same definition, raising a Symfony ServiceCircularReferenceException at request time.
    • Tests. New tests/Integration/Auth/AdvauthDiSeamTest.php (4 cases) pins both halves of the fix directly against src/Rest/Auth/container.php's definitions: the service is built via the AdvauthFactory factory (not autowired), \Advauth has no service id or alias, AdminAuth/CustomerAuth wire $auth to rest.auth.advauth by id, and — behaviourally — resolving the service actually populates $CI->advauth. Added to the Integration suite in phpunit.xml.dist.
    • Client forks. See the "Check for overrides" note below.
    • No DB migration, new config key, or application/config/autoload.php change.
  • [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 AdvGiftCardSettings admin controller, reading/writing the GIFT_CARDS registry group directly via AdvGiftCardSettingsReader. 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 7 GIFT_CARDS keys (enabledGiftCards, freeAmount, minAmount, maxAmount, giftCards, enabledSms, orderPrefix), preserving the legacy bool casts and the giftCards array's quirky self-referential storage (regKey/regGroup both 'GIFT_CARDS'); save() persists the same 7 keys, sorting giftCards before write and applying the legacy defaults (minAmount → 0, maxAmount → 500) for omitted fields. Registry-backed key/value config, not a DB table, so — like sibling Features/StorefrontConfig — there is no Entity/Repository. \Registry isn't autowireable (same constraint as Features\FeatureRegistry), so it's resolved lazily from the CI super-object on first use rather than constructor-injected. New Validator (minAmount/maxAmount required|numeric, matching AdvGiftCardSettings::validation()) and WriteService (validate → persist → re-read) round out the write path. New Advisable\Rest\Order\Controllers\GiftCardSetting (src/Rest/Order/Controllers/GiftCardSetting.php) exposes GET/POST /rest/order/gift-card-setting as a singleton config resource (no /item, /{id}, or list/relations — same shape as Rest\Features\Controllers\Features); enabledGiftCards is only persisted when the caller holds AUTH_ROLE_ADVISABLE (resolved via ResourceContext::hasRole()), silently ignored for ADMIN/ORDERS callers, mirroring the legacy controller unsetting it before savePostData() for non-Advisable users. Registered in src/Domains/Order/container.php and src/Rest/Order/container.php; routed (locale-prefixed and bare) in application/config/rest_routes.php; RBAC in application/config/rest_policies.php (backend auth, AUTH_ROLE_ADMIN/AUTH_ROLE_ORDERS — AUTH_ROLE_ADVISABLE bypasses via superuser check, same as sibling GiftCardOrder).
    • REST API. New, purely additive endpoint — GET/POST /rest/order/gift-card-setting. Backend auth; ADVISABLE/ADMIN/ORDERS roles. Recorded as rest_api_versions.php 1.20.
    • Tests. New tests/Unit/Domains/Order/GiftCardSetting/GiftCardSettingRegistryTest.php (7 cases): the 7-key read mapping and bool casts, orderPrefix/giftCards defaults on a null/empty registry, the ENABLED-write RBAC gate (persisted for Advisable, skipped otherwise, defaulted to false when omitted), giftCards sort-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.
  • [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 under src/Rest/Product/Resources/ each already defined an addRelationToData() method, but resource() built and returned the array directly (or, for Review, returned its local $data variable) 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([...]) (Review wraps its existing $data), matching the idiom already used by Bundle/Attribute/Variation/Tag. Every newly-serialized relation also gained an OA\Property entry on its resource's #[OA\Schema] — none of these were previously documented — and public/openapi.json / public/openapi-v1.json were regenerated via php cli.php job/GenerateOpenApiJson. Affected resources and the relations each now embeds on ?with=...:
      • BundleDisplay (ProductBundleDisplayResource) → bundle
      • BundleCriteria (ProductBundleCriteriaResource) → bundle, references
      • BundlePricing (ProductBundlePricingResource) → bundle, criterion
      • BundleBuilderReference (ProductBundleBuilderReferenceResource) → bundle
      • BundleCriteriaReference (ProductBundleCriteriaReferenceResource) → criterion
      • RelatedGroup (ProductRelatedGroupResource) → translations
      • Related (ProductRelatedResource) → product, relatedProduct, group
      • Review (ProductReviewResource) → product
      • WaitingList (ProductWaitingListResource) → product
      • ProductMeta (ProductProductMetaResource) → product
      • Download (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 /download now 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 as rest_api_versions.php 1.19. Same pattern as #482 (rest_api_versions.php 1.18) and the #418 ProductWishlistResource fix (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.
  • [4.120.0] fix(product-bundle): serialize display/criteria/builderReferences/pricing relations on ProductBundleResource (Advisable-com/ecommercen#482)

    • Why. Resource::resource() (src/Rest/Product/Resources/Bundle/Resource.php) built and returned a bare array, never calling the addRelationToData() method already defined on the class. GET /rest/product/bundle/{id}?with=display,pricing,criteria returned only the base scalar keys even though the repository's relation loader had already batch-fetched display/criteria/builderReferences/pricing onto the entity — the requested relations were silently dropped.
    • The change. Wrapped the return value in $this->addRelationToData([...]), matching the idiom already used by the Attribute, Variation, and Tag sibling 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 populated display/criteria/builderReferences/pricing arrays when requested via ?with=..., where they previously returned nothing. Additive — recorded as rest_api_versions.php 1.18. Same pattern as the #418 ProductWishlistResource relation fix (rest_api_versions.php 1.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/BundleCriteria separately 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.
  • [4.120.0] docs(rest/product): document display/criteria/builderReferences/pricing relations in ProductBundleResource'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, and pricing appear on the wire via addRelationToData(), but the published OpenAPI spec stayed silent on them — a working, public relation contract (already advertised by the controller's OA\Tag x.relations) went undocumented.
    • The change. Added OA\Property array entries for display, criteria, builderReferences, and pricing to the schema, mirroring the Attribute/Variation/Tag sibling idiom already used elsewhere on the resource, and regenerated public/openapi.json / public/openapi-v1.json. Documentation-only — no behavioral, route, DI, or relation-loading change.
    • Scope. Bundle parent resource only; the leaf-family back-relations are tracked separately under #512.
    • No rest_api_versions.php entry — 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.
  • [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\Validator and Seo\DefaultMetaTag\Validator — over-rejection, the real user-facing impact. pageTitle (65), metaDescription (150/500), metaKeywords (1000), url (255), and metaTitle (255) are policy limits on TEXT/VARCHAR columns (not column caps), and crawler/browser copy budgets are counted in characters. A 150-character Greek metaDescription is ~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) &lt; 8 is a floor, so byte-counting weakened it: a 4-character Greek password is 8 bytes and passed both validateForCreate() and validatePasswordChange(). 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/couponCode are 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 both Seo validators 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). The Seo\* and Cart\Cart fixes 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 a rest_api_versions.php entry. Admin\User's password-floor fix tightens instead: a request that previously got a 2xx with a short multibyte password can now get a 422 on any of its three endpoints — the same shape of change that earned #502 its 1.16 entry, and is recorded here as rest_api_versions.php 1.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), and tests/Unit/Domains/Admin/User/ValidatorTest.php (+1 case); brand-new tests/Unit/Domains/Cart/Cart/ValidatorTest.php (28 cases) — Cart\Cart\Validator previously had no unit coverage at all.
    • Precedent. The identical fix already landed on Customer\CustomerMessageHistory\Validator under #502 (develop commit 2cab14dab, rest_api_versions.php 1.16); this is the systemic follow-up sweep across the remaining Validator.php classes.
    • No DB migration, OpenAPI schema, or language-key changes.
  • [4.120.0] fix(customer-message-history): reject invalid REST writes with 422 instead of a raw DB 500, and correct inverted OpenAPI type/messageType descriptions (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 $errors array that nothing ever populated, so POST /rest/customer/customer-message-history (and its /{id} update) accepted any payload and forwarded it straight to WriteRepository. A payload missing userId, type, or messageType reached the driver and failed on the underlying shop_customer_message_history NOT NULL constraint, surfacing as a raw HTTP 500 that also echoed the driver's error text into the response body. Separately, WriteData's two #[OA\Property] descriptions for type and messageType were swapped relative to what every writer (Adv_mailer::addEmailToCustomerHistory(), the SMS helpers) actually stores: type holds the message category/purpose (e.g. ORDER_ON_STORE), messageType holds the delivery channel (EMAIL/SMS, per the MessageChannel enum, #468).
    • The change (#502). Validator now enforces: userId/type/messageType required on create (non-empty on update, when present); userId must be a positive integer; type capped at 255 chars and messageType at 50 chars, matching the shop_customer_message_history column definitions — measured via mb_strlen(..., 'UTF-8') rather than strlen(), so the caps count characters (matching MySQL varchar(n) semantics) rather than bytes, since the table is utf8-charset. Violations throw ValidationException, which HandlesWriteActions::doStore()/doUpdate() already catch and translate into a 422 with a per-field errors map via ApiEndpointTrait::sendValidationErrors() — no controller or route change needed.
    • The change (#501). Corrected the type/messageType #[OA\Property] description: strings on WriteData to match actual write-time semantics, and regenerated public/openapi.json / public/openapi-v1.json. Documentation-only — no schema, type, or required-ness change.
    • REST API. POST /rest/customer/customer-message-history and POST /rest/customer/customer-message-history/{id} (backend-only, ADMIN+MARKETING — application/config/rest_policies.php:657) now reject a missing/invalid userId/type/messageType with a 422 errors map instead of a 500; well-formed requests are unaffected. Any caller that was (even inadvertently) relying on the old 500-on-bad-input behavior will now see a 422 instead — recorded as rest_api_versions.php 1.16.
    • Client forks. See the "Check for overrides" note below — a fork with its own Custom\ override or alias of Validator should 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-integer userId, 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.md and docs/flows/system/SY-24-email-dispatch.md updated: the message_type data-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 (restricting type/messageType to MessageChannel's exact values) remains open as #497.
    • No DB migration or language-key changes.
  • [4.120.0] feat(gift-rules): add a near-miss gift teaser (giftsNearMiss) to GET /rest/cart (Advisable-com/ecommercen#446)

    • Why. The cart payload's gifts block (#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-level giftsNearMiss array, built by CartGiftNearMissPresenter (src/Domains/Checkout/Gift/CartGiftNearMissPresenter.php) over a new legacy engine method Adv_gifts_model::getNearMissGiftsForProductsInCart() (discovered via a loosened getActiveGiftRulesForNearMiss() predicate that shares SQL with the earned path). Each row exposes only the allowlisted id, ruleId, remainingAmount, remainingQuantity, giftUserChoiceCount, choices, requirements, image, description — raw amount_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 on amount_from still reports 0.01, matching the validators' &lt;= fail semantics); rule-10 combinations report the distance for the binding (lagging) required product to the shared next gift_per_count tier. The product-code→product-id map is resolved once per cart render and shared with CartGiftPresenter (no double lookup).
    • Bug fix (earned path too). Adv_gifts_model::buildActiveGiftRulesSql() — shared by the earned-gift discovery (getActiveGiftRulesForRequiredProducts()) as well as near-miss — called createRequireVendorsSql([]) 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.php 1.15. Documented in OpenAPI via new CartGiftResource / CartGiftNearMissResource schema components referenced from the GET /rest/cart 200 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 the IN () discovery error.
    • Docs. docs/flows/customer/CF-14-gift-rules.md updated: the near-miss section, the rule-by-rule computability table, and the empty-cart / strictly-exceed behavior.
    • No DB migration or language-key changes.
  • [4.120.0] refactor(view-metrics): remove dead wipeMetricsBefore()/applyDeletionQuery() from Adv_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_metrics rows were never purged by it. Its sole caller-less helper applyDeletionQuery() existed only to support wipeMetricsBefore().
    • The change. Removed both dead methods. applyObjectWhereQuery() (still used by applyLastRecordsQuery() and applyMetricsInRangeQuery()) 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_metrics stores 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.md updated: 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.

Notes

  • [4.120.0] Regeneration warning: src/Piraeus/PireausWsdlClass.php now carries ten hand-applied #[\ReturnTypeWillChange] attributes on its ArrayAccess/Iterator/Countable methods (Advisable-com/ecommercen#521). The class is WsdlToPhp-generated and predates the ^8.1 requirement — real return types were deliberately not declared, since offsetGet(): mixed and 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, or phpunit.xml.dist's new failOnDeprecation="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 — adds is_points_awarded TINYINT(1) NOT NULL DEFAULT 0 to shop_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() or bulkSetStatus() (rather than inheriting the empty Product_reviews_admin subclass) must port the atomic claim (claimReviewPointsAward()) — not an is_points_awarded read-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 deprecated app.php:19 client_views config) while Adv_mailer read application/views/main/mail/ (via emailViews.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.md at 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_views resolves to "main" — Adv_base_controller's constructor (Adv_base_controller.php:104) overwrites it unconditionally from Template::templateFolder(), which reads mainTemplate.json:3's "templateFolder": "main"; AdvEmailViewer inherits that via Admin_c → Adv_admin_controller:29 → Base_c → Adv_base_controller:104, and the admin view reads the property through MX_Loader::__get() (application/third_party/MX/Loader.php:333-336). So the viewer resolves to application/views/main/mail/ — the same directory Adv_mailer reaches via emailViews.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 #5 citation elsewhere in the doc was unaffected, since it sits above the removed item.

  • [4.120.0] Proofread follow-up (round 2). A doc-ba-proofread pass 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 deprecated app.php:19 client_views value 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-caller previewOrder_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 at Adv_base_controller.php:104. The Configuration section and the "Two Mail Directories" table's default/mail/ row were reworded accordingly, and a one-paragraph latent-fragility note was added to the "Two Mail Directories" section: the viewer's mainTemplate.json + hardcoded /mail/ composition and Adv_mailer's whole emailViews.json::templateFolder string only agree today because "main" + "/mail" == "main/mail" — a fork changing either config independently would silently diverge, with no error raised. Last Updated was bumped to 2026-07-27 on 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) and docs/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/:181 further, 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.php is 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 the html_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 protected methods 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 raw shop_product.price. A fork that compensated for the missing discount downstream (or that overrode CartTotalsCalculator / OrderBasketBuilder to 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 with ENABLE_SPECIAL_DISCOUNTS switched 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.subtotal value returned by GET /rest/cart and POST /rest/checkout/totals changes 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_points on REST-placed orders now honours POINT_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 overrode OrderBasketBuilder, or reimplemented buildRow(), 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 new countryDetails property and corrects the country scalar:

    • Advisable\Rest\Customer\Resources\Customer\Resource (src/Rest/Customer/Resources/Customer/Resource.php) — a client fork carrying its own Custom\Rest\Customer\Resources\Customer\Resource override must reconcile: reapply the scalar-recovery fix (countryAlpha2()) and the countryDetails embed (addCountryDetails()), or its /rest/customer/me keeps leaking the raw Country entity.
    • Velora angle. velora's native/Tauri client is the caller sending ?with=country on session refresh — after this fix its scalar country reads work again with no frontend change required. Any velora code that had adapted to parse the previously-buggy raw object must switch to reading countryDetails instead.
  • [4.120.0] Check for overrides: Advisable\Rest\Product\Resources\Product\Resource::addRelationToData() changed (Advisable-com/ecommercen#479):

    • The categories line switched from addCollectionToData($data, 'categories', Category\Collection::class) to the new addCategoriesToData($data). Three fork shapes, three outcomes:
    • Copied the whole Resource class under custom/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 whatever categories call it wrote, typically the old unscoped addCollectionToData($data, 'categories', Category\Collection::class). Such a fork should now replace that line with $data = $this->addCategoriesToData($data);: the method was made protected (not private) precisely so an overriding subclass can delegate to it instead of reimplementing the scope/sort.
    • Only overrides resource() (and calls the parent's addRelationToData()) — unaffected, inherits this fix automatically.
    • Also check for a fork that re-adds categories to Product\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.
  • [4.120.0] UI Update: Velora / other headless storefronts — the product categories relation 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 — published remains backend-only on the category payload.

  • [4.120.0] Check for overrides: the /rest/product/tag* GET route repoint (Advisable-com/ecommercen#481) touches application/config/rest_routes.php, a file client repos commonly keep a local copy of:

    • A client fork with its own copy of rest_routes.php keeps the buggy TagCategory-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/tag and depending on the buggy category-shaped payload must switch to /rest/product/tag-category, or adapt to the corrected leaf-tag payload.
  • [4.120.0] Check for overrides (slider slide-scoping fix — #483): Advisable\Rest\Slider\Controllers\Slider gained a new required constructor dependency and rewrote index(), show(), and item() to post-filter the slides relation for non-backend context.

    • A client fork's Custom\Rest\Slider\Controllers\Slider must add SlideVisibilityFilter $slideVisibility to its own __construct() and forward it to parent::__construct(), and its DI wiring (container.php override, if any) must supply the new $slideVisibility arg — otherwise container compilation fails on the missing autowire.
    • If Custom\Rest\Slider\Controllers\Slider overrides index(), show(int|string $id), or item() 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 are protected, not private — $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 overrides Audience\Repository.
  • [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.php keeps the admin-gated Badge::class row after an upstream merge and must reapply the methods block (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.
  • [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). A Custom\ 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), and all() returns a new loyalty section. A fork asserting the exact payload shape, or subclassing the provider, must reconcile.

  • [4.120.0] Check for overrides: Advisable\Domains\Checkout\PlaceOrderData no longer has a pointsSpend property — it is replaced by the boolean redeemPoints. Any fork constructing PlaceOrderData directly with the pointsSpend: 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 redeemPoints to /rest/checkout/totals as well as /place-order, render the points line from the new pointsSpend / pointsCash fields instead of assuming zero, and read the redemption ratio from the new loyalty section of /rest/storefront-config rather than hardcoding it. Any client still sending the removed pointsSpend integer must switch to the boolean redeemPoints.

  • [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.php keeps the class_exists() check and does not inherit the fix — since the failure mode is an uncaught TypeError rather than a degraded result, fork maintainers with a local Pscache copy should apply the same two-line change.

  • [4.120.0] Check for overrides: afterAdd($id), afterEdit($id), afterDelete($id) in Adv_vats_admin are 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 call parent::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_model or Adv_product_tag_categories_admin / Adv_product_tag_categories_model needs to re-apply this scoped-uniqueness logic in its own controller override — call the extended Advisable\Domains\Support\Slug\SlugGenerator::generateUnique() with the new $excludeColumn / $excludeValue / $masterScope parameters — 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 plain where() on tag_cat_id cannot work, because that column is not on shop_product_tags_mui.

  • [4.120.0] Known gap — accepted for now: no unique index/migration was added on shop_product_tags_mui.slug (scoped to tag_cat_id + lang) or shop_product_tag_categories_mui.slug (scoped to lang). 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 in generateUnique() 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\WriteService and Advisable\Domains\Product\Tag\Category\WriteService share the same SlugGenerator class and the same underlying tables (shop_product_tags_mui, shop_product_tag_categories_mui) but were not updated by this fix. Their create() only calls generateMuiSlugs()/generateUnique() when the incoming slug field 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 through GeneratesSlugs::generateMuiSlugs() for both REST write services and run slug resolution on update() as well as create().

  • [4.120.0] UI Update: GET /rest/product/bundle, GET /rest/product/bundle/item and GET /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 by filter[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\Bundle gained the new scope method as private, 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\Bundle overrides the public Bundle::index(), Bundle::item() or Bundle::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()) for index()/item(), and replicate the per-row is_active gate for show().
  • [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 (the siteModeAllowedControllers entry), or ecommercen/settings/controllers/Adv_settings.php will 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_ALLOWLIST must be populated in .env (shipped commented-out in .env.example) and IS_ENABLED must be checked on settings/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's CancelIncompleteOrders override of any of these three protected methods keeps its own unguarded DateTime::createFromFormat() call and stays fully exposed to the fatal documented above. A cancelPendingPayByBank() overrider additionally keeps the raw PAY_BY_BANK/EXPIRATION interpolation and the 'PTS' hazard.
    • AdvCancelPendingGiftCards::executeCommand() (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php) — an overriding fork keeps the raw new \DateInterval($this->ci->config->item('giftCardDateTimeIntervalToDrop')) call and its config-drift hazard.
    • New overridable surface a fork should know about: AdvCancelIncompleteOrders::orderEntryDate(), ::payByBankExpirationSeconds(), and AdvCancelPendingGiftCards::dateTimeIntervalToDrop().
  • [4.120.0] No REST/API contract changed and no new config/registry key is introduced (PAY_BY_BANK/EXPIRATION and giftCardDateTimeIntervalToDrop already existed; only their handling is now defensive) — no UI 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 from phinx.php) now requires a docs/changelog/unreleased/&lt;issue>-&lt;slug>.md fragment, or CI fails with a message naming the exact path to create. A second check fails a PR that edits docs/changelog/Changelog.4.*.md or docs/changelog/Changelog.md from 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] &lt;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_CLIENT is set) — a fork never writes docs/changelog/**, it records notes in docs/client/Client.md instead.

  • [4.120.0] Implemented via one shared script, .docker/scripts/ci/check-changelog-fragment.sh, invoked by both the Bitbucket Changelog Fragment Gate step and the GitHub changelog-gate job, so the two hosts cannot drift apart. See docs/changelog/README.md for the developer-facing statement of the rule.

  • [4.120.0] Check for overrides:

    • A fork whose Custom\Rest\Customer\Controllers\Customer merely extends the upstream controller needs no action — it inherits the new allow-list automatically via PolicyResolver's get_parent_class() fallback. That fallback is gated on class_exists($controllerClass, false), but RouterDispatcher always 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 own Custom\Rest\Customer\Controllers\Customer subclass as its own top-level policy key (which wins over the parent fallback) — inherits nothing from this change and must copy the relations block across by hand. No automated check catches this. Fail-open warning: if such a copy carries only 'backend' and 'customer' and drops 'default' => [], its public scope reverts to completely unfiltered ?with= — RelationFilterMiddleware reads $relations[$scope] ?? $relations['default'] ?? null and skips all filtering when that resolves to null. 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.
  • [4.120.0] Velora angle. velora's native/Tauri client is the ?with=country caller on session refresh; it is explicitly unaffected — country was deliberately retained in the customer scope for exactly this reason.

  • [4.120.0] Check for overrides: application/views/admin/auth/tasks_list.php is 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 ten html_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 getAvailableTransporters on 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 throws TransporterPriceUnavailableException rather than quoting a wrong price, so storefront checkout and admin order creation refuse the order (existing order_error / null-serial recovery) instead of booking one at the wrong total. One error log 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, per docs/changelog/Changelog.4.112.md:26) do not inherit the new null-price handling and will still fatal / silently misprice on an available-but-unpriced transporter. Each such fork must add the same $price === null guard immediately after its transporterPricing->price() call, before its free-shipping / overweight branches. Forks that override the customTransportCost() seam to price destinations the standard pricing tables do not cover must also override the new protected AdvTransporters::canResolveTransportCost(), or those destinations will be filtered out of getAvailable() as unpriced.

  • [4.120.0] Build: the new exception class is loaded via the composer classmap (composer.json's "ecommercen" classmap entry) — composer dump-autoload is required.

  • [4.120.0] Check for overrides: Adv_shelfcodes_admin::validation() is protected and its signature changed (the unused $isUpdate parameter 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 declares validation($isUpdate = false) in its own override keeps working. No action expected, but a fork overriding validation() may want to drop the now-unused parameter too. A fork overriding validation() that itself consults $isUpdate for edit-only logic will now always receive its own default value from edit()'s call site (previously true), 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, subtotal and discount_price — now gross. discount_price is legacy's save_price, the difference of two gross figures, not the net saving;
    • shop_order_basket.product_vat — now the adjusted rate from VatForOrder::vat(), not the raw vat.value;
    • GET /rest/cart → totals.subtotal and POST /rest/checkout/totals → subtotal, total — now VAT-inclusive.
    • shop_order.total stays 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 against total_vat minus extras.
  • [4.120.0] Known divergence — shop_order.total on 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.total can differ from the legacy figure by one cent. The amount charged is unaffected — total_vat and 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 $paidQty from 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 needs CartTotalsCalculator'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 additional netSubtotal key and its subtotal is now gross; getItemPrice() keeps its signature but returns the gross unit price. A fork overriding this class must return netSubtotal or PlaceOrderService will fall back to treating subtotal as net for shop_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 additional netGiftDiscount key, and rows carry a non-persisted price_without_vat key (OrderBasketBuilder::PRICE_WITHOUT_VAT_KEY) that OrderBasket\WriteData drops. 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 overrides invoiceVat() / 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 call setInvoice() / 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/cart items now include the product's vat relation. Resolving the gross subtotal requires the VAT rate, so vat was added to the cart controller's eager-load graph — which means ProductResource's pre-existing hasRelation('vat') gate now passes and items[].productCode.product.vat is 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.subtotal from GET /rest/cart and subtotal / total from POST /rest/checkout/totals as 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/shipping and POST /rest/checkout/totals change the money figures they return for any shop with transporter options configured, and gain three fields: overweightCost (read-only, already inside cost/shippingCost — never add it to a total), deliveryCost (a separate line, already inside /totals total), and an optional payWay request field. availableTransporters[].options no longer contains the pricing-control reg_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.0 and ?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\ShippingCalculator gains two new protected client-extension seams, ports of the legacy AdvTransporters hooks — override these instead of redeclaring the calculator (the smile_v4 fork currently redeclares transportCost() 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 of AdvTransporters::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 $price is 0 (port of AdvTransporters::applyTransportSurcharge()).
  • [4.120.0] Check for overrides: ShippingCalculator's constructor-promoted properties widened from private to protected, 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 new CartWeightCalculator $weightCalculator argument (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 in src/Domains/Cart/container.php. CartTotalsCalculator is 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_pricing rows before deploying: a transporter/country with no options row fails open to 0 on 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 a Custom\ subclass has copied or overridden any of these methods, check its order_by($bucketExpr, 'ASC') call for the missing false third argument (escape=false); without it, CodeIgniter's order_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 that db_debug=false silently 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: 0 as "a real €0 acquisition cost" will now receive null and must handle it. A consumer that already treated 0 as "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 raw acquisition_value and will keep emitting cost: 0.

  • [4.120.0] UI Update: POST /rest/checkout/place-order can 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. The error.code field 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 throws LoyaltyRedemptionExceedsOrderTotalException. It extends \RuntimeException, so an existing catch (\RuntimeException) still catches it — but a fork overriding PlaceOrderService, or calling placeOrder() outside the REST controller, will map it to its own generic handling and lose the 422. A fork overriding Checkout::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 a RuntimeException subclass.

  • [4.120.0] Check for overrides: Adv_order_model gained frontOrderCostTerms(), couponValueDryRun(), payableBeforePointsForCheckout(), adminOrderGiftPackaging(), adminOrderCostTerms() and payableBeforePointsForAdminOrder(); create_order() and create_order_admin() now assemble total_vat through the shared helper and can return the existing null-serial contract on refusal. Adv_order and Adv_orders_admin are among the most-overridden classes in client forks — a fork overriding checkout(), checkoutView(), redeemOtherPostElementsPointsAdd(), redeemOtherPostElementsPointsEdit(), create_order() or create_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) and eshop.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_error session 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 render order_error will show the new key correctly. The admin path deliberately uses SESS_KEY_ESHOP_ERROR instead, 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 by enforceLengths(): void, which THROWS Mcp\Exception\ToolCallException instead of returning string[].
    • ToolResult::META_TITLE_SOFT / ToolResult::META_DESCRIPTION_SOFT — RENAMED to META_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 a warnings key.
  • [4.120.0] MCP tool contract: update_category, update_brand, update_product, update_product_content and both categories_batch_update / products_batch_update tools no longer return warnings, and now reject an over-length meta_title (>65) or meta_description (>150) instead of writing it. Inside a batch this surfaces as a per-row status: "error", not a whole-call failure.

  • [4.120.0] Check for overrides:

    • AdvCancelPendingGiftCards::cancelPendingIrisOrders() (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php) is protected. Any client fork overriding it — directly, or via application/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) and AdvCancelPendingGiftCards::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 existing new Relation(...) call site — including named-argument ones using scopableColumn: / scope: — compiles unchanged and gets the fail-closed default. A fork that subclasses Relation and 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 = []) on RelationLoaderInterface and all four RelationLoader/* classes, plus AbstractRelationLoader::loadNested() (6th), BaseRepository::loadRelations() (6th), BaseRepository::loadRelation() (9th) and BaseRepository::get() (5th). A fork with a custom loader implementing RelationLoaderInterface, or one extending AbstractRelationLoader (the pattern used upstream by Product\Variation\Repository\Repository's anonymous ONE_TO_MANY override), will fatal on an LSP incompatibility until its load() signature is widened to match — and must forward the new argument into loadNested() 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 by published — that moved to the relation loader — and is now ordering-only. A fork that copied the whole Resource class keeps #479's own-published filter, 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 the usort(). A fork that merely overrides addRelationToData() and delegates to addCategoriesToData() inherits the fix automatically.
    • Every upstream domain Service::get() gained a 5th parameter (array $visibilityExemptions = []) and a second interface (ExemptsRelationVisibility), and every buildSpecifications() now passes a 4th WithRelations argument. ReadService itself is unchanged, so a fork's own service that merely implements ReadService keeps compiling and stays fail-closed. But a fork that overrides an upstream service's get() with the old 4-parameter signature will fatal — the parent now declares 5. Once the signature is widened it must also forward $visibilityExemptions into $this->repository->get(), and any overridden buildSpecifications() must pass $listRequest->visibilityExemptions as the 4th WithRelations argument; 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 implement ExemptsRelationVisibility and 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 extend GenerateListRequest, or which overrides generate() without chaining to the parent, gets Call to undefined method ::exemptAllRelationVisibility() — a fatal on every backend request to that controller, not a silent under-report. Every upstream $listRequestClass wired across src/Rest/**/container.php was verified to extend GenerateListRequest, so upstream is safe. Note the parameter is typed object precisely 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() or index() 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 overriding get(); this one covers a fork overriding its caller. Three upstream controllers were found doing exactly this and fixed here — Slider::show(), Admin\Me::me() and Admin\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 adds protected HandlesRestfulActions::fetchOneWithVisibility(), which owns the instanceof ExemptsRelationVisibility dance in one place and falls back to the plain four-argument call (fail-closed) for a non-participating service. A fork overriding show()/index() should call fetchOneWithVisibility() 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 any src/Rest/**/Controllers/*.php that 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 categories relation is now filtered by ancestor reachability, not just the category's own published flag (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; published remains 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 covers CustomerReview::class writes (store/update/ destroy), not just Review::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-142 bulk, :209-222 single), amount from registry ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD, gated by both POINT_SYSTEM/IS_ENABLED and ECOMMERCEN_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_PRODUCTS to CustomerReview::class's REST policy (rest_policies.php:664), which carries no methods override, so the grant reaches store/update/destroy on shop_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.php and application/config/rest_policies.php both live under application/, the path a client fork overrides with its own copy (per custom/**'s sibling convention — client customizations go in custom/ or application/). A client fork carrying its own copy of either file will not pick up the AUTH_ROLE_PRODUCTS grant automatically and must add it by hand to both the menu entry's roles array and the Review/CustomerReview policy defaults.roles. A fork whose Product_reviews_admin subclass re-declares __construct() with its own allowRole() array must add AUTH_ROLE_PRODUCTS there 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-contained private method to a delegation to the new injected GiftPackagingResolver, and PlaceOrderService::__construct() gained a new final constructor argument (GiftPackagingResolver $giftPackagingResolver) — it is now the 20th required constructor parameter. A client fork that overrides PlaceOrderService, 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 into exceedsPayable() — a fork that overrode or duplicated that inline check needs review, since the canonical predicate now lives in one place rather than being copied between PlaceOrderService and the quote endpoint.

    • Advisable\Rest\Checkout\Controllers\Checkout::__construct() gained three new promoted protected constructor 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 new protected properties are also new override surface.
    • PlaceOrderData::normalizeSelectedGifts() widened from private static to public static — safe for callers in general, but a fork that redeclared a same-named private static method on a subclass now fatals: PHP rejects reducing visibility on override, and public → private is a reduction.
    • PlaceOrderService::resolveGiftPackaging() itself stays private, but the Registry reads it used to own moved out to GiftPackagingResolver. A fork that had copied (not overridden) this method to add its own gift-packaging logic now carries a second, drifting implementation of the GIFT_PACKAGING reads — exactly the quote/charge drift this issue set out to eliminate. Such a fork should retarget its copy at GiftPackagingResolver instead.
    • Advisable\Domains\Checkout\GiftPackagingResolver is a new DI service (registered in src/Domains/Checkout/container.php with $registry wired to null for lazy CI resolution) and therefore a new client-override/alias seam.
    • Test-fixture break on merge: GiftPackagingResolver::$registry is a typed ?Registry property, so the lazily-resolved $ci->registry is 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's registry really is a \Registry), but any fork's gift-packaging test fixture that supplies a duck-typed anonymous class as $ci->registry will now fail with a TypeError. This hit our own suite (tests/Unit/Checkout/PlaceOrderServiceTest.php) and had to be fixed by mocking the real \Registry class instead; forks should expect the same fixture fix.
  • [4.120.0] UI Update: /rest/checkout/totals response gained four fields (giftPackagingCost, giftDiscount, payableBeforePoints, loyalty), and its total now moves on gift-packaging and rule-13 carts to match what /place-order actually charges. itemCount is 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's loyalty.wouldExceed / loyalty.canRedeem instead of computing the redemption client-side from a hardcoded ratio, and should re-baseline any fixture or contract test pinning total or itemCount for a gift-packaged or rule-13 cart. The redemption input contract is unchanged — redeemPoints boolean only, all-or-nothing, no quantity field on either endpoint.

  • [4.120.0] No data migration ships, and existing EMAIL_SUBJECTS rows are untouched. A shop that already accumulated junk rows from the old bug (a truncated key like samp, or a bogus non-language row like subm) 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 own application/ controller keeps the buggy rtrim() call and will not inherit this fix automatically — it needs the same change applied to its copy. The new protected seam, 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's sampleCurrencyId() / sampleEmailData() already establish.

  • [4.120.0] REQUIRES npm run admin-production (or npm run all-production): this fix edits the source bundle assets/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.php in the main repo — the empty client-override stub for Adv_auth). A fork that overrode Auth::afterTaskDelete() was previously having it fire on task completions and un-completions too, in addition to deletions. After this fix it fires only on taskDelete. Such a fork must add overrides for the new Auth::afterTaskCompleted() / Auth::afterTaskUncompleted() methods to preserve its previous behaviour on completion/un-completion.

  • [4.120.0] Check for overrides: the PayPal Advanced tran_ticket order-id fix (Advisable-com/ecommercen#542) touches overridable methods in both Webrun and Adv_checkout:

    • Webrun::handlePaypalOrder() (application/controllers/Webrun.php) is overridable — a fork with its own override keeps its old serial-keyed tran_ticket persistence 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 empty 200.
    • Webrun::paypalOrderData(), Webrun::paypalAdvancedCreateOrder(), and Webrun::paypalAdvancedCaptureOrder() are each independently overridable — a fork overriding any of these keeps its own copy of, respectively, the incorrect non-nullable return type, the missing 404 on an order that can't be found, and the missing guard against an empty tran_ticket before calling PayPalRestApi::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_ticket guard before getOrderDetails(), routed through the existing paypalAdvancedFail() 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.js patch 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 new mix.then() JS hashing loop.
  • [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 hardcoded 8-video count should drop that override entirely and instead set VIDEOSHOWCASE.HOMEPAGE_LIMIT via 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) when BunnyStream::__construct()'s parameter order was reordered, and any fork copying the full method body silently kept constructing BunnyStream with the old, now-wrong argument order. Overriding only the limit via the registry key avoids that whole class of merge risk going forward.
  • [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 a function_exists() guard and is overridable in application/helpers/. A client that overrides this helper (or the whole file) keeps its own exclusion list and will still block voucher creation for stripe, xpay, and ethniki_nbgpay until its own copy adds them. In the main repo application/helpers/eshop_helper.php is a 7-byte &lt;?php stub, 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 which Adv_checkout handlers actually write PENDING_ACCEPTED.
    • orderGetSentStatusForPayWayVoucher() and orderGetRevertedStatusForPayWayVoucher() (ecommercen/helpers/eshop_helper.php:186-198) are each independently function_exists()-guarded, so a fork can shadow either wrapper on its own. Both are thin readers of isOrderPaidAtDeliveryByPayWay() 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 own SENT/PAID_SENT (or PENDING_ACCEPTED/PAID) logic, and its stripe/xpay/ethniki_nbgpay orders keep getting the wrong voucher status after this merge. The consumers sit outside this diff — ecommercen/eshop/controllers/Adv_orders_admin.php (5 sites, via orderGetSentStatusForPayWayVoucher()) and ecommercen/libraries/vouchers/AdvCancelVoucher.php (14 sites, via orderGetRevertedStatusForPayWayVoucher()) — 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 a function_exists() guard and is overridable in application/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.php is itself the client-override location. A client fork with its own Cronjob.php keeps its duplicated order_debris() switch and does not get the delegation — so it also keeps the unreachable xpay branch 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: the private methods cancelPendingPayByBank(), handlePendingIrisOrders(), handlePendingXpayOrders(), cancelPendingDefaultCards(), returnPointsToCustomers(), and xPay() — not overridable — plus the protected method handlePendingPaypalAdvancedOrders(), which is overridable; also removed: the $xPay property; the Iris/XPay/PayPalRestApi imports; and the OrderForErpHookFireTrait/OrderCancelHookFireTrait traits. A fork that subclassed Cronjob and overrode handlePendingPaypalAdvancedOrders() silently loses that override and needs a deliberate reconciliation against the new delegating order_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, default 10800 = 180 minutes) — the grace period handlePendingXpayOrders() waits before probing the Nexi API (Advisable-com/ecommercen#500). Deliberately has no admin settings field; set it directly in the registry table 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() is protected — a client-override seam. Old: the full shop_order row, fetched via getRecords(['conditions' => ['id' => $id], 'as_row' => true]). New: a constructed stdClass carrying only id and order_serial. A fork that overrides processOrder(), or that reads any column off its return value other than ->id/->order_serial, will break. Rollback behavior is unchanged — still false from all four rollback paths.
    • Audit forks for: (a) an override of processOrder(); (b) any read of a column other than ->id/->order_serial off create_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 Scanaccess barcode-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.php override, or its own /scanaccess route 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_found will have a harmless orphaned override row after this merge — runtime data only, no repo-side action required.
  • [4.120.0] Check for overrides: the advauth REST DI seam (Advisable-com/ecommercen#523) changes how the legacy advauth library is obtained — four things for a fork to check:

    • Remove any \Advauth service id or alias from a fork's own custom/Rest/**/container.php. This is the one item that can actively break a fork, but only in its alias form: aliasing \Advauth to rest.auth.advauth — or defining \Advauth with the AdvauthFactory factory — does recurse. di()->get(\Advauth::class) resolves the alias into the factory, whose own load->library('advauth') re-enters the same service while it is still being constructed, raising a Symfony ServiceCircularReferenceException at request time (CI's Loader resolves libraries via di()->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 bare new 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 forked Advauth.php, or an advauth entry added to application/config/autoload.php. These become dead code after this fix, since the Loader early-returns when $CI->advauth is 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 the rest.auth.advauth service 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 load session/cart/advauth themselves), $CI->advauth is 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 for Advauth.
  • [4.120.0] Actions for the Shopify Admin GraphQL layer:

    • New .env vars: set SHOPIFY_STORE_DOMAIN and SHOPIFY_ACCESS_TOKEN (stubs added to .env.example) for any environment that runs the Shopify import. The token needs Admin API read scopes including read_content. Without them the layer throws ShopifyConfigurationException at client build time.
    • Grant read_all_orders before migrating orders: with read_orders alone 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 on order_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 delete cache/container.php to force a recompile on deploy.
  • [4.120.0] Check for overrides: eleven src/Rest/Product/Resources/*/Resource.php resources (Advisable-com/ecommercen#512) now actually return the relations they declare:

    • Each resource's resource() built a bare array (or, for Review, returned its local $data) and never called the already-defined addRelationToData(), 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 eleven Resource::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 → bundle
      • BundleCriteria → bundle, references
      • BundlePricing → bundle, criterion
      • BundleBuilderReference → bundle
      • BundleCriteriaReference → criterion
      • RelatedGroup → translations
      • Related → product, relatedProduct, group
      • Review → product
      • WaitingList → product
      • ProductMeta → product
      • Download → 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.
  • [4.120.0] Check for overrides: ProductBundleResource::resource() (Advisable-com/ecommercen#482) now actually returns the relations it declares:

    • display, criteria, builderReferences, and pricing on GET /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-defined addRelationToData(). A client fork carrying its own override of Resource::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/BundleCriteria separately 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) used strlen($password) &lt; 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 uses mb_strlen($password, 'UTF-8') &lt; 8, so a short multibyte password that previously passed POST /rest/admin/users (create), POST /rest/admin/users/{id}/password (admin reset), or POST /rest/admin/users/change-password (self-service change) will now be rejected with a 422.
    • A client fork carrying its own Custom\Domains\Admin\User\Validator override, 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's lang is 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 $errors array that was never populated, so any payload — including one missing userId/type/messageType — passed through silently. A client fork carrying its own Custom\ override or alias of this Validator (or of WriteService, 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-integer userId, 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 a public and a protected method:

    • wipeMetricsBefore() (was public) and applyDeletionQuery() (was protected) were removed from ecommercen/view_metrics/models/Adv_view_metrics_model.php. A client fork's application/ layer could in theory have overridden either method or called wipeMetricsBefore() 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 by applyLastRecordsQuery()/applyMetricsInRangeQuery() — no action needed there.