Skip to content

Payment Processing ​

Flow ID: CF-08 Module(s): checkout, Checkout domain Complexity: Very High Last Updated: 2026-09-29

Business Overview ​

Payment processing handles 19 payment method registrations (18 distinct adapter classes) through a central dispatcher. After checkout confirmation (CF-07), the Adv_checkout::run() method routes to the appropriate gateway on the legacy path; the REST path goes through PaymentInitializerFactory. Each follows: create order → redirect/render form → handle callback → update status.

All 18 adapter classes now have modern REST adapters registered via PaymentInitializerFactory. The legacy Adv_checkout::run() switch statement remains the path for storefront (non-REST) checkout; both paths share the same underlying gateway libraries.


API Reference ​

Modern REST Endpoints ​

MethodPathAuthDescription
POST/rest/checkout/place-orderGuest/CustomerCreate order and initialize payment in one call; accepts optional returnUrl/cancelUrl (or return_url/cancel_url) for the customer's own post-payment landing pages; returns {orderId, orderSerial, status} plus an optional paymentRedirect object (omitted when the payway has no redirect) and an optional transactionId (the gateway's own reference; omitted when the adapter returns none — #724). There is no redirectUrl key (src/Domains/Checkout/PlaceOrderResult.php:37-58)
GET/rest/checkout/payment-methodsGuestDiscover the payment methods the merchant selected for external frontends; returns {"paymentMethods":[{key, label, requiresRedirect}]}
GET/rest/checkout/payment-status/{orderId}CustomerCheck payment result after redirect
POST/rest/checkout/confirm-payment/{orderId}CustomerVerify payment with the gateway and confirm via PaymentConfirmationService (contract below)

GET /rest/checkout/payment-methods — Route rest_routes.php:1752 (+ locale-prefixed :1760); added in commit 633ae4bd7 (#228). Controller src/Rest/Checkout/Controllers/Checkout.php:1555-1587 (OA attribute :1534-1554) calls PaymentInitializerFactory::create() then PaymentInitializer::getRegisteredPaymentMethods(). The response body is {"paymentMethods":[...]} (Checkout.php:1586). It returns only the payways the merchant selected in METHODS.PAYWAY_EXTERNAL (Settings → Payments, #672) that also have a registered adapter (credentials configured, #673), in the merchant's saved order rather than registration order; the offline adapters are filtered by the same selection. An unset/empty key yields an empty list (fail-closed, no fallback to METHODS.PAYWAY), and place-order refuses anything outside the list with 422 payway_not_available (#749; OA text Checkout.php:1537-1549). The PAYMENT_METHOD_LABELS const (Checkout.php:69-79) covers 9 keys; all others return the raw key as label.

POST /rest/checkout/confirm-payment/{orderId} contract (Checkout.php:1458, OA :1478-1532): for Viva Wallet the request body must carry the gateway transactionId, otherwise the response is confirmed:false (#796, Checkout.php:1507-1511, :1797-1816). Since #754 PaymentConfirmationService::confirmPayment() no longer writes shop_order.is_paid (product-owner ruling in the code comment, src/Domains/Checkout/PaymentConfirmationService.php:58-81), and isPaid was removed from the payment-status and confirm-payment responses (Checkout.php:1361-1387, :1478-1532). Legacy gateway handlers still set it via set_is_paid() (Adv_order_model.php:1916-1918).

POST /rest/checkout/place-order 422 errors: besides payway_not_available and loyalty_redemption_exceeds_order_total, a payway the chosen transporter does not accept (cash on delivery with a transporter that does not take it) returns 422 payway_not_allowed_for_transporter with error.payway and error.transportId (#338, Checkout.php:1204-1218).

POST /rest/checkout/place-order — returnUrl/cancelUrl (#734): two optional body parameters, documented at Checkout.php:960 (returnUrl) and :993 (cancelUrl), name where the customer's browser should land after payment succeeds or is cancelled/fails — never a gateway-facing field. See ## Payment Confirmation below for how (and for which gateways) the redirect is actually delivered.

Browse endpoints interactively in the API Reference.

Legacy ​

TypePathPurpose
Internalcheckout->run($checkoutData)Payment dispatch
Callback/checkout/get_response/{gateway}Gateway returns
Webhook/checkout/handle/{gateway}/{action}Async notifications

Payment Gateways ​

All 19 registrations now go through PaymentInitializerFactory. Registration order: PaymentInitializerFactory.php:69-89. Any missing settings helper or empty required credential causes the adapter to be silently skipped — that payway will not appear in GET /rest/checkout/payment-methods or be accepted at POST /rest/checkout/place-order.

AdapterKeyRedirectGate
DeliveryAdapterdeliveryNoAlways
BankTransferAdapterbank_transferNoAlways
PaidAtStoreAdapterpaid_at_storeNoAlways
StripeAdapterstripeYesSTRIPE.SECRET_KEY
VivaWalletAdaptervivawalletYesgetVivaWalletMergedSettings() creds
PayPalAdapterpaypaladvancedYesgetPaypalAdvancedSettings() client_id + secret
PayPalExpressAdapterpaypalYesgetPaypalSettings() client_id + secret
JccAdapterjccYesgetJccSettings() username + password
IrisAdapteririsYesgetIrisSettings() username, password, customerCode, checkDigit
KlarnaAdapterklarna_paymentsYesgetKlarnaSettings() username + password
EthnikiAdapterethnikiYes (iframe)getEthnikiBankSettings() publicKey + privateKey
EthnikiNbgPayAdapterethniki_nbgpayYesgetEthnikiNBGPaySettings() appID + appKey
EthnikiEEAdapterethniki_eeYes (iframe)getEthnikiEEBankSettings() merchantId, directApiKey, host, apiVersion
XPayAdapterxpayYesgetXPaySettings() API_KEY
PiraeusAdapterpiraeusYesgetPiraeusBankSettings() → PiraeusConfig::isConfigured()
CardLinkAdapter (Alpha)alphaYesgetAlphaBankSettings() ID, SSK, Submit
CardLinkAdapter (Eurobank)eurobankYesgetEurobankSettings() merchantId, secret, url
ApcoPayAdapterapcopayYesgetApcoPaySettings() profileId, secret, merchantId, merchantPassword
PayByBankAdapterpaybybankNogetPayByBankSettings() payByBankApiKey + payByBankApiUrl

19 registrations, 18 distinct classes. Note: KlarnaAdapter key is klarna_payments (not klarna); PayPalExpressAdapter key is paypal (distinct from paypaladvanced); PayByBankAdapter has supportsRedirect() === false — it returns a bank payment code, not a redirect URL.


Per-Gateway initializePayment Notes ​

  • CardLink (Alpha/Eurobank): POST redirect; CardLink v2 SHA-256 digest over concatenated fields + secret; CardLinkAdapter.php:53-142
  • JCC: calls Jcc::registerOrder() (amount in integer cents, ISO 4217 numeric currency); returns GET redirect to formUrl; JccAdapter.php:49-75
  • IRIS: calls Iris::registerOrder(); GET redirect to bankSelectionToolUrl; IrisAdapter.php:45-72
  • Klarna: requires klarna_authorization_token in paymentData; Klarna::createOrder() converts the token. Order lines are itemised from the persisted order + basket (Order\Repository + OrderBasket\Repository, injected nullable) mirroring the legacy KlarnaHelper::generateOrderLines() construction — one line per basket row (SKU as name/reference) plus order-level shipping/coupon/points/gift lines, with order_amount = shop_order.total_vat. A reconciliation guard falls back to a single consolidated line when the itemised lines don't sum exactly to the order total (Klarna rejects order_amount != Σ order_lines.total_amount), so itemisation can never break checkout. When paymentData lacks the front-end token, the adapter recovers it from the webhook-stored callback row via the Order\KlarnaPayment repository (recoverTokenFromCallback(), #307) — the resilient path the legacy getAuthorizationToken() also uses. KlarnaAdapter::initializePayment() / resolveOrderLines() (#304, #307). Hardened in this change: klarnaCreateOrderSuccess() (Adv_checkout.php:3688-3717) now builds $co_data with a plain (array) cast at :3698; the previous json_decode(json_encode($orderData), true) returned null on any non-UTF-8 byte, silently blanking the confirmation email for orders Klarna had already marked PAID.
  • Ethniki iBank: GET redirect to Simplify JS SDK URL with data-* params; EthnikiAdapter.php:66-98
  • NBGPay: NBGPayHelper::createHPPLink(); phone split via PhoneHelper; locale injected from config_item('language_abbr'); EthnikiNbgPayAdapter.php:51-86
  • Ethniki EE: NBGEEHelper::beginNewSession(); session-based validation; Greek fields uppercased+transliterated; EthnikiEEAdapter.php:88-176
  • XPay: XPay::createHostedPaymentOrder(); notification URL = legacy checkout/xPayHook; XPayAdapter.php:61-98 — order descriptor localized via t('checkout.xpay.order_description', [serial, siteName]) — matches legacy storefront.
  • Piraeus: SOAP Piraeus::issueTicket(); POST redirect with form params; confirmation via the legacy checkout/get_response/piraeus callback (Adv_checkout::piraeusResponse(), ecommercen/checkout/controllers/Adv_checkout.php:2084; success/cancel at :2172 / :2214); PiraeusAdapter.php:37-74
  • ApcoPay: ApcoPay::getPaymentUrl(); language el→gr mapped; statusUrl = legacy checkout/apcopay_status; ApcoPayAdapter.php:71-116. Fixed in this change: removed redundant shop_order re-read (Adv_checkout.php:2754-2759) that overwrote the customer-joined row, causing contact data (email, mobile, phone) to be transmitted as null to the live gateway. The join now survives to the reads at :2782-2784.
  • PayByBank: PayByBank::createOrder(); NO redirect — returns bank payment code; confirmation via the legacy payByBankResponse() callback (Adv_checkout.php:1062) and polling — the PollPayByBankStatus job (src/Domains/Checkout/Jobs/PollPayByBankStatus.php) routes through PaymentConfirmationService::confirmPayment() / cancelPayment(); its schedule entry is opt-in (commented out) at application/config/jobs.php:48; PayByBankAdapter.php:45-70. Since #724 PlaceOrderService writes the code to shop_order.pbb_payment_code as well as tran_ticket, keyed on payway === 'paybybank' (PlaceOrderService.php:720-759), and place-order returns it as transactionId so a guest can read it without the customer-only payment-status endpoint (PlaceOrderResult.php:37-58)
  • PayPal Express: PayPal Orders v2 createOrder with user_action: CONTINUE; GET redirect to approve/payer-action link; PayPalExpressAdapter.php:41-102

Order descriptor localization (legacy parity) ​

XPay, CardLink Alpha, Ethniki, Ethniki EE, VivaWallet, and PayPal Express adapters now render their gateway-facing descriptor via the same t() key the legacy storefront uses, so the descriptor on the customer's statement or bank page is identical and localized across both checkout paths. All keys (checkout.xpay.order_description, checkout.alpha.order_description, checkout.ethniki.order_description, hct_eshop_name, hct_paypal_express_description) already exist in all 8 locale files — no language file changes were needed.

CardLink has acquirer-specific descriptor logic: Alpha uses t('checkout.alpha.order_description', [serial, siteName]) (wired via orderDescriptionKey: 'checkout.alpha.order_description' at PaymentInitializerFactory.php:499 inside registerAlpha()); Eurobank keeps the legacy literal "{siteName} Order {serial}" (matching Adv_checkout::_eurobank() directly — no key is set).

Two behavior changes worth noting for reconciliation:

  • VivaWallet customerTrns changed from 'Order #'.$serial to t('hct_eshop_name').' '.$serial (e.g. "Eshop ORD-2026-042"); VivaWalletAdapter.php:43.
  • PayPal Express purchase_units[0].description changed from 'Order #'.$serial to the static t('hct_paypal_express_description') (e.g. "Eshop Order") — the serial is still carried in reference_id; PayPalExpressAdapter.php:51.

Adapters deliberately NOT changed (no localized legacy descriptor to match): PayPalAdapter (paypaladvanced — no localized legacy descriptor to match; the legacy flow sets a page title only, not a PayPal API descriptor field), StripeAdapter, KlarnaAdapter (per-product line items), EthnikiNbgPayAdapter (no gateway descriptor field).


Universal Success Path ​

1. order_model->set_status(orderSerial, 'PAID', ignoreStock=true)
2. order_model->set_is_paid(orderSerial)
3. afterOrderSuccessHooks()         → ERP sync
4. adv_mailer->order_complete()     → confirmation email
5. sendSmsSuccess()                 → SMS notification
6. informLowStock()                 → admin alert
7. if (guest): customer_exit()
8. cart->destroy()                  → clear session cart

Universal Failure Path ​

1. set_status(orderSerial, 'CANCELED', stockMode='+')  → return stock
2. cancelCoupon()                   → markCouponUnused()
3. returnPointsToCustomers()        → loyalty rollback
4. afterOrderCancelHooks()          → ERP sync (cancel)

Payment Confirmation ​

All 19 payway registrations confirm via the legacy path — see CF-09 Payment Webhooks for the full breakdown of which gateways have a dedicated asynchronous webhook (JCC, Viva Wallet, Klarna, Skroutz SmartCart, plus XPay's in-controller handlers) versus which confirm solely via the synchronous browser-redirect/POST-back callback, checkout/get_response/{gateway}. Either way, REST-placed orders are resolved by shared order_serial, exactly like storefront-placed ones. A REST-only deployment still requires legacy checkout routes to be mounted for confirmation.

A REST-native webhook family (/rest/webhooks/{stripe,vivawallet,paypal,piraeus,paybybank}) existed briefly (added 4.99.6) and was retired in Advisable-com/ecommercen#721: Webhook::class never had a rest_policies.php entry, so every request 401'd from the day it shipped, and no gateway was ever configured to call it. See the rest_api_versions.php entry and #721 for the full rationale.

Client redirect on confirmation (#734) ​

A REST-placed order may supply returnUrl/cancelUrl on POST /rest/checkout/place-order (Checkout.php:960, :993) — its own page for the customer to land on once payment succeeds or is cancelled/fails. PaymentCallbackUrlBuilder carries that value through to the legacy gateway callback as the CLIENT_RETURN_URL_PARAM query parameter (advReturnUrl, src/Domains/Checkout/Payment/PaymentCallbackUrlBuilder.php:113); only a REST-placed order's callback URL carries it, since the storefront builds its callbacks inline and never sets the parameter.

Once the legacy checkout/get_response/{gateway} callback reaches a terminal status, redirectToClientReturnUrl() (ecommercen/checkout/controllers/Adv_checkout.php:862-879) checks for that query parameter; if present, it issues a 303 redirect (Location: header) to the client's URL instead of rendering this deployment's legacy thank-you/failure view. This is wired at the tail of alphaResponseSuccess()/alphaResponseFail(), eurobankResponseSuccess()/eurobankResponseFail(), and the shared ethnikiResponseSuccess() (serving both ethniki and ethniki_ee) — five call sites in total (Adv_checkout.php:783, :934, :989, :1399, :1455; roster in the docblock :798-801). It is not wired for iris or klarna_payments — per Checkout.php:975-980's own "KNOWN LIMITATION" note, those two gateways' onward redirect was never wired, so the customer lands on this deployment's confirmation page instead. ethniki's failure leg and ethniki_ee's cancel/fail legs are also deliberately absent, each for its own tracked reason (deliberately-absent arms documented at Adv_checkout.php:802-810).


Business Rules ​

RuleDescription
Order must be PENDINGAll success handlers check status before updating
Stock not adjusted on successignoreStock=true — stock deducted at creation
Stock returned on cancelreturnOrderStock() adds qty back
Coupon rolled back on cancelUsage counter decremented
Points rolled back on cancelBoth spent and earned reversed
Guest logged out on successcustomer_exit() clears session
Installments validatedminOrderAmount threshold + allowed values per gateway

Client Extension Points ​

TypeDetails
Legacy hooksafterOrderSuccessHooks(), afterOrderCancelHooks(), successExtras()
Modern adaptersImplement PaymentAdapterInterface for custom gateways
DI overrideReplace PaymentInitializerFactory for custom adapter registration
Gateway credentialsAll configured via Registry (per-gateway group)
Piraeus cardholder nameOverride formatPiraeusCardholderName() to sanitize characters rejected by Paycenter IssueNewTicket calls — REST: src/Piraeus/Piraeus.php:227 (called at :205), legacy: ecommercen/checkout/controllers/Adv_checkout.php:1980 (called at :2042)

Klarna Legacy AJAX API ​

These endpoints are served by AdvApiKlarna (ecommercen/api/controllers/AdvApiKlarna.php) and manage the Klarna Payments lifecycle from the admin panel and storefront. All endpoints read POST data and return JSON via sendOutput(). Bot requests are rejected.

Base path: /api/klarna/{method} (routed via api/api_klarna)

MethodPathParametersAuthResponseDescription
POST/api/klarna/openPaymentSessionintent, purchase_country, purchase_currency, order_amount, transportationCost, couponDiscount, giftPackagingCost, pointsDiscountSession{session_id, client_token, ...}Create a Klarna payment session with computed order lines from the current cart; stores session_id in shop_order_klarna_payment. The session locale is derived server-side from config_item('language_abbr') via KlarnaHelper::localeForLanguage() (#309) — any client-posted locale is ignored.
POST/api/klarna/updatePaymentSessionsession_id, order_amount, transportationCost, couponDiscount, giftPackagingCost, pointsDiscountSession{success: bool}Update an existing Klarna session (e.g., after cart or address changes) with recalculated order lines
POST/api/klarna/cancelOrderklarnaPaymentsOrderId, orderIdSession{} on success; 403 on failureCancel a Klarna order; updates payment status to canceled and sets shop order status to CANCELED
POST/api/klarna/captureOrderklarnaPaymentsOrderId, orderIdSession{} on success; 403 on failureCapture (charge) a previously authorized Klarna order for the full total_vat amount; updates payment status to captured
POST/api/klarna/refundOrderklarnaPaymentsOrderId, orderIdSession{} on success; 403 on failureRefund a captured Klarna order for the full total_vat amount; updates payment status to refunded and sets shop order status to CANCELED

Order lines: Generated by KlarnaHelper::generateOrderLines() from current cart contents, live pricing data, transportation cost, coupon discount, gift packaging cost, and points discount.

Amounts (integer minor units): Klarna requires all monetary values as integer minor units per ISO 4217 (int64). Both the API client and the order-line helper convert via a toMinorUnits() helper — (int) round($amount * 100) (src/PaymentGateways/Klarna/Klarna.php, src/PaymentGateways/Klarna/KlarnaHelper.php). This covers order_amount (open/update session + create order), captured_amount, refunded_amount, and every order-line unit_price / total_amount / total_discount_amount. Before #302 these were unrounded floats (e.g. 19.99 * 100 = 1998.9999999999998), which could violate the int64 schema and the order_amount == Σ order_lines.total_amount reconciliation; the modern KlarnaAdapter and the JS widget already rounded correctly.

EMD attachment (Advisable-com/ecommercen#448): Klarna::openPaymentSession(), updatePaymentSession(), and createOrder() (src/PaymentGateways/Klarna/Klarna.php) each gained a trailing optional ?array $attachment = null param, passed through untouched as Klarna's Extra Merchant Data attachment field when provided (null by default — no behavior change for existing callers). This unblocks travel/ferry segments (e.g. ferry_reservation_details) where Klarna's risk assessment requires it. EMD can carry consumer PII, so for EU orders it should not be injected at openPaymentSession (session creation) — the GDPR-appropriate point is updatePaymentSession once context/consent is known, or ideally the client-side Klarna authorize() call. Neither AdvApiKlarna (this legacy AJAX API) nor the modern KlarnaAdapter populate $attachment yet — the parameter is plumbing only, available for a caller (e.g. a client fork) to use.


Data Model ​

Key tables involved in payment processing ​

TablePurpose
shop_orderOrder record — status transitions (PENDING -> PAID or CANCELED), payway identifies gateway
shop_order_basketOrder line items — stock adjusted on creation, returned on cancellation
shop_order_klarna_paymentKlarna-specific session data (session_id, order_id, status: authorized/captured/refunded/canceled)
shop_order_klarna_paymentsExtended Klarna Payments tracking (see schema below)
couponsCoupon usage counter — incremented on order creation, decremented on cancellation
shop_customerCustomer record — loyalty points balance updated (deducted pre-payment, returned on cancellation)
shop_order_transactionsPayment transaction log (gateway reference, amount, status, timestamp)
pbb_loggingPayByBank transaction log (~14.5K rows in production); tracks PBB payment lifecycle events
xpay_loggingXPay transaction audit trail — request/response payloads (TEXT JSON), flow discriminator (order vs gift_card) for cross-flow token isolation; the securityToken is embedded in response_data JSON and read back by getStoredSecurityToken() (:308-335) for webhook verification. No dedicated token column.

For the full shop_order column schema, see AD-03 Order Management.

shop_order_klarna_payments — Klarna Payments extended tracking ​

ColumnTypeDescription
idINT AUTO_INCREMENTPrimary key
klarna_payments_order_idVARCHARKlarna-assigned order identifier
fraud_statusVARCHARKlarna fraud assessment result
authorized_payment_methodVARCHARPayment method authorized by the customer (e.g., pay_later, slice_it)
session_idVARCHARKlarna session identifier (links to shop_order_klarna_payment.session_id)
authorization_tokenVARCHARToken received after customer authorization, used to place the Klarna order
shop_order_idINTFK to shop_order.id
statusVARCHARPayment status (authorized, captured, refunded, canceled)
auto_captureTINYINTWhether the order was auto-captured on authorization (1 = yes)

NBG EE (Ethniki Enterprise Edition) — Undocumented Gateway ​

NBG EE is completely undocumented externally. It uses a session-based checkout flow with version-specific endpoints:

  • V62: CREATE_CHECKOUT_SESSION — initiates a checkout session with the bank
  • V63: INITIATE_CHECKOUT — starts the checkout process within an established session

The flow relies on server-side session state rather than token-based authorization. Validation is session-based (no digest or HMAC). This gateway has no public API documentation from NBG — all integration knowledge is derived from implementation code and internal communications.

XPay (Nexi) — securityToken echo, no HMAC ​

XPay Greece does not use HMAC for webhook authenticity. Instead, the gateway returns a securityToken in the HPP-create response (src/PaymentGateways/NexiXPay/XPay.php:151), persists it in xpay_logging keyed by (order_serial, flow), and echoes it back in every server-to-server xPayHook notification. The token is compared via hash_equals() (XPay.php:304) and fails closed if either side is missing. getStoredSecurityToken() (:315) retrieves the stored token from the response_data JSON field for verification.

The flow column ('order' vs 'gift_card') ensures a gift-card webhook cannot accept a regular order's stored token even when GIFT_CARDS.ORDER_PREFIX is empty and serials happen to collide.


Headless Checkout Endpoints (REST, v4.99.3+) ​

The modern headless commerce layer provides stateless checkout via REST:

MethodPathAuthResponseNotes
POST/rest/checkout/shippingAnyAvailable transportersRequires countryAlpha2 in body. Threshold-aware since #568 — cost applies the free-shipping threshold, TRANS_FREE_ALL, and the overweight per-kg surcharge against the current cart; also returns overweightCost (display-only) and deliveryCost (COD surcharge, keyed off an optional payWay field)
POST/rest/checkout/coupon/validateAny{valid: true/false}Requires couponCode in body
POST/rest/checkout/totalsAnyCart totals with gifts and loyaltyReturns 404 "No cart found" without active cart. Since #595, emits giftDiscount, giftsNearMiss, giftPackagingCost, pointsSpend, pointsCash, payableBeforePoints, and a loyalty decision block; computes via GiftMatcher, OrderBasketBuilder, GiftPackagingResolver, and LoyaltyRedemption (src/Rest/Checkout/Controllers/Checkout.php:712, :808-810, :835-839, :841-858). Coupon deducted from gross subtotal BEFORE shipping threshold since #568
POST/rest/checkout/place-orderGuest email or CustomerCreated orderPlaces the order via PlaceOrderService — the only one of these endpoints that does. Accepts optional returnUrl/cancelUrl body params (Checkout.php:960, :993, #734) naming the customer's own post-payment landing pages — see ## Payment Confirmation
POST/rest/checkout/cancel-payment/{orderId}Customer{status: 'canceled' | errorIdempotent cancellation of a PENDING order; returns 404 if order not found or not PENDING. Reasons: already_canceled, invalid_status (src/Rest/Checkout/Controllers/Checkout.php:1642-1694, OA :1628-1641)
GET/rest/checkout/payment-status/{orderId}CustomerPayment statusPost-payment polling

PlaceOrderService is the order-placement orchestrator used by place-order only — it in turn calls ShippingCalculator (with CartWeightCalculator's cart-weight figure, #568) and CouponValidator, then dispatches to whichever of the 19 registered payment adapters matches the order's payway. shipping, coupon/validate, and totals are lighter-weight quote/preview endpoints: each calls ShippingCalculator and/or CouponValidator directly from Checkout without going through PlaceOrderService, so a client can preview a price before committing to place-order.


Known Issues & Security Gaps ​

  1. XPay EUR hardcode is legacy-path only: ecommercen/checkout/controllers/Adv_checkout.php:3878 passes 'EUR' as the currency regardless of the active storefront currency on the legacy storefront path only. The modern XPayAdapter correctly passes $context->currencyCode (XPayAdapter.php:71) and is not affected. Gift card payments on the legacy path correctly use $this->currentCurrency->code. See Advisable-com/ecommercen#249 for follow-up scope.

  2. payment-methods returns raw keys as labels for 10 of 19 payways: PAYMENT_METHOD_LABELS (Checkout.php:69-79) covers only 9 keys. eurobank, jcc, iris, klarna_payments, ethniki, ethniki_nbgpay, ethniki_ee, piraeus, paybybank, and paypal all return their raw key as the display label. Minor gap for headless clients that rely on the label field.

  3. No dedicated REST webhook for any gateway: the REST webhook family (/rest/webhooks/{stripe,vivawallet,paypal,piraeus,paybybank}) was retired in Advisable-com/ecommercen#721 — it had never been reachable (401 to every caller since the routes were declared in 4.99.6, continuing unconditionally once 4.104.0 extracted them into their own route file; Webhook::class never had a rest_policies.php entry) and no gateway was ever configured to call it. Every payway confirms via legacy: jcc, klarna, xpay, and Viva Wallet/vivawallet have a dedicated asynchronous webhook; iris, ethniki×3, alpha, eurobank, apcopay, stripe, paypaladvanced/paypal, and piraeus confirm solely via the synchronous checkout/get_response/{gateway} callback. Either way, REST-placed orders are resolved by shared order_serial. A REST-only deployment still requires legacy checkout routes to be mounted for confirmation.

  4. Unguarded post-commit order re-reads in gateway adapters (read/write-split deployments only): three legacy gateway methods re-read the just-created order post-commit and dereference it without staleness checks:

    • jcc() (Adv_checkout.php:3361): re-read at :3363, dereferenced unguarded at :3368 — (int)($orderObj->total_vat * 100) — transmitting an amount of 0 to JCC if the read lags behind the write.
    • iris() (:3495): re-read at :3497; same pattern, 'instructedAmount' => (int)($orderObj->total_vat * 100) at :3502, plus $orderObj->id at :3505.
    • klarnaPayments() (:3609): the re-read at :3618 flows to two distinct non-nullable object parameters across three call sites: klarnaCreateOrderFail(object $orderData) (signature :3672, called at :3623 and :3657) and klarnaCreateOrderSuccess(object $orderData, array …) (signature :3688, called at :3668). A stale read is therefore a fatal TypeError, not a degraded render; also dereferenced unguarded at :3637.
    • Contrast: xpay() (:3823) issues the same re-read at :3829 but is guarded (:3831-3836: log error, set order_error, redirect to preview_order), so it degrades gracefully rather than fataling.

    Exploitability: Reachable only on a read/write-split deployment. Reported closed in production at the infrastructure layer per docs/changelog/Changelog.4.119.md:17 — MaxScale causal_reads: "local" (+ causal_reads_timeout: "3s") enforces read-after-write consistency. Rollout: wecare 2026-07-23; realm-1 and seajets 2026-07-24. The defence-in-depth form of these checks is preserved on the spike/508-carry-forward-defence-in-depth branch.

  5. Stale comment refers to the retired REST webhook (low severity): PollPayByBankStatus::reconcile() carries the comment // We may have missed the webhook — capture here. (src/Domains/Checkout/Jobs/PollPayByBankStatus.php:117-118, the PAID/COMPLETED arm at :118-123), but the REST webhook family was retired in Advisable-com/ecommercen#721 (see item 3), so there is no REST webhook to have missed. Comment-only; behavior is unaffected.

For additional XPay webhook and security gaps, see CF-09 Payment Webhooks and IN-23 Nexi XPay Greece.


Tests ​

Test FileCoverage
tests/Unit/Checkout/PaymentInitializerTest.phpPayment adapter registration and gating
tests/Unit/Checkout/Payment/**Individual adapter initialization tests
tests/Legacy/Checkout/AdvCheckoutTest.phpLegacy gateway dispatch and success/failure paths
tests/Legacy/Eshop/AdvPaymentsRegistryTest.phpPayment method registry and adapter list
tests/Legacy/Webhooks/AdvKlarnaPaymentsTest.phpKlarna webhook callback handling

Wiki Guides: Stripe integration setup and keys — see Stripe Guide. Gateway calls are protected by circuit breakers — see Circuit Breaker Guide. Post-payment hooks run as deferred tasks — see Deferred Task Guide.

Shared Patterns ​