CoolCMS API

EshopPaymentHook
in package

FinalYes

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:

  1. processPayment() never lets a duplicate callback with the same state through.
  2. The "order really awaits payment" guard makes a late or repeated COMPLETED a no-op.
  3. 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']

Properties

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, skippedOrderIds: 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
bool

allowsOutOfStock()

private allowsOutOfStock() : bool
Return values
bool

coversOrderTotal()

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
bool

ensureStockCoverage()

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
bool

isCancelled()

private isCancelled(int $orderId) : bool
Parameters
$orderId : int
Return values
bool

isPayable()

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
bool

notifyAdmin()

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
Return values
int|null
On this page

Search results