Skip to content

<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /changelog/Changelog.4.105.md for this page in Markdown format</div>

Home | Changelog

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::openPaymentSession forwarded the storefront's hardcoded locale: '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 locale is now derived server-side from config_item('language_abbr') (the active site language — the same source NBGPay uses) via a new KlarnaHelper::localeForLanguage() map (elel-GR, enen-GB, dede-DE, frfr-FR, itit-IT, eses-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 hardcoded locale on cart-update re-render — secondary (the session locale governs the widget), left as a minor follow-up.
  • [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 KlarnaAdapter read the Klarna authorization_token only from paymentData['klarna_authorization_token'] and threw if absent — unlike the legacy Adv_checkout::getAuthorizationToken(), which falls back to the token Klarna pushes to the merchant_urls.authorization webhook (stored in shop_order_klarna_payments keyed by session_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\KlarnaPayment repository over shop_order_klarna_payments (src/Domains/Order/KlarnaPayment/Repository/), registered in the Order container. KlarnaAdapter now calls recoverTokenFromCallback() — looking up the stored authorization_token by paymentData['klarna_payment_session'] — before throwing. The repository is injected nullable, so contexts without it (unit tests) keep the strict behavior.
    • Tests. 3 new KlarnaAdapterTest cases (recover when the front-end token is absent; throw when no stored row; throw when no session id) + a container-resolution guard tests/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_id as klarna_payment_session in paymentExternalData. The compiled DI container must be rebuilt (delete cache/container.php) to pick up the new service.
  • [4.105.0] chore(klarna): use Klarna discount order-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 discount order-line type (negative amount) instead of type: 'physical', across the legacy helper (KlarnaHelper::generateOrderLines()), the storefront widget (KlarnaWidget.vue), and the modern KlarnaAdapter. discount is Klarna's documented type for reductions; amounts and reconciliation are unchanged.
    • Production URL typo. Klarna::BASE_URL_PRODUCTION was https://apI.klarna.com (capital I) → corrected to https://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 / KlarnaAdapterTest extended to assert the discount type; Klarna + PaymentInitializer suites green (52).
  • [4.105.0] feat(klarna): itemize KlarnaAdapter order lines from the persisted order/basket (Advisable-com/ecommercen#304)

    • The gap. The modern REST KlarnaAdapter sent a single consolidated "Order {serial}" line to Klarna, losing the per-line breakdown the legacy Adv_checkout::klarnaPayments() path produces via KlarnaHelper::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 via PaymentInitializerFactory::registerKlarna()) and builds itemized lines mirroring legacy: one line per basket row (SKU as name/reference, unit_price from original_price, total_discount_amount on discounted rows, total_amount derived as qty*unit_price - discount to satisfy Klarna's per-line rule), plus order-level shipping_fee / coupon / points / gift lines from shop_order fields, with order_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 locale constructor 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_id in paymentData + a shop_order_klarna_payments lookup) and EMD (the adapter lacks customer_id) remain follow-ups; localized line names need the request language plumbed into PaymentContext — lines currently use the SKU.
    • Tests. 5 new KlarnaAdapterTest cases (itemization, shipping+discount lines, discounted-row math, fallback on non-reconciliation, fallback on empty basket); existing cases unchanged. KlarnaAdapterTest + KlarnaHelperTest green (25), PaymentInitializer green (27).
  • [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 became 1998.9999999999998 in IEEE-754 — a non-integer that violates Klarna's integer-minor-units (int64) schema and can break the order_amount == Σ order_lines.total_amount reconciliation. Whole-euro prices masked it; typical decimal prices are affected. The modern KlarnaAdapter and the JS widget already rounded correctly — the legacy client was the outlier.
    • Fix. A toMinorUnits() helper ((int) round($amount * 100)) was added to both Klarna and KlarnaHelper, and every amount now routes through it: order_amount (open/update session + create order), captured_amount, refunded_amount, and all order-line unit_price / total_amount / total_discount_amount. The misleading (float) cast on createOrder's order_amount was 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 (the 19.99 → 1999 regression). KlarnaHelperTest + KlarnaAdapterTest green (20 tests).
    • Out of scope. Independent rounding of order_amount vs 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.
  • [4.105.0] fix(klarna): gate checkout submission on approved + authorization_token, not the show_form UI hint (Advisable-com/ecommercen#303)

    • The bug. The Klarna Payments widget and checkout mixin decided payment success from the show_form field of the Klarna.Payments.authorize() response. Klarna documents show_form as a UI hint ("a boolean indicating whether to keep showing the form or to remove it"), not a success signal — its documented success signal is approved: true plus an authorization_token. When Klarna returns approved: true with show_form: false (approved, hide the widget), the storefront's data.show_form === true submit gate was never satisfied, so the order was never placed and a valid, approved payment silently stalled. The widget callback also treated res.show_form === false as "authorization missing", conflating the UI hint with an auth failure.
    • Fix. KlarnaWidget.vue now emits the authorize event only when res.approved === true and an authorization_token is present (otherwise it shows the authorization-missing alert); checkoutPage.js keys the auto-submit gate off data.authorization_token instead of data.show_form === true (still requiring approved + accepted terms); and the now-unused show_form field is dropped from the stored klarnaPaymentAuthorization Vuex 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_form as deprecated only on reauthorize(); on authorize()/finalize() it remains an active (but non-success) field. This is therefore a semantic-misuse fix with a real stall scenario, not an authorize()-deprecation removal. Optional follow-up tracked in #303: handle finalize_required: true (we hardcode payment_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 regenerate public/ui/main/ automatically.
  • [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 modern CustomerSmsMarketing REST 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 via Customer::updateMe (#21).
    • What shipped. Three customer-auth endpoints on the existing SMS-marketing controller, routed under the /rest/customer/me self-service surface: GET /rest/customer/me/sms-marketing (lists the caller's own subscriptions; optional filter[providerId]), POST /rest/customer/me/sms-marketing (opt-in upsert — {providerId, phone}), and DELETE /rest/customer/me/sms-marketing/{providerId} (opt-out — idempotent). Together these give velora full parity with the legacy setViberPhone, which both subscribes and unsubscribes via the addToViber flag. 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 (mirrors Review::enforceStorefrontReviewScope()), so one customer can never read another's rows. POST/DELETE server-force customer_id (JWT) — the write also forces creation_date and 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 against CUSTOMER_SMS_MARKETING_PROVIDER (the legacy global constant is kept out of the domain layer).
    • Atomic opt-in. WriteService::subscribeForCustomer() delegates directly to WriteRepository::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 existing findForCustomerAndProvider() 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); PolicyResolverIntegrationTest rows for CustomerSmsMarketing (mine/subscribe/unsubscribe = customer, admin CRUD = backend). Fully closes #297 acceptance item 5.
  • [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_marketing had no DB-level uniqueness on (customer_id, provider_id). Both write paths — the legacy AdvCustomerSmsMarketing::saveCustomerSmsMarketing() (blind INSERT) and the modern CustomerSmsMarketing WriteService::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 recent creation_date per pair (highest id breaks ties), then adds UNIQUE INDEX uq_customer_provider (customer_id, provider_id). The DROP INDEX is 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() runs INSERT … ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), phone = VALUES(phone), creation_date = VALUES(creation_date) — the LAST_INSERT_ID(id) trick keeps insert_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 legacy saveCustomerSmsMarketing() was converted to the same atomic upsert — required, since a blind INSERT would now fatal on the new unique index. Both preserve their existing return contracts.
    • Tests. Modern ServiceTest gains upsert-on-duplicate + distinct-pair-still-inserts cases; new tests/Integration/Legacy/Eshop/AdvCustomerSmsMarketingTest.php covers save→resave-same-pair (one row, phone updated, no duplicate-key fatal) + sign-up/delete round-trip.
  • [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 raw json_decode() result straight into array_merge($_POST, …). When the request body was application/x-www-form-urlencoded (or any non-JSON), json_decode() returned a non-array and array_merge() threw a fatal TypeError on PHP 8.1+. The legacy /api/cart/update and /api/cart/cartData endpoints (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 returns bool: it only merges into $_POST when json_decode() yields an array (success path unchanged), returns true for an absent body, and returns false for 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 proper 400 Bad Request via a new denyMalformedBody() helper when the body is not JSON, matching the documented endpoint contract (JSON only). massUpdate() was already safe (its own json_decode + denyCall).
    • Tests. New tests/Legacy/Core/MyInputTest.php (9 reflection-based unit tests): merge path, the #26 regression (form-encoded → false, $_POST untouched, no throw), empty body, malformed JSON, and JSON scalars. Legacy suite green.
    • Flow doc. docs/flows/customer/CF-05-cart-management.md Live Testing Notes updated — the former "form-encoded POST causes array_merge() TypeError" note now describes the 400 behaviour.
  • [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 backend registry toggles 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_blog renders unconditionally; per-client enablement is done by shipping blog views/routes. A real blog-enable toggle is a separate future feature — flip blog to registry when it lands).
      • Computed: smartPoints (true when ≥1 active smart-point/locker transporter is configured).
    • 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 only builder — the one feature with genuine legacy enforcement (it directly replaces the removed in-controller BUILDER.ENABLED gate). The other features are discovery-only: their flags were never enforced server-side in legacy (e.g. ENABLE_WAITING_LIST only 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.php declares both the feature→resolver map and the controller→feature guard map; Domains/Features/FeatureRegistry resolves it for both consumers. Builder's in-controller BUILDER.ENABLED gate (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 the GetInstanceRegistry output stub); PolicyResolverIntegrationTest row for Features (auth: guest).
  • [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 atomic gifts.remaining decrement via Gift\WriteRepository::decrementRemaining() (including the remaining >= N guard that prevents decrement below zero and the NULL/unlimited-stock case) and the DecrementGiftStockOnPaidListener that 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() and getActiveGiftRulesChoicesAndRequirementsFromProductIds() both delegate to the legacy engine, which internally reads product_parser_model and uses the shopmodule helper + Registry. A lean REST request autoloads none of these, so both calls would have faulted in production with "undefined property / undefined function getResultObjectAsIndexedArray". Fixed in LegacyGiftRuleEngine by eagerly loading product_parser_model, the shopmodule helper, and Registry before 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 the vats_model stack, a Redis/cache-gated dependency not wired in the lean DB test environment (consistent with other gated skips in the suite). GiftMatcher and CartGiftPresenter logic is covered by the unit tests in the #85 / velora#44 commits with a mocked GiftRuleEngine.
  • [4.105.0] feat(rest): surface eligible gift rules in the cart payload (velora#44 / Advisable-com/ecommercen#288)

    • What shipped. A gifts block 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 raw Gift, GiftChoice, and GiftRequirement tables remain backend-only; only the legacy-allowlisted fields are exposed (id, ruleId, amount, giftUserChoiceCount, choices, requirements, image, description) plus earnedCount, matching AdvCartResource::getGiftRules()/mapGiftRuleFieldsForJson.
    • Implementation. New Domains/Checkout/Gift/CartGiftPresenter ports getGifts() and getGiftRules(): computes earned gifts, fetches applicable rules with choices and requirements, and overrides Rule-13 choices with the computed cheapest products. Reuses the GiftRuleEngine seam from #85 (no engine re-implementation, no raw-table exposure). The Cart controller injects CartGiftPresenter; buildCartResponse() appends the gifts block (empty for empty or degenerate carts). The GiftRuleEngine seam gains getActiveGiftRulesChoicesAndRequirementsFromProductIds and filterApplicableGiftRules bridge 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).
  • [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_id and computed totals without gift awareness. Promotional gifts — including Rule-13 (cheapest-product-free) and free gift rows — were silently skipped. The gifts.remaining stock counter was never decremented on REST orders.
    • Approach. The existing 13-rule legacy engine is reused verbatim behind a GiftRuleEngine / LegacyGiftRuleEngine seam (the same CI bridge PlaceOrderService already uses for Registry), rather than re-implementing it or exposing raw Gift-table reads.
    • What shipped. Domains/Checkout/Gift: GiftMatcher (server-authoritative earn; selectedGifts from the client only picks from the eligible pool, capped at the earned count), GiftRuleEngine + LegacyGiftRuleEngine seam, GiftOutcome DTO. OrderBasketBuilder::applyGiftOutcome(): Rule-13 trims matching paid rows (yielding giftDiscount) while other rules append free gift rows (price=0, discount_string=GIFT, gift_id). PlaceOrderService now matches gifts up-front, subtracts the Rule-13 giftDiscount from the payable total, and persists the gift-adjusted basket (the snapshot remains pre-gift, matching legacy cart_contents). PlaceOrderData.selectedGifts is the new request field ([{giftId, productId, qty}]), normalised and server-validated by GiftMatcher. New Promotion\Gift\WriteRepository::decrementRemaining() — an atomic UPDATE gifts SET remaining = remaining - N WHERE id = ? AND remaining IS NOT NULL AND remaining >= N that closes the #203 REST race condition (previously only the legacy path decremented stock). DecrementGiftStockOnPaidListener (on OrderPaid) fires at payment success, mirroring the legacy afterSuccess timing.
    • Tests. GiftMatcherTest, OrderBasketBuilderGiftTest, DecrementGiftStockOnPaidListenerTest, PlaceOrderDataTest (normalization), PlaceOrderServiceTest (gift flow). DB integration test ships separately in the c71eae358 commit.
  • [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 legacy Adv_front_controller::activeCustomerCoupons() flow: resolve the customer's audiences (via shop_customer_audience), then return the audience-scoped coupon codes — kept in the Plus domain rather than the backend Promotion/Coupon CRUD resource. The customer id is always taken from the JWT, never from a request param. The endpoint is gated on the SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG registry flag, matching the legacy storefront gate.
    • Data boundary. The response is a curated projection (code, name, discount, validity) — not the raw admin Coupon resource. 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() ports getCouponCodeForAudiences() with the default CouponCheckConfig (audience_id IN, is_sent, is_used &lt; max_count_usage, coupon_rules date window, GROUP BY coupon.id). Service::availableForCustomer() resolves audiences then scopes (no audiences → empty, matching the legacy validAudience short-circuit). Plus/Audience Repository::getAudienceIdsForCustomer() mirrors AdvAudienceModel::getCustomerAudience.
    • Tests. ServiceTest (empty-audience short-circuit + delegation); PolicyResolverIntegrationTest rows for CustomerCoupon (auth: customer). Follow-up: DB integration test for the audience-scoped query (needs seeded coupon/audience/rule fixtures).
  • [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 legacy AdvBlockBuilder::frontRender() renders blocks publicly to guests when the BUILDER.ENABLED registry flag is on — the REST layer did not mirror this.
    • What shipped. rest_policies gains a Builder entry: 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 the BUILDER.ENABLED registry flag (404 when off for non-backend callers; backend callers always have access). The Builder Resource drops the GrapesJS content source for storefront callers (isBackend() guard) — the storefront renders the compiled title/css/html/js, matching frontRender.
    • Tests. Context-aware Builder/ResourceTest; PolicyResolverIntegrationTest rows for Builder. No new endpoints (cms/builder routes already exist).
  • [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 the isBackend() 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 product jsonState.
    • What shipped. The three fields are moved out of the isBackend() guard in src/Rest/Product/Resources/Product/Resource.php and are now always-emitted. Their OpenAPI descriptions are corrected (were mislabelled "Admin-only"). rest_policies gains CustomizationSchema with index/show/item → guest (schema is safe reference data: id/name/isActive/schema; no prices or PII). Write policies remain backend + ADMIN/PRODUCTS.
    • Tests. ScopeFilteringTest moves the three fields from PRODUCT_BACKEND_ONLY to PRODUCT_PUBLIC; PolicyResolverIntegrationTest gains CustomizationSchema rows. No new endpoints.
  • [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-list is 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 no customer_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: injects Customer\Service, resolves the customer by JWT, takes email from customer.mail, accepts only productId from the request body, and server-forces waiting_status=0, creation_date, and lang. WriteService::createForCustomer() validates the email and product and enforces a one-open-opt-in-per-(email, product) dedup via new Repository::existsOpenForEmailAndProduct() (status=0 check), matching the legacy isInWaitingList() parity. Rest/Product/container.php wires $customerService → Customer\Customer\Service.
    • Tests. New WriteServiceCustomerTest; PolicyResolverIntegrationTest rows for WaitingList (store=customer, reads/writes backend).
  • [4.105.0] feat(rest): customer-scoped product review submission (velora#42 / Advisable-com/ecommercen#288)

    • What shipped. POST /rest/product/review is now open to customer auth (update/destroy remain backend). The controller overrides store() to accept only productId, starPoints, nickname, and content; it server-forces customer_id (from JWT), active=0, is_email_sent=0, review_date, and lang — matching the legacy safeAddReview() storefront flow rather than the admin CRUD path. index/item/show are scoped to approved-only (active=1) for non-backend callers, mirroring the Order::enforceOrderScope() pattern. The Review Resource emits customerId, isEmailSent, and active only 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). New Repository::existsForProductAndCustomer() backs the dedup check.
    • Tests. New WriteServiceCustomerTest; context-aware ResourceTest; PolicyResolverIntegrationTest store row updated from backend to customer.
    • Follow-ups. Advisable-com/ecommercen#57 (rate-limiting + verified-purchase on review submission) and #15 (audit the review active field as tri-state 0/1/2, not boolean) are tracked separately and not part of this commit.
  • [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 legacy AdvGetBulkerSmsStatus cron. 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 projected Repository::findOrderIdsBySmsStatus() (an id-only SELECT, matching the legacy job's narrow read rather than hydrating full Order entities), writes go through the Order WriteRepository; the gateway call talks to Advisable\Bulker\BulkerSms directly — the same class the legacy Bulker library 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-op UPDATE on every unchanged row each run.
    • Wiring. Registered in src/Domains/Order/container.php (the cron dispatcher AdvJob resolves a job via $container->has($name) ? get($name) : new $name(), so a DI-constructor job fatals on the new fallback if unregistered — the exact gap that bit PollPayByBankStatus in #163). Added to application/config/jobs.php commandOptions plus 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 bulker config 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 status 4 and are skipped, so no order data is corrupted). Configure application/config/bulker.php before 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 a createBulkerClient() config-path test against real CI config) + a DB-backed findOrderIdsBySmsStatus() repository test. All green.
    • Scope — #155 is a partial close. Of the 11 jobs in #155, only GetBulkerSmsStatus had an existing modern collaborator. The other 10 are blocked on missing domain services and are split into four prerequisite type:port issues, 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 four Sent/Send mail jobs).
    • Flow doc. docs/flows/integration/IN-14-sms.md resynced (Bulker status-polling section, architecture listing, cron-jobs table).
  • [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 piraeus case fell through to alpha (AdvGiftCardPage::getPayWayFormData()), so selecting Piraeus silently ran Alpha Bank. The piraeusFormData() body that existed also (a) used the regular-checkout credentials, (b) never persisted the TranTicket (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 classic Adv_checkout::_piraeus.
    • Dedicated POS config (getPiraeusGiftCardBankSettings()). New registry group PIRAEUSBANK_GIFTCARDS mirrors the checkout getPiraeusBankSettings() 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 checkout PIRAEUSBANK POS — nothing is inherited. The gift-card POS is provisioned separately and typically needs RequestType=02 (Sale) where checkout may run 00 (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 empty INSTALLMENTS value 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 pure resolvePiraeusInstallments() helper; the storefront dropdown is fed by piraeusGiftCardInstallmentsDropDown().
    • piraeusFormData(). Now reachable (switch fixed; the dead apcopayalpha fall-through removed). Issues the SOAP ticket with the gift-card POS, persists TranTicket to gift_card_orders.tran_ticket, reads the correct shop_customer phone columns (landphone/mobilephone) with null guards, and returns the redirect form (no TranTicket in the form, per the manual — identified by MerchantReference).
    • 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 dedicated giftCard.piraeus.fail.title language key was added across all 8 locales.
    • Admin UI. payment_settings.php gains 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 the PIRAEUSBANK_GIFTCARDS keys, guarded by GIFT_CARDS.ENABLED so toggling the feature off never wipes stored credentials. piraeus added to getGiftCardPayWays() 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.php covering 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.
  • [4.105.0] fix(docker): prefix all integration container names with COMPOSE_PROJECT_NAME for side-by-side checkouts

    • The problem. Every service in .docker/integration/*.compose.yml hardcoded container_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.sh now exports COMPOSE_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-database example commands (in .claude/agents/devops.md and docs/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.
  • [4.105.0] fix(checkout): release coupons on every incomplete-order cancellation path (Advisable-com/ecommercen#290)

    • The bug. In the active AdvCancelIncompleteOrders cron, cancelCoupon() was called only from cancelPendingDefaultCards(). The PayByBank, Iris (CANCELED branch), PayPal Advanced, and XPay cancel branches set the order to CANCELED and 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()OrderCanceledRestoreCouponUsageListener — 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. The cancelCoupon() 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_ticket cancel path releases the coupon (verified red before the fix / green after), the shared cancelOrder() 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.md updated (architecture diagram, per-gateway steps, and the now-resolved Known Issue removed).
  • [4.105.0] fix(ui): admin notification bell no longer counts undated tasks as overdue (Advisable-com/ecommercen#70)

    • The bug. The Vuex getter getUserTasksWithDueDateExpired evaluated new Date(e.due_date).getTime() &lt; Date.now() unconditionally. new Date(null).getTime() returns 0, 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_date before the date comparison (e.due_date && new Date(e.due_date).getTime() &lt; Date.now()). Tasks without a due date are now excluded from the overdue count.

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). Run php cli.php job/GenerateOpenApiJson to refresh the tracked public/openapi*.json and public/api-versions.json — nothing regenerates them at deploy, and this step was missed in the 4.103.0 cut.
  • [4.105.0] REQUIRES npm run admin-production:

    • The Vue bell due_date fix edits the source bundle assets/admin/js/tasks/tasks.js. The admin bundle (public/ui/admin/dist/) must be rebuilt for the fix to take effect in production.