Platební vrstva#
Platby všech modulů žijí v modulu Systém: cms_system_payments (platba), cms_system_payment_items
(účetní snapshot položek), cms_system_payment_transactions (transakce u brány) a
cms_system_payment_error_logs (log zpracování). Notifikaci brány i návrat zákazníka zpracovává
PaymentProcessorTrait v Api presenteru plateb; modul se o zaplacení dozví callbackem
(cms_system_payment_callbacks), který zapne jeho zaplacené položky.
Založení platby#
Platba a její snapshot vznikají atomicky: PaymentManager::saveWithSnapshotItems($payment, $items)
uloží obojí v jedné transakci (inAtomicTransaction()), vnořená save() běží jako savepointy
a rozhoduje až vnější commit. Doklad bere cenu ze snapshotu, ne z živého ceníku; platba bez
snapshotu by se fakturovala z ceny platné v den fakturace.
| Situace | Výsledek |
|---|---|
| snapshot se neuloží | vrátí se i platba, entity se odpojí z identity mapy |
druhé souběžné doručení s týmž gateway_payment_id |
DB_DUPLICATE_ENTRY, ale až po commitu vítěze (InnoDB drží zámek unikátního klíče); když vítěz rollbackne, projde |
| úspěch | po commitu znovu bump cache tagů Payment a PaymentItem (vnořené bumpy proběhly před skutečným commitem) |
Variabilní symbol dosazuje server: PaymentManager::afterSave() na insertu zapíše
variableSymbol = id přes updateColumn(), pokud je prázdný. Vyplněnou hodnotu (admin, import)
nepřepisuje.
Variabilní symbol nedopisuj z klienta druhým save()
:Api:System:Payment:save je jen pro administraci. Druhé uložení z frontu RBAC odmítne a
platba zůstane s variable_symbol = NULL; bankovní převod pak nejde spárovat.
Transakce u brány#
Jedna platba může mít u brány víc transakcí (zákazník pokus opakuje, brána notifikaci
zopakuje). Do tabulky transakcí se zapisuje jen přes PaymentTransactionManager:
| Pravidlo | Metoda | Jak |
|---|---|---|
| tatáž transakce podruhé řádek aktualizuje | record() |
INSERT … ON DUPLICATE KEY UPDATE na unikátním klíči (brána, gateway_payment_id); vrací id, 0 = zápis selhal |
stav platby: COMPLETED přebíjí, jinak nejnovější |
resolvePaymentStatus() |
jen transakce bez reject_reason |
| callback jednou za transakci | claimExecution() |
UPDATE … SET executed_datetime = NOW() WHERE … AND executed_datetime IS NULL; false = už běžel nebo běží souběžně |
Odmítnutá notifikace (rejectReason) se zapíše také, aby po pokusu o podvrh zůstala stopa, ale
o stavu platby nikdy nerozhoduje. Stav dokončené platby zamrzne
(PaymentStatus::isFrozenForGateway()): další události se jen zapíšou k transakcím. Výjimkou je
opakovaná platba: zaplacená transakce s jiným ID u brány, která ještě nic neaktivovala
(hasExecutedTransaction), u callbacků tarifů aktivuje znovu (doCallback(…, repeated: true),
log repeatedPaymentActivated, notifikace správci). Callback objednávky e-shopu se znovu
nespouští (repeatedPaymentNotActivated — přeplatek k vrácení). Tatáž transakce znovu skončí
na claimExecution (transactionAlreadyExecuted); bez ID transakce od brány se znovu
neaktivuje (transactionNotIdentified). Každé oznámení se zapisuje do
cms_system_payment_gateway_notifications.
Nárok na callback se nečte, bere se zápisem
Dvě notifikace o téže transakci chodí i souběžně (opakování brány a návrat zákazníka).
Kontrola „přečti executed_datetime, pak zapiš“ by pustila obě a tarif by se aktivoval
dvakrát. Nová cesta ke callbacku jde vždy přes claimExecution().
Transakce bez gateway_payment_id se neslučuje
Unikátní klíč s NULL neplatí, takže každé zavolání record() bez id brány založí nový
řádek. Brána, která id transakce nevrací, musí dodat aspoň referenceId a duplicitu hlídat
sama.
Ověření notifikace brány#
Brána (PaymentGateways/*Gateway.php) se na transakci zeptá zpět a vrátí GatewayCheckResult:
stav a důkazy, které o transakci hlásí sama brána. Co s nimi udělat, rozhoduje jediné místo,
PaymentProcessorTrait::processPayment(). Brána sama stav platby nezapisuje.
| Pole | Význam |
|---|---|
status, gatewayPaymentId, state, subState |
stav podle brány a id transakce u ní |
amount, currencyCode |
částka a měna, které potvrdila brána |
referenceId |
naše payment_id, které jsme bráně poslali při založení (GoPay order_number, Comgate refId, Stripe metadata.payment_id, PayU extOrderId, PayPal purchase_units[0].reference_id) |
rejected, rejectReason |
notifikace neprošla ověřením původu; vzniká přes GatewayCheckResult::reject() |
processPayment() jde v tomto pořadí a první odmítnutí končí: zapíše transakci s důvodem a řádek
logu, stav platby nemění.
| Krok | Odmítne, když | Důvod v logu |
|---|---|---|
| původ | brána vrátila rejected |
rejected: <rejectReason> |
| reference | referenceId je vyplněné a nerovná se id platby |
referenceMismatch(…) |
| jiná transakce | ověřené gatewayPaymentId se liší od uloženého a referenceId chybí |
alternateTransactionWithoutReference |
| zápis id | — gatewayPaymentId se zapíše jen u stavu, který znamená „brána transakci zná“ (PENDING, COMPLETED, CANCELED, REFUNDED, EXPIRED, VOIDED, BAD_PRICE), a jiné uložené id nepřepíše |
gatewayPaymentIdNotOverwritten(…) |
| částka | jen u COMPLETED: částka chybí, liší se o víc než 0,01 (AMOUNT_TOLERANCE) nebo nesedí nahlášená měna → stav BAD_PRICE |
badPrice: … |
Odmítnutí není výjimka a odpověď bráně odejde normálně. Kdyby odmítnutí zapsalo FAILED, šlo by
jedním podvrženým požadavkem shodit zaplacenou platbu.
BAD_PRICE se uloží a callback proběhne, ale platba není zaplacená, takže objednávka na PAID
nepřejde. Brána, která částku nehlásí vůbec, skončí také na BAD_PRICE. Výjimku dá nastavení
brány require_amount[0]: nenahlášená částka nebo měna se jen zapíše (amountUnverified: …)
a platba projde. Nahlášená částka, která nesedí, je BAD_PRICE vždy.
Id transakce, na které se brána ptá, vybírá AbstractPaymentGateway::pickGatewayPaymentId():
přednost má id z požadavku, uložené id je jen záloha. Jedna platba má u brány i víc transakcí
(zákazník nedokončí platbu, „Zopakovat platbu“ založí druhou a uložené id se přepíše, a pak
zaplatí tu první). S uloženým id by se taková notifikace zahodila a peníze by přišly
k nezaplacené objednávce. Cizí transakci podstrčenou útočníkem zastaví krok „jiná transakce“:
nese jeho payment_id, ne naše.
Postup: přidávám bránu
- V
checkPayment()se zeptej brány zpět (API brány), nevěř parametrům notifikace. - Vyplň
amountacurrencyCodez odpovědi brány; částku v haléřích převeďamountFromMinorUnits(). - Při založení transakce pošli bráně id platby a v odpovědi ho vrať jako
referenceId. - Notifikaci, jejíž původ nejde ověřit, vrať jako
GatewayCheckResult::reject($reason).
Brána bez zpětného dotazu musí původ prokázat tajemstvím
SMS brána mobilniplatby se zeptat zpět neumí: příjem SMS i doručenka jsou jednosměrné
GET se stavem v parametrech a shortcode, který určuje cenu, je veřejný. Původ proto
prokazuje sdílené tajemství: vlastní parametr secret v adrese, kterou provozovatel
zaregistruje u agregátora, a v nastavení brány delivery_secret[…]. Hlídá ho
verifyNotificationOrigin() u checkPayment() i checkDelivery(). Bez nastaveného
tajemství se odmítne každá notifikace (deliverySecretNotConfigured); kdyby to bylo naopak,
dva anonymní GET by zaplatily libovolnou objednávku. Druhou vrstvou je volitelný seznam
adres odesílatele delivery_ips[1.2.3.4,10.0.0.0/8].
Callback modulu#
Kam zaplacená platba zavolá, určuje jen PaymentCallbackRegistry::HANDLERS podle
callback_key řádku cms_system_payment_callbacks. Sloupce namespace, class a function
v tom řádku jsou jen pro diagnostiku a PaymentCallbackManager::beforeSave() je při uložení
přepíše hodnotou z registru; řádek s klíčem mimo registr neuloží (INVALID_DATA). Přímý řetězec Payment.callback (Třída::metoda) projde jen tehdy,
když míří na handler z registru.
| Klíč | Handler |
|---|---|
ESHOP_ORDER |
EshopPaymentHook::handlePayment |
BAZAAR_PAYMENT, COMCAT_PAYMENT, DISCUSSION_PAYMENT |
PaymentItemManager::updateStatusForPayment modulu |
SYSTEM_USER_PAYMENT |
UserPaymentManager::updateStatusForPayment |
Handler dostane (Payment $payment, int $transactionId, bool $isDuplicateActivation).
$isDuplicateActivation = true znamená, že aktivaci už provedla jiná transakce téže platby
(zákazník platil dvakrát); modul si podle příznaku na tarifu vybere, co smí aktivovat znovu.
Starší handler s jediným parametrem funguje dál, PHP přebytečné argumenty zahodí. U COMPLETED
jde callback přes claimExecution(), u ostatních stavů se volá bez nároku, takže handler musí
stav platby kontrolovat sám.
PaymentCallbackRegistry::PAYABILITY_CHECKS je volitelná kontrola před bránou: smí se platba
ještě začít platit? Dnes ji má jen ESHOP_ORDER (EshopPaymentHook::isPaymentPayable():
zrušenou, propadlou nebo už uhrazenou objednávku zaplatit nejde).
Nedosažitelný nebo neúspěšný handler platbu neshodí
Odpověď bráně zůstane normální, ale zapíše se řádek logu platby callbackUnresolved
(třída nebo metoda neexistuje), callbackFailed (handler vrátil neúspěšný Result) nebo
callbackRejected (cíl mimo registr) a zpráva do logu payment. Platba je pak zaplacená a položky
neaktivní, viz další oddíl.
Platební stránka#
Stránka „Provedení platby“ (/platba/p-{id}) se otevírá i bez přihlášení: kdo má odkaz, smí
platbu bez účtu zaplatit. Data jí dává interní akce :Api:System:Payment:payPage
(PaymentPayPageTrait::internalActionPayPage()), ne get. Akce nemá action*(), takže zvenčí končí
404, a vrací jen pole z PAY_PAGE_FIELDS. E-mail, IP, podpis, gatewayPaymentId ani callback
v ní nejsou.
Kdy se platba smí otevřít a založit u brány, rozhoduje jediná metoda
assertPaymentPayable(); volá ji payPage i create:
- cíl platby musí jít ještě zaplatit (
PAYABILITY_CHECKS), to platí i pro administrátora, - privilegovaná role (
PermissionResolver::isGrantedByPrivilegedRole()) další pravidla přeskočí, - platba
COMPLETEDneboREFUNDEDse znovu neotevře (opakované otevření by přepsalogatewayPaymentIdhotové platby), - platba vázaná na účet patří jen vlastníkovi, identita jde výhradně z JWT,
- platba bez účtu (
user_id IS NULL) zůstává otevřená.
Pole přidané do PAY_PAGE_FIELDS uvidí anonymní návštěvník
Výčet je pozitivní schválně: nový sloupec entity se do odpovědi sám nedostane. Grant hostovi
na :Api:System:Payment:get místo toho by otevřel čtení všech plateb každému s tokenem,
protože get má vnější routu a vrací celou entitu.
Zaplaceno, ale neaktivováno#
claimExecution() spotřebuje nárok dřív, než callback modulu doběhne. Spadne-li proces
mezi tím nebo vrátí-li handler neúspěch, platba zůstane COMPLETED a zaplacené položky
neaktivní. Obnova je ruční; PaymentActivationAuditManager::notifyMissing() jen zajistí, že se
o tom administrátor dozví. Volá ho cron /cron/system/payment/activation-audit (každých 15 minut,
sám se hlídá na nejvýš jeden běh za 10 minut).
| Modul | „Chybí aktivace“ znamená |
|---|---|
system |
cms_system_user_payment_items.active = 0 |
bazaar, comcat, discussion |
…_payment_items.active = 0 (handler je zapíná updateColumns(['active' => true])) |
eshop |
platba spárovaná (order_payments) s nezrušenou objednávkou ve stavu WAITING-ON-PAYMENT, CREATED nebo APPROVED |
Hledá se jen platba starší než 15 minut (?minutes=N; běžící callback není pád) a mladší než
14 dní (?days=N), nejvýš 50 na běh. Na každou přijde jedna notifikace PAYMENT_NOT_ACTIVATED;
značkou je řádek logu platby se status_text = activationMissingNotified, viditelný i v detailu
platby.
Postup: zapojuji platbu nového modulu
- Přidej handler do
PaymentCallbackRegistry::HANDLERSa migrací založ řádekcms_system_payment_callbacksse stejnýmcallback_key. Bez zápisu v registru se callback nezavolá (callbackRejected). - Položky modulu zakládej neaktivní a callback je zapne
updateColumns(['active' => true]). - Doplň dotaz modulu do
PaymentActivationAuditManager(vzorbazaar), jinak se o nedokončené aktivaci nikdo nedozví. - Ověř: zaplať testovací platbu s handlerem, který vrátí neúspěch, a spusť
/cron/system/payment/activation-audit?minutes=0. Ve zvonku má být jedna notifikace a další běh (hlídka pustí nejdřív za 10 minut) už žádnou nepřidá.
U e-shopu přijde notifikace i za záměrně nepřepnutou objednávku
Nedoplatek nebo propadlá rezervace nechají objednávku v platitelném stavu a už dostaly
ORDER_PAYMENT_ATTENTION. Sonda je nerozezná a pošle druhou, souhrnnou.
Upozornění pro administrátory#
Případy „stalo se něco, co musí člověk dořešit“ posílá AdminAlertManager::send($typeKey,
$itemId, $text, $once): najde aktivní typ podle klíče a uloží společnou notifikaci
(receiverUserId = NULL). Je best-effort: chybějící nebo neaktivní typ vrátí false bez chyby,
výjimka se jen zapíše do logu notifications. $once = true pošle nejvýš jednu notifikaci na
(typ, cíl), i přečtenou, dokud ji nesmaže retence přečtených.
Bez řádku typu se upozornění tiše neodešle
Nový klíč potřebuje migraci typu notifikace (viz
Životní cyklus manageru). Dokud chybí,
send() vrací false a nikde nic nespadne.
Kam sáhnout#
| Chci | Kde |
|---|---|
| zpracování notifikace brány, zamrznutí stavu, callback | app/UI/Api/System/Presenters/Traits/Helpers/PaymentProcessorTrait.php |
| brány, výběr id transakce, výsledek ověření | app/UI/Api/System/PaymentGateways/ (AbstractPaymentGateway.php, Dto/GatewayCheckResult.php) |
| kam smí callback zavolat | app/UI/Api/System/PaymentGateways/PaymentCallbackRegistry.php |
| data a pravidla platební stránky | app/UI/Api/System/Presenters/Traits/Actions/PaymentPayPageTrait.php |
| platba + snapshot, variabilní symbol | app/UI/Api/System/Models/Managers/PaymentManager.php |
| transakce u brány | app/UI/Api/System/Models/Managers/PaymentTransactionManager.php |
| sonda nedokončené aktivace | app/UI/Api/System/Models/Managers/PaymentActivationAuditManager.php, cron app/UI/Cron/System/Presenters/PaymentPresenter.php |
| upozornění administrátorům | app/UI/Api/System/Models/Managers/AdminAlertManager.php |
Navazující kapitoly: Expresní SMS platby · Životní cyklus manageru · Cron presentery