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:
- Gateway from the
pgparameter (empty =mobilniplatby); it must implementExpressSmsGatewayInterface. - Origin:
verifyNotificationOrigin()— shared secretdelivery_secret, fail-closed. A missingidorshortcodeis a rejection. A rejection creates or changes nothing, it only writes the logexpressExecutewithoutpayment_id. - Idempotence: a payment with the same
gateway_payment_idon the same gateway → the stored original reply is returned and nothing is processed. - Parse. The shape
KEYWORD <code>is a form SMS:loadPayment()→checkPayment()→processPayment(). - Express SMS:
registry->get(module)→provider->resolve(service, id, gateId). - Price only from
PaymentGatePhonebyshortcode; 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. - A
Paymentis created (price and currency of the number, owneruser_id+ e-mail, callback,gateway_payment_id, region), the accounting snapshotcms_system_payment_itemsand the module items throughcreatePaymentItems()— inactive. processPayment()with a syntheticGatewayCheckResult: transaction, status, module callback (activation),PAYMENT_DONE, invoice by cron. An MO number (level 0) =COMPLETED, MT =PENDING, completed by the delivery report.- Reply:
text/plain; charset=utf-8, HTTP 200, explicitContent-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 returnsExpressResolution::found([...ExpressTarget]), orfailed()with the reasonunknownService/objectNotFound/objectInactive.ExpressTargetcarries 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.$paymentGateIdis 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 isCOMPLETED.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.
- Add the module word to
ExpressModuleCode(upper case, no diacritics). - In the module Api layer create
app/UI/Api/<Module>/Payments/<Module>ExpressServiceProvider.phpasfinal class … extends AbstractQuickPriceExpressServiceProvider. The constructor takes the module's typed managers (object, tariff, payment item). - Implement
getModuleCode(),getCallbackKey(),buildObjectLabel()(ASCII, goes into the SMS),createPaymentItemEntity(),findObject(),findActiveTariffs()(withtranslationsandquickPrices) andsavePaymentItem(). - When the object is not an advert, override the hooks:
isObjectActive()— the default is the entity'sisActive(); a discussion post uses!banned && !deactivated,bindPaymentItemObject()— the default issetAdvertId(); discussion callssetMessageId(). The base rejects a deleted object (isDeleted()) on its own and reads the owner fromgetOwnerUserId().- The callback key must be in
PaymentCallbackRegistry::HANDLERSand have a row incms_system_payment_callbacks— without it every SMS ends ascallbackMissing. - No DI registration is needed: the class lives in
app/UI, whichsearchscans, and the registry finds it by the interface. After adding the class cleartemp/cache(DI container). - Add the front section to the object detail presenter with
ExpressPaymentTrait:findExpressPaymentTariffs(),getExpressPaymentReference()(object id),getExpressPaymentModuleCode(). The model isFront\Bazaar\…\AdvertDetailTrait. - 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:
- Decide the service word. Bind it only to tariffs the callback can actually activate.
UserPaymentRepositoryextendsmember_untilfor tariffs withduration> 0 and adds credits for tariffs withcredits> 0; a tariff with both at zero would be paid and do nothing, so it is not offered. findOffers($paymentGateId)returns active tariffs × active non-zero prices for the gateway that received the SMS. The price must be final (the gateway hasfinal_prices).resolve()checks the object with a fresh read (UserManager::findFresh()— a ban must apply immediately, not after the cache expires).createPaymentItems()creates the item inactive; the manager'safterSavethen 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
idis stored incms_system_payments.gateway_payment_idwithout a prefix — the delivery report matches itsrequestparameter 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 getsnullfromsaveExpressPayment()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 inlong_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 andcompose().tests/Manual/System/payment_gateway_auth_harness.php, section 13 — the whole path throughApiBridge: valid SMS, repeatedatt, 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.