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.
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.