Exportação CSV da tela admin de impostos no layout eNotas

TLDR: /admin/taxes ganha um botão que baixa um CSV de 50 colunas no layout de importação de vendas do eNotas, com as mesmas linhas e filtros da tela.

Contexto

A spec 20260811153509_admin_taxes_enotas_export já trocou a base da tela /admin/taxes de ProfessionalPaymentInvoice para Meeting e reescreveu TaxInvoiceSerializer com as colunas do eNotas. Mas ela deixou explicitamente fora de escopo “gerar o CSV de fato (export/download)” — a montagem do arquivo enviado ao eNotas seguiu sendo feita fora da plataforma.

Esta entrega fecha esse ciclo: a mesma listagem que já aparece na tela passa a ser baixável no formato exato que o eNotas importa.

O layout é um contrato externo do eNotas (Importador de Vendas): 50 colunas fixas, na ordem, separadas por ;. O export desta entrega cobre a perspectiva do terapeuta — dados do terapeuta e valor da taxa retida (10%), que é o que a tela já exibe.

Decisões tomadas no brainstorm

Tema Decisão
ChaveUnica meeting.id puro, sem prefixo — mantém o CSV idêntico ao que a tela já mostra na coluna Sessão
Venda_Data order.updated_at, como o serializer já faz — não meeting.start_at. O filtro de período também é por orders.updated_at, então CSV, tela e filtro batem entre si
Endereço TaxInvoiceSerializer passa a expor os 7 campos separados que o CSV exige; cliente_endereco continua existindo como composição deles. A tela não muda visualmente
Paginação FetchTaxSummary vira FetchTaxes, com paginação opcional — uma única porta de entrada para tela, API e export. A branch abandonada feat/export-taxes criou um FetchTaxExport separado e depois o unificou (commit d4739e30); vamos direto ao destino
Rota Só GET /admin/taxes/export, dentro do authenticate :admin_user. Sem versão na API JSON — não há consumidor programático hoje
Truncamento Feito no builder do CSV, não no serializer — os limites de tamanho são exigência do eNotas, e truncar no serializer cortaria também o que aparece na tela

Revisões durante a entrega

Tema Decisão
Paginação nas telas Tornar a paginação opcional no use case quebrou as duas telas que já existiam: Admin::TaxesController#index e o endpoint JSON não passavam page quando o usuário não navegava, e a view chama @taxes.current_page/total_pages (Kaminari), que não existem numa relation sem paginar. Regressão pega por 12 specs. Ambos os controllers passaram a mandar page: params[:page].presence || 1 — só o export chama o use case sem paginação
Colapso de espaços sanitize no builder usa squish, não só a troca de ; por espaço: "Ana; Silva" virava "Ana Silva" (espaço duplo). Isso também resolve quebra de linha dentro de campo

Objetivos

  • Botão “Exportar CSV” em /admin/taxes que baixa o arquivo respeitando os filtros aplicados na tela (start_period, end_period, professional_name)
  • Gerar o arquivo no layout eNotas: 50 colunas na ordem exata, separador ;, UTF-8, header obrigatório, todas as 50 posições presentes em cada linha mesmo quando vazias
  • Exportar todas as linhas do filtro, sem paginação — a tela pagina, o arquivo não
  • Unificar FetchTaxSummary e o export em um único FetchTaxes com paginação opcional
  • Versionar o layout eNotas como documentação de referência em .project/docs/reference/

Fora de escopo

  • Endpoint GET /api/v1/admin/taxes/export na API JSON — sem consumidor conhecido (YAGNI)
  • Export na perspectiva do paciente (valor cheio da sessão) — esta entrega só cobre a do terapeuta
  • Integração via API com o eNotas (emissão automática de notas) — o arquivo continua sendo enviado manualmente pelo Importador de Vendas
  • Mudança visual na tela /admin/taxes — a tabela segue com as mesmas 8 colunas e o endereço concatenado
  • Geração assíncrona / via job — o volume atual (sessões de um período) é gerado na request

Mudanças

Serializer

  • app/serializers/tax_invoice_serializer.rb — os métodos privados cidade_uf, cep e pais e os campos crus do endereço viram atributos públicos: cliente_endereco_cidade (address.district), cliente_endereco_uf, cliente_endereco_cep, cliente_endereco_logradouro (address.street), cliente_endereco_numero (address.number), cliente_endereco_bairro (address.neighborhood) e cliente_endereco_pais. cliente_endereco continua existindo, agora montado a partir desses atributos ("logradouro, número, bairro, cidade - UF, CEP, país", pulando partes ausentes), para a tela e a API JSON seguirem funcionando sem mudança. A normalização já existente (remoção de ;, UF via STATE_ABBREVIATIONS com remoção de acentos, CEP em 8 dígitos com "00000000" tratado como ausente, país "Brasil" quando country_code é BR ou ausente) é preservada, só muda de lugar

Use case

  • app/use_cases/professional_payment_invoices/fetch_tax_summary.rb → fetch_taxes.rb — renomeado para ProfessionalPaymentInvoices::FetchTaxes. Só chama #paginate quando context.page ou context.limit estiverem presentes; caso contrário devolve a coleção completa. Expõe context.taxes como antes
  • app/use_cases/professional_payment_invoices/build_enotas_csv.rb (novo, < UseCaseBase) — recebe taxes: (as sessões) e expõe context.csv (string). Guarda COLUMNS, a lista congelada dos 50 nomes de coluna na ordem do layout, e MAX_LENGTHS, o mapa de limites por coluna. Monta o header e uma linha por sessão via TaxInvoiceSerializer, escrevendo nil nas colunas não preenchidas. Usa CSV.generate(col_sep: ";"). Cada valor passa por uma sanitização final: remove ;, colapsa quebras de linha e trunca no limite da coluna quando houver
  • app/use_cases/professional_payment_invoices/export_taxes_csv_flow.rb (novo, < UseCaseBase::Flow) — encadeia FetchTaxes e BuildEnotasCsv, recebendo os filtros e expondo context.csv

Mapeamento das 50 colunas

Preenchidas (14 de 50):

# Coluna Origem Máx.
1 ChaveUnica meeting.id 1000
2 Cliente_NomeRazaoSocial nome do terapeuta 115
4 Cliente_Documento CPF do terapeuta, só dígitos 14
5 Cliente_Email e-mail do terapeuta 80
6 Cliente_EnderecoCidade address.district —
7 Cliente_EnderecoUF UF normalizada (2 letras) 2
8 Cliente_EnderecoCEP CEP em 8 dígitos 8
9 Cliente_Endereco address.street 125
10 Cliente_EnderecoNumero address.number 10
12 Cliente_EnderecoBairro address.neighborhood 30
13 Cliente_EnderecoPais "Brasil" ou address.country —
18 Produto_Nome constante "ATENDIMENTO TERAPEUTICO" 255
21 Venda_ValorTotal taxa retida (CalculateFee, 10%), formato 0.00 —
22 Venda_Data order.updated_at em DD/MM/AAAA —

As 36 colunas restantes (Cliente_NomeFantasia, Cliente_EnderecoComplemento, Cliente_Telefone, Cliente_TipoPessoa, Cliente_InscricaoMunicipal, Cliente_InscricaoEstadual, Produto_IDExterno, Produto_ValorTotal, Venda_MeioPagamento, Venda_DataVencimento e toda a família NFe_*) saem vazias, mas presentes — a linha precisa manter os 50 delimitadores nas posições corretas. NFe_CNAE e NFe_CodigoServicoMunicipio ficam vazios porque já estão configurados no cadastro da empresa no eNotas (Empresa > Dados municipais).

Controller, rota e view

  • config/routes.rb — get "/admin/taxes/export", to: "admin/taxes#export" dentro do bloco authenticate :admin_user, antes/junto da rota de index já existente
  • app/controllers/admin/taxes_controller.rb — #index passa a chamar FetchTaxes; nova action #export chama ExportTaxesCsvFlow com start_period, end_period e professional_name (sem page/limit) e responde send_data csv, filename:, type: "text/csv; charset=utf-8". O before_action :authenticate_admin_user!/:require_developer! já cobre a nova action. Nome do arquivo: impostos-enotas-<start>-<end>.csv com as barras do período trocadas por - (ex.: impostos-enotas-01-2026-12-2026.csv); sem filtro de período, impostos-enotas.csv
  • app/controllers/api/v1/admin/taxes_controller.rb — passa a chamar FetchTaxes (segue mandando page/limit, então continua paginado)
  • app/views/admin/taxes/index.html.erb — botão “Exportar CSV” no taxes-header, como link_to admin_taxes_export_path(request.query_parameters), preservando os filtros ativos. Renderizado sempre, inclusive no estado vazio
  • app/assets/stylesheets/admin/taxes.css — estilo do botão de export, alinhado à direita no header, seguindo o padrão visual do botão “Filtrar”

Testes (TDD)

  • spec/use_cases/professional_payment_invoices/fetch_taxes_spec.rb — renomeado de fetch_tax_summary_spec.rb: pagina quando page/limit vêm preenchidos, devolve a coleção inteira quando ambos estão ausentes, e mantém os casos de filtro por período/nome
  • spec/use_cases/professional_payment_invoices/build_enotas_csv_spec.rb (novo) — header com os 50 nomes na ordem exata; cada linha com 50 campos; colunas não preenchidas presentes e vazias; separador ;; valor com ponto e 2 casas; data em DD/MM/AAAA; ; no nome do terapeuta substituído; nome acima de 115 caracteres truncado
  • spec/features/admin/taxes_index_spec.rb — botão “Exportar CSV” presente, e o link carrega os filtros ativos da tela
  • spec/requests/admin/taxes_export_spec.rb (novo) — GET /admin/taxes/export redireciona sem sessão, redireciona para o Avo quando o admin não é developer, e devolve text/csv com as linhas do período para um admin developer. Sem mocks, conforme a regra de request specs
  • spec/requests/api/v1/admin/taxes_spec.rb — ajustado para o rename de FetchTaxSummary para FetchTaxes (comportamento inalterado)

Como verificar

  • make test test=spec/use_cases/professional_payment_invoices/fetch_taxes_spec.rb
  • make test test=spec/use_cases/professional_payment_invoices/build_enotas_csv_spec.rb
  • make test test=spec/requests/admin/taxes_export_spec.rb
  • make test test=spec/requests/api/v1/admin/taxes_spec.rb
  • make test test=spec/features/admin/taxes_index_spec.rb
  • make lint
  • Manual: acessar /admin/taxes, filtrar um período, clicar em “Exportar CSV” e conferir que o arquivo baixado tem o mesmo conjunto de linhas da listagem (somando todas as páginas, não só a primeira)
  • Manual: abrir o CSV baixado e conferir contra o checklist do layout eNotas — header com 50 colunas, 49 ; por linha, datas DD/MM/AAAA, valores com ponto, nenhum ; dentro de campo
  • Manual: subir o arquivo no Importador de Vendas do eNotas em ambiente de teste e confirmar que as linhas são reconhecidas sem erro de layout

Documentação

  • .project/docs/reference/payments/enotas_csv_layout.md (novo) — layout do CSV de importação do eNotas e o mapeamento coluna a coluna com o nosso domínio, conforme detalhado acima
  • .project/docs/README.md — entrada nova para o doc de referência
  • Nenhuma mudança em rules/ — não há regra de negócio nova: a taxa de 10% continua em ProfessionalPaymentInvoices::CalculateFee e os critérios de elegibilidade da sessão já foram definidos na spec anterior. O fluxo de repasse segue descrito em invoice_payment_flow