Appearance
<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>
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-configgains ahomesection (#841) —heroSliderGroupIdandheroSliderLimit, 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 refusesdeliverywith a smart-point-only transporter whose flag is off (422payway_not_allowed_for_transporter),POST /rest/checkout/shippingpublishes the verdict per transporter ascodAllowed, 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-configgains anappUpdatesection — 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]=1onGET /rest/product/vendorandGET /rest/product/categorykeeps only vendors / categories (by published subtree) with at least one sellable product (#788) - [4.124.0] feat(rest/order):
GET /rest/order/order/{id}/trackingexposes a computedtrackingUrl— 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:
gtcodeis one well-formed voucher (letters, digits, hyphens), the order status isSENT,PAID_SENTorINVOICED(the legacy customer order page's "being delivered" set), and the order's transporter — looked up regardless ofactive, 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 givenull, as do free-text or comma-separated codes. - Why. A headless client had
trackingCodeandtransportIdbut 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 inAdvisable\Domains\Transporter\TrackingUrl\Resolver; the legacygetLinkForTransferProvider()andgetMinifiedLinkForTransferProvider()read it instead of carrying their own switch. Their outputs are unchanged (pinned bytests/Unit/Helpers/TransporterTrackLinkParityTest.php, which passes against both the old and the new helper).
- What. A new nullable key on the tracking snapshot. It is non-null only when tracking can actually work:
- [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_orderandAdv_checkoutnow setdofollow = falseindefaultRender(), so every account, login, signup, password, wishlist, cart, checkout and payment-return page emits<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: 5for 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.
- Add
- [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 theset_checkbox()default, and the form helper only pre-checks when the default is strictlytrue. - Because the box looked unchecked, the next save of the page (for any setting) sent no value and overwrote
EUROPHARMACY.ENABLEDwithNULL. 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 stores0instead ofNULL.
- On Settings → Only Advisable, the Europharmacy enable checkbox always showed unchecked, even after a save had stored
- [4.124.0] fix(storefront): stop reading the favicon fragment off storage disk on every render (#668)
application/views/main.phpno longer callsstorage()->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 viaNamedLoggerInterfaceon thestorage-fragmentchannel, 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): voidinvalidates a cached entry.AdvFavicons::save()calls it right after writing the favicon, so a new upload is reflected immediately instead of waiting outcache_l2_default_expires.- Caching follows
cache_l2_default_expires— a value of0or 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 toS3Client, 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 thes3Demodisk),SITEMAP_S3_CONNECT_TIMEOUT/SITEMAP_S3_TIMEOUT/SITEMAP_S3_RETRIES, andPRIVATE_S3_CONNECT_TIMEOUT/PRIVATE_S3_TIMEOUT/PRIVATE_S3_RETRIES.- Defaults, applied in
Storage.phpwhen 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_MODEread from the environment.
- [4.124.0] fix(europharmacy): read the Europharmacy connection settings from the registry instead of
application/config/europharmacy.phpSyncProductsEuropharmacy,SyncOrdersStatusEuropharmacyandPostOrdersEuropharmacybuilt their API client fromapplication/config/europharmacy.php, which upstream ships with placeholder values (XXXXXXXXXXXXXXXX). TheEuroPharmacyConfigToRegistrypatch 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 loggedEuropharmacy call error XXXXXXXXXXXXXXXX/api/Products…: cURL error 6: Could not resolve host.- The jobs now read
EUROPHARMACY.BASE_URL,USERNAMEandPASSWORD(decrypted) through the newAdvisable\Europharmacy\Config::fromRegistry(). With the toggle on, an empty key throwsInvalidArgumentException: Missing configuration parameter: EUROPHARMACY.<KEY>. - While
EUROPHARMACY.ENABLEDis off, all three jobs return straight away with no API call. Each run writes oneinfoline on theeuropharmacychannel, e.g.SyncProductsEuropharmacy skipped: EUROPHARMACY.ENABLED is off, so a job that is scheduled but disabled can be traced. At the defaultAPP_LOG_THRESHOLD=ERRORthe line is filtered out; lower the threshold toINFOto see it. application/config/europharmacy.phpstays 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 soproduct_codeskeeps its index range scan (#836)Adv_front_controller::$productIdsToParsecollected DB string values next to ints, and CI3'swhere_in()quotes a string value but leaves an int bare — so a mixed list compiled toIN (159319, '1409484', ...). MariaDB responds by dropping theint(11)index range and full-scanning theproduct_codesindex (1.76M rows on a large catalogue) on every product/category/vendor/search render.- New
Advisable\Utils\IntUniqueCollection(final, extendsUniqueCollection; casts on construct,addItemandaddItems) now backsproductIdsToParse— wired at the single pointAdv_front_controllerconstructs it.UniqueCollectionitself is unchanged: it also holds tag slugs and12/3attribute-filter values elsewhere, which must not be cast to int. Adv_product_model::getProductCodeImages()now normalises its$productIdsargument witharray_map('intval', ...)beforewhere_in(), matching the existing hardening ingetProductCodes()andgetBarcodeByProductIdsAsArray(). This covers the ~30 direct callers (product variations, XML feeds, admin controllers) that never go throughproductIdsToParse.- Added tests:
tests/Unit/Utils/IntUniqueCollectionTest.php,tests/Unit/Utils/UniqueCollectionTest.php(regression:UniqueCollectionstill returns strings unchanged — tag slugs,12/3attribute filters), andtests/Legacy/Eshop/AdvProductModelGetProductCodeImagesIntIdsTest.php.
- [4.124.0] fix(docker): copy
application/config/main.phpinto the node build stage so PurgeCSS keeps pagination and breadcrumb classes (#820)application/config/main.phpholds 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.jslists 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-itemand.page-linkfrom the four purged bundles (homepage.css,product.css,category_listing.css,vendor_listing.css);product.cssandhomepage.cssalso lost.pagination. Breadcrumbs rendered as a browser-default numbered list and listing pagination lost its item/link styling. Local builds and hosts serving the committedpublic/uibundles were unaffected — they have the whole tree. Verified: an image build and a localnpm run productionfrom 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.jsgains apurgecss(options)wrapper, and the four storefront purge profiles now takepurgecssfrom it (const { purgecss } = require('../helpers')on line 1, replacingrequire('@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-configgains apriceTrackingsection — the EU Omnibus reference-price switch (enabled=PRICE_TRACK.ENABLED) and the merchant's per-languagediscountLabel/badgeLabelcopy — 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/shippingquote for the destination (#808) - [4.124.0] feat(rest/product):
sort=finalPriceorders by the consumer price (shop_prices_view.final_price) andsort=availabilityputs 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.jsscript 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 whenMAILCHIMP.ENABLEDis on, otherwise the first script ofproduction/production.phporproduction/ld_json.php. - Only the
$isBuilderEditorbranch offooter_js.phpchanges; storefront pages never reach it.
- The block-builder editor page closed the
- [4.124.0] fix(builder): stop the wrap-with-link toolbar action from retagging images to
<a>(#807)- The block builder's Wrap with link toolbar action retagged the selected component's tag to
<a>. On an image this emitted<a src=…/>and the<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
<a>(marked with a persistedadvLinkWrappercomponent 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<img>. Non-image components keep the previous retag behaviour. - A load-time repair pass (
assets/admin/js/builder/editor/linkWrapperNormalizer.js) now runs on GrapesJSproject:load, outside undo tracking: any image previously saved astagName: "a"is restored to<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.
- The block builder's Wrap with link toolbar action retagged the selected component's tag to
- [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_registeredis written as the Unix timestamp the column andWriteDataexpect (#798) - [4.124.0] fix(eshop): stop the Skroutz/Facebook review-reminder cron fataling on an empty day
- Bug.
Adv_order_model::getMailsForSkroutzReview()andgetMailsForFacebookReview()returnedfalsewhen their query matched no rows, while the siblinggetMailsForGoogleReview()already returned[]. Their single consumer,sendMailForReviewToCustomers(), passes the result straight intocount($records)and thenforeach. Under PHP 8count(false)is aTypeError, so the job dies withcount(): Argument #1 ($value) must be of type Countable|array, bool givenon 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 thefalsereturn.
- Bug.
- [4.124.0] fix(orders): populate
shop_order.ip_addresson storefront orders- Bug.
shop_order.ip_address VARCHAR(45)has been in the schema sincedatabase/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 carriedNULLfor 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 setsip_addressfromMY_Input::ip_address(), alongsideentry_datetime. That override resolves CloudFlare'sCF-Connecting-IPfirst, then the firstX-Forwarded-Forentry, then falls back toREMOTE_ADDR, so the value stored is the customer's address rather than the edge's. No schema change — the column is already there, alreadyVARCHAR(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 separatecreate_order_admin()and keep storingNULL: 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.
- Bug.
- [4.124.0] feat(rest/transporter):
GET /rest/transporter/{id}/smart-pointserves the pickup-point catalog whenever the provider can fetch it — the legacy storefront's widget preference (USE_TRANSPORTER_WIDGET) no longer turns it into409 widget_only— and gainscountry,near+radiusandlimitfilters plus a per-transporter cache (#810) - [4.124.0] fix(rest/checkout): the payment verdict comes from
status, and the modern confirmation path stops stampingis_paid(#754)- The defect.
GET /rest/checkout/payment-status/{orderId}and both response arms ofPOST /rest/checkout/confirm-payment/{orderId}publishedshop_order.is_paidas the payment verdict. It is not one.is_paidis 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 isstatus. - It was already reporting paid orders as unpaid. Deterministically for
paybybank, whose PAID callback arm never callsset_is_paid(), and as a live race forvivawallet, whose webhook writes status throughupdate_order()— which special-cases onlyCANCELEDand 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.
isPaidis gone from those responses andstatus— 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.
PaymentConfirmationServiceno longer writesis_paidwhen it confirms a payment — nothing automated should stamp a flag meaning "someone has checked this order". It still writesstatusand still dispatchesOrderPaid. - Full reasoning and every rejected alternative are in
docs/decisions/754-payment-verdict-from-status.md.
- The defect.
- [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-ordernow returnstransactionId— the gateway's own reference for the payment, as the adapter produced it — omitted rather than null when the adapter produced none, exactly aspaymentRedirectalready is. Every existing payway's response body is therefore byte-identical. Forpaybybankthis is the bank payment code, and it is the whole product: that payway has no redirect at all, so the order landsPENDINGand 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 fromGET /rest/checkout/payment-status/{orderId}— which requires a customer token, whileplace-orderaccepts 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_ticketonly, while every existing reader of a PayByBank code readsshop_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 aspbbPaymentCode. Legacy has written that column since the payway existed. Measured on a live dataset of 3,134,494 orders:pbb_payment_codenon-empty on 20,035 rows,tran_ticketon 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 oneupdate(). - Keyed on the payway, deliberately.
pbb_payment_codeis 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 totran_ticketalone. - Named
transactionId, notpaymentReference. It is the valuepayment-statusalready publishes under that name, and the property it is copied from onPaymentInitResult. 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.
- The response half.
- [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-builderwhere_in(), which throwsInvalidArgumentException: where_in() expects $values to be a non-empty arrayon an empty array (CodeIgniter'ssystem/database/DB_query_builder.php:751-752). Dropping the predicate instead — while keeping theshop_product_category_lpjoin — 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
$catstoday, because the category id is 404-gated upstream on both existence andpublished. The real exposure is thatgetCatProductsPriceRange()ispublicwhile its only in-repo caller isprotected, 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 inget()(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, addingeurobankandpaybybanktodelivery,bank_transfer,paid_at_storeandvivawallet. 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-orderrefuses anything outside the merchant's savedMETHODS.PAYWAY_EXTERNALlist 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 — andvivawalletdid 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 ingetCardPayWays(), so theCancelIncompleteOrderssweep 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.phpgains a fifth guard: every member of the external pool must either land atPENDING_ACCEPTEDor be visible togetDebrisOrders(), 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.
- What changed.
- [4.124.0] fix(checkout/vivawallet):
Config::isConfigured()requires the merchant id in ISV mode, so a deployment without one registers novivawalletadapter 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.vuerenders the deadline column with only two states:v-if="shouldShowDeadline"shows the countdown, and itsv-elseshowsservices_bar.unpaid_services.deadline_passed. ButshowDeadline()returnsfalsefor two different reasons — the deadline has passed, or there is no deadline at all (getDeadlineis'', its initial state and the value theservicesstore getter falls back to). The "no deadline" case therefore fell through to thev-elseand 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/deadlinecomputes the deadline only from products flaggedis_essentialand 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 carriesis_essentialper item.isEssential()coerces withNumber(service.is_essential) === 1rather than testing truthiness: CI3 serialises theint(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 absorbsnull, 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_essentialrule are untouched. Rows for essential services behave exactly as before, countdown and expiry message included.
- Bug.
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()orsendNotFound()in itsApi_variationssubclass, 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\StorefrontConfigProvidergained a sixth constructor argument (HomeConfigResolver $home) and a sixthall()entry; a fork that constructs the provider by hand or overridesall()must follow.Adv_settingsgainedexternalHome(),externalHomeValidation()and the public form-validation callbackexternalHomeHeroGroupExists(); a fork'sSettingscontroller defining methods of the same names shadows them. - [4.124.0] UI Update: REST contract note 1.91. Additive: one new object on the
dataenvelope,home: { heroSliderGroupId: int|null, heroSliderLimit: int|null }, read from the new Registry keysHOME_EXTERNAL.HERO_SLIDESHOW_GROUP_ID/HERO_SLIDESHOW_LIMIT.heroSliderGroupId: nullmeans render no hero — unset, or the group was deleted (the id is checked againstslideshow_groupson every read).heroSliderLimit: nullmeans no cap. Fetch the hero withGET /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'shome_slideshow_groupsfile 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 requiredCashOnDeliveryPolicy $cashOnDeliveryPolicyargument 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 requiredCashOnDeliveryPolicy $cashOnDeliveryPolicyargument afterSettingRepository $settingRepository(before the optional$smartPointClassNames), with the same override consequence, andcalculate()rows carry a newcodAllowedkey.Adv_order::previewValidation()and::checkoutValidationRules()addcallback_paywayTransporterCheckValidationto thepaywayrule — a fork that overrides either method keeps the old, unenforced behaviour until it adds the rule. NewAdvTransporters::isCashOnDeliveryAllowed(). - [4.124.0] UI Update: new language key
form_validation_paywayTransporterCheckValidationin all eightapplication/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 therest_api_versions.phpentry — do not offer cash on delivery (hide or disable it) while a transporter withcodAllowedfalse is selected. The rule reads the existingtransporters_settingsrows; no migration and no new setting. The publishedpublic/openapi*.jsonis regenerated at the release cut. - [4.124.0] Check for overrides:
Advisable\Domains\StorefrontConfig\StorefrontConfigProvidergained a sixth constructor argument (AppUpdateConfigResolver $appUpdate) and a sixthall()entry; a fork that constructs the provider by hand or overridesall()must follow.Adv_settingsgained themobile_app_settings()action and the viewapplication/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
dataenvelope,appUpdate: { android: { minimumVersion, storeUrl }, ios: { minimumVersion, storeUrl } }, every fieldstring|null. The app compares its own version againstminimumVersion(exactlyX.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 publishesnull, which means no minimum.storeUrlis an absolutehttpsURL ornull. No migration — theAPP_UPDATEregistry 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()andAdvisable\Domains\Product\Category\Service::filterSpecification()gained asellableProductssentinel-relation branch; a fork that overrides either method must carry it, or the key falls through to a bareFilteron the id column.SortByAvailabilitynow reads its stock predicate from the newAdvisable\Domains\Product\Product\Repository\SellableProductSql(emitted SQL unchanged); its privatePRODUCTS/CODESconstants 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-ordernow updates the signed-in customer'sshop_customerrow with the details the order used — the billing columns always, thesendto_*columns only when the request carries its ownshippingAddress, andcompany_afm/company_doy/company_name/company_address/professiononly on an invoice order. These are exactly the columnsAdv_order::setUpCustomerData()writes for a logged-in customer;mail,passwordand 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 fromshippingAddresswhenever the request sends it, so a client must not put a store or locker address there (lockers travel intransporterExternalData), 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 overridesplaceOrder()does not get the new step 8b until it merges it. To change which columns are saved, aliasAdvisable\Domains\Checkout\CheckoutCustomerDetailsWriterrather than overridingplaceOrder(). - [4.124.0] REQUIRES
php migrator.php migrate:20260923120000_add_expires_at_to_shop_cart.php— addsshop_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), envAPP_GUEST_CART_LIFETIME, in seconds, defaulting tosess_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, scheduled15 1 * * *inapplication/config/jobs.php. A fork that overridesjobs.phpmust 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 insrc/Domains/Cart/container.php.getOrCreateCart()no longer inserts a new guest cart with the caller's$cartToken;claimCart()also clearsexpires_at. A fork that constructs the service by hand or overrides these methods must follow.RemoveExpiredGuestCarts::__construct()gainedint $guestCartLifetimeSeconds, wired from the same resolved value. A fork that keeps its OWNapplication/config/config.phpthrough the merge has noguest_cart_lifetimekey: the container then falls back tosess_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 intocache/container.php, so changingAPP_GUEST_CART_LIFETIMEneeds a container rebuild. - [4.124.0] UI Update: REST contract note 1.85.
X-Cart-Tokenvalues now expire afterguest_cart_lifetimeseconds of inactivity; every use of the cart extends it. An expired or unknown token behaves exactly like a missing cart:GET /rest/cartreturnscart: null, item/coupon/clear routes answer 404,claimanswers 404, guestplace-orderanswers "Cart not found".POST /rest/cart/itemswith such a token creates a NEW cart under a NEW token — clients must persist thecartTokenreturned in the response (they already must for a first cart). No response shape changes. - [4.124.0] Client forks that customised
public/robots.txtkeep their own copy (or get a merge conflict); merge the new blocked-crawler group andCrawl-delayby hand. - [4.124.0]
Adv_customer::defaultRender()andAdv_order::defaultRender()are new override points. A fork controller that overrides them must callparent::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. TheeuroPharmacyEnabledset_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.phpmay still read the favicon fragment straight off storage.- Detect: grep that fork's
application/views/main.phpforstorage()->exists('files/favicons/view.html'). If it's there, the fork is affected. A fork that renders static favicon markup (nostorage()read at all) is unaffected — no change needed. - Swap. Replace:phpwith:
<?php if (storage()->exists('files/favicons/view.html')) : ?> <?= storage()->get('files/favicons/view.html'); ?> <?php endif; ?>php<?= di()->get(\Advisable\Storage\CachedFragmentReader::class)->read('files/favicons/view.html'); ?> - 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-mergehas nothing to flag, so check for this explicitly on every merge past this change. - Rebuild the compiled DI container after merging — delete
cache/container.php(or run the cache clear, which runscache/delete.sh). A stale compiled container makes the new view line throwServiceNotFoundExceptionon every page outside development. - A fork that overrides
AdvFavicons::save()must add, right after its ownstorage()->put('files/favicons/view.html', ...):phpSkipping this means a newly uploaded favicon won't show until the cache expires (up todi()->get(\Advisable\Storage\CachedFragmentReader::class)->forget('files/favicons/view.html');cache_l2_default_expires, 1 day by default). - A fork with its own
application/config/storage.phpstill gets the new S3 timeout/retry defaults — they live inStorage.php, not in the config file. To make them configurable per-disk, copy theconnect_timeout/timeout/retrieskeys from the upstream config.
- Detect: grep that fork's
- [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.phpholds real credentials that differ from the registry now connects with the registry values. CheckEUROPHARMACY.*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()— theEUROPHARMACY.ENABLEDgate is its first statement. A child override that doesn't callparent::executeCommand()doesn't get the gate. - [4.124.0] Check for overrides:
SyncProductsEuropharmacy::logger(),SyncOrdersStatusEuropharmacy::logger(),PostOrdersEuropharmacy::logger()and the$loggerproperty — 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 reassignsAdv_front_controller::$productIdsToParse, so every fork picks up the fix as-is. (Gea'sRelated_product_model::$productIdsToParseis 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 toparent::— Blustore, Edructer, Megastore (application/modules/eshop/models/Product_model.php). None of the three needs a backport: each already does its ownarray_map('intval', $productIds)before building theIN (...)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 intoPscache, which hashesserialize($arguments)—Adv_front_controller.php(twogetAttributesValuesGroupsComboForProductIdscalls plus onegetVariationGroupsWithValuescall) and thefilterCriteoProducts()Criteo filter inAdv_product_categories,Adv_vendorsandAdv_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 to0and 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/*.jslists 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.jsthat no longer exists, which fails all four purged bundles; - a view file listed in
homepage.jsthat no longer exists; category_list.jsentries missing the.phpextension, and sometimes theapplication/views/prefix — these already skip their files silently today, so that bundle is purging wrongly now.
- a view file listed in
- [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.dockerfilecontains the COPY, placed after theapplication/viewsCOPY. This matters especially if their node stage has diverged from upstream, or if they resolved a conflict there in favour of their own version:Otherwise their image build now fails with the error above namingCOPY --chown=${USER_ID}:${GROUP_ID} application/config/main.php ./application/config/main.phpapplication/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.jscurrently match no files (tracked as #821). That is why globs are not checked. - [4.124.0] Check for overrides:
Advisable\Domains\StorefrontConfig\StorefrontConfigProvidergained a fifth constructor argument (PriceTrackingConfigResolver $priceTracking) and a fifthall()entry; a fork that constructs the provider by hand or overridesall()must follow.Advisable\Domains\Product\PriceTracking\Options::fromRegistry()now delegates to the newOptions::fromRegistryInstance(Registry $registry, array $adminLanguages), which is the one reader of thePRICE_TRACKgroup — 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
dataenvelope,priceTracking: { enabled: bool, discountLabel: {<lang>: string}, badgeLabel: {<lang>: string} }.enabledfails closed (an unconfigured shop publishesfalse) and is independent of the fourENABLED_GRAPH*keys, which gate the price-history graph widget and are not published.TRACK_DAYSandSHOW_DISCOUNT_ON_VALUEare deliberately not published either —GET /rest/product/price-tracking/graphalready 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 asproductChartSettings.discountLabel/discountBadgeLabel, each a string already resolved to the page language: here the badge copy isbadgeLabel, anddiscountLabelis a{<lang>: string}map, not a string. The legacy storefront is untouched. - [4.124.0] Check for overrides:
Advisable\Domains\Checkout\PlaceOrderService::placeOrder()now resolves$shippingAddronce, before theShippingCalculator::calculate()call, and hands it the destination triple; the later// 7. Build order datablock reuses that variable instead of resolving its own. A fork that overridesplaceOrder()and still passes$data->billingAddressto the calculator keeps the bug. - [4.124.0] UI Update: none — no REST surface change, so no
rest_api_versions.phpentry. 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 ofcolumnSortSpecification(); a fork that overrides the method must carry them or the keys fall through to a bareORDER BYon an unjoined table. - [4.124.0] UI Update: two new sort keys on
GET /rest/product/product—finalPrice/-finalPriceandavailability/-availability; REST contract note 1.84.sort=pricestill orders the raw column and is not what a shopper sees. - [4.124.0] Check for overrides:
application/views/main/components/footer/footer_js.phpis a per-fork copy. At the 4.124.0 cut, 24 of 39 client forks still carry the buggybuilder.js'); ?>"><script>line, 23 of them in a copy that has diverged from upstream. After the upstream merge, confirm the line readsbuilder.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 rebuiltpublic/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 theel/enlocale files (block-builder Wrap with link). - Unpaid-services modal —
ServicesBarUnpaidServicesModalContent.vue, compiled intopublic/ui/admin/dist/advisable-services.js.
- #807 —
- [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)) beforeresolveGuestCustomer()runs, where it used to run after and hand the calculator the freshly minted guest id. A fork that overridesplaceOrder()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 intodate_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:
reviewForFacebookandreviewForSkroutzonly 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) overridesgetMailsForFacebookReview()andgetMailsForSkroutzReview()with its owngroup_byed copies that alsoreturn false. Being anextendsoverride, 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. ItsgetMailsForGoogleReview()override already returns[]. - [4.124.0] The fatal masks, rather than causes, an empty result. A
reviewForSkroutzrun that suddenly starts failing daily meansshop_order.skroutz_refererhas 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 writingNULL, and there is no conflict at merge time to surface that. Anything built on this column must treatNULLas "unknown", never as a distinct address. - [4.124.0] The IP is only as trustworthy as the edge.
MY_Input::ip_address()trusts the firstX-Forwarded-Forvalue unconditionally andapplication/config/config.php'sproxy_ipsis 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 tocache.l2); theSmartPointsDisabledException::REASON_WIDGET_ONLYconstant and branch are removed. A fork that overridesfetch()or constructs the Service by hand must follow both signatures; a fork that catcheswidget_onlyby name will never see it again. - [4.124.0] UI Update: REST contract note 1.82.
409now carries onlyno_provider/not_enabled_for_provider; a BOX NOW transporter with the widget preference on answers200with its full catalog. New optional query keys on the same route —country(ISO 3166-1 alpha-2),near(lat,lng) withradius(metres, default 25000, ignored withoutnear; results distance-ascending),limit; an unparseable value answers400 invalid_query_paramsnaming 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
isPaidfrom those two endpoints must switch to the status test — an order is settled whenstatusisPAIDorPENDING_ACCEPTED, and awaiting payment when it isPENDING. ⚠️PENDING_ACCEPTEDis a terminal state, not an intermediate one: the three offline payways (delivery,bank_transfer,paid_at_store) settle there at placement and never reachPAID, so a client that treats onlyPAIDas settled renders every cash-on-delivery order as unpaid forever. A client that reads the field defensively (raw.isPaid ?? false) will now silently readfalsefor 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 = 0permanently and therefore drops out of admin'sfilter[isPaid]andisPaidsort. 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 carriesis_paid = 1while a REST-confirmed one carries0, 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]
AdvEmailPaypalCanceledAndIsPaidToAdminwill report fewer orders, since fewer will beCANCELEDandis_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 theis_paidwrite 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 isAdvisable\Domains\Checkout\PlaceOrderService::placeOrder(). - [4.124.0] What is NOT fixed here, so it is not read as done:
GET /rest/checkout/payment-methodsstill reportsrequiresRedirect: falsefor both the offline payways andpaybybank, which behave completely differently after placement — the offline three landPENDING_ACCEPTEDand are complete,paybybanklandsPENDINGand is unpaid. Telling those apart from the picker alone is #732's, which owns that payload. A client must not treatrequiresRedirect: falseas "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:- 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.
- Delegation to
parent::after queueing query-builder state (e.g. applying its ownWHEREbefore the call). This shape inherits both the guard and thereset_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 availabilityWHEREand then callsparent::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 ownMETHODS.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 fromGET /rest/checkout/payment-methodseven after being selected. - [4.124.0] Known gap carried, not introduced: both new payways are advertised by
GET /rest/checkout/payment-methodswith their raw key aslabel, becausePAYMENT_METHOD_LABELScovers nine of the nineteen paywaysPaymentInitializerFactoryregisters (allPayWays()lists twenty;proxypayis 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 bykey— so it is deliberately not patched here. - [4.124.0] UI Update: on an ISV Viva deployment (
is_isv => trueinviva_wallet*.php) whoseVIVAWALLET.MERCHANT_IDis empty,GET /rest/checkout/payment-methodsstops listingvivawalletandPOST /rest/checkout/place-orderrefuses it with the existing422 payway_not_available— where before it was listed and every placement failed at Viva with a400after the order row, cart clear and points debit had already happened. ISV is the mode a deployment inherits by default — this repo's ownapplication/config/viva_wallet.phpandviva_wallet_dev.phpshipis_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 withis_isv => false) are unaffected: the OAuth pair alone still configures them. To restore the payway on an ISV deployment, fillVIVAWALLET.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\Configis a value object, not a container service, so there is noCustom\override ofisConfigured()— 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 omitsis_isvis treated as non-ISV and silently keeps the old, defective behaviour (the new gate is a no-op); a shadowed reader that suppliesis_isvtruthy but sources the merchant id from a key other thanmerchant_idsilently loses the payway. - [4.124.0] Merge conflict resolution:
rest_api_versions.phpis append-only and every in-flight REST delivery adds an entry (#794). After resolving, keep thechangelogarray strictly ascending by version (a naive keep-both can yield1.75, 1.78, 1.76, 1.77) and regeneratepublic/api-versions.jsonfrom the PHP registry (composer openapi-json, then restore the release-ownedopenapi*.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-orderandPOST /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 underContent-Type: application/json— adeliveryorder 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/destrdo not throw on this — they hand back the raw string — so a headless client sawresponse.orderId === undefinedrather 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 loadseshop/order_modelitself. 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": "<uuid>"}, and avivawalletorder cannot be confirmed without it. Viva appends the transaction id to the return URL as thetquery 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 throughAdv_checkout::vivaWalletResponse()and theAdvVivawebhook, 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 theshop_product_category_lpjoin nor awhere_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 existingservice-deadlinedivs in a<template v-if="hasDeadline && isEssential(unpaidServices)">and adds thehasDeadlinecomputed plus theisEssential()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
hasDeadlinehalf 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 theNumber(...) === 1coercion; a fork that "simplifies" it toservice.is_essentialreintroduces the row-level half of the bug, because the value arrives as the string"0".
- the
- [4.124.0] A fork that renders the deadline from its own per-row source instead of the global
getDeadlinegetter can skip this hunk entirely — the bug is specific to the global-value-on-every-row rendering.