Skip to content

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:

  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:
  |     +--> 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) + ErpHook

Note: 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

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','stripe')
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, stripe)

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, delegates to the shared cancelOrder($order) helper, which runs:

  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 the applied coupon unused via coupons_model->markCouponUnused()
  4. internalApiOrderCancelHook() -- fires the InternalApiOrderCancelHook for 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):

  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 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:

  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 via cancelOrder() -- cancel, return points, release the applied coupon, 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 via cancelOrder() -- cancel, return points, release the applied coupon, fire cancel hook.
  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: cancel via the shared cancelOrder() helper -- set_status + return points + release coupon + ERP cancel hook (:195-200)
  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 true immediately (idempotent success). Returns false if the order is not found (:124-126).
  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() (guarded by !empty($order->tran_ticket); a ticket-less order falls through to cancelGiftCard() instead of throwing, mirroring the main flow's "empty ticket => cancel" rule -- Advisable-com/ecommercen#416)
    • 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. (The #290 coupon-release fix landed in AdvCancelIncompleteOrders only; this deprecated duplicate was intentionally left untouched — another reason to remove it.)
  2. No grace period for XPay: Unlike vivawallet (2 days), a PENDING xpay order 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: for xpay this is currently latent — see item 3, that branch is unreachable.
  3. xpay and klarna_payments branches are unreachable — those PENDING orders are never cancelled: the job switches on case 'xpay' (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:51-52, handler handlePendingXpayOrders() at :197-209) and case 'klarna_payments' (:64), but its sole order source getDebrisOrders() (ecommercen/eshop/models/Adv_order_model.php:2673-2683) filters WHERE payway IN getCardPayWays(), and getCardPayWays() (ecommercen/helpers/eshop_helper.php:228-243) returns neither — even though xpay is a registered payway (ecommercen/eshop/libraries/AdvPaymentsRegistry.php:697) that the gift-card job special-cases (AdvCancelPendingGiftCards.php:30). Net effect: PENDING xpay / klarna_payments orders are never timed out, cancelled, or reconciled by this cron, and both handlers are dead code. The deprecated Cronjob::order_debris() duplicate (item 1) carries the same dead branch.