AkiraAkira.dev
5 min de leitura

O que a 3.0 do Laravel SISP partiu, e porquê

Um reembolso que ninguém enviava, uma referência que era um timestamp, e uma migração que nunca corria em MySQL. A 3.0 gastou-se nisto.

também em EN FR

Até à 3.0, bastava chamar Sisp::refund($transaction)->full()->process() para a transação ficar refunded e o evento TransactionRefunded sair. Quem tivesse um listener nesse evento tinha tudo para avisar o cliente de que o dinheiro vinha a caminho.

Não vinha. Nunca saiu um único pedido do processo. O único cliente HTTP do Laravel SISP é o TransactionStatusClient, que pergunta à SISP o estado de uma transação e mais nada.

A 3.0 mexeu em 141 ficheiros ao longo de 128 commits. Sete desses commits levam !. Seis são correções, o sétimo endurece um valor por omissão, e nenhum alarga a superfície do pacote.

Chamar registo ao que é registo

Um reembolso na SISP emite-se no back office e confirma-se no ficheiro de reconciliação diário. O que o pacote faz é registo contabilístico: constrói o pedido assinado, guarda-o, move a transação e emite o evento. O nome process() prometia o resto. A fatura, essa, nem se mexia: passou a acompanhar o reembolso só na 3.0.

A operação passou então a chamar-se pelo que faz: RecordRefundAction, evento RefundRecorded, RefundBuilder::record(), e a rota responde Refund recorded. Issue it in the SISP back office if you have not already. Um teste garante que nada volta a sair por ali:

// tests/Actions/RecordRefundReachesNoGatewayTest.php
it('sends nothing to the gateway when a refund is recorded', function (): void {
    Http::preventStrayRequests();
    Http::fake();

    resolve(RecordRefundAction::class)->handle(recordableTransaction(), 100.0, 'customer_request');

    Http::assertNothingSent();
});

O comportamento é exatamente o de antes. Mudou o nome, e com ele o que a integração pode presumir.

A migração que nunca correu em MySQL

A primeira migração do pacote indexava quatro colunas de uma vez:

// database/migrations/create_laravel_sisp_table.php
$table->index(['merchant_ref', 'merchant_session', 'status', 'message_type']);

Quatro colunas varchar(255) em utf8mb4 pedem 4080 bytes de chave. O InnoDB permite 3072. Nenhuma instalação nova chegava ao fim das migrações em MySQL ou MariaDB, durante toda a vida da 2.x, e ninguém reparou, porque a integração contínua só corria em SQLite, que não impõe limite nenhum de chave.

O índice passou a ser só merchant_session, e há um job novo que corre as migrações em MySQL 8 e PostgreSQL 16 a cada push. Na primeira execução apanhou logo um segundo defeito: o migrate:rollback morria em MySQL com o erro 1553. O foreignId()->constrained() faz o MySQL criar um índice próprio sobre transaction_id, a verificação existente dava esse índice por suficiente e não criava o dedicado, e a chave estrangeira acabava dependente do índice único que o rollback estava a largar.

Uma pipeline que só corre no motor que ninguém põe em produção testa-se a si mesma, não as instalações que devia proteger.

Quem bate à porta não é a SISP

Três das sete quebras desconfiam mais do pedido que chega.

Os geradores por omissão juntam dez caracteres aleatórios maiúsculos ao timestamp:

// src/Actions/Generators/MerchantReferenceGeneratorAction.php
public function __invoke(): string
{
    return 'R'.now()->format('YmdHis').Str::upper(Str::random(10));
}

R20260523235959 passa a R20260523235959K7M2QX9TBV. De quinze caracteres para vinte e cinco, e dois pagamentos iniciados no mesmo segundo deixam de colidir, que era a outra metade do mesmo problema.

O callback valida o fingerprint antes do pipeline, e um callback recusado não escreve nada:

// src/Http/Controllers/CallbackController.php
if ($this->rejectsFingerprint($payload)) {
    Log::warning('SISP callback rejected: the fingerprint does not match.', [
        'merchant_ref' => $payload->merchantRef,
    ]);

    return redirect(config('sisp.redirect_url', '/'));
}

Até à 2.2, quem soubesse a referência e a sessão de um pagamento pendente punha-o em failed, cancelava a fatura, disparava o PaymentFailed e apagava os campos de recusa que a SISP tinha escrito. A correção saiu nesse patch; a 3.0 leva-a como quebra declarada, porque a transação, a tentativa, a fatura e os metadados do pedido ficam agora intocados e nenhum evento sai. Quem procurava merchant_response = 'invalid_callback_fingerprint' para detetar callbacks forjados passa a vigiar aquela linha de log.

A terceira é a página de resultado, que só abre através de um URL assinado válido durante trinta minutos e deixou de levar merchant_session na prop do Inertia. Referência e sessão juntas continuam a bastar para cancelar um pagamento pendente através do callback de cancelamento não assinado, e a página entregava uma das duas. A defesa é a referência deixar de ser adivinhável, pelo que uma instalação que fique com os geradores da 2.x fica também com o buraco.

Um estado vive numa linha, e a linha tranca-se

O cancelamento recusava completed e cancelled, o que deixava transações em failed e em refunded livres para transitarem para cancelled. Passou a recusar as quatro. O trinco é mais antigo: já na 2.2 a transação era lida com lockForUpdate() dentro de uma transação de base de dados e o estado validado na linha trancada, para que dois cancelamentos simultâneos não vencessem ambos. O que a 3.0 acrescenta são os dois estados recusados a mais e a fatura a mover-se na mesma transação.

O InvoiceStatus ganhou o caso refunded: um reembolso total leva a fatura para lá, um parcial deixa a transação e a fatura onde estão. Um match exaustivo sobre esse enum sem ramo default rebenta com UnhandledMatchError na primeira fatura reembolsada que lhe chegar, e é essa a forma que uma quebra deve ter.

Sete quebras, zero aliases

A objeção justa é que sete quebras numa release são um custo que recai sobre quem já tinha a 2.x instalada, e que um class_alias com um ciclo de depreciação teria sido mais simpático.

Nos nomes teria sido pior. Código que chama event(new TransactionRefunded(...)) rebenta numa classe que já não existe, e isso resolve-se em quinze minutos. Um listener deixado no nome antigo, agora apenas um alias, continua a compilar e deixa de disparar, sem erro e sem log, num caminho de reembolso de que alguém depende. O guia de upgrade pede três coisas por esta ordem: renomear quem dispara o evento, esvaziar a fila antes de instalar, corrigir os test doubles.

Sete quebras de uma vez custam menos do que um listener que se cala durante um mês.

Pesquisar no Dev Log

Pesquisar artigos, etiquetas, ideias…