EshopPaymentHook
in package
Eshop payment hook: the callback target of `cms_system_payment_callbacks.callback_key = ESHOP_ORDER`.
IMPORTANT: the FQCN is stored in the database, in the namespace and class columns of that
callback row (resolved by PaymentProcessorTrait::resolveCallbackTarget()). Moving or renaming
the class requires deploying the matching SQL together with the code. Until then checkout with a
payment gateway fails closed through OrderPaymentManager::checkCallbackWiring(), which is
intended: better not to create the order than to let it be paid blindly.
IMPORTANT: PaymentProcessorTrait::processStatus() calls doCallback() unconditionally for every
payment state transition (NEW, START, PENDING, COMPLETED, ...), not only on success, so the hook
checks for COMPLETED itself. doCallback() has no try/catch of its own, so the hook keeps
itself fail-open: an exception from here would fail the response to the gateway, which would
treat the callback as undelivered and repeat it (or mark the payment unresolved).
Idempotence has three layers:
processPayment()never lets a duplicate callback with the same state through.- The "order really awaits payment" guard makes a late or repeated
COMPLETEDa no-op. - OrderTransitionManager::apply() has its own no-op guard for the same status plus a per-order lock.
apply() is always called outside a transaction: a nested one would, through a savepoint, break
the durability of the e-mail outbox (errorCode nestedTransaction).
Stock coverage: the Store reservation of a WAITING-ON-PAYMENT
order expires on its own (ReservationExpirer) while the payment link keeps working, so a
late payment could mark as PAID goods already sold to someone else. Before the transition the
hook therefore checks the reservation (ensureStockCoverage()): a lapsed one is renewed
via OrderStockManager::rereserve(). When the stock is no longer there, the money has
arrived but the order is neither moved to PAID nor canceled automatically — refunding or
restocking is a business decision. It stays in its payable status with a note in
status_informations, the order_payments link keeps the payment visible, and the admin gets
a bell notification ORDER_PAYMENT_ATTENTION (also sent for a payment completed on a cancelled
order and for an underpayment — every case where money arrived and the order cannot settle).
Table of Contents
Constants
- NOTIFICATION_TYPE_KEY : string = 'ORDER_PAYMENT_ATTENTION'
- Admin bell type for a received payment the order could not absorb (created by SQL migration).
- STATUS_PAID : string = 'PAID'
- Target status after a confirmed payment: paid through the gateway (`pair_payment = 1`).
- PAYABLE_STATUS_KEYS : array<string|int, mixed> = ['WAITING-ON-PAYMENT', 'CREATED', 'APPROVED']
- Statuses from which a payment may move the order. Anything else (canceled, already paid, shipped) is a late or duplicate callback and a no-op.
- SETTING_MODULE : int = 4
Properties
- $em : EntityManagerInterface
- $notificationManager : NotificationManager
- $notificationTypeManager : NotificationTypeManager
- $orderManager : OrderManager
- $settingContext : SettingContext
- $stockManager : OrderStockManager
- $transitionService : OrderTransitionManager
Methods
- __construct() : mixed
- handlePayment() : Result
- isPaymentPayable() : bool
- Tells whether a payment of this module may still be started at the gateway. False once any linked order is canceled/expired or already settled, so the customer cannot pay for an order that will never ship. A payment without linked orders is not ours to judge (true).
- allowsOutOfStock() : bool
- coversOrderTotal() : bool
- Checks that the payment covers the order; otherwise the order is not marked paid.
- ensureStockCoverage() : bool
- Ensures the goods are still held for the order before it may count as paid (see the class docblock for the decision). A lapsed or released reservation is renewed under the order lock; strictly, unless the shop sells out of stock or the original reservation was already partial ({@see OrderStockCoverage::allowPartialForRenewal()}).
- isCancelled() : bool
- isPayable() : bool
- Guard that reads the order status only from the DB (raw), because an in-memory entity may be stale and the "already paid / canceled" decision must be hard.
- notifyAdmin() : void
- Best-effort admin bell entry (like `NotificationProducerTrait`): every admin sees it, a missing/inactive type is skipped silently, a failure never reaches the gateway.
- orderIdsForPayment() : array<int, int>
- Returns the order ids linked to a payment.
- statusIdByKey() : int|null
Constants
NOTIFICATION_TYPE_KEY
Admin bell type for a received payment the order could not absorb (created by SQL migration).
public
string
NOTIFICATION_TYPE_KEY
= 'ORDER_PAYMENT_ATTENTION'
STATUS_PAID
Target status after a confirmed payment: paid through the gateway (`pair_payment = 1`).
public
string
STATUS_PAID
= 'PAID'
PAYABLE_STATUS_KEYS
Statuses from which a payment may move the order. Anything else (canceled, already paid, shipped) is a late or duplicate callback and a no-op.
private
array<string|int, mixed>
PAYABLE_STATUS_KEYS
= ['WAITING-ON-PAYMENT', 'CREATED', 'APPROVED']
SETTING_MODULE
private
int
SETTING_MODULE
= 4
Properties
$em read-only
private
EntityManagerInterface
$em
$notificationManager read-only
private
NotificationManager
$notificationManager
$notificationTypeManager read-only
private
NotificationTypeManager
$notificationTypeManager
$orderManager read-only
private
OrderManager
$orderManager
$settingContext read-only
private
SettingContext
$settingContext
$stockManager read-only
private
OrderStockManager
$stockManager
$transitionService read-only
private
OrderTransitionManager
$transitionService
Methods
__construct()
public
__construct(EntityManagerInterface $em, OrderManager $orderManager, OrderTransitionManager $transitionService, OrderStockManager $stockManager, SettingContext $settingContext, NotificationManager $notificationManager, NotificationTypeManager $notificationTypeManager) : mixed
Parameters
- $em : EntityManagerInterface
- $orderManager : OrderManager
- $transitionService : OrderTransitionManager
- $stockManager : OrderStockManager
- $settingContext : SettingContext
- $notificationManager : NotificationManager
- $notificationTypeManager : NotificationTypeManager
handlePayment()
public
handlePayment(Payment $payment) : Result
Parameters
- $payment : Payment
Return values
Result —always success, because the hook must not fail the gateway response (fail-open);
data: array{transitionedOrderIds: list
isPaymentPayable()
Tells whether a payment of this module may still be started at the gateway. False once any linked order is canceled/expired or already settled, so the customer cannot pay for an order that will never ship. A payment without linked orders is not ours to judge (true).
public
isPaymentPayable(Payment $payment) : bool
Intended for the payment layer's pre-gateway guard (assertPaymentPayable()).
Parameters
- $payment : Payment
Return values
boolallowsOutOfStock()
private
allowsOutOfStock() : bool
Return values
boolcoversOrderTotal()
Checks that the payment covers the order; otherwise the order is not marked paid.
private
coversOrderTotal(int $orderId, Payment $payment) : bool
The payment layer already verifies amount and currency (PaymentProcessorTrait::verifyAmount():
a mismatch with payment.total_price gives BAD_PRICE, never COMPLETED). This check guards something
else: the payment is created with the order amount at submit time
(OrderPaymentManager::createForOrder()), but the order may have become more expensive
meanwhile (admin edit, added shipping), so the old payment is lower than what the customer owes and
PAID would be false.
A mismatch is a no-op plus a log entry in the eshop.general channel, never an exception (the fail-open
rule applies here too). The payment and the order_payments link stay, so staff see in the admin
what happened and can settle the difference manually.
An overpayment is let through: the customer sent more than needed, which is no reason to leave the order unpaid.
Parameters
- $orderId : int
- $payment : Payment
Return values
boolensureStockCoverage()
Ensures the goods are still held for the order before it may count as paid (see the class docblock for the decision). A lapsed or released reservation is renewed under the order lock; strictly, unless the shop sells out of stock or the original reservation was already partial ({@see OrderStockCoverage::allowPartialForRenewal()}).
private
ensureStockCoverage(int $orderId, int $paymentId) : bool
Parameters
- $orderId : int
- $paymentId : int
Return values
boolisCancelled()
private
isCancelled(int $orderId) : bool
Parameters
- $orderId : int
Return values
boolisPayable()
Guard that reads the order status only from the DB (raw), because an in-memory entity may be stale and the "already paid / canceled" decision must be hard.
private
isPayable(int $orderId) : bool
Parameters
- $orderId : int
Return values
boolnotifyAdmin()
Best-effort admin bell entry (like `NotificationProducerTrait`): every admin sees it, a missing/inactive type is skipped silently, a failure never reaches the gateway.
private
notifyAdmin(int $orderId, string $text) : void
Parameters
- $orderId : int
- $text : string
orderIdsForPayment()
Returns the order ids linked to a payment.
private
orderIdsForPayment(int $paymentId) : array<int, int>
The authoritative payment-to-orders link is the join table, not the denormalized
orders.payment_id, which only holds the last paired payment.
Parameters
- $paymentId : int
Return values
array<int, int>statusIdByKey()
private
statusIdByKey(string $key) : int|null
Parameters
- $key : string