Appearance
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
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /rest/checkout/place-order | Guest/Customer | Create 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-methods | Guest | Discover the payment methods the merchant selected for external frontends; returns {"paymentMethods":[{key, label, requiresRedirect}]} |
| GET | /rest/checkout/payment-status/{orderId} | Customer | Check payment result after redirect |
| POST | /rest/checkout/confirm-payment/{orderId} | Customer | Verify 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
| Type | Path | Purpose |
|---|---|---|
| Internal | checkout->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.
| Adapter | Key | Redirect | Gate |
|---|---|---|---|
| DeliveryAdapter | delivery | No | Always |
| BankTransferAdapter | bank_transfer | No | Always |
| PaidAtStoreAdapter | paid_at_store | No | Always |
| StripeAdapter | stripe | Yes | STRIPE.SECRET_KEY |
| VivaWalletAdapter | vivawallet | Yes | getVivaWalletMergedSettings() creds |
| PayPalAdapter | paypaladvanced | Yes | getPaypalAdvancedSettings() client_id + secret |
| PayPalExpressAdapter | paypal | Yes | getPaypalSettings() client_id + secret |
| JccAdapter | jcc | Yes | getJccSettings() username + password |
| IrisAdapter | iris | Yes | getIrisSettings() username, password, customerCode, checkDigit |
| KlarnaAdapter | klarna_payments | Yes | getKlarnaSettings() username + password |
| EthnikiAdapter | ethniki | Yes (iframe) | getEthnikiBankSettings() publicKey + privateKey |
| EthnikiNbgPayAdapter | ethniki_nbgpay | Yes | getEthnikiNBGPaySettings() appID + appKey |
| EthnikiEEAdapter | ethniki_ee | Yes (iframe) | getEthnikiEEBankSettings() merchantId, directApiKey, host, apiVersion |
| XPayAdapter | xpay | Yes | getXPaySettings() API_KEY |
| PiraeusAdapter | piraeus | Yes | getPiraeusBankSettings() → PiraeusConfig::isConfigured() |
| CardLinkAdapter (Alpha) | alpha | Yes | getAlphaBankSettings() ID, SSK, Submit |
| CardLinkAdapter (Eurobank) | eurobank | Yes | getEurobankSettings() merchantId, secret, url |
| ApcoPayAdapter | apcopay | Yes | getApcoPaySettings() profileId, secret, merchantId, merchantPassword |
| PayByBankAdapter | paybybank | No | getPayByBankSettings() 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 toformUrl;JccAdapter.php:49-75 - IRIS: calls
Iris::registerOrder(); GET redirect tobankSelectionToolUrl;IrisAdapter.php:45-72 - Klarna: requires
klarna_authorization_tokeninpaymentData;Klarna::createOrder()converts the token. Order lines are itemised from the persisted order + basket (Order\Repository+OrderBasket\Repository, injected nullable) mirroring the legacyKlarnaHelper::generateOrderLines()construction — one line per basket row (SKU as name/reference) plus order-level shipping/coupon/points/gift lines, withorder_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 rejectsorder_amount != Σ order_lines.total_amount), so itemisation can never break checkout. WhenpaymentDatalacks the front-end token, the adapter recovers it from the webhook-stored callback row via theOrder\KlarnaPaymentrepository (recoverTokenFromCallback(), #307) — the resilient path the legacygetAuthorizationToken()also uses.KlarnaAdapter::initializePayment()/resolveOrderLines()(#304, #307). Hardened in this change:klarnaCreateOrderSuccess()(Adv_checkout.php:3688-3717) now builds$co_datawith a plain(array)cast at:3698; the previousjson_decode(json_encode($orderData), true)returnednullon 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 viaPhoneHelper; locale injected fromconfig_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 = legacycheckout/xPayHook;XPayAdapter.php:61-98— order descriptor localized viat('checkout.xpay.order_description', [serial, siteName])— matches legacy storefront. - Piraeus: SOAP
Piraeus::issueTicket(); POST redirect with form params; confirmation via the legacycheckout/get_response/piraeuscallback (Adv_checkout::piraeusResponse(),ecommercen/checkout/controllers/Adv_checkout.php:2084; success/cancel at:2172/:2214);PiraeusAdapter.php:37-74 - ApcoPay:
ApcoPay::getPaymentUrl(); languageel→grmapped; statusUrl = legacycheckout/apcopay_status;ApcoPayAdapter.php:71-116. Fixed in this change: removed redundantshop_orderre-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 legacypayByBankResponse()callback (Adv_checkout.php:1062) and polling — thePollPayByBankStatusjob (src/Domains/Checkout/Jobs/PollPayByBankStatus.php) routes throughPaymentConfirmationService::confirmPayment()/cancelPayment(); its schedule entry is opt-in (commented out) atapplication/config/jobs.php:48;PayByBankAdapter.php:45-70. Since #724PlaceOrderServicewrites the code toshop_order.pbb_payment_codeas well astran_ticket, keyed onpayway === 'paybybank'(PlaceOrderService.php:720-759), andplace-orderreturns it astransactionIdso a guest can read it without the customer-onlypayment-statusendpoint (PlaceOrderResult.php:37-58) - PayPal Express: PayPal Orders v2
createOrderwithuser_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
customerTrnschanged from'Order #'.$serialtot('hct_eshop_name').' '.$serial(e.g."Eshop ORD-2026-042");VivaWalletAdapter.php:43. - PayPal Express
purchase_units[0].descriptionchanged from'Order #'.$serialto the statict('hct_paypal_express_description')(e.g."Eshop Order") — the serial is still carried inreference_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 cartUniversal 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::classnever had arest_policies.phpentry, so every request 401'd from the day it shipped, and no gateway was ever configured to call it. See therest_api_versions.phpentry 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
| Rule | Description |
|---|---|
| Order must be PENDING | All success handlers check status before updating |
| Stock not adjusted on success | ignoreStock=true — stock deducted at creation |
| Stock returned on cancel | returnOrderStock() adds qty back |
| Coupon rolled back on cancel | Usage counter decremented |
| Points rolled back on cancel | Both spent and earned reversed |
| Guest logged out on success | customer_exit() clears session |
| Installments validated | minOrderAmount threshold + allowed values per gateway |
Client Extension Points
| Type | Details |
|---|---|
| Legacy hooks | afterOrderSuccessHooks(), afterOrderCancelHooks(), successExtras() |
| Modern adapters | Implement PaymentAdapterInterface for custom gateways |
| DI override | Replace PaymentInitializerFactory for custom adapter registration |
| Gateway credentials | All configured via Registry (per-gateway group) |
| Piraeus cardholder name | Override 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)
| Method | Path | Parameters | Auth | Response | Description |
|---|---|---|---|---|---|
| POST | /api/klarna/openPaymentSession | intent, purchase_country, purchase_currency, order_amount, transportationCost, couponDiscount, giftPackagingCost, pointsDiscount | Session | {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/updatePaymentSession | session_id, order_amount, transportationCost, couponDiscount, giftPackagingCost, pointsDiscount | Session | {success: bool} | Update an existing Klarna session (e.g., after cart or address changes) with recalculated order lines |
| POST | /api/klarna/cancelOrder | klarnaPaymentsOrderId, orderId | Session | {} on success; 403 on failure | Cancel a Klarna order; updates payment status to canceled and sets shop order status to CANCELED |
| POST | /api/klarna/captureOrder | klarnaPaymentsOrderId, orderId | Session | {} on success; 403 on failure | Capture (charge) a previously authorized Klarna order for the full total_vat amount; updates payment status to captured |
| POST | /api/klarna/refundOrder | klarnaPaymentsOrderId, orderId | Session | {} on success; 403 on failure | Refund 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
| Table | Purpose |
|---|---|
shop_order | Order record — status transitions (PENDING -> PAID or CANCELED), payway identifies gateway |
shop_order_basket | Order line items — stock adjusted on creation, returned on cancellation |
shop_order_klarna_payment | Klarna-specific session data (session_id, order_id, status: authorized/captured/refunded/canceled) |
shop_order_klarna_payments | Extended Klarna Payments tracking (see schema below) |
coupons | Coupon usage counter — incremented on order creation, decremented on cancellation |
shop_customer | Customer record — loyalty points balance updated (deducted pre-payment, returned on cancellation) |
shop_order_transactions | Payment transaction log (gateway reference, amount, status, timestamp) |
pbb_logging | PayByBank transaction log (~14.5K rows in production); tracks PBB payment lifecycle events |
xpay_logging | XPay 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_ordercolumn schema, see AD-03 Order Management.
shop_order_klarna_payments — Klarna Payments extended tracking
| Column | Type | Description |
|---|---|---|
id | INT AUTO_INCREMENT | Primary key |
klarna_payments_order_id | VARCHAR | Klarna-assigned order identifier |
fraud_status | VARCHAR | Klarna fraud assessment result |
authorized_payment_method | VARCHAR | Payment method authorized by the customer (e.g., pay_later, slice_it) |
session_id | VARCHAR | Klarna session identifier (links to shop_order_klarna_payment.session_id) |
authorization_token | VARCHAR | Token received after customer authorization, used to place the Klarna order |
shop_order_id | INT | FK to shop_order.id |
status | VARCHAR | Payment status (authorized, captured, refunded, canceled) |
auto_capture | TINYINT | Whether 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:
| Method | Path | Auth | Response | Notes |
|---|---|---|---|---|
| POST | /rest/checkout/shipping | Any | Available transporters | Requires 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/validate | Any | {valid: true/false} | Requires couponCode in body |
| POST | /rest/checkout/totals | Any | Cart totals with gifts and loyalty | Returns 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-order | Guest email or Customer | Created order | Places 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' | error | Idempotent 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} | Customer | Payment status | Post-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
XPay EUR hardcode is legacy-path only:
ecommercen/checkout/controllers/Adv_checkout.php:3878passes'EUR'as the currency regardless of the active storefront currency on the legacy storefront path only. The modernXPayAdaptercorrectly 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.payment-methodsreturns 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.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::classnever had arest_policies.phpentry) 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 synchronouscheckout/get_response/{gateway}callback. Either way, REST-placed orders are resolved by sharedorder_serial. A REST-only deployment still requires legacy checkout routes to be mounted for confirmation.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 of0to 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->idat:3505.klarnaPayments()(:3609): the re-read at:3618flows to two distinct non-nullableobjectparameters across three call sites:klarnaCreateOrderFail(object $orderData)(signature:3672, called at:3623and:3657) andklarnaCreateOrderSuccess(object $orderData, array …)(signature:3688, called at:3668). A stale read is therefore a fatalTypeError, not a degraded render; also dereferenced unguarded at:3637.- Contrast:
xpay()(:3823) issues the same re-read at:3829but is guarded (:3831-3836: log error, setorder_error, redirect topreview_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— MaxScalecausal_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 thespike/508-carry-forward-defence-in-depthbranch.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, thePAID/COMPLETEDarm 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 File | Coverage |
|---|---|
tests/Unit/Checkout/PaymentInitializerTest.php | Payment adapter registration and gating |
tests/Unit/Checkout/Payment/** | Individual adapter initialization tests |
tests/Legacy/Checkout/AdvCheckoutTest.php | Legacy gateway dispatch and success/failure paths |
tests/Legacy/Eshop/AdvPaymentsRegistryTest.php | Payment method registry and adapter list |
tests/Legacy/Webhooks/AdvKlarnaPaymentsTest.php | Klarna webhook callback handling |
Related Flows
- CF-05 Cart Management — cart cleared on success
- CF-06 Order Preview — generates checkout data
- CF-07 Order Confirmation — order creation before payment
- CF-09 Payment Webhooks — async callbacks
- CF-13 Coupons — coupon rolled back on failure
- CF-23 Gift Cards — gift card payment via Viva Wallet
- CF-32 Loyalty Points — points rolled back on failure
- SY-02 Order Status Emails — confirmation email on success
- AD-03 Order Management — admin order status
- IN-08 ERP Integrations — ERP sync after payment success
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
- SY-24 Email Dispatch — order confirmation and cancellation emails dispatched after payment resolution
- SY-26 Circuit Breaker — protects external payment gateway API calls from cascading failures
- SY-27 Deferred Tasks — ERP sync (
afterOrderSuccessHooks) and SMS notifications run as deferred tasks