OrderCronTrait
Cron entry points of {@see \App\UI\Api\Eshop\Models\Managers\OrderManager}: expiry of unpaid orders, cancellation of orders left awaiting payment, and catch-up invoice issuing.
Both are domain methods over orders, extracted into a trait only for file readability.
Requires $em, $orderStatusManager, $orderTransitionManager, $invoiceGenerator,
$cronFailureManager and getSettingContext() on the consumer.
An order that fails gets a back-off (CronFailureManager) and the selection skips it until then; otherwise permanently broken orders at the head would eat the whole batch on every run.
Tags
Table of Contents
Constants
- DEFAULT_UNPAID_CANCEL_AFTER_DAYS : int = 30
- Fallback of {@see UNPAID_CANCEL_SETTING_KEY} when the row is missing or not a positive number (user decision 18. 10.: 30 days).
- UNPAID_CANCEL_SETTING_KEY : string = 'order.unpaid.cancelAfterDays'
- Setting (module {@see UNPAID_CANCEL_SETTING_MODULE}, e-shop) with the days after creation an order may stay awaiting payment before the cron cancels it. Edited in Eshop › Settings › Orders.
- EXPIRE_DEFAULT_BATCH : int = 50
- EXPIRE_QUEUE : string = 'eshop.order.expire'
- EXPIRED_STATUS_KEY : string = 'EXPIRED'
- INVOICE_DEFAULT_BATCH : int = 20
- INVOICE_QUEUE : string = 'eshop.order.invoice'
- ORDERS_TABLE : string = 'cms_mod_eshop_orders'
- STATUSES_TABLE : string = 'cms_mod_eshop_order_statuses'
- UNPAID_CANCEL_DEFAULT_BATCH : int = 50
- UNPAID_CANCEL_QUEUE : string = 'eshop.order.cancel-unpaid'
- UNPAID_CANCEL_SETTING_MODULE : int = 4
Methods
-
cancelUnpaid()
: array{processed: int, cancelled: int, skipped: int, errors: list
} - Cancels orders that stayed awaiting payment (card/gateway or bank transfer) for {@see unpaidCancelAfterDays()} days since creation (cron `/cron/eshop/order/cancel-unpaid`).
-
expireUnpaid()
: array{processed: int, expired: int, skipped: int, errors: list
} - Expires unpaid orders periodically (cron `/cron/eshop/order/expire`).
-
runInvoiceCatchup()
: array{processed: int, generated: int, permanent: int, skipped: int, errors: list
} - Issues invoices for orders catch-up style (cron `/cron/eshop/order/invoice`).
- unpaidCancelAfterDays() : int
- Days an order may stay awaiting payment before {@see cancelUnpaid()} cancels it: the setting {@see UNPAID_CANCEL_SETTING_KEY}, else {@see DEFAULT_UNPAID_CANCEL_AFTER_DAYS}.
- cancelDueUnpaidOrderIds() : array<int, int>
- Returns orders awaiting payment created before the cutoff, oldest first (read only; the fresh load is done by `apply()`). Parked orders arrive as an id list.
- dueInvoiceOrders() : array<int, TOrderGraph>
- Returns orders of the given status that have no handled invoice for it yet (`invoiced_status` is null or differs from the status), like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `duePayments()`.
- expireDueOrderIds() : array<int, int>
- Returns unpaid orders past `reservation_expires_at` whose current status allows cancelling (`allow_cancelling=1`), via raw SQL (read only; the fresh load is done by `apply()`).
- findExpiredStatus() : TOrderStatusGraph|null
- statusesRequiringInvoice() : array<int, OrderStatus>
- Returns active statuses with `createInvoice=1` (the batch is a budget across them, like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `rulesByStatus()`).
Constants
DEFAULT_UNPAID_CANCEL_AFTER_DAYS
Fallback of {@see UNPAID_CANCEL_SETTING_KEY} when the row is missing or not a positive number (user decision 18. 10.: 30 days).
public
int
DEFAULT_UNPAID_CANCEL_AFTER_DAYS
= 30
UNPAID_CANCEL_SETTING_KEY
Setting (module {@see UNPAID_CANCEL_SETTING_MODULE}, e-shop) with the days after creation an order may stay awaiting payment before the cron cancels it. Edited in Eshop › Settings › Orders.
public
string
UNPAID_CANCEL_SETTING_KEY
= 'order.unpaid.cancelAfterDays'
EXPIRE_DEFAULT_BATCH
private
int
EXPIRE_DEFAULT_BATCH
= 50
EXPIRE_QUEUE
private
string
EXPIRE_QUEUE
= 'eshop.order.expire'
EXPIRED_STATUS_KEY
private
string
EXPIRED_STATUS_KEY
= 'EXPIRED'
INVOICE_DEFAULT_BATCH
private
int
INVOICE_DEFAULT_BATCH
= 20
INVOICE_QUEUE
private
string
INVOICE_QUEUE
= 'eshop.order.invoice'
ORDERS_TABLE
private
string
ORDERS_TABLE
= 'cms_mod_eshop_orders'
STATUSES_TABLE
private
string
STATUSES_TABLE
= 'cms_mod_eshop_order_statuses'
UNPAID_CANCEL_DEFAULT_BATCH
private
int
UNPAID_CANCEL_DEFAULT_BATCH
= 50
UNPAID_CANCEL_QUEUE
private
string
UNPAID_CANCEL_QUEUE
= 'eshop.order.cancel-unpaid'
UNPAID_CANCEL_SETTING_MODULE
private
int
UNPAID_CANCEL_SETTING_MODULE
= 4
Methods
cancelUnpaid()
Cancels orders that stayed awaiting payment (card/gateway or bank transfer) for {@see unpaidCancelAfterDays()} days since creation (cron `/cron/eshop/order/cancel-unpaid`).
public
cancelUnpaid([int|null $limit = null ]) : array{processed: int, cancelled: int, skipped: int, errors: list}
Like expireUnpaid() it has no cancellation logic of its own: it selects due ids with raw
SQL over the module's tables (OrderTransitionManager::UNPAID_CANCEL_DUE_PREDICATE) and
calls only OrderTransitionManager::apply() to the EXPIRED status with the opt
cancelUnpaidCreatedBefore, which re-checks under the lock, refuses an order whose payment
already brought money (paymentReceived → back-off and an admin notification once attempts are
exhausted), releases the reservation, gives back vouchers and queues the status e-mail of
EXPIRED ("not paid within the period, cancelled"). A late gateway payment then leaves the
order cancelled and alerts the admin (EshopPaymentHook),
and the gateway refuses to start a new payment for it (isPaymentPayable()).
Idempotent: is_cancelled=1 removes the order from the next selection and apply() is a no-op
on the same status, so a second run sends no second e-mail.
Parameters
- $limit : int|null = null
Return values
array{processed: int, cancelled: int, skipped: int, errors: listexpireUnpaid()
Expires unpaid orders periodically (cron `/cron/eshop/order/expire`).
public
expireUnpaid([int|null $limit = null ]) : array{processed: int, expired: int, skipped: int, errors: list}
Has no cancellation logic of its own: it selects due order ids with raw SQL (read only) and
calls only OrderTransitionManager::apply() to the EXPIRED status (OrderStatus::$key),
which handles the fresh load under lock, gating, reservation release, voucher decrement
(cancelOrder=1) and the e-mail. Idempotent: apply() is a no-op on an already-target status
and is_cancelled=1 removes the order from the next selection (see
expireDueOrderIds()), so two consecutive runs produce no second effect.
The scope is deliberately limited to statuses with allow_cancelling=1 (currently
CREATED/APPROVED, resolved by JOIN, not hardcoded ids): checkGating() would reject anything
else (e.g. WAITING-ON-PAYMENT with a proforma already issued) with transitionNotAllowed.
The underlying stock reservation of WAITING-ON-PAYMENT orders expires independently via the
Store cron /cron/store/reservation/expire, so this limit does not endanger stock.
Parameters
- $limit : int|null = null
Return values
array{processed: int, expired: int, skipped: int, errors: listrunInvoiceCatchup()
Issues invoices for orders catch-up style (cron `/cron/eshop/order/invoice`).
public
runInvoiceCatchup([int|null $limit = null ]) : array{processed: int, generated: int, permanent: int, skipped: int, errors: list}
A watermark reconcile of the same shape as
InvoiceManager::processPaidPayments(), but over
orders: a batch of orders whose current status has createInvoice=1 and whose
invoiced_status differs from that status id (null means never invoiced for this status).
Has no invoicing logic of its own: it calls only
OrderInvoiceGenerator::generateForStatus(), the same
generator OrderTransitionManager uses; the generator writes invoiced_status itself on
success and when it finds an existing invoice.
Permanent vs transient failures (errorCode from the generator): the codes in
OrderInvoiceGenerator::PERMANENT_ERROR_CODES (for example sourceInvoiceMissing,
negativeTotal) are permanent (retrying does not help), so the watermark still advances,
otherwise the order would eat the batch forever; the admin resolves it manually (see errors
in the stats). Anything else (missing seller configuration, DB error, \Throwable) is
transient: the watermark is left alone and the order is retried next run.
IMPORTANT: the order includes must be OrderTransitionManager::ORDER_INCLUDES (the same
list, notably gifts), otherwise the object graph is cleared incorrectly.
Parameters
- $limit : int|null = null
Return values
array{processed: int, generated: int, permanent: int, skipped: int, errors: listunpaidCancelAfterDays()
Days an order may stay awaiting payment before {@see cancelUnpaid()} cancels it: the setting {@see UNPAID_CANCEL_SETTING_KEY}, else {@see DEFAULT_UNPAID_CANCEL_AFTER_DAYS}.
public
unpaidCancelAfterDays() : int
IMPORTANT: zero or a negative value never passes through (the admin form allows 1–3650). The status
awaiting payment cannot be cancelled by hand (allow_cancelling=0), so "never cancel" would leave
such orders hanging forever; 0 would cancel every order awaiting payment on the next run.
Return values
intcancelDueUnpaidOrderIds()
Returns orders awaiting payment created before the cutoff, oldest first (read only; the fresh load is done by `apply()`). Parked orders arrive as an id list.
private
cancelDueUnpaidOrderIds(DateTimeImmutable $createdBefore, int $limit[, array<int, int> $parkedIds = [] ]) : array<int, int>
Parameters
- $createdBefore : DateTimeImmutable
- $limit : int
- $parkedIds : array<int, int> = []
Return values
array<int, int>dueInvoiceOrders()
Returns orders of the given status that have no handled invoice for it yet (`invoiced_status` is null or differs from the status), like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `duePayments()`.
private
dueInvoiceOrders(OrderStatus $status, int $limit[, array<int, int> $parkedIds = [] ]) : array<int, TOrderGraph>
Parked orders are skipped; ordered by id (deterministic).
Parameters
- $status : OrderStatus
- $limit : int
- $parkedIds : array<int, int> = []
Return values
array<int, TOrderGraph>expireDueOrderIds()
Returns unpaid orders past `reservation_expires_at` whose current status allows cancelling (`allow_cancelling=1`), via raw SQL (read only; the fresh load is done by `apply()`).
private
expireDueOrderIds(int $limit[, array<int, int> $parkedIds = [] ]) : array<int, int>
Uses the module's own tables (cms_mod_eshop_*), so the layer boundary guard does not
report it. Parked orders arrive as an id list, not a JOIN on a foreign tracking table.
Parameters
- $limit : int
- $parkedIds : array<int, int> = []
Return values
array<int, int>findExpiredStatus()
private
findExpiredStatus() : TOrderStatusGraph|null
Return values
TOrderStatusGraph|nullstatusesRequiringInvoice()
Returns active statuses with `createInvoice=1` (the batch is a budget across them, like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `rulesByStatus()`).
private
statusesRequiringInvoice() : array<int, OrderStatus>