Endpoint admin de impostos (taxas de pagamento)

TLDR: Cria o endpoint GET /api/v1/admin/taxes, restrito a admins, substituindo a query SQL manual usada hoje para consultar impostos retidos por ano/terapeuta — mais a tela /admin/taxes que consome a mesma lógica, com filtro por ano/nome e paginação.

Contexto

Hoje a consulta de impostos (taxas retidas sobre pagamentos de terapeutas) é feita rodando uma query SQL manualmente direto no banco, filtrando por ano e, opcionalmente, por um terapeuta específico. A query só considera professional_payment_invoices com status = 1 (pago) e traz, por invoice: terapeuta, ano, status, mês de pagamento, sessões incluídas, subtotal, taxa da plataforma, taxa de transferência, total retido (soma das duas taxas) e total pago.

Esta entrega expõe essa mesma lógica como endpoint JSON, liberado apenas para admin_user autenticado com role developer (sessão web, o mesmo mecanismo já usado pelo Avo — não existe hoje autenticação por token para admin). Em vez de reescrever a query SQL manual em Ruby, ela é reconstruída via ActiveRecord/associations, já que ProfessionalPaymentInvoice já possui belongs_to :professional e has_many :professional_payment_invoice_meetings/has_many :meetings, evitando SQL cru e N+1.

Revisões durante a entrega

Data Decisão
28/07 A versão inicial incluía totalizadores agregados (soma de subtotal/taxas/total) em vez de paginação. Trocado por paginação: a lista pode ter milhares de registros (ano inteiro, todos os terapeutas), pesado para o front renderizar de uma vez — e os totais eram calculados carregando tudo em Ruby (invoices.sum { ... }), ineficiente no mesmo cenário. Totalizadores agregados saíram do escopo.
28/07 b A tela admin filtrava por professional_id, pouco intuitivo para um admin (que não sabe o ID de cabeça). Trocado para busca por nome (professional_name), parcial e case-insensitive (ILIKE '%nome%' em users.name), só na tela HTML.
28/07 c Verificado que nada no código consumia GET /api/v1/admin/taxes com professional_id (a tela /admin/taxes não chama esse endpoint, tem lógica própria no controller). Sem consumidor real do filtro por ID, professional_id/#by_professional foi removido de tudo — query, use case e endpoint JSON — deixando professional_name como único filtro por terapeuta.
28/07 d A tela /admin/taxes, inicialmente cogitada como fora de escopo, acabou implementada nesta mesma entrega, já que reaproveita 100% da lógica (FetchTaxSummary/TaxSummaryQuery) construída para o endpoint JSON. A TaxSummaryQuery também passou a ordenar por users.name, paid_at (antes não ordenava), para a listagem sair agrupada por terapeuta.

Objetivos

  • Expor GET /api/v1/admin/taxes retornando a mesma informação da query manual (lista de invoices pagas, filtráveis por year e professional_name, ambos opcionais)
  • Paginar a lista (page/limit, default limit: 30) — sem isso, um filtro sem terapeuta pode devolver milhares de registros de uma vez
  • Restringir o acesso a admin_user autenticado com role developer, reaproveitando a sessão Devise já existente
  • Não alterar a query manual/SQL usada hoje — ela continua existindo como referência

Fora de escopo

  • Autenticação por token para admin_user — mantém sessão
  • Exportação para Excel/CSV. Como o endpoint é paginado, a exportação não deve reusar GET /api/v1/admin/taxes. O padrão sugerido é um endpoint próprio (ex.: GET /api/v1/admin/taxes/export), com os mesmos filtros year/professional_name, sem paginação, devolvendo CSV e reaproveitando a mesma TaxSummaryQuery sem chamar #paginate. A avaliar em entrega futura.

Mudanças

  • app/controllers/api/v1/admin/base_controller.rb (novo) — < ApplicationController, before_action :authenticate_admin_user!, before_action :require_developer! (renderiza 403 se current_admin_user.developer? for false), respond_to :json. Não herda de API::BaseController porque este herda de ActionController::API, que não inclui suporte a session/cookies, necessário para a autenticação por sessão do admin_user.
  • app/controllers/api/v1/admin/taxes_controller.rb (novo) — < API::V1::Admin::BaseController, #index. Lê params[:year], params[:professional_name], params[:page] e params[:limit] (todos opcionais), chama ProfessionalPaymentInvoices::FetchTaxSummary e renderiza { taxes: [...], total:, total_pages:, current_page:, next_page:, prev_page:, limit_value: }.
  • app/queries/professional_payment_invoices/tax_summary_query.rb (novo, < BaseQuery) — parte de ProfessionalPaymentInvoice.where(status: :paid), faz .joins(:professional).order("users.name", :paid_at), aplica where(paid_at: ano...) quando year presente, #by_professional_name(name) com where("users.name ILIKE ?", "%#{sanitize_sql_like(name)}%") quando presente (retorna self sem filtrar quando blank?), includes(:professional, :professional_payment_invoice_meetings) para evitar N+1, e #paginate(page:, limit:) (DEFAULT_LIMIT = 30).
  • app/use_cases/professional_payment_invoices/fetch_tax_summary.rb (novo, < UseCaseBase) — recebe year:, professional_name:, page: e limit: opcionais, usa a query acima e expõe a página resultante (context.taxes).
  • app/serializers/tax_invoice_serializer.rb (novo, < ApplicationSerializer) — serializa cada linha (terapeuta, ano, invoice_id, status, pago_em, sessões, qtd_sessões, subtotal, taxa_plataforma, taxa_transferencia, retido_ibft, total_pago).
  • app/controllers/admin/taxes_controller.rb (novo) — < ApplicationController, before_action :authenticate_admin_user!, before_action :require_developer! (redireciona para Avo.configuration.root_path com alert se não for developer), #index chama FetchTaxSummary com year/professional_name/page/limit e expõe @taxes.
  • app/views/admin/taxes/index.html.erb (novo) — formulário GET com filtros year/professional_name, tabela com as linhas de @taxes, paginação e estado vazio. Campo “Ano” restrito a 4 caracteres numéricos (maxlength, inputmode="numeric", oninput removendo não-dígitos).
  • app/assets/stylesheets/admin/taxes.css (novo) — estilos da tela, incluindo wrapper com scroll horizontal na tabela e breakpoint responsivo (@media max-width: 640px).
  • config/routes.rb — novo namespace :admin dentro de namespace :api do namespace :v1 do ... end end, com resources :taxes, only: :index; e get "/admin/taxes", to: "admin/taxes#index" dentro do bloco authenticate :admin_user do ... end (mesmo mecanismo do Avo/GoodJob).
  • app/views/layouts/application.html.erb — <title> passa a usar content_for(:title) || "TrgClubAPI", para telas HTML fora do Avo definirem o próprio título.

Plano de implementação

  1. test: query — TaxSummaryQuery sempre filtra por status: paid, filtra por ano quando informado, pagina via #paginate(page:, limit:) respeitando o DEFAULT_LIMIT
  2. test: use case — FetchTaxSummary monta cada linha com os campos esperados e retorna a página correta
  3. test: request — GET /api/v1/admin/taxes retorna 401 sem sessão, 403 com admin sem role developer, 200 com admin developer, respeitando filtros e paginação
  4. feat: query object
  5. feat: use case
  6. feat: controllers (base admin + taxes) + rota + serializer
  7. test: query — #by_professional_name filtra por nome parcial e case-insensitive; não filtra quando blank?
  8. feat: #by_professional_name(name) na query e professional_name: no FetchTaxSummary
  9. feat: controller + view da tela admin passam a usar professional_name
  10. fix: remover professional_id/#by_professional (sem consumidor) também no endpoint JSON
  11. feat: tela /admin/taxes (controller, view, CSS, rota, título do layout) — commitada sem teste antes, quebrando o fluxo TDD dos passos 1-10; cobertura adicionada retroativamente no passo 13
  12. fix: campo “Ano” restrito a 4 dígitos e CSS responsivo — também sem teste antes
  13. test: feature — cobertura retroativa da tela /admin/taxes: redireciona para sign in sem sessão, redireciona para o Avo quando admin não é developer, mostra estado vazio, lista invoices pagas, filtra por year e por professional_name, pagina, e valida os atributos HTML do campo year

Os passos 1-10 seguiram TDD. Os passos 11-12 foram implementados direto, sem teste antes — desvio identificado na revisão e coberto retroativamente no passo 13. Índice em paid_at fica de fora a menos que apareça um problema real de performance.

Como verificar

  • make test test=spec/queries/professional_payment_invoices/tax_summary_query_spec.rb
  • make test test=spec/use_cases/professional_payment_invoices/fetch_tax_summary_spec.rb
  • make test test=spec/requests/api/v1/admin/taxes_spec.rb
  • make test test=spec/features/admin/taxes_index_spec.rb
  • Manual: autenticar como admin_user com role developer (sessão web), chamar GET /api/v1/admin/taxes?year=2025 e comparar as linhas retornadas (primeira página) com o retorno da query SQL manual rodada direto no banco para o mesmo ano
  • Manual: acessar /admin/taxes, digitar parte do nome de um terapeuta (minúsculo/parcial) no filtro e confirmar que só as invoices desse terapeuta aparecem
  • Manual: acessar /admin/taxes em viewport mobile (< 640px) e confirmar que a tabela rola horizontalmente em vez de quebrar o layout, e que o campo “Ano” só aceita dígitos (até 4 caracteres)

Documentação

Nenhuma mudança de documentação necessária — não introduz regra de negócio nova, apenas expõe (via API e tela) um dado que já existe: invoices pagas e suas taxas. O cálculo das taxas está descrito em invoice_payment_flow.