Skip to content

[dados] Recorte do /historico não alcança entradas e aportes — filtrar 15 a 31 de agosto devolve o salário de 01/08, sob um cabeçalho de data fora do intervalo #134

Description

@Guiroos

Onde

lib/queries/historico.ts:252-253 — o filtro de precisão do recorte existe, mas cobre só um dos três tipos que precisam dele:

// Filtro JS de precisão para fixedExpenses (dueDay pode colocar fora do range)
const fxItemsFiltered = fxItems.filter((f) => f.date >= de && f.date <= ate)

Os itens que ficam de fora dele:

  • lib/queries/historico.ts:193-208incomeItems, date: i.referenceMonth
  • lib/queries/historico.ts:210-230investItems, date: inv.referenceMonth

Evidência

Dos 5 tipos que collectHistoricoItems mistura, 3 garantem que a data exibida cai dentro de [de, ate] e 2 não — a minoria é o defeito (categoria 5 do .claude/audit.md), contada no recorte da própria função e não no repo inteiro:

Tipo Filtro de data Dentro de [de,ate]?
saida_avulsa / saida_parcelada between(transactions.date, de, ate) — SQL (:81)
resgate between(investmentWithdrawals.date, de, ate) — SQL (:108)
saida_fixa mês no IN + filtro JS pela data de exibição (:253)
entrada inArray(incomes.referenceMonth, refMonths) (:98)
investimento inArray(investments.referenceMonth, refMonths) (:103)

referenceMonthsInRange (:51-61) expande o intervalo para meses inteiros — e está certo em fazer isso, porque um gasto fixo com referenceMonth = M pode exibir em M+1 via dueDay (é exatamente o que __tests__/integration/export-queries.test.ts:174-202 prova). Só que incomes.referenceMonth e investments.referenceMonth são sempre YYYY-MM-01 (referenceMonthSchema, lib/validations/utils.ts:50-52: /^\d{4}-\d{2}-01$/), e o item é exibido nessa data. Então todo mês que o IN traz por sobreposição entrega entrada/aporte datados no dia 1º, mesmo quando de é posterior.

Rodei a aritmética do recorte isoladamente (as duas funções copiadas de historico.ts, sem dependência):

de=2026-08-15 ate=2026-08-31 refMonths=["2026-08-01"]
   entrada/aporte datados FORA de [de,ate] e mesmo assim devolvidos: ["2026-08-01"]
   (gasto fixo dueDay=1 no mesmo mês -> exibe 2026-08-01 -> mantido? false)

de=2026-08-10 ate=2026-08-20 refMonths=["2026-08-01"]
   entrada/aporte datados FORA de [de,ate] e mesmo assim devolvidos: ["2026-08-01"]
   (gasto fixo dueDay=1 no mesmo mês -> exibe 2026-08-01 -> mantido? false)

de=2026-06-02 ate=2026-08-31 refMonths=["2026-06-01","2026-07-01","2026-08-01"]
   entrada/aporte datados FORA de [de,ate] e mesmo assim devolvidos: ["2026-06-01"]
   (gasto fixo dueDay=1 no mesmo mês -> exibe 2026-06-01 -> mantido? false)

A terceira linha é o intervalo default (últimos 90 dias, historico-params.ts:30-34), então o vazamento não depende de o usuário mexer em filtro nenhum.

E a incoerência não é teórica: na segunda linha, um gasto fixo com dueDay = 1 e uma entrada, ambos exibidos em 2026-08-01, dentro da mesma chamada e do mesmo intervalo — o gasto fixo é descartado pela linha 253 e a entrada passa. A mesma função já decidiu que data de exibição fora da janela desqualifica o item; ela só não aplicou a decisão a dois dos cinco tipos.

Verificações feitas (tentativa de falsificar o achado)

  1. "Entrada é mensal, então mês-sobrepõe-intervalo seria o design correto." Não se sustenta: o item tem data concreta, ela é renderizada como cabeçalho de grupo, e o próprio arquivo já rejeitou essa leitura para gasto fixo na linha 253. Um mesmo 2026-08-01 sair e entrar no mesmo resultado não é granularidade de mês, é inconsistência.
  2. referenceMonth pode ter dia diferente de 01? Não — referenceMonthSchema é regex ancorada em -01. Todo item de entrada/aporte é datado no dia 1º, logo qualquer de posterior ao dia 1º do mês vaza.
  3. Existe omissão além do vazamento? Não. refMonths sempre contém todos os meses que sobrepõem o intervalo, e a data do item é o 1º daquele mês — o erro é só inclusão indevida, nunca item faltando. Isso mantém o impacto simples e a correção segura.
  4. A superfície tem consumidor? (exigência 7) Duas, ambas vivas: app/(app)/historico/page.tsx:26HistoricoClient, que agrupa por item.date e imprime um cabeçalho por data (HistoricoClient.tsx:48-51 monta os grupos, :212-215 renderiza date={formatGroupDate(date)}); e app/api/export/extrato/route.ts:22, que exporta os mesmos itens em .xlsx/.csv.
  5. Já existe issue? Não. As três issues vizinhas de /historico são de outra coisa: [types] parseHistoricoParams tipa de/ate como datas mas devolve a query string crua, sem validação #32 (validação de de/ate), [types] ?categorias=/?contas= do /historico chegam crus às colunas uuid — a mesma função valida tipos, de e ate três linhas acima #110 (?categorias=/?contas= sem validar UUID), [dados] Exportação completa descarta gasto fixo que vence depois do teto — o teto é MAX(referenceMonth), a exibição é referenceMonth + dueDay #97 (teto da exportação completa para gasto fixo). search_issues por collectHistoricoItems/referenceMonthsInRange não devolve nada desta natureza.
  6. git log -S "Filtro JS de precisão para fixedExpenses" → só 98c85f4, o import inicial do repo. Não há commit defendendo a exclusão de entradas/aportes do filtro; a linha nasceu resolvendo o caso do dueDay e nunca foi generalizada.

Impacto

Qualquer usuário que estreite o recorte do /historico para dentro de um mês — "primeira quinzena", "de 10 a 20", conferir uma semana específica. A entrada de salário e os aportes do mês inteiro aparecem no feed, sob um cabeçalho de data anterior ao início que ele acabou de escolher. Como entrada costuma ser o maior valor do mês, o extrato de um recorte de 5 dias pode vir dominado por um lançamento que não pertence a ele.

O mesmo resultado vai para o arquivo exportado, cujo nome afirma o recorte: mare-extrato-2026-08-15-a-2026-08-31.xlsx (app/api/export/extrato/route.ts:25-27) contendo uma linha datada 2026-08-01.

O intervalo default (90 dias) também vaza, mas só o mês da ponta — um item. O caso caro é o recorte curto e deliberado.

Cobertura

Existe teste sobre a função, e é preciso dizer o que ele garante (exigência 3), porque ele aponta para a correção errada:

  • __tests__/unit/historico-merge.test.ts:95-108 testa referenceMonthsInRange isolada e assere a expansão para meses inteiros ('2024-12-15','2025-01-10'['2024-12-01','2025-01-01']). Esse comportamento está correto e deve continuar verde — estreitar referenceMonthsInRange é a "correção óbvia" e é a errada.
  • __tests__/integration/export-queries.test.ts:174-202 é a prova de que estreitar quebra: um "Aluguel" com referenceMonth 2024-06-01 e dueDay 20 só entra no recorte porque o mês 2024-06-01 está no IN. Se o IN passar a exigir o mês inteiro dentro de [de,ate], esse teste fica vermelho.

Ou seja: a suíte hoje protege o pré-filtro do SQL e não cobre nada do recorte final. Nenhum teste chama collectHistoricoItems com de posterior ao dia 1º.

Caso proposto (integração, junto de export-queries.test.ts ou em arquivo próprio de collectHistoricoItems), com a entrada que só a correção certa rejeita:

  • Usuário com uma income em referenceMonth = 2025-08-01 e um fixedExpense em referenceMonth = 2025-08-01, dueDay = 25.
  • collectHistoricoItems({ de: '2025-08-15', ate: '2025-08-31', tipos: [...ALL_TIPOS], ... })
  • Esperar: a entrada não aparece (falha hoje) e o gasto fixo aparece (exibe em 2025-08-25).

O gasto fixo no mesmo caso é o que dá o poder discriminante: ele obriga o mês 2025-08-01 a continuar no IN. Um teste só com a entrada passaria também na correção errada (estreitar referenceMonthsInRange), que derruba o gasto fixo junto e quebra export-queries.test.ts — a suíte acusaria em outro arquivo, longe da mudança, e o sinal se perderia. Com os dois no mesmo it, a única implementação que passa é a certa.

Proposta

Promover o filtro da linha 253 de "só fxItems" para "todo o feed", aplicando-o depois do merge em lib/queries/historico.ts:

const merged = mergeAndSortFeedItems([txItems, fxItems, incomeItems, investItems, withdrawItems])
  .filter((item) => item.date >= de && item.date <= ate)

e remover fxItemsFiltered (:253), que passa a ser redundante.

Por que esse predicado nesse ponto, e não um gte/lte sobre referenceMonth no SQL de incomes/investments (exigência 6): o where do SQL continua tendo de trazer meses inteiros por causa do gasto fixo, então um bound adicional só para duas das cinco tabelas passaria a expressar "o que está no recorte" em dois lugares com regras diferentes — que é o defeito que esta issue relata, reintroduzido um nível abaixo. O filtro único pós-merge deixa uma definição só, opera sobre HistoricoFeedItem.date (o campo que a UI de fato imprime) e é no-op para txItems/withdrawItems, já filtrados no SQL.

Efeito colateral desejável: getHistoricoFeed pagina sobre essa lista (:277-287), então a contagem, o hasMore e o cursor passam a refletir o recorte pedido.

Custo estimado

P — 1 arquivo (lib/queries/historico.ts), duas linhas.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions