Skip to content

Nexi XPay Greece — Hosted Payment Page Integration ​

Flow ID: IN-23 | Module(s): checkout, gift_cards, job, eshop | Complexity: High Last Updated: 2026-09-29 — Adv_checkout.php / Adv_settings.php citations re-anchored for the 4.124.0 line shift (+3); previous note, 2026-09-14 — Advisable-com/ecommercen#729: getXPaySettings() citation moved again to registry_helper.php:586-594 after getVivaWalletExternalSettings() was inserted between getVivaWalletMergedSettings() and getJccSettings(); the helper itself is unchanged. (Previously moved to :534-542 by #706.)

Business Overview ​

Nexi XPay Greece is a card-payment gateway used by Greek merchants. The integration uses the Hosted Payment Page (HPP) model: the platform creates an order on the Nexi API, receives a redirect URL, and hands the browser off to Nexi's own checkout page. Nexi handles all card-number entry and 3-D Secure challenges. When the customer finishes, Nexi fires a server-to-server webhook and redirects the browser back to the merchant return URLs.

Two independent payment surfaces share the same gateway class:

  • Regular checkout (checkout module) — for standard shop orders stored in the shop_order table.
  • Gift card checkout (gift_cards module) — for gift card purchases stored in gift_card_orders.

Both environments (sandbox and production) are supported via a single boolean toggle. URLs:

  • Sandbox: https://xpaysandbox.nexigroup.com/api/phoenix-0.0/psp/ (src/PaymentGateways/NexiXPay/XPay.php:12)
  • Production: https://xpay.nexigroup.com/api/phoenix-0.0/psp/ (src/PaymentGateways/NexiXPay/XPay.php:11)

API Reference ​

HPP Order Creation ​

Request — POST api/v1/orders/hpp

Headers:

HeaderValue
X-API-KEYMerchant API key from registry
Correlation-IdUUID v4 generated per request
Content-Typeapplication/json

Payload shape (src/PaymentGateways/NexiXPay/XPay.php:84-102):

order.orderId          → merchant order serial
order.amount           → cents (EUR×100)
order.currency         → 'EUR'
order.customerId       → optional
order.description      → optional
order.customerInfo.cardHolderEmail         → optional
order.customerInfo.mobilePhoneCountryCode  → optional
order.customerInfo.mobilePhone             → optional
paymentSession.actionType   → 'PAY'
paymentSession.recurrence.action → 'NO_RECURRING'
paymentSession.amount       → cents
paymentSession.language     → ISO 639-2 (default 'ELL' for Greek)
paymentSession.paymentService → 'cards'
paymentSession.resultUrl    → success redirect URL
paymentSession.cancelUrl    → cancel redirect URL
paymentSession.notificationUrl → server-to-server webhook URL

Success response (HTTP 200):

FieldDescription
hostedPageURL the browser must be redirected to
orderIdNexi-side order identifier (stored as tran_ticket)
securityTokenOpaque token echoed back in every webhook notification

If the response lacks hostedPage, the method returns null and logs an error (src/PaymentGateways/NexiXPay/XPay.php:156-158).

Order Status Query ​

Request — GET api/v1/orders/{xpayOrderId} (src/PaymentGateways/NexiXPay/XPay.php:350)

Returns the latest operation from the operations array (index 0). The caller uses operationResult to determine the current payment state.

operationResult Status Mapping ​

XPay::mapOperationResultToStatus() (src/PaymentGateways/NexiXPay/XPay.php:403-421):

Nexi operationResultInternal status
AUTHORIZED, EXECUTEDPAID
DECLINED, DENIED_BY_RISK, THREEDS_FAILED, VOIDED, FAILED, CANCELEDCANCELED
THREEDS_VALIDATED, PENDINGPENDING
REFUNDEDREFUNDED
anything elseUNKNOWN

Webhook Notification Shape ​

Nexi delivers a JSON body with a top-level operation object. Real Nexi/XPay Greece S2S notifications carry no top-level order — that key only appears in the order-creation response shape (see above). Required fields:

  • operation (object, required)
  • operation.operationResult (string)
  • operation.orderId (merchant order serial) — falls back to a top-level order.orderId only for the order-creation shape
  • securityToken (root level — echoed from HPP creation response)

XPay::parsePaymentNotification() throws \InvalidArgumentException in exactly three cases (src/PaymentGateways/NexiXPay/XPay.php:222-248; fixed in Advisable-com/ecommercen#427):

  • operation absent — "Missing required field in notification: operation"
  • operation.operationResult absent — "Missing operationResult in notification"
  • no order id in either operation.orderId or order.orderId — "Missing orderId in notification"

Code Flow ​

Regular Checkout — Initiation ​

Entry: Adv_checkout::run($checkoutdata = []) dispatches case 'xpay' to xpay() (ecommercen/checkout/controllers/Adv_checkout.php:161).

  1. Calls getXPaySettings() to load API key and environment flag (Adv_checkout.php:3826).
  2. Loads order and customer records; redirects to preview_order on lookup failure (Adv_checkout.php:3829-3845).
  3. Builds return URLs:
    • successUrl → checkout/get_response/xpay/success/{serial} (Adv_checkout.php:3849)
    • cancelUrl → checkout/get_response/xpay/cancel/{serial} (Adv_checkout.php:3850)
    • notificationUrl → checkout/xPayHook (Adv_checkout.php:3851)
  4. Splits customer phone via PhoneHelper::splitInternationalAndNational() (Adv_checkout.php:3854-3870).
  5. Calls XPay::createHostedPaymentOrder() with amount as $orderData->total_vat in EUR (Adv_checkout.php:3875-3883).
  6. On failure: logs REQUEST_FAILED, sets session error, redirects to preview_order (Adv_checkout.php:3886-3892).
  7. On success: logs REQUEST and redirects browser to $response['hostedPage'] (Adv_checkout.php:3895-3897).

Regular Checkout — Webhook (POST checkout/xPayHook) ​

Adv_checkout::xPayHook() (Adv_checkout.php:3906):

  1. Decodes input_stream() JSON; returns HTTP 400 on failure (Adv_checkout.php:3908-3915).
  2. Logs a CALLBACK row to xpay_logging via logXPayTransaction() before any verification (Adv_checkout.php:3919).
  3. Calls XPay::parsePaymentNotification(); returns HTTP 400 on \Exception (Adv_checkout.php:3921-3928).
  4. Calls XPay::verifyNotificationSecurityToken(); returns HTTP 401 on failure (Adv_checkout.php:3930-3937).
  5. Loads order; returns HTTP 404 if not found (Adv_checkout.php:3940-3946).
  6. Ignores REFUNDED notifications with an info log (Adv_checkout.php:3952-3958).
  7. State machine — updates when the transition is safe (Adv_checkout.php:3964-3974):
    • PENDING + PAID → update
    • PENDING + CANCELED → update
    • PAID + CANCELED → update (reversal)
    • CANCELED + PAID → update (resurrection)
  8. Delegates to processXPayPaymentResult() which calls order_model->set_status() / set_is_paid() / cancelOrder() and triggers post-hooks (email, SMS, stock alert, loyalty, ERP hook) (Adv_checkout.php:4126-4183).

Regular Checkout — Return URLs ​

xpayResponse() dispatches get_response/xpay/{action} to xPaySuccess() or xPayCancel() (Adv_checkout.php:4224-4237).

xPaySuccess() (Adv_checkout.php:3992):

  1. Reads order_serial from URI segment 5 and paymentid from query string.
  2. Updates tran_ticket on the order row if not already set (Adv_checkout.php:4005-4007).
  3. Calls XPay::getOrderStatus() with the serial; falls through to xPayCancel() on failure.
  4. Maps operationResult; if PAID, calls processXPayPaymentResult() then renders the success view (checkoutXPaySuccess).
  5. If not PAID, delegates to xPayCancel().

xPayCancel() (Adv_checkout.php:4079):

  1. Updates tran_ticket; cancels the order if still PENDING via cancelOrder().
  2. Renders the failure view (checkoutXPayFail).

Regular Checkout — Cron Reconciliation ​

See SY-03 Incomplete Order Cancellation for the full reconciliation mechanics and grace-period behavior controlled by the XPAY/EXPIRATION registry key.

Gift Card Checkout — Initiation ​

AdvGiftCardPage::getPayWayFormData() dispatches case 'xpay' to xpayFormData() (ecommercen/gift_cards/controllers/AdvGiftCardPage.php:120).

xpayFormData() (AdvGiftCardPage.php:1402):

  1. Calls getXPaySettings() and builds a customer payload from gift_card_orders + phone splitting.
  2. Calls XPay::createHostedPaymentOrder() with the gift-card amount from $postData['giftCard'], the current store currency code, and the 'gift_card' flow discriminator (AdvGiftCardPage.php:1425-1434).
  3. Stores $response['orderId'] as tran_ticket on the gift card order row (AdvGiftCardPage.php:1443).
  4. Returns a ['type' => 'redirect', 'url' => $response['hostedPage']] envelope.

Gift Card Checkout — Webhook (POST gift-card/xPayHook) ​

AdvGiftCardPage::xPayHook() (AdvGiftCardPage.php:1502):

  1. Decodes input_stream() JSON; returns HTTP 400 on failure.
  2. Parses and verifies securityToken with flow 'gift_card' (AdvGiftCardPage.php:1523); returns HTTP 401 on failure.
  3. Ignores REFUNDED (AdvGiftCardPage.php:1540).
  4. Resolves GiftCardStatus enum from $orderObj->gift_card_status; logs error and returns on unknown value.
  5. Dispatches xpayWebhookAction() result:
    • 'accept' → acceptGiftCard() (issues coupon, marks Completed); acceptPostActions() now runs only when the claim succeeds — a refused claim logs and returns early without issuing a coupon (AdvGiftCardPage.php:1555-1569)
    • 'cancel' → cancelGiftCard() + cancelPostActions()
    • 'noop' → logs and returns without modification

Gift Card — State Machine ​

AdvGiftCardPage::xpayWebhookAction(GiftCardStatus, string): string (AdvGiftCardPage.php:1605):

Current statusXPay statusAction
PendingPAIDaccept
PendingCANCELEDcancel
CompletedCANCELEDcancel (reversal)
CompletedPAIDnoop (outcome selection only — see BR6; duplicate-coupon safety lives in the model UPDATE)
Canceledanynoop (terminal)
anyPENDING / REFUNDED / unknownnoop

REFUNDED is filtered upstream in xPayHook() before xpayWebhookAction() is called (AdvGiftCardPage.php:1540).

Gift Card Checkout — Return URLs ​

The storefront return-URL path is guarded the same way as the webhook (#583). successView() fires acceptPostActions() only when the claim succeeds, while renderSuccess() runs either way — deliberately, so a refreshed return URL still shows the success page even after the coupon has already been issued (AdvGiftCardPage.php:1166-1170). AdvGiftCardPage::acceptGiftCard()'s own signature changed from void to bool (AdvGiftCardPage.php:1125).

Admin manual acceptance. POST /admin/giftCards/acceptGiftCard/{orderId} (admin-only; no public REST contract changed) now answers 409 Conflict with {"error": "This gift card order is already completed."} and skips post-actions when the order is already Completed; 404/500 responses are unchanged (AdvGiftCardAdminListing.php:149-152). Full admin-side coverage lives in AD-23 Gift Cards Admin.

Gift Card — Cron Reconciliation ​

AdvCancelPendingGiftCards::cancelPendingXpayOrders() (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php:253-278):

  • Queries pending gift card orders with payway='xpay' and created_at < now() - giftCardDateTimeIntervalToDrop.
  • Calls XPay::getOrderStatus() for each; accepts if PAID, cancels otherwise — the accept call is Pending-only and can be a silent no-op if the order has already moved past Pending (AdvCancelPendingGiftCards.php:274-276).
  • XPay orders are excluded from the bulk-cancel query that handles all other payways (AdvCancelPendingGiftCards.php:43).

Data Model ​

xpay_logging table ​

Created by database/migrations/20251117205429_create_xpay_logging_table.php.

ColumnTypeNotes
idunsigned int, auto-increment PK
order_serialVARCHAR(50) NOT NULLMerchant order serial
flowVARCHAR(20) NOT NULL'order' or 'gift_card'
transaction_idVARCHAR(100) NULLNexi-side order ID (populated on RESPONSE rows)
transaction_typeVARCHAR(50)REQUEST, RESPONSE, CALLBACK, PROCESSED, STATUS_CHECK, REQUEST_FAILED
request_dataTEXT NULLJSON-encoded request payload
response_dataTEXT NULLJSON-encoded response payload (contains securityToken on successful RESPONSE rows)
statusVARCHAR(50) NULLSUCCESS (for REQUEST/RESPONSE rows), FAILED (for errors), raw Nexi operationResult for PROCESSED rows (AUTHORIZED, EXECUTED, DECLINED, etc.), or null (STATUS_CHECK, CALLBACK)
created_atTIMESTAMP DEFAULT CURRENT_TIMESTAMP

Indexes (migration:20-31):

NameColumnsPurpose
idx_order_serialorder_serialOrder lookup
idx_transaction_idtransaction_idNexi-side ID lookup
idx_created_atcreated_atTime-range queries
idx_token_lookup(order_serial, flow, transaction_type, status, created_at)Covering index for getStoredSecurityToken() query

Write sources ​

xpay_logging is written from two separate code paths:

  1. XPay::logToDatabase() (src/PaymentGateways/NexiXPay/XPay.php:527) — writes REQUEST and RESPONSE rows (with flow and securityToken) during createHostedPaymentOrder(), and also writes RESPONSE/FAILED rows on GuzzleException (XPay.php:186-192).
  2. Adv_checkout::logXPayTransaction() (ecommercen/checkout/controllers/Adv_checkout.php:4194) — writes REQUEST, CALLBACK, PROCESSED, STATUS_CHECK, and REQUEST_FAILED rows but does not populate flow or transaction_id.

shop_order table (regular flow) ​

  • tran_ticket is updated on the success return URL with the paymentid query parameter (Adv_checkout.php:4005-4007).
  • status is set to PAID or CANCELED by processXPayPaymentResult().
  • is_paid flag is set to 1 on PAID (Adv_checkout.php:4144).

gift_card_orders table (gift card flow) ​

  • tran_ticket is written with the Nexi-side orderId at HPP creation time (AdvGiftCardPage.php:1443).
  • gift_card_status transitions via five writers: acceptGiftCard() (Pending-only, ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php:49), acceptGiftCardManually() (additionally allows Canceled, used only by the admin grid, :62), cancelGiftCard() (:135), cancelPending() (:147-160) — the bulk-cancel writer, called directly by the cron's bulk sweep (AdvCancelPendingGiftCards.php:48), bypassing cancelGiftCard() — and the generic update_giftcard_order() (:316-319) used by the Viva webhook path. Both accept paths funnel through acceptGiftCardFrom() (:70).

Domain Layer ​

Modern Layer ​

src/PaymentGateways/NexiXPay/XPay.php — PSR-4 class under Advisable\PaymentGateways\NexiXPay. Constructor accepts optional ?CI_DB_query_builder $db and ?Client $httpClient for testability; production callers use new XPay($config) and receive the autowired DI-container DB handle (XPay.php:36-53).

Public API:

MethodDescription
createHostedPaymentOrder(...)Creates an HPP order on the Nexi API; logs REQUEST and RESPONSE to xpay_logging
getOrderStatus(string $xpayOrderId)Polls GET api/v1/orders/{id}; returns parsed response or null on failure
parsePaymentNotification(array $notification)Validates and normalises a webhook payload; throws on missing required fields
verifyNotificationSecurityToken(array $notification, string $orderSerial, string $flow = 'order')Compares inbound securityToken against stored value using hash_equals; fail-closed
mapOperationResultToStatus(string $operationResult)Maps Nexi enum values to internal PAID / CANCELED / PENDING / REFUNDED / UNKNOWN
generateCorrelationId()Returns UUID v4 as required by the Nexi API header
formatAmount(float $amount, string $currency)Converts to integer cents via (int)round($amount * 100)

REST Adapter ​

The REST checkout path uses XPayAdapter (src/Domains/Checkout/Payment/Adapters/XPayAdapter.php) which wraps XPay for PaymentAdapterInterface. Its initializePayment() delegates to XPay::createHostedPaymentOrder() and returns a PaymentInitResult containing a GET redirect to the hosted page.

renderOrderDescription() (XPayAdapter.php:89-98) calls t(self::ORDER_DESCRIPTION_KEY, [$context->orderSerial, $this->siteName]) using the constant ORDER_DESCRIPTION_KEY = 'checkout.xpay.order_description' (XPayAdapter.php:42). This is the same translation key and argument order used by the legacy storefront, so the Nexi order.description field is byte-for-byte identical and localized whether the order is placed via the legacy storefront or the REST API.

siteName is wired from config_item('site_name') by PaymentInitializerFactory::registerXPay() (src/Domains/Checkout/Payment/PaymentInitializerFactory.php:411-412). The factory reuses the legacy server-to-server webhook URL (checkout/xPayHook) until a dedicated REST webhook handler lands; order_serial is shared between legacy and REST orders so the legacy handler resolves REST-placed orders transparently (PaymentInitializerFactory.php:407-410). See CF-08 Payment Processing for the full REST payment adapter conventions.

Legacy Layer ​

FileRole
ecommercen/checkout/controllers/Adv_checkout.phpRegular checkout — xpay(), xPayHook(), xPaySuccess(), xPayCancel(), xpayResponse(), processXPayPaymentResult(), logXPayTransaction()
ecommercen/gift_cards/controllers/AdvGiftCardPage.phpGift card checkout — xpayFormData(), xPayHook(), xPaySuccess(), xPayCancel(), xpayWebhookAction()
ecommercen/job/libraries/AdvCancelIncompleteOrders.phpRegular order cron reconciliation — handlePendingXpayOrders(), lazy xPay() accessor
ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.phpGift card cron reconciliation — cancelPendingXpayOrders()
ecommercen/helpers/registry_helper.phpgetXPaySettings() helper — reads XPAY.API_KEY and XPAY.IS_PRODUCTION from the registry (registry_helper.php:586-594)
ecommercen/eshop/libraries/AdvPaymentsRegistry.phpRegisters the xpay provider config with two settings: API_KEY (password type, required) and IS_PRODUCTION (checkbox, default false) (AdvPaymentsRegistry.php:697-706)

Configuration ​

Most XPay settings are stored in the XPAY registry group and surfaced in the admin payment-settings view (write path: ecommercen/settings/controllers/Adv_settings.php:1335-1336; read/render: :1506-1507).

Registry KeyTypeRequiredDescription
XPAY.API_KEYpasswordYesMerchant API key issued by Nexi
XPAY.IS_PRODUCTIONbooleanNo (default false)false = sandbox, true = live production
XPAY.EXPIRATIONstring (DateInterval)No (default 10800 sec)Grace seconds: orders unresolved after this window are dropped by the cron (xPayExpirationSeconds() reader at ecommercen/job/libraries/AdvCancelIncompleteOrders.php:372-381)

getXPaySettings() reads the first two keys and returns an array consumed by new XPay($config) (ecommercen/helpers/registry_helper.php:586-594). Note: getXPaySettings() does NOT read XPAY.EXPIRATION; the cron reads it from the registry via xPayExpirationSeconds() (AdvCancelIncompleteOrders.php:372-381), falling back to the XPAY_DEFAULT_EXPIRATION_SECONDS = 10800 class constant at :17 when the key is unset or non-numeric.

Gift card timeout — $config['giftCardDateTimeIntervalToDrop'] (PHP DateInterval string, validated to default 'PT180M') controls when the AdvCancelPendingGiftCards cron drops unresolved pending XPay gift card orders (application/config/app.php:560).

Client Extension Points ​

The xpay case is handled inside the switch blocks of Adv_checkout::run() and Adv_checkout::get_response(). Client repos that override application/core/Front_c.php or create subclasses of Adv_checkout can:

  • Override processXPayPaymentResult() (marked protected) to add custom post-payment hooks (Adv_checkout.php:4126).
  • Override logXPayTransaction() (marked protected) to route audit rows to a different table or logging backend (Adv_checkout.php:4194).
  • Override AdvGiftCardPage::xpayWebhookAction() (marked public static) or replace the gift card controller entirely, since the state machine is a pure static helper.
  • Inject a custom \GuzzleHttp\Client into XPay to wrap HTTP calls with tenancy-level configuration (proxy, timeout, etc.).

Business Rules ​

  1. The Nexi XPay Greece HPP model transfers 3-D Secure and card-number responsibility to Nexi. The merchant never sees raw card data.
  2. Currency is hard-coded to 'EUR' in the regular checkout path (Adv_checkout.php:3878). The gift card path uses $this->currentCurrency->code (AdvGiftCardPage.php:1428).
  3. Amount must be expressed in integer cents. XPay::formatAmount() converts via (int)round($amount * 100) to avoid floating-point drift (XPay.php:437-442).
  4. The platform's only documented webhook-authenticity mechanism is the securityToken echo-back. There is no HMAC. verifyNotificationSecurityToken() is fail-closed: rejects when either the inbound token or the stored token is missing or empty (XPay.php:289-305).
  5. REFUNDED notifications are explicitly ignored on both webhook handlers — they log and return without touching order state (Adv_checkout.php:3952-3958, AdvGiftCardPage.php:1540).
  6. [RESOLVED — #583, commit f89660a84] acceptGiftCard() is idempotent: the model claims the order with a single conditional UPDATE ... WHERE id = ? AND gift_card_status IN (...) before any coupon row is issued, and rolls back and returns false if zero rows were affected (ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php:88-106). The same UPDATE also clears canceled_at on a Canceled → Completed accept (:97) — required because applyStatusFilter() resolves Completed as completed_at IS NOT NULL AND canceled_at IS NULL; without the clear, the order would vanish from the admin Completed filter. Duplicate-coupon safety now lives in this claim-before-issue model UPDATE, not in the xpayWebhookAction() state machine — per the code's own docblock (AdvGiftCardPage.php:1588-1593), the retained (Completed, PAID) → 'noop' mapping (AdvGiftCardPage.php:1605-1620) now exists to select which outcome the callback takes, not to make the write safe. Scope: #583 fixed this defect for all gift-card payways; XPay and Piraeus were the only two that previously guarded against it, and only at the caller — the state-machine guard documented here is now redundant-but-retained alongside the model-level guard.
  7. Cross-flow token collision is prevented by the flow discriminator column on xpay_logging. The idx_token_lookup covering index ensures the discriminated lookup is efficient (migration:23-31).
  8. 'xpay' is registered in getGiftCardPayWays() (ecommercen/helpers/eshop_helper.php:352-373, return array :360-371, 'xpay' at :369) and in the gift-card Vue payWayInstallments map with an empty array (AdvGiftCardPage.php:1052), matching the iris / alpha / ethniki / vivawallet pattern for payways that carry no installments.

Known Issues & Security Gaps ​

  1. Duplicate writes to xpay_logging on HPP creation. XPay::createHostedPaymentOrder() writes REQUEST and RESPONSE rows via logToDatabase() (XPay.php:131,145). The caller Adv_checkout::xpay() then writes a second REQUEST row (or REQUEST_FAILED) via logXPayTransaction() (Adv_checkout.php:3888,3895). A single HPP creation thus produces two REQUEST rows with different schemas (the gateway-level row includes flow, transaction_id, and full payload in request_data; the controller-level row lacks flow and stores the response envelope instead). This makes forensic queries on xpay_logging ambiguous for the HPP-creation event.

  2. logXPayTransaction() never writes the flow column. The controller-level helper (Adv_checkout.php:4194-4215) builds $logData without a flow key, so the column receives its MySQL default. The flow column is VARCHAR(20) NOT NULL with no explicit default (migration :13), so strict-mode-OFF behavior writes an empty string '' (not NULL). All rows written by CALLBACK, PROCESSED, STATUS_CHECK, and REQUEST_FAILED events have flow = '' (empty string). The idx_token_lookup index and the getStoredSecurityToken() query filter on flow, meaning these controller-level rows cannot serve as token sources and do not interfere with token lookup, but the schema contract is violated for audit purposes.

  3. xPayHook() stamps meta_data incompletely. The PAID branch stamps meta_data => __METHOD__ at AdvCancelIncompleteOrders.php:274, but the cancel branch does not stamp at all (:270). The intent is to record which code path caused the status change, but the inconsistency means that a webhook-driven CANCELED order has no meta_data marker. Other webhook handlers (JCC, Viva legacy) stamp both paths. The xpay_logging table provides a parallel audit trail, but the meta_data marker is inconsistent. See line 273-274 (PAID path) vs 268-270 (cancel path) in AdvCancelIncompleteOrders.php:249-270.

  4. xPayCancel() does not guard against a missing paymentid query parameter. The guard at the top of xPayCancel() checks !$orderSerial || !$orderData || !$paymentId and calls inactive_payment() (Adv_checkout.php:4086-4089). However, Nexi's cancellation redirect may not include a paymentid parameter in all edge cases (browser back-button, session expiry). A customer returning without paymentid lands on inactive_payment() rather than a graceful "payment cancelled" page, with no order state update.

  5. xPaySuccess() makes an extra getOrderStatus() API call on every browser redirect. The HPP flow already delivers a server-to-server webhook before the browser redirect completes. If the webhook arrives first and updates the order to PAID, the getOrderStatus() call in xPaySuccess() is redundant (Adv_checkout.php:4010). In the reverse case (webhook delayed), the status check is necessary. There is no mechanism to short-circuit when the order is already PAID from the webhook, so every return-URL visit incurs an outbound API call.

  6. handlePendingXpayOrders() evaluates the map call with nullability risk. In AdvCancelIncompleteOrders::handlePendingXpayOrders(), the status map at line 266 uses $xpayOrderStatus['operationResult'] ?? '' even when $xpayOrderStatus is null (which returns null['operationResult'], causing a PHP 8.0+ error if accessed). The null guard at line 268 (if (!$xpayOrderStatus || ...) catches this because the short-circuit || ensures the cancel branch is taken, but the null-coalescing is evaluated first and could throw. In PHP 8.0+, this is a TypeError / ValueError. The order is cancelled correctly, but the error occurs before the condition is evaluated. (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:266-270)

  7. Regular checkout xpayResponse() does not return HTTP 200 to the Nexi webhook. xPayHook() sets explicit status codes for error paths (400, 401, 404) but does not explicitly set 200 for the success path. CodeIgniter's default response is 200, so this is not a runtime bug, but the lack of an explicit set_status_header(200) is inconsistent with the error paths and could cause a silent issue if the framework default changes. (Adv_checkout.php:3906-3946)

  8. Incomplete test coverage for the controller-layer and cron XPay code paths. tests/Unit/PaymentGateways/NexiXPay/XPayTest.php covers the standalone XPay class (58 tests — 45 methods + 13 provider rows). tests/Unit/Jobs/AdvCancelIncompleteOrdersTest.php covers handlePendingXpayOrders() with 4 dedicated test methods plus xPayExpirationSeconds_* variants at :397-452. tests/Legacy/GiftCards/AdvGiftCardPageTest.php covers the xpayWebhookAction() state machine (3 tests) plus 12 Piraeus tests at :116-380 and 3 XPay tests at :23-114 (15 total methods). Remaining untested: the regular checkout controller methods xpay(), xPayHook(), xPaySuccess(), xPayCancel(), processXPayPaymentResult(), and the gift card xpayFormData(), xPayHook(), xPaySuccess(), xPayCancel(). The gift card cron helper AdvCancelPendingGiftCards::cancelPendingXpayOrders() is also untested. A new 559-line, 10-test tests/Legacy/GiftCards/AdvGiftCardOrdersModelTest.php now covers the model-level accept guard (acceptGiftCard() / acceptGiftCardFrom()) that this cron path depends on, though cancelPendingXpayOrders() itself remains uncovered.

  9. Stale comments reference a non-existent method. src/Domains/Checkout/Payment/Adapters/XPayAdapter.php:37 and :91 both reference Adv_checkout::_xpay() (with leading underscore), a method that does not exist. The actual method is xpay() (no underscore) at ecommercen/checkout/controllers/Adv_checkout.php:3823 (post-#734 offset).

  10. Width mismatch for transaction IDs across tables. AdvGiftCardPage::xpayFormData() stores the Nexi-returned orderId into gift_card_orders.tran_ticket varchar(32) (AdvGiftCardPage.php:1443; DDL at database/initial/initial.sql:609), while xpay_logging.transaction_id sizes it at varchar(100) (migration :14). With strict mode off, an over-32-char Nexi ID is silently truncated on write to gift_card_orders, diverging from what was logged.

Tests ​

tests/Unit/PaymentGateways/NexiXPay/XPayTest.php — 58 tests ​

Runs in isolated processes (#[RunTestsInSeparateProcesses], #[PreserveGlobalState(false)]) because the XPay constructor reads config_item('ISO639-2') and config_item('language_abbr') which require a CI3 boot (XPayTest.php:40-41).

Coverage groups:

GroupDescription
ConstructorSandbox/production URL selection, IS_PRODUCTION truthy-string coercion, default-empty API key (XPayTest.php:83-140)
generateCorrelationIdUUID v4 validity and uniqueness across 10 consecutive calls (XPayTest.php:141-166)
mapOperationResultToStatusData-provider across 11 mapped values plus 2 UNKNOWN-fallthrough cases (13 data-provider rows at :167-196)
parsePaymentNotificationHappy path (all fields), three InvalidArgumentException paths, optional-field nullability, rawNotification preservation, status map threading (XPayTest.php:197-372)
verifyNotificationSecurityToken12 tests: match, mismatch, missing token, empty token, non-string token, no stored row, empty response_data, missing securityToken key in stored row, DB exception, query-shape regression including flow filter and gift-card flow isolation (XPayTest.php:373-559)
HTTP-mocked createHostedPaymentOrderHappy path, missing hostedPage, ClientException, ServerException, ConnectException, endpoint assertion, cents conversion, header shape, optional customer data present/absent, language resolution from ISO 639-2 map (11 tests at XPayTest.php:594-785)
HTTP-mocked getOrderStatusHappy path with multiple operations (latest = index 0), empty operations, correct endpoint, ClientException, ServerException (XPayTest.php:786-870)
API key redactionVerifies ***REDACTED*** appears and the raw key never appears in log output via a Monolog\TestHandler + DI container swap (XPayTest.php:871-914)

tests/Legacy/GiftCards/AdvGiftCardPageTest.php — 15 tests ​

Total 15 test methods covering Piraeus (12 tests), XPay (3 tests).

GroupTestsDescription
Piraeus integration12piraeusSuccessAction() / piraeusFailAction() state machine (AdvGiftCardPageTest.php:116-380)
XPay integration3xpayWebhookAction() state machine and decision matrix (AdvGiftCardPageTest.php:23-114)

XPay coverage (3 tests):

TestDescription
Data-driven decision matrix10 (GiftCardStatus, xpayStatus) pairs verifying expected action string
Exhaustive 3×6 matrix walkAsserts only 'accept', 'cancel', 'noop' ever escape the function
Regression guardVerifies 'accept' is never returned outside the (Pending, PAID) path