Endpoint de débitos por cliente — Plano de implementação

TLDR: cria interest_cents/discount_cents em installments e expõe GET /api/v1/customers/:customer_id/charges/ com os débitos do cliente, parcelas, totais e produtos.

Spec: .project/docs/specs/20260924135144_customer_debits_endpoint.md Branch: styles/tab-negociation

Arquitetura: migration adiciona as duas colunas com default 0; um controller aninhado em Api::V1::Customers busca o cliente, carrega os débitos com parcelas e produtos, ordena não pagos primeiro e serializa com um serializer dedicado (CustomerDebitSerializer), sem paginação.

Stack: Rails 8.1, PostgreSQL 17, ActiveModel::Serializers, Minitest com fixtures, annotaterb.

Restrições globais

  • Controller se chama Customers::ChargesController; model, associações e serializer mantêm o nome atual do domínio (Debit, customer.debits, CustomerDebitSerializer).
  • Todo usuário autenticado vê todos os débitos do cliente (sem filtro por responsável).
  • “Pago” = Debit::STATUS_MAP[:paid] (hoje negotiated).
  • current_amount_cents = payable_amount_cents; juros e desconto não entram nesse cálculo.
  • Testes rodam no container já de pé: docker exec nectar-charges-backend-api-1 bin/rails test <path> (o make backend.run.test falha com a porta 4012 ocupada pelo container em execução).
  • Baseline antes do plano: 214 runs, 0 failures.

Task 1: Colunas de juros e desconto em installments

Files: - Create: modules/backend/db/migrate/20260924160000_add_interest_and_discount_to_installments.rb - Modify: modules/backend/app/models/installment.rb - Modify: modules/backend/db/schema.rb (gerado) - Modify: anotações em app/models/installment.rb, test/models/installment_test.rb, test/fixtures/installments.yml (geradas) - Test: modules/backend/test/models/installment_test.rb

Interfaces: - Produces: Installment#interest_cents e Installment#discount_cents (Integer, default 0, >= 0).

  • [ ] Step 1: Write the failing tests

Adicionar ao fim de InstallmentTest, antes do end final:

```ruby test “defaults interest and discount to zero” do # arrange customer = Customer.create!(name: “Zuleica Prado”, email: “zuleica.prado@example.com”, phone: “11922220000”) debit = Debit.create!(customer: customer, opened_at: Time.current)

# act
installment = debit.installments.create!(number: 1, amount_cents: 25_001, due_on: Date.current)

# assert
assert_equal 0, installment.interest_cents
assert_equal 0, installment.discount_cents   end

test “rejects negative interest” do # arrange customer = Customer.create!(name: “Wagner Lemos”, email: “wagner.lemos@example.com”, phone: “11911110000”) debit = Debit.create!(customer: customer, opened_at: Time.current) installment = debit.installments.build(number: 1, amount_cents: 25_001, due_on: Date.current, interest_cents: -1)

# act
valid = installment.valid?

# assert
assert_not valid
assert_includes installment.errors[:interest_cents], "must be greater than or equal to 0"   end

test “rejects negative discount” do # arrange customer = Customer.create!(name: “Vilma Rocha”, email: “vilma.rocha@example.com”, phone: “11900000000”) debit = Debit.create!(customer: customer, opened_at: Time.current) installment = debit.installments.build(number: 1, amount_cents: 25_001, due_on: Date.current, discount_cents: -1)

# act
valid = installment.valid?

# assert
assert_not valid
assert_includes installment.errors[:discount_cents], "must be greater than or equal to 0"   end ```
  • [ ] Step 2: Run to verify it fails

bash docker exec nectar-charges-backend-api-1 bin/rails test test/models/installment_test.rb

Expected: FAIL — NoMethodError: undefined method 'interest_cents' / ActiveModel::UnknownAttributeError: unknown attribute 'interest_cents'

  • [ ] Step 3: Write minimal implementation

db/migrate/20260924160000_add_interest_and_discount_to_installments.rb:

ruby class AddInterestAndDiscountToInstallments < ActiveRecord::Migration[8.1] def change add_column :installments, :interest_cents, :integer, default: 0, null: false add_column :installments, :discount_cents, :integer, default: 0, null: false end end

app/models/installment.rb, logo após a validação de updated_amount_cents:

ruby validates :interest_cents, :discount_cents, numericality: { only_integer: true, greater_than_or_equal_to: 0 }

Rodar a migration e regenerar as anotações:

bash docker exec nectar-charges-backend-api-1 bin/rails db:migrate docker exec nectar-charges-backend-api-1 bundle exec annotaterb models

Conferir que db/schema.rb tem t.integer "interest_cents", default: 0, null: false e t.integer "discount_cents", default: 0, null: false em installments, e que os três blocos de anotação ganharam:

# discount_cents :integer default(0), not null # interest_cents :integer default(0), not null

  • [ ] Step 4: Run to verify it passes

bash docker exec nectar-charges-backend-api-1 bin/rails test test/models/installment_test.rb

Expected: PASS

  • [ ] Step 5: Commit

bash git add modules/backend/db/migrate/20260924160000_add_interest_and_discount_to_installments.rb modules/backend/db/schema.rb modules/backend/app/models/installment.rb modules/backend/test/models/installment_test.rb modules/backend/test/fixtures/installments.yml git commit -m "feat: add interest and discount to installments"


Task 2: GET /api/v1/customers/:customer_id/charges/

Files: - Modify: modules/backend/config/routes.rb - Create: modules/backend/app/controllers/api/v1/customers/charges_controller.rb - Create: modules/backend/app/serializers/customer_debit_serializer.rb - Test: modules/backend/test/controllers/api/v1/customers/charges_controller_test.rb

Interfaces: - Consumes: Installment#interest_cents, Installment#discount_cents (Task 1); Installment#payable_amount_cents; Debit::STATUS_MAP. - Produces: Api::V1::Customers::ChargesController#index; CustomerDebitSerializer.

  • [ ] Step 1: Write the failing tests

test/controllers/api/v1/customers/charges_controller_test.rb:

```ruby require “test_helper”

module Api module V1 module Customers class ChargesControllerTest < ActionDispatch::IntegrationTest setup do @admin_token = token_for(users(:admin)) @attendant_token = token_for(users(:attendant)) end

    test "lists the customer's debits with totals and products" do
      get "/api/v1/customers/#{customers(:joana).id}/charges", headers: bearer_header(@admin_token)

      assert_response :ok
      data = JSON.parse(response.body)["data"]
      assert_equal [ debits(:joana_pending).id ], data.map { |c| c["id"] }

      charge = data.first
      assert_equal "pending",         charge["status"]
      assert_equal "repayment_first", charge["kind"]
      assert_equal 2,                 charge["installments_count"]
      assert_equal 50_002,            charge["total_cents"]
      assert_equal 52_351,            charge["total_payable_cents"]
      assert_equal [ { "name" => "Pos em Ciencia de Dados", "external_id" => "pos-ciencia-de-dados" } ],
                   charge["products"]
    end

    test "installments come ordered by number with current amount, interest and discount" do
      installments(:joana_overdue).update!(interest_cents: 1_200, discount_cents: 300)

      get "/api/v1/customers/#{customers(:joana).id}/charges", headers: bearer_header(@admin_token)

      items = JSON.parse(response.body)["data"].first["installments"]
      assert_equal [ 1, 2 ], items.map { |i| i["number"] }

      overdue, upcoming = items
      assert_equal 25_001,                                      overdue["amount_cents"]
      assert_equal installments(:joana_overdue).due_on.iso8601, overdue["due_on"]
      assert_equal 1_200,                                       overdue["interest_cents"]
      assert_equal 300,                                         overdue["discount_cents"]
      assert_equal 27_350,                                      overdue["current_amount_cents"]
      assert_equal "overdue",                                   overdue["status"]
      assert_nil                                                overdue["paid_at"]

      assert_equal 25_001, upcoming["current_amount_cents"]
      assert_equal 0,      upcoming["interest_cents"]
      assert_equal 0,      upcoming["discount_cents"]
    end

    test "paid installment exposes the payment date" do
      get "/api/v1/customers/#{customers(:rafael).id}/charges", headers: bearer_header(@admin_token)

      item = JSON.parse(response.body)["data"].first["installments"].first
      assert_equal "paid",                                         item["status"]
      assert_equal installments(:rafael_paid).paid_at.iso8601,     item["paid_at"]
    end

    test "unpaid debits come before paid ones" do
      paid = customers(:joana).debits.create!(status: :negotiated, opened_at: 100.days.ago,
                                              created_at: 100.days.ago)

      get "/api/v1/customers/#{customers(:joana).id}/charges", headers: bearer_header(@admin_token)

      ids = JSON.parse(response.body)["data"].map { |c| c["id"] }
      assert_equal [ debits(:joana_pending).id, paid.id ], ids
    end

    test "attendant sees every debit of the customer, even unassigned ones" do
      get "/api/v1/customers/#{customers(:instituto_lumen).id}/charges",
          headers: bearer_header(@attendant_token)

      assert_response :ok
      ids = JSON.parse(response.body)["data"].map { |c| c["id"] }
      assert_equal [ debits(:lumen_no_response).id ], ids
    end

    test "debits from other customers are not listed" do
      get "/api/v1/customers/#{customers(:joana).id}/charges", headers: bearer_header(@admin_token)

      ids = JSON.parse(response.body)["data"].map { |c| c["id"] }
      assert_not_includes ids, debits(:rafael_negotiated).id
      assert_not_includes ids, debits(:lumen_no_response).id
    end

    test "customer without debits returns an empty list" do
      customer = Customer.create!(name: "Ulisses Mota", email: "ulisses.mota@example.com",
                                  phone: "11955550000")

      get "/api/v1/customers/#{customer.id}/charges", headers: bearer_header(@admin_token)

      assert_response :ok
      assert_empty JSON.parse(response.body)["data"]
    end

    test "unknown customer returns not found" do
      get "/api/v1/customers/0/charges", headers: bearer_header(@admin_token)

      assert_response :not_found
    end

    test "unauthenticated request is rejected" do
      get "/api/v1/customers/#{customers(:joana).id}/charges"

      assert_response :unauthorized
    end
  end
end   end end ```
  • [ ] Step 2: Run to verify it fails

bash docker exec nectar-charges-backend-api-1 bin/rails test test/controllers/api/v1/customers/charges_controller_test.rb

Expected: FAIL — ActionController::RoutingError: No route matches [GET] "/api/v1/customers/.../charges"

  • [ ] Step 3: Write minimal implementation

config/routes.rb, dentro de namespace :v1, logo após resources :contracts:

ruby get "customers/:customer_id/charges/" => "customers/charges#index"

app/controllers/api/v1/customers/charges_controller.rb:

```ruby module Api module V1 module Customers class ChargesController < BaseController def index render json: { data: ActiveModelSerializers::SerializableResource.new(debits, each_serializer: CustomerDebitSerializer) }, status: :ok end

    private

    def customer
      @customer ||= Customer.find(params[:customer_id])
    end

    def debits
      @debits ||= customer.debits
        .includes(:installments, :product_debits)
        .order(Arel.sql(unpaid_first), :created_at)
    end

    def unpaid_first
      Debit.sanitize_sql_array([ "CASE WHEN debits.status IN (?) THEN 1 ELSE 0 END", Debit::STATUS_MAP[:paid] ])
    end
  end
end   end end ```

app/serializers/customer_debit_serializer.rb:

```ruby class CustomerDebitSerializer < ActiveModel::Serializer attributes :id, :status, :kind, :installments_count, :total_cents, :total_payable_cents, :products, :installments

def status = object.status.to_s

def kind = object.payment_type.to_s

def installments_count = loaded_installments.size

def total_cents = loaded_installments.sum(&:amount_cents)

def total_payable_cents = loaded_installments.sum(&:payable_amount_cents)

def products object.product_debits.map { |pd| { name: pd.product_name, external_id: pd.external_id } } end

def installments loaded_installments.sort_by(&:number).map do |installment| { number: installment.number, amount_cents: installment.amount_cents, due_on: installment.due_on.iso8601, interest_cents: installment.interest_cents, discount_cents: installment.discount_cents, current_amount_cents: installment.payable_amount_cents, status: installment.status.to_s, paid_at: installment.paid_at&.iso8601 } end end

private

def loaded_installments @loaded_installments ||= object.installments.to_a end end ```

  • [ ] Step 4: Run to verify it passes

bash docker exec nectar-charges-backend-api-1 bin/rails test test/controllers/api/v1/customers/charges_controller_test.rb

Expected: PASS

  • [ ] Step 5: Commit

bash git add modules/backend/config/routes.rb modules/backend/app/controllers/api/v1/customers/charges_controller.rb modules/backend/app/serializers/customer_debit_serializer.rb modules/backend/test/controllers/api/v1/customers/charges_controller_test.rb git commit -m "feat: list customer charges endpoint"


Task 3: Seeds com juros nas parcelas atrasadas

Files: - Modify: modules/backend/db/seeds.rb

Interfaces: - Consumes: Installment#interest_cents (Task 1).

  • [ ] Step 1: Alterar o seed

Em db/seeds.rb, no bloco debit.installments.create! (parcelas overdue), adicionar interest_cents:

ruby rand(1..installments_count).times do |i| debit.installments.create!( number: i + 1, amount_cents: installment_amount_cents, interest_cents: rand(100..(installment_amount_cents / 10)), due_on: rand(5..90).days.ago.to_date, status: :overdue ) end

installment_amount_cents é no mínimo 5_000, então o intervalo é sempre válido (100..500 no menor caso). discount_cents fica no default 0.

  • [ ] Step 2: Rodar o seed

bash docker exec nectar-charges-backend-api-1 bin/rails db:seed:replant

Expected: termina sem erro, e docker exec nectar-charges-backend-api-1 bin/rails runner 'puts Installment.where("interest_cents > 0").count' imprime um número maior que zero.

  • [ ] Step 3: Commit

bash git add modules/backend/db/seeds.rb git commit -m "chore: seed interest on overdue installments"


Task 4: Verificação final e documentação

Files: - Modify: .project/docs/README.md - Modify: .project/docs/specs/20260924135144_customer_debits_endpoint.md (status: proposed → done)

  • [ ] Step 1: Suíte completa

bash docker exec nectar-charges-backend-api-1 bin/rails test

Expected: PASS — 214 runs da baseline + 12 novos, 0 failures.

  • [ ] Step 2: Chamada real

Autenticado como admin, GET http://localhost:4012/api/v1/customers/<id de um cliente do seed>/charges/ devolve data com débitos não pagos primeiro e parcelas com interest_cents, discount_cents e current_amount_cents.

  • [ ] Step 3: Índice

Adicionar a spec em specs/ e este plano em plans/ no .project/docs/README.md, e marcar a spec como done.

  • [ ] Step 4: Commit

bash git add .project/docs/README.md .project/docs/specs/20260924135144_customer_debits_endpoint.md git commit -m "docs: index customer charges spec and plan"