Endpoints de clientes — listagem e visão geral
TLDR: novo
GET /api/v1/customers, que lista os clientes paginado e com busca, e novoGET /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 esummary), montado por um únicoCustomerSerializera partir de métodos doCustomersobre associações pré-carregadas. Débitos e parcelas continuam emGET /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 doPaginatable(page,per_page,meta) e com busca porsearch. - 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 osummary. 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,phonee endereço. - Devolver um
summarycom os KPIs e o resumo de parcelas, calculado em Ruby sobre os débitos e parcelas pré-carregados comincludes. - 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,productseunpaid_debt_amount_centsestão em “Regras dos campos do cliente”; as dosummary, em “Regras do resumo”. summary.total_debt_amount_centsé a “Dívida total”,summary.unpaid_debt_amount_centso “Em aberto” (igual aounpaid_debt_amount_centsde fora),summary.installments_counto “Total de parcelas” e “Total pago” ésummary.paid.amount_cents.summary.next_due_installment:due_on(YYYY-MM-DD) eamount_centsda próxima parcela a vencer;nullse 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: [],0nos números enext_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 comnameeemail(LOWER(...) LIKE, igual aDebit.searcheContract.search). Se o termo tiver dígitos, compara também só os dígitos comdocumentephone. Vazio ou ausente lista todos.- Ordenação:
nameascendente e depoisid, para a paginação ser estável. per_pagesegue o padrão do Kaminari do projeto (25). Sem resultado →data: []etotal_count: 0.
Visão geral
GET /api/v1/customers/:id → 200 com { "data": <objeto de cliente> }.
- Cliente inexistente →
404(viarescue_from ActiveRecord::RecordNotFounddoBaseController).
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_ida 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) declararesources :customers, only: [] do resources :charges ... end. No merge das duas, o bloco ficaresources :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 deDebit.searcheContract.search:name/emailcomLOWER(...) LIKE; se o termo tiver dígitos, tambémdocument/phonesó 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_namede todos os débitos, sem repetir, ordenado;debit_statuses: status distintos de todos os débitos;unpaid_debt_amount_cents: soma das parcelasUNPAID_STATUSESdos débitos ativos;summary: hash do resumo, a partir deoverdue,paideupcoming;next_due_installment: menordue_onentre asupcoming, somando as do mesmo dia.
- Privados:
active_installments(parcelas dos débitos emDebit::ACTIVE_STATUSES) einstallments_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_centscom valor original,defaultedcontando como não paga, parcelas pagas e de débitos inativos de fora;financial_status:negativatedganha deoverdue;overduecom algum débito em cobrança, mesmo sem parcela vencida;cancelledsó com todos os débitos cancelados;up_to_dateno resto;productssem repetição e em ordem alfabética, juntando débitos diferentes;summary: soma por categoria com valor original;defaultedconta emoverdue;next_due_installmenté aupcomingde menor data, somando as do mesmo dia, enullsem parcela a vencer; débitoscancelled/negotiatedignorados; cliente sem parcelas → zeros;collaborator: nome douserquando há responsável;nilsem responsável;with_list_detailscarrega 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:200comdatano formato da resposta emetade paginação;searchfiltra;401sem token;show:200com dados pessoais, endereço,collaboratoresummary;404para id inexistente;401sem token.
Fixtures e seed
test/fixtures/customers.yml:joanacomuser: attendant;rafaeleinstituto_lumensem responsável, para cobrir onull.db/seeds.rb:Customer.create!recebeuser: user, o mesmo usuário dos débitos daquele cliente no loop. Continua idempotente (o seed já fazdestroy_allantes).
Como verificar
make test(ou a suíte do backend) verde, incluindo os testes novos.- Com o seed carregado,
curl -H "Authorization: Bearer <token>" "localhost:<porta>/api/v1/customers?search=<parte do nome>"devolve a página commeta, e o “Em aberto” de um cliente é igual aosummary.unpaid_debt_amount_centsdoGET /api/v1/customers/<id>. - 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. - Um cliente do seed traz
collaboratorcom o nome do usuário responsável. curlcom id inexistente →404; sem token →401.
Documentação
- Criar
.project/docs/rules/collections/customer_overview_summary.mdcom a tabela de regras do resumo. - Adicionar esta spec e a regra ao índice
.project/docs/README.md.