Use case Debits::Repayment — reparcelamento no Asaas

TLDR: dado um Negotiation aprovado de reparcelamento, o use case cria o novo parcelamento no Asaas, cancela o parcelamento vigente e marca o Debit como awaiting_negotiation_payment. O negotiated continua sendo do webhook.

Contexto

Hoje o reparcelamento é manual no checkout-api: o operador duplica o pagamento, edita e clica em “Gerar Pagamento no Asaas”. Esse fluxo cria o parcelamento novo, apaga as cobranças não pagas do original e não tem transação nem compensação entre o Asaas e o banco local (uma falha no meio deixa cobrança órfã ou duplicada num retry).

O nectar-charges já tem o Negotiation (com as regras da R-001) e o Debit importado do checkout com provider_customer_id, provider_installment_id e organization_slug. Falta o passo que executa o acordo no Asaas.

A R-006 (RN-STATUS-1) proíbe marcar negotiated sem confirmação de pagamento por webhook. Por isso o use case marca um status intermediário e deixa o negotiated para o webhook.

Análise do fluxo manual no checkout-api e do que o nectar-charges herda dela:

  • A ordem lá é criar o novo primeiro e cancelar o original depois. Este spec segue a mesma ordem, para que uma falha na criação deixe o original intacto.
  • O webhook do checkout-api procura o pagamento por Payment.find_by!(reference: externalReference). Se não acha, falha em silêncio (context.fail!, sem exceção e sem retry). As cobranças do acordo passam a gerar só uma linha de log lá. Para não colidir com nenhum reference do checkout, o externalReference do acordo usa o prefixo nectar_negotiation_.
  • O cancelamento do original dispara PAYMENT_DELETED no checkout-api, que marca o pagamento original como cancelado. No ApoloService, um pagamento cancelado não concede nem remove acesso.

Objetivos

  • Criar Debits::Repayment (app/use_cases/debits/repayment.rb), que recebe debit: e negotiation: e devolve Success/Failure.
  • Criar o módulo Asaas em app/services/, no padrão de Apolo: tradutor fino da API, sem regra de negócio.
  • Criar o model PaymentProviderAccount (nome, slug e token criptografado) como fonte do token do Asaas por organization.
  • Guardar no Negotiation os ids do parcelamento novo no Asaas, para o retry e para o webhook futuro.
  • Adicionar o status awaiting_negotiation_payment ao Debit.
  • Ser reexecutável sem duplicar cobrança quando uma execução anterior falhou no meio.

Fora de escopo

  • Webhooks do Asaas no nectar-charges: negotiated (1ª parcela do acordo paga) e volta para pending (1ª parcela vencida sem pagamento). Ficam para o próximo spec de webhooks.
  • Devolução de acesso ao aluno. O checkout-api ignora as cobranças do acordo (o externalReference não existe lá). No reparcelamento manual, o pagamento novo carrega original_payment e é isso que concede o acesso quando o aluno paga. Aqui ninguém concede. Se o aluno perdeu o acesso por atraso, pagar o acordo não o devolve. Pendência a resolver junto do spec de webhooks (verificado só no ApoloService; CbtrgService e OnionService não foram lidos).
  • Parcelamento do acordo apagado por fora. A partir de deleting_old_charges, o use case confia no provider_installment_id gravado e não reconsulta o Asaas — o id só foi escrito depois de o provedor confirmar, e reconferir custaria um round-trip por retry. Se alguém apagar esse parcelamento direto no painel do Asaas, o retry apaga as cobranças antigas e marca o débito como awaiting_negotiation_payment com o cliente sem cobrança nenhuma, em silêncio. A reação certa é o webhook PAYMENT_DELETED desfazer o estado, e não o use case desconfiar do próprio registro a cada execução: pendência do spec de webhooks. (O job de remoção do checkout-api não alcança essas cobranças, porque o externalReference do acordo não existe no banco de lá; sobra a exclusão manual.)
  • Contratos e negativações: ficam no débito antigo, sem migrar para o gerado. A negativação é um fato vivo preso a um débito fechado; revisar junto do fluxo de negativação.
  • O 2º reparcelamento, que exige contrato de confissão de dívida assinado, e a quitação.
  • Endpoint HTTP, controller e tela que chamam o use case, e tela de cadastro de PaymentProviderAccount.
  • Aprovar o Negotiation e validar a R-001 (limite de 2 reparcelamentos por produto, máximo de 12 parcelas, 1ª parcela em até 7 dias, sem desconto). O use case confia no Negotiation aprovado.
  • Cartão de crédito (exige endpoint próprio do Asaas). Só boleto e pix.
  • Split, desconto, multa e juros no parcelamento novo.
  • Atualizar Debit#payment_type (repayment_first/repayment_second) e as parcelas locais (Installment) do Debit. As parcelas do original ficam como estão.
  • Expor awaiting_negotiation_payment nos filtros de Debit.search, no STATUS_MAP e no frontend.

Mudanças

app/services/asaas/

Segue o padrão de app/services/apolo/ (client.rb, error.rb, rejected.rb, unavailable.rb).

O service é tradutor puro: não consulta model nenhum. Quem resolve a conta da organization é o use case, que passa só o token adiante. Assim services/ continua sem saber de PaymentProviderAccount, como manda backend_layers.md.

  • Asaas::Client: único ponto que usa Faraday. Recebe o token do chamador. A base URL vem de ENV["ASAAS_URL"] e, em branco, cai no sandbox (https://api-sandbox.asaas.com), para que um ambiente sem configuração nunca gere cobrança real; produção define ASAAS_URL explicitamente. 4xx vira Asaas::Rejected; 5xx, timeout e JSON inválido viram Asaas::Unavailable.
  • Métodos do módulo Asaas, que recebem e devolvem o nosso vocabulário (centavos, símbolos):
    • find_installment_by_reference(token:, reference:) → GET /v3/payments?externalReference= e devolve o installment da cobrança encontrada.
    • create_installment(token:, customer_id:, billing_type:, total_cents:, installments_count:, first_due_on:, reference:, description:) → POST /v3/installments com totalValue + installmentCount.
    • installment_charges(token:, installment_id:) → GET /v3/installments/{id}/payments, devolvendo um array de Asaas::Charge ordenado por número, sem as cobranças avulsas (sem installmentNumber).
    • cancel_open_charges(token:, installment_id:) → DELETE /v3/installments/{id}/payments.

No Asaas, “payment” é a cobrança e “installment” é o parcelamento inteiro; aqui Installment é a parcela. Por isso o service traduz: o que a API chama de payment vira Asaas::Charge, a mesma palavra dos estados (deleting_old_charges) e do próprio produto. As colunas provider_payment_id, que já existiam, mantêm o nome.

PaymentProviderAccount

Tabela com a conta do provedor de pagamento de cada organization do checkout.

  • Colunas: name (string, obrigatório), slug (string, obrigatório, índice único) e token (string, obrigatório). O slug é o mesmo organizations.slug do checkout que o Debit guarda em organization_slug, e é por ele que o token é achado.
  • PaymentProviderAccount usa encrypts :token (Active Record Encryption), para o token não ficar em texto puro no banco nem aparecer em dump. O app ainda não usa Active Record Encryption. As chaves entram por ENV, como o resto da configuração do app (o app não lê credentials): ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY, ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY e ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT, geradas com bin/rails db:encryption:init e documentadas no .env.example. Em teste, config/environments/test.rb fixa chaves fictícias. Cadastrar as três variáveis em cada ambiente (ward) é passo operacional, fora do código.
  • O token nunca entra em serializer, log nem mensagem de Failure.
  • Não há tela nem endpoint de cadastro. As linhas entram por console ou seed, e o nome do Debit (organization_slug) deve casar com o slug.
  • Fixture de teste com token fictício.

Negotiation e Debit

  • Migrations em negotiations: billing_type, provider_installment_id, provider_payment_id, provider_status (null: false, default pending) e generated_debit_id (FK para debits, único). Mais índice único em debit_id, que é o que garante o has_one no banco.
  • Negotiation: enumerize :billing_type, in: %i[boleto pix], obrigatório quando repayment?; expired entra no enumerize :status; enumerize :provider_status, in: %i[pending creating_repayment deleting_old_charges done], eixo separado do status, que não se mexe durante a execução. As transições são do model: start_repayment_creation!, repayment_created! e old_charges_deleted!.
  • Debit: awaiting_negotiation_payment e replaced entram no enumerize :status, os dois fora de OPEN_STATUSES e ACTIVE_STATUSES. has_many :negotiations vira has_one :negotiation, mais has_one :originating_negotiation pelo generated_debit_id. O model ganha replace!.

Debits::Repayment

Entrada: só negotiation. O débito é negotiation.debit, o que torna impossível passar os dois desencontrados. A forma de pagamento também não é parâmetro: viaja no próprio Negotiation.

O fluxo troca a dívida por outra, e a ordem é o ponto central: o registro local nunca fica atrás do provedor.

  1. Guardas, sem tocar no Asaas: :invalid_negotiation, :invalid_debit, :require_contract (débito já é repayment_first), :repayment_limit_reached (já é repayment_second), :payment_provider_account_not_found e :already_provisioned.
  2. creating_repayment. Grava o estado; consulta pela referência nectar_negotiation_#{negotiation.id} e reaproveita se achar, senão cria o parcelamento; lê as cobranças; e, numa transação, cria o débito novo com suas parcelas e uma cópia dos produtos, liga generated_debit e passa a deleting_old_charges.
  3. deleting_old_charges. Apaga as cobranças do parcelamento antigo e, numa transação, fecha o débito antigo (replaced, closed_at) e grava done.
  4. Success(result: { debit:, generated_debit:, negotiation: }).

O débito novo nasce repayment_first, awaiting_negotiation_payment, com opened_at de agora, os ids do parcelamento, o provider_checkout_url da 1ª cobrança e cliente, atendente, organization_slug, provider_customer_id e payment_provider copiados do antigo. Contratos e negativações ficam no antigo.

Falhas. Asaas::Rejected e Asaas::Unavailable viram Failure(:asaas_error, result: { provider_status:, message: }). Uma falha não gera transição: a negociação permanece no estado em que estava, e é ele que diz onde retomar.

provider_status ao entrar Retoma em
pending Passo 2, do zero
creating_repayment Passo 2; a consulta pela referência é obrigatória e a parte local é pulada se generated_debit_id existir
deleting_old_charges Passo 3; o débito novo já existe dos dois lados
done :already_provisioned

A máquina de estados, a regra de ordem e a tabela de retomada estão em reference/negotiation/repayment_execution.md.

Como verificar

cd modules/backend && bin/rails t, com fixtures YAML (sem factory) e HTTP stubado, no padrão de test/services/apolo_test.rb. A cobertura em test/use_cases/debits/repayment_test.rb:

  • caminho feliz: cria o débito novo com parcelas e produtos, fecha o antigo como replaced e a negociação chega a done;
  • o débito novo carrega os ids do provedor, a URL de pagamento da 1ª cobrança, e cliente, atendente, organization_slug, provider_customer_id e payment_provider do antigo;
  • as parcelas do novo vêm das cobranças do Asaas, ordenadas, com valor, vencimento, provider_charge_id e link; as do antigo ficam intactas;
  • cada guarda devolve o Failure esperado e não chama o Asaas, inclusive :require_contract e :repayment_limit_reached;
  • creating_repayment é gravado antes da chamada ao provedor, e o débito novo existe antes do apagamento;
  • falha na criação: nenhum débito novo, Debit antigo intacto;
  • falha no apagamento: o débito novo permanece, o antigo segue aberto, e o retry só repete o apagamento;
  • retry com referência já existente no Asaas: reaproveita o parcelamento sem criar outro.

Em test/services/asaas_test.rb: o token vai no header, o service não consulta model nenhum (assert_no_queries), as cobranças são mapeadas para o nosso vocabulário e ordenadas, e cada erro do provedor vira Rejected ou Unavailable sem vazar o token. Nos models, as transições e o token criptografado (o valor cru no banco difere do lido).

Manual no sandbox do Asaas: rodar o use case no console com um Debit de teste e conferir no painel a criação do parcelamento novo, o apagamento do anterior e, em especial, que paymentExternalReference aparece como externalReference das cobranças — a idempotência depende disso e a doc do Asaas não o afirma.

Documentação

  • Atualizar R-006: acrescentar o estado awaiting_negotiation_payment, a transição criada pelo Debits::Repayment e as transições futuras (negotiated e volta para pending), marcando as duas últimas como pendentes do spec de webhooks.
  • Atualizar R-001: apontar o teste vinculado e registrar que o use case executa o acordo aprovado.
  • Adicionar este spec ao índice .project/docs/README.md.
  • Registrar em .project/docs/learnings/ o achado de acesso do aluno (checkout-api ignora as cobranças do acordo), como insumo do spec de webhooks.