Payment layer#
Payments of all modules live in the System module: cms_system_payments (the payment),
cms_system_payment_items (the accounting snapshot of the items), cms_system_payment_transactions
(transactions at the gateway) and cms_system_payment_error_logs (the processing log). The
gateway notification and the customer return are handled by PaymentProcessorTrait in the
payment Api presenter; a module learns about the payment through a callback
(cms_system_payment_callbacks) that switches its paid items on.
Creating a payment#
The payment and its snapshot are created atomically:
PaymentManager::saveWithSnapshotItems($payment, $items) stores both in one transaction
(inAtomicTransaction()), the nested save() calls run as savepoints and only the outer commit
decides. The invoice takes the price from the snapshot, not from the live price list; a payment
without a snapshot would be invoiced at the price valid on the invoicing day.
| Situation | Result |
|---|---|
| the snapshot is not stored | the payment is rolled back too, the entities are detached from the identity map |
a second concurrent delivery with the same gateway_payment_id |
DB_DUPLICATE_ENTRY, but only after the winner commits (InnoDB holds the unique key lock); if the winner rolls back, it passes |
| success | after the commit the Payment and PaymentItem cache tags are bumped again (the nested bumps ran before the real commit) |
The variable symbol is set by the server: on insert PaymentManager::afterSave() writes
variableSymbol = id with updateColumn() when it is empty. A filled value (admin, import) is
not overwritten.
Do not fill in the variable symbol from the client with a second save()
:Api:System:Payment:save is for the administration only. RBAC refuses a second save from
the front end and the payment stays with variable_symbol = NULL; a bank transfer then
cannot be paired.
Transactions at the gateway#
One payment may have several transactions at the gateway (the customer retries, the gateway
repeats the notification). The transaction table is written only through
PaymentTransactionManager:
| Rule | Method | How |
|---|---|---|
| the same transaction a second time updates the row | record() |
INSERT … ON DUPLICATE KEY UPDATE on the unique key (gateway, gateway_payment_id); returns the id, 0 = the write failed |
payment status: COMPLETED wins, otherwise the newest |
resolvePaymentStatus() |
only transactions without reject_reason |
| the callback once per transaction | claimExecution() |
UPDATE … SET executed_datetime = NOW() WHERE … AND executed_datetime IS NULL; false = already ran or is running concurrently |
A rejected notification (rejectReason) is written too, so a spoofing attempt leaves a trace,
but it never decides the payment status. The status of a completed payment freezes
(PaymentStatus::isFrozenForGateway()): further events are only written to the transactions. The
exception is a repeated payment: a paid transaction with a different gateway ID that has not
activated anything yet (hasExecutedTransaction) activates tariff callbacks again
(doCallback(…, repeated: true), log repeatedPaymentActivated, a notification to
administrators). The e-shop order callback is not run again (repeatedPaymentNotActivated — an
overpayment to refund). The same transaction again stops at claimExecution
(transactionAlreadyExecuted); without a transaction ID from the gateway nothing is activated
again (transactionNotIdentified). Every notification is written to
cms_system_payment_gateway_notifications.
The callback claim is taken by a write, not a read
Two notifications about the same transaction arrive even concurrently (a gateway retry and
the customer return). A "read executed_datetime, then write" check would let both through
and the tariff would be activated twice. Any new path to the callback goes through
claimExecution().
A transaction without gateway_payment_id is not merged
The unique key does not apply to NULL, so every record() call without a gateway id
creates a new row. A gateway that returns no transaction id must supply at least
referenceId and guard duplicates itself.
Verifying a gateway notification#
A gateway (PaymentGateways/*Gateway.php) asks back about the transaction and returns a
GatewayCheckResult: the status and the proofs the gateway itself reports about the transaction.
What to do with them is decided in one place, PaymentProcessorTrait::processPayment(). A gateway
never writes the payment status itself.
| Field | Meaning |
|---|---|
status, gatewayPaymentId, state, subState |
status according to the gateway and its transaction id |
amount, currencyCode |
amount and currency confirmed by the gateway |
referenceId |
our payment_id sent to the gateway when the transaction was created (GoPay order_number, Comgate refId, Stripe metadata.payment_id, PayU extOrderId, PayPal purchase_units[0].reference_id) |
rejected, rejectReason |
the notification failed origin verification; built with GatewayCheckResult::reject() |
processPayment() goes in this order and the first rejection ends it: it records the transaction
with the reason and a log row, and leaves the payment status alone.
| Step | Rejects when | Reason in the log |
|---|---|---|
| origin | the gateway returned rejected |
rejected: <rejectReason> |
| reference | referenceId is filled and differs from the payment id |
referenceMismatch(…) |
| another transaction | the verified gatewayPaymentId differs from the stored one and referenceId is missing |
alternateTransactionWithoutReference |
| id write | — gatewayPaymentId is written only for a status meaning "the gateway knows the transaction" (PENDING, COMPLETED, CANCELED, REFUNDED, EXPIRED, VOIDED, BAD_PRICE) and never overwrites a different stored id |
gatewayPaymentIdNotOverwritten(…) |
| amount | only for COMPLETED: the amount is missing, differs by more than 0.01 (AMOUNT_TOLERANCE) or the reported currency does not match → status BAD_PRICE |
badPrice: … |
A rejection is not an exception and the reply to the gateway goes out normally. If a rejection
wrote FAILED, one forged request could knock down a paid payment.
BAD_PRICE is saved and the callback runs, but the payment is not paid, so the order does not
move to PAID. A gateway that reports no amount at all also ends at BAD_PRICE. The gateway
setting require_amount[0] makes an exception: an unreported amount or currency is only logged
(amountUnverified: …) and the payment passes. A reported amount that does not match is always
BAD_PRICE.
The transaction id the gateway asks about is picked by
AbstractPaymentGateway::pickGatewayPaymentId(): the id from the request wins, the stored id is
only a fallback. One payment can have several transactions at the gateway (the customer does not
finish, "Repeat payment" creates a second one and overwrites the stored id, then the customer pays
the first). With the stored id such a notification would be dropped and the money would arrive
for an unpaid order. A foreign transaction slipped in by an attacker is stopped by the "another
transaction" step: it carries the attacker's payment_id, not ours.
Procedure: adding a gateway
- In
checkPayment()ask the gateway back (its API); do not trust the notification parameters. - Fill
amountandcurrencyCodefrom the gateway response; convert an amount in minor units withamountFromMinorUnits(). - Send the payment id to the gateway when creating the transaction and return it as
referenceId. - Return a notification whose origin cannot be verified as
GatewayCheckResult::reject($reason).
A gateway that cannot be asked back must prove origin with a secret
The SMS gateway mobilniplatby cannot be queried: both the SMS receipt and the delivery report
are one-way GET requests with the status in the parameters, and the shortcode that sets the
price is public. Origin is therefore proven by a shared secret: our own secret parameter in
the URL the operator registers at the aggregator, and delivery_secret[…] in the gateway
settings. verifyNotificationOrigin() enforces it in both checkPayment() and
checkDelivery(). Without a configured secret every notification is rejected
(deliverySecretNotConfigured); the other way round, two anonymous GET requests would pay any
order. An optional second layer is the sender allowlist delivery_ips[1.2.3.4,10.0.0.0/8].
Module callback#
Where a paid payment calls back is decided only by PaymentCallbackRegistry::HANDLERS by the
callback_key of the cms_system_payment_callbacks row. The namespace, class and function
columns of that row are diagnostics only and PaymentCallbackManager::beforeSave() overwrites
them with the registry value on save; a row with a key outside the registry is not saved
(INVALID_DATA). A direct Payment.callback string (Class::method) passes only when it points
to a registered handler.
| Key | Handler |
|---|---|
ESHOP_ORDER |
EshopPaymentHook::handlePayment |
BAZAAR_PAYMENT, COMCAT_PAYMENT, DISCUSSION_PAYMENT |
the module's PaymentItemManager::updateStatusForPayment |
SYSTEM_USER_PAYMENT |
UserPaymentManager::updateStatusForPayment |
The handler receives (Payment $payment, int $transactionId, bool $isDuplicateActivation).
$isDuplicateActivation = true means another transaction of the same payment already activated
it (the customer paid twice); the module picks by the tariff flag what may be activated again. An
older handler with a single parameter still works, PHP drops surplus arguments. For COMPLETED
the callback goes through claimExecution(); for other statuses it is called without a claim, so
the handler must check the payment status itself.
PaymentCallbackRegistry::PAYABILITY_CHECKS is an optional pre-gateway check: may the payment
still be started? Today only ESHOP_ORDER has one (EshopPaymentHook::isPaymentPayable(): a
cancelled, expired or already settled order cannot be paid).
An unreachable or failing handler does not break the payment
The reply to the gateway stays normal, but a payment log row callbackUnresolved (class or
method does not exist), callbackFailed (the handler returned a failed Result) or
callbackRejected (target outside the registry) is written, plus a message in the payment
log. The payment is then paid and the items inactive, see the next section.
Payment page#
The "Payment execution" page (/platba/p-{id}) opens even without signing in: whoever has the
link may pay an account-less payment. Its data come from the internal action
:Api:System:Payment:payPage (PaymentPayPageTrait::internalActionPayPage()), not from get. The
action has no action*(), so from outside it ends with 404, and it returns only the fields from
PAY_PAGE_FIELDS. E-mail, IP, signature, gatewayPaymentId and callback are not among them.
Whether a payment may be opened and started at the gateway is decided by one method,
assertPaymentPayable(), called by both payPage and create:
- the payment target must still be payable (
PAYABILITY_CHECKS), which applies to the administrator too, - a privileged role (
PermissionResolver::isGrantedByPrivilegedRole()) skips the remaining rules, - a
COMPLETEDorREFUNDEDpayment is not reopened (reopening would overwritegatewayPaymentIdof a finished payment), - a payment bound to an account belongs only to its owner, the identity comes only from the JWT,
- a payment without an account (
user_id IS NULL) stays open.
A field added to PAY_PAGE_FIELDS is visible to an anonymous visitor
The list is positive on purpose: a new entity column never reaches the response by itself. A
guest grant on :Api:System:Payment:get instead would open reading of all payments to anyone
with a token, because get has an external route and returns the whole entity.
Paid but not activated#
claimExecution() consumes the claim before the module callback finishes. If the process
dies in between or the handler fails, the payment stays COMPLETED and the paid items inactive.
Recovery is manual; PaymentActivationAuditManager::notifyMissing() only makes sure the
administrator learns about it. It is called by the cron /cron/system/payment/activation-audit
(every 15 minutes, guarding itself to at most one run per 10 minutes).
| Module | "Missing activation" means |
|---|---|
system |
cms_system_user_payment_items.active = 0 |
bazaar, comcat, discussion |
…_payment_items.active = 0 (the handler switches it on with updateColumns(['active' => true])) |
eshop |
the payment is paired (order_payments) with a non-cancelled order in WAITING-ON-PAYMENT, CREATED or APPROVED |
Only payments older than 15 minutes (?minutes=N; a running callback is not a crash) and younger
than 14 days (?days=N) are searched, at most 50 per run. Each gets one PAYMENT_NOT_ACTIVATED
notification; the marker is a payment log row with status_text = activationMissingNotified,
visible in the payment detail as well.
Procedure: wiring up the payment of a new module
- Add the handler to
PaymentCallbackRegistry::HANDLERSand create, by a migration, acms_system_payment_callbacksrow with the samecallback_key. Without the registry entry the callback is not called (callbackRejected). - Create the module items inactive; the callback switches them on with
updateColumns(['active' => true]). - Add the module's query to
PaymentActivationAuditManager(followbazaar), otherwise nobody learns about an unfinished activation. - Check: pay a test payment with a handler that fails, and run
/cron/system/payment/activation-audit?minutes=0. The bell should show one notification, and the next run (the guard lets it through after 10 minutes at the earliest) adds none.
For the e-shop a notification also comes for a deliberately unswitched order
An underpayment or an expired reservation leaves the order in a payable state and has
already raised ORDER_PAYMENT_ATTENTION. The probe cannot tell them apart and sends a second,
summary one.
Alerts for administrators#
"Something happened that a person must resolve" cases are sent by
AdminAlertManager::send($typeKey, $itemId, $text, $once): it finds the active type by key and
stores a shared notification (receiverUserId = NULL). It is best effort: a missing or inactive
type returns false without an error, an exception is only logged to notifications.
$once = true sends at most one notification per (type, target), even a read one, until the
read-notification retention deletes it.
Without a type row the alert is silently not sent
A new key needs a notification type migration (see
Manager lifecycle). Until it exists,
send() returns false and nothing fails anywhere.
Where to look#
| I want | Where |
|---|---|
| gateway notification processing, status freezing, callback | app/UI/Api/System/Presenters/Traits/Helpers/PaymentProcessorTrait.php |
| gateways, transaction id choice, verification result | app/UI/Api/System/PaymentGateways/ (AbstractPaymentGateway.php, Dto/GatewayCheckResult.php) |
| where a callback may call | app/UI/Api/System/PaymentGateways/PaymentCallbackRegistry.php |
| payment page data and rules | app/UI/Api/System/Presenters/Traits/Actions/PaymentPayPageTrait.php |
| payment + snapshot, variable symbol | app/UI/Api/System/Models/Managers/PaymentManager.php |
| transactions at the gateway | app/UI/Api/System/Models/Managers/PaymentTransactionManager.php |
| the unfinished-activation probe | app/UI/Api/System/Models/Managers/PaymentActivationAuditManager.php, cron app/UI/Cron/System/Presenters/PaymentPresenter.php |
| alerts for administrators | app/UI/Api/System/Models/Managers/AdminAlertManager.php |
Related chapters: Express SMS payments · Manager lifecycle · Cron presenters