Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Express SMS payments

Express SMS payments#

An express SMS pays for one service with a single message KEYWORD MODULE SERVICE ID, without the payment form. When the SMS arrives the payment does not exist — it is created from the SMS and then runs through the same processing as a payment from the form. The operator's view is in Paying by SMS.

Parts#

Part Where Job
ExpressSmsParser app/Core/Payments/Express/ parse() text → ExpressSmsCommand (express / form / invalid), compose() the same shape for the front, serviceCodeFromKeyword()
ExpressServiceProviderInterface same one module: getModuleCode(), resolve(), createPaymentItems(), describe()
ExpressServiceRegistry same module word → provider; providers are found lazily by type in the DI container; describe(gateId) adds the gateway number for each service price
AbstractQuickPriceExpressServiceProvider same shared logic of modules with quick prices (Bazaar, Comcat, Discussion)
ExpressTarget, ExpressResolution, ExpressServiceDescription same DTOs between the module and the System layer
ExpressModuleCode same module words (INZ, FIRMA, DISK, UCET) — shared by Api and Front
PaymentExpressExecuteTrait app/UI/Api/System/Presenters/Traits/Actions/ SMS receipt api/system/payment/express-execute
PaymentExpressOffersTrait same internal action expressOffers — text and number for the front
ExpressPaymentTrait app/Core/Traits/Front/Presenters/ the "Express payment" section next to an object on the front

Providers today:

Class Word Services Callback
Api\Bazaar\Payments\BazaarExpressServiceProvider INZ quick prices of advert tariffs BAZAAR_PAYMENT
Api\Comcat\Payments\ComcatExpressServiceProvider FIRMA quick prices of company tariffs COMCAT_PAYMENT
Api\Discussion\Payments\DiscussionExpressServiceProvider DISK quick prices of post tariffs DISCUSSION_PAYMENT
Api\System\Payments\MembershipExpressServiceProvider UCET CLEN (tariffs with duration > 0), KREDIT (tariffs with credits > 0), tariff prices for the gateway SYSTEM_USER_PAYMENT

There is deliberately no table of "express services" in the database: the services are the quick prices (cms_mod_*_tariff_quick_prices) or the membership tariff price list. A second list would silently drift away from them.

How an SMS is received#

internalActionExpressExecute() in PaymentExpressExecuteTrait:

  1. Gateway from the pg parameter (empty = mobilniplatby); it must implement ExpressSmsGatewayInterface.
  2. Origin: verifyNotificationOrigin() — shared secret delivery_secret, fail-closed. A missing id or shortcode is a rejection. A rejection creates or changes nothing, it only writes the log expressExecute without payment_id.
  3. Idempotence: a payment with the same gateway_payment_id on the same gateway → the stored original reply is returned and nothing is processed.
  4. Parse. The shape KEYWORD <code> is a form SMS: loadPayment() → checkPayment() → processPayment().
  5. Express SMS: registry->get(module) → provider->resolve(service, id, gateId).
  6. Price only from PaymentGatePhone by shortcode; the target whose price and currency equal the number is picked (matchExpressPrice()). There are several targets when several regions offer the service or — for membership — the tariff has several prices.
  7. A Payment is created (price and currency of the number, owner user_id + e-mail, callback, gateway_payment_id, region), the accounting snapshot cms_system_payment_items and the module items through createPaymentItems() — inactive.
  8. processPayment() with a synthetic GatewayCheckResult: transaction, status, module callback (activation), PAYMENT_DONE, invoice by cron. An MO number (level 0) = COMPLETED, MT = PENDING, completed by the delivery report.
  9. Reply: text/plain; charset=utf-8, HTTP 200, explicit Content-Length, ASCII text; for MT ;<level>.

The provider interface#

interface ExpressServiceProviderInterface
{
    public function getModuleCode(): string;
    public function resolve(string $serviceCode, int $objectId, int $paymentGateId): ExpressResolution;
    public function createPaymentItems(ExpressTarget $target, int $paymentId): bool;
    /** @return list<ExpressServiceDescription> */
    public function describe(int $paymentGateId): array;
}
  • resolve() gets untrusted input from anybody: the service from the text and the id. It must check that the service is offered now and the object exists and is active. It returns ExpressResolution::found([...ExpressTarget]), or failed() with the reason unknownService / objectNotFound / objectInactive.
  • ExpressTarget carries the tariff, service price and currency, region, callbackKey, owner (ownerUserId, ownerEmail), allowDuplicateActivation, product type and supply kind for the invoice. It never carries a price from the SMS text.
  • $paymentGateId is used by a module whose prices differ per gateway (membership tariffs are priced per region × gateway × currency). Quick price modules ignore it.
  • createPaymentItems() creates the items inactive through the module manager (with its save hooks); the callback activates them once the payment is COMPLETED.
  • describe() returns the offered services for the admin overview and the front. The registry adds the number.

Step by step: add an express service to a module with quick prices#

The module has tariffs with quick prices (tariff → quick price per region, payment item = object + tariff + payment), like Bazaar, Comcat and Discussion.

  1. Add the module word to ExpressModuleCode (upper case, no diacritics).
  2. In the module Api layer create app/UI/Api/<Module>/Payments/<Module>ExpressServiceProvider.php as final class … extends AbstractQuickPriceExpressServiceProvider. The constructor takes the module's typed managers (object, tariff, payment item).
  3. Implement getModuleCode(), getCallbackKey(), buildObjectLabel() (ASCII, goes into the SMS), createPaymentItemEntity(), findObject(), findActiveTariffs() (with translations and quickPrices) and savePaymentItem().
  4. When the object is not an advert, override the hooks:
  5. isObjectActive() — the default is the entity's isActive(); a discussion post uses !banned && !deactivated,
  6. bindPaymentItemObject() — the default is setAdvertId(); discussion calls setMessageId(). The base rejects a deleted object (isDeleted()) on its own and reads the owner from getOwnerUserId().
  7. The callback key must be in PaymentCallbackRegistry::HANDLERS and have a row in cms_system_payment_callbacks — without it every SMS ends as callbackMissing.
  8. No DI registration is needed: the class lives in app/UI, which search scans, and the registry finds it by the interface. After adding the class clear temp/cache (DI container).
  9. Add the front section to the object detail presenter with ExpressPaymentTrait: findExpressPaymentTariffs(), getExpressPaymentReference() (object id), getExpressPaymentModuleCode(). The model is Front\Bazaar\…\AdvertDetailTrait.
  10. Extend the harness tests/Manual/System/payment_gateway_auth_harness.php (section 13) with a valid SMS, an inactive object and a wrong price.

Step by step: add a service on another price list#

When the module has no quick prices (membership tariffs), implement the interface directly — the model is MembershipExpressServiceProvider:

  1. Decide the service word. Bind it only to tariffs the callback can actually activate. UserPaymentRepository extends member_until for tariffs with duration > 0 and adds credits for tariffs with credits > 0; a tariff with both at zero would be paid and do nothing, so it is not offered.
  2. findOffers($paymentGateId) returns active tariffs × active non-zero prices for the gateway that received the SMS. The price must be final (the gateway has final_prices).
  3. resolve() checks the object with a fresh read (UserManager::findFresh() — a ban must apply immediately, not after the cache expires).
  4. createPaymentItems() creates the item inactive; the manager's afterSave then neither extends nor credits anything and the callback does it all once.

Idempotence#

The aggregator repeats the same SMS up to twelve times (same id, growing att).

  • The aggregator id is stored in cms_system_payments.gateway_payment_id without a prefix — the delivery report matches its request parameter directly on that column.
  • The unique index (cms_system_payment_gates_payment_gate_id, gateway_payment_id) prevents two payments from two concurrent attempts. The losing attempt gets null from saveExpressPayment() and replies neutrally; the aggregator asks again and gets the stored reply.
  • The reply is stored in cms_system_payment_error_logs (status_text = expressReply, text in long_message) and a repetition returns it unchanged — for an MT number with the same level.
  • The callback runs once per transaction (claimExecution), exactly as for a form payment — see repeated payment.

A failed SMS as a claim#

Every error branch after the origin check (nonsense text, foreign keyword, unknown module or service, missing or inactive object, unknown number, wrong price, missing callback, item failure) creates a FAILED payment with gateway_payment_id:

  • traceability — for an MO number the operator charged the money on sending; the payment number is the claim code in the reply and the payment description holds the reason (Expresní SMS neaktivována: priceMismatch),
  • idempotence — a repeated attempt finds the payment and returns the same reply,
  • no callback and no items — nothing is activated.

For an MT number the reply carries ;FREE<level>, so the customer is not charged. MobilniplatbyGateway::checkDelivery() rejects a delivery report for a payment that is not PENDING — otherwise DELIVERED for a FAILED ("FREE") payment would mark it paid afterwards.

A rejection on origin (missing or wrong secret) creates no payment: whoever does not know the secret must not be able to write even one row into the payment table.

Tests#

  • tests/Unit/Core/Payments/ExpressSmsParserTest.php — the parser and compose().
  • tests/Manual/System/payment_gateway_auth_harness.php, section 13 — the whole path through ApiBridge: valid SMS, repeated att, wrong price, missing / inactive object, keyword, secret, form SMS, delivery report for FAILED; 13.D discussion; 13.U membership (credits once, MT level + delivery report, banned user). Fixtures clean up after themselves. Run: NETTE_DEBUG=1 php -d memory_limit=1G tests/Manual/System/payment_gateway_auth_harness.php.

Cookie settings