Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Payment layer

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

  1. In checkPayment() ask the gateway back (its API); do not trust the notification parameters.
  2. Fill amount and currencyCode from the gateway response; convert an amount in minor units with amountFromMinorUnits().
  3. Send the payment id to the gateway when creating the transaction and return it as referenceId.
  4. 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:

  1. the payment target must still be payable (PAYABILITY_CHECKS), which applies to the administrator too,
  2. a privileged role (PermissionResolver::isGrantedByPrivilegedRole()) skips the remaining rules,
  3. a COMPLETED or REFUNDED payment is not reopened (reopening would overwrite gatewayPaymentId of a finished payment),
  4. a payment bound to an account belongs only to its owner, the identity comes only from the JWT,
  5. 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.

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

  1. Add the handler to PaymentCallbackRegistry::HANDLERS and create, by a migration, a cms_system_payment_callbacks row with the same callback_key. Without the registry entry the callback is not called (callbackRejected).
  2. Create the module items inactive; the callback switches them on with updateColumns(['active' => true]).
  3. Add the module's query to PaymentActivationAuditManager (follow bazaar), otherwise nobody learns about an unfinished activation.
  4. 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

Cookie settings