Endpoints de clientes — Plano de implementação

TLDR: cria os use cases Customers::Overview (resumo agregado no banco) e Customers::List (listagem paginada com busca), e expõe GET /api/v1/customers/:id e GET /api/v1/customers.

Spec: .project/docs/specs/20260925084434_customer_overview_endpoint.md Branch: 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). Nunca payable_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_on das upcoming, somando as que vencem nessa data; nil se não houver.
  • Listagem: financial_status na ordem negativated → overdue → cancelled (todos os débitos cancelados) → up_to_date. products de todos os débitos, sem repetir, em ordem alfabética. Sem coluna de responsável.
  • README.md nã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_status na ordem negativated → 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.search em test/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 com NoMethodError: undefined method 'search' for class Customer.

  • [x] Step 2: Customer.search(scope, params) com LOWER(customers.name) LIKE :q OR LOWER(customers.email) LIKE :q e, se o termo tiver dígitos, customers.document LIKE :doc OR customers.phone LIKE :doc. Termo em branco devolve o scope.

  • [x] Step 3: Customers::List troca o filter interno por:

ruby customers = Customer.search(Customer.all, { search: search }) .order(:name, :id) .page(page) .per(per_page)

  • [x] Step 4: Controller: index chama Customers::List.call(params.permit(:search, :page, :per_page).to_h) e usa result[:customers] no serializer e no meta.

  • [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 com Customer.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

  1. docker exec nectar-charges-backend-api-1 bin/rails test → 269 runs, 0 failures.
  2. Com o seed carregado: bash curl -s -H "Authorization: Bearer <token>" localhost:<porta>/api/v1/customers/<id> | jq Conferir que summary bate com a soma manual de amount_cents das parcelas do cliente nos débitos em andamento.
  3. curl com id 0 → 404; sem header → 401.
  4. curl -s -H "Authorization: Bearer <token>" "localhost:<porta>/api/v1/customers?search=<parte do nome>" | jq: página com meta, e o unpaid_debt_amount_cents de um cliente igual ao summary.unpaid_debt_amount_cents do GET /api/v1/customers/<id>.
  5. Atualizar status da spec para done depois 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