Use case Debits::Repayment — reparcelamento no Asaas
TLDR: dado um
Negotiationaprovado de reparcelamento, o use case cria o novo parcelamento no Asaas, cancela o parcelamento vigente e marca oDebitcomoawaiting_negotiation_payment. Onegotiatedcontinua 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 nenhumreferencedo checkout, oexternalReferencedo acordo usa o prefixonectar_negotiation_. - O cancelamento do original dispara
PAYMENT_DELETEDno checkout-api, que marca o pagamento original como cancelado. NoApoloService, um pagamento cancelado não concede nem remove acesso.
Objetivos
- Criar
Debits::Repayment(app/use_cases/debits/repayment.rb), que recebedebit:enegotiation:e devolveSuccess/Failure. - Criar o módulo
Asaasemapp/services/, no padrão deApolo: 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
Negotiationos ids do parcelamento novo no Asaas, para o retry e para o webhook futuro. - Adicionar o status
awaiting_negotiation_paymentaoDebit. - 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 parapending(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
externalReferencenão existe lá). No reparcelamento manual, o pagamento novo carregaoriginal_paymente é 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ó noApoloService;CbtrgServiceeOnionServicenão foram lidos). - Parcelamento do acordo apagado por fora. A partir de
deleting_old_charges, o use case confia noprovider_installment_idgravado 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 comoawaiting_negotiation_paymentcom o cliente sem cobrança nenhuma, em silêncio. A reação certa é o webhookPAYMENT_DELETEDdesfazer 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 oexternalReferencedo 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
Negotiatione 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 noNegotiationaprovado. - Cartão de crédito (exige endpoint próprio do Asaas). Só
boletoepix. - Split, desconto, multa e juros no parcelamento novo.
- Atualizar
Debit#payment_type(repayment_first/repayment_second) e as parcelas locais (Installment) doDebit. As parcelas do original ficam como estão. - Expor
awaiting_negotiation_paymentnos filtros deDebit.search, noSTATUS_MAPe 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 otokendo chamador. A base URL vem deENV["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 defineASAAS_URLexplicitamente. 4xx viraAsaas::Rejected; 5xx, timeout e JSON inválido viramAsaas::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 oinstallmentda cobrança encontrada.create_installment(token:, customer_id:, billing_type:, total_cents:, installments_count:, first_due_on:, reference:, description:)→POST /v3/installmentscomtotalValue+installmentCount.installment_charges(token:, installment_id:)→GET /v3/installments/{id}/payments, devolvendo um array deAsaas::Chargeordenado por número, sem as cobranças avulsas (seminstallmentNumber).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) etoken(string, obrigatório). Oslugé o mesmoorganizations.slugdo checkout que oDebitguarda emorganization_slug, e é por ele que o token é achado. PaymentProviderAccountusaencrypts :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 porENV, como o resto da configuração do app (o app não lêcredentials):ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY,ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEYeACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT, geradas combin/rails db:encryption:inite documentadas no.env.example. Em teste,config/environments/test.rbfixa 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 oslug. - 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, defaultpending) egenerated_debit_id(FK paradebits, único). Mais índice único emdebit_id, que é o que garante ohas_oneno banco. Negotiation:enumerize :billing_type, in: %i[boleto pix], obrigatório quandorepayment?;expiredentra noenumerize :status;enumerize :provider_status, in: %i[pending creating_repayment deleting_old_charges done], eixo separado dostatus, que não se mexe durante a execução. As transições são do model:start_repayment_creation!,repayment_created!eold_charges_deleted!.Debit:awaiting_negotiation_paymentereplacedentram noenumerize :status, os dois fora deOPEN_STATUSESeACTIVE_STATUSES.has_many :negotiationsvirahas_one :negotiation, maishas_one :originating_negotiationpelogenerated_debit_id. O model ganhareplace!.
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.
- 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_founde:already_provisioned. creating_repayment. Grava o estado; consulta pela referêncianectar_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, ligagenerated_debite passa adeleting_old_charges.deleting_old_charges. Apaga as cobranças do parcelamento antigo e, numa transação, fecha o débito antigo (replaced,closed_at) e gravadone.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
replacede a negociação chega adone; - o débito novo carrega os ids do provedor, a URL de pagamento da 1ª cobrança, e cliente, atendente,
organization_slug,provider_customer_idepayment_providerdo antigo; - as parcelas do novo vêm das cobranças do Asaas, ordenadas, com valor, vencimento,
provider_charge_ide link; as do antigo ficam intactas; - cada guarda devolve o
Failureesperado e não chama o Asaas, inclusive:require_contracte: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,
Debitantigo 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 peloDebits::Repaymente as transições futuras (negotiatede volta parapending), 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.