Appearance
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:
- REST / Webhook-driven cancellation (
PaymentConfirmationService::cancelPayment()): invoked immediately by payment gateway webhooks and by the REST endpointPOST /rest/checkout/cancel-payment/{orderId}. This is the primary path for gateways that fire synchronous callbacks. - Scheduled cleanup (
AdvCancelIncompleteOrderscron): 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
| File | Role |
|---|---|
ecommercen/job/libraries/AdvCancelIncompleteOrders.php | Job implementation |
application/modules/job/libraries/CancelIncompleteOrders.php | Client-overridable subclass |
ecommercen/eshop/models/Adv_order_model.php | getDebrisOrders(), set_status() |
ecommercen/libraries/internal/OrderCancelHookFireTrait.php | ERP cancel hook trait |
ecommercen/libraries/internal/OrderForErpHookFireTrait.php | ERP acceptance hook trait |
src/PaymentGateways/Iris/Iris.php | Iris payment gateway client |
src/PaymentGateways/PayPal/PayPalRestApi.php | PayPal Advanced REST client |
src/PaymentGateways/NexiXPay/XPay.php | Nexi XPay payment gateway client (getOrderStatus, mapOperationResultToStatus) |
ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php | Pending gift-card reconciliation (mirrors this flow for gift cards) |
ecommercen/eshop/models/Adv_viva_logging_model.php | Payment logging (modern port: src/Domains/Checkout/VivaLogging/ — #148; legacy model still active) |
ecommercen/coupons/models/Adv_coupons_model.php | Coupon 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 ASCThe 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:
| Gateway | Timeout | Rationale |
|---|---|---|
vivawallet | 2 days (P2D) | Viva Wallet redirects can take longer |
jcc | 20 minutes (PT20M) | Fast redirect flow |
| All others | 3 hours (PT180M) | Standard timeout |
If now > entry_datetime + timeout:
set_status(order_serial, 'CANCELED', false, '+', 'order_debris')-- cancels and restores stock (+mode)returnPointsToCustomers()-- rolls back loyalty points ifPOINT_SYSTEM.IS_ENABLEDcancelCoupon()-- marks coupon as unused viacoupons_model->markCouponUnused()internalApiOrderCancelHook()-- fires theInternalApiOrderCancelHookfor ERP sync
PayByBank
Uses the configurable expiration from registry PAY_BY_BANK.EXPIRATION (in seconds):
- Wait until
entry_datetime + EXPIRATIONhas passed. - Call
Factories::payByBank()->cancelOrder(order_serial)to cancel the pending bank transfer. - Log the result to the PayByBank log table (
insertPBBLog()). - Cancel the order via
set_status()(even if the API call fails -- the log captures the error). - Return loyalty points.
- Fire cancel hook.
Iris
Checks actual payment status before deciding:
- Load Iris order record via
order_model->getIrisRecordsByOrderId(). - Call
Iris->getOrderStatus(['orderId' => irisOrderId])to query Iris. - 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, setmeta_datatojob:CancelIncompleteOrders:handlePendingIrisOrders, and fire the ERP acceptance hook (internalApiOrderForErpHook()).
PayPal Advanced
Verifies capture status via PayPal REST API:
- If
tran_ticketis present, callPayPalRestApi->getOrderDetails(tran_ticket). - Check
purchase_units[0].payments.captures[0].status. - If
tran_ticketis empty or status is notCOMPLETED: cancel and return points. - 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):
- Call
XPay->getOrderStatus(order_serial)to fetch the Nexi XPay order record (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:192) - Map
operationResultthroughXPay->mapOperationResultToStatus()to a normalized status (:193) - If response is empty or status is not
PAID:set_status('CANCELED', 'order_debris')+returnPointsToCustomers()+internalApiOrderCancelHook()(:196-199) - 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.phproutes each gateway's cancel/failure callback here. - REST endpoint:
POST /rest/checkout/cancel-payment/{orderId}calls this method directly.
The method:
- Idempotency guard (
:129): iforder.status === 'CANCELED', returnsfalseimmediately. Also returnsfalseif the order is not found. - Stock restoration (
:156): calls$this->stockService->restoreStockForOrder($orderId)— equivalent to the cron'sset_status('CANCELED', '+')stock-restore path. - Event dispatch (
:162): dispatchesOrderCanceledevent, which triggers registered listeners for loyalty-point restore and coupon-usage decrement. - Returns
trueon 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
| Column | Type | Role |
|---|---|---|
id | int | PK |
order_serial | varchar | Human-readable order number |
status | varchar | Current status (filtered for PENDING) |
payway | varchar | Payment gateway identifier |
entry_datetime | datetime | Order creation timestamp (used for timeout) |
coupon_id | int/null | FK to applied coupon |
tran_ticket | varchar/null | Payment transaction reference (PayPal) |
canceled_date | datetime | Set on cancellation |
meta_data | text | Audit trail for status changes |
Related Tables
| Table | Role |
|---|---|
shop_order_basket | Order line items (stock restoration source) |
product_codes | Product SKU stock levels |
iris_orders | Iris payment gateway order mapping |
pay_by_bank_log | PayByBank API call history |
coupons | Coupon usage tracking |
customer_points | Loyalty 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
| Group | Key | Purpose |
|---|---|---|
PAY_BY_BANK | EXPIRATION | Timeout in seconds before PayByBank orders are cancelled |
POINT_SYSTEM | IS_ENABLED | Whether 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()inecommercen/helpers/registry_helper.php:379-387;XPayclient lazy-instantiated viaxPay()getter
Client Extension Points
Override the job class: Extend
AdvCancelIncompleteOrdersinapplication/modules/job/libraries/CancelIncompleteOrders.phpto:- Add custom payment gateway handlers (new
casein the switch) - Change timeout logic for specific gateways
- Add custom notification on cancellation
- Add custom payment gateway handlers (new
Override
getCardPayWays(): Redefine inapplication/helpers/to include or exclude payment methods from debris cleanup.Custom ERP hooks: The
InternalApiOrderCancelHookandInternalApiOrderForErpHookfire webhooks that ERP integrations can subscribe to for order sync.Adjust schedule: Modify the cron expression and grace time in
jobs.php.
Business Rules
| Rule | Description |
|---|---|
| Card payments only | Only orders with online payment methods (from getCardPayWays()) are considered |
| Gateway-specific timeouts | Viva Wallet: 2 days, JCC: 20 min, all others: 3 hours |
| PayByBank respects registry | Timeout driven by PAY_BY_BANK.EXPIRATION registry value (seconds) |
| Iris may accept | If Iris reports PAID, the order is accepted rather than cancelled |
| Iris PENDING = skip | PENDING Iris orders are retried on the next job run |
| XPay may accept | If Nexi XPay reports PAID, the order is accepted; otherwise cancelled. No grace-period timeout — every run hits the API. |
| Stock always restored | set_status() with stockMode='+' restores stock on cancellation |
| Coupon released | Applied coupons are marked unused so the customer can reuse them |
| Points returned | Loyalty points are credited back when POINT_SYSTEM.IS_ENABLED |
| ERP notification | Cancel and accept hooks fire for all cancellations/acceptances |
| Idempotent | Only 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:
- Blanket cancel for most payways: gift-card orders older than
giftCardDateTimeIntervalToDropare mass-cancelled viagift_card_orders_model->cancelPending(), except those paid withiris,paypaladvanced, orxpay(:30). - API-verified payways route to dedicated handlers:
cancelPendingIrisOrders()-- usesIris->getOrderStatus()andIrisHelper::interpretIrisResponse()cancelPendingPaypalAdvancedOrders()-- usesPayPalRestApi->getOrderDetails()cancelPendingXpayOrders()-- usesXPay->getOrderStatus()andXPay->mapOperationResultToStatus()(:91-114)
- Accept/cancel uses gift-card model methods:
gift_card_orders_model->acceptGiftCard($id)andcancelGiftCard($id)instead ofset_status().
Known Issues & Security Gaps
- Duplicate XPay handler in deprecated
Cronjob::order_debris():application/controllers/Cronjob.php:237-260carries an identicalcase 'xpay'branch. Future fixes must be applied to both files or they will drift. Consider removing the deprecated branch. - No grace period for XPay: Unlike
vivawallet(2 days), every PENDINGxpayorder 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. - Undefined
$responsevariable inhandlePendingPaypalAdvancedOrders()(ecommercen/job/libraries/AdvCancelIncompleteOrders.php:176-181):$responseis assigned insideif (!empty($order->tran_ticket)) { ... }but read unconditionally on line 181. PHP 8 emitsE_WARNING: Undefined variable $response. Fix: initialise$response = [];before the conditional. - Coupons not released for non-default gateway cancellations (
ecommercen/job/libraries/AdvCancelIncompleteOrders.php):cancelCoupon()is called only fromcancelPendingDefaultCards()(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).
Related Flows
- SY-01 Cron Job Framework -- job scheduling and execution
- AD-03 Order Management -- PENDING to CANCELED transition
- CF-08 Payment Processing -- payment gateway integration
- SY-16 PayByBank Polling -- complementary PayByBank status checking