Pular para o conteúdo
Carioca Taxi Engenharia
PagamentosBackend

Nunca pagar duas vezes: como deixamos o dinheiro do motorista à prova de falhas

Repasses, saques e estornos passam por redes que falham na pior hora. Os padrões que usamos para que cada real saia uma vez só, mesmo quando a resposta do banco se perde.

Jacques Ben BilombeDesenvolvedor, Carioca Taxi
· 5 min de leitura

Este é o 2º artigo da série “Construindo o Carioca Taxi”.

Num app de corridas, o dinheiro anda em várias etapas. O passageiro paga a corrida pelo app, por Pix ou cartão. O valor entra na conta do Carioca Taxi. Quando a corrida termina, a parte do motorista (o valor menos a comissão) é repassada para a conta de recebimento dele, e o motorista saca para o banco quando quiser. Se a corrida é estornada, a parte dele volta.

Cada seta desse caminho é uma chamada de rede para uma instituição de pagamento. E chamadas de rede falham de um jeito traiçoeiro: às vezes o pedido chega, o dinheiro se move, e a resposta se perde no caminho de volta. Para quem está do lado de cá, um "deu certo" perdido parece igual a um "deu errado". Se tentarmos de novo, pagamos duas vezes.

Este é o segundo artigo da série. Aqui contamos os padrões que usamos para que isso não aconteça.

O princípio: atrasar é aceitável, duplicar não é

Toda decisão do código de pagamentos segue uma regra: na dúvida, não mexa no dinheiro agora. Um repasse que sai um minuto depois, no próximo ciclo, é um incômodo pequeno. Um repasse em dobro é um prejuízo, e um problema com o motorista para desfazer.

1. Cada movimento tem uma identidade

Antes de qualquer transferência existir no banco de pagamentos, ela existe no nosso banco, como uma linha com identidade própria. Um repasse nasce ligado à corrida e ao tipo ("corrida", "gorjeta", "taxa de cancelamento"), e um índice único garante um repasse por corrida e tipo. O cron pode rodar dez vezes ao mesmo tempo que a mesma corrida continua com um repasse só.

Esse identificador vai junto como referência em cada chamada ao gateway (repasse-<id>, saque-<id>, devolucao-<id>). É ele que nos permite, depois, perguntar: "essa transferência já existe?"

2. Reservar antes de transferir

Dois caminhos podem tentar enviar o mesmo repasse: o fim da corrida e o cron. Então, antes de chamar o gateway, o código reserva a linha, mudando o estado de "agendado" para "enviado" só se ela ainda estiver agendada. Quem não conseguir reservar desiste.

Figura 1: Os estados de um repasse. Toda volta para trás passa por uma conferência no gateway antes de tentar de novo.
Figura 1: Os estados de um repasse. Toda volta para trás passa por uma conferência no gateway antes de tentar de novo.

Junto com a reserva, gravamos a hora (enviado_em). Ela resolve um caso que demoramos a ver. E se o servidor cair entre reservar e receber a resposta? A linha fica "enviada" para sempre, sem número de transferência. Agora o cron procura esses casos depois de cinco minutos e decide pelo que encontrar no gateway.

3. A resposta se perdeu? Pergunte antes de repetir

É o coração do problema. Quando a chamada de transferência dá erro, nós não marcamos simplesmente "falhou". Primeiro perguntamos ao gateway se existe uma transferência com a nossa referência. Se existe, ela deu certo e só a resposta se perdeu: gravamos como feita.

Figura 2: Transferir sem duplicar. A resposta perdida é recuperada pela referência, não por uma nova transferência.
Figura 2: Transferir sem duplicar. A resposta perdida é recuperada pela referência, não por uma nova transferência.

Há um detalhe. A documentação do gateway que usamos não permite filtrar transferências pela referência, só por data. Então listamos as transferências criadas a partir da véspera e procuramos a nossa. E, se essa busca também falhar, não transferimos: o repasse fica para o próximo ciclo.

async function transferirSemDuplicar(gw, referencia, desde, jaTentou, transferir) {
  if (jaTentou) {
    const existente = await gw.buscarTransferencia(referencia, desde); // se falhar, lança: não transfere
    if (existente) return existente;
  }
  try {
    return await transferir();
  } catch (erro) {
    const existente = await gw.buscarTransferencia(referencia, desde).catch(() => null);
    if (existente) return existente; // deu certo; só a resposta se perdeu
    throw erro;
  }
}

A mesma função envolve os três movimentos: o repasse ao motorista, a devolução num estorno e o saque.

4. Dois caminhos, uma marca

Mesmo com o repasse automático, o painel tem um "repassar à mão", para os casos em que a equipe paga direto. Dois caminhos para o mesmo dinheiro são um convite à duplicata. Por isso os dois deixam a mesma marca na corrida ("repassada em"):

  • o automático não envia o que já tem marca;
  • o manual ignora corridas com uma transferência a caminho, já feita ou presa num saque.

Encontramos essa última regra numa revisão interna, antes de ligar o repasse automático: o painel teria pago de novo uma corrida cuja transferência ainda estava a caminho. Um teste agora prova que não acontece.

5. Quando o banco diz "ainda não"

Instituições de pagamento abrem a conta de recebimento de cada motorista com verificação de identidade, e no começo costumam limitar quantas contas novas uma plataforma pode abrir. Um motorista que entra nesse intervalo não pode ficar sem receber. Então:

  • ele entra numa fila, e o cron tenta abrir a conta de novo, por ordem de chegada, sem martelar o gateway (para no primeiro que ainda for recusado);
  • o que ele ganha fica guardado na conta do Carioca Taxi, como repasses agendados;
  • ele pode pedir um saque expresso: a equipe transfere pelo painel e dá baixa. Os repasses incluídos ficam presos àquele saque, para não irem também para a conta dele se ela abrir no meio do caminho. E uma corrida estornada antes do pagamento sai do valor sozinha.

6. O dinheiro do cartão tem prazo

Pix cai na hora; cartão, não. O gateway libera o valor de uma corrida paga no cartão semanas depois. Se repassarmos ao motorista antes disso, estamos pagando com o dinheiro de outras corridas. Cada repasse nasce com uma data de "disponível em" que segue o prazo do cartão, e o app do motorista mostra quanto ainda está "a liberar" e quando. A taxa de cancelamento paga com cartão segue a mesma regra.

Como testamos

O gateway tem uma versão simulada, com a mesma interface do real, e ela sabe falhar de propósito. Num teste, ela aceita a transferência e "perde" a resposta. Em outro, a busca fica fora do ar. Em outro, o servidor "cai" entre a reserva e a chamada. Cada caso tem um teste que confere o resultado que importa: o saldo do motorista no fim. Contar quantas vezes uma função foi chamada não basta.

O adaptador do gateway real é testado contra respostas montadas a partir da documentação oficial, campo a campo.

Considerações finais

Nada aqui é novo: identidade para cada movimento, reserva antes de agir, conferência antes de repetir, uma marca só para dois caminhos. São os mesmos princípios de qualquer sistema financeiro. O trabalho está em aplicá-los a cada seta do caminho do dinheiro, inclusive às que parecem inofensivas, como um botão de "repassar à mão" num painel.

No próximo artigo, saímos do servidor e vamos para o painel do táxi: GPS em segundo plano no Android.