Skip to content

Incomplete Order Cancellation

Flow ID: SY-03 | Module(s): job, eshop, checkout | Complexity: High Last Updated: 2026-06-04

Business Overview

When a customer initiates checkout with an online payment method but never completes payment, the order remains in PENDING status indefinitely. Two independent paths handle cancellation:

  1. REST / Webhook-driven cancellation (PaymentConfirmationService::cancelPayment()): invoked immediately by payment gateway webhooks and by the REST endpoint POST /rest/checkout/cancel-payment/{orderId}. This is the primary path for gateways that fire synchronous callbacks.
  2. Scheduled cleanup (AdvCancelIncompleteOrders cron): runs every 5 minutes as a safety net for stuck PENDING orders that the webhook path missed (e.g. abandonment without a gateway callback). It cancels stale PENDING orders, restores stock, returns loyalty points, releases used coupons, and fires ERP/cancel hooks for downstream integrations.

The cron job has special handling for four payment gateways that require external API calls before cancellation:

  • PayByBank -- cancel the pending bank transfer via the PayByBank API before cancelling the order.
  • Iris -- check whether the customer actually paid via the Iris API; if paid, accept the order instead of cancelling.
  • PayPal Advanced -- verify capture status via PayPal REST API; cancel only if not completed.
  • NexiXPay -- query the Nexi XPay API for operationResult; if PAID, accept the order, otherwise cancel.

Architecture

AdvCancelIncompleteOrders
  |
  +--> order_model::getDebrisOrders()       query PENDING card-payment orders
  |
  +--> per-gateway handler:
  |     +--> cancelPendingDefaultCards()     timeout-based cancel (most gateways)
  |     |     +--> set_status('CANCELED', '+')   restore stock
  |     |     +--> returnPointsToCustomers()     loyalty point rollback
  |     |     +--> cancelCoupon()               release used coupon  [DEFAULT CARDS ONLY]
  |     |     +--> internalApiOrderCancelHook()  ERP cancel webhook
  |     |
  |     +--> cancelPendingPayByBank()        API cancel + order cancel
  |     |     +--> set_status('CANCELED', '+')
  |     |     +--> returnPointsToCustomers()
  |     |     +--> internalApiOrderCancelHook()
  |     |
  |     +--> handlePendingIrisOrders()       API status check + accept or cancel
  |     |     +--> [CANCELED] set_status() + returnPointsToCustomers() + cancelHook
  |     |     +--> [PAID]    update_order(PAID) + internalApiOrderForErpHook()
  |     |
  |     +--> handlePendingPaypalAdvancedOrders()  API capture check + cancel
  |     |     +--> set_status('CANCELED') + returnPointsToCustomers()
  |     |
  |     +--> handlePendingXpayOrders()       API status check + accept or cancel
  |           +--> [not PAID] set_status('CANCELED') + returnPointsToCustomers() + cancelHook
  |           +--> [PAID]     update_order(PAID) + internalApiOrderForErpHook()

Note: cancelCoupon() is called only in cancelPendingDefaultCards(). The PayByBank, Iris (CANCELED branch), PayPal Advanced, and XPay cancellation paths do not release coupons (see Known Issues #4).

Key Files

FileRole
ecommercen/job/libraries/AdvCancelIncompleteOrders.phpJob implementation
application/modules/job/libraries/CancelIncompleteOrders.phpClient-overridable subclass
ecommercen/eshop/models/Adv_order_model.phpgetDebrisOrders(), set_status()
ecommercen/libraries/internal/OrderCancelHookFireTrait.phpERP cancel hook trait
ecommercen/libraries/internal/OrderForErpHookFireTrait.phpERP acceptance hook trait
src/PaymentGateways/Iris/Iris.phpIris payment gateway client
src/PaymentGateways/PayPal/PayPalRestApi.phpPayPal Advanced REST client
src/PaymentGateways/NexiXPay/XPay.phpNexi XPay payment gateway client (getOrderStatus, mapOperationResultToStatus)
ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.phpPending gift-card reconciliation (mirrors this flow for gift cards)
ecommercen/eshop/models/Adv_viva_logging_model.phpPayment logging (modern port: src/Domains/Checkout/VivaLogging/ — #148; legacy model still active)
ecommercen/coupons/models/Adv_coupons_model.phpCoupon release

Code Flow

1. Order Selection

order_model->getDebrisOrders() queries the shop_order table:

sql
SELECT id, order_serial, payway, entry_datetime, coupon_id, tran_ticket
FROM shop_order
WHERE status = 'PENDING'
  AND payway IN ('proxypay','paypal','alpha','ethniki','ethniki_ee','eurobank',
                 'paybybank','piraeus','apcopay','vivawallet','jcc','iris',
                 'paypaladvanced','klarna_payments','xpay')
ORDER BY id ASC

The getCardPayWays() helper (ecommercen/helpers/eshop_helper.php) defines the list of eligible payment methods. Cash-on-delivery and bank-transfer orders are excluded because they do not require immediate online payment.

2. Per-Gateway Handling

The job routes each order to the appropriate handler via a switch on payway:

Default Card Gateways (proxypay, paypal, alpha, ethniki, ethniki_ee, eurobank, piraeus, apcopay, klarna_payments)

Timeout-based cancellation with gateway-specific grace periods:

GatewayTimeoutRationale
vivawallet2 days (P2D)Viva Wallet redirects can take longer
jcc20 minutes (PT20M)Fast redirect flow
All others3 hours (PT180M)Standard timeout

If now > entry_datetime + timeout:

  1. set_status(order_serial, 'CANCELED', false, '+', 'order_debris') -- cancels and restores stock (+ mode)
  2. returnPointsToCustomers() -- rolls back loyalty points if POINT_SYSTEM.IS_ENABLED
  3. cancelCoupon() -- marks coupon as unused via coupons_model->markCouponUnused()
  4. internalApiOrderCancelHook() -- fires the InternalApiOrderCancelHook for ERP sync

PayByBank

Uses the configurable expiration from registry PAY_BY_BANK.EXPIRATION (in seconds):

  1. Wait until entry_datetime + EXPIRATION has passed.
  2. Call Factories::payByBank()->cancelOrder(order_serial) to cancel the pending bank transfer.
  3. Log the result to the PayByBank log table (insertPBBLog()).
  4. Cancel the order via set_status() (even if the API call fails -- the log captures the error).
  5. Return loyalty points.
  6. Fire cancel hook.

Iris

Checks actual payment status before deciding:

  1. Load Iris order record via order_model->getIrisRecordsByOrderId().
  2. Call Iris->getOrderStatus(['orderId' => irisOrderId]) to query Iris.
  3. Interpret response via IrisHelper::interpretIrisResponse():
    • PENDING or no response: skip (try again on next run).
    • CANCELED: cancel the order, return points, fire cancel hook.
    • PAID: accept the order -- update status to PAID, set meta_data to job:CancelIncompleteOrders:handlePendingIrisOrders, and fire the ERP acceptance hook (internalApiOrderForErpHook()).

PayPal Advanced

Verifies capture status via PayPal REST API:

  1. If tran_ticket is present, call PayPalRestApi->getOrderDetails(tran_ticket).
  2. Check purchase_units[0].payments.captures[0].status.
  3. If tran_ticket is empty or status is not COMPLETED: cancel and return points.
  4. If COMPLETED: the order is left as-is (no action taken in current code).

NexiXPay

Verifies payment status with Nexi XPay before deciding (no grace-period timeout — every PENDING xpay order is API-checked on each run):

  1. Call XPay->getOrderStatus(order_serial) to fetch the Nexi XPay order record (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:192)
  2. Map operationResult through XPay->mapOperationResultToStatus() to a normalized status (:193)
  3. If response is empty or status is not PAID: set_status('CANCELED', 'order_debris') + returnPointsToCustomers() + internalApiOrderCancelHook() (:196-199)
  4. If status is PAID (payment succeeded but customer never returned): update_order(status=PAID) + internalApiOrderForErpHook() (:202-203)

3. REST / Webhook-Driven Cancellation

PaymentConfirmationService::cancelPayment() (src/Domains/Checkout/PaymentConfirmationService.php:120-171) is the primary cancellation path, invoked synchronously by:

  • Payment gateway webhooks: src/Rest/Webhooks/Controllers/Webhook.php routes each gateway's cancel/failure callback here.
  • REST endpoint: POST /rest/checkout/cancel-payment/{orderId} calls this method directly.

The method:

  1. Idempotency guard (:129): if order.status === 'CANCELED', returns false immediately. Also returns false if the order is not found.
  2. Stock restoration (:156): calls $this->stockService->restoreStockForOrder($orderId) — equivalent to the cron's set_status('CANCELED', '+') stock-restore path.
  3. Event dispatch (:162): dispatches OrderCanceled event, which triggers registered listeners for loyalty-point restore and coupon-usage decrement.
  4. Returns true on success.

The AdvCancelIncompleteOrders cron acts as the safety net for orders where no webhook callback was received (customer abandoned the gateway page, network timeout, gateway outage). It selects only status = 'PENDING' orders, so any order already cancelled by cancelPayment() is skipped automatically.

4. Stock Restoration

The set_status() method's stockMode = '+' parameter triggers returnOrderStock(), which iterates the order basket and adds quantities back to product_codes.stock.

5. Loyalty Point Rollback

When POINT_SYSTEM.IS_ENABLED is true in registry, the job loads the loyalty library and calls returnPointsToCustomers() to credit back any points the customer spent on the order.

Data Model

Primary Table: shop_order

ColumnTypeRole
idintPK
order_serialvarcharHuman-readable order number
statusvarcharCurrent status (filtered for PENDING)
paywayvarcharPayment gateway identifier
entry_datetimedatetimeOrder creation timestamp (used for timeout)
coupon_idint/nullFK to applied coupon
tran_ticketvarchar/nullPayment transaction reference (PayPal)
canceled_datedatetimeSet on cancellation
meta_datatextAudit trail for status changes
TableRole
shop_order_basketOrder line items (stock restoration source)
product_codesProduct SKU stock levels
iris_ordersIris payment gateway order mapping
pay_by_bank_logPayByBank API call history
couponsCoupon usage tracking
customer_pointsLoyalty point ledger

Configuration

Job Scheduling (application/config/jobs.php)

php
['command' => 'CancelIncompleteOrders', 'schedule' => '*/5 * * * *', 'graceTime' => 300, 'retryTimes' => 3]

Runs every 5 minutes in the core queue. Enabled by default.

Registry Settings

GroupKeyPurpose
PAY_BY_BANKEXPIRATIONTimeout in seconds before PayByBank orders are cancelled
POINT_SYSTEMIS_ENABLEDWhether to rollback loyalty points on cancellation

Environment / Gateway Configuration

  • Iris: configured via getIrisSettings() helper
  • PayPal Advanced: configured via getPaypalAdvancedSettings() helper
  • PayByBank: instantiated via Factories::payByBank() factory
  • NexiXPay: configured via getXPaySettings() in ecommercen/helpers/registry_helper.php:379-387; XPay client lazy-instantiated via xPay() getter

Client Extension Points

  1. Override the job class: Extend AdvCancelIncompleteOrders in application/modules/job/libraries/CancelIncompleteOrders.php to:

    • Add custom payment gateway handlers (new case in the switch)
    • Change timeout logic for specific gateways
    • Add custom notification on cancellation
  2. Override getCardPayWays(): Redefine in application/helpers/ to include or exclude payment methods from debris cleanup.

  3. Custom ERP hooks: The InternalApiOrderCancelHook and InternalApiOrderForErpHook fire webhooks that ERP integrations can subscribe to for order sync.

  4. Adjust schedule: Modify the cron expression and grace time in jobs.php.

Business Rules

RuleDescription
Card payments onlyOnly orders with online payment methods (from getCardPayWays()) are considered
Gateway-specific timeoutsViva Wallet: 2 days, JCC: 20 min, all others: 3 hours
PayByBank respects registryTimeout driven by PAY_BY_BANK.EXPIRATION registry value (seconds)
Iris may acceptIf Iris reports PAID, the order is accepted rather than cancelled
Iris PENDING = skipPENDING Iris orders are retried on the next job run
XPay may acceptIf Nexi XPay reports PAID, the order is accepted; otherwise cancelled. No grace-period timeout — every run hits the API.
Stock always restoredset_status() with stockMode='+' restores stock on cancellation
Coupon releasedApplied coupons are marked unused so the customer can reuse them
Points returnedLoyalty points are credited back when POINT_SYSTEM.IS_ENABLED
ERP notificationCancel and accept hooks fire for all cancellations/acceptances
IdempotentOnly PENDING orders are selected; once cancelled, they will not be reprocessed

Gift Card Reconciliation

A parallel job, AdvCancelPendingGiftCards (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php), reconciles PENDING gift-card orders on the same cadence. It differs from the main flow in three ways:

  1. Blanket cancel for most payways: gift-card orders older than giftCardDateTimeIntervalToDrop are mass-cancelled via gift_card_orders_model->cancelPending(), except those paid with iris, paypaladvanced, or xpay (:30).
  2. API-verified payways route to dedicated handlers:
    • cancelPendingIrisOrders() -- uses Iris->getOrderStatus() and IrisHelper::interpretIrisResponse()
    • cancelPendingPaypalAdvancedOrders() -- uses PayPalRestApi->getOrderDetails()
    • cancelPendingXpayOrders() -- uses XPay->getOrderStatus() and XPay->mapOperationResultToStatus() (:91-114)
  3. Accept/cancel uses gift-card model methods: gift_card_orders_model->acceptGiftCard($id) and cancelGiftCard($id) instead of set_status().

Known Issues & Security Gaps

  1. Duplicate XPay handler in deprecated Cronjob::order_debris(): application/controllers/Cronjob.php:237-260 carries an identical case 'xpay' branch. Future fixes must be applied to both files or they will drift. Consider removing the deprecated branch.
  2. No grace period for XPay: Unlike vivawallet (2 days), every PENDING xpay order hits the Nexi XPay API on every cron run from creation. If the customer is still on the gateway page the order may be cancelled prematurely. Same risk exists in the Iris branch.
  3. Undefined $response variable in handlePendingPaypalAdvancedOrders() (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:176-181): $response is assigned inside if (!empty($order->tran_ticket)) { ... } but read unconditionally on line 181. PHP 8 emits E_WARNING: Undefined variable $response. Fix: initialise $response = []; before the conditional.
  4. Coupons not released for non-default gateway cancellations (ecommercen/job/libraries/AdvCancelIncompleteOrders.php): cancelCoupon() is called only from cancelPendingDefaultCards() (line 88). The PayByBank, Iris (CANCELED branch), PayPal Advanced, and XPay cancellation handlers omit it. Coupons on those orders remain permanently consumed after order cancellation. Each handler's cancel path should call $this->cancelCoupon($order->coupon_id).