surfaai · parte 6 de 7

Migração de gateway em produção: Asaas → Stripe sem quebrar o checkout

07 de outubro de 20267 min de leituraCesar Eduardo Sturmer

Os artigos 1 e 2 desta série prometeram esta história duas vezes: o surfaai não nasceu no Stripe. Começou no Asaas, e eu troquei o gateway com a plataforma já em uso.

As migrations contam o calendário melhor que a minha memória. As tabelas do Asaas entraram em 27 de janeiro. Os campos do Stripe entraram em 3 de março — quatro migrations no mesmo dia. A limpeza do que sobrou é de 6 de março. Cinco semanas de Asaas, e a troca concentrada em três dias.

O título diz "sem quebrar o checkout" e não "sem quebrar nada" por um motivo que eu explico no fim: uma parte quebrou, de propósito.

Por que essa migração é diferente das outras

Trocar biblioteca de UI, ORM, até banco de dados: todas essas têm uma rede de segurança. Você testa à exaustão e, no pior caso, reverte o deploy.

Gateway de pagamento não tem isso, porque o estado que você está migrando não vive só no seu banco. Ele vive em três lugares ao mesmo tempo:

  • no seu banco — pagamentos, créditos, status
  • no gateway antigo — que continua sendo a verdade sobre o histórico dele
  • na cabeça do usuário — ele já pagou, já tem crédito, e espera que aquele crédito continue valendo amanhã

Reverter um deploy conserta o primeiro. Não conserta os outros dois. Se eu quebrasse o crédito de alguém que pagou em fevereiro, nenhum git revert devolveria a confiança.

Decisão 1: não reescrever dado histórico

A primeira tentação é normalizar tudo — pegar cada pagamento do Asaas e encaixar nos campos que o Stripe usa, para ter um schema só. Eu não fiz isso, e é a decisão da qual tenho mais certeza.

Transformar dado financeiro histórico para caber num formato que não existia na época da transação é a receita da inconsistência sutil: um campo que quase bate, um status sem equivalente direto, um valor de taxa calculado por uma regra que não era a regra daquele dia. E o pior é que você não descobre na hora. Descobre seis meses depois, conciliando, sem saber mais qual dos dois números era o certo.

Então os registros antigos continuaram com o formato antigo. Um registro de fevereiro nunca finge ser um registro de março. O custo é um código de leitura que sabe lidar com dois formatos. Vale muito menos que o risco de corromper histórico de dinheiro.

Decisão 2: separar o renomear do integrar — e eu fiz na ordem errada

Aqui está o erro que eu cometi, e ele está documentado na própria migration.

Três colunas guardavam identificadores de pagamento com nome do gateway velho. A migration 20260303000004 renomeou as três:

-- payments: asaas_payment_id → gateway_payment_id
ALTER TABLE payments
  RENAME COLUMN asaas_payment_id TO gateway_payment_id;

-- payments: asaas_fee → gateway_fee
ALTER TABLE payments
  RENAME COLUMN asaas_fee TO gateway_fee;

-- provider_payment_settings: asaas_fee_paid_by → gateway_fee_paid_by
ALTER TABLE provider_payment_settings
  RENAME COLUMN asaas_fee_paid_by TO gateway_fee_paid_by;

Nomes neutros, sem marca de fornecedor — gateway_payment_id guarda um pi_xxx do Stripe hoje e guardaria outro amanhã. Isso está certo.

O problema é o comentário no cabeçalho dessa migration, escrito por mim:

Contexto: migração Asaas → Stripe concluída. Os campos guardavam dados Stripe com nome errado.

Ou seja: por um intervalo, o asaas_payment_id estava guardando PaymentIntent do Stripe. A coluna mentia sobre o próprio conteúdo. Eu integrei primeiro e renomeei depois, e durante esse intervalo qualquer pessoa lendo aquele schema — inclusive eu, em três meses — concluiria a coisa errada.

O renomear foi feito em migration dedicada, separada de qualquer lógica de negócio, justamente para poder revisar e reverter isolado. Isso foi acerto. O erro foi a ordem: o ALTER TABLE devia ter vindo antes da primeira linha de código que escrevia dado novo ali, não depois. Renomear coluna é barato e reversível; conviver com um nome que mente, não.

Decisão 3: o que apagar e o que guardar

A limpeza de 6 de março separou as colunas do Asaas em dois grupos, e o critério não foi técnico — foi se o usuário vê ou não.

Removidas, porque eram ids internos de um sistema que eu não uso mais:

ALTER TABLE payments
  DROP COLUMN IF EXISTS asaas_split_id,
  DROP COLUMN IF EXISTS asaas_transfer_id,
  DROP COLUMN IF EXISTS asaas_boleto_barcode,
  DROP COLUMN IF EXISTS asaas_pix_qrcode;

Mantidas, porque são o que alguém pode querer abrir de novo:

  • asaas_boleto_url — o link do boleto
  • asaas_invoice_url — o comprovante
  • asaas_pix_payload — o copia-e-cola do PIX

O asaas_split_id não serve para nada: é a chave de um repasse numa API que eu não chamo mais. Mas o asaas_invoice_url é o comprovante de alguém. Se essa pessoa pedir a nota de uma compra de fevereiro, eu preciso ter o link.

O critério que ficou: id interno de sistema morto pode ir; artefato que o usuário vê, fica. É a diferença entre limpar schema e apagar o passado de alguém.

E ficou uma terceira categoria, que eu documentei em vez de resolver:

-- Para remoção futura (após confirmar que nenhum provider usa Asaas):
--   provider_payment_settings.asaas_account_id
--   provider_payment_settings.asaas_wallet_id
--   asaas_webhook_events (tabela completa)

Ainda estão lá. Escrever a condição de remoção no comentário foi melhor que apagar no escuro, mas "para remoção futura" sem data é só dívida com nome bonito.

Decisão 4: webhook do zero, não adaptado

Eu não tentei adaptar o handler do Asaas para o formato do Stripe. Reconstruí.

A tentação era grande — o handler antigo funcionava, e "só" o formato do payload mudava. Mas modelo de eventos não é formato de payload. O Asaas e o Stripe discordam sobre quais eventos existem, quando são emitidos, quantas vezes, e o que é a chave de identidade de cada um. Adaptar significaria carregar os pressupostos do modelo antigo para dentro do novo, onde eles estariam errados de um jeito difícil de ver.

A reconstrução é o que permitiu colocar a idempotência desde o primeiro commit do handler novo, em vez de descobri-la por incidente. O artigo anterior destrincha as camadas que saíram disso — incluindo a que eu descobri, escrevendo, que não estava ligada.

A parte que quebrou de propósito

Aqui o título cobra o preço.

O fluxo do aluno não quebrou: quem tinha crédito continuou com crédito, quem estava comprando continuou comprando. Essa parte eu protegi, e é o que o artigo 1 prometeu.

O fluxo do provider quebrou inteiro, por necessidade. KYC não se transfere entre gateways. A aprovação de conta no Asaas e o onboarding do Stripe Connect Express têm estados, requisitos e documentos diferentes, e não existe "mapear de um para o outro" — a Stripe não aceita a verificação de outra empresa, e está certa em não aceitar. Cada provider teve que passar pelo onboarding novo.

Isso é o custo de saída que eu descrevi no artigo 2 como trade-off do Connect: as contas são da Stripe, e trocar de gateway significa refazer o onboarding de todos. Eu escrevi aquele parágrafo tendo acabado de pagar essa conta.

O que dá para fazer é só o que eu fiz: avisar antes, explicar por quê, e não deixar ninguém descobrir que precisa refazer cadastro na hora que ia receber.

A lição

Migração de gateway não é um problema de integração de API. É um problema de modelagem de estado ao longo do tempo: como representar "isso aconteceu no sistema antigo" e "isso acontece no novo" sem fingir que são a mesma coisa.

As três decisões que importaram foram todas sobre o que não unificar — histórico não reescrito, colunas de display preservadas, handler novo em vez de adaptado. A vontade de ter um schema só, limpo, sem rastro do fornecedor antigo, é estética. O histórico financeiro de outra pessoa não é lugar para exercer estética.

E a que eu erraria diferente: renomear antes de escrever. Uma coluna com nome errado guardando dado certo é uma armadilha que você mesmo arma, e que só dispara quando você já esqueceu que armou.

← Todos os artigos