Appearance
<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /changelog/Changelog.4.105.md for this page in Markdown format</div>
Version 4
version 4.105
[4.105.0] fix(klarna): derive the Klarna session locale from the site language instead of hardcoding
el-GR(Advisable-com/ecommercen#309)- The bug.
AdvApiKlarna::openPaymentSessionforwarded the storefront's hardcodedlocale: 'el-GR', so the Klarna widget always rendered in Greek regardless of the site language. The widget UI language is independent of the purchase currency — the original #309 framing wrongly bundled them. The storefront charges in base EUR (multi-currency is display-only), so the currency was already correct and is left unchanged. - Fix. The Klarna session
localeis now derived server-side fromconfig_item('language_abbr')(the active site language — the same source NBGPay uses) via a newKlarnaHelper::localeForLanguage()map (el→el-GR,en→en-GB,de→de-DE,fr→fr-FR,it→it-IT,es→es-ES), with an English fallback for languages Klarna doesn't support (ru/zh). The client-posted locale is ignored — single source of truth. - Tests. New
KlarnaHelperTest::locale_for_language_*case (mapping + case/whitespace tolerance + fallback). Klarna suites green (29). PHP-only — no storefront rebuild needed. - Follow-up.
KlarnaWidget.vue::explicitKlarnaPaymentsLoad()still passes a hardcodedlocaleon cart-update re-render — secondary (the session locale governs the widget), left as a minor follow-up.
- The bug.
[4.105.0] feat(klarna): recover the authorization token from the webhook callback when the front-end token is missing (Advisable-com/ecommercen#307)
- The gap. The modern REST
KlarnaAdapterread the Klarnaauthorization_tokenonly frompaymentData['klarna_authorization_token']and threw if absent — unlike the legacyAdv_checkout::getAuthorizationToken(), which falls back to the token Klarna pushes to themerchant_urls.authorizationwebhook (stored inshop_order_klarna_paymentskeyed bysession_id). So a headless order could fail if the front-end died after the customer authorized (app-switch / browser close), even though Klarna had delivered the token server-side. - What shipped. New read-only
Order\KlarnaPaymentrepository overshop_order_klarna_payments(src/Domains/Order/KlarnaPayment/Repository/), registered in the Order container.KlarnaAdapternow callsrecoverTokenFromCallback()— looking up the storedauthorization_tokenbypaymentData['klarna_payment_session']— before throwing. The repository is injected nullable, so contexts without it (unit tests) keep the strict behavior. - Tests. 3 new
KlarnaAdapterTestcases (recover when the front-end token is absent; throw when no stored row; throw when no session id) + a container-resolution guardtests/Integration/Domains/Order/KlarnaPayment/ContainerTest.php. Klarna + PaymentInitializer suites green (55). - Deploy notes. The fallback engages only when the headless client sends the Klarna
session_idasklarna_payment_sessioninpaymentExternalData. The compiled DI container must be rebuilt (deletecache/container.php) to pick up the new service.
- The gap. The modern REST
[4.105.0] chore(klarna): use Klarna
discountorder-line type + fix production API URL typo (Advisable-com/ecommercen#306)- Discount line type. Coupon and loyalty-points discount lines are now emitted with Klarna's dedicated
discountorder-line type (negative amount) instead oftype: 'physical', across the legacy helper (KlarnaHelper::generateOrderLines()), the storefront widget (KlarnaWidget.vue), and the modernKlarnaAdapter.discountis Klarna's documented type for reductions; amounts and reconciliation are unchanged. - Production URL typo.
Klarna::BASE_URL_PRODUCTIONwashttps://apI.klarna.com(capitalI) → corrected tohttps://api.klarna.com. Harmless (DNS hostnames are case-insensitive) but no longer misleading. - Split out (#309). The third item originally bundled in #306 — hardcoded
purchase_currency: 'EUR'/locale: 'el-GR'in the storefront Klarna callers — was split to #309: it's multi-currency/i18n work, not a cleanup (the order amount is sent in EUR, so swapping the currency without converting the amount would mismatch; locale needs Klarna's country/currency/locale matrix). - Tests.
KlarnaHelperTest/KlarnaAdapterTestextended to assert thediscounttype; Klarna + PaymentInitializer suites green (52).
- Discount line type. Coupon and loyalty-points discount lines are now emitted with Klarna's dedicated
[4.105.0] feat(klarna): itemize KlarnaAdapter order lines from the persisted order/basket (Advisable-com/ecommercen#304)
- The gap. The modern REST
KlarnaAdaptersent a single consolidated"Order {serial}"line to Klarna, losing the per-line breakdown the legacyAdv_checkout::klarnaPayments()path produces viaKlarnaHelper::generateOrderLines(). Consolidated lines are accepted but degrade Klarna risk scoring and the customer's Klarna-app statement. - What shipped. The adapter now reads the persisted order + basket (
Order\Repository+OrderBasket\Repository, injected viaPaymentInitializerFactory::registerKlarna()) and builds itemized lines mirroring legacy: one line per basket row (SKU as name/reference,unit_pricefromoriginal_price,total_discount_amounton discounted rows,total_amountderived asqty*unit_price - discountto satisfy Klarna's per-line rule), plus order-levelshipping_fee/ coupon / points / gift lines fromshop_orderfields, withorder_amount = shop_order.total_vat. - Reconciliation guard. If the itemized lines don't sum exactly to the order total in minor units (rounding drift, an unmodelled fee, COD delivery cost), the adapter falls back to the consolidated single line — Klarna rejects a create-order when
order_amount != Σ order_lines.total_amount, so a mismatch is never sent. Itemization is best-effort and can never break checkout. The repositories are injected nullable, so contexts without them (unit tests) keep the consolidated path. - Also. Removed the dead
localeconstructor param (never used or passed — Klarna locale is set when the headless client opens the session). - Deferred (tracked on #304). Authorization-callback token fallback (needs the Klarna
session_idinpaymentData+ ashop_order_klarna_paymentslookup) and EMD (the adapter lackscustomer_id) remain follow-ups; localized line names need the request language plumbed intoPaymentContext— lines currently use the SKU. - Tests. 5 new
KlarnaAdapterTestcases (itemization, shipping+discount lines, discounted-row math, fallback on non-reconciliation, fallback on empty basket); existing cases unchanged.KlarnaAdapterTest+KlarnaHelperTestgreen (25),PaymentInitializergreen (27).
- The gap. The modern REST
[4.105.0] fix(klarna): send Klarna amounts as integer minor units, not unrounded floats (Advisable-com/ecommercen#302)
- The bug. The legacy Klarna client (
src/PaymentGateways/Klarna/Klarna.php,KlarnaHelper.php) multiplied amounts by 100 without rounding or an integer cast. A value like €19.99 became1998.9999999999998in IEEE-754 — a non-integer that violates Klarna's integer-minor-units (int64) schema and can break theorder_amount == Σ order_lines.total_amountreconciliation. Whole-euro prices masked it; typical decimal prices are affected. The modernKlarnaAdapterand the JS widget already rounded correctly — the legacy client was the outlier. - Fix. A
toMinorUnits()helper ((int) round($amount * 100)) was added to bothKlarnaandKlarnaHelper, and every amount now routes through it:order_amount(open/update session + create order),captured_amount,refunded_amount, and all order-lineunit_price/total_amount/total_discount_amount. The misleading(float)cast oncreateOrder'sorder_amountwas dropped. - Tests. New
tests/Unit/PaymentGateways/Klarna/KlarnaHelperTest.php(3 tests) asserts integer minor units for product, discounted, shipping, coupon, points, and gift-packaging lines (the19.99 → 1999regression).KlarnaHelperTest+KlarnaAdapterTestgreen (20 tests). - Out of scope. Independent rounding of
order_amountvs the summed order lines could still differ by a cent in rare edge cases — a pre-existing reconciliation concern, tracked separately only if it surfaces.
- The bug. The legacy Klarna client (
[4.105.0] fix(klarna): gate checkout submission on
approved+authorization_token, not theshow_formUI hint (Advisable-com/ecommercen#303)- The bug. The Klarna Payments widget and checkout mixin decided payment success from the
show_formfield of theKlarna.Payments.authorize()response. Klarna documentsshow_formas a UI hint ("a boolean indicating whether to keep showing the form or to remove it"), not a success signal — its documented success signal isapproved: trueplus anauthorization_token. When Klarna returnsapproved: truewithshow_form: false(approved, hide the widget), the storefront'sdata.show_form === truesubmit gate was never satisfied, so the order was never placed and a valid, approved payment silently stalled. The widget callback also treatedres.show_form === falseas "authorization missing", conflating the UI hint with an auth failure. - Fix.
KlarnaWidget.vuenow emits the authorize event only whenres.approved === trueand anauthorization_tokenis present (otherwise it shows the authorization-missing alert);checkoutPage.jskeys the auto-submit gate offdata.authorization_tokeninstead ofdata.show_form === true(still requiringapproved+ accepted terms); and the now-unusedshow_formfield is dropped from the storedklarnaPaymentAuthorizationVuex state (verified read-nowhere). The authorization-token flow to the backend is unchanged — only the success/submit gate moves off the UI hint. - Deprecation note. Klarna's SDK reference marks
show_formas deprecated only onreauthorize(); onauthorize()/finalize()it remains an active (but non-success) field. This is therefore a semantic-misuse fix with a real stall scenario, not anauthorize()-deprecation removal. Optional follow-up tracked in #303: handlefinalize_required: true(we hardcodepayment_method_category: 'pay_now', which normally never requires it). - Tests / deploy. No JS unit-test harness covers this path (Klarna tests are PHP-only); verify against the Klarna playground — an approved authorization auto-submits and creates the order across widget states, and a declined/error authorization shows the alert without submitting. The storefront bundle must be rebuilt (
npm run production) for the change to take effect — the source change does not regeneratepublic/ui/main/automatically.
- The bug. The Klarna Payments widget and checkout mixin decided payment success from the
[4.105.0] feat(rest): customer self-service SMS-marketing (Viber) opt-in (velora#24 / Advisable-com/ecommercen#296)
- Why. Viber subscription was velora's last piece of customer-update code still riding the legacy session-cookie
/api/customer/*surface — the modernCustomerSmsMarketingREST controller existed but was gated to admin auth (ADMIN/MARKETING), so a logged-in customer could not manage their own opt-in. This adds the customer-scoped self-service surface (Option A from the issue), consistent with how birthday landed viaCustomer::updateMe(#21). - What shipped. Three customer-auth endpoints on the existing SMS-marketing controller, routed under the
/rest/customer/meself-service surface:GET /rest/customer/me/sms-marketing(lists the caller's own subscriptions; optionalfilter[providerId]),POST /rest/customer/me/sms-marketing(opt-in upsert —{providerId, phone}), andDELETE /rest/customer/me/sms-marketing/{providerId}(opt-out — idempotent). Together these give velora full parity with the legacysetViberPhone, which both subscribes and unsubscribes via theaddToViberflag. The admin CRUD (/rest/customer/customer-sms-marketing) stays backend-only. - Security scoping. The listing force-overwrites
filter[customerId]with the bearer-token customer id (mirrorsReview::enforceStorefrontReviewScope()), so one customer can never read another's rows. POST/DELETE server-forcecustomer_id(JWT) — the write also forcescreation_dateand ignores both if sent in the body; DELETE resolves the caller's row by(customer_id, providerId)so a customer can only ever remove their own. Unknown providers are rejected on POST at the REST boundary againstCUSTOMER_SMS_MARKETING_PROVIDER(the legacy global constant is kept out of the domain layer). - Atomic opt-in.
WriteService::subscribeForCustomer()delegates directly toWriteRepository::upsertByCustomerProvider()(landed in #297) — no PHP-level check-then-insert race.WriteService::unsubscribeForCustomer()removes the caller's row for a provider (idempotent — a no-op when absent) via the existingfindForCustomerAndProvider()lookup. REST minor → v1.8 (additive; v1.6/v1.7 stay active). - Tests.
WriteServiceCustomerTest(create, upsert-no-duplicate, per-provider separation, unsubscribe remove/idempotent/targeted, missing provider/phone validation);PolicyResolverIntegrationTestrows forCustomerSmsMarketing(mine/subscribe/unsubscribe= customer, admin CRUD = backend). Fully closes #297 acceptance item 5.
- Why. Viber subscription was velora's last piece of customer-update code still riding the legacy session-cookie
[4.105.0] fix(customer): enforce one SMS-marketing consent row per (customer, provider) + atomic upserts (Advisable-com/ecommercen#297 — partial)
- The gap.
shop_customer_sms_marketinghad no DB-level uniqueness on(customer_id, provider_id). Both write paths — the legacyAdvCustomerSmsMarketing::saveCustomerSmsMarketing()(blindINSERT) and the modernCustomerSmsMarketingWriteService::create()— relied on a controller-level check-then-insert, leaving a TOCTOU window where two concurrent opt-ins for the same customer+provider could each miss the existence check and insert duplicate rows. - Migration (
20260608120000_dedup_sms_marketing_add_unique_customer_provider). Collapses existing duplicate(customer_id, provider_id)rows keeping the most recentcreation_dateper pair (highestidbreaks ties), then addsUNIQUE INDEX uq_customer_provider (customer_id, provider_id). TheDROP INDEXis emitted only when the index already exists (diverged-schema guard), and before/after row counts plus the collapsed-pair count are logged.down()is forward-only. - Atomic upserts. New
WriteRepository::upsertByCustomerProvider()runsINSERT … ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), phone = VALUES(phone), creation_date = VALUES(creation_date)— theLAST_INSERT_ID(id)trick keepsinsert_id()resolving to the affected row in both branches.WriteService::create()routes through it when the natural key is present (the admin endpoint is idempotent now). The legacysaveCustomerSmsMarketing()was converted to the same atomic upsert — required, since a blindINSERTwould now fatal on the new unique index. Both preserve their existing return contracts. - Tests. Modern
ServiceTestgains upsert-on-duplicate + distinct-pair-still-inserts cases; newtests/Integration/Legacy/Eshop/AdvCustomerSmsMarketingTest.phpcovers save→resave-same-pair (one row, phone updated, no duplicate-key fatal) + sign-up/delete round-trip.
- The gap.
[4.105.0] fix(cart): legacy cart API returns 400 instead of a fatal TypeError on non-JSON bodies (Advisable-com/ecommercen#26)
- The bug.
MY_Input::setInputStreamAsPost()passed the rawjson_decode()result straight intoarray_merge($_POST, …). When the request body wasapplication/x-www-form-urlencoded(or any non-JSON),json_decode()returned a non-array andarray_merge()threw a fatalTypeErroron PHP 8.1+. The legacy/api/cart/updateand/api/cart/cartDataendpoints (AdvApiCartController) faulted instead of rejecting the request. - Blast radius.
setInputStreamAsPost()is shared by 11 legacy controllers (cart, VAT, transporters, Klarna, coupons, and six admin controllers), so the same latent fatal existed on every JSON-body endpoint that received a non-JSON body. - Fix.
setInputStreamAsPost()is now defensive and returnsbool: it only merges into$_POSTwhenjson_decode()yields an array (success path unchanged), returnstruefor an absent body, and returnsfalsefor a non-empty non-JSON body without touching$_POST. The new return value is backward-compatible — the other 10 callers ignore it and now degrade gracefully (no fatal) instead of crashing. - Cart 400.
AdvApiCartController::update()and::cartData()check the return and emit a proper400 Bad Requestvia a newdenyMalformedBody()helper when the body is not JSON, matching the documented endpoint contract (JSON only).massUpdate()was already safe (its ownjson_decode+denyCall). - Tests. New
tests/Legacy/Core/MyInputTest.php(9 reflection-based unit tests): merge path, the #26 regression (form-encoded →false,$_POSTuntouched, no throw), empty body, malformed JSON, and JSON scalars. Legacy suite green. - Flow doc.
docs/flows/customer/CF-05-cart-management.mdLive Testing Notes updated — the former "form-encoded POST causesarray_merge()TypeError" note now describes the 400 behaviour.
- The bug.
[4.105.0] feat(rest): GET /rest/features discovery endpoint + disabled-feature request guard (velora#70 / Advisable-com/ecommercen#287)
- Why. Velora RFC #27 §3 locked "backend owns feature state, frontend reads it." The storefront was hardcoding
features.{wishlist,blog,loyalty,smartPoints}at build time, which can silently drift from the backendregistrytoggles that actually gate those features. This is the backend half that lets the FE read instead of hardcode. - Discovery endpoint. New
GET /rest/features(auth: guest) returns a backend-derived boolean map keyed by the platform feature enum. Registry-backed flags are read live from the DB, so an admin toggle flip is reflected on the next request with no rebuild. - Feature set (7). Resolved three ways:
- Registry toggles (live DB reads):
blogComments(OTHER.ENABLE_BLOG_COMMENTS),loyalty(POINT_SYSTEM.IS_ENABLED),waitingList(OTHER.ENABLE_WAITING_LIST),builder(BUILDER.ENABLED). - Always-on (no backend toggle):
wishlist,blog(the blog has no master flag —Adv_blogrenders unconditionally; per-client enablement is done by shipping blog views/routes. A real blog-enable toggle is a separate future feature — flipblogtoregistrywhen it lands). - Computed:
smartPoints(true when ≥1 active smart-point/locker transporter is configured).
- Registry toggles (live DB reads):
- Server-side guard. New
FeatureGuardMiddleware(after Authorization in the REST pipeline) returns 404 to the storefront for endpoints of a disabled feature — defense-in-depth, since the FE flag was never a security boundary. Valid backend tokens bypass (admins still manage disabled features); the lookup falls back to the controller's parent class so a versioned override subclass inherits its base gate. v1 guards onlybuilder— the one feature with genuine legacy enforcement (it directly replaces the removed in-controllerBUILDER.ENABLEDgate). The other features are discovery-only: their flags were never enforced server-side in legacy (e.g.ENABLE_WAITING_LISTonly governed the FE button; the blog has no toggle at all and is always-on, enabled per-client by shipping blog views/routes), so guarding them would 404 requests legacy accepted. A real blog-enable toggle is a separate future feature. - Single source of truth. New
application/config/rest_features.phpdeclares both the feature→resolver map and the controller→feature guard map;Domains/Features/FeatureRegistryresolves it for both consumers. Builder's in-controllerBUILDER.ENABLEDgate (shipped earlier this release under velora#49) is removed in favour of the middleware — identical behaviour (404 + backend bypass), now a single enforcement point. REST minor → v1.7 (additive; v1.6 stays active). - Tests.
FeatureRegistryTest(resolver per type + unknown-key fail-closed + parent-class guard fallback + config-consistency guards: every guarded value is a declared feature and every guarded key maps to an existing controller source file);FeatureGuardMiddlewareTest(all four branches incl. customer-treated-as-guest, via theGetInstanceRegistryoutput stub);PolicyResolverIntegrationTestrow forFeatures(auth: guest).
- Why. Velora RFC #27 §3 locked "backend owns feature state, frontend reads it." The storefront was hardcoding
[4.105.0] test(checkout): DB integration test for gift stock decrement + harden gift-engine bridge (Advisable-com/ecommercen#85)
- What shipped. New
tests/Integration/Domains/Checkout/Gift/GiftCheckoutFlowTest.php— a DB-backed integration test for the stateful gift effect of paying for an order: the atomicgifts.remainingdecrement viaGift\WriteRepository::decrementRemaining()(including theremaining >= Nguard that prevents decrement below zero and theNULL/unlimited-stock case) and theDecrementGiftStockOnPaidListenerthat drives it from the persisted order basket. - Bridge bugs found and fixed. Writing the test surfaced two real defects in
LegacyGiftRuleEngine(the seam introduced in the #85 placement commit):getGiftForProductsInCart()andgetActiveGiftRulesChoicesAndRequirementsFromProductIds()both delegate to the legacy engine, which internally readsproduct_parser_modeland uses theshopmodulehelper +Registry. A lean REST request autoloads none of these, so both calls would have faulted in production with "undefined property / undefined functiongetResultObjectAsIndexedArray". Fixed inLegacyGiftRuleEngineby eagerly loadingproduct_parser_model, theshopmodulehelper, andRegistrybefore invoking the engine. - Scope. The gift-matching read query itself is not exercised by the integration test — it pulls in
product_parser_model's L2 cache and thevats_modelstack, a Redis/cache-gated dependency not wired in the lean DB test environment (consistent with other gated skips in the suite).GiftMatcherandCartGiftPresenterlogic is covered by the unit tests in the #85 / velora#44 commits with a mockedGiftRuleEngine.
- What shipped. New
[4.105.0] feat(rest): surface eligible gift rules in the cart payload (velora#44 / Advisable-com/ecommercen#288)
- What shipped. A
giftsblock is now included in the guest cart payload, listing every free-gift rule the current cart qualifies for — eligibility computed from live cart state, the multi-choice pool, and the requirements — so the storefront can render gift selection without any additional request. The rawGift,GiftChoice, andGiftRequirementtables remain backend-only; only the legacy-allowlisted fields are exposed (id,ruleId,amount,giftUserChoiceCount,choices,requirements,image,description) plusearnedCount, matchingAdvCartResource::getGiftRules()/mapGiftRuleFieldsForJson. - Implementation. New
Domains/Checkout/Gift/CartGiftPresenterportsgetGifts()andgetGiftRules(): computes earned gifts, fetches applicable rules with choices and requirements, and overrides Rule-13 choices with the computed cheapest products. Reuses theGiftRuleEngineseam from #85 (no engine re-implementation, no raw-table exposure). The Cart controller injectsCartGiftPresenter;buildCartResponse()appends thegiftsblock (empty for empty or degenerate carts). TheGiftRuleEngineseam gainsgetActiveGiftRulesChoicesAndRequirementsFromProductIdsandfilterApplicableGiftRulesbridge methods. - Epic close. This completes the #288 velora storefront-parity backend epic (#42/#43/#45b/#47/#49/#85 all shipped). Note: velora#45a (coupon-rule display) is closed by design — coupon rules are validated server-side at apply time; no read endpoint was built.
- Tests. New
CartGiftPresenterTest(allowlist enforcement,earnedCount, Rule-13 choice override, empty-cart cases).
- What shipped. A
[4.105.0] feat(checkout): process promotional gifts in REST checkout (Advisable-com/ecommercen#85 / Advisable-com/ecommercen#288)
- The gap. REST order placement hard-nulled
gift_idand computed totals without gift awareness. Promotional gifts — including Rule-13 (cheapest-product-free) and free gift rows — were silently skipped. Thegifts.remainingstock counter was never decremented on REST orders. - Approach. The existing 13-rule legacy engine is reused verbatim behind a
GiftRuleEngine/LegacyGiftRuleEngineseam (the same CI bridgePlaceOrderServicealready uses forRegistry), rather than re-implementing it or exposing raw Gift-table reads. - What shipped.
Domains/Checkout/Gift:GiftMatcher(server-authoritative earn;selectedGiftsfrom the client only picks from the eligible pool, capped at the earned count),GiftRuleEngine+LegacyGiftRuleEngineseam,GiftOutcomeDTO.OrderBasketBuilder::applyGiftOutcome(): Rule-13 trims matching paid rows (yieldinggiftDiscount) while other rules append free gift rows (price=0,discount_string=GIFT,gift_id).PlaceOrderServicenow matches gifts up-front, subtracts the Rule-13giftDiscountfrom the payable total, and persists the gift-adjusted basket (the snapshot remains pre-gift, matching legacycart_contents).PlaceOrderData.selectedGiftsis the new request field ([{giftId, productId, qty}]), normalised and server-validated byGiftMatcher. NewPromotion\Gift\WriteRepository::decrementRemaining()— an atomicUPDATE gifts SET remaining = remaining - N WHERE id = ? AND remaining IS NOT NULL AND remaining >= Nthat closes the #203 REST race condition (previously only the legacy path decremented stock).DecrementGiftStockOnPaidListener(onOrderPaid) fires at payment success, mirroring the legacyafterSuccesstiming. - Tests.
GiftMatcherTest,OrderBasketBuilderGiftTest,DecrementGiftStockOnPaidListenerTest,PlaceOrderDataTest(normalization),PlaceOrderServiceTest(gift flow). DB integration test ships separately in the c71eae358 commit.
- The gap. REST order placement hard-nulled
[4.105.0] feat(rest): customer "my coupons" endpoint in the Plus package (velora#45b / Advisable-com/ecommercen#288)
- What shipped. New
GET /rest/plus/customer-coupon(auth: customer) returning the authenticated customer's audience-targeted coupons. Faithful port of the legacyAdv_front_controller::activeCustomerCoupons()flow: resolve the customer's audiences (viashop_customer_audience), then return the audience-scoped coupon codes — kept in the Plus domain rather than the backendPromotion/CouponCRUD resource. The customer id is always taken from the JWT, never from a request param. The endpoint is gated on theSMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAGregistry flag, matching the legacy storefront gate. - Data boundary. The response is a curated projection (code, name, discount, validity) — not the raw admin
Couponresource. Coupon rule tables stay backend-only (velora#45a — coupon rule display — was not built; rules are validated server-side at apply time). - New domain stack.
Domains/Plus/CustomerCoupon:Repository::availableForAudiences()portsgetCouponCodeForAudiences()with the defaultCouponCheckConfig(audience_id IN,is_sent,is_used < max_count_usage,coupon_rulesdate window,GROUP BY coupon.id).Service::availableForCustomer()resolves audiences then scopes (no audiences → empty, matching the legacyvalidAudienceshort-circuit).Plus/Audience Repository::getAudienceIdsForCustomer()mirrorsAdvAudienceModel::getCustomerAudience. - Tests.
ServiceTest(empty-audience short-circuit + delegation);PolicyResolverIntegrationTestrows forCustomerCoupon(auth: customer). Follow-up: DB integration test for the audience-scoped query (needs seeded coupon/audience/rule fixtures).
- What shipped. New
[4.105.0] feat(rest): expose page-builder blocks to the storefront (velora#49 / Advisable-com/ecommercen#288)
- The gap. The Builder controller was not listed in
rest_policies, so it fell back to the secure backend default. The legacyAdvBlockBuilder::frontRender()renders blocks publicly to guests when theBUILDER.ENABLEDregistry flag is on — the REST layer did not mirror this. - What shipped.
rest_policiesgains aBuilderentry:index/show/item → guest; writes remain backend (authz unchanged — the controller was previously unlisted, falling back to the global default). The Builder controller now gates guest reads on theBUILDER.ENABLEDregistry flag (404 when off for non-backend callers; backend callers always have access). The Builder Resource drops the GrapesJScontentsource for storefront callers (isBackend()guard) — the storefront renders the compiledtitle/css/html/js, matchingfrontRender. - Tests. Context-aware
Builder/ResourceTest;PolicyResolverIntegrationTestrows forBuilder. No new endpoints (cms/builder routes already exist).
- The gap. The Builder controller was not listed in
[4.105.0] feat(rest): expose product customization schema to the storefront (velora#47 / Advisable-com/ecommercen#288)
- The gap. The three product-customization fields (
cartProductCustomizationSchemaId,cartProductCustomizationRangeFrom,cartProductCustomizationRangeTo) were inside theisBackend()block of the Product Resource, so headless storefronts received them as null and could not render the customization form. The legacy storefront ships these to guests via the productjsonState. - What shipped. The three fields are moved out of the
isBackend()guard insrc/Rest/Product/Resources/Product/Resource.phpand are now always-emitted. Their OpenAPI descriptions are corrected (were mislabelled "Admin-only").rest_policiesgainsCustomizationSchemawithindex/show/item → guest(schema is safe reference data: id/name/isActive/schema; no prices or PII). Write policies remain backend + ADMIN/PRODUCTS. - Tests.
ScopeFilteringTestmoves the three fields fromPRODUCT_BACKEND_ONLYtoPRODUCT_PUBLIC;PolicyResolverIntegrationTestgainsCustomizationSchemarows. No new endpoints.
- The gap. The three product-customization fields (
[4.105.0] feat(rest): customer-scoped back-in-stock waiting-list opt-in (velora#43 / Advisable-com/ecommercen#288)
- What shipped.
POST /rest/product/waiting-listis now open to customer auth. The customer's email is derived from the authenticated account (never the request body) — the waiting-list table is email-keyed with nocustomer_id, and trusting a body email would allow a customer to subscribe arbitrary third parties. - Policy and resource scoping.
rest_policies:WaitingList::store → auth: customer; reads and update/destroy remain backend (the resource exposes subscriber emails / PII). No change to the GET paths. - New domain additions.
WaitingList::store()overrides the base action: injectsCustomer\Service, resolves the customer by JWT, takesemailfromcustomer.mail, accepts onlyproductIdfrom the request body, and server-forceswaiting_status=0,creation_date, andlang.WriteService::createForCustomer()validates the email and product and enforces a one-open-opt-in-per-(email, product) dedup via newRepository::existsOpenForEmailAndProduct()(status=0check), matching the legacyisInWaitingList()parity.Rest/Product/container.phpwires$customerService → Customer\Customer\Service. - Tests. New
WriteServiceCustomerTest;PolicyResolverIntegrationTestrows forWaitingList(store=customer, reads/writes backend).
- What shipped.
[4.105.0] feat(rest): customer-scoped product review submission (velora#42 / Advisable-com/ecommercen#288)
- What shipped.
POST /rest/product/reviewis now open to customer auth (update/destroy remain backend). The controller overridesstore()to accept onlyproductId,starPoints,nickname, andcontent; it server-forcescustomer_id(from JWT),active=0,is_email_sent=0,review_date, andlang— matching the legacysafeAddReview()storefront flow rather than the admin CRUD path.index/item/showare scoped to approved-only (active=1) for non-backend callers, mirroring theOrder::enforceOrderScope()pattern. TheReviewResource emitscustomerId,isEmailSent, andactiveonly for backend context, so the storefront cannot harvest reviewer ids or read pending/rejected reviews. - New domain additions.
WriteService::createForCustomer()enforces a required product, a 1–5 star-rating range, and a one-per-product-per-customer dedup (legacy parity). NewRepository::existsForProductAndCustomer()backs the dedup check. - Tests. New
WriteServiceCustomerTest; context-awareResourceTest;PolicyResolverIntegrationTeststorerow updated from backend to customer. - Follow-ups. Advisable-com/ecommercen#57 (rate-limiting + verified-purchase on review submission) and #15 (audit the review
activefield as tri-state 0/1/2, not boolean) are tracked separately and not part of this commit.
- What shipped.
[4.105.0] feat(order/jobs): port the Bulker SMS-status poller to the Order domain (Advisable-com/ecommercen#155 — partial close)
- What shipped. New
Advisable\Domains\Order\Jobs\GetBulkerSmsStatus— the modern PSR-4 port of the legacyAdvGetBulkerSmsStatuscron. It reconciles orders whose confirmation SMS is still BUFFERED (shop_order.sms_status = 4) against the Bulker delivery-report endpoint and writes back the resolved status (2=delivered,3=undelivered). Reads use a projectedRepository::findOrderIdsBySmsStatus()(an id-onlySELECT, matching the legacy job's narrow read rather than hydrating full Order entities), writes go through the OrderWriteRepository; the gateway call talks toAdvisable\Bulker\BulkerSmsdirectly — the same class the legacyBulkerlibrary wrapped, so the status-code interpretation is byte-for-byte unchanged. The delivery-report lookup is keyed by the numeric order id, exactly as the legacy job polled it. - Improvement over legacy. An order that comes back still-buffered (
4) is now skipped instead of re-written with the identical value — the legacy job issued that no-opUPDATEon every unchanged row each run. - Wiring. Registered in
src/Domains/Order/container.php(the cron dispatcherAdvJobresolves a job via$container->has($name) ? get($name) : new $name(), so a DI-constructor job fatals on thenewfallback if unregistered — the exact gap that bitPollPayByBankStatusin #163). Added toapplication/config/jobs.phpcommandOptionsplus a schedule entry next to the legacy line, commented out (operator opt-in); the legacy job stays the active path until an operator flips it. - Deploy note. The job is not a no-op on an unconfigured deployment: the config guard only short-circuits when the
bulkerconfig is entirely absent/empty, and the shipped placeholder values (apiBaseUrl,auth_key) are non-empty — so enabling the schedule without setting real Bulker credentials will fire a live delivery-report HTTP call per buffered order (gateway/transport errors map to status4and are skipped, so no order data is corrupted). Configureapplication/config/bulker.phpbefore enabling the schedule. This matches the legacy job's behaviour. - Tests. 9 unit tests over the read→poll→write matrix (delivered / undelivered / still-buffered-skip / buffered-mid-batch-does-not-halt / non-positive-id / unconfigured-gateway / buffered-only selection /
execute()full-pipeline) + 3 container/integration tests (registration, resolution, and acreateBulkerClient()config-path test against real CI config) + a DB-backedfindOrderIdsBySmsStatus()repository test. All green. - Scope — #155 is a partial close. Of the 11 jobs in #155, only
GetBulkerSmsStatushad an existing modern collaborator. The other 10 are blocked on missing domain services and are split into four prerequisitetype:portissues, mirroring the #163→#274 split: Loyalty domain (#291, AddPointsToCustomer{Deliver,FromStore}), Audience-membership service (#292, AddCustomersTo{Audience,SpecificAudience}), CustomerTag bulk-assignment (#293, Add{Registered,}TagsToCustomers), and a Mailer domain (#294, the fourSent/Sendmail jobs). - Flow doc.
docs/flows/integration/IN-14-sms.mdresynced (Bulker status-polling section, architecture listing, cron-jobs table).
- What shipped. New
[4.105.0] feat(gift-cards): complete the Piraeus Bank payway for gift cards with a dedicated POS credential set
- The problem. Piraeus was wired into the gift-card payway switch but unreachable — the
piraeuscase fell through toalpha(AdvGiftCardPage::getPayWayFormData()), so selecting Piraeus silently ran Alpha Bank. ThepiraeusFormData()body that existed also (a) used the regular-checkout credentials, (b) never persisted theTranTicket(the HMAC secret needed to validate the bank callback), and (c) had no return-URL handlers at all. - Why Piraeus is special. Unlike every other gateway (which sends success/failure URLs per transaction), Piraeus has those URLs pre-registered bank-side against a specific POS, and the response HashKey is keyed partly on
PosId/AcquirerId. Because the gift-card return URLs (/gift-card/piraeusSuccess,/gift-card/piraeusFail) differ from checkout's, the bank issues a separate credential set bound to them. The flow itself is unchanged from the official Redirection Manual (§4 Ticketing, §5 Response/HashKey) and mirrors the classicAdv_checkout::_piraeus. - Dedicated POS config (
getPiraeusGiftCardBankSettings()). New registry groupPIRAEUSBANK_GIFTCARDSmirrors the checkoutgetPiraeusBankSettings()field-for-field (all 13 keys: credentials,REQUEST_TYPE/EXPIRE_PRE_AUTH,CURRENCY_CODE,BNPL,PARAMETERS,POST_ACTION, installments) but is fully independent of the checkoutPIRAEUSBANKPOS — nothing is inherited. The gift-card POS is provisioned separately and typically needsRequestType=02(Sale) where checkout may run00(Preauthorization); inheriting the checkout value returned "Invalid transaction type". All keys are entered in admin for the gift-card POS. Installments are config-driven per gift-card POS — an emptyINSTALLMENTSvalue yields none (so a client with no gift-card installments needs no special handling), and a future client that runs installments on its gift-card POS just fills the field in. Validation mirrors the classic checkout via the pureresolvePiraeusInstallments()helper; the storefront dropdown is fed bypiraeusGiftCardInstallmentsDropDown(). piraeusFormData(). Now reachable (switch fixed; the deadapcopay→alphafall-through removed). Issues the SOAP ticket with the gift-card POS, persistsTranTickettogift_card_orders.tran_ticket, reads the correctshop_customerphone columns (landphone/mobilephone) with null guards, and returns the redirect form (noTranTicketin the form, per the manual — identified byMerchantReference).piraeusSuccess()/piraeusFail(). New return-URL handlers. Success validates the HashKey (piraeusHashKeyMatches(), HMAC-SHA256 keyed on the stored ticket using the gift-card POS/Acquirer) and only then accepts. A pure state machine (piraeusSuccessAction/piraeusFailAction) guards against duplicate-coupon issuance on a refreshed/re-POSTed success callback and never revokes an already-paid coupon on a late failure callback. A dedicatedgiftCard.piraeus.fail.titlelanguage key was added across all 8 locales.- Admin UI.
payment_settings.phpgains a "Gift Card credentials" subsection under Piraeus Bank (credentials + installments), shown only when the gift-card feature is enabled;Adv_settings::payment_settings()saves/reads thePIRAEUSBANK_GIFTCARDSkeys, guarded byGIFT_CARDS.ENABLEDso toggling the feature off never wipes stored credentials.piraeusadded togetGiftCardPayWays()so it can be enabled in the gift-card payway list. - Diagnostics.
AdvGiftCardPage::_remap()now logs swallowed exceptions instead of masking every action failure as a silent 404. - Tests. 22 new pure-logic cases in
tests/Legacy/GiftCards/AdvGiftCardPageTest.phpcovering the success/fail state machine, the HashKey validation (valid, tampered field, wrong POS/acquirer, empty ticket, missing fields), and installment resolution (empty/zero request, empty allowlist, count not offered, below minimum, valid). Full Legacy gift-card suite green (32 tests). - Infra note. The bank-side IP allowlist for the gift-card POS is configured outside the codebase.
- The problem. Piraeus was wired into the gift-card payway switch but unreachable — the
[4.105.0] fix(docker): prefix all integration container names with
COMPOSE_PROJECT_NAMEfor side-by-side checkouts- The problem. Every service in
.docker/integration/*.compose.ymlhardcodedcontainer_name: adveshop4-*. Container names are global to the Docker daemon, so a second checkout of the repo failed to start its integration stack with "container name … is already in use". compose.shnow exportsCOMPOSE_PROJECT_NAME, defaulting to the checkout's root directory basename (a pre-exported value is honoured). This makes the project name unique per checkout directory.- All 21
container_name:entries across the 8 compose files (dev, web-dev, web-prod, tools, mariadb, vitess, db-test, redis standalone + cluster) are now templated as${COMPOSE_PROJECT_NAME:-adveshop4}-*. - Docs updated. Two hardcoded
docker cp … adveshop4-databaseexample commands (in.claude/agents/devops.mdanddocs/guides/docker/local-development.md) were rewritten to resolve the container dynamically via"$(./compose.sh ps -q database)"so they work on any checkout. - Net effect. Multiple checkouts can run their integration stacks simultaneously. The default checkout still renders
adveshop4-*names, so single-checkout workflows are unchanged.
- The problem. Every service in
[4.105.0] fix(checkout): release coupons on every incomplete-order cancellation path (Advisable-com/ecommercen#290)
- The bug. In the active
AdvCancelIncompleteOrderscron,cancelCoupon()was called only fromcancelPendingDefaultCards(). The PayByBank, Iris (CANCELED branch), PayPal Advanced, and XPay cancel branches set the order toCANCELEDand restored loyalty points but never released the applied coupon — so a customer whose pending order was cancelled via one of those gateways had their coupon left permanently marked used and could not reuse it. (The modern REST cancellation path —PaymentConfirmationService::cancelPayment()→OrderCanceled→RestoreCouponUsageListener— already handled this; the gap was legacy-cron-only, and the legacy cron is the active path.) - Fix. Extracted a shared
cancelOrder($order)helper (set_status('CANCELED', '+')+returnPointsToCustomers()+cancelCoupon()+internalApiOrderCancelHook()) and routed all five cancellation branches through it, so every gateway now releases the coupon. ThecancelCoupon()truthy-id guard is preserved (couponless orders are a no-op). No behaviour change for the default-cards path, which already released coupons. - Out of scope. The deprecated
Cronjob::order_debris()duplicate (application/controllers/Cronjob.php) was intentionally left untouched — tracked by the existing SY-03 "duplicate handler" known issue. - Tests. New
tests/Unit/Jobs/AdvCancelIncompleteOrdersTest.php(3 tests): the PayPal-Advanced empty-tran_ticketcancel path releases the coupon (verified red before the fix / green after), the sharedcancelOrder()cancels and releases, and the couponless no-op guard. The gateway-coupled paths (PayByBank / Iris / XPay) are covered transitively via the shared helper plus inspection. - Flow doc.
docs/flows/system/SY-03-incomplete-order-cancellation.mdupdated (architecture diagram, per-gateway steps, and the now-resolved Known Issue removed).
- The bug. In the active
[4.105.0] fix(ui): admin notification bell no longer counts undated tasks as overdue (Advisable-com/ecommercen#70)
- The bug. The Vuex getter
getUserTasksWithDueDateExpiredevaluatednew Date(e.due_date).getTime() < Date.now()unconditionally.new Date(null).getTime()returns0, which is always less than the current timestamp, so every task with no due date was flagged as expired and inflated the overdue badge count. - Fix. Added a truthiness guard on
due_datebefore the date comparison (e.due_date && new Date(e.due_date).getTime() < Date.now()). Tasks without a due date are now excluded from the overdue count.
- The bug. The Vuex getter
Notes
[4.105.0] REST surface changed — regenerate OpenAPI specs at release cut:
- The REST API gained endpoints since the last cut: v1.7 (
GET /rest/features, #287) and v1.8 (GET/POST /rest/customer/me/sms-marketing, #296). Runphp cli.php job/GenerateOpenApiJsonto refresh the trackedpublic/openapi*.jsonandpublic/api-versions.json— nothing regenerates them at deploy, and this step was missed in the 4.103.0 cut.
- The REST API gained endpoints since the last cut: v1.7 (
[4.105.0] REQUIRES
npm run admin-production:- The Vue bell
due_datefix edits the source bundleassets/admin/js/tasks/tasks.js. The admin bundle (public/ui/admin/dist/) must be rebuilt for the fix to take effect in production.
- The Vue bell