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.124.md for this page in Markdown format</div>

Home | Changelog

Version 4 ​

version 4.124 ​

  • [4.124.0] fix(security): require an admin session with the advisable, admin or products role for every /api/variations/* endpoint; the API behind the admin variations screens previously had no authentication, so any anonymous caller could change stock, variation values, groups, masters, priorities and names. Unauthorised callers now get HTTP 401 {"error":"Unauthorized"} (Advisable-com/ecommercen#850)
  • [4.124.0] feat(rest/storefront-config): GET /rest/storefront-config gains a home section (#841) — heroSliderGroupId and heroSliderLimit, the slider group the merchant chose for an external frontend's home hero and how many of its sliders to show — set on a new admin page, Settings → External frontend home page (settings/externalHome), so a headless storefront no longer addresses its hero by a hardcoded group name
  • [4.124.0] fix(checkout): enforce the per-transporter "cash on delivery for smart points" setting (DELIVERY_OPTION) server-side — REST place-order refuses delivery with a smart-point-only transporter whose flag is off (422 payway_not_allowed_for_transporter), POST /rest/checkout/shipping publishes the verdict per transporter as codAllowed, and the legacy storefront preview and checkout refuse a crafted POST with the same rule (#338)
  • [4.124.0] feat(rest/storefront-config): GET /rest/storefront-config gains an appUpdate section — per mobile platform (android, ios) the merchant's minimum supported app version and the store listing (#840) to update from, edited in the new Settings → Mobile app page — so a headless app can hard-block an outdated build without shipping a release to move the floor
  • [4.124.0] feat(rest/product): filter[hasSellableProducts]=1 on GET /rest/product/vendor and GET /rest/product/category keeps only vendors / categories (by published subtree) with at least one sellable product (#788)
  • [4.124.0] feat(rest/order): GET /rest/order/order/{id}/tracking exposes a computed trackingUrl — the carrier's public tracking page for the order's voucher (#835)
    • What. A new nullable key on the tracking snapshot. It is non-null only when tracking can actually work: gtcode is one well-formed voucher (letters, digits, hyphens), the order status is SENT, PAID_SENT or INVOICED (the legacy customer order page's "being delivered" set), and the order's transporter — looked up regardless of active, since historical orders ride deactivated carriers — is a carrier whose tracking page takes the voucher in the URL: ACS, GT, GTV2, CENTER, BOXNOW, ELTA, SPEEDEX, EASYMAIL, DHL, DAILYCOURIER. ACSSoap, TAXYDEMA, TAXYDEMAV2, CYPRUSPOST, SKROUTZ, FIS and ASAP return a fixed landing page and give null, as do free-text or comma-separated codes.
    • Why. A headless client had trackingCode and transportId but no way to turn them into a link without hardcoding every carrier's URL template, which is exactly the list the backend already owns.
    • One map. The class_name → track-URL helper map now lives in Advisable\Domains\Transporter\TrackingUrl\Resolver; the legacy getLinkForTransferProvider() and getMinifiedLinkForTransferProvider() read it instead of carrying their own switch. Their outputs are unchanged (pinned by tests/Unit/Helpers/TransporterTrackLinkParityTest.php, which passes against both the old and the new helper).
  • [4.124.0] fix(checkout): a signed-in REST order saves the customer's checkout details onto their account, as the storefront does on every logged-in order (#837)
  • [4.124.0] fix(rest/cart): a guest cart token is no longer a permanent bearer credential — guest carts get an idle lifetime (expires_at, sliding on use, default = the storefront session lifetime), an expired token resolves to nothing on every guest path, a new guest cart always gets a server-minted token (a client could previously choose its own), and a nightly job deletes expired guest carts (#789)
  • [4.124.0] fix(seo): mark cart, checkout and customer account pages noindex
    • Adv_customer, Adv_order and Adv_checkout now set dofollow = false in defaultRender(), so every account, login, signup, password, wishlist, cart, checkout and payment-return page emits &lt;meta name="robots" content="noindex,nofollow">. Before, only some cart and checkout pages did.
  • [4.124.0] chore(seo): robots.txt crawler policy
    • Add Crawl-delay: 5 for generic crawlers (honoured by Bing and most SEO bots; ignored by Googlebot).
    • Block third-party SEO crawlers (Semrush incl. SiteAuditBot, DotBot, rogerbot, PetalBot, BLEXBot, DataForSeoBot, serpstatbot, barkrowler, MegaIndex.ru) and merge them with the existing blocked bots (Slurp, Baiduspider, YandexBot, MJ12bot, Ezooms) into one group.
    • AhrefsBot and all AI crawlers (GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot, …) stay allowed and follow the User-agent: * rules.
  • [4.124.0] fix(settings): keep the Europharmacy enable checkbox in sync with EUROPHARMACY.ENABLED
    • On Settings → Only Advisable, the Europharmacy enable checkbox always showed unchecked, even after a save had stored EUROPHARMACY.ENABLED = 1. The view passed (int) as the set_checkbox() default, and the form helper only pre-checks when the default is strictly true.
    • Because the box looked unchecked, the next save of the page (for any setting) sent no value and overwrote EUROPHARMACY.ENABLED with NULL. That silently switched off the Europharmacy jobs.
    • The view now passes a bool default, like the other checkboxes on the page. The controller saves the value cast to int, so an unticked box stores 0 instead of NULL.
  • [4.124.0] fix(storefront): stop reading the favicon fragment off storage disk on every render (#668)
    • application/views/main.php no longer calls storage()->exists('files/favicons/view.html') / storage()->get(...) per request. It now reads through a new shared service, Advisable\Storage\CachedFragmentReader::read(string $path): string, which caches the fragment's content and its absence in the shared L2 cache (cache.l2), with the expiry stored in the cached value. read() never throws: a storage error is swallowed, logged as a warning via NamedLoggerInterface on the storage-fragment channel, and the page renders without the favicon — nothing is cached on that path, so the next request retries storage instead of caching the failure.
    • Previously, an S3/R2 blip on that per-request read (e.g. a 502 on HeadObject) turned into a 500 on every storefront page (observed on bluestore, 2026-08-23).
    • CachedFragmentReader::forget(string $path): void invalidates a cached entry. AdvFavicons::save() calls it right after writing the favicon, so a new upload is reflected immediately instead of waiting out cache_l2_default_expires.
    • Caching follows cache_l2_default_expires — a value of 0 or less (e.g. -1, used in development) disables it, matching Pscache, so the fragment is re-read from storage on every request (still without ever throwing).
    • Storage::exists() / Storage::get() are unchanged for every other caller.
  • [4.124.0] fix(storage): harden the S3 client against slow or unresponsive endpoints (#668)
    • Storage::s3Filesystem() now passes a connect timeout, a request timeout and a retry count to S3Client, per disk, sourced from new optional env vars (all commented out in .env.example): FILES_S3_CONNECT_TIMEOUT / FILES_S3_TIMEOUT / FILES_S3_RETRIES (also used by the s3Demo disk), SITEMAP_S3_CONNECT_TIMEOUT / SITEMAP_S3_TIMEOUT / SITEMAP_S3_RETRIES, and PRIVATE_S3_CONNECT_TIMEOUT / PRIVATE_S3_TIMEOUT / PRIVATE_S3_RETRIES.
    • Defaults, applied in Storage.php when a var is unset, empty or non-numeric: 2s connect timeout, 0 (no limit) request timeout — deliberately unbounded, so a large upload isn't cut off — and 2 retries.
    • An explicit retries value now takes precedence over AWS_MAX_ATTEMPTS / AWS_RETRY_MODE read from the environment.
  • [4.124.0] fix(europharmacy): read the Europharmacy connection settings from the registry instead of application/config/europharmacy.php
    • SyncProductsEuropharmacy, SyncOrdersStatusEuropharmacy and PostOrdersEuropharmacy built their API client from application/config/europharmacy.php, which upstream ships with placeholder values (XXXXXXXXXXXXXXXX). The EuroPharmacyConfigToRegistry patch had already moved the settings into the registry, and Settings → Only Advisable edits them there, but the jobs were never switched over. A tenant configured through the admin therefore still called the placeholder host, and every run logged Europharmacy call error XXXXXXXXXXXXXXXX/api/Products…: cURL error 6: Could not resolve host.
    • The jobs now read EUROPHARMACY.BASE_URL, USERNAME and PASSWORD (decrypted) through the new Advisable\Europharmacy\Config::fromRegistry(). With the toggle on, an empty key throws InvalidArgumentException: Missing configuration parameter: EUROPHARMACY.&lt;KEY>.
    • While EUROPHARMACY.ENABLED is off, all three jobs return straight away with no API call. Each run writes one info line on the europharmacy channel, e.g. SyncProductsEuropharmacy skipped: EUROPHARMACY.ENABLED is off, so a job that is scheduled but disabled can be traced. At the default APP_LOG_THRESHOLD=ERROR the line is filtered out; lower the threshold to INFO to see it.
    • application/config/europharmacy.php stays in place for the one-time patch but is no longer read at runtime. It now carries a deprecation header.
  • [4.124.0] fix(eshop): cast product ids to int before MariaDB IN() lists so product_codes keeps its index range scan (#836)
    • Adv_front_controller::$productIdsToParse collected DB string values next to ints, and CI3's where_in() quotes a string value but leaves an int bare — so a mixed list compiled to IN (159319, '1409484', ...). MariaDB responds by dropping the int(11) index range and full-scanning the product_codes index (1.76M rows on a large catalogue) on every product/category/vendor/search render.
    • New Advisable\Utils\IntUniqueCollection (final, extends UniqueCollection; casts on construct, addItem and addItems) now backs productIdsToParse — wired at the single point Adv_front_controller constructs it. UniqueCollection itself is unchanged: it also holds tag slugs and 12/3 attribute-filter values elsewhere, which must not be cast to int.
    • Adv_product_model::getProductCodeImages() now normalises its $productIds argument with array_map('intval', ...) before where_in(), matching the existing hardening in getProductCodes() and getBarcodeByProductIdsAsArray(). This covers the ~30 direct callers (product variations, XML feeds, admin controllers) that never go through productIdsToParse.
    • Added tests: tests/Unit/Utils/IntUniqueCollectionTest.php, tests/Unit/Utils/UniqueCollectionTest.php (regression: UniqueCollection still returns strings unchanged — tag slugs, 12/3 attribute filters), and tests/Legacy/Eshop/AdvProductModelGetProductCodeImagesIntIdsTest.php.
  • [4.124.0] fix(docker): copy application/config/main.php into the node build stage so PurgeCSS keeps pagination and breadcrumb classes (#820)
    • application/config/main.php holds the CodeIgniter pagination tag config ($config['pager']: pagination, page-item, page-link) and the breadcrumb tag config ($config['breadcrumbs']: breadcrumb, breadcrumb-item). build/purgecss_profiles/main/_commons.js lists it as PurgeCSS content, but the node build stage (.docker/images/app.dockerfile, app_builder_node) never received it, and PurgeCSS 6.0.0 skips a missing content path silently. Every image-built storefront purged .breadcrumb, .breadcrumb-item, .page-item and .page-link from the four purged bundles (homepage.css, product.css, category_listing.css, vendor_listing.css); product.css and homepage.css also lost .pagination. Breadcrumbs rendered as a browser-default numbered list and listing pagination lost its item/link styling. Local builds and hosts serving the committed public/ui bundles were unaffected — they have the whole tree. Verified: an image build and a local npm run production from the same commit now produce identical selector sets in all four bundles.
  • [4.124.0] fix(build): fail the asset build when a PurgeCSS content path doesn't exist, instead of silently skipping it (#820)
    • build/purgecss_profiles/helpers.js gains a purgecss(options) wrapper, and the four storefront purge profiles now take purgecss from it (const { purgecss } = require('../helpers') on line 1, replacing require('@fullhuman/postcss-purgecss')). The wrapper checks every literal (non-glob) content path and fails the build if one is missing — glob patterns and { raw, extension } entries aren't checked. It fires in every Mix mode (dev, watch, production) and in the Docker image build. On failure it prints:
      PurgeCSS content path(s) not found:
      <relative/path/one>
      <relative/path/two>
      Fix or remove the path in the purge profile, or copy it into the Docker node build stage.
  • [4.124.0] feat(rest/storefront-config): GET /rest/storefront-config gains a priceTracking section — the EU Omnibus reference-price switch (enabled = PRICE_TRACK.ENABLED) and the merchant's per-language discountLabel / badgeLabel copy — so a headless storefront no longer hardcodes a legal-disclosure switch (#765)
  • [4.124.0] fix(rest/checkout): place-order quotes and charges shipping for the address the parcel goes to (shippingAddress ?? billingAddress), not the billing address — the charge now equals the /rest/checkout/shipping quote for the destination (#808)
  • [4.124.0] feat(rest/product): sort=finalPrice orders by the consumer price (shop_prices_view.final_price) and sort=availability puts sellable products before sold-out ones, both composable with any other sort key (#764, #786)
  • [4.124.0] fix(builder): close the builder.js script tag on the block-builder editor page (#815)
    • The block-builder editor page closed the builder.js script element with a second opening <script> instead of </script>. The browser read the following markup as that element's inline text up to the next </script>, so the next script element on the page was silently dropped: the sweetalert2 CDN tag when MAILCHIMP.ENABLED is on, otherwise the first script of production/production.php or production/ld_json.php.
    • Only the $isBuilderEditor branch of footer_js.php changes; storefront pages never reach it.
  • [4.124.0] fix(builder): stop the wrap-with-link toolbar action from retagging images to &lt;a> (#807)
    • The block builder's Wrap with link toolbar action retagged the selected component's tag to &lt;a>. On an image this emitted &lt;a src=…/> and the &lt;img> vanished from the rendered block.
    • Images are now handled separately: if the image's own parent is a link already holding only that image, the link modal edits it in place; if another anchor already encloses the image, the action is refused with a toast instead of corrupting the markup; otherwise the image is wrapped in a new &lt;a> (marked with a persisted advLinkWrapper component property so it can be told apart from an editor-authored link later). Clearing the URL on a marked wrapper unwraps it back to a bare &lt;img>. Non-image components keep the previous retag behaviour.
    • A load-time repair pass (assets/admin/js/builder/editor/linkWrapperNormalizer.js) now runs on GrapesJS project:load, outside undo tracking: any image previously saved as tagName: "a" is restored to &lt;img>, and its link attributes are moved onto the reusable parent link (or a new marked wrapper), or dropped if another anchor already encloses the image.
  • [4.124.0] fix(rest/checkout): a guest can place an order again — the cart is resolved by its token before the guest customer is created, and the guest's date_registered is written as the Unix timestamp the column and WriteData expect (#798)
  • [4.124.0] fix(eshop): stop the Skroutz/Facebook review-reminder cron fataling on an empty day
    • Bug. Adv_order_model::getMailsForSkroutzReview() and getMailsForFacebookReview() returned false when their query matched no rows, while the sibling getMailsForGoogleReview() already returned []. Their single consumer, sendMailForReviewToCustomers(), passes the result straight into count($records) and then foreach. Under PHP 8 count(false) is a TypeError, so the job dies with count(): Argument #1 ($value) must be of type Countable|array, bool given on any day the query returns nothing — before a single email is considered.
    • Fix. Both methods now return [] on an empty result, matching the Google variant. All three review-query methods are now array-returning; the consumer is unchanged.
    • Scope. sendMailForReviewToCustomers() is the only caller of the three methods, so no other code path depended on the false return.
  • [4.124.0] fix(orders): populate shop_order.ip_address on storefront orders
    • Bug. shop_order.ip_address VARCHAR(45) has been in the schema since database/initial/initial.sql, but no code path ever wrote it. Adv_order_model::create_order() assembles the order row without the column, so every storefront order has carried NULL for the entire life of the table — and the column's documented meaning, "Client IP at order time" (AD-03), was never true.
    • Fix. create_order() now sets ip_address from MY_Input::ip_address(), alongside entry_datetime. That override resolves CloudFlare's CF-Connecting-IP first, then the first X-Forwarded-For entry, then falls back to REMOTE_ADDR, so the value stored is the customer's address rather than the edge's. No schema change — the column is already there, already VARCHAR(45), and already IPv6-wide.
    • Scope. Storefront checkout only, and deliberately so. create_order() has exactly one caller in the codebase (Adv_order::checkout(), ecommercen/eshop/controllers/Adv_order.php:977). Admin, public, Shopflix and Skroutz orders all go through the separate create_order_admin() and keep storing NULL: an operator-created order is already attributable through the admin audit log, so recording an IP there would duplicate that record against the wrong person — the member of staff, not the customer. REST-placed orders (src/Domains/Checkout/PlaceOrderService.php) build their own order row and are likewise unaffected.
  • [4.124.0] feat(rest/transporter): GET /rest/transporter/{id}/smart-point serves the pickup-point catalog whenever the provider can fetch it — the legacy storefront's widget preference (USE_TRANSPORTER_WIDGET) no longer turns it into 409 widget_only — and gains country, near+radius and limit filters plus a per-transporter cache (#810)
  • [4.124.0] fix(rest/checkout): the payment verdict comes from status, and the modern confirmation path stops stamping is_paid (#754)
    • The defect. GET /rest/checkout/payment-status/{orderId} and both response arms of POST /rest/checkout/confirm-payment/{orderId} published shop_order.is_paid as the payment verdict. It is not one. is_paid is a reconciliation marker meaning "someone has checked this order" — stamped by the admin order action, by a reconciliation cron, and by some but not all legacy handlers. The payment lifecycle is status.
    • It was already reporting paid orders as unpaid. Deterministically for paybybank, whose PAID callback arm never calls set_is_paid(), and as a live race for vivawallet, whose webhook writes status through update_order() — which special-cases only CANCELED and leaves the flag untouched. A real order in exactly that shape (status = PAID, is_paid = 0) is on record, and the consuming app rendered "payment not completed" to a customer who had just paid.
    • The fix is a removal, not a redefinition. isPaid is gone from those responses and status — already published on all three — is the verdict. Redefining the field in place was rejected: the same name is a filter, a sort key and a resource property on the order endpoints, where it reads the real column and must keep doing so, so one published name would have meant two different things depending on which endpoint answered.
    • The writer changed too, by product-owner ruling. PaymentConfirmationService no longer writes is_paid when it confirms a payment — nothing automated should stamp a flag meaning "someone has checked this order". It still writes status and still dispatches OrderPaid.
    • Full reasoning and every rejected alternative are in docs/decisions/754-payment-verdict-from-status.md.
  • [4.124.0] feat(rest/checkout): return the payment reference on place-order, and write the PayByBank code to the column everything reads (#724)
    • The response half. POST /rest/checkout/place-order now returns transactionId — the gateway's own reference for the payment, as the adapter produced it — omitted rather than null when the adapter produced none, exactly as paymentRedirect already is. Every existing payway's response body is therefore byte-identical. For paybybank this is the bank payment code, and it is the whole product: that payway has no redirect at all, so the order lands PENDING and the customer pays by typing the code into their own banking app. Until now the code was minted, written to the order and dropped from the response, so a client had to fetch it back from GET /rest/checkout/payment-status/{orderId} — which requires a customer token, while place-order accepts a guest with only an email. A guest could place a PayByBank order and never be able to read the code that makes it payable.
    • The column half, which the issue did not describe. The REST placement path wrote the reference to shop_order.tran_ticket only, while every existing reader of a PayByBank code reads shop_order.pbb_payment_code: the admin order list renders it, the order-created email interpolates it into the sentence telling the customer what to pay with, and the REST order resource publishes it as pbbPaymentCode. Legacy has written that column since the payway existed. Measured on a live dataset of 3,134,494 orders: pbb_payment_code non-empty on 20,035 rows, tran_ticket on zero. So a REST-placed PayByBank order was invisible to all three, and support could not read the code back to a customer who lost it. Both columns are now written in one update().
    • Keyed on the payway, deliberately. pbb_payment_code is named for PayByBank and the admin list renders it under that heading, so it is not filled with a Stripe session id or a Viva order code. Every other adapter's reference still goes to tran_ticket alone.
    • Named transactionId, not paymentReference. It is the value payment-status already publishes under that name, and the property it is copied from on PaymentInitResult. A second name for one value on two endpoints of the same controller is how clients end up maintaining mapping tables.
    • Full reasoning, the rejected alternatives and the anchors are in docs/decisions/724-paybybank-payment-code-on-response.md.
  • [4.124.0] fix(eshop): guard Adv_product_model::getCatProductsPriceRange() against an empty category set (#803)
    • getCatProductsBase($catId) can return an empty array, and the method passed that straight into a query-builder where_in(), which throws InvalidArgumentException: where_in() expects $values to be a non-empty array on an empty array (CodeIgniter's system/database/DB_query_builder.php:751-752). Dropping the predicate instead — while keeping the shop_product_category_lp join — would have priced the range over the entire catalogue rather than the (empty) requested category, so the method now returns [] in that case.
    • This is hardening, not a live-crash fix: no route in this repo reaches an empty $cats today, because the category id is 404-gated upstream on both existence and published. The real exposure is that getCatProductsPriceRange() is public while its only in-repo caller is protected, so a client override calling the model directly with an unvalidated category id bypasses that controller-level gate.
    • The guard also calls $this->db->reset_query() before returning. CodeIgniter flushes accumulated query-builder state only in get() (system/database/DB_query_builder.php:1249), which the early return skips, so a caller that queues a predicate and then delegates to this method would otherwise have that predicate leak into the next query of the request. Nothing in this repo queues state before calling, so this is a no-op here and only the empty path reaches it — the SQL emitted for a non-empty category list is byte-for-byte unchanged.
    • Added test: tests/Legacy/Eshop/AdvProductModelCategoryPriceRangeGuardTest.php.
  • [4.124.0] feat(helpers): widen getExternalPayWays() to the six Phase-1 external payways (#749)
    • What changed. getExternalPayWays() (ecommercen/helpers/eshop_helper.php) now returns six keys instead of four, adding eurobank and paybybank to delivery, bank_transfer, paid_at_store and vivawallet. That helper is the pool the external-frontend payway panel in Settings → Payments offers, and the filter its save leg applies, so until now a merchant could not even persist a selection of either payway — array_intersect() dropped the key silently, with no error shown.
    • Why a legacy helper gates the REST payment surface. It is the surprising part and worth stating once: POST /rest/checkout/place-order refuses anything outside the merchant's saved METHODS.PAYWAY_EXTERNAL list as its very first statement (#673), and that list can only ever contain keys this helper permits. Adding a payway to the headless checkout therefore means editing a CodeIgniter helper.
    • The pool's stated rule changed, and older text says otherwise. The helper's docblock used to say an entry "earns its place only once that payway has been driven end to end through an external frontend", and Changelog.4.121.md (#679) describes the pool the same way. That rule is now recorded as scoped by the product owner (epic #748) rather than verified: admission is what makes a payway selectable and therefore verifiable at all, so nothing could ever satisfy the old wording — and vivawallet did not satisfy it when it was admitted either. The real acceptance run is #756, one real staging transaction per payway, and it is a removal gate: a payway that fails it comes out of the list. The released 4.121 text is left as the frozen record it is; this note is the reconciliation.
    • Nothing else moved. The storefront's own payway list (METHODS.PAYWAY) has a separate, unfiltered writer and no reader crosses between the two, so what the rendered storefront offers is provably unchanged. Both new payways were already in getCardPayWays(), so the CancelIncompleteOrders sweep already had a terminal path for each and no cancel-path work was needed. The admin view needed no edit — its available side is computed from the helper.
    • New drift guard. tests/Unit/Helpers/PayWayDebrisCoverageTest.php gains a fifth guard: every member of the external pool must either land at PENDING_ACCEPTED or be visible to getDebrisOrders(), so a future payway cannot enter the pool with no way to reach a terminal state. It checks a property of each entry, never the completeness of the list, which stays a human decision with no oracle in the code.
    • Full reasoning, the rejected alternatives, and the blast-radius sweep are in docs/decisions/749-widen-external-payways.md.
  • [4.124.0] fix(checkout/vivawallet): Config::isConfigured() requires the merchant id in ISV mode, so a deployment without one registers no vivawallet adapter instead of advertising a payway every order fails on (#806)
  • [4.124.0] fix(rest/order): stop a throwing OrderPaid listener leaking half-rendered email HTML into the JSON response (#797)
  • [4.124.0] fix(cart): buildOrderSummary() loads the order model it reads instead of inheriting it from the caller (#797)
  • [4.124.0] fix(rest/checkout): confirm Viva Wallet payments with the transaction id, and stop treating an in-progress transaction as paid (#796)
  • [4.124.0] fix(legacy): admin product search no longer 500s on a stale/invalid category filter (#799)
  • [4.124.0] fix(admin/services): stop the unpaid-services modal from claiming the eshop is locked when no lock applies
    • Bug. assets/admin/js/advisable-services/components/molecules/ServicesBarUnpaidServicesModalContent.vue renders the deadline column with only two states: v-if="shouldShowDeadline" shows the countdown, and its v-else shows services_bar.unpaid_services.deadline_passed. But showDeadline() returns false for two different reasons — the deadline has passed, or there is no deadline at all (getDeadline is '', its initial state and the value the services store getter falls back to). The "no deadline" case therefore fell through to the v-else and was rendered as "the deadline has passed".
    • Symptom. A customer whose unpaid services are all non-essential saw "Το eshop έχει κλειδωθεί, δεν δέχεστε παραγγελίες." in the services bar's unpaid-services modal, even with the due date still in the future and no lock in force. The services backend was correct throughout: /api/services/customer/services/deadline computes the deadline only from products flagged is_essential and returns an empty string when none is unpaid — exactly the input the modal misread. A second, narrower symptom shared the same root: the deadline is a single global value (the nearest essential due date plus the grace days) but was rendered on every row, so a non-essential service displayed an unrelated essential service's countdown.
    • Fix. Gate the whole deadline block behind hasDeadline && isEssential(unpaidServices), which adds the missing third state (no deadline ⇒ render nothing) and makes the column row-accurate, since the unpaid-services payload already carries is_essential per item. isEssential() coerces with Number(service.is_essential) === 1 rather than testing truthiness: CI3 serialises the int(1) column as a string, and "0" is truthy in JavaScript — a bare truthiness check would have shown the message on every non-essential row instead. The coercion also absorbs null, which is what the column holds for products saved before the flag existed.
    • Scope. The admin services bar's unpaid-services modal only. Client-side only — no PHP, no API and no contract change; the deadline endpoint and its is_essential rule are untouched. Rows for essential services behave exactly as before, countdown and expiry message included.

Notes

  • [4.124.0] Check for overrides: any integration, script or external service that calls /api/variations/* (or /{lang}/api/variations/*) without an admin session, or with an admin lacking the advisable, admin or products role, now receives 401 JSON. The admin variations screens are unchanged.
  • [4.124.0] Check for overrides: a fork that overrides AdvApiVariationsController, or defines _remap(), isAuthorisedAdminCaller(), isDispatchableMethod() or sendNotFound() in its Api_variations subclass, would bypass or alter the gate and must be reviewed. A fork that defines a method with any of those names and an incompatible signature would also hit a PHP inheritance fatal on merge.
  • [4.124.0] Check for overrides: Advisable\Domains\StorefrontConfig\StorefrontConfigProvider gained a sixth constructor argument (HomeConfigResolver $home) and a sixth all() entry; a fork that constructs the provider by hand or overrides all() must follow. Adv_settings gained externalHome(), externalHomeValidation() and the public form-validation callback externalHomeHeroGroupExists(); a fork's Settings controller defining methods of the same names shadows them.
  • [4.124.0] UI Update: REST contract note 1.91. Additive: one new object on the data envelope, home: { heroSliderGroupId: int|null, heroSliderLimit: int|null }, read from the new Registry keys HOME_EXTERNAL.HERO_SLIDESHOW_GROUP_ID / HERO_SLIDESHOW_LIMIT. heroSliderGroupId: null means render no hero — unset, or the group was deleted (the id is checked against slideshow_groups on every read). heroSliderLimit: null means no cap. Fetch the hero with GET /rest/slider/slider?filter[groupId]=…&sort=groupPriority. Admin: a new page under Settings (roles Advisable, Admin) with a select of the existing slideshow groups and an optional limit; saving an id that does not exist is refused. The rendered storefront is untouched — its home page still follows the theme's home_slideshow_groups file config, which this neither reads nor writes. Nothing is published until a merchant saves the page: every shop starts with both keys null, so an external frontend that switches to this section shows no hero until the page is filled in.
  • [4.124.0] Check for overrides: Advisable\Domains\Checkout\PlaceOrderService::__construct() gains a required CashOnDeliveryPolicy $cashOnDeliveryPolicy argument before ?Registry $registry; a fork that redeclares the constructor or instantiates the service by hand must pass it (the container autowires it). ShippingCalculator::__construct() gains a required CashOnDeliveryPolicy $cashOnDeliveryPolicy argument after SettingRepository $settingRepository (before the optional $smartPointClassNames), with the same override consequence, and calculate() rows carry a new codAllowed key. Adv_order::previewValidation() and ::checkoutValidationRules() add callback_paywayTransporterCheckValidation to the payway rule — a fork that overrides either method keeps the old, unenforced behaviour until it adds the rule. New AdvTransporters::isCashOnDeliveryAllowed().
  • [4.124.0] UI Update: new language key form_validation_paywayTransporterCheckValidation in all eight application/language/*/form_validation_lang.php (Chinese and Russian carry the English text, as their sibling COD key does). The rendered storefront already hides cash on delivery in this case, so a customer using it sees no change; the new message only appears for a POST that bypassed the storefront. Headless clients: see the rest_api_versions.php entry — do not offer cash on delivery (hide or disable it) while a transporter with codAllowed false is selected. The rule reads the existing transporters_settings rows; no migration and no new setting. The published public/openapi*.json is regenerated at the release cut.
  • [4.124.0] Check for overrides: Advisable\Domains\StorefrontConfig\StorefrontConfigProvider gained a sixth constructor argument (AppUpdateConfigResolver $appUpdate) and a sixth all() entry; a fork that constructs the provider by hand or overrides all() must follow. Adv_settings gained the mobile_app_settings() action and the view application/views/admin/settings/mobile_app_settings.php; the admin menu gained a Settings → Mobile app entry (admin.menu.general.mobileApp).
  • [4.124.0] UI Update: REST contract note 1.89. Additive: one new object on the data envelope, appUpdate: { android: { minimumVersion, storeUrl }, ios: { minimumVersion, storeUrl } }, every field string|null. The app compares its own version against minimumVersion (exactly X.Y.Z) and blocks when it is lower; the server never compares. It fails open: an unconfigured platform, a cleared field or an unparseable stored value publishes null, which means no minimum. storeUrl is an absolute https URL or null. No migration — the APP_UPDATE registry rows are written by the first save of the admin page. The legacy storefront is untouched.
  • [4.124.0] Check for overrides: Advisable\Domains\Product\Vendor\Service::filterSpecification() and Advisable\Domains\Product\Category\Service::filterSpecification() gained a sellableProducts sentinel-relation branch; a fork that overrides either method must carry it, or the key falls through to a bare Filter on the id column. SortByAvailability now reads its stock predicate from the new Advisable\Domains\Product\Product\Repository\SellableProductSql (emitted SQL unchanged); its private PRODUCTS / CODES constants are gone.
  • [4.124.0] UI Update: one new boolean filter on two list endpoints; REST contract note 1.88. 0, empty or absent applies no filter.
  • [4.124.0] Check for overrides: Advisable\Rest\Order\Controllers\Order::__construct() gained a seventh parameter, TrackingUrlResolver $trackingUrlResolver. A fork that subclasses the controller with its own constructor, or re-registers it in its container without autowiring, must pass it.
  • [4.124.0] UI Update: POST /rest/checkout/place-order now updates the signed-in customer's shop_customer row with the details the order used — the billing columns always, the sendto_* columns only when the request carries its own shippingAddress, and company_afm / company_doy / company_name / company_address / profession only on an invoice order. These are exactly the columns Adv_order::setUpCustomerData() writes for a logged-in customer; mail, password and every other column are left alone. The write happens once the order row exists and before the payment step, so it covers offline, gateway and failed-payment orders alike; a refused request writes nothing, and a failed customer write is logged and never fails the placed order. An optional address field (region, county, phone, mobile) is written only when the request carries it: an explicit empty string clears the saved value, as a blank storefront input does, while an omitted or null field leaves the stored value unchanged, so a client that leaves out a key cannot erase, for example, the mobile number used for SMS and courier contact. The sendto_* columns are written from shippingAddress whenever the request sends it, so a client must not put a store or locker address there (lockers travel in transporterExternalData), or it overwrites the customer's saved home shipping address. Guest checkout is unchanged. Request and response bodies are unchanged.
  • [4.124.0] Check for overrides: Advisable\Domains\Checkout\PlaceOrderService::__construct() gained a last parameter, ?CheckoutCustomerDetailsWriter $customerDetailsWriter = null (autowired; defaults to one built over $customerWriteService), so an existing construction keeps compiling. A fork that overrides placeOrder() does not get the new step 8b until it merges it. To change which columns are saved, alias Advisable\Domains\Checkout\CheckoutCustomerDetailsWriter rather than overriding placeOrder().
  • [4.124.0] REQUIRES php migrator.php migrate: 20260923120000_add_expires_at_to_shop_cart.php — adds shop_cart.expires_at + idx_expires_at, and gives every existing guest cart ONE FULL LIFETIME FROM DEPLOY (not an expiry derived from its age), so no shopper loses a basket to the deploy.
  • [4.124.0] New config: guest_cart_lifetime (application/config/config.php), env APP_GUEST_CART_LIFETIME, in seconds, defaulting to sess_expiration (604800 s, 7 days) — the lifetime of the legacy storefront's session-held basket. How long a basket survives is a merchant decision: too short and a returning shopper silently loses it.
  • [4.124.0] New job: Advisable\Domains\Cart\RemoveExpiredGuestCarts, scheduled 15 1 * * * in application/config/jobs.php. A fork that overrides jobs.php must add it. It is housekeeping — an expired cart is already unreachable without it. It also sweeps a guest cart with NO expiry once it has been idle a full lifetime: only pre-#789 code inserts one after the migration's backfill (a rolling deploy), and it is otherwise unreachable and never removed.
  • [4.124.0] Check for overrides: Advisable\Domains\Cart\CartService::__construct() gained a fifth argument, int $guestCartLifetimeSeconds, wired in src/Domains/Cart/container.php. getOrCreateCart() no longer inserts a new guest cart with the caller's $cartToken; claimCart() also clears expires_at. A fork that constructs the service by hand or overrides these methods must follow. RemoveExpiredGuestCarts::__construct() gained int $guestCartLifetimeSeconds, wired from the same resolved value. A fork that keeps its OWN application/config/config.php through the merge has no guest_cart_lifetime key: the container then falls back to sess_expiration, then to 604800 s (CartService::LEGACY_SESSION_LIFETIME_SECONDS), so the cart routes keep working, but add the key to set the lifetime deliberately. The lifetime is compiled into cache/container.php, so changing APP_GUEST_CART_LIFETIME needs a container rebuild.
  • [4.124.0] UI Update: REST contract note 1.85. X-Cart-Token values now expire after guest_cart_lifetime seconds of inactivity; every use of the cart extends it. An expired or unknown token behaves exactly like a missing cart: GET /rest/cart returns cart: null, item/coupon/clear routes answer 404, claim answers 404, guest place-order answers "Cart not found". POST /rest/cart/items with such a token creates a NEW cart under a NEW token — clients must persist the cartToken returned in the response (they already must for a first cart). No response shape changes.
  • [4.124.0] Client forks that customised public/robots.txt keep their own copy (or get a merge conflict); merge the new blocked-crawler group and Crawl-delay by hand.
  • [4.124.0] Adv_customer::defaultRender() and Adv_order::defaultRender() are new override points. A fork controller that overrides them must call parent::defaultRender() to keep the noindex.
  • [4.124.0] Tenants that lost the toggle. Where a later save already wiped the value, the Europharmacy jobs are off. Tick the Europharmacy toggle in Settings → Only Advisable and save to turn it back on.
  • [4.124.0] UI Update: application/views/admin/settings/only_advisable.php. The euroPharmacyEnabledset_checkbox() default is now (bool). A client copy of this view needs the same change.
  • [4.124.0] Check for overrides — fork upgrade required: a fork's own application/views/main.php may still read the favicon fragment straight off storage.
    1. Detect: grep that fork's application/views/main.php for storage()->exists('files/favicons/view.html'). If it's there, the fork is affected. A fork that renders static favicon markup (no storage() read at all) is unaffected — no change needed.
    2. Swap. Replace:
      php
      <?php if (storage()->exists('files/favicons/view.html')) : ?>
          <?= storage()->get('files/favicons/view.html'); ?>
      <?php endif; ?>
      with:
      php
      <?= di()->get(\Advisable\Storage\CachedFragmentReader::class)->read('files/favicons/view.html'); ?>
    3. No merge conflict is produced. An affected fork that skips this swap merges clean and silently keeps the old per-request disk read and the 500-on-blip behaviour — client-upstream-merge has nothing to flag, so check for this explicitly on every merge past this change.
    4. Rebuild the compiled DI container after merging — delete cache/container.php (or run the cache clear, which runs cache/delete.sh). A stale compiled container makes the new view line throw ServiceNotFoundException on every page outside development.
    5. A fork that overrides AdvFavicons::save() must add, right after its own storage()->put('files/favicons/view.html', ...):
      php
      di()->get(\Advisable\Storage\CachedFragmentReader::class)->forget('files/favicons/view.html');
      Skipping this means a newly uploaded favicon won't show until the cache expires (up to cache_l2_default_expires, 1 day by default).
    6. A fork with its own application/config/storage.php still gets the new S3 timeout/retry defaults — they live in Storage.php, not in the config file. To make them configurable per-disk, copy the connect_timeout / timeout / retries keys from the upstream config.
  • [4.124.0] Tenants that go quiet. Where the patch ran while the config file still held the placeholder base URL, it wrote only EUROPHARMACY.ENABLED = 0. On those tenants the Europharmacy jobs now do nothing, instead of failing against the placeholder host. To turn the integration on, enter the base URL, username and password in Settings → Only Advisable and tick the Europharmacy toggle.
  • [4.124.0] The registry wins over the config file. A client fork whose own application/config/europharmacy.php holds real credentials that differ from the registry now connects with the registry values. Check EUROPHARMACY.* in Settings → Only Advisable before deploying.
  • [4.124.0] Check for overrides: SyncProductsEuropharmacy::initializeConfig(), SyncOrdersStatusEuropharmacy::initializeConfig(), PostOrdersEuropharmacy::initializeConfig() — the body now reads the registry. A child override keeps its own config source.
  • [4.124.0] Check for overrides: SyncProductsEuropharmacy::executeCommand(), SyncOrdersStatusEuropharmacy::executeCommand(), PostOrdersEuropharmacy::executeCommand() — the EUROPHARMACY.ENABLED gate is its first statement. A child override that doesn't call parent::executeCommand() doesn't get the gate.
  • [4.124.0] Check for overrides: SyncProductsEuropharmacy::logger(), SyncOrdersStatusEuropharmacy::logger(), PostOrdersEuropharmacy::logger() and the $logger property — both are new on the parents (protected ?Advisable\Logger\NamedLoggerInterface $logger, protected function logger(): NamedLoggerInterface). A child that already declares either with a different type or visibility fails when the class loads.
  • [4.124.0] Check for overrides:
    • productIdsToParse: swept all 36 local forks — none reassigns Adv_front_controller::$productIdsToParse, so every fork picks up the fix as-is. (Gea's Related_product_model::$productIdsToParse is a separate, model-local, add-only property that is never read back — out of scope and unaffected either way.)
    • getProductCodeImages(): 3 of 36 forks override it wholesale with a raw-SQL replacement instead of delegating to parent:: — Blustore, Edructer, Megastore (application/modules/eshop/models/Product_model.php). None of the three needs a backport: each already does its own array_map('intval', $productIds) before building the IN (...) list, so they were never exposed to the mixed-type bug this fixes.
  • [4.124.0] Pscache key churn: six call sites pass productIdsToParse->getItems() straight into Pscache, which hashes serialize($arguments) — Adv_front_controller.php (two getAttributesValuesGroupsComboForProductIds calls plus one getVariationGroupsWithValues call) and the filterCriteoProducts() Criteo filter in Adv_product_categories, Adv_vendors and Adv_search. Expect one cold cache miss per key right after deploy, then a stable (better) hit rate than before — keep this in mind when reading post-deploy DB load, it's expected and self-resolving.
  • [4.124.0] Non-numeric ids (e.g. a misconfigured Google Recommendations id-prefix strip in Adv_home.php / Adv_products.php) now cast to 0 and match no row, instead of reaching the query as an invalid, unquoted SQL fragment.
  • [4.124.0] Rebuild to pick it up. Image-deployed tenants get the restored breadcrumb/pagination styling only once a new image is built from this release. Nothing to do otherwise.
  • [4.124.0] Check for overrides: client purge profiles. A client fork whose build/purgecss_profiles/main/*.js lists a literal content path that doesn't exist in its tree will get a failing asset build (dev and production, locally and in the image) after merging this release. The error above names each path. Fix: correct or remove the path in that profile. Shapes seen across client forks at the time of writing:
    • a view file listed in _commons.js that no longer exists, which fails all four purged bundles;
    • a view file listed in homepage.js that no longer exists;
    • category_list.js entries missing the .php extension, and sometimes the application/views/ prefix — these already skip their files silently today, so that bundle is purging wrongly now.
  • [4.124.0] Profiles taking the upstream line 1 get the check. A client profile file that keeps its own require('@fullhuman/postcss-purgecss') on line 1 keeps today's silent skip.
  • [4.124.0] Client forks should confirm that, after the merge, the node stage of their .docker/images/app.dockerfile contains the COPY, placed after the application/views COPY. This matters especially if their node stage has diverged from upstream, or if they resolved a conflict there in favour of their own version:
    COPY --chown=${USER_ID}:${GROUP_ID} application/config/main.php ./application/config/main.php
    Otherwise their image build now fails with the error above naming application/config/main.php, rather than silently shipping unstyled breadcrumbs.
  • [4.124.0] Known limitation, not fixed here: the ten Vue/JS glob entries in _commons.js currently match no files (tracked as #821). That is why globs are not checked.
  • [4.124.0] Check for overrides: Advisable\Domains\StorefrontConfig\StorefrontConfigProvider gained a fifth constructor argument (PriceTrackingConfigResolver $priceTracking) and a fifth all() entry; a fork that constructs the provider by hand or overrides all() must follow. Advisable\Domains\Product\PriceTracking\Options::fromRegistry() now delegates to the new Options::fromRegistryInstance(Registry $registry, array $adminLanguages), which is the one reader of the PRICE_TRACK group — a fork that re-reads those keys elsewhere should project from it instead.
  • [4.124.0] UI Update: REST contract note 1.81. Additive: one new object on the data envelope, priceTracking: { enabled: bool, discountLabel: {&lt;lang>: string}, badgeLabel: {&lt;lang>: string} }. enabled fails closed (an unconfigured shop publishes false) and is independent of the four ENABLED_GRAPH* keys, which gate the price-history graph widget and are not published. TRACK_DAYS and SHOW_DISCOUNT_ON_VALUE are deliberately not published either — GET /rest/product/price-tracking/graph already applies both. A label with no row for a language is ""; the client keeps its own copy for that case. The names differ from the rendered storefront's and the admin's app context, which carry the same copy as productChartSettings.discountLabel / discountBadgeLabel, each a string already resolved to the page language: here the badge copy is badgeLabel, and discountLabel is a {&lt;lang>: string} map, not a string. The legacy storefront is untouched.
  • [4.124.0] Check for overrides: Advisable\Domains\Checkout\PlaceOrderService::placeOrder() now resolves $shippingAddr once, before the ShippingCalculator::calculate() call, and hands it the destination triple; the later // 7. Build order data block reuses that variable instead of resolving its own. A fork that overrides placeOrder() and still passes $data->billingAddress to the calculator keeps the bug.
  • [4.124.0] UI Update: none — no REST surface change, so no rest_api_versions.php entry. Behavioural change only: an order whose shipping address sits in a different pricing region from its billing address is now charged the destination's transporter price (measured on wecare 4.123.0.2: GT to Crete with an Attica billing address was charged 4.00 and is now charged 5.00 — the figure the quote endpoint already returned). Same-address orders are unaffected; collect-at-store is unaffected, the calculator zeroes it from the transporter side either way. Legacy has always priced from the destination (Adv_order_model::orderAdminSetUpPricingDefaults()), so this aligns REST with the rendered storefront.
  • [4.124.0] Check for overrides: Advisable\Domains\Product\Product\Service::sortSpecification() gained two sentinel-relation branches (finalPrice, availability) ahead of columnSortSpecification(); a fork that overrides the method must carry them or the keys fall through to a bare ORDER BY on an unjoined table.
  • [4.124.0] UI Update: two new sort keys on GET /rest/product/product — finalPrice / -finalPrice and availability / -availability; REST contract note 1.84. sort=price still orders the raw column and is not what a shopper sees.
  • [4.124.0] Check for overrides: application/views/main/components/footer/footer_js.php is a per-fork copy. At the 4.124.0 cut, 24 of 39 client forks still carry the buggy builder.js'); ?>"><script> line, 23 of them in a copy that has diverged from upstream. After the upstream merge, confirm the line reads builder.js'); ?>"></script> in each fork.
  • [4.124.0] REQUIRES npm run all-production: two admin-bundle source changes need the bundles rebuilt to take effect (this release commits the rebuilt public/ui/admin/dist/; a fork that builds its own bundles must rebuild):
    • #807 — assets/admin/js/builder/editor/commands.js, editor.js, linkWrapperNormalizer.js, and the el/en locale files (block-builder Wrap with link).
    • Unpaid-services modal — ServicesBarUnpaidServicesModalContent.vue, compiled into public/ui/admin/dist/advisable-services.js.
  • [4.124.0] UI Update: the Wrap with link toolbar action on an image no longer breaks the image. It now edits the image's existing wrapping link in place, refuses (with an error toast) when the image is already inside another link, or wraps the image in a new link otherwise; an empty URL unwraps a link it created. The load-time repair only fixes blocks as they are opened in the editor — already-published pages stay broken until each affected block is reopened and re-published, since the storefront renders the block's stored html.
  • [4.124.0] Check for overrides: Advisable\Domains\Checkout\PlaceOrderService::placeOrder() now validates the cart (resolveCart($data->customerId, $cartToken)) before resolveGuestCustomer() runs, where it used to run after and hand the calculator the freshly minted guest id. A fork that overrides placeOrder() and keeps the old order keeps both symptoms: resolveCart() treats a non-null customer id as conclusive, so a guest's cart-token branch was unreachable and every guest order ended in "Cart not found or does not match the provided cart ID."; and before that, WriteData::__construct() threw on the datetime string written into date_registered, so the call was a 500 on every payway.
  • [4.124.0] UI Update: no REST surface change. Two behavioural consequences worth knowing: (1) a guest whose cart token matches no cart is refused without a customer row being written first — the refusal is the same message as before; (2) precedence between the two guest refusals is now "no cart" before "bad email / registered account" — a request with no valid cart cannot become an order whatever its identity says. Signed-in callers are unchanged: their id still wins over a stale X-Cart-Token, and a test pins it.
  • [4.124.0] This is a latent fatal, not a new one: reviewForFacebook and reviewForSkroutz only crash on a day with zero eligible customers. A shop whose Skroutz referral tracking has gone quiet hits it every run; a shop with steady volume can go months without seeing it, then fail the first empty day.
  • [4.124.0] Check for overrides: v4-wecare (application/modules/eshop/models/Order_model.php) overrides getMailsForFacebookReview() and getMailsForSkroutzReview() with its own group_byed copies that also return false. Being an extends override, it never reaches the parent, so this fix does not reach that fork and produces no merge conflict to surface it — the same two returns must be changed there. Its getMailsForGoogleReview() override already returns [].
  • [4.124.0] The fatal masks, rather than causes, an empty result. A reviewForSkroutz run that suddenly starts failing daily means shop_order.skroutz_referer has stopped being written; the cron's window is one day wide, so it stays empty until referral capture is restored.
  • [4.124.0] Shops now retain a customer IP per storefront order where previously they retained none. That is personal data under GDPR, so it is worth a line in the shop's privacy policy and an explicit retention decision — the column has no expiry or anonymisation of its own.
  • [4.124.0] A client fork carrying its own create_order() override will keep writing NULL, and there is no conflict at merge time to surface that. Anything built on this column must treat NULL as "unknown", never as a distinct address.
  • [4.124.0] The IP is only as trustworthy as the edge. MY_Input::ip_address() trusts the first X-Forwarded-For value unconditionally and application/config/config.php's proxy_ips is empty, so on a deployment where traffic can reach PHP without passing CloudFlare the header is client-forgeable. Fine for forensics; do not build a security control on the value without confirming the edge strips or overwrites that header.
  • [4.124.0] Check for overrides: Advisable\Domains\Transporter\SmartPointCatalog\Service::fetch() gained a second parameter (?CatalogFilter $filter = null) and a fourth constructor argument (?CacheAdapterInterface $cache = null, wired to cache.l2); the SmartPointsDisabledException::REASON_WIDGET_ONLY constant and branch are removed. A fork that overrides fetch() or constructs the Service by hand must follow both signatures; a fork that catches widget_only by name will never see it again.
  • [4.124.0] UI Update: REST contract note 1.82. 409 now carries only no_provider / not_enabled_for_provider; a BOX NOW transporter with the widget preference on answers 200 with its full catalog. New optional query keys on the same route — country (ISO 3166-1 alpha-2), near (lat,lng) with radius (metres, default 25000, ignored without near; results distance-ascending), limit; an unparseable value answers 400 invalid_query_params naming the key. The response item shape is unchanged. The catalog is cached per transporter for 10 minutes after normalisation, so a filtered read never costs an upstream call of its own; a cache backend failure falls through to a live fetch. The legacy storefront is untouched — it never called this service.
  • [4.124.0] UI Update: any headless client reading isPaid from those two endpoints must switch to the status test — an order is settled when status is PAID or PENDING_ACCEPTED, and awaiting payment when it is PENDING. ⚠️ PENDING_ACCEPTED is a terminal state, not an intermediate one: the three offline payways (delivery, bank_transfer, paid_at_store) settle there at placement and never reach PAID, so a client that treats only PAID as settled renders every cash-on-delivery order as unpaid forever. A client that reads the field defensively (raw.isPaid ?? false) will now silently read false for every order, so this is a required change rather than an optional one.
  • [4.124.0] Intended consequence, stated so it is not mistaken for a regression: a REST-confirmed order now sits at is_paid = 0 permanently and therefore drops out of admin's filter[isPaid] and isPaid sort. That is correct — nobody has checked it. Do not restore the write to bring those orders back into the filter.
  • [4.124.0] A knowingly accepted asymmetry: the 18 legacy set_is_paid() call sites are deliberately untouched, so a legacy-confirmed order carries is_paid = 1 while a REST-confirmed one carries 0, for the same payment outcome. Changing 18 handlers on the platform's only live payment surface is a separate change with its own regression risk. If it needs resolving it is its own issue against those handlers, not a reversal of this one.
  • [4.124.0] AdvEmailPaypalCanceledAndIsPaidToAdmin will report fewer orders, since fewer will be CANCELEDand is_paid. It is an anomaly report, so a smaller result set is the correct outcome — worth confirming it still runs rather than assuming.
  • [4.124.0] Check for overrides: Advisable\Domains\Checkout\PaymentConfirmationService::confirmPayment() — no signature change, but a fork that overrides it and re-adds the is_paid write reinstates the defect.
  • [4.124.0] Check for overrides: Advisable\Domains\Checkout\PlaceOrderResult::__construct() gained a fifth parameter, ?string $transactionId = null. It is nullable with a default and sits last, so an existing fork constructing it keeps compiling unchanged; only a fork that has reordered or re-signatured the constructor needs a look. The one upstream construction site is Advisable\Domains\Checkout\PlaceOrderService::placeOrder().
  • [4.124.0] What is NOT fixed here, so it is not read as done: GET /rest/checkout/payment-methods still reports requiresRedirect: false for both the offline payways and paybybank, which behave completely differently after placement — the offline three land PENDING_ACCEPTED and are complete, paybybank lands PENDING and is unpaid. Telling those apart from the picker alone is #732's, which owns that payload. A client must not treat requiresRedirect: false as "nothing further to do".
  • [4.124.0] Check for overrides: Product_model::getCatProductsPriceRange() (application/modules/eshop/models/Product_model.php, today an empty passthrough — class Product_model extends Adv_product_model {}) is the fork seam. Two override shapes matter, and only the first needs manual work:
    1. Wholesale replacement of the body. Such a fork does not receive this guard on the next upstream sync — the override replaces the upstream method body entirely, so the empty-array guard added here is silently absent from it. There is no signature change and no merge conflict to surface this: the break stays latent (no route currently reaches it unguarded) until something calls the override with an unvalidated category id, at which point it throws exactly as described above. Such a fork should backport the guard manually.
    2. Delegation to parent:: after queueing query-builder state (e.g. applying its own WHERE before the call). This shape inherits both the guard and the reset_query() that goes with it, so it needs no backport — but note that on the empty path the queued predicate is now discarded rather than executed, which is the point: before this change it was neither, and would have leaked into whatever query ran next. A fork relying on that predicate surviving the call would be relying on a bug. Swept the twelve client checkouts available locally: exactly one overrides this method, and it is shape 2 — it applies an availability WHERE and then calls parent::getCatProductsPriceRange(...) rather than replacing the body. No manual backport needed there.
  • [4.124.0] UI Update: ecommercen/** is the shared lane, so every fork inherits the widened pool on its next upstream merge — but nothing changes for a deployment until its own admin saves the new selection. Both enforcement points read that deployment's own METHODS.PAYWAY_EXTERNAL, which no merge touches. A fork that merges this and expects Eurobank or Pay by Bank to appear for its headless client has one step left: Settings → Payments → the external-frontend panel, move the payway across, save. The gateway's own credentials must also be configured, or the payway is omitted from GET /rest/checkout/payment-methods even after being selected.
  • [4.124.0] Known gap carried, not introduced: both new payways are advertised by GET /rest/checkout/payment-methods with their raw key as label, because PAYMENT_METHOD_LABELS covers nine of the nineteen payways PaymentInitializerFactory registers (allPayWays() lists twenty; proxypay is kept only so historical orders resolve a label and has no adapter). Issue #732 owns that field's fate — complete the missing labels or drop it so clients localise by key — so it is deliberately not patched here.
  • [4.124.0] UI Update: on an ISV Viva deployment (is_isv => true in viva_wallet*.php) whose VIVAWALLET.MERCHANT_ID is empty, GET /rest/checkout/payment-methods stops listing vivawallet and POST /rest/checkout/place-order refuses it with the existing 422 payway_not_available — where before it was listed and every placement failed at Viva with a 400 after the order row, cart clear and points debit had already happened. ISV is the mode a deployment inherits by default — this repo's own application/config/viva_wallet.php and viva_wallet_dev.php ship is_isv => true — so any client that has not shipped its own config file is in ISV mode, staging and production alike, and is subject to this gate. Non-ISV deployments (a client config file with is_isv => false) are unaffected: the OAuth pair alone still configures them. To restore the payway on an ISV deployment, fill VIVAWALLET.MERCHANT_ID (admin: Payment settings → Viva Wallet → Merchant ID) with the sub-merchant id the ISV credentials are authorised for — on the sandbox, the demo sub-merchant id, not the live one.
  • [4.124.0] Check for overrides: Advisable\VivaWallet\Config is a value object, not a container service, so there is no Custom\ override of isConfigured() — the fork-visible seam is the registry reader chain (getVivaWalletSettings() → getVivaWalletMergedSettings() → getVivaWalletExternalSettings(), ecommercen/helpers/registry_helper.php), which forks are known to shadow. Two cases to check: a shadowed reader that omits is_isv is treated as non-ISV and silently keeps the old, defective behaviour (the new gate is a no-op); a shadowed reader that supplies is_isv truthy but sources the merchant id from a key other than merchant_id silently loses the payway.
  • [4.124.0] Merge conflict resolution: rest_api_versions.php is append-only and every in-flight REST delivery adds an entry (#794). After resolving, keep the changelog array strictly ascending by version (a naive keep-both can yield 1.75, 1.78, 1.76, 1.77) and regenerate public/api-versions.json from the PHP registry (composer openapi-json, then restore the release-owned openapi*.json) rather than hand-splicing — the manifest is generated from that array, so the PHP side is the only source of truth.
  • [4.124.0] UI Update: POST /rest/checkout/place-order and POST /rest/checkout/confirm-payment/{orderId} return a body that parses as JSON again. Both were prefixing it with several kilobytes of half-rendered order-confirmation email HTML under Content-Type: application/json — a delivery order answered 4163 bytes of which the JSON was 71. Every REST order on an immediately-settling payway (delivery, bank_transfer, paid_at_store) was affected, plus every confirmed gateway order. ofetch/destr do not throw on this — they hand back the raw string — so a headless client saw response.orderId === undefined rather than an error. If you built a workaround that strips everything before the first {, remove it.
  • [4.124.0] Check for overrides: buildOrderSummary() (ecommercen/helpers/cart_helper.php) now loads eshop/order_model itself. A fork that redefines the helper inherits the bug and needs the same one-line addition.
  • [4.124.0] Deployment note: because the email render was throwing, no order settled through the REST API has ever sent its customer order-confirmation email. It does now. A staging deployment pointed at production SMTP credentials will start mailing real customers — check the mailer configuration before deploying.
  • [4.124.0] UI Update: POST /rest/checkout/confirm-payment/{orderId} now accepts an optional JSON body {"transactionId": "&lt;uuid>"}, and a vivawallet order cannot be confirmed without it. Viva appends the transaction id to the return URL as the t query parameter — a headless client must forward it verbatim. Every other payway ignores the body, and a request without one behaves exactly as before. Headless clients that take Viva payments must be updated; the rendered storefront is unaffected, since it settles through Adv_checkout::vivaWalletResponse() and the AdvViva webhook, neither of which changed.
  • [4.124.0] Check for overrides:
    • Product_model::fixWhereInCondition() — a fork that has overridden this method keeps the empty-array-into-where_in() throw and should adopt the same guard: skip a value that is an empty array.
    • Product_model::fixAdminSearch() — a fork that has overridden this method keeps both the throw and the "unresolvable category silently returns the unfiltered catalogue" behaviour in its own categories block, and should adopt the same compute-then-branch fix: resolve the category ids first; when the result is empty, emit neither the shop_product_category_lp join nor a where_in, and force an empty result set instead.
    • Reporting_model::fixWhereInCondition() — a fork that has overridden this method keeps the same empty-array throw, reachable through a whitespace-only barcode textarea in the Special Products report; it should adopt the same empty-array skip.
  • [4.124.0] UI Update: a row for a non-essential unpaid service now renders no deadline column at all, where it previously always rendered one (wrongly, as either the lock message or another service's countdown). Nothing else in the row changes.
  • [4.124.0] Merge conflict resolution (assets/admin/js/advisable-services/components/molecules/ServicesBarUnpaidServicesModalContent.vue): the upstream change wraps the two existing service-deadline divs in a &lt;template v-if="hasDeadline && isEssential(unpaidServices)"> and adds the hasDeadline computed plus the isEssential() method. A fork that customised either div should keep its own markup inside the new wrapper rather than dropping the wrapper. Two constraints matter and a resolution that breaks either reintroduces the bug:
    • the hasDeadline half of the guard must be kept, otherwise an empty deadline still falls through to the "eshop locked" branch — that is the bug itself;
    • isEssential() must keep the Number(...) === 1 coercion; a fork that "simplifies" it to service.is_essential reintroduces the row-level half of the bug, because the value arrives as the string "0".
  • [4.124.0] A fork that renders the deadline from its own per-row source instead of the global getDeadline getter can skip this hunk entirely — the bug is specific to the global-value-on-every-row rendering.