Appearance
Incomplete Order Cancellation
Flow ID: SY-03 | Module(s): job, eshop, checkout | Complexity: High Last Updated: 2026-07-21 — 4.119.0 release resync: corrected the getDebrisOrders payway list and the cancel-payment idempotent return; recorded the unreachable xpay/klarna_payments branches
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:
| +--> cancelOrder($order) SHARED cancel side-effects (every path)
| | +--> set_status('CANCELED', '+') cancel + restore stock
| | +--> returnPointsToCustomers() loyalty point rollback
| | +--> cancelCoupon() release the applied coupon
| | +--> internalApiOrderCancelHook() ERP cancel webhook
| |
| +--> cancelPendingDefaultCards() timeout-based cancel (most gateways) --> cancelOrder()
| +--> cancelPendingPayByBank() SDK cancelOrder() + insertPBBLog() --> cancelOrder()
| +--> handlePendingIrisOrders() [CANCELED] --> cancelOrder() | [PAID] --> update_order(PAID) + ErpHook
| +--> handlePendingPaypalAdvancedOrders() [not COMPLETED] --> cancelOrder()
| +--> handlePendingXpayOrders() [not PAID] --> cancelOrder() | [PAID] --> update_order(PAID) + ErpHookNote: every cancellation branch routes through the shared cancelOrder() helper, so all five gateways (default cards, PayByBank, Iris, PayPal Advanced, XPay) release the applied coupon via cancelCoupon() on cancel (Advisable-com/ecommercen#290).
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','stripe')
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, stripe)
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, delegates to the shared cancelOrder($order) helper, which runs:
set_status(order_serial, 'CANCELED', false, '+', 'order_debris')-- cancels and restores stock (+mode)returnPointsToCustomers()-- rolls back loyalty points ifPOINT_SYSTEM.IS_ENABLEDcancelCoupon()-- marks the applied coupon unused viacoupons_model->markCouponUnused()internalApiOrderCancelHook()-- fires theInternalApiOrderCancelHookfor ERP sync
All five cancellation paths (default cards, PayByBank, Iris, PayPal Advanced, XPay) route their cancel branch through cancelOrder(), so every gateway releases the applied coupon (Advisable-com/ecommercen#290 — previously only this default-cards path did).
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 via the shared
cancelOrder()helper -- set_status + restore stock, return loyalty points, release the applied coupon, fire the ERP cancel hook (even if the API call fails, the log captures the error).
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 via
cancelOrder()-- cancel, return points, release the applied coupon, 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 viacancelOrder()-- cancel, return points, release the applied coupon, fire cancel hook. - 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: cancel via the sharedcancelOrder()helper -- set_status + return points + release coupon + ERP cancel hook (:195-200) - 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', returnstrueimmediately (idempotent success). Returnsfalseif the order is not found (:124-126). - 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()(guarded by!empty($order->tran_ticket); a ticket-less order falls through tocancelGiftCard()instead of throwing, mirroring the main flow's "empty ticket => cancel" rule -- Advisable-com/ecommercen#416)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. (The #290 coupon-release fix landed inAdvCancelIncompleteOrdersonly; this deprecated duplicate was intentionally left untouched — another reason to remove it.) - No grace period for XPay: Unlike
vivawallet(2 days), a PENDINGxpayorder would hit the Nexi XPay API on every cron run from creation, so if the customer is still on the gateway page the order could be cancelled prematurely. The same risk exists in the Iris branch. Note: forxpaythis is currently latent — see item 3, that branch is unreachable. xpayandklarna_paymentsbranches are unreachable — those PENDING orders are never cancelled: the job switches oncase 'xpay'(ecommercen/job/libraries/AdvCancelIncompleteOrders.php:51-52, handlerhandlePendingXpayOrders()at:197-209) andcase 'klarna_payments'(:64), but its sole order sourcegetDebrisOrders()(ecommercen/eshop/models/Adv_order_model.php:2673-2683) filtersWHERE payway IN getCardPayWays(), andgetCardPayWays()(ecommercen/helpers/eshop_helper.php:228-243) returns neither — even thoughxpayis a registered payway (ecommercen/eshop/libraries/AdvPaymentsRegistry.php:697) that the gift-card job special-cases (AdvCancelPendingGiftCards.php:30). Net effect: PENDINGxpay/klarna_paymentsorders are never timed out, cancelled, or reconciled by this cron, and both handlers are dead code. The deprecatedCronjob::order_debris()duplicate (item 1) carries the same dead branch.
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