Appearance
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
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /rest/checkout/place-order | Guest/Customer | Create order and initialize payment in one call; returns {orderId, orderSerial, status, redirectUrl} |
| GET | /rest/checkout/payment-methods | Guest | Discover available payment methods for the current config; returns [{key, label, requiresRedirect}] |
| GET | /rest/checkout/payment-status/{orderId} | Customer | Check 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
| 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 | getVivaWalletSettings() 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) - 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 REST webhook;PiraeusAdapter.php:37-74 - ApcoPay:
ApcoPay::getPaymentUrl(); languageel→grmapped; statusUrl = legacycheckout/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
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)REST Webhook Coverage
| Payway | REST webhook | Validation |
|---|---|---|
| stripe | POST /rest/webhooks/stripe | HMAC-SHA256 via STRIPE.WEBHOOK_SECRET |
| vivawallet | POST /rest/webhooks/vivawallet | empty body → verification key exchange |
| paypaladvanced/paypal | POST /rest/webhooks/paypal | server-side re-fetch via PayPal Orders v2 GET (commit 4d15de008) |
| piraeus | POST /rest/webhooks/piraeus | HMAC validateNotificationResponse() |
| paybybank | POST /rest/webhooks/paybybank | none (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
| 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: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)
| 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: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:
| Method | Path | Auth | Response | Notes |
|---|---|---|---|---|
| POST | /rest/checkout/shipping | Any | Available transporters | Requires countryAlpha2 in body |
| POST | /rest/checkout/coupon/validate | Any | {valid: true/false} | Requires couponCode in body |
| POST | /rest/checkout/totals | Any | Cart totals | Returns 404 "No cart found" without active cart |
| GET | /rest/checkout/payment-status/{orderId} | Customer | Payment status | Post-payment polling |
These endpoints use the PlaceOrderService orchestrator with ShippingCalculator, CouponValidator, and all 19 registered payment adapters.
Known Issues & Security Gaps
XPay EUR hardcode is legacy-path only:
ecommercen/checkout/controllers/Adv_checkout.php:3474passes'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.Stale OpenAPI description in
paymentMethods()controller (src/Rest/Checkout/Controllers/Checkout.php:452-458): the OpenAPIdescriptionstill 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 inPaymentInitializerFactory. The generated OpenAPI spec is misleading for API consumers.payment-methodsreturns 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.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 sharedorder_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.
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