Use case Debits::Repayment — Plano de implementação
TLDR: seis tasks em TDD:
PaymentProviderAccount(token criptografado), colunas doNegotiation, status doDebit, móduloAsaas, o use caseDebits::Repaymente a documentação.
Spec:
.project/docs/specs/20260929021525_debit_repayment_use_case.mdBranch:feat/repayment
Arquitetura: o use case (app/use_cases/debits/repayment.rb) orquestra: valida as guardas, cria o
parcelamento novo no Asaas (idempotente por externalReference), grava os ids no Negotiation, cancela o
parcelamento vigente e só então muda o status do Debit. O módulo Asaas (app/services/asaas*) é o
tradutor fino da API, no padrão de Apolo, e busca o token em PaymentProviderAccount.
Stack: Rails 8.1, u-case (Micro::Case), enumerize, Faraday, Minitest + WebMock, fixtures YAML.
Restrições globais
- Testes:
cd modules/backend && bin/rails t(nuncamake). Lint:cd modules/backend && bin/rubocop. - Baseline antes de começar: 422 testes verdes e rubocop limpo.
- Fixtures YAML, nunca factories. Subjects dos testes do use case saem de fixtures existentes
(
joana_pending,joana_simulated), ajustadas nosetup. - Commits: uma linha, no máximo 60 caracteres, sem menção a IA nem
Co-Authored-By(commons:commit). - Nomes em inglês no código; docs em
.project/docs/em português. - Migrations: depois de
bin/rails db:migrate, commitar odb/schema.rbgerado. - Tasks 1 e 2 alteram o
schema.rb: executar em sequência. A Task 3 não tem migration e pode rodar em paralelo com elas. A Task 4 depende da 1; a Task 5 depende das 1 a 4; a Task 6 vem por último.
Mapa de arquivos
| Arquivo | Ação | Responsabilidade |
|---|---|---|
db/migrate/20260929031000_create_payment_provider_accounts.rb |
criar | tabela payment_provider_accounts |
app/models/payment_provider_account.rb |
criar | conta do provedor por organization, token criptografado |
config/application.rb |
alterar | chaves do Active Record Encryption vindas de ENV |
config/environments/test.rb |
alterar | chaves fictícias e encrypt_fixtures |
.env.example |
alterar | documenta as chaves de encryption e ASAAS_URL |
test/fixtures/payment_provider_accounts.yml |
criar | conta trg de teste |
db/migrate/20260929032000_add_provider_fields_to_negotiations.rb |
criar | billing_type, provider_installment_id, provider_payment_id |
app/models/negotiation.rb |
alterar | enumerize :billing_type |
app/models/debit.rb |
alterar | status awaiting_negotiation_payment |
app/services/asaas.rb |
criar | API do Asaas em vocabulário nosso |
app/services/asaas/{client,error,rejected,unavailable,installment,payment}.rb |
criar | Faraday, erros e value objects |
app/use_cases/debits/repayment.rb |
criar | o use case |
test/models/payment_provider_account_test.rb |
criar | testes do model |
test/models/{negotiation,debit}_test.rb |
alterar | novos casos |
test/services/asaas_test.rb |
criar | testes do módulo Asaas |
test/use_cases/debits/repayment_test.rb |
criar | testes do use case |
.project/docs/{rules,learnings,README.md,RULES.md} |
alterar/criar | documentação |
Task 1: PaymentProviderAccount com token criptografado
Files:
- Create: modules/backend/db/migrate/20260929031000_create_payment_provider_accounts.rb
- Create: modules/backend/app/models/payment_provider_account.rb
- Create: modules/backend/test/fixtures/payment_provider_accounts.yml
- Create: modules/backend/test/models/payment_provider_account_test.rb
- Modify: modules/backend/config/application.rb
- Modify: modules/backend/config/environments/test.rb
- Modify: .env.example
Interfaces:
- Consumes: nada.
- Produces: PaymentProviderAccount (name, slug único, token criptografado); fixture payment_provider_accounts(:trg) com token "trg-test-token".
- [ ] Step 1: Escrever o teste que falha
modules/backend/test/models/payment_provider_account_test.rb:
```ruby require “test_helper”
class PaymentProviderAccountTest < ActiveSupport::TestCase test “requires name, slug and token” do # arrange account = PaymentProviderAccount.new
# act
valid = account.valid?
# assert
assert_not valid
assert_includes account.errors[:name], "can't be blank"
assert_includes account.errors[:slug], "can't be blank"
assert_includes account.errors[:token], "can't be blank" end
test “rejects a duplicated slug” do # arrange duplicate = PaymentProviderAccount.new(name: “Outra conta”, slug: payment_provider_accounts(:trg).slug, token: “another-token”)
# act
valid = duplicate.valid?
# assert
assert_not valid
assert_includes duplicate.errors[:slug], "has already been taken" end
test “stores the token encrypted” do # arrange account = PaymentProviderAccount.find(payment_provider_accounts(:trg).id)
# act
raw = account.token_before_type_cast
# assert
assert_equal "trg-test-token", account.token
assert_not_equal "trg-test-token", raw end end ```
- [ ] Step 2: Rodar e ver falhar
bash
cd modules/backend && bin/rails t test/models/payment_provider_account_test.rb
Esperado: FAIL — NameError: uninitialized constant PaymentProviderAccount.
- [ ] Step 3: Implementação mínima
modules/backend/db/migrate/20260929031000_create_payment_provider_accounts.rb:
```ruby class CreatePaymentProviderAccounts < ActiveRecord::Migration[8.1] def change create_table :payment_provider_accounts do |t| t.string :name, null: false t.string :slug, null: false t.string :token, null: false
t.timestamps
end
add_index :payment_provider_accounts, :slug, unique: true end end ```
modules/backend/app/models/payment_provider_account.rb:
```ruby # An organization’s account at a payment provider (Asaas). Each checkout organization has its own account, # so the API token is looked up by the same slug the debit stores in organization_slug. class PaymentProviderAccount < ApplicationRecord encrypts :token
validates :name, :slug, :token, presence: true validates :slug, uniqueness: true end ```
modules/backend/config/application.rb — dentro de class Application, depois de config.x.session_cookie.secure = true:
ruby
# Active Record Encryption keys (PaymentProviderAccount#token). Generate with `bin/rails db:encryption:init`.
config.active_record.encryption.primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
config.active_record.encryption.deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
config.active_record.encryption.key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
modules/backend/config/environments/test.rb — dentro de Rails.application.configure, ao final do bloco:
ruby
# Fixed keys so encrypted attributes and encrypted fixtures work without any ENV.
config.active_record.encryption.primary_key = "test-primary-key-0123456789abcdef"
config.active_record.encryption.deterministic_key = "test-deterministic-key-0123456789ab"
config.active_record.encryption.key_derivation_salt = "test-key-derivation-salt-0123456789"
config.active_record.encryption.encrypt_fixtures = true
modules/backend/test/fixtures/payment_provider_accounts.yml:
yaml
trg:
name: TRG
slug: trg
token: trg-test-token
.env.example — depois do bloco do Apolo (após APOLO_API_TOKEN=):
# Asaas API base URL. Empty falls back to the sandbox (https://api-sandbox.asaas.com); production sets it explicitly.
# Each organization's API token lives in payment_provider_accounts (encrypted), not here.
ASAAS_URL=
# Active Record Encryption keys for payment_provider_accounts.token (secret). Generate with `bin/rails db:encryption:init`.
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
Depois:
bash
cd modules/backend && bin/rails db:migrate
(Em desenvolvimento, exporte as três chaves antes, geradas por bin/rails db:encryption:init. A migration em si não usa encryption.)
- [ ] Step 4: Rodar e ver passar
bash
cd modules/backend && bin/rails t test/models/payment_provider_account_test.rb && bin/rails t && bin/rubocop
Esperado: PASS (3 testes novos; suíte completa verde; rubocop limpo).
- [ ] Step 5: Commit
bash
git add modules/backend/db modules/backend/app/models/payment_provider_account.rb modules/backend/config modules/backend/test .env.example
git commit -m "feat: cria PaymentProviderAccount com token criptografado"
Task 2: Colunas do acordo no Negotiation
Files:
- Create: modules/backend/db/migrate/20260929032000_add_provider_fields_to_negotiations.rb
- Modify: modules/backend/app/models/negotiation.rb
- Modify: modules/backend/test/models/negotiation_test.rb
Interfaces:
- Consumes: nada.
- Produces: negotiation.billing_type (boleto ou pix, enumerize), negotiation.provider_installment_id, negotiation.provider_payment_id.
- [ ] Step 1: Escrever os testes que falham
Acrescentar em modules/backend/test/models/negotiation_test.rb, antes do end da classe:
```ruby test “accepts boleto and pix as billing type” do # arrange negotiation = negotiations(:joana_simulated)
# act / assert
%w[boleto pix].each do |billing_type|
negotiation.billing_type = billing_type
assert_predicate negotiation, :valid?
end end
test “rejects credit card as billing type” do # arrange negotiation = negotiations(:joana_simulated) negotiation.billing_type = “credit_card”
# act
valid = negotiation.valid?
# assert
assert_not valid
assert_includes negotiation.errors[:billing_type], "is not included in the list" end
test “keeps the provider ids of the installment created in the provider” do # arrange negotiation = negotiations(:joana_simulated)
# act
negotiation.update!(provider_installment_id: "ins_1", provider_payment_id: "pay_1")
# assert
assert_equal "ins_1", negotiation.reload.provider_installment_id
assert_equal "pay_1", negotiation.provider_payment_id end ```
- [ ] Step 2: Rodar e ver falhar
bash
cd modules/backend && bin/rails t test/models/negotiation_test.rb
Esperado: FAIL — NoMethodError: undefined method 'billing_type='.
- [ ] Step 3: Implementação mínima
modules/backend/db/migrate/20260929032000_add_provider_fields_to_negotiations.rb:
```ruby class AddProviderFieldsToNegotiations < ActiveRecord::Migration[8.1] def change change_table :negotiations, bulk: true do |t| t.string :billing_type t.string :provider_installment_id t.string :provider_payment_id end
add_index :negotiations, :provider_installment_id, unique: true,
where: "provider_installment_id IS NOT NULL" end end ```
modules/backend/app/models/negotiation.rb — logo abaixo do enumerize :status, ...:
ruby
enumerize :billing_type, in: %i[boleto pix]
Depois:
bash
cd modules/backend && bin/rails db:migrate
- [ ] Step 4: Rodar e ver passar
bash
cd modules/backend && bin/rails t test/models/negotiation_test.rb && bin/rails t && bin/rubocop
Esperado: PASS.
- [ ] Step 5: Commit
bash
git add modules/backend/db modules/backend/app/models/negotiation.rb modules/backend/test/models/negotiation_test.rb
git commit -m "feat: guarda dados do parcelamento do acordo no Negotiation"
Task 3: Status awaiting_negotiation_payment no Debit
Files:
- Modify: modules/backend/app/models/debit.rb
- Modify: modules/backend/test/models/debit_test.rb
Interfaces:
- Consumes: nada.
- Produces: debit.awaiting_negotiation_payment?, Debit.with_status(:awaiting_negotiation_payment). O status não está em Debit::OPEN_STATUSES nem em Debit::ACTIVE_STATUSES.
- [ ] Step 1: Escrever os testes que falham
Acrescentar em modules/backend/test/models/debit_test.rb, antes do end da classe:
```ruby test “accepts awaiting_negotiation_payment as a status” do # arrange debit = debits(:joana_pending)
# act
debit.update!(status: :awaiting_negotiation_payment)
# assert
assert_predicate debit.reload, :awaiting_negotiation_payment? end
test “does not treat a debit awaiting the negotiation payment as open or active” do # arrange / act / assert assert_not_includes Debit::OPEN_STATUSES, “awaiting_negotiation_payment” assert_not_includes Debit::ACTIVE_STATUSES, “awaiting_negotiation_payment” end ```
- [ ] Step 2: Rodar e ver falhar
bash
cd modules/backend && bin/rails t test/models/debit_test.rb
Esperado: FAIL — o primeiro teste com ActiveRecord::RecordInvalid (status inválido).
- [ ] Step 3: Implementação mínima
modules/backend/app/models/debit.rb — trocar o in: do enumerize :status:
ruby
enumerize :status,
in: %i[pending negotiated awaiting_negotiation_payment no_forecast bureau_report no_response
cancelled negativated],
default: :pending, predicates: true, scope: true
- [ ] Step 4: Rodar e ver passar
bash
cd modules/backend && bin/rails t test/models/debit_test.rb && bin/rails t && bin/rubocop
Esperado: PASS.
- [ ] Step 5: Commit
bash
git add modules/backend/app/models/debit.rb modules/backend/test/models/debit_test.rb
git commit -m "feat: adiciona status awaiting_negotiation_payment ao Debit"
Task 4: Módulo Asaas
Files:
- Create: modules/backend/app/services/asaas.rb
- Create: modules/backend/app/services/asaas/client.rb
- Create: modules/backend/app/services/asaas/error.rb
- Create: modules/backend/app/services/asaas/rejected.rb
- Create: modules/backend/app/services/asaas/unavailable.rb
- Create: modules/backend/app/services/asaas/installment.rb
- Create: modules/backend/app/services/asaas/payment.rb
- Create: modules/backend/test/services/asaas_test.rb
Interfaces:
- Consumes: PaymentProviderAccount da Task 1 (find_by(slug:), #token).
- Produces (todas com organization_slug: como primeiro argumento nomeado):
- Asaas.find_installment_by_reference(organization_slug:, reference:) → Asaas::Installment ou nil
- Asaas.create_installment(organization_slug:, customer_id:, billing_type:, total_cents:, installments_count:, first_due_on:, reference:, description:) → Asaas::Installment
- Asaas.first_payment(organization_slug:, installment_id:) → Asaas::Payment
- Asaas.cancel_open_payments(organization_slug:, installment_id:) → Array<String> (ids cancelados)
- Asaas::Installment = Data.define(:id), Asaas::Payment = Data.define(:id, :installment_id, :number)
- Asaas::Error, Asaas::Rejected (4xx), Asaas::Unavailable (5xx, timeout, JSON inválido, conta inexistente)
- [ ] Step 1: Escrever os testes que falham
modules/backend/test/services/asaas_test.rb:
```ruby require “test_helper”
class AsaasTest < ActiveSupport::TestCase BASE_URL = “https://api-sandbox.asaas.com”.freeze PAYMENTS_URL = “#{BASE_URL}/v3/payments”.freeze INSTALLMENTS_URL = “#{BASE_URL}/v3/installments”.freeze
def with_env(values) previous = values.keys.to_h { |key| [ key, ENV[key] ] } values.each { |key, value| ENV[key] = value } yield ensure previous.each { |key, value| ENV[key] = value } end
def find_installment(reference: “nectar_negotiation_7”) Asaas.find_installment_by_reference(organization_slug: “trg”, reference: reference) end
def create_installment Asaas.create_installment( organization_slug: “trg”, customer_id: “cus_1”, billing_type: :pix, total_cents: 200_008, installments_count: 8, first_due_on: Date.new(2026, 10, 5), reference: “nectar_negotiation_7”, description: “Reparcelamento” ) end
test “sends the organization token in the access_token header” do request = stub_request(:get, PAYMENTS_URL) .with(query: hash_including(“externalReference” => “nectar_negotiation_7”), headers: { “access_token” => “trg-test-token” }) .to_return(status: 200, body: { data: [] }.to_json)
find_installment
assert_requested request end
test “finds the installment that carries the reference” do stub_request(:get, PAYMENTS_URL).with(query: hash_including({})) .to_return(status: 200, body: { data: [ { id: “pay_1”, installment: “ins_9” } ] }.to_json)
installment = find_installment
assert_equal "ins_9", installment.id end
test “returns nil when no payment carries the reference” do stub_request(:get, PAYMENTS_URL).with(query: hash_including({})) .to_return(status: 200, body: { data: [] }.to_json)
assert_nil find_installment end
test “creates the installment sending the total in reais with the reference” do request = stub_request(:post, INSTALLMENTS_URL).with do |req| JSON.parse(req.body) == { “customer” => “cus_1”, “billingType” => “PIX”, “installmentCount” => 8, “totalValue” => 2000.08, “dueDate” => “2026-10-05”, “paymentExternalReference” => “nectar_negotiation_7”, “description” => “Reparcelamento” } end.to_return(status: 200, body: { id: “ins_new” }.to_json)
installment = create_installment
assert_requested request
assert_equal "ins_new", installment.id end
test “picks the first payment of the installment” do stub_request(:get, “#{INSTALLMENTS_URL}/ins_new/payments”).to_return( status: 200, body: { data: [ { id: “pay_2”, installmentNumber: 2 }, { id: “pay_1”, installmentNumber: 1 } ] }.to_json )
payment = Asaas.first_payment(organization_slug: "trg", installment_id: "ins_new")
assert_equal "pay_1", payment.id
assert_equal "ins_new", payment.installment_id
assert_equal 1, payment.number end
test “raises Unavailable when the installment has no first payment” do stub_request(:get, “#{INSTALLMENTS_URL}/ins_new/payments”) .to_return(status: 200, body: { data: [] }.to_json)
assert_raises(Asaas::Unavailable) { Asaas.first_payment(organization_slug: "trg", installment_id: "ins_new") } end
test “cancels the open payments and returns their ids” do request = stub_request(:delete, “#{INSTALLMENTS_URL}/ins_old/payments”).to_return( status: 200, body: { deleted: true, id: “ins_old”, deletedPayments: [ { id: “pay_a” }, { id: “pay_b” } ] }.to_json )
ids = Asaas.cancel_open_payments(organization_slug: "trg", installment_id: "ins_old")
assert_requested request
assert_equal %w[pay_a pay_b], ids end
test “raises Rejected on a 4xx without leaking the token” do stub_request(:post, INSTALLMENTS_URL).to_return( status: 400, body: { errors: [ { code: “invalid_action”, description: “Cliente inválido” } ] }.to_json )
error = assert_raises(Asaas::Rejected) { create_installment }
assert_includes error.message, "Cliente inválido"
assert_not_includes error.message, "trg-test-token" end
test “raises Unavailable on a 5xx” do stub_request(:post, INSTALLMENTS_URL).to_return(status: 502, body: “Bad Gateway”)
assert_raises(Asaas::Unavailable) { create_installment } end
test “raises Unavailable on a timeout” do stub_request(:post, INSTALLMENTS_URL).to_timeout
assert_raises(Asaas::Unavailable) { create_installment } end
test “raises Unavailable on an invalid JSON body” do stub_request(:post, INSTALLMENTS_URL).to_return(status: 200, body: “<html>”)
assert_raises(Asaas::Unavailable) { create_installment } end
test “raises Unavailable when the organization has no payment provider account” do assert_raises(Asaas::Unavailable) do Asaas.find_installment_by_reference(organization_slug: “unknown”, reference: “nectar_negotiation_7”) end end
test “uses ASAAS_URL when it is set” do request = stub_request(:get, %r{\Ahttps://asaas.test/v3/payments}) .to_return(status: 200, body: { data: [] }.to_json)
with_env("ASAAS_URL" => "https://asaas.test") { find_installment }
assert_requested request end end ```
- [ ] Step 2: Rodar e ver falhar
bash
cd modules/backend && bin/rails t test/services/asaas_test.rb
Esperado: FAIL — NameError: uninitialized constant AsaasTest::Asaas (ou NoMethodError).
- [ ] Step 3: Implementação mínima
modules/backend/app/services/asaas/error.rb:
ruby
module Asaas
class Error < StandardError; end
end
modules/backend/app/services/asaas/rejected.rb:
ruby
module Asaas
# 4xx — Asaas refused the request (e.g. an invalid token or customer).
class Rejected < Error; end
end
modules/backend/app/services/asaas/unavailable.rb:
ruby
module Asaas
# Timeout, connection failure, 5xx, an unreadable body or a missing account — worth retrying or fixing setup.
class Unavailable < Error; end
end
modules/backend/app/services/asaas/installment.rb:
ruby
module Asaas
Installment = Data.define(:id)
end
modules/backend/app/services/asaas/payment.rb:
ruby
module Asaas
Payment = Data.define(:id, :installment_id, :number)
end
modules/backend/app/services/asaas/client.rb:
```ruby module Asaas # The only place Faraday appears. The token comes from the organization’s PaymentProviderAccount and the # base URL from ENV[“ASAAS_URL”], falling back to the sandbox so an unconfigured environment never charges. module Client DEFAULT_URL = “https://api-sandbox.asaas.com”
def self.get(organization_slug, path, params = {}) = request(organization_slug, :get, path, params: params)
def self.post(organization_slug, path, body) = request(organization_slug, :post, path, body: body)
def self.delete(organization_slug, path) = request(organization_slug, :delete, path)
def self.request(organization_slug, verb, path, params: nil, body: nil)
response = connection(organization_slug).public_send(verb, path) do |req|
req.params.update(params) if params.present?
req.body = body.to_json if body
end
raise Rejected, rejection_message(response) if response.status.between?(400, 499)
raise Unavailable, "status #{response.status}" unless response.success?
JSON.parse(response.body)
rescue Faraday::Error => e
raise Unavailable, e.class.name
rescue JSON::ParserError
raise Unavailable, "invalid JSON body"
end
private_class_method :request
def self.rejection_message(response)
description = JSON.parse(response.body).dig("errors", 0, "description")
[ "status #{response.status}", description ].compact.join(": ")
rescue JSON::ParserError
"status #{response.status}"
end
private_class_method :rejection_message
def self.connection(organization_slug)
account = PaymentProviderAccount.find_by(slug: organization_slug)
raise Unavailable, "no payment provider account for #{organization_slug}" if account.nil?
Faraday.new(
url: ENV["ASAAS_URL"].presence || DEFAULT_URL,
headers: {
"access_token" => account.token,
"Content-Type" => "application/json",
"User-Agent" => "nectar-charges"
},
request: { open_timeout: 5, timeout: 10 }
)
end
private_class_method :connection end end ```
modules/backend/app/services/asaas.rb:
```ruby # Asaas (payment provider): installments created and cancelled in the organization’s own account. module Asaas BILLING_TYPES = { boleto: “BOLETO”, pix: “PIX” }.freeze
# Idempotency lookup: the installment whose payments carry our reference, if it already exists. def self.find_installment_by_reference(organization_slug:, reference:) row = Client.get(organization_slug, “/v3/payments”, externalReference: reference, limit: 1) .fetch(“data”, []).first return if row.nil? || row[“installment”].blank?
Installment.new(id: row["installment"]) end
def self.create_installment(organization_slug:, customer_id:, billing_type:, total_cents:, installments_count:, first_due_on:, reference:, description:) row = Client.post(organization_slug, “/v3/installments”, { customer: customer_id, billingType: BILLING_TYPES.fetch(billing_type.to_sym), installmentCount: installments_count, totalValue: (total_cents / 100.0).round(2), dueDate: first_due_on.iso8601, paymentExternalReference: reference, description: description })
Installment.new(id: row.fetch("id")) end
def self.first_payment(organization_slug:, installment_id:) rows = Client.get(organization_slug, “/v3/installments/#{installment_id}/payments”).fetch(“data”, []) row = rows.find { |payment| payment[“installmentNumber”] == 1 } raise Unavailable, “installment #{installment_id} has no first payment” if row.nil?
Payment.new(id: row["id"], installment_id: installment_id, number: 1) end
# Cancels the pending and overdue payments of an installment; returns the ids that were cancelled. def self.cancel_open_payments(organization_slug:, installment_id:) row = Client.delete(organization_slug, “/v3/installments/#{installment_id}/payments”)
Array(row["deletedPayments"]).map { |payment| payment["id"] } end end ```
- [ ] Step 4: Rodar e ver passar
bash
cd modules/backend && bin/rails t test/services/asaas_test.rb && bin/rails t && bin/rubocop
Esperado: PASS (13 testes novos).
- [ ] Step 5: Commit
bash
git add modules/backend/app/services modules/backend/test/services/asaas_test.rb
git commit -m "feat: adiciona módulo Asaas para parcelamentos"
Task 5: Use case Debits::Repayment
Files:
- Create: modules/backend/app/use_cases/debits/repayment.rb
- Create: modules/backend/test/use_cases/debits/repayment_test.rb
Interfaces:
- Consumes: Tasks 1 a 4 — PaymentProviderAccount, Negotiation#billing_type/provider_installment_id/provider_payment_id, Debit status awaiting_negotiation_payment, Asaas.find_installment_by_reference, Asaas.create_installment, Asaas.first_payment, Asaas.cancel_open_payments, Asaas::Error.
- Produces: Debits::Repayment.call(debit:, negotiation:, billing_type:) →
- Success(result: { debit:, negotiation: })
- Failure(:invalid_negotiation | :invalid_debit | :invalid_billing_type | :payment_provider_account_not_found)
- Failure(:asaas_error, result: { step:, message: }) com step em :lookup, :create, :cancel
- Failure(:update_failed, result: { debit:, negotiation: })
- [ ] Step 1: Escrever os testes que falham
modules/backend/test/use_cases/debits/repayment_test.rb:
```ruby require “test_helper”
module Debits class RepaymentTest < ActiveSupport::TestCase BASE_URL = “https://api-sandbox.asaas.com”.freeze INSTALLMENTS_URL = “#{BASE_URL}/v3/installments”.freeze PAYMENTS_URL = “#{BASE_URL}/v3/payments”.freeze
setup do
@debit = debits(:joana_pending)
@debit.update!(organization_slug: "trg", provider_customer_id: "cus_1", provider_installment_id: "ins_original")
@negotiation = negotiations(:joana_simulated)
@negotiation.update!(status: :approved)
@reference = "nectar_negotiation_#{@negotiation.id}"
end
def stub_lookup(found: nil)
data = found ? [ { id: "pay_x", installment: found } ] : []
stub_request(:get, PAYMENTS_URL).with(query: hash_including("externalReference" => @reference))
.to_return(status: 200, body: { data: data }.to_json)
end
def stub_create(id: "ins_new")
stub_request(:post, INSTALLMENTS_URL).to_return(status: 200, body: { id: id }.to_json)
end
def stub_first_payment(installment_id: "ins_new")
stub_request(:get, "#{INSTALLMENTS_URL}/#{installment_id}/payments").to_return(
status: 200,
body: { data: [ { id: "pay_2", installmentNumber: 2 }, { id: "pay_1", installmentNumber: 1 } ] }.to_json
)
end
def stub_cancel(installment_id: "ins_original", status: 200)
stub_request(:delete, "#{INSTALLMENTS_URL}/#{installment_id}/payments").to_return(
status: status, body: { deleted: true, id: installment_id, deletedPayments: [ { id: "pay_old" } ] }.to_json
)
end
def call(billing_type: "boleto")
Debits::Repayment.call(debit: @debit, negotiation: @negotiation, billing_type: billing_type)
end
test "creates the installment, cancels the current one and marks the debit as awaiting the payment" do
# arrange
stub_lookup
create = stub_create
stub_first_payment
cancel = stub_cancel
# act
result = call
# assert
assert_predicate result, :success?
assert_requested create
assert_requested cancel
assert_predicate @debit.reload, :awaiting_negotiation_payment?
assert_equal "ins_new", @negotiation.reload.provider_installment_id
assert_equal "pay_1", @negotiation.provider_payment_id
assert_equal "boleto", @negotiation.billing_type
end
test "sends the negotiation terms and the reference to the provider" do
# arrange
stub_lookup
create = stub_request(:post, INSTALLMENTS_URL).with do |req|
body = JSON.parse(req.body)
body["customer"] == "cus_1" && body["billingType"] == "PIX" && body["installmentCount"] == 8 &&
body["totalValue"] == 2000.08 && body["dueDate"] == @negotiation.first_due_on.iso8601 &&
body["paymentExternalReference"] == @reference
end.to_return(status: 200, body: { id: "ins_new" }.to_json)
stub_first_payment
stub_cancel
# act
result = call(billing_type: "pix")
# assert
assert_predicate result, :success?
assert_requested create
end
test "fails with invalid_negotiation when the negotiation is not approved" do
# arrange
@negotiation.update!(status: :simulated)
# act
result = call
# assert
assert_predicate result, :failure?
assert_equal :invalid_negotiation, result.type
assert_not_requested :any, %r{asaas\.com}
end
test "fails with invalid_negotiation when the negotiation is not a repayment" do
# arrange
@negotiation.update_columns(kind: "full_settlement")
# act
result = call
# assert
assert_equal :invalid_negotiation, result.type
end
test "fails with invalid_negotiation when the negotiation belongs to another debit" do
# arrange
@negotiation.update_columns(debit_id: debits(:rafael_negotiated).id)
# act
result = call
# assert
assert_equal :invalid_negotiation, result.type
end
test "fails with invalid_negotiation when the first due date is in the past" do
# arrange
@negotiation.update_columns(first_due_on: 1.day.ago.to_date)
# act
result = call
# assert
assert_equal :invalid_negotiation, result.type
end
test "fails with invalid_debit when the debit is not open" do
# arrange
@debit.update!(status: :awaiting_negotiation_payment)
# act
result = call
# assert
assert_equal :invalid_debit, result.type
end
test "fails with invalid_debit when the debit has no provider installment" do
# arrange
@debit.update!(provider_installment_id: nil)
# act
result = call
# assert
assert_equal :invalid_debit, result.type
end
test "fails with invalid_billing_type for credit card" do
# act
result = call(billing_type: "credit_card")
# assert
assert_equal :invalid_billing_type, result.type
assert_not_requested :any, %r{asaas\.com}
end
test "fails with payment_provider_account_not_found when the organization has no account" do
# arrange
@debit.update!(organization_slug: "unknown")
# act
result = call
# assert
assert_equal :payment_provider_account_not_found, result.type
assert_not_requested :any, %r{asaas\.com}
end
test "leaves everything untouched when the creation fails" do
# arrange
stub_lookup
stub_request(:post, INSTALLMENTS_URL).to_return(status: 500, body: "boom")
# act
result = call
# assert
assert_equal :asaas_error, result.type
assert_equal :create, result[:step]
assert_predicate @debit.reload, :pending?
assert_nil @negotiation.reload.provider_installment_id
end
test "keeps the new installment ids and the debit unchanged when the cancellation fails" do
# arrange
stub_lookup
stub_create
stub_first_payment
stub_cancel(status: 502)
# act
result = call
# assert
assert_equal :asaas_error, result.type
assert_equal :cancel, result[:step]
assert_predicate @debit.reload, :pending?
assert_equal "ins_new", @negotiation.reload.provider_installment_id
end
test "on retry only repeats the cancellation" do
# arrange
@negotiation.update!(provider_installment_id: "ins_new", provider_payment_id: "pay_1", billing_type: "boleto")
create = stub_create
cancel = stub_cancel
# act
result = call
# assert
assert_predicate result, :success?
assert_not_requested create
assert_requested cancel
assert_predicate @debit.reload, :awaiting_negotiation_payment?
end
test "reuses an installment that already exists for the reference" do
# arrange
stub_lookup(found: "ins_existing")
create = stub_create
stub_first_payment(installment_id: "ins_existing")
stub_cancel
# act
result = call
# assert
assert_predicate result, :success?
assert_not_requested create
assert_equal "ins_existing", @negotiation.reload.provider_installment_id
end
test "on a second repayment cancels the installment of the previous agreement" do
# arrange
@debit.negotiations.create!(
user: users(:attendant), kind: :repayment_first, status: :cancelled, proposed_total_cents: 100_000,
installments_count: 4, first_due_on: 10.days.ago.to_date, provider_installment_id: "ins_previous"
)
stub_lookup
stub_create
stub_first_payment
previous = stub_cancel(installment_id: "ins_previous")
original = stub_cancel(installment_id: "ins_original")
# act
result = call
# assert
assert_predicate result, :success?
assert_requested previous
assert_not_requested original
end end end ```
- [ ] Step 2: Rodar e ver falhar
bash
cd modules/backend && bin/rails t test/use_cases/debits/repayment_test.rb
Esperado: FAIL — NameError: uninitialized constant Debits::Repayment.
- [ ] Step 3: Implementação mínima
modules/backend/app/use_cases/debits/repayment.rb:
```ruby module Debits # Executes an approved repayment negotiation at the payment provider: creates the new installment, cancels the # current one and marks the debit as awaiting the agreement’s first payment. Marking it negotiated is the # webhook’s job (R-006, RN-STATUS-1). class Repayment < Micro::Case REPAYMENT_KINDS = %w[repayment_first repayment_second].freeze BILLING_TYPES = %w[boleto pix].freeze REFERENCE_PREFIX = “nectar_negotiation_“.freeze
attribute :debit, validates: { presence: true }
attribute :negotiation, validates: { presence: true }
attribute :billing_type, validates: { presence: true }
def call!
return Failure(:invalid_negotiation) unless valid_negotiation?
return Failure(:invalid_debit) unless valid_debit?
return Failure(:invalid_billing_type) unless BILLING_TYPES.include?(billing_type.to_s)
return Failure(:payment_provider_account_not_found) unless account_exists?
provision_installment
cancel_current_installment
return Failure(:update_failed, result: outcome) unless debit.update(status: :awaiting_negotiation_payment)
Success(result: outcome)
rescue Asaas::Error => e
Failure(:asaas_error, result: { step: @step, message: e.message })
rescue ActiveRecord::RecordInvalid
Failure(:update_failed, result: outcome)
end
private
def outcome = { debit: debit, negotiation: negotiation }
def slug = debit.organization_slug
def reference = "#{REFERENCE_PREFIX}#{negotiation.id}"
def valid_negotiation?
negotiation.debit_id == debit.id && negotiation.approved? &&
REPAYMENT_KINDS.include?(negotiation.kind.to_s) && due_date_acceptable?
end
# A retry after the installment exists only needs to cancel, so an expired first due date no longer matters.
def due_date_acceptable?
negotiation.provider_installment_id.present? || negotiation.first_due_on >= Date.current
end
def valid_debit?
Debit::OPEN_STATUSES.include?(debit.status.to_s) && debit.provider_customer_id.present? &&
debit.provider_installment_id.present? && slug.present?
end
def account_exists? = PaymentProviderAccount.exists?(slug: slug)
def provision_installment
return if negotiation.provider_installment_id.present?
installment = find_or_create_installment
@step = :lookup
payment = Asaas.first_payment(organization_slug: slug, installment_id: installment.id)
negotiation.update!(provider_installment_id: installment.id, provider_payment_id: payment.id,
billing_type: billing_type)
end
def find_or_create_installment
@step = :lookup
existing = Asaas.find_installment_by_reference(organization_slug: slug, reference: reference)
return existing if existing
@step = :create
Asaas.create_installment(
organization_slug: slug, customer_id: debit.provider_customer_id, billing_type: billing_type,
total_cents: negotiation.proposed_total_cents, installments_count: negotiation.installments_count,
first_due_on: negotiation.first_due_on, reference: reference, description: description
)
end
def cancel_current_installment
@step = :cancel
Asaas.cancel_open_payments(organization_slug: slug, installment_id: current_installment_id)
end
# The installment currently collecting the debt: the latest agreement's, when there is one, else the original.
def current_installment_id
previous = debit.negotiations.where.not(id: negotiation.id).where.not(provider_installment_id: nil)
.order(:id).last
previous&.provider_installment_id || debit.provider_installment_id
end
def description = [ "Reparcelamento", debit.product_names.presence ].compact.join(" - ") end end ```
- [ ] Step 4: Rodar e ver passar
bash
cd modules/backend && bin/rails t test/use_cases/debits/repayment_test.rb && bin/rails t && bin/rubocop
Esperado: PASS (15 testes novos; suíte completa verde; rubocop limpo).
- [ ] Step 5: Commit
bash
git add modules/backend/app/use_cases/debits/repayment.rb modules/backend/test/use_cases/debits/repayment_test.rb
git commit -m "feat: adiciona use case Debits::Repayment"
Task 6: Documentação
Files:
- Modify: .project/docs/rules/collections/case_status_marking.md
- Modify: .project/docs/rules/negotiation/installment_renegotiation.md
- Modify: .project/docs/RULES.md
- Create: .project/docs/learnings/checkout_ignores_negotiation_payments.md
- Modify: .project/docs/README.md
Interfaces: - Consumes: o comportamento entregue nas Tasks 1 a 5. - Produces: regras e learning atualizados, índice completo.
- [ ] Step 1: R-006 — novo estado e transições
Em case_status_marking.md: atualizar updated: para 2026-09-29; na tabela Regras acrescentar a linha
markdown
| `RN-STATUS-4` | `AGUARDANDO_PAGAMENTO_NEGOCIACAO` é marcado quando o reparcelamento é criado no Asaas (`Debits::Repayment`). Dele só se sai por webhook: `NEGOCIADO` (1ª parcela do acordo paga) ou de volta a `EM_COBRANCA` (1ª parcela vencida sem pagamento). Os dois webhooks ainda não existem |
e na Máquina de estados acrescentar, antes do SEM_PREVISAO --> EM_COBRANCA:
EM_COBRANCA --> AGUARDANDO_PAGAMENTO_NEGOCIACAO: reparcelamento criado<br/>no Asaas (Debits::Repayment)
AGUARDANDO_PAGAMENTO_NEGOCIACAO --> NEGOCIADO: 1ª parcela do acordo paga<br/>(via webhook — pendente)
AGUARDANDO_PAGAMENTO_NEGOCIACAO --> EM_COBRANCA: 1ª parcela vencida<br/>(via webhook — pendente)
Na seção Restrições acrescentar: “O reparcelamento cancela no Asaas o parcelamento vigente na hora em que é criado. Se o acordo não for pago e o Debit voltar a EM_COBRANCA, ele fica sem cobrança ativa no Asaas até um novo reparcelamento.”
- [ ] Step 2: R-001 — teste vinculado
Em installment_renegotiation.md: atualizar updated: e trocar o texto de Teste vinculado por:
markdown
`test/use_cases/debits/repayment_test.rb` cobre a execução do acordo aprovado no Asaas. As regras
`RN-REPARC-1` a `RN-REPARC-6` seguem sem teste: a aprovação do `Negotiation` (motor de negociação,
[USER-015](../../features/USER-015-backend_negotiation_engine.md)) ainda não foi implementada.
- [ ] Step 3:
RULES.md
Trocar RN-STATUS-1 a RN-STATUS-3 por RN-STATUS-1 a RN-STATUS-4 (linha do mapa de IDs de R-006).
- [ ] Step 4: Learning
Criar .project/docs/learnings/checkout_ignores_negotiation_payments.md:
```markdown
title: O checkout-api ignora as cobranças do acordo criadas pelo nectar-charges scope: backend date: 2026-09-29 certainty: medium —
O checkout-api ignora as cobranças do acordo criadas pelo nectar-charges
TLDR: o webhook do Asaas no checkout-api só reconhece cobranças cujo
externalReferenceé oreferencede umPaymentdele. As cobranças do reparcelamento criadas pelo nectar-charges caem em “Payment not found” e ninguém devolve o acesso do aluno quando ele paga o acordo.
O que aconteceu
Ao analisar o reparcelamento manual do checkout-api para desenhar o Debits::Repayment, apareceram
dois efeitos que o novo fluxo herda.
O que vale saber
Payments::FindByExternalReferencefazPayment.find_by!(reference:)e, se não acha, falha em silêncio (context.fail!, sem exceção e sem retry). As cobranças do acordo geram só uma linha de log lá. Por isso oexternalReferencedo acordo usa o prefixonectar_negotiation_.- No reparcelamento manual, o pagamento novo tem
original_payment, e é isso que faz oApoloServiceconceder acesso quando o aluno paga. No fluxo do nectar-charges esse vínculo não existe: se o aluno perdeu o acesso por atraso, pagar o acordo não o devolve. - O cancelamento do parcelamento original dispara
PAYMENT_DELETEDno checkout-api, que marca o pagamento original como cancelado. NoApoloServiceum pagamento cancelado não concede nem remove acesso.CbtrgServiceeOnionServicenão foram lidos.
O que fazer
Resolver junto do spec de webhooks do nectar-charges (negotiated e volta para pending): definir quem
pede a devolução de acesso ao Apolo quando a 1ª parcela do acordo é paga.
```
- [ ] Step 5: Índice
README.md
Na tabela de ## learnings/ acrescentar (a linha do plano já foi indexada na criação dele):
markdown
| [checkout_ignores_negotiation_payments.md](learnings/checkout_ignores_negotiation_payments.md) | O checkout-api ignora as cobranças do acordo criadas pelo nectar-charges e ninguém devolve o acesso do aluno quando ele paga o acordo | medium |
- [ ] Step 6: Verificar e commitar
bash
git diff --stat .project/docs
git add .project/docs
git commit -m "docs: regras e learning do Debits::Repayment"
Verificação final
cd modules/backend && bin/rails t— toda a suíte verde (422 originais + os novos).cd modules/backend && bin/rubocop— sem ofensas.- Sandbox do Asaas (manual, fora do CI): com um
PaymentProviderAccountde teste eASAAS_URLno sandbox, rodarDebits::Repayment.call(...)no console e conferir no painel o parcelamento novo, o cancelamento do anterior e, em especial, quepaymentExternalReferenceaparece comoexternalReferencedas cobranças (a idempotência porfind_installment_by_referencedepende disso e a doc do Asaas não o afirma). - Operacional: cadastrar
ACTIVE_RECORD_ENCRYPTION_*eASAAS_URLem cada ambiente (ward) e criar as linhas depayment_provider_accountspor organization antes de usar o use case.
Cobertura do spec
| Requisito do spec | Task |
|---|---|
PaymentProviderAccount com token criptografado e chaves por ENV |
1 |
Colunas billing_type, provider_installment_id, provider_payment_id no Negotiation |
2 |
Status awaiting_negotiation_payment fora de OPEN_STATUSES e ACTIVE_STATUSES |
3 |
Módulo Asaas (client, erros, ASAAS_URL com fallback no sandbox) |
4 |
| Guardas, criação idempotente, cancelamento do vigente, retry e 2º reparcelamento | 5 |
| Atualização de R-006, R-001, learning e índices | 6 |