Endpoint de débitos por cliente — Plano de implementação
TLDR: cria
interest_cents/discount_centseminstallmentse expõeGET /api/v1/customers/:customer_id/charges/com os débitos do cliente, parcelas, totais e produtos.
Spec:
.project/docs/specs/20260924135144_customer_debits_endpoint.mdBranch: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](hojenegotiated). 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>(omake backend.run.testfalha 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"