Endpoints de clientes — listagem e visão geral

TLDR: novo GET /api/v1/customers, que lista os clientes paginado e com busca, e novo GET /api/v1/customers/:id, que devolve um cliente. Os dois devolvem o mesmo payload por cliente (dados pessoais, endereço, situação financeira, produtos, responsável, em aberto e summary), montado por um único CustomerSerializer a partir de métodos do Customer sobre associações pré-carregadas. Débitos e parcelas continuam em GET /api/v1/customers/:customer_id/charges.

Contexto

A página Clientes (CustomersPage) lista a base inteira numa tabela paginada, com busca por nome, CPF, telefone ou e-mail. Cada linha mostra nome e CPF, contato, situação financeira, cursos, responsável e o valor em aberto.

A aba Informações gerais (PersonalTab) do detalhe do cliente mostra:

  • KPIs: Dívida total, Total pago, Em aberto, Vencido (valor e quantidade de parcelas), Próx. vencimento (data e valor da próxima parcela a vencer);
  • resumo de parcelas: Total de parcelas, Vencidas, Pagas, A vencer (quantidade e valor de cada);
  • informações pessoais: CPF, telefone, e-mail e endereço.

Hoje não existe endpoint de cliente no backend, e o front lê tudo isso de mock (INITIAL_CUSTOMERS). O endpoint de charges devolve débitos e parcelas, não dados pessoais nem resumo pronto.

A decisão é que listagem e visão geral devolvem o mesmo cliente, com os campos da tabela e os números consolidados, e o de charges traz o detalhe por débito e parcela. A lógica fica no model Customer, sem use case, e um só serializer atende os dois endpoints.

Objetivos

  • Expor GET /api/v1/customers, paginado no padrão do Paginatable (page, per_page, meta) e com busca por search.
  • Em cada item, devolver tudo o que a tabela de Clientes e a aba Informações gerais mostram: identificação e contato, endereço, situação financeira, cursos/produtos, responsável (collaborator), “Em aberto” e o summary. Tudo calculado com um número fixo de queries (5), sem N+1.
  • Expor GET /api/v1/customers/:id, pelo id numérico do cliente (o mesmo usado em /customers/:customer_id/charges).
  • Devolver os dados pessoais: id, name, document, document_type, email, phone e endereço.
  • Devolver um summary com os KPIs e o resumo de parcelas, calculado em Ruby sobre os débitos e parcelas pré-carregados com includes.
  • Qualquer usuário autenticado acessa, igual ao endpoint de charges.

Regras do resumo

Regra Decisão
Valor usado Valor original (installments.amount_cents). Juros e desconto não entram.
Débitos considerados Só os em andamento: pending, no_forecast, no_response, bureau_report, negativated. Ficam de fora cancelled e negotiated.
Vencidas Parcelas overdue e defaulted.
Pagas Parcelas paid.
A vencer Parcelas upcoming.
Total de parcelas Soma das três categorias (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 (= vencidas + a vencer).
Próx. vencimento A parcela upcoming de menor due_on: data e valor original. Se mais de uma parcela vence nessa data (débitos diferentes), o valor é a soma delas.

Sem débitos em andamento, todas as contagens e valores são 0 e next_due_installment é null. Sem parcela a vencer, next_due_installment também é null.

Regras dos campos do cliente

Coluna do front Campo Regra
Cliente name, document Direto de customers.
Contato phone, email Direto de customers.
Sit. financeira financial_status Derivada dos débitos do cliente, na ordem abaixo: a primeira que bater vale.
Responsável collaborator Nome do user ligado ao cliente (customers.user_id); null se não houver. Independente do user dos débitos: não há sincronização entre os dois.
Cursos/Produtos products product_debits.product_name de todos os débitos do cliente, sem repetir, em ordem alfabética.
Em aberto unpaid_debt_amount_cents Mesma regra do resumo: vencidas + a vencer dos débitos em andamento, valor original.

financial_status:

Ordem Valor Quando
1 negativated (NEGATIVADO) Algum débito negativated.
2 overdue (EM ATRASO) Algum débito em cobrança: pending, no_forecast, no_response ou bureau_report (Debit::OPEN_STATUSES).
3 cancelled (CANCELADO) Tem débitos e todos estão cancelled.
4 up_to_date (EM DIA) Qualquer outro caso, inclusive cliente sem débitos.

O backend devolve a chave em inglês e o front traduz para o rótulo (EM DIA, EM ATRASO, NEGATIVADO, CANCELADO).

Resposta da API

Os dois endpoints devolvem o mesmo objeto de cliente:

json { "id": 42, "name": "Joana Ribeiro", "document": "39053344705", "document_type": "CPF", "email": "joana@example.com", "phone": "11999998888", "address": { "zip_code": "01310100", "street": "Av. Paulista", "number": "1000", "complement": "ap 12", "neighborhood": "Bela Vista", "city": "São Paulo", "state": "SP", "country": "Brasil" }, "financial_status": "overdue", "products": [ "Formação de Terapeutas - TRG" ], "collaborator": "Aretha Morais", "unpaid_debt_amount_cents": 200008, "summary": { "total_debt_amount_cents": 300012, "unpaid_debt_amount_cents": 200008, "installments_count": 12, "overdue": { "count": 2, "amount_cents": 50002 }, "paid": { "count": 4, "amount_cents": 100004 }, "upcoming": { "count": 6, "amount_cents": 150006 }, "next_due_installment": { "due_on": "2024-07-10", "amount_cents": 25501 } } }

  • As regras de financial_status, products e unpaid_debt_amount_cents estão em “Regras dos campos do cliente”; as do summary, em “Regras do resumo”.
  • summary.total_debt_amount_cents é a “Dívida total”, summary.unpaid_debt_amount_cents o “Em aberto” (igual ao unpaid_debt_amount_cents de fora), summary.installments_count o “Total de parcelas” e “Total pago” é summary.paid.amount_cents.
  • summary.next_due_installment: due_on (YYYY-MM-DD) e amount_cents da próxima parcela a vencer; null se não houver.
  • Campos de endereço vazios vêm como null.
  • Cliente sem responsável: collaborator: null.
  • Cliente sem débitos: financial_status: "up_to_date", products: [], 0 nos números e next_due_installment: null.
  • Qualquer usuário autenticado acessa, sem filtro por responsável. Sem token → 401.

Listagem

GET /api/v1/customers?page=1&per_page=8&search=joana → 200

json { "data": [ { "id": 42, "name": "Joana Ribeiro", "...": "objeto de cliente acima" } ], "meta": { "current_page": 1, "next_page": null, "prev_page": null, "total_pages": 1, "total_count": 1 } }

  • search (opcional): compara sem diferenciar maiúsculas com name e email (LOWER(...) LIKE, igual a Debit.search e Contract.search). Se o termo tiver dígitos, compara também só os dígitos com document e phone. Vazio ou ausente lista todos.
  • Ordenação: name ascendente e depois id, para a paginação ser estável.
  • per_page segue o padrão do Kaminari do projeto (25). Sem resultado → data: [] e total_count: 0.

Visão geral

GET /api/v1/customers/:id → 200 com { "data": <objeto de cliente> }.

  • Cliente inexistente → 404 (via rescue_from ActiveRecord::RecordNotFound do BaseController).

Fora de escopo

  • Integração do front-end (service, hook, PersonalTab, trocar a rota da página para o id numérico): mudança separada.
  • Edição dos dados pessoais (o botão “Editar” da PersonalTab).
  • Atribuir ou trocar o responsável pela API (create/update de cliente) e filtrar a listagem por responsável.
  • Backfill de customers.user_id a partir dos débitos existentes.
  • Detalhe dos cursos na listagem (progresso, aulas, certificado): a lista traz só o nome.
  • Filtros do painel da listagem (situação, curso, acadêmico, responsável, status da cobrança, “só aptos”) e exportação CSV no servidor.
  • “Últimas movimentações” (auditoria) da aba.
  • Valores com juros/desconto no resumo.
  • Qualquer mudança no endpoint de charges.

Mudanças

modules/backend/db/migrate/<timestamp>_add_user_to_customers.rb (novo)

add_reference :customers, :user, null: true, foreign_key: true, igual a debits.user_id. Sem backfill.

modules/backend/app/models/customer.rb — responsável

belongs_to :user, optional: true (igual ao Debit) e def collaborator = user&.name. Atualizar o bloco de schema (annotate) com user_id, índice e foreign key.

modules/backend/config/routes.rb

Adicionar resources :customers, only: [ :index, :show ] (hoje está only: [ :show ]).

A branch feat/customer-charges-endpoint (ainda não mergeada) declara resources :customers, only: [] do resources :charges ... end. No merge das duas, o bloco fica resources :customers, only: [ :index, :show ] do ... end.

modules/backend/app/controllers/api/v1/customers_controller.rb (novo)

index inclui Paginatable, chama Customer.list(params.permit(:search, :page, :per_page)) e renderiza { data: CustomerSerializer (each), meta: pagination_meta(customers) }.

show busca Customer.with_list_details.find(params[:id]) e renderiza { data: CustomerSerializer }.

modules/backend/app/models/debit.rb e installment.rb

Constantes de status usadas pelo Customer:

```ruby # Debit ACTIVE_STATUSES = (OPEN_STATUSES + %w[negativated]).freeze

Installment

OVERDUE_STATUSES = %w[overdue defaulted].freeze UNPAID_STATUSES = (OVERDUE_STATUSES + %w[upcoming]).freeze ```

modules/backend/app/models/customer.rb

  • Customer.search(scope, params), no formato de Debit.search e Contract.search: name/email com LOWER(...) LIKE; se o termo tiver dígitos, também document/phone só com os dígitos.
  • scope :with_list_details, -> { includes(:user, debits: %i[installments product_debits]) }: carrega responsável, débitos, parcelas e produtos em 4 queries, qualquer que seja o número de clientes.
  • Customer.list(params): search(with_list_details, params).order(:name, :id).page(params[:page]).per(params[:per_page]).
  • Métodos de instância, todos sobre as associações carregadas (enumerables em Ruby, nunca where, para não disparar query por cliente):
    • financial_status: tabela de “Regras dos campos do cliente”;
    • products: product_name de todos os débitos, sem repetir, ordenado;
    • debit_statuses: status distintos de todos os débitos;
    • unpaid_debt_amount_cents: soma das parcelas UNPAID_STATUSES dos débitos ativos;
    • summary: hash do resumo, a partir de overdue, paid e upcoming;
    • next_due_installment: menor due_on entre as upcoming, somando as do mesmo dia.
  • Privados: active_installments (parcelas dos débitos em Debit::ACTIVE_STATUSES) e installments_summary(statuses) ({ count:, amount_cents: }).

São 5 queries por requisição (clientes + responsáveis + débitos + parcelas + produtos), mais o COUNT da paginação na listagem.

modules/backend/app/serializers/customer_serializer.rb (novo)

Um serializer para os dois endpoints: id, name, document, document_type, email, phone, address (aninhado a partir das colunas address_*), financial_status, products, collaborator, unpaid_debt_amount_cents e summary, todos lidos do Customer.

Testes

  • test/models/customer_test.rb:
    • search: por nome e por e-mail sem diferenciar maiúsculas; por dígitos do CPF e do telefone, com ou sem máscara; termo sem correspondência → vazio; termo em branco → escopo inteiro;
    • campos da listagem por cliente (financial_status, products, unpaid_debt_amount_cents);
    • unpaid_debt_amount_cents com valor original, defaulted contando como não paga, parcelas pagas e de débitos inativos de fora;
    • financial_status: negativated ganha de overdue; overdue com algum débito em cobrança, mesmo sem parcela vencida; cancelled só com todos os débitos cancelados; up_to_date no resto;
    • products sem repetição e em ordem alfabética, juntando débitos diferentes;
    • summary: soma por categoria com valor original; defaulted conta em overdue; next_due_installment é a upcoming de menor data, somando as do mesmo dia, e null sem parcela a vencer; débitos cancelled/negotiated ignorados; cliente sem parcelas → zeros;
    • collaborator: nome do user quando há responsável; nil sem responsável;
    • with_list_details carrega tudo em 5 queries, sem N+1 (o teste atual espera 4 e passa a esperar 5).
  • test/controllers/api/v1/customers_controller_test.rb:
    • index: 200 com data no formato da resposta e meta de paginação; search filtra; 401 sem token;
    • show: 200 com dados pessoais, endereço, collaborator e summary; 404 para id inexistente; 401 sem token.

Fixtures e seed

  • test/fixtures/customers.yml: joana com user: attendant; rafael e instituto_lumen sem responsável, para cobrir o null.
  • db/seeds.rb: Customer.create! recebe user: user, o mesmo usuário dos débitos daquele cliente no loop. Continua idempotente (o seed já faz destroy_all antes).

Como verificar

  1. make test (ou a suíte do backend) verde, incluindo os testes novos.
  2. Com o seed carregado, curl -H "Authorization: Bearer <token>" "localhost:<porta>/api/v1/customers?search=<parte do nome>" devolve a página com meta, e o “Em aberto” de um cliente é igual ao summary.unpaid_debt_amount_cents do GET /api/v1/customers/<id>.
  3. Com o seed carregado, curl -H "Authorization: Bearer <token>" localhost:<porta>/api/v1/customers/<id> devolve a resposta descrita acima, e os números batem com a soma manual das parcelas do cliente nos débitos em andamento.
  4. Um cliente do seed traz collaborator com o nome do usuário responsável.
  5. curl com id inexistente → 404; sem token → 401.

Documentação

  • Criar .project/docs/rules/collections/customer_overview_summary.md com a tabela de regras do resumo.
  • Adicionar esta spec e a regra ao índice .project/docs/README.md.