Endpoints de clientes — Plano de implementação
TLDR: cria os use cases
Customers::Overview(resumo agregado no banco) eCustomers::List(listagem paginada com busca), e expõeGET /api/v1/customers/:ideGET /api/v1/customers.
Spec:
.project/docs/specs/20260925084434_customer_overview_endpoint.mdBranch:feat/CustomerSummary
Arquitetura: o use case busca o cliente e roda duas queries sobre as parcelas dos débitos em andamento: GROUP BY installments.status com COUNT/SUM(amount_cents), e a menor due_on das upcoming com a soma do valor nessa data. O controller só chama o use case e renderiza o CustomerOverviewSerializer, que recebe o summary por instance_options.
A listagem pagina customers com Kaminari e calcula os campos da página com 3 agregações sobre os ids da página: totais de parcelas, status dos débitos e nomes dos produtos. São 4 queries por página, qualquer que seja o tamanho dela. O CustomerListItemSerializer lê os campos calculados por instance_options[:details].
Stack: Rails 8.1, PostgreSQL 17, u-case (Micro::Case), ActiveModel::Serializers (adapter :attributes), Minitest com fixtures.
Restrições globais
- Valor usado no resumo: sempre
installments.amount_cents(original). Nuncapayable_amount_cents. - Débitos considerados:
pending,no_forecast,no_response,bureau_report,negativated. overdue+defaulted= vencidas.- Nenhuma parcela é carregada em memória: só
pluck. - Próx. vencimento: menor
due_ondasupcoming, somando as que vencem nessa data;nilse não houver. - Listagem:
financial_statusna ordemnegativated→overdue→cancelled(todos os débitos cancelados) →up_to_date.productsde todos os débitos, sem repetir, em ordem alfabética. Sem coluna de responsável. README.mdnão entra no plano: a spec e a regra não são indexadas lá.- Qualquer usuário autenticado acessa (sem filtro por responsável).
- Testes rodam no container:
docker exec nectar-charges-backend-api-1 bin/rails test <path>. - Lint:
docker exec nectar-charges-backend-api-1 bin/rubocop <paths>. - Baseline antes das Tasks 4–6: 252 runs, 0 failures (Tasks 1–3 já feitas).
- Commits são feitos pelo usuário; os passos de commit abaixo indicam só o ponto e a mensagem.
Mapa de arquivos
| Arquivo | Responsabilidade |
|---|---|
modules/backend/app/use_cases/customers/overview.rb (novo) |
Busca o cliente e calcula o summary |
modules/backend/app/serializers/customer_overview_serializer.rb (novo) |
JSON: dados pessoais, address aninhado, summary |
modules/backend/app/controllers/api/v1/customers_controller.rb (novo) |
show: chama o use case e renderiza |
modules/backend/app/models/customer.rb |
Customer.search(scope, params) (Task 7) |
modules/backend/app/use_cases/customers/list.rb (novo) |
Busca, pagina e calcula os campos da listagem |
modules/backend/app/serializers/customer_list_item_serializer.rb (novo) |
JSON de um item da listagem |
modules/backend/config/routes.rb |
resources :customers, only: [ :index, :show ] |
modules/backend/test/use_cases/customers/overview_test.rb (novo) |
Regras do resumo |
modules/backend/test/use_cases/customers/list_test.rb (novo) |
Regras da listagem |
modules/backend/test/controllers/api/v1/customers_controller_test.rb (novo) |
Respostas HTTP: 200, 404, 401 |
.project/docs/rules/collections/customer_overview_summary.md (novo) |
Regra de negócio do resumo |
Tasks 1–7 estão feitas. Task 5 depende da Task 4, e a Task 7 revisa as duas.
Task 1: Use case Customers::Overview (feita)
Files:
- Create: modules/backend/app/use_cases/customers/overview.rb
- Test: modules/backend/test/use_cases/customers/overview_test.rb
Interfaces:
- Consumes: Customer, Installment, Debit (existentes); fixtures customers(:joana|:rafael|:instituto_lumen), debits(:joana_pending), installments(:joana_overdue|:joana_upcoming).
- Produces: Customers::Overview.call(customer_id:) → Success com result[:customer] (Customer) e result[:summary]:
ruby
{
total_debt_amount_cents: Integer, unpaid_debt_amount_cents: Integer, installments_count: Integer,
overdue: { count: Integer, amount_cents: Integer },
paid: { count: Integer, amount_cents: Integer },
upcoming: { count: Integer, amount_cents: Integer },
next_due_installment: { due_on: Date, amount_cents: Integer } | nil
}
Levanta ActiveRecord::RecordNotFound para cliente inexistente.
- [x] Step 1: Write the failing tests
modules/backend/test/use_cases/customers/overview_test.rb:
```ruby require “test_helper”
module Customers class OverviewTest < ActiveSupport::TestCase test “summarizes active debits by original amount” do result = Overview.call(customer_id: customers(:joana).id)
assert_predicate result, :success?
assert_equal customers(:joana), result[:customer]
assert_equal(
{
total_debt_amount_cents: 50_002,
unpaid_debt_amount_cents: 50_002,
installments_count: 2,
overdue: { count: 1, amount_cents: 25_001 },
paid: { count: 0, amount_cents: 0 },
upcoming: { count: 1, amount_cents: 25_001 },
next_due_installment: { due_on: 15.days.from_now.to_date, amount_cents: 25_001 }
},
result[:summary]
)
end
test "counts defaulted installments as overdue and paid ones as paid" do
debit = debits(:joana_pending)
debit.installments.create!(number: 3, amount_cents: 10_000, due_on: 60.days.ago.to_date,
status: :defaulted)
debit.installments.create!(number: 4, amount_cents: 20_000, due_on: 50.days.ago.to_date,
status: :paid, paid_at: 50.days.ago)
summary = Overview.call(customer_id: customers(:joana).id)[:summary]
assert_equal({ count: 2, amount_cents: 35_001 }, summary[:overdue])
assert_equal({ count: 1, amount_cents: 20_000 }, summary[:paid])
assert_equal 80_002, summary[:total_debt_amount_cents]
assert_equal 60_002, summary[:unpaid_debt_amount_cents]
assert_equal 4, summary[:installments_count]
end
test "next due installment is the earliest upcoming, summing the ones due that day" do
other = customers(:joana).debits.create!(status: :pending, opened_at: 10.days.ago)
other.installments.create!(number: 1, amount_cents: 10_000, due_on: 15.days.from_now.to_date,
status: :upcoming)
other.installments.create!(number: 2, amount_cents: 7_000, due_on: 45.days.from_now.to_date,
status: :upcoming)
summary = Overview.call(customer_id: customers(:joana).id)[:summary]
assert_equal({ due_on: 15.days.from_now.to_date, amount_cents: 35_001 }, summary[:next_due_installment])
end
test "ignores installments of cancelled and negotiated debits" do
cancelled = customers(:joana).debits.create!(status: :cancelled, opened_at: 10.days.ago)
cancelled.installments.create!(number: 1, amount_cents: 99_999, due_on: 5.days.ago.to_date,
status: :overdue)
joana = Overview.call(customer_id: customers(:joana).id)[:summary]
rafael = Overview.call(customer_id: customers(:rafael).id)[:summary]
assert_equal 50_002, joana[:total_debt_amount_cents]
assert_equal 0, rafael[:paid][:count]
assert_equal 0, rafael[:total_debt_amount_cents]
assert_nil rafael[:next_due_installment]
end
test "customer without installments returns zeros" do
summary = Overview.call(customer_id: customers(:instituto_lumen).id)[:summary]
assert_equal(
{
total_debt_amount_cents: 0,
unpaid_debt_amount_cents: 0,
installments_count: 0,
overdue: { count: 0, amount_cents: 0 },
paid: { count: 0, amount_cents: 0 },
upcoming: { count: 0, amount_cents: 0 },
next_due_installment: nil
},
summary
)
end
test "raises not found for an unknown customer" do
assert_raises(ActiveRecord::RecordNotFound) do
Overview.call(customer_id: 0)
end
end end end ```
Observação: joana_overdue tem updated_amount_cents: 27350; o primeiro teste só passa se o resumo usar amount_cents (25001).
- [x] Step 2: Run to verify it fails
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/use_cases/customers/overview_test.rb
Expected: FAIL — NameError: uninitialized constant Customers::OverviewTest::Overview
- [x] Step 3: Write minimal implementation
modules/backend/app/use_cases/customers/overview.rb:
```ruby module Customers class Overview < Micro::Case ACTIVE_DEBIT_STATUSES = %w[pending no_forecast no_response bureau_report negativated].freeze OVERDUE_STATUSES = %w[overdue defaulted].freeze
attribute :customer_id
def call!
customer = Customer.find(customer_id)
installments = Installment.joins(:debit)
.where(debits: { customer_id: customer.id, status: ACTIVE_DEBIT_STATUSES })
Success(result: { customer: customer, summary: summarize(installments) })
end
private
def summarize(installments)
totals = totals_by_status(installments)
overdue = bucket(totals, OVERDUE_STATUSES)
paid = bucket(totals, %w[paid])
upcoming = bucket(totals, %w[upcoming])
{
total_debt_amount_cents: overdue[:amount_cents] + paid[:amount_cents] + upcoming[:amount_cents],
unpaid_debt_amount_cents: overdue[:amount_cents] + upcoming[:amount_cents],
installments_count: overdue[:count] + paid[:count] + upcoming[:count],
overdue: overdue,
paid: paid,
upcoming: upcoming,
next_due_installment: next_due_installment(installments)
}
end
def next_due_installment(installments)
due_on, amount_cents = installments.where(status: :upcoming)
.group("installments.due_on").order("installments.due_on").limit(1)
.pluck(Arel.sql("installments.due_on"), Arel.sql("SUM(installments.amount_cents)"))
.first
due_on && { due_on: due_on, amount_cents: amount_cents }
end
def totals_by_status(installments)
installments
.group("installments.status")
.pluck(Arel.sql("installments.status"), Arel.sql("COUNT(*)"), Arel.sql("SUM(installments.amount_cents)"))
.to_h { |status, count, amount_cents| [ status, { count: count, amount_cents: amount_cents } ] }
end
def bucket(totals, statuses)
rows = totals.values_at(*statuses).compact
{ count: rows.sum { _1[:count] }, amount_cents: rows.sum { _1[:amount_cents] } }
end end end ```
- [x] Step 4: Run to verify it passes
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/use_cases/customers/overview_test.rb
docker exec nectar-charges-backend-api-1 bin/rubocop app/use_cases/customers/overview.rb test/use_cases/customers/overview_test.rb
Expected: PASS (6 runs, 0 failures); rubocop sem offenses.
- [ ] Step 5: Commit (usuário)
bash
git add modules/backend/app/use_cases/customers/overview.rb modules/backend/test/use_cases/customers/overview_test.rb
git commit -m "feat: customer overview summary use case"
Task 2: GET /api/v1/customers/:id (feita)
Files:
- Modify: modules/backend/config/routes.rb
- Create: modules/backend/app/controllers/api/v1/customers_controller.rb
- Create: modules/backend/app/serializers/customer_overview_serializer.rb
- Test: modules/backend/test/controllers/api/v1/customers_controller_test.rb
Interfaces:
- Consumes: Customers::Overview.call(customer_id:) → result[:customer], result[:summary] (Task 1); Api::V1::BaseController (autenticação, rescue_from ActiveRecord::RecordNotFound → 404).
- Produces: Api::V1::CustomersController#show; CustomerOverviewSerializer (opção summary:).
- [x] Step 1: Write the failing tests
modules/backend/test/controllers/api/v1/customers_controller_test.rb:
```ruby require “test_helper”
module Api module V1 class CustomersControllerTest < ActionDispatch::IntegrationTest setup do @token = token_for(users(:attendant)) end
test "returns the customer's personal data" do
get "/api/v1/customers/#{customers(:joana).id}", headers: bearer_header(@token)
assert_response :ok
data = JSON.parse(response.body)["data"]
assert_equal customers(:joana).id, data["id"]
assert_equal "Joana Ribeiro", data["name"]
assert_equal "39053344705", data["document"]
assert_equal "CPF", data["document_type"]
assert_equal "joana.ribeiro@example.com", data["email"]
assert_equal "11988887777", data["phone"]
assert_equal(
{
"zip_code" => "51020-000",
"street" => "Rua das Palmeiras",
"number" => "412",
"complement" => nil,
"neighborhood" => "Boa Viagem",
"city" => "Recife",
"state" => "PE",
"country" => "Brasil"
},
data["address"]
)
end
test "returns the financial summary" do
get "/api/v1/customers/#{customers(:joana).id}", headers: bearer_header(@token)
summary = JSON.parse(response.body)["data"]["summary"]
assert_equal(
{
"total_debt_amount_cents" => 50_002,
"unpaid_debt_amount_cents" => 50_002,
"installments_count" => 2,
"overdue" => { "count" => 1, "amount_cents" => 25_001 },
"paid" => { "count" => 0, "amount_cents" => 0 },
"upcoming" => { "count" => 1, "amount_cents" => 25_001 },
"next_due_installment" => { "due_on" => 15.days.from_now.to_date.iso8601, "amount_cents" => 25_001 }
},
summary
)
end
test "unknown customer returns not found" do
get "/api/v1/customers/0", headers: bearer_header(@token)
assert_response :not_found
end
test "unauthenticated request is rejected" do
get "/api/v1/customers/#{customers(:joana).id}"
assert_response :unauthorized
end
private
def token_for(user)
Warden::JWTAuth::UserEncoder.new.call(user, :user, nil).first
end
def bearer_header(token)
{ "Authorization" => "Bearer #{token}" }
end
end end end ```
- [x] Step 2: Run to verify it fails
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/controllers/api/v1/customers_controller_test.rb
Expected: FAIL — ActionController::RoutingError: No route matches [GET] "/api/v1/customers/..." (o 401 sem token também falha com 404 de rota)
- [x] Step 3: Write minimal implementation
modules/backend/config/routes.rb, logo após resources :contracts, only: [ :index, :create ]:
ruby
resources :customers, only: [ :show ]
modules/backend/app/controllers/api/v1/customers_controller.rb:
```ruby module Api module V1 class CustomersController < BaseController def show result = Customers::Overview.call(customer_id: params[:id])
render json: {
data: ActiveModelSerializers::SerializableResource.new(
result[:customer], serializer: CustomerOverviewSerializer, summary: result[:summary]
)
}, status: :ok
end
end end end ```
modules/backend/app/serializers/customer_overview_serializer.rb:
```ruby class CustomerOverviewSerializer < ActiveModel::Serializer attributes :id, :name, :document, :document_type, :email, :phone, :address, :summary
def document_type = object.document_type.to_s
def address { zip_code: object.address_zip_code, street: object.address_street, number: object.address_number, complement: object.address_complement, neighborhood: object.address_neighborhood, city: object.address_city, state: object.address_state, country: object.address_country } end
def summary = instance_options.fetch(:summary) end ```
- [x] Step 4: Run to verify it passes
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/controllers/api/v1/customers_controller_test.rb
docker exec nectar-charges-backend-api-1 bin/rubocop config/routes.rb app/controllers/api/v1/customers_controller.rb app/serializers/customer_overview_serializer.rb test/controllers/api/v1/customers_controller_test.rb
docker exec nectar-charges-backend-api-1 bin/rails test
Expected: PASS (4 runs no arquivo); suíte completa 252 runs, 0 failures; rubocop sem offenses.
- [ ] Step 5: Commit (usuário)
bash
git add modules/backend/config/routes.rb modules/backend/app/controllers/api/v1/customers_controller.rb modules/backend/app/serializers/customer_overview_serializer.rb modules/backend/test/controllers/api/v1/customers_controller_test.rb
git commit -m "feat: customer overview endpoint"
Task 3: Regra de negócio do resumo (feita)
Files:
- Create: .project/docs/rules/collections/customer_overview_summary.md
Interfaces: - Consumes: tabela “Regras do resumo” da spec. - Produces: doc de regra indexado.
- [x] Step 1: Criar o doc de regra
.project/docs/rules/collections/customer_overview_summary.md:
```markdown
title: Resumo financeiro do cliente created: 2026-09-25 updated: 2026-09-25 certainty: high —
Resumo financeiro do cliente
TLDR: como são calculados os números da aba Informações gerais (
GET /api/v1/customers/:id→summary).
Regras
| Regra | Decisão |
|---|---|
| Valor usado | Valor original da parcela (amount_cents). Juros e desconto não entram. |
| Débitos considerados | Só os em andamento: pending, no_forecast, no_response, bureau_report, negativated. cancelled e negotiated ficam de fora. |
| Vencidas | Parcelas overdue e defaulted. |
| Pagas | Parcelas paid. |
| A vencer | Parcelas upcoming. |
| Total de parcelas | Vencidas + pagas + a vencer. |
| Dívida total | Soma do valor de todas as parcelas consideradas. |
| Total pago | Valor das pagas. |
| Em aberto | Dívida total − Total pago. |
| Próx. vencimento | A parcela upcoming de menor due_on: data e valor original. Se mais de uma vence nessa data, o valor é a soma delas. Sem parcela a vencer, null. |
Onde está no código
modules/backend/app/use_cases/customers/overview.rb```
Antes de criar, conferir o frontmatter de um doc irmão (.project/docs/rules/collections/case_status_marking.md) e alinhar as chaves.
- [ ] Step 2: Commit (usuário)
bash
git add .project/docs/rules/collections/customer_overview_summary.md
git commit -m "docs: customer overview summary rules"
Task 4: Use case Customers::List (feita)
Revisada pela Task 7: a busca passou para
Customer.search. O código abaixo é o da primeira versão.
Files:
- Create: modules/backend/app/use_cases/customers/list.rb
- Test: modules/backend/test/use_cases/customers/list_test.rb
Interfaces:
- Consumes: Customer, Debit, Installment, ProductDebit; Customers::Overview::ACTIVE_DEBIT_STATUSES e OVERDUE_STATUSES (Task 1); fixtures customers(:joana|:rafael|:instituto_lumen), debits(:joana_pending|:rafael_negotiated|:lumen_no_response), product_debits(:joana_ciencia_de_dados|:rafael_gestao_publica).
- Produces: Customers::List.call(search:, page:, per_page:) → Success com result[:customers] (relação paginada do Kaminari) e result[:details]:
ruby
{
customer_id => {
financial_status: "negativated" | "overdue" | "cancelled" | "up_to_date",
products: [String],
unpaid_debt_amount_cents: Integer,
overdue_installments_count: Integer
}
}
details tem uma chave para cada cliente da página.
Valores das fixtures, que os testes usam:
| Cliente | Débitos | financial_status |
products |
Em aberto | Vencidas |
|---|---|---|---|---|---|
| Instituto Lumen LTDA | no_response, sem parcelas |
up_to_date |
[] |
0 | 0 |
| Joana Ribeiro | pending: 1 overdue + 1 upcoming de 25.001 |
overdue |
["Pos em Ciencia de Dados"] |
50.002 | 1 |
| Rafael Duarte | negotiated: 1 paid |
up_to_date |
["MBA em Gestao Publica"] |
0 | 0 |
- [x] Step 1: Write the failing tests
modules/backend/test/use_cases/customers/list_test.rb:
```ruby require “test_helper”
module Customers class ListTest < ActiveSupport::TestCase test “lists every customer ordered by name with the table fields” do result = List.call(search: nil, page: nil, per_page: nil)
assert_predicate result, :success?
assert_equal %w[instituto_lumen joana rafael].map { customers(_1) }, result[:customers].to_a
assert_equal(
{
customers(:instituto_lumen).id => {
financial_status: "up_to_date", products: [],
unpaid_debt_amount_cents: 0, overdue_installments_count: 0
},
customers(:joana).id => {
financial_status: "overdue", products: [ "Pos em Ciencia de Dados" ],
unpaid_debt_amount_cents: 50_002, overdue_installments_count: 1
},
customers(:rafael).id => {
financial_status: "up_to_date", products: [ "MBA em Gestao Publica" ],
unpaid_debt_amount_cents: 0, overdue_installments_count: 0
}
},
result[:details]
)
end
test "counts defaulted as overdue and leaves paid and inactive debits out" do
debits(:joana_pending).installments.create!(number: 3, amount_cents: 10_000, due_on: 60.days.ago.to_date,
status: :defaulted)
debits(:joana_pending).installments.create!(number: 4, amount_cents: 20_000, due_on: 50.days.ago.to_date,
status: :paid, paid_at: 50.days.ago)
cancelled = customers(:joana).debits.create!(status: :cancelled, opened_at: 10.days.ago)
cancelled.installments.create!(number: 1, amount_cents: 99_999, due_on: 5.days.ago.to_date, status: :overdue)
joana = details_for(:joana)
assert_equal 60_002, joana[:unpaid_debt_amount_cents]
assert_equal 2, joana[:overdue_installments_count]
end
test "negativated wins over overdue" do
customers(:joana).debits.create!(status: :negativated, opened_at: 5.days.ago)
assert_equal "negativated", details_for(:joana)[:financial_status]
end
test "cancelled only when every debit is cancelled" do
debits(:lumen_no_response).update!(status: :cancelled)
assert_equal "cancelled", details_for(:instituto_lumen)[:financial_status]
customers(:instituto_lumen).debits.create!(status: :pending, opened_at: 1.day.ago)
assert_equal "up_to_date", details_for(:instituto_lumen)[:financial_status]
end
test "products are unique and sorted across debits" do
other = customers(:joana).debits.create!(status: :pending, opened_at: 5.days.ago)
other.product_debits.create!(external_id: "aa-curso", product_name: "Aa Curso")
other.product_debits.create!(external_id: "pos-ciencia-de-dados", product_name: "Pos em Ciencia de Dados")
assert_equal [ "Aa Curso", "Pos em Ciencia de Dados" ], details_for(:joana)[:products]
end
test "searches by name, email, document and phone" do
assert_equal [ customers(:joana) ], search("joana")
assert_equal [ customers(:instituto_lumen) ], search("LUMEN.EXAMPLE")
assert_equal [ customers(:joana) ], search("390.533.447-05")
assert_equal [ customers(:rafael) ], search("(21) 97777")
assert_equal [], search("ninguem")
end
test "paginates with page and per_page" do
result = List.call(search: nil, page: 2, per_page: 2)
assert_equal [ customers(:rafael) ], result[:customers].to_a
assert_equal 3, result[:customers].total_count
end
test "query count does not grow with the page size" do
one = count_queries { List.call(search: nil, page: 1, per_page: 1) }
three = count_queries { List.call(search: nil, page: 1, per_page: 3) }
assert_equal one, three
end
private
def details_for(name)
List.call(search: nil, page: nil, per_page: nil)[:details].fetch(customers(name).id)
end
def search(term)
List.call(search: term, page: nil, per_page: nil)[:customers].to_a
end
def count_queries(&)
count = 0
counter = ->(*, payload) { count += 1 unless payload[:name].in?(%w[SCHEMA TRANSACTION]) }
ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &)
count
end end end ```
- [x] Step 2: Run to verify it fails
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/use_cases/customers/list_test.rb
Expected: FAIL — NameError: uninitialized constant Customers::ListTest::List
- [x] Step 3: Write minimal implementation
modules/backend/app/use_cases/customers/list.rb:
```ruby module Customers class List < Micro::Case attribute :search attribute :page attribute :per_page
def call!
customers = filter(Customer.order(:name, :id)).page(page).per(per_page)
Success(result: { customers: customers, details: details_for(customers.map(&:id)) })
end
private
def filter(scope)
term = search.to_s.strip
return scope if term.empty?
digits = term.gsub(/\D/, "")
conditions = [ "customers.name ILIKE :term", "customers.email ILIKE :term" ]
conditions += [ "customers.document LIKE :digits", "customers.phone LIKE :digits" ] if digits.present?
scope.where(conditions.join(" OR "), term: "%#{Customer.sanitize_sql_like(term)}%", digits: "%#{digits}%")
end
def details_for(ids)
totals = installment_totals(ids)
statuses = debit_statuses(ids)
products = product_names(ids)
ids.index_with do |id|
unpaid, overdue_count = totals.fetch(id, [ 0, 0 ])
{
financial_status: financial_status(statuses.fetch(id, []), overdue_count),
products: products.fetch(id, []),
unpaid_debt_amount_cents: unpaid,
overdue_installments_count: overdue_count
}
end
end
def installment_totals(ids)
Installment.joins(:debit)
.where(debits: { customer_id: ids, status: Overview::ACTIVE_DEBIT_STATUSES })
.where(status: Overview::OVERDUE_STATUSES + %w[upcoming])
.group("debits.customer_id")
.pluck(Arel.sql("debits.customer_id"),
Arel.sql("SUM(installments.amount_cents)"),
Arel.sql("COUNT(*) FILTER (WHERE installments.status IN ('overdue', 'defaulted'))"))
.to_h { |id, unpaid, overdue_count| [ id, [ unpaid, overdue_count ] ] }
end
def debit_statuses(ids)
Debit.where(customer_id: ids).group(:customer_id)
.pluck(:customer_id, Arel.sql("ARRAY_AGG(DISTINCT debits.status)"))
.to_h
end
def product_names(ids)
ProductDebit.joins(:debit).where(debits: { customer_id: ids })
.distinct.order(:product_name)
.pluck(Arel.sql("debits.customer_id"), :product_name)
.group_by(&:first)
.transform_values { |rows| rows.map(&:last) }
end
def financial_status(statuses, overdue_count)
return "negativated" if statuses.include?("negativated")
return "overdue" if overdue_count.positive?
return "cancelled" if statuses.any? && statuses.all?("cancelled")
"up_to_date"
end end end ```
- [x] Step 4: Run to verify it passes
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/use_cases/customers/list_test.rb
docker exec nectar-charges-backend-api-1 bin/rubocop app/use_cases/customers/list.rb test/use_cases/customers/list_test.rb
Expected: PASS (8 runs, 0 failures); rubocop sem offenses.
- [ ] Step 5: Commit (usuário)
bash
git add modules/backend/app/use_cases/customers/list.rb modules/backend/test/use_cases/customers/list_test.rb
git commit -m "feat: customers list use case"
Task 5: GET /api/v1/customers (feita)
Revisada pela Task 7: a busca passou para
Customer.search. O código abaixo é o da primeira versão.
Files:
- Modify: modules/backend/config/routes.rb
- Modify: modules/backend/app/controllers/api/v1/customers_controller.rb
- Create: modules/backend/app/serializers/customer_list_item_serializer.rb
- Modify: modules/backend/test/controllers/api/v1/customers_controller_test.rb
Interfaces:
- Consumes: Customers::List.call(search:, page:, per_page:) → result[:customers], result[:details] (Task 4); Paginatable#pagination_meta.
- Produces: Api::V1::CustomersController#index; CustomerListItemSerializer (opção details:).
- [x] Step 1: Write the failing tests
Em modules/backend/test/controllers/api/v1/customers_controller_test.rb, antes do private:
```ruby test “lists customers with pagination meta” do get “/api/v1/customers”, params: { per_page: 2 }, headers: bearer_header(@token)
assert_response :ok
body = JSON.parse(response.body)
assert_equal [ "Instituto Lumen LTDA", "Joana Ribeiro" ], body["data"].map { _1["name"] }
assert_equal(
{ "current_page" => 1, "next_page" => 2, "prev_page" => nil, "total_pages" => 2, "total_count" => 3 },
body["meta"]
)
end
test "list item carries the table fields" do
get "/api/v1/customers", params: { search: "joana" }, headers: bearer_header(@token)
assert_equal(
[
{
"id" => customers(:joana).id,
"name" => "Joana Ribeiro",
"document" => "39053344705",
"document_type" => "CPF",
"email" => "joana.ribeiro@example.com",
"phone" => "11988887777",
"financial_status" => "overdue",
"products" => [ "Pos em Ciencia de Dados" ],
"unpaid_debt_amount_cents" => 50_002,
"overdue_installments_count" => 1
}
],
JSON.parse(response.body)["data"]
)
end
test "unauthenticated list is rejected" do
get "/api/v1/customers"
assert_response :unauthorized
end ```
- [x] Step 2: Run to verify it fails
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/controllers/api/v1/customers_controller_test.rb
Expected: FAIL nos 3 testes novos — No route matches [GET] "/api/v1/customers"; os 4 de show continuam passando.
- [x] Step 3: Write minimal implementation
modules/backend/config/routes.rb: trocar resources :customers, only: [ :show ] por:
ruby
resources :customers, only: [ :index, :show ]
modules/backend/app/controllers/api/v1/customers_controller.rb:
```ruby module Api module V1 class CustomersController < BaseController include Paginatable
def index
result = Customers::List.call(search: params[:search], page: params[:page], per_page: params[:per_page])
customers = result[:customers]
render json: {
data: ActiveModelSerializers::SerializableResource.new(
customers, each_serializer: CustomerListItemSerializer, details: result[:details]
),
meta: pagination_meta(customers)
}, status: :ok
end
def show
result = Customers::Overview.call(customer_id: params[:id])
render json: {
data: ActiveModelSerializers::SerializableResource.new(
result[:customer], serializer: CustomerOverviewSerializer, summary: result[:summary]
)
}, status: :ok
end
end end end ```
modules/backend/app/serializers/customer_list_item_serializer.rb:
```ruby class CustomerListItemSerializer < ActiveModel::Serializer attributes :id, :name, :document, :document_type, :email, :phone, :financial_status, :products, :unpaid_debt_amount_cents, :overdue_installments_count
def document_type = object.document_type.to_s def financial_status = details[:financial_status] def products = details[:products] def unpaid_debt_amount_cents = details[:unpaid_debt_amount_cents] def overdue_installments_count = details[:overdue_installments_count]
private
def details = instance_options.fetch(:details).fetch(object.id) end ```
- [x] Step 4: Run to verify it passes
bash
docker exec nectar-charges-backend-api-1 bin/rails test test/controllers/api/v1/customers_controller_test.rb
docker exec nectar-charges-backend-api-1 bin/rubocop config/routes.rb app/controllers/api/v1/customers_controller.rb app/serializers/customer_list_item_serializer.rb test/controllers/api/v1/customers_controller_test.rb
docker exec nectar-charges-backend-api-1 bin/rails test
Expected: PASS (7 runs no arquivo); suíte completa 263 runs, 0 failures; rubocop sem offenses.
- [ ] Step 5: Commit (usuário)
bash
git add modules/backend/config/routes.rb modules/backend/app/controllers/api/v1/customers_controller.rb modules/backend/app/serializers/customer_list_item_serializer.rb modules/backend/test/controllers/api/v1/customers_controller_test.rb
git commit -m "feat: customers list endpoint"
Task 6: Regras da listagem no doc de regra (feita)
Files:
- Modify: .project/docs/rules/collections/customer_overview_summary.md
Interfaces: - Consumes: seção “Regras da listagem” da spec. - Produces: R-008 cobrindo listagem e visão geral.
- [x] Step 1: Acrescentar a seção
Depois da tabela de regras, adicionar ## Listagem (GET /api/v1/customers) com:
- a tabela coluna → campo → regra (
financial_status,products,unpaid_debt_amount_cents,overdue_installments_count); - a tabela de
financial_statusna ordemnegativated→overdue→cancelled→up_to_date; - em “Onde está no código”,
modules/backend/app/use_cases/customers/list.rb.
Atualizar o TLDR para citar as duas telas, e updated no frontmatter.
- [ ] Step 2: Commit (usuário)
bash
git add .project/docs/rules/collections/customer_overview_summary.md
git commit -m "docs: customers list rules"
Task 7: Busca no modelo (feita)
Alinha a busca ao padrão de Debit.search / Contract.search: ela fica no modelo. O use case Customers::List chama Customer.search, ordena, pagina e calcula os campos. O controller não consulta o banco: só repassa search, page e per_page.
Files:
- Modify: modules/backend/app/models/customer.rb
- Modify: modules/backend/test/models/customer_test.rb
- Modify: modules/backend/app/use_cases/customers/list.rb
- Modify: modules/backend/test/use_cases/customers/list_test.rb
- Modify: modules/backend/app/controllers/api/v1/customers_controller.rb
-
[x] Step 1: Testes de
Customer.searchemtest/models/customer_test.rb(seção# --- search ---): nome, e-mail, dígitos do CPF, dígitos do telefone, sem correspondência, termo em branco. Falharam comNoMethodError: undefined method 'search' for class Customer. -
[x] Step 2:
Customer.search(scope, params)comLOWER(customers.name) LIKE :q OR LOWER(customers.email) LIKE :qe, se o termo tiver dígitos,customers.document LIKE :doc OR customers.phone LIKE :doc. Termo em branco devolve oscope. -
[x] Step 3:
Customers::Listtroca ofilterinterno por:
ruby
customers = Customer.search(Customer.all, { search: search })
.order(:name, :id)
.page(page)
.per(per_page)
-
[x] Step 4: Controller:
indexchamaCustomers::List.call(params.permit(:search, :page, :per_page).to_h)e usaresult[:customers]no serializer e nometa. -
[x] Step 5:
list_test.rb: o teste de busca por nome/e-mail/CPF/telefone vai para o modelo; fica um teste de que o use case filtra comCustomer.search. -
[x] Step 6: Verificar
bash
docker exec nectar-charges-backend-api-1 bin/rails test
docker exec nectar-charges-backend-api-1 bin/rubocop app/models/customer.rb app/use_cases/customers/list.rb app/controllers/api/v1/customers_controller.rb test/models/customer_test.rb test/use_cases/customers/list_test.rb
Resultado: 269 runs, 0 failures; rubocop sem offenses.
- [ ] Step 7: Commit (usuário)
bash
git add modules/backend/app/models/customer.rb modules/backend/test/models/customer_test.rb modules/backend/app/use_cases/customers/list.rb modules/backend/test/use_cases/customers/list_test.rb modules/backend/app/controllers/api/v1/customers_controller.rb
git commit -m "refactor: customers search in the model"
Verificação final
docker exec nectar-charges-backend-api-1 bin/rails test→ 269 runs, 0 failures.- Com o seed carregado:
bash curl -s -H "Authorization: Bearer <token>" localhost:<porta>/api/v1/customers/<id> | jqConferir quesummarybate com a soma manual deamount_centsdas parcelas do cliente nos débitos em andamento. curlcom id0→404; sem header →401.curl -s -H "Authorization: Bearer <token>" "localhost:<porta>/api/v1/customers?search=<parte do nome>" | jq: página commeta, e ounpaid_debt_amount_centsde um cliente igual aosummary.unpaid_debt_amount_centsdoGET /api/v1/customers/<id>.- Atualizar
statusda spec paradonedepois do merge.
Nota de merge
A branch feat/customer-charges-endpoint declara resources :customers, only: [] do resources :charges, only: [ :index ], controller: :customer_charges end. No merge, o resultado deve ser:
ruby
resources :customers, only: [ :index, :show ] do
resources :charges, only: [ :index ], controller: :customer_charges
end