Use case Debits::Repayment — Plano de implementação

TLDR: seis tasks em TDD: PaymentProviderAccount (token criptografado), colunas do Negotiation, status do Debit, módulo Asaas, o use case Debits::Repayment e a documentação.

Spec: .project/docs/specs/20260929021525_debit_repayment_use_case.md Branch: 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 (nunca make). 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 no setup.
  • 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 o db/schema.rb gerado.
  • 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 é o reference de um Payment dele. 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::FindByExternalReference faz Payment.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 o externalReference do acordo usa o prefixo nectar_negotiation_.
  • No reparcelamento manual, o pagamento novo tem original_payment, e é isso que faz o ApoloService conceder 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_DELETED no checkout-api, que marca o pagamento original como cancelado. No ApoloService um pagamento cancelado não concede nem remove acesso. CbtrgService e OnionService nã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 PaymentProviderAccount de teste e ASAAS_URL no sandbox, rodar Debits::Repayment.call(...) no console e conferir no painel o parcelamento novo, o cancelamento do anterior e, em especial, que paymentExternalReference aparece como externalReference das cobranças (a idempotência por find_installment_by_reference depende disso e a doc do Asaas não o afirma).
  • Operacional: cadastrar ACTIVE_RECORD_ENCRYPTION_* e ASAAS_URL em cada ambiente (ward) e criar as linhas de payment_provider_accounts por 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