Skip to content

Payment Processing

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

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; returns {orderId, orderSerial, status, redirectUrl}
GET/rest/checkout/payment-methodsGuestDiscover available payment methods for the current config; returns [{key, label, requiresRedirect}]
GET/rest/checkout/payment-status/{orderId}CustomerCheck payment result after redirect

GET /rest/checkout/payment-methods — Route rest_routes.php:1709 (+ locale-prefixed :1717); added in commit 633ae4bd7 (#228). Controller src/Rest/Checkout/Controllers/Checkout.php:464-479 calls PaymentInitializerFactory::create() then PaymentInitializer::getRegisteredPaymentMethods() (:65-77). Only returns adapters whose gate conditions are met (credentials configured). The PAYMENT_METHOD_LABELS const (Checkout.php:50-60) covers 9 keys; all others return the raw key as label.

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
VivaWalletAdaptervivawalletYesgetVivaWalletSettings() 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)
  • 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 REST webhook; PiraeusAdapter.php:37-74
  • ApcoPay: ApcoPay::getPaymentUrl(); language elgr mapped; statusUrl = legacy checkout/apcopay_status; ApcoPayAdapter.php:71-116
  • PayByBank: PayByBank::createOrder(); NO redirect — returns bank payment code; confirmation via REST webhook; PayByBankAdapter.php:45-70
  • 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)

REST Webhook Coverage

PaywayREST webhookValidation
stripePOST /rest/webhooks/stripeHMAC-SHA256 via STRIPE.WEBHOOK_SECRET
vivawalletPOST /rest/webhooks/vivawalletempty body → verification key exchange
paypaladvanced/paypalPOST /rest/webhooks/paypalserver-side re-fetch via PayPal Orders v2 GET (commit 4d15de008)
piraeusPOST /rest/webhooks/piraeusHMAC validateNotificationResponse()
paybybankPOST /rest/webhooks/paybybanknone (mirrors legacy unsigned pattern)

jcc, iris, klarna, ethniki×3, xpay, alpha, eurobank, and apcopay still confirm via legacy CI3 callback routes (e.g. checkout/xPayHook, checkout/apcopay_status), resolving REST-placed orders by shared order_serial. A REST-only deployment still requires legacy checkout routes to be mounted for these gateways.


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:1636 (called at :1699)

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:145), persists it in xpay_logging keyed by (order_serial, flow), and echoes it back in every server-to-server xPayHook notification. The controller compares via hash_equals() (:297) and fails closed if either side is missing.

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
POST/rest/checkout/coupon/validateAny{valid: true/false}Requires couponCode in body
POST/rest/checkout/totalsAnyCart totalsReturns 404 "No cart found" without active cart
GET/rest/checkout/payment-status/{orderId}CustomerPayment statusPost-payment polling

These endpoints use the PlaceOrderService orchestrator with ShippingCalculator, CouponValidator, and all 19 registered payment adapters.


Known Issues & Security Gaps

  1. XPay EUR hardcode is legacy-path only: ecommercen/checkout/controllers/Adv_checkout.php:3474 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. Stale OpenAPI description in paymentMethods() controller (src/Rest/Checkout/Controllers/Checkout.php:452-458): the OpenAPI description still claims Eurobank, Ethniki×3, Piraeus, JCC, Iris, Klarna, PayByBank, and PayPal Express are "intentionally NOT surfaced" and "will appear once ported" — but all of these ARE ported and ARE registered in PaymentInitializerFactory. The generated OpenAPI spec is misleading for API consumers.

  3. payment-methods returns raw keys as labels for 10 of 19 payways: PAYMENT_METHOD_LABELS (Checkout.php:50-60) 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.

  4. No dedicated REST webhook for 8 gateways: jcc, iris, klarna, ethniki×3, xpay, alpha, eurobank, and apcopay confirm payment via legacy CI3 callback routes (e.g. checkout/xPayHook, checkout/apcopay_status), resolving REST-placed orders by shared order_serial. A REST-only deployment still requires legacy checkout routes to be mounted for these gateways.

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


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