Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Platební vrstva

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

  1. V checkPayment() se zeptej brány zpět (API brány), nevěř parametrům notifikace.
  2. Vyplň amount a currencyCode z odpovědi brány; částku v haléřích převeď amountFromMinorUnits().
  3. Při založení transakce pošli bráně id platby a v odpovědi ho vrať jako referenceId.
  4. 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:

  1. cíl platby musí jít ještě zaplatit (PAYABILITY_CHECKS), to platí i pro administrátora,
  2. privilegovaná role (PermissionResolver::isGrantedByPrivilegedRole()) další pravidla přeskočí,
  3. platba COMPLETED nebo REFUNDED se znovu neotevře (opakované otevření by přepsalo gatewayPaymentId hotové platby),
  4. platba vázaná na účet patří jen vlastníkovi, identita jde výhradně z JWT,
  5. 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

  1. Přidej handler do PaymentCallbackRegistry::HANDLERS a migrací založ řádek cms_system_payment_callbacks se stejným callback_key. Bez zápisu v registru se callback nezavolá (callbackRejected).
  2. Položky modulu zakládej neaktivní a callback je zapne updateColumns(['active' => true]).
  3. Doplň dotaz modulu do PaymentActivationAuditManager (vzor bazaar), jinak se o nedokončené aktivaci nikdo nedozví.
  4. 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

Nastavení cookies