Skip to content

Incomplete Order Cancellation ​

Flow ID: SY-03 | Module(s): job, eshop, checkout | Complexity: High Last Updated: 2026-09-29 — 4.124.0 resync: citation drift corrections (Adv_settings.php, Adv_order_model.php, PaymentConfirmationService.php) and the retired REST webhook controller (Advisable-com/ecommercen#721) replaced by the actual cancelPayment() callers; earlier (2026-09-14) citation refresh: the getXPaySettings() citation moved again, to registry_helper.php:585-595, after getVivaWalletExternalSettings() (Advisable-com/ecommercen#729) was inserted between getVivaWalletMergedSettings() and getJccSettings(), pushing every later helper further down the file; the pay_by_bank_expiration admin-field citation was corrected from Adv_settings.php:1363 (which is unrelated PayPal settings code) to Adv_settings.php:1564 (the actual ->set_rules('pay_by_bank_expiration', '', 'trim') call); Advisable-com/ecommercen#688: set_status() now also restores gift stock on CANCELED cancellations (Adv_order_model.php:1830-1832, restoreOrderGifts()), gated by the new shop_order.gifts_applied column, with a matching RestoreGiftStockOnCanceledListener on the modern REST cancellation path -- see Stock Restoration, Code Flow step 3, and Data Model below; Advisable-com/ecommercen#706: the getXPaySettings() citation moved to registry_helper.php:534-542 after getVivaWalletMergedSettings() was added above it in the same helper file; the helper itself is unchanged; Advisable-com/ecommercen#531: an unparseable entry_datetime (NULL or a legacy zero-date row) no longer fatals the whole cron run -- the new shared orderEntryDate() helper skips just that order (logged, left PENDING) and the batch continues; PAY_BY_BANK/EXPIRATION is now validated the same way XPAY/EXPIRATION already was, falling back to 86400s (24h) when unset/non-numeric, and the gift-card sibling job's giftCardDateTimeIntervalToDrop config item gets an analogous PT180M fallback; Advisable-com/ecommercen#500: getCardPayWays() now covers xpay/klarna_payments/ethniki_nbgpay (17 payways total, closing the drift that stranded their PENDING orders), XPay gained a registry-configurable grace period (XPAY/EXPIRATION) before probing Nexi, and the deprecated Cronjob::order_debris() route now delegates to the job instead of duplicating it; Advisable-com/ecommercen#530: tests/Unit/Helpers/PayWayDebrisCoverageTest.php now also guards a second, independent payway-list invariant (isOrderPaidAtDeliveryByPayWay(), see AD-34) -- the getCardPayWays() behavior documented below is unchanged

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-driven cancellation (PaymentConfirmationService::cancelPayment()): invoked immediately by the REST endpoint POST /rest/checkout/cancel-payment/{orderId}, by Rest\Order\Controllers\Order, and by the PollPayByBankStatus job (the REST webhook family was retired in Advisable-com/ecommercen#721). This is the primary path for REST-placed orders.
  2. Scheduled cleanup (AdvCancelIncompleteOrders cron): runs every 5 minutes as a safety net for stuck PENDING orders that the REST 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. An order whose entry_datetime cannot be trusted (NULL, or a legacy '0000-00-00 00:00:00' zero-date row) is skipped, not cancelled -- logged at error level and left PENDING for a human to fix, rather than aborting the whole run or being silently auto-cancelled (Advisable-com/ecommercen#531).

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 -- after a configurable grace period elapses (registry XPAY/EXPIRATION, default 180 minutes), 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:
  |     +--> orderEntryDate($order)          SHARED entry_datetime guard (default cards, PayByBank, XPay)
  |     |     null (unparseable) --> return; skip this order, batch continues (#531)
  |     |
  |     +--> cancelOrder($order)             SHARED cancel side-effects (every path)
  |     |     +--> set_status('CANCELED', '+')   cancel + restore product stock + restore gift stock (#688)
  |     |     +--> 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
application/controllers/Cronjob.phpDeprecated manual-trigger route (/cronjob/order_debris); order_debris() delegates to (new CancelIncompleteOrders())->executeCommand([]) instead of duplicating the switch (Advisable-com/ecommercen#500)
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_nbgpay','ethniki_ee',
                 'eurobank','paybybank','piraeus','apcopay','vivawallet','jcc','iris',
                 'paypaladvanced','stripe','klarna_payments','xpay')
ORDER BY id ASC

The getCardPayWays() helper (ecommercen/helpers/eshop_helper.php) defines the list of eligible payment methods -- 17 entries as of Advisable-com/ecommercen#500, which added ethniki_nbgpay, klarna_payments, and xpay after finding they parked orders at PENDING but were missing from this list (see Known Issues). Cash-on-delivery and bank-transfer orders are excluded because they do not require immediate online payment -- along with delivery, bank_transfer, and paid_at_store, whose Adv_checkout handlers write PENDING_ACCEPTED rather than PENDING, so getDebrisOrders()'s status = 'PENDING' filter never selects them regardless of this list. getCardPayWays() is despite its name not "card payways" but "payways that park an order at PENDING" -- it legitimately includes non-cards (paybybank, iris, klarna_payments). A drift guard, tests/Unit/Helpers/PayWayDebrisCoverageTest.php, pins every allPayWays() entry other than those three PENDING_ACCEPTED payways as required here. That same test class also pins a second, independent invariant added by Advisable-com/ecommercen#530 -- the sibling helper isOrderPaidAtDeliveryByPayWay()'s landing-status partition of allPayWays(), which governs voucher (shipping-label) eligibility rather than debris-cron coverage (see AD-34 Voucher Generation) -- so the file is not debris-cron-only despite its name; the getCardPayWays() behavior described above is unaffected by that second guard.

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_nbgpay, ethniki_ee, eurobank, piraeus, apcopay, stripe, klarna_payments) ​

Timeout-based cancellation with gateway-specific grace periods. klarna_payments is routed here on purpose: the modern REST checkout's KlarnaAdapter writes PENDING by design and its fraud_status webhook is the single confirmation point, so a lost webhook is swept by this same 3-hour default rather than a dedicated API probe.

entry_datetime is parsed via the shared orderEntryDate($order): ?DateTime helper before any timeout math runs. A NULL or legacy zero-date ('0000-00-00 00:00:00') value returns null from the helper, and the caller returns immediately -- the order is skipped (left PENDING, logged at error level) rather than raising a fatal Error from calling ->add() on a parse failure or, worse, being silently auto-cancelled by a zero-date value that a naive false-only check would treat as valid (Advisable-com/ecommercen#531).

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), validated by payByBankExpirationSeconds():

  1. entry_datetime is parsed via the same shared orderEntryDate() guard described above; a null result skips the order instead of throwing.
  2. Wait until entry_datetime + EXPIRATION has passed. payByBankExpirationSeconds() (ecommercen/job/libraries/AdvCancelIncompleteOrders.php) validates the registry value to a positive int before it reaches the DateInterval spec string, falling back to PAY_BY_BANK_DEFAULT_EXPIRATION_SECONDS = 86400 (24 hours) for any unset, empty, or non-numeric value -- mirroring xPayExpirationSeconds() (Advisable-com/ecommercen#500, #531). The admin field behind this registry key (ecommercen/settings/controllers/Adv_settings.php:1564) validates with trim alone (no required, no numeric), and no shop ships a default row for it, so this fallback is the only thing standing between an empty/blank admin field and an invalid DateInterval spec ('PTS') that would abort the whole cron run.
  3. Call Factories::payByBank()->cancelOrder(order_serial) to cancel the pending bank transfer.
  4. Log the result to the PayByBank log table (insertPBBLog()).
  5. 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, but only after a grace period elapses -- added by Advisable-com/ecommercen#500, since making the previously-unreachable xpay branch reachable would otherwise have started cancelling orders mid-redirect:

  1. Entry-datetime guard: entry_datetime is parsed via the shared orderEntryDate() guard described under Default Card Gateways above; a null result skips the order before the grace-period math below runs.
  2. Grace-period guard (xPayExpirationSeconds(), ecommercen/job/libraries/AdvCancelIncompleteOrders.php): if now <= entry_datetime + XPAY.EXPIRATION seconds, return immediately and let the next 5-minute run retry. The window defaults to 10800 seconds (180 minutes) and is read from registry XPAY/EXPIRATION; any unset, empty, non-numeric, or non-positive registry value falls back to the default rather than reaching the DateInterval spec string (which would throw and abort the whole cron run). There is no admin settings field for this key -- it is registry-only. PAY_BY_BANK/EXPIRATION is validated the same way by payByBankExpirationSeconds() (Advisable-com/ecommercen#531) -- see the PayByBank section above.
  3. Call XPay->getOrderStatus(order_serial) to fetch the Nexi XPay order record
  4. Map operationResult through XPay->mapOperationResultToStatus() to a normalized status
  5. If response is empty or status is not PAID: cancel via the shared cancelOrder() helper -- set_status + return points + release coupon + ERP cancel hook
  6. If status is PAID (payment succeeded but customer never returned): update_order(status=PAID) + internalApiOrderForErpHook()

3. REST-Driven Cancellation ​

PaymentConfirmationService::cancelPayment() (src/Domains/Checkout/PaymentConfirmationService.php:139-190) is the primary cancellation path for REST-placed orders. The REST webhook controller (src/Rest/Webhooks) was retired in Advisable-com/ecommercen#721 and no longer exists. Current callers:

  • REST endpoint: POST /rest/checkout/cancel-payment/{orderId} — Checkout::cancelPayment() (src/Rest/Checkout/Controllers/Checkout.php:1682).
  • Order REST controller: Rest\Order\Controllers\Order (src/Rest/Order/Controllers/Order.php:290).
  • PayByBank polling job: PollPayByBankStatus (src/Domains/Checkout/Jobs/PollPayByBankStatus.php:107 for orders the gateway no longer knows, :128 for cancelled gateway statuses).

The method:

  1. Idempotency guard (:148): if order.status === 'CANCELED', returns true immediately (idempotent success). Returns false if the order is not found (:143-145).
  2. Stock restoration (:175): calls $this->stockService->restoreStockForOrder($orderId) — equivalent to the cron's set_status('CANCELED', '+') stock-restore path.
  3. Event dispatch (:181): dispatches OrderCanceled event, which triggers registered listeners for loyalty-point restore, coupon-usage decrement, and gift-stock restore (RestoreGiftStockOnCanceledListener, src/Domains/Order/Event/Listeners/RestoreGiftStockOnCanceledListener.php -- Advisable-com/ecommercen#688).
  4. Returns true on success.

The AdvCancelIncompleteOrders cron acts as the safety net for orders where no cancel/confirm signal was received (no REST cancel-payment call, no gateway callback: 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.

Since Advisable-com/ecommercen#688, set_status() also restores gift stock under a narrower gate -- stockMode === '+' and status === 'CANCELED' (ecommercen/eshop/models/Adv_order_model.php:1830-1832): it calls restoreOrderGifts($orderSerial) (:1031-1073), which claims the once-only shop_order.gifts_applied marker via claimGiftsApplied() (:1125-1137) and, on a successful claim, restores each gift's counter via gifts_model->restoreCounter(). Because every legacy cancellation path documented in this flow converges on the shared cancelOrder() helper (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:194), which calls set_status($order->order_serial, 'CANCELED', false, '+', 'order_debris'), gift stock is restored for every gateway (default cards, PayByBank, Iris, PayPal Advanced, XPay) on cancellation.

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
gifts_appliedtinyint(1)1 while gift stock is currently consumed for this order; cleared by claimGiftsApplied() on restore (database/migrations/20260831120000_add_gifts_applied_to_shop_order.php:13, Advisable-com/ecommercen#688)
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 (default 86400 = 24 hours if unset, empty, or non-numeric -- validated by payByBankExpirationSeconds(), Advisable-com/ecommercen#531; has an admin field, ecommercen/settings/controllers/Adv_settings.php:1564, but it is validated with trim alone and no shop ships a default row)
XPAYEXPIRATIONGrace period in seconds before a PENDING xpay order is probed against the Nexi API (default 10800 = 180 minutes if unset, empty, non-numeric, or non-positive). No admin UI field -- registry-only (Advisable-com/ecommercen#500).
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:585-595; XPay client lazy-instantiated via xPay() getter; grace period read from registry XPAY/EXPIRATION via xPayExpirationSeconds()

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/ (the base is function_exists()-guarded) to include or exclude payment methods from debris cleanup. Caution: omitting a payway whose checkout handler writes PENDING silently strands its orders forever -- no stock restore, no loyalty-points return, no coupon release, no ERP cancel hook, and no error raised anywhere. This is exactly how xpay, klarna_payments, and ethniki_nbgpay drifted out of coverage upstream (Advisable-com/ecommercen#500); a client override does not automatically inherit that fix and must independently include any PENDING-writing payway it adds.

  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, XPay: registry XPAY.EXPIRATION (default 3 hours), all others: 3 hours
PayByBank respects registryTimeout driven by PAY_BY_BANK.EXPIRATION registry value (seconds); defaults to 86400 (24 hours) for any unset/empty/non-numeric value (Advisable-com/ecommercen#531)
XPay respects registryGrace period driven by XPAY.EXPIRATION registry value (seconds); no admin UI field, defaults to 180 minutes for any unset/invalid value
Untrustworthy entry_datetime = skipAn order whose entry_datetime fails to parse cleanly (NULL or a legacy zero-date row) is left PENDING and logged, never auto-cancelled and never allowed to abort the run (Advisable-com/ecommercen#531)
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 -- but only once the XPAY.EXPIRATION grace period has elapsed, so an order whose shopper is still mid-redirect on the Nexi hosted page is left alone for the next run.
Stock always restoredset_status() with stockMode='+' restores product stock on cancellation; when status === 'CANCELED' it also restores gift stock via restoreOrderGifts() (Advisable-com/ecommercen#688)
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 (:43).
  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() (:146-169)
  3. Accept/cancel uses gift-card model methods: gift_card_orders_model->acceptGiftCard($id) and cancelGiftCard($id) instead of set_status().
  4. giftCardDateTimeIntervalToDrop is now validated the same way the main flow validates XPAY/PAY_BY_BANK EXPIRATION: the new dateTimeIntervalToDrop() helper guards a missing/empty/non-string config value and a malformed DateInterval spec, falling back to DEFAULT_DATE_TIME_INTERVAL_TO_DROP = 'PT180M' (3 hours -- identical to the value this repo's application/config/app.php:537 ships) and logging at error level (Advisable-com/ecommercen#531). The live vector is a client fork whose application/config/app.php predates the giftCardDateTimeIntervalToDrop key: previously, config->item() returning null fed straight into new \DateInterval(null), which throws before the job's first query runs, aborting the entire gift-card reconciliation run.

Known Issues & Security Gaps ​

  1. No grace period for Iris (open): unlike vivawallet (2 days) and now xpay (registry XPAY/EXPIRATION, default 180 minutes -- closed for XPay by Advisable-com/ecommercen#500), a PENDING iris order hits the Iris API on every cron run from creation, so if the customer is still completing the Iris flow the order could in principle be flagged CANCELED before they finish. Not yet fixed; this issue previously also covered XPay, which is now resolved.
  2. xpay/klarna_payments/ethniki_nbgpay payway-coverage gap -- resolved (Advisable-com/ecommercen#500): these three branches were previously unreachable dead code -- getCardPayWays() (ecommercen/helpers/eshop_helper.php) omitted all three, so the sole order source getDebrisOrders() could never select their PENDING orders even though the job's switch already handled them (the gift-card job's AdvCancelPendingGiftCards special-cased xpay the whole time, underscoring that it was a registered, live payway). Fixed by adding all three to getCardPayWays(), bringing it to 17 entries; a drift guard (tests/Unit/Helpers/PayWayDebrisCoverageTest.php) now pins the invariant. That same test class was later extended (Advisable-com/ecommercen#530, see item 4 below) with a second, independent invariant over the sibling voucher-eligibility helper -- the class name predates that second guard and is kept as-is since released changelogs already cite it by name.
  3. Duplicate XPay handler in deprecated Cronjob::order_debris() -- resolved (Advisable-com/ecommercen#500): the manually-triggerable route used to carry a full duplicate of the switch and per-gateway handlers -- including the same then-dead xpay branch -- at permanent risk of drifting from the job (the #290 coupon-release fix had already landed only in the job, not this duplicate). order_debris() now delegates to (new CancelIncompleteOrders())->executeCommand([]), so the route and the scheduled job are guaranteed to behave identically going forward.
  4. Observation: getCardPayWays() and isOrderPaidAtDeliveryByPayWay()'s exclusion list are now set-identical -- not a cue to unify them. After Advisable-com/ecommercen#530, both lists hold the same 17 payways, because both independently partition allPayWays() along the same PENDING-vs-PENDING_ACCEPTED landing-status boundary that Adv_checkout's handlers actually produce (see AD-34 Voucher Generation). This is a deliberate observation, not a design invariant to enforce, and unifying the two lists is explicitly out of scope here: a client fork that overrides only one of the two helpers would silently diverge from the other, a client-override migration hazard. If unification is ever pursued, it needs its own scoped issue with an explicit override-migration plan.
  5. One bad entry_datetime aborting the whole cron run -- resolved (Advisable-com/ecommercen#531): cancelPendingDefaultCards(), cancelPendingPayByBank(), and handlePendingXpayOrders() each fed entry_datetime into DateTime::createFromFormat() and called ->add() on the result one line later. entry_datetime is datetime DEFAULT NULL, so NULL (or an empty/garbage value) is structurally possible; for those, createFromFormat() returns false, and false->add() is a fatal Error in PHP 8 that killed the whole executeCommand() foreach -- stranding every remaining PENDING order in that run (stock reserved, coupons held, points withheld) and re-fataling every 5 minutes until the row was hand-fixed. A legacy zero-date row ('0000-00-00 00:00:00') is a second, distinct bad-data shape: createFromFormat() does not return false for it -- it returns a valid DateTime rolled back to -0001-11-30 with a "parsed date was invalid" warning from DateTime::getLastErrors(). A false-only guard would have let that object through as valid and silently auto-cancelled the order, since a year -0001 timestamp trivially clears every grace window. A related hazard in the same file: cancelPendingPayByBank() interpolated the raw PAY_BY_BANK/EXPIRATION registry value into a DateInterval spec with no validation, so an empty/non-numeric value (reachable via the unvalidated pay_by_bank_expiration admin field) built an invalid spec and threw, the same batch-abort. Fixed by the shared orderEntryDate() guard (which inspects DateTime::getLastErrors() in addition to the return value, so both bad-data shapes are treated as unparseable and the order is skipped rather than cancelled or auto-cancelled) and by payByBankExpirationSeconds(), mirroring xPayExpirationSeconds() (#500). The gift-card sibling job AdvCancelPendingGiftCards had the analogous hazard around giftCardDateTimeIntervalToDrop, fixed the same way.