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/taxesque 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/taxesretornando a mesma informação da query manual (lista de invoices pagas, filtráveis poryeareprofessional_name, ambos opcionais) - Paginar a lista (
page/limit, defaultlimit: 30) — sem isso, um filtro sem terapeuta pode devolver milhares de registros de uma vez - Restringir o acesso a
admin_userautenticado com roledeveloper, 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 filtrosyear/professional_name, sem paginação, devolvendo CSV e reaproveitando a mesmaTaxSummaryQuerysem 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 securrent_admin_user.developer?forfalse),respond_to :json. Não herda deAPI::BaseControllerporque este herda deActionController::API, que não inclui suporte asession/cookies, necessário para a autenticação por sessão doadmin_user.app/controllers/api/v1/admin/taxes_controller.rb(novo) —< API::V1::Admin::BaseController,#index. Lêparams[:year],params[:professional_name],params[:page]eparams[:limit](todos opcionais), chamaProfessionalPaymentInvoices::FetchTaxSummarye 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 deProfessionalPaymentInvoice.where(status: :paid), faz.joins(:professional).order("users.name", :paid_at), aplicawhere(paid_at: ano...)quandoyearpresente,#by_professional_name(name)comwhere("users.name ILIKE ?", "%#{sanitize_sql_like(name)}%")quando presente (retornaselfsem filtrar quandoblank?),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) — recebeyear:,professional_name:,page:elimit: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 paraAvo.configuration.root_pathcom alert se não for developer),#indexchamaFetchTaxSummarycomyear/professional_name/page/limite expõe@taxes.app/views/admin/taxes/index.html.erb(novo) — formulário GET com filtrosyear/professional_name, tabela com as linhas de@taxes, paginação e estado vazio. Campo “Ano” restrito a 4 caracteres numéricos (maxlength,inputmode="numeric",oninputremovendo 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— novonamespace :admindentro denamespace :api do namespace :v1 do ... end end, comresources :taxes, only: :index; eget "/admin/taxes", to: "admin/taxes#index"dentro do blocoauthenticate :admin_user do ... end(mesmo mecanismo do Avo/GoodJob).app/views/layouts/application.html.erb—<title>passa a usarcontent_for(:title) || "TrgClubAPI", para telas HTML fora do Avo definirem o próprio título.
Plano de implementação
- test: query —
TaxSummaryQuerysempre filtra porstatus: paid, filtra por ano quando informado, pagina via#paginate(page:, limit:)respeitando oDEFAULT_LIMIT - test: use case —
FetchTaxSummarymonta cada linha com os campos esperados e retorna a página correta - test: request —
GET /api/v1/admin/taxesretorna 401 sem sessão, 403 com admin sem roledeveloper, 200 com admindeveloper, respeitando filtros e paginação - feat: query object
- feat: use case
- feat: controllers (base admin + taxes) + rota + serializer
- test: query —
#by_professional_namefiltra por nome parcial e case-insensitive; não filtra quandoblank? - feat:
#by_professional_name(name)na query eprofessional_name:noFetchTaxSummary - feat: controller + view da tela admin passam a usar
professional_name - fix: remover
professional_id/#by_professional(sem consumidor) também no endpoint JSON - 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 - fix: campo “Ano” restrito a 4 dígitos e CSS responsivo — também sem teste antes
- 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 poryeare porprofessional_name, pagina, e valida os atributos HTML do campoyear
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.rbmake test test=spec/use_cases/professional_payment_invoices/fetch_tax_summary_spec.rbmake test test=spec/requests/api/v1/admin/taxes_spec.rbmake test test=spec/features/admin/taxes_index_spec.rb- Manual: autenticar como
admin_usercom roledeveloper(sessão web), chamarGET /api/v1/admin/taxes?year=2025e 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/taxesem 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.