CoolCMS API

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
phpstan-import-type

TOrderGraph from OrderCalculator

TOrderStatusGraph from OrderTransitionManager

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'

EXPIRED_STATUS_KEY

private string EXPIRED_STATUS_KEY = 'EXPIRED'

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

expireUnpaid()

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

runInvoiceCatchup()

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

unpaidCancelAfterDays()

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
int

cancelDueUnpaidOrderIds()

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

statusesRequiringInvoice()

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>
Return values
array<int, OrderStatus>
On this page

Search results