Appearance
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.phpcitations re-anchored for the 4.124.0 line shift (+3); previous note, 2026-09-14 — Advisable-com/ecommercen#729:getXPaySettings()citation moved again toregistry_helper.php:586-594aftergetVivaWalletExternalSettings()was inserted betweengetVivaWalletMergedSettings()andgetJccSettings(); the helper itself is unchanged. (Previously moved to:534-542by #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 (
checkoutmodule) — for standard shop orders stored in theshop_ordertable. - Gift card checkout (
gift_cardsmodule) — for gift card purchases stored ingift_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:
| Header | Value |
|---|---|
X-API-KEY | Merchant API key from registry |
Correlation-Id | UUID v4 generated per request |
Content-Type | application/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 URLSuccess response (HTTP 200):
| Field | Description |
|---|---|
hostedPage | URL the browser must be redirected to |
orderId | Nexi-side order identifier (stored as tran_ticket) |
securityToken | Opaque 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 operationResult | Internal status |
|---|---|
AUTHORIZED, EXECUTED | PAID |
DECLINED, DENIED_BY_RISK, THREEDS_FAILED, VOIDED, FAILED, CANCELED | CANCELED |
THREEDS_VALIDATED, PENDING | PENDING |
REFUNDED | REFUNDED |
| anything else | UNKNOWN |
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-levelorder.orderIdonly for the order-creation shapesecurityToken(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):
operationabsent —"Missing required field in notification: operation"operation.operationResultabsent —"Missing operationResult in notification"- no order id in either
operation.orderIdororder.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).
- Calls
getXPaySettings()to load API key and environment flag (Adv_checkout.php:3826). - Loads order and customer records; redirects to
preview_orderon lookup failure (Adv_checkout.php:3829-3845). - 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)
- Splits customer phone via
PhoneHelper::splitInternationalAndNational()(Adv_checkout.php:3854-3870). - Calls
XPay::createHostedPaymentOrder()with amount as$orderData->total_vatin EUR (Adv_checkout.php:3875-3883). - On failure: logs
REQUEST_FAILED, sets session error, redirects topreview_order(Adv_checkout.php:3886-3892). - On success: logs
REQUESTand redirects browser to$response['hostedPage'](Adv_checkout.php:3895-3897).
Regular Checkout — Webhook (POST checkout/xPayHook)
Adv_checkout::xPayHook() (Adv_checkout.php:3906):
- Decodes
input_stream()JSON; returns HTTP 400 on failure (Adv_checkout.php:3908-3915). - Logs a
CALLBACKrow toxpay_loggingvialogXPayTransaction()before any verification (Adv_checkout.php:3919). - Calls
XPay::parsePaymentNotification(); returns HTTP 400 on\Exception(Adv_checkout.php:3921-3928). - Calls
XPay::verifyNotificationSecurityToken(); returns HTTP 401 on failure (Adv_checkout.php:3930-3937). - Loads order; returns HTTP 404 if not found (
Adv_checkout.php:3940-3946). - Ignores
REFUNDEDnotifications with an info log (Adv_checkout.php:3952-3958). - State machine — updates when the transition is safe (
Adv_checkout.php:3964-3974):PENDING + PAID→ updatePENDING + CANCELED→ updatePAID + CANCELED→ update (reversal)CANCELED + PAID→ update (resurrection)
- Delegates to
processXPayPaymentResult()which callsorder_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):
- Reads
order_serialfrom URI segment 5 andpaymentidfrom query string. - Updates
tran_ticketon the order row if not already set (Adv_checkout.php:4005-4007). - Calls
XPay::getOrderStatus()with the serial; falls through toxPayCancel()on failure. - Maps
operationResult; ifPAID, callsprocessXPayPaymentResult()then renders the success view (checkoutXPaySuccess). - If not
PAID, delegates toxPayCancel().
xPayCancel() (Adv_checkout.php:4079):
- Updates
tran_ticket; cancels the order if stillPENDINGviacancelOrder(). - 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):
- Calls
getXPaySettings()and builds a customer payload fromgift_card_orders+ phone splitting. - 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). - Stores
$response['orderId']astran_ticketon the gift card order row (AdvGiftCardPage.php:1443). - Returns a
['type' => 'redirect', 'url' => $response['hostedPage']]envelope.
Gift Card Checkout — Webhook (POST gift-card/xPayHook)
AdvGiftCardPage::xPayHook() (AdvGiftCardPage.php:1502):
- Decodes
input_stream()JSON; returns HTTP 400 on failure. - Parses and verifies
securityTokenwith flow'gift_card'(AdvGiftCardPage.php:1523); returns HTTP 401 on failure. - Ignores
REFUNDED(AdvGiftCardPage.php:1540). - Resolves
GiftCardStatusenum from$orderObj->gift_card_status; logs error and returns on unknown value. - 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 status | XPay status | Action |
|---|---|---|
Pending | PAID | accept |
Pending | CANCELED | cancel |
Completed | CANCELED | cancel (reversal) |
Completed | PAID | noop (outcome selection only — see BR6; duplicate-coupon safety lives in the model UPDATE) |
Canceled | any | noop (terminal) |
| any | PENDING / REFUNDED / unknown | noop |
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'andcreated_at < now() - giftCardDateTimeIntervalToDrop. - Calls
XPay::getOrderStatus()for each; accepts ifPAID, 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.
| Column | Type | Notes |
|---|---|---|
id | unsigned int, auto-increment PK | |
order_serial | VARCHAR(50) NOT NULL | Merchant order serial |
flow | VARCHAR(20) NOT NULL | 'order' or 'gift_card' |
transaction_id | VARCHAR(100) NULL | Nexi-side order ID (populated on RESPONSE rows) |
transaction_type | VARCHAR(50) | REQUEST, RESPONSE, CALLBACK, PROCESSED, STATUS_CHECK, REQUEST_FAILED |
request_data | TEXT NULL | JSON-encoded request payload |
response_data | TEXT NULL | JSON-encoded response payload (contains securityToken on successful RESPONSE rows) |
status | VARCHAR(50) NULL | SUCCESS (for REQUEST/RESPONSE rows), FAILED (for errors), raw Nexi operationResult for PROCESSED rows (AUTHORIZED, EXECUTED, DECLINED, etc.), or null (STATUS_CHECK, CALLBACK) |
created_at | TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
Indexes (migration:20-31):
| Name | Columns | Purpose |
|---|---|---|
idx_order_serial | order_serial | Order lookup |
idx_transaction_id | transaction_id | Nexi-side ID lookup |
idx_created_at | created_at | Time-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:
XPay::logToDatabase()(src/PaymentGateways/NexiXPay/XPay.php:527) — writesREQUESTandRESPONSErows (withflowandsecurityToken) duringcreateHostedPaymentOrder(), and also writesRESPONSE/FAILEDrows onGuzzleException(XPay.php:186-192).Adv_checkout::logXPayTransaction()(ecommercen/checkout/controllers/Adv_checkout.php:4194) — writesREQUEST,CALLBACK,PROCESSED,STATUS_CHECK, andREQUEST_FAILEDrows but does not populateflowortransaction_id.
shop_order table (regular flow)
tran_ticketis updated on the success return URL with thepaymentidquery parameter (Adv_checkout.php:4005-4007).statusis set toPAIDorCANCELEDbyprocessXPayPaymentResult().is_paidflag is set to1onPAID(Adv_checkout.php:4144).
gift_card_orders table (gift card flow)
tran_ticketis written with the Nexi-sideorderIdat HPP creation time (AdvGiftCardPage.php:1443).gift_card_statustransitions 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), bypassingcancelGiftCard()— and the genericupdate_giftcard_order()(:316-319) used by the Viva webhook path. Both accept paths funnel throughacceptGiftCardFrom()(: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:
| Method | Description |
|---|---|
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
| File | Role |
|---|---|
ecommercen/checkout/controllers/Adv_checkout.php | Regular checkout — xpay(), xPayHook(), xPaySuccess(), xPayCancel(), xpayResponse(), processXPayPaymentResult(), logXPayTransaction() |
ecommercen/gift_cards/controllers/AdvGiftCardPage.php | Gift card checkout — xpayFormData(), xPayHook(), xPaySuccess(), xPayCancel(), xpayWebhookAction() |
ecommercen/job/libraries/AdvCancelIncompleteOrders.php | Regular order cron reconciliation — handlePendingXpayOrders(), lazy xPay() accessor |
ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php | Gift card cron reconciliation — cancelPendingXpayOrders() |
ecommercen/helpers/registry_helper.php | getXPaySettings() helper — reads XPAY.API_KEY and XPAY.IS_PRODUCTION from the registry (registry_helper.php:586-594) |
ecommercen/eshop/libraries/AdvPaymentsRegistry.php | Registers 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 Key | Type | Required | Description |
|---|---|---|---|
XPAY.API_KEY | password | Yes | Merchant API key issued by Nexi |
XPAY.IS_PRODUCTION | boolean | No (default false) | false = sandbox, true = live production |
XPAY.EXPIRATION | string (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()(markedprotected) to add custom post-payment hooks (Adv_checkout.php:4126). - Override
logXPayTransaction()(markedprotected) to route audit rows to a different table or logging backend (Adv_checkout.php:4194). - Override
AdvGiftCardPage::xpayWebhookAction()(markedpublic static) or replace the gift card controller entirely, since the state machine is a pure static helper. - Inject a custom
\GuzzleHttp\ClientintoXPayto wrap HTTP calls with tenancy-level configuration (proxy, timeout, etc.).
Business Rules
- The Nexi XPay Greece HPP model transfers 3-D Secure and card-number responsibility to Nexi. The merchant never sees raw card data.
- 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). - Amount must be expressed in integer cents.
XPay::formatAmount()converts via(int)round($amount * 100)to avoid floating-point drift (XPay.php:437-442). - The platform's only documented webhook-authenticity mechanism is the
securityTokenecho-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). REFUNDEDnotifications are explicitly ignored on both webhook handlers — they log and return without touching order state (Adv_checkout.php:3952-3958,AdvGiftCardPage.php:1540).- [RESOLVED — #583, commit
f89660a84]acceptGiftCard()is idempotent: the model claims the order with a single conditionalUPDATE ... WHERE id = ? AND gift_card_status IN (...)before any coupon row is issued, and rolls back and returnsfalseif zero rows were affected (ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php:88-106). The same UPDATE also clearscanceled_aton a Canceled → Completed accept (:97) — required becauseapplyStatusFilter()resolves Completed ascompleted_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 thexpayWebhookAction()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. - Cross-flow token collision is prevented by the
flowdiscriminator column onxpay_logging. Theidx_token_lookupcovering index ensures the discriminated lookup is efficient (migration:23-31). 'xpay'is registered ingetGiftCardPayWays()(ecommercen/helpers/eshop_helper.php:352-373, return array:360-371,'xpay'at:369) and in the gift-card VuepayWayInstallmentsmap with an empty array (AdvGiftCardPage.php:1052), matching theiris/alpha/ethniki/vivawalletpattern for payways that carry no installments.
Known Issues & Security Gaps
Duplicate writes to
xpay_loggingon HPP creation.XPay::createHostedPaymentOrder()writesREQUESTandRESPONSErows vialogToDatabase()(XPay.php:131,145). The callerAdv_checkout::xpay()then writes a secondREQUESTrow (orREQUEST_FAILED) vialogXPayTransaction()(Adv_checkout.php:3888,3895). A single HPP creation thus produces twoREQUESTrows with different schemas (the gateway-level row includesflow,transaction_id, and full payload inrequest_data; the controller-level row lacksflowand stores the response envelope instead). This makes forensic queries onxpay_loggingambiguous for the HPP-creation event.logXPayTransaction()never writes theflowcolumn. The controller-level helper (Adv_checkout.php:4194-4215) builds$logDatawithout aflowkey, so the column receives its MySQL default. Theflowcolumn isVARCHAR(20) NOT NULLwith no explicit default (migration:13), so strict-mode-OFF behavior writes an empty string''(not NULL). All rows written byCALLBACK,PROCESSED,STATUS_CHECK, andREQUEST_FAILEDevents haveflow = ''(empty string). Theidx_token_lookupindex and thegetStoredSecurityToken()query filter onflow, 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.xPayHook()stampsmeta_dataincompletely. ThePAIDbranch stampsmeta_data => __METHOD__atAdvCancelIncompleteOrders.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-drivenCANCELEDorder has nometa_datamarker. Other webhook handlers (JCC, Viva legacy) stamp both paths. Thexpay_loggingtable provides a parallel audit trail, but themeta_datamarker is inconsistent. See line 273-274 (PAID path) vs 268-270 (cancel path) inAdvCancelIncompleteOrders.php:249-270.xPayCancel()does not guard against a missingpaymentidquery parameter. The guard at the top ofxPayCancel()checks!$orderSerial || !$orderData || !$paymentIdand callsinactive_payment()(Adv_checkout.php:4086-4089). However, Nexi's cancellation redirect may not include apaymentidparameter in all edge cases (browser back-button, session expiry). A customer returning withoutpaymentidlands oninactive_payment()rather than a graceful "payment cancelled" page, with no order state update.xPaySuccess()makes an extragetOrderStatus()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 toPAID, thegetOrderStatus()call inxPaySuccess()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 alreadyPAIDfrom the webhook, so every return-URL visit incurs an outbound API call.handlePendingXpayOrders()evaluates the map call with nullability risk. InAdvCancelIncompleteOrders::handlePendingXpayOrders(), the status map at line 266 uses$xpayOrderStatus['operationResult'] ?? ''even when$xpayOrderStatusisnull(which returnsnull['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 aTypeError/ValueError. The order is cancelled correctly, but the error occurs before the condition is evaluated. (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:266-270)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 explicitset_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)Incomplete test coverage for the controller-layer and cron XPay code paths.
tests/Unit/PaymentGateways/NexiXPay/XPayTest.phpcovers the standaloneXPayclass (58 tests — 45 methods + 13 provider rows).tests/Unit/Jobs/AdvCancelIncompleteOrdersTest.phpcovershandlePendingXpayOrders()with 4 dedicated test methods plusxPayExpirationSeconds_*variants at:397-452.tests/Legacy/GiftCards/AdvGiftCardPageTest.phpcovers thexpayWebhookAction()state machine (3 tests) plus 12 Piraeus tests at:116-380and 3 XPay tests at:23-114(15 total methods). Remaining untested: the regular checkout controller methodsxpay(),xPayHook(),xPaySuccess(),xPayCancel(),processXPayPaymentResult(), and the gift cardxpayFormData(),xPayHook(),xPaySuccess(),xPayCancel(). The gift card cron helperAdvCancelPendingGiftCards::cancelPendingXpayOrders()is also untested. A new 559-line, 10-testtests/Legacy/GiftCards/AdvGiftCardOrdersModelTest.phpnow covers the model-level accept guard (acceptGiftCard()/acceptGiftCardFrom()) that this cron path depends on, thoughcancelPendingXpayOrders()itself remains uncovered.Stale comments reference a non-existent method.
src/Domains/Checkout/Payment/Adapters/XPayAdapter.php:37and:91both referenceAdv_checkout::_xpay()(with leading underscore), a method that does not exist. The actual method isxpay()(no underscore) atecommercen/checkout/controllers/Adv_checkout.php:3823(post-#734 offset).Width mismatch for transaction IDs across tables.
AdvGiftCardPage::xpayFormData()stores the Nexi-returnedorderIdintogift_card_orders.tran_ticket varchar(32)(AdvGiftCardPage.php:1443; DDL atdatabase/initial/initial.sql:609), whilexpay_logging.transaction_idsizes it atvarchar(100)(migration:14). With strict mode off, an over-32-char Nexi ID is silently truncated on write togift_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:
| Group | Description |
|---|---|
| Constructor | Sandbox/production URL selection, IS_PRODUCTION truthy-string coercion, default-empty API key (XPayTest.php:83-140) |
generateCorrelationId | UUID v4 validity and uniqueness across 10 consecutive calls (XPayTest.php:141-166) |
mapOperationResultToStatus | Data-provider across 11 mapped values plus 2 UNKNOWN-fallthrough cases (13 data-provider rows at :167-196) |
parsePaymentNotification | Happy path (all fields), three InvalidArgumentException paths, optional-field nullability, rawNotification preservation, status map threading (XPayTest.php:197-372) |
verifyNotificationSecurityToken | 12 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 createHostedPaymentOrder | Happy 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 getOrderStatus | Happy path with multiple operations (latest = index 0), empty operations, correct endpoint, ClientException, ServerException (XPayTest.php:786-870) |
| API key redaction | Verifies ***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).
| Group | Tests | Description |
|---|---|---|
| Piraeus integration | 12 | piraeusSuccessAction() / piraeusFailAction() state machine (AdvGiftCardPageTest.php:116-380) |
| XPay integration | 3 | xpayWebhookAction() state machine and decision matrix (AdvGiftCardPageTest.php:23-114) |
XPay coverage (3 tests):
| Test | Description |
|---|---|
| Data-driven decision matrix | 10 (GiftCardStatus, xpayStatus) pairs verifying expected action string |
| Exhaustive 3×6 matrix walk | Asserts only 'accept', 'cancel', 'noop' ever escape the function |
| Regression guard | Verifies 'accept' is never returned outside the (Pending, PAID) path |
Related Flows
- CF-08 Payment Processing — covers the full checkout payment dispatch including XPay's slot in
process_order() - CF-09 Payment Webhooks — covers the webhook authenticity model,
securityTokensequence diagram, and cross-provider comparison - CF-23 Gift Cards — covers the gift card purchase flow end-to-end, including all supported payways
- SY-03 Incomplete Order Cancellation — covers the
AdvCancelIncompleteOrdersjob that drives XPay cron reconciliation for regular orders - AD-23 Gift Cards Admin — covers
AdvCancelPendingGiftCardsand gift card admin operations