brilliantmind / mkesh
PHP package for the MKESH (PagamKesh) mobile money integration over the Ericsson EWP Aggregator (XML over HTTP), with first-class Laravel support.
Requires
- php: ^8.1
- ext-dom: *
- ext-libxml: *
- guzzlehttp/guzzle: ^7.14.2
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- illuminate/config: ^10.0 || ^11.0 || ^12.0
- illuminate/database: ^10.0 || ^11.0 || ^12.0
- illuminate/events: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- phpunit/phpunit: ^10.5 || ^11.0
- ramsey/uuid: ^4.7
- vlucas/phpdotenv: ^5.6
Suggests
- illuminate/database: Required to use the MkeshTransaction / MkeshResponse Eloquent models and the shipped migrations.
- illuminate/support: Required to use the Laravel ServiceProvider and Mkesh facade.
README
Pacote PHP para a integração MKESH / PagamKesh através do Agregador Ericsson EWP (API "XML over HTTP"), com suporte nativo para Laravel.
O pacote constrói e interpreta todo o XML por si e expõe objectos tipados de pedido/resposta. Autenticação HTTP Basic, transporte PSR-18 (Guzzle por omissão).
| Operação | Método | Fluxo | Endpoint (por omissão) |
|---|---|---|---|
| Debit request | debit() |
C2B – cobrar um cliente | /DebitServlet/DebitSvlt |
| SP transfer | transfer() |
B2C – pagar a um cliente | /sptransfer/sptransfer |
| Get transaction status | getTransactionStatus() |
recuperar um resultado | /GetTransactionStatus/GetStatusSvlt |
| Debit completed | parseDebitCompleted() |
callback assíncrono C2B | (o seu webhook) |
| Transfer completed | parseInitiateTransferCompleted() |
callback assíncrono B2C | (o seu webhook) |
Índice
- Requisitos
- Instalação
- Configuração
- Como funciona o fluxo C2B
- Guia rápido Laravel — do zero ao primeiro pagamento
- Usar numa classe Laravel — controller, service, job, command
- Operações em detalhe — payloads completos
- Enums
- Erros
- Base de dados
- TLS, IP de origem e cliente HTTP
- Testes e resolução de problemas
examples/usage.phpé um guia anotado com tudo isto num só ficheiro de código.
1. Requisitos
- PHP 8.1+
- Extensões
ext-domeext-libxml - Um cliente HTTP PSR-18 (o Guzzle vem incluído)
- Laravel 10, 11 ou 12 (opcional — o pacote funciona em PHP puro)
2. Instalação
composer require brilliantmind/mkesh
Em Laravel o MkeshServiceProvider e a facade Mkesh são registados
automaticamente. Publique a configuração:
php artisan vendor:publish --tag=mkesh-config php artisan migrate
As migrations vêm dentro do pacote e correm directamente com migrate. Só
precisa de as publicar se quiser alterar o schema:
php artisan vendor:publish --tag=mkesh-migrations
3. Configuração
3.1 Variáveis de ambiente
# Credenciais HTTP Basic (dadas pelo provedor) MKESH_USERNAME=o-seu-utilizador MKESH_PASSWORD= # FRI creditada quando cobra um cliente (C2B) MKESH_SP_FRI=FRI:pagamKesh/USER # Carteira debitada num pagamento B2C. Vazio = usa a MKESH_SP_FRI. MKESH_SP_TRANSFER_FRI=FRI:47225552/MM # Prefixo obrigatório nos ids. O pacote aplica-o sozinho. MKESH_TRANSACTION_PREFIX=ACME # O seu endpoint de callback — registe este URL junto do provedor MKESH_CALLBACK_URL=https://a-sua-app.co.mz/api/mkesh/callback MKESH_BASE_URL=https://41.220.193.151 MKESH_CURRENCY=MZN MKESH_TIMEOUT=30 MKESH_VERIFY_SSL=true MKESH_SSL_CA_BUNDLE=
Referência completa (ficheiro pronto a copiar em .env.example):
| Variável | Omissão | Descrição |
|---|---|---|
MKESH_USERNAME |
— | Utilizador HTTP Basic |
MKESH_PASSWORD |
— | Senha HTTP Basic |
MKESH_SP_FRI |
FRI:pagamKesh/USER |
FRI creditada num débito (C2B) |
MKESH_SP_TRANSFER_FRI |
(usa MKESH_SP_FRI) |
Carteira debitada num pagamento (B2C) |
MKESH_BASE_URL |
https://41.220.193.151 |
Host do agregador |
MKESH_CURRENCY |
MZN |
Moeda por omissão |
MKESH_TRANSACTION_PREFIX |
— | Prefixo forçado nos ids (ex.: ACME) |
MKESH_CALLBACK_URL |
— | O seu endpoint de callback |
MKESH_SEND_CALLBACK_URL |
false |
Emitir <callbackurl> dentro do débito |
MKESH_VERIFY_SSL |
true |
Verificar o certificado TLS |
MKESH_SSL_CA_BUNDLE |
— | Caminho para o CA de verificação |
MKESH_TIMEOUT |
30 |
Timeout por pedido (segundos) |
MKESH_DEBIT_PATH |
/DebitServlet/DebitSvlt |
Path do débito |
MKESH_SP_TRANSFER_PATH |
/sptransfer/sptransfer |
Path da transferência |
MKESH_STATUS_PATH |
/GetTransactionStatus/GetStatusSvlt |
Path da consulta |
3.2 Três regras rígidas do agregador
- O prefixo é obrigatório. Todo o
externaltransactionid/referenceidtem de começar pelo seu token de parceiro (ex.:ACME). Defina-o uma vez na configuração e passe ids simples — o pacote prefixa-os, de forma idempotente. - Os ids têm de ser únicos por service provider. Reutilizar um dá
REFERENCE_ID_ALREADY_IN_USE. Use$config->newTransactionId()e grave o valor antes de enviar o pedido. - O endpoint do callback é registado do lado do provedor, não vai em cada
pedido. Dê-lhes o URL; o pacote não coloca
<callbackurl>no payload do débito a não ser que activesendCallbackUrl: true.
3.3 PHP puro (sem Laravel)
use BrilliantMind\Mkesh\Config\MkeshConfig; use BrilliantMind\Mkesh\MkeshClient; $config = new MkeshConfig( username: 'o-seu-utilizador', password: 'a-sua-senha', serviceProviderFri: 'FRI:pagamKesh/USER', // creditada no débito (C2B) transactionPrefix: 'ACME', callbackUrl: 'https://a-sua-app.co.mz/api/mkesh/callback', spTransferSendingFri: 'FRI:47225552/MM', // debitada no pagamento (B2C) ); $mkesh = MkeshClient::create($config);
Ou a partir de um array, com o mesmo formato do config/mkesh.php:
$config = MkeshConfig::fromArray([ 'username' => 'o-seu-utilizador', 'password' => 'a-sua-senha', 'service_provider_fri' => 'FRI:pagamKesh/USER', 'sp_transfer_sending_fri' => 'FRI:47225552/MM', 'transaction_prefix' => 'ACME', ]);
3.4 Checklist de onboarding
A folha do provedor deixa o bloco por ambiente em branco. Estes valores têm de ser acordados com eles separadamente para teste e produção:
| Valor | Direcção | Corresponde a |
|---|---|---|
| Endereço IP de origem | você → provedor | o IP de saída que eles autorizam |
| URL de callback | você → provedor | MKESH_CALLBACK_URL |
| Utilizador / senha | provedor → você | MKESH_USERNAME / MKESH_PASSWORD |
| Nr. de conta / MSISDN | provedor → você | MKESH_SP_FRI / MKESH_SP_TRANSFER_FRI |
| Prefixo de transacção | provedor → você | MKESH_TRANSACTION_PREFIX |
| URL base | provedor → você | MKESH_BASE_URL |
4. Como funciona o fluxo C2B
Cobrar um cliente é assíncrono. A resposta do débito só diz que o pedido foi aceite — o dinheiro ainda não se moveu.
Parceiro MKESH Cliente
│ │ │
│ 1. debitrequest v1_1 │ │
├──────────────────────────────►│ │
│ 2. debitresponse PENDING │ │
│◄──────────────────────────────┤ SMS: aprovação pendente │
│ ├─────────────────────────────►│
│ │ aprova antes de expirar │
│ │◄─────────────────────────────┤
│ 3. debitcompletedrequest v1_2│ │
│◄──────────────────────────────┤ │
│ <ResponseCode>SUCCESS</…> │ SMS: débito concluído │
├──────────────────────────────►├─────────────────────────────►│
│ │ │
│ ─ se o passo 3 nunca chegar ─│ │
│ 4. gettransactionstatus v1_3 │ │
├──────────────────────────────►│ │
│ SUCCESSFUL / FAILED │ │
│◄──────────────────────────────┤ │
Na prática:
debit()devolvePENDINGe umapprovalid. Grave os ids e pare.- O cliente aprova no telemóvel. Não há sinal síncrono deste passo.
- O agregador faz POST do
debitcompletedrequestpara o seu endpoint. Tem de responder<ResponseCode>SUCCESS</ResponseCode>— um200vazio não é aceite e o callback será reenviado. - Se o callback nunca chegar, consulte
getTransactionStatus($referenceId)até o estado ficar liquidado.
Os pagamentos B2C (transfer()) são mais simples: uma sptransferresponse com
sucesso significa que o dinheiro foi transferido.
5. Guia rápido Laravel
Do zero ao primeiro pagamento em cinco passos.
Passo 1 — instalar e configurar
composer require brilliantmind/mkesh php artisan vendor:publish --tag=mkesh-config php artisan migrate
Preencha o .env conforme a secção 3.1.
Passo 2 — copiar os ficheiros de exemplo
Os models já vêm no pacote — não precisa de copiar nada para os ter:
use BrilliantMind\Mkesh\Laravel\Models\MkeshTransaction; use BrilliantMind\Mkesh\Laravel\Models\MkeshResponse;
O resto é código da sua aplicação, e por isso fica em
examples/Laravel/ para copiar e adaptar:
| Ficheiro de exemplo | Destino |
|---|---|
MkeshPaymentService.php |
app/Services/ |
MkeshCallbackController.php |
app/Http/Controllers/ |
ReconcileMkeshTransaction.php |
app/Jobs/ |
Passo 3 — registar a rota do callback
Fora do grupo protegido por CSRF, para o agregador conseguir chegar lá sem token:
// routes/api.php use App\Http\Controllers\MkeshCallbackController; Route::post('/mkesh/callback', MkeshCallbackController::class);
Passo 4 — dar o URL ao provedor
https://a-sua-app.co.mz/api/mkesh/callback. Eles configuram-no do lado deles.
Confirme também que o IP de saída do seu servidor está autorizado.
Passo 5 — cobrar
$transaccao = app(MkeshPaymentService::class)->charge( msisdn: '258823040400', amount: '25.00', payable: $encomenda, ); $transaccao->status; // TransactionStatus::PENDING
E já está. O serviço despacha sozinho o job de reconciliação: ou chega o callback, ou o job apanha o resultado por polling.
6. Usar numa classe Laravel
6.1 Injecção no construtor (recomendado)
MkeshClient está registado no container como singleton — basta declarar o tipo:
namespace App\Services; use BrilliantMind\Mkesh\MkeshClient; use BrilliantMind\Mkesh\Request\DebitRequest; use BrilliantMind\Mkesh\ValueObject\Money; final class CheckoutService { public function __construct( private readonly MkeshClient $mkesh, ) { } public function pagar(string $msisdn, string $valor): void { $id = $this->mkesh->config()->newTransactionId(); // grave $id na sua base de dados AQUI, antes de enviar $resposta = $this->mkesh->debit( DebitRequest::charge($msisdn, Money::of($valor), $id), ); } }
6.2 Facade
use BrilliantMind\Mkesh\Laravel\Facades\Mkesh; $id = Mkesh::config()->newTransactionId(); $debito = Mkesh::debit(DebitRequest::charge('258823040400', Money::of(25), $id)); $estado = Mkesh::getTransactionStatus($id); $callback = Mkesh::parseDebitCompleted($request->getContent());
Métodos disponíveis na facade:
| Método | Devolve |
|---|---|
Mkesh::debit($request) |
DebitResponse |
Mkesh::transfer($request) |
SpTransferResponse |
Mkesh::getTransactionStatus($ref) |
TransactionStatusResponse |
Mkesh::parseDebitCompleted($xml) |
DebitCompletedNotification |
Mkesh::parseInitiateTransferCompleted($xml) |
InitiateTransferCompletedNotification |
Mkesh::acknowledgeCallback() |
CallbackResponse |
Mkesh::config() |
MkeshConfig |
6.3 Controller que inicia um pagamento
namespace App\Http\Controllers; use App\Services\MkeshPaymentService; use BrilliantMind\Mkesh\Enum\ErrorCode; use BrilliantMind\Mkesh\Exception\ErrorResponseException; use BrilliantMind\Mkesh\Exception\TransportException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class PagamentoController extends Controller { public function __construct( private readonly MkeshPaymentService $pagamentos, ) { } public function store(Request $request): JsonResponse { $dados = $request->validate([ 'msisdn' => ['required', 'regex:/^258[0-9]{9}$/'], 'valor' => ['required', 'numeric', 'min:1'], ]); try { $transaccao = $this->pagamentos->charge( msisdn: $dados['msisdn'], amount: (string) $dados['valor'], ); } catch (ErrorResponseException $e) { $codigo = $e->code(); // Erros que o cliente consegue resolver: mostre a mensagem. if ($codigo->isCustomerFault()) { return response()->json([ 'mensagem' => match ($codigo) { ErrorCode::AUTHORIZATION_CURRENT_BALANCE_TOO_LOW => 'Saldo insuficiente.', ErrorCode::ACCOUNTHOLDER_NOT_ACTIVE => 'Conta mKesh inactiva.', default => 'Não foi possível processar o pagamento.', }, ], 422); } report($e); return response()->json(['mensagem' => 'Serviço indisponível.'], 502); } catch (TransportException $e) { // CUIDADO: pode ter passado do lado deles. Não reenvie às cegas — // o job de reconciliação vai apurar o estado real. report($e); return response()->json(['mensagem' => 'Sem resposta do MKESH.'], 504); } return response()->json([ 'referencia' => $transaccao->external_transaction_id, 'estado' => $transaccao->status->value, 'mensagem' => 'Confirme o pagamento no seu telemóvel.', ], 202); } }
6.4 Controller que recebe o callback
O ponto crítico: o corpo da resposta tem de ser o documento ResponseCode.
namespace App\Http\Controllers; use App\Models\MkeshTransaction; use BrilliantMind\Mkesh\Callback\CallbackResponse; use BrilliantMind\Mkesh\Exception\MkeshException; use BrilliantMind\Mkesh\Laravel\Facades\Mkesh; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\DB; final class MkeshCallbackController extends Controller { public function __invoke(Request $request): Response { try { $callback = Mkesh::parseDebitCompleted($request->getContent()); } catch (MkeshException) { return response('', 400); // corpo inválido: não reenviem } DB::transaction(function () use ($callback): void { // Bloqueie a linha: os callbacks podem chegar em duplicado. $transaccao = MkeshTransaction::query() ->where('type', MkeshTransaction::TYPE_DEBIT) ->where('external_transaction_id', $callback->externalTransactionId) ->lockForUpdate() ->first(); if ($transaccao === null || $transaccao->isSettled()) { return; // reenvio de algo já liquidado — ignorar } $transaccao->update([ 'financial_transaction_id' => $callback->transactionId, 'status' => $callback->status, 'completed_at' => now(), ]); if ($callback->isSuccessful()) { $transaccao->payable?->marcarComoPaga(); } }); return response(CallbackResponse::success()->toXml(), 200) ->header('Content-Type', CallbackResponse::CONTENT_TYPE); } }
Regras de ouro para o webhook:
- Responda sempre
SUCCESSquando conseguir ler o corpo, mesmo que a transacção já esteja liquidada. Caso contrário ficam a reenviar. - Torne-o idempotente — procure pelo
externalTransactionIde ignore se já estiver liquidado. - Bloqueie a linha (
lockForUpdate) para dois reenvios simultâneos não liquidarem a mesma transacção duas vezes. - Não faça trabalho demorado aqui. Despache um job.
6.5 Job de reconciliação
Cobre o ramo "sem resposta do MKESH". Ver
ReconcileMkeshTransaction:
ReconcileMkeshTransaction::dispatch($transaccao->id)->delay(now()->addMinutes(2));
Faz polling ao gettransactionstatus com backoff progressivo e pára assim que a
linha estiver liquidada — seja pelo callback, seja pelo próprio polling. Códigos
retentáveis (ErrorCode::isRetryable(), sobretudo TRANSACTION_NOT_FOUND, que
aqui significa "ainda não registado") libertam o job para nova tentativa.
6.6 Command Artisan para reconciliar em lote
namespace App\Console\Commands; use App\Jobs\ReconcileMkeshTransaction; use App\Models\MkeshTransaction; use BrilliantMind\Mkesh\Enum\TransactionStatus; use Illuminate\Console\Command; final class ReconciliarMkesh extends Command { protected $signature = 'mkesh:reconciliar {--minutos=10}'; protected $description = 'Consulta o estado dos débitos ainda pendentes'; public function handle(): int { $pendentes = MkeshTransaction::query() ->where('type', MkeshTransaction::TYPE_DEBIT) ->where('status', TransactionStatus::PENDING) ->where('created_at', '<', now()->subMinutes((int) $this->option('minutos'))) ->get(); foreach ($pendentes as $transaccao) { ReconcileMkeshTransaction::dispatch($transaccao->id); } $this->info("{$pendentes->count()} transacções enviadas para reconciliação."); return self::SUCCESS; } }
Agende-o em routes/console.php:
Schedule::command('mkesh:reconciliar')->everyFifteenMinutes();
6.7 Testar sem tocar na rede
Injecte um cliente PSR-18 falso — não é preciso mais nada:
use BrilliantMind\Mkesh\Config\MkeshConfig; use BrilliantMind\Mkesh\MkeshClient; use GuzzleHttp\Psr7\HttpFactory; use GuzzleHttp\Psr7\Response; $http = new class (new Response(200, [], $xmlDeResposta)) implements \Psr\Http\Client\ClientInterface { public function __construct(private $resposta) {} public function sendRequest(\Psr\Http\Message\RequestInterface $r): \Psr\Http\Message\ResponseInterface { return $this->resposta; } }; $factory = new HttpFactory(); $mkesh = new MkeshClient($config, $http, $factory, $factory); // No teste, substitua o singleton do container: $this->app->instance(MkeshClient::class, $mkesh);
7. Operações em detalhe
Todos os ids mostrados já incluem o prefixo
ACME, que o pacote aplica automaticamente aexternaltransactionid/providertransactionid/referenceid.
7.1 Debit request — C2B (cobrar um cliente)
use BrilliantMind\Mkesh\Request\DebitRequest; use BrilliantMind\Mkesh\ValueObject\Fri; use BrilliantMind\Mkesh\ValueObject\Money; $resposta = $mkesh->debit(DebitRequest::charge( customerMsisdn: '258823040400', amount: Money::of(25), // 25 MZN externalTransactionId: '000001', )); // Forma completa, com todos os parâmetros: $pedido = new DebitRequest( fromFri: Fri::msisdn('258823040400'), // quem paga amount: Money::of(25, 'MZN'), externalTransactionId: '000001', toFri: null, // omissão: serviceProviderFri da config referenceId: null, // omissão: igual ao externalTransactionId fromMessage: null, toMessage: null, );
Pedido enviado (Content-Type: text/xml):
<?xml version="1.0" encoding="UTF-8"?> <ns0:debitrequest xmlns:ns0="http://www.ericsson.com/em/emm/financial/v1_1"> <fromfri>FRI:258823040400/MSISDN</fromfri> <tofri>FRI:pagamKesh/USER</tofri> <amount> <amount>25</amount> <currency>MZN</currency> </amount> <externaltransactionid>ACME000001</externaltransactionid> <referenceid>ACME000001</referenceid> </ns0:debitrequest>
O referenceid assume por omissão o valor do id externo (como na folha do
provedor) e é por ele que o getTransactionStatus() procura a transacção.
Resposta (PENDING) → DebitResponse:
<?xml version="1.0" encoding="UTF-8"?> <ns0:debitresponse xmlns:ns0="http://www.ericsson.com/em/emm/financial/v1_1"> <transactionid>3171312</transactionid> <status>PENDING</status> <approvalid>470390</approvalid> </ns0:debitresponse>
$resposta->transactionId; // "3171312" $resposta->status; // TransactionStatus::PENDING $resposta->approvalId; // "470390" $resposta->isPending(); // true
7.2 SP transfer — B2C (pagar a um cliente)
A carteira de origem vem de spTransferSendingFri, que é muitas vezes uma FRI
diferente da creditada num débito (FRI:47225552/MM vs FRI:pagamKesh/USER);
se não estiver definida, usa a serviceProviderFri.
use BrilliantMind\Mkesh\Request\SpTransferRequest; $resposta = $mkesh->transfer(SpTransferRequest::payout( customerMsisdn: '258823040400', amount: Money::of(25), providerTransactionId: 'XXXXX', ));
Pedido enviado:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <ns2:sptransferrequest xmlns:ns2="http://www.ericsson.com/em/emm/serviceprovider/v1_2/backend"> <sendingfri>FRI:47225552/MM</sendingfri> <receivingfri>FRI:258823040400/MSISDN</receivingfri> <amount> <amount>25</amount> <currency>MZN</currency> </amount> <providertransactionid>ACMEXXXXX</providertransactionid> <referenceid>ACMEXXXXX</referenceid> </ns2:sptransferrequest>
Resposta → SpTransferResponse:
<?xml version="1.0" encoding="UTF-8"?> <ns0:sptransferresponse xmlns:ns0="http://www.ericsson.com/em/emm/serviceprovider/v1_2/backend"> <transactionid>3282002</transactionid> <providertransactionid>ACME-XXXXXX</providertransactionid> </ns0:sptransferresponse>
$resposta->transactionId; // "3282002" $resposta->providerTransactionId; // "ACME-XXXXXX"
7.3 Get transaction status (recuperar um resultado)
Use o referenceid de uma operação anterior quando não houve resposta nem
callback.
$estado = $mkesh->getTransactionStatus('000001'); // prefixo aplicado
Pedido enviado:
<?xml version="1.0" encoding="UTF-8"?> <ns0:gettransactionstatusrequest xmlns:ns0="http://www.ericsson.com/em/emm/financial/v1_3"> <referenceid>ACME000001</referenceid> </ns0:gettransactionstatusrequest>
Resposta (SUCESSO):
<?xml version="1.0" encoding="UTF-8"?> <ns0:gettransactionstatusresponse xmlns:ns0="http://www.ericsson.com/em/emm/financial/v1_3"> <financialtransactionid>3171312</financialtransactionid> <status>SUCCESSFUL</status> <providertransactionid>ACME000001</providertransactionid> </ns0:gettransactionstatusresponse>
Resposta (FALHA) — repare que não traz providertransactionid:
<?xml version="1.0" encoding="UTF-8"?> <ns0:gettransactionstatusresponse xmlns:ns0="http://www.ericsson.com/em/emm/financial/v1_3"> <financialtransactionid>3221328</financialtransactionid> <status>FAILED</status> </ns0:gettransactionstatusresponse>
$estado->financialTransactionId; // "3171312" $estado->status; // TransactionStatus::SUCCESSFUL $estado->providerTransactionId; // null quando FAILED $estado->status->isSettled(); // true — pare de consultar
7.4 Callback debit-completed
O agregador faz POST disto para o endpoint que registou junto do provedor.
Pedido recebido:
<?xml version="1.0" encoding="UTF-8"?> <ns0:debitcompletedrequest xmlns:ns0="http://www.ericsson.com/em/emm/callback/v1_2"> <transactionid>3171312</transactionid> <externaltransactionid>ACME000001</externaltransactionid> <receiverinfo> <fri>FRI:1360073/MM</fri> <msisdn>8230X04XX</msisdn> <language>en</language> </receiverinfo> <status>SUCCESSFUL</status> <communicationchannel>http-sp</communicationchannel> <referenceid>ACME000001</referenceid> </ns0:debitcompletedrequest>
$callback = $mkesh->parseDebitCompleted($corpoDoPedido); $callback->transactionId; // "3171312" $callback->externalTransactionId; // "ACME000001" — o NOSSO id $callback->referenceId; $callback->status; // TransactionStatus::SUCCESSFUL $callback->communicationChannel; // "http-sp" $callback->receiver->fri; // "FRI:1360073/MM" $callback->receiver->msisdn; $callback->receiver->language; // "en" $callback->isSuccessful();
A resposta obrigatória. Um 200 vazio não é aceite e o callback será
reenviado:
<?xml version="1.0" encoding="utf-8"?><ResponseCode>SUCCESS</ResponseCode>
use BrilliantMind\Mkesh\Callback\CallbackResponse; $ack = CallbackResponse::success(); $ack->toXml(); // o corpo a devolver CallbackResponse::CONTENT_TYPE; // "text/xml; charset=utf-8"
7.5 Callback transfer-completed (B2C)
O equivalente B2C é entregue da mesma forma:
$callback = $mkesh->parseInitiateTransferCompleted($corpoDoPedido); $callback->financialTransactionId; $callback->externalTransactionId; $callback->status->isSuccessful(); $callback->receiver->msisdn; // responda com o mesmo <ResponseCode>SUCCESS</ResponseCode>
8. Enums
| Enum | Casos | Serve para |
|---|---|---|
Enum\TransactionStatus |
PENDING SUCCESSFUL FAILED UNKNOWN |
o estado de um débito/transferência |
Enum\ErrorCode |
22 códigos + UNKNOWN |
decidir o que fazer perante uma falha |
Enum\CallbackResponseCode |
SUCCESS FAILURE |
o corpo devolvido pelo seu webhook |
Enum\FriType |
MSISDN USER MM |
construir FRIs |
Todos interpretam defensivamente: um valor desconhecido dá UNKNOWN em vez de
lançar excepção, para que um valor novo da plataforma nunca parta o parsing.
8.1 TransactionStatus
use BrilliantMind\Mkesh\Enum\TransactionStatus; TransactionStatus::PENDING; // à espera da aprovação do cliente TransactionStatus::SUCCESSFUL; // dinheiro movido TransactionStatus::FAILED; TransactionStatus::UNKNOWN; // valor não reconhecido // O diagrama do provedor escreve SUCCESS/FAILURE, os payloads escrevem // SUCCESSFUL/FAILED — ambos são aceites. TransactionStatus::fromWire('SUCCESS'); // SUCCESSFUL TransactionStatus::fromWire('failure'); // FAILED TransactionStatus::fromWire('seja o que for'); // UNKNOWN $status->isPending(); $status->isSuccessful(); $status->isFailed(); $status->isSettled(); // true se SUCCESSFUL ou FAILED — pare de consultar
Em Eloquent, faça cast directo para o enum:
protected $casts = ['status' => TransactionStatus::class];
8.2 ErrorCode
Cobre os códigos que mudam o que a aplicação faz, com a decisão já embutida em vez de ficar à mercê de comparações de strings:
use BrilliantMind\Mkesh\Enum\ErrorCode; $codigo = $e->code(); // ErrorCode; $e->getErrorCode() dá a string crua $codigo->isRetryable(); // transitório — repita o mesmo pedido mais tarde $codigo->isDuplicate(); // id já usado; consulte o estado, NÃO reenvie $codigo->isCustomerFault(); // sem saldo / inactivo / PIN errado / expirou $codigo->isExpired(); // a janela de aprovação passou $codigo->isAuthFailure(); // credenciais erradas ou IP não autorizado $codigo->description(); // do catálogo completo de 670 códigos
Casos disponíveis, por família:
| Família | Casos |
|---|---|
| Pesquisa / idempotência | TRANSACTION_NOT_FOUND REFERENCE_ID_ALREADY_IN_USE AMBIGUOUS_REFERENCE_ID EXPIRED_OR_INVALID_TRANSACTION_ID |
| Contraparte | ACCOUNTHOLDER_WITH_FRI_NOT_FOUND ACCOUNTHOLDER_WITH_MSISDN_NOT_FOUND ACCOUNTHOLDER_NOT_ACTIVE AUTHORIZATION_ACCOUNTHOLDER_NOT_ACTIVE ACCOUNT_NOT_FOUND |
| Dinheiro | AUTHORIZATION_CURRENT_BALANCE_TOO_LOW AUTHORIZATION_MAX_TRANSFER_AMOUNT AUTHORIZATION_MAXIMUM_AMOUNT_ALLOWED_TO_SEND AUTHORIZATION_MAXIMUM_AMOUNT_ALLOWED_TO_RECEIVE AMOUNT_INVALID INVALID_CURRENCY CURRENCY_NOT_SUPPORTED |
| Aprovação | TRANSACTION_REQUEST_EXPIRED INCORRECT_PIN QUEUED_FOR_APPROVAL INVALID_APPROVAL_TRANSACTION_STATUS RETRY_FROM_BEGINNING |
| Acesso | AUTHORIZATION_FAILED |
O enum não lista os 670 códigos de propósito — seria uma segunda cópia do
ErrorCodes sem ganho nenhum. Qualquer código fora dele dá ErrorCode::UNKNOWN,
enquanto getErrorCode() e ErrorCodes::description() continuam a funcionar
sobre a string crua.
8.3 CallbackResponseCode
use BrilliantMind\Mkesh\Enum\CallbackResponseCode; CallbackResponseCode::SUCCESS; // o único documentado pelo provedor CallbackResponseCode::FAILURE; CallbackResponse::of(CallbackResponseCode::SUCCESS)->toXml();
Na dúvida responda SUCCESS e reconcilie fora de banda com o
gettransactionstatus — o tratamento de FAILURE do lado da plataforma não
está especificado.
8.4 FriType e os value objects
use BrilliantMind\Mkesh\Enum\FriType; use BrilliantMind\Mkesh\ValueObject\Fri; use BrilliantMind\Mkesh\ValueObject\Money; FriType::MSISDN; // FRI:258823040400/MSISDN — número de telemóvel FriType::USER; // FRI:pagamKesh/USER — conta de service provider FriType::MOBILE_MONEY; // FRI:1360073/MM — conta mobile money Fri::msisdn('258823040400'); Fri::user('pagamKesh'); Fri::mobileMoney('1360073'); Fri::fromString('FRI:258823040400/MSISDN'); (string) $fri; // "FRI:258823040400/MSISDN" // O valor é guardado como string normalizada, para não apanhar erros de // vírgula flutuante ao serializar o XML. Money::of(25); // 25 MZN Money::of('25.50', 'MZN'); Money::of(25.5)->amount; // "25.5"
9. Erros
Erros de negócio chegam como um envelope errorResponse e são lançados como
ErrorResponseException.
<?xml version="1.0" encoding="UTF-8"?> <ns0:errorResponse xmlns:ns0="http://www.ericsson.com/lwac" errorcode="ACCOUNTHOLDER_WITH_FRI_NOT_FOUND"> <arguments name="fri" value="FRI:258823040420/MSISDN"/> </ns0:errorResponse>
use BrilliantMind\Mkesh\Enum\ErrorCode; use BrilliantMind\Mkesh\Exception\ErrorResponseException; use BrilliantMind\Mkesh\Exception\MkeshException; try { $mkesh->debit($pedido); } catch (ErrorResponseException $e) { $e->getErrorCode(); // "ACCOUNTHOLDER_WITH_FRI_NOT_FOUND" $e->getDescription(); // "Account holder with given FRI could not be found" $e->getArguments(); // ["fri" => "FRI:258823040420/MSISDN"] $e->getArgument('fri'); $e->getRawXml(); // corpo original, para auditoria $e->code(); // ErrorCode (ver secção 8.2) // is() aceita enum ou string, indiferentemente $e->is(ErrorCode::TRANSACTION_NOT_FOUND); $e->is('TRANSACTION_NOT_FOUND'); $e->is(ErrorCode::AMOUNT_INVALID, ErrorCode::INVALID_CURRENCY); } catch (MkeshException $e) { // qualquer outra falha do pacote (transporte, config, parsing…) }
Hierarquia — todas implementam a interface marcadora MkeshException:
| Excepção | Lançada quando |
|---|---|
ErrorResponseException |
a plataforma devolveu um <errorResponse> |
TransportException |
falha de ligação/TLS/timeout, corpo vazio ou XML inválido |
ConfigurationException |
configuração em falta ou inválida |
InvalidArgumentException |
value object ou input de pedido inválido |
Catálogo completo de códigos
Os 670 códigos da referência da plataforma estão em
BrilliantMind\Mkesh\Error\ErrorCodes, e a descrição é acrescentada
automaticamente à mensagem da excepção:
use BrilliantMind\Mkesh\Error\ErrorCodes; ErrorCodes::description('TRANSACTION_NOT_FOUND'); ErrorCodes::has('REFERENCE_ID_ALREADY_IN_USE'); ErrorCodes::all(); // array<string, string>
Os três erros que vai mesmo encontrar
| Código | Significado | O que fazer |
|---|---|---|
REFERENCE_ID_ALREADY_IN_USE |
id repetido | O original quase de certeza passou. Consulte o estado — não reenvie com id novo. |
TRANSACTION_NOT_FOUND |
ainda não registado | Consultou cedo demais. Repita mais tarde. |
ACCOUNTHOLDER_WITH_FRI_NOT_FOUND |
número não é mKesh | Valide o MSISDN antes de cobrar. |
10. Base de dados
Duas migrations e dois models acompanham o pacote. As migrations correm com
php artisan migrate; os models estão em
BrilliantMind\Mkesh\Laravel\Models\ e não precisam de ser copiados.
Chaves primárias são UUID
Ambas as tabelas usam UUID ordenado (HasUuids) em vez de auto-incremento.
Estes ids saem da base de dados — vão em payloads de fila, logs e tickets de
suporte — e um inteiro sequencial revelaria o volume de transacções e
convidaria à enumeração. Sendo ordenados (ordenáveis no tempo), o índice não
fragmenta como aconteceria com UUID v4 puro.
Consequência prática: onde passar a chave, é string:
ReconcileMkeshTransaction::dispatch($transaccao->getKey()); // string, não int
payable_id é string, de propósito
A ligação polimórfica usa colunas string, não nullableMorphs(). Um pacote
não deve impor o tipo de chave aos models da aplicação: nullableMorphs()
forçaria unsignedBigInteger e partiria quem usa UUID; nullableUuidMorphs()
partiria quem usa auto-incremento. string aceita os dois.
// Na sua encomenda: public function mkeshTransactions(): MorphMany { return $this->morphMany(MkeshTransaction::class, 'payable'); } // E do outro lado: $transaccao->payable; // a sua Encomenda / Factura / Subscrição $transaccao->payable()->associate($encomenda)->save();
Nomes de tabela e ligação
Migrations e models lêem os mesmos valores da config, por isso renomear num sítio chega:
// config/mkesh.php 'database' => [ 'connection' => env('MKESH_DB_CONNECTION'), // null = ligação por omissão 'tables' => [ 'transactions' => 'mkesh_transactions', 'responses' => 'mkesh_responses', ], ],
mkesh_transactions — o livro-razão
A linha é criada antes do pedido sair, para reservar o id localmente.
| Coluna | Notas |
|---|---|
id |
UUID ordenado |
type |
debit (C2B) ou transfer (B2C) |
external_transaction_id |
enviado no débito, único por tipo |
provider_transaction_id |
enviado na transferência, único por tipo |
reference_id |
o que o getTransactionStatus() procura |
financial_transaction_id |
devolvido pela plataforma |
approval_id |
de um débito pendente |
msisdn, amount, currency |
contraparte e dinheiro |
status |
PENDING / SUCCESSFUL / FAILED / UNKNOWN |
error_code, error_message |
código da plataforma + descrição do catálogo |
payable_type, payable_id |
ligação polimórfica (payable_id é string) |
completed_at |
quando liquidou |
Métodos e scopes do model:
// Transições que respeitam a idempotência $transaccao->settle(TransactionStatus::SUCCESSFUL, '3171312'); // false se já liquidada $transaccao->fail($errorResponseException); // grava código + descrição // Estado $transaccao->isPending(); $transaccao->isSettled(); $transaccao->isDebit(); $transaccao->isTransfer(); // Scopes MkeshTransaction::query()->debits()->pending()->get(); MkeshTransaction::query()->stale(10)->get(); // pendentes há mais de 10 min MkeshTransaction::query()->forExternalId('ACME000001')->first();
settle() devolver false é a rede de segurança contra callbacks reenviados:
o primeiro resultado nunca é sobrescrito.
mkesh_responses — a auditoria
Propositadamente não é única por transacção, porque os callbacks são reenviados. É precisamente isso que a torna útil: prova o que chegou, quando, e o que respondeu.
| Coluna | Notas |
|---|---|
id |
UUID ordenado |
direction |
inbound (callback recebido) / outbound (resposta a pedido nosso) |
operation |
debitcompletedrequest, sptransferresponse, errorResponse… |
mkesh_transaction_id |
ligação ao livro-razão, quando houve correspondência |
external_transaction_id, reference_id, financial_transaction_id |
desnormalizados para pesquisa |
status, error_code |
extraídos para consulta |
response_code |
o SUCCESS / FAILURE que devolvemos |
http_status |
código HTTP devolvido ou recebido |
payload |
o XML intacto |
$transaccao->responses; // tudo o que o MKESH disse sobre ela $resposta->transaction; // relação inversa // Registar, sem montar o array à mão MkeshResponse::logCallback($callback, $corpoBruto, $transaccao); MkeshResponse::logUnparsable($corpoBruto); // corpo que não deu para ler MkeshResponse::logError($excepcao, 'debitresponse', $transaccao); MkeshResponse::query()->inbound()->latest()->get();
Ao apagar uma transacção, o mkesh_transaction_id fica a null mas o registo
sobrevive — a prova do que chegou não se apaga com o livro-razão.
11. TLS, IP de origem e cliente HTTP
TLS
O agregador está publicado em HTTPS sobre um IP nu, por isso o certificado
não corresponde ao hostname e a verificação por omissão falha. A verificação
está ligada por omissão — aponte MKESH_SSL_CA_BUNDLE para o certificado
que o provedor fornecer e só desligue (MKESH_VERIFY_SSL=false) contra o
ambiente de testes.
IP de origem
O provedor faz whitelist do IP de origem: as chamadas têm de sair do host que registou junto deles. Uma máquina local ou um IP de saída diferente é rejeitado ao nível da rede, antes de qualquer XML ser interpretado.
Cliente HTTP personalizado
MkeshClient::create() liga o Guzzle por si. Para usar outro cliente PSR-18,
injecte-o (com as factories PSR-17) pelo construtor:
$client = new MkeshClient($config, $psr18Client, $requestFactory, $streamFactory);
12. Testes e resolução de problemas
composer test
Teste manual pela linha de comandos
php examples/mkesh-test.php debit 258823040400 25 php examples/mkesh-test.php transfer 258823040400 10 php examples/mkesh-test.php status 000001
Imprime o XML enviado e a resposta interpretada — é a forma mais rápida de resolver um desacordo com o provedor.
Problemas comuns
| Sintoma | Causa provável |
|---|---|
| O callback chega repetidamente | Não está a devolver <ResponseCode>SUCCESS</ResponseCode>. Um 200 vazio não chega. |
REFERENCE_ID_ALREADY_IN_USE no primeiro envio |
O id foi reutilizado. Use newTransactionId() e grave-o antes de enviar. |
TRANSACTION_NOT_FOUND ao consultar |
Consultou cedo demais, ou usou o id errado — a procura é pelo referenceid. |
AUTHORIZATION_FAILED |
Credenciais erradas ou IP de origem não autorizado. |
| Erro de certificado TLS | Host num IP nu. Configure MKESH_SSL_CA_BUNDLE. |
| Timeout sem resposta | Pode ter passado do lado deles. Consulte o estado antes de reenviar. |
Débito fica sempre PENDING |
O cliente não aprovou. O pedido expira; veja TRANSACTION_REQUEST_EXPIRED. |
Licença
MIT — © 2026 BrilliantMind. Ver LICENSE.
Suporte: it@brilliantmind.co.mz