Skip to content

[arquitetura] /parcelas calcula "termina em" na própria page, ancorado no mês atual — parcelamento que começa no mês seguinte exibe o fim um mês antes #114

Description

@Guiroos

Onde

  • app/(app)/parcelas/page.tsx:15-21calcEndLabel, aritmética de mês feita à mão dentro da page
  • app/(app)/parcelas/page.tsx:43 — uso por grupo; :46-47lastEnd, o máximo que alimenta os dois KPIs
  • lib/queries/parcelas.ts:63-77nextChargeMonth, já calculado e devolvido pela query, e ignorado pela page
  • lib/utils/date.ts — o módulo que é dono da aritmética de mês no projeto
  • Consumidores: app/(app)/parcelas/page.tsx:94 e :167 ("termina em") e components/parcelas/InstallmentGroupCard.tsx:130-134 ("termina")

Evidência

A page reimplementa aritmética de calendário e ancora o resultado no mês atual, quando o dado que ela precisa — o mês da próxima parcela — já vem pronto da query:

// app/(app)/parcelas/page.tsx:15-21
function calcEndLabel(currentYM: string, remainingInstallments: number): string {
  const [year, month] = currentYM.split('-').map(Number)
  const totalMonths = year * 12 + (month - 1) + (remainingInstallments - 1)
  const endYear = Math.floor(totalMonths / 12)
  const endMonth = String((totalMonths % 12) + 1).padStart(2, '0')
  return formatMonthShort(`${endYear}-${endMonth}`)
}

A âncora currentYM só é correta quando a próxima parcela cai no mês corrente. collectInstallmentGroups conta como pagas as parcelas com referenceMonth < currentMonthStr (lib/queries/parcelas.ts:58), então um grupo cuja 1ª parcela ainda vai vencer tem paidInstallments = 0 e remainingInstallments = totalInstallments — e a page projeta essas N parcelas a partir de hoje, não do mês em que elas começam.

Isso é o caminho comum, não um caso de borda: calcBaseReferenceMonth (lib/utils/date.ts:220-226) joga a 1ª parcela para o mês seguinte sempre que a compra é feita depois do dia de fechamento do cartão.

Rodando a função real contra o mês verdadeiro da última parcela (nextChargeMonth + remaining - 1), com mês corrente 2026-08:

OK   compra 3x, 1a parcela no mês atual:                      tela=2026-10  real=2026-10
ERRO compra 3x após o fechamento (1a parcela mês seguinte):   tela=2026-10  real=2026-11
ERRO compra 12x após o fechamento:                            tela=2027-07  real=2027-08
OK   grupo antigo 12x, 7 pagas:                               tela=2026-12  real=2026-12

O erro se propaga para os dois cards de KPI, porque lastEnd (:46-47) é o máximo dos endLabel.

Além do valor errado, o trecho contraria a regra do CLAUDE.md"usar helpers de lib/utils/date.ts — nunca construir strings de mês manualmente". grep -rn "year \* 12\|/ 12)" sobre app/, components/ e lib/ devolve só estas duas linhas: é o único lugar do código que faz aritmética de mês na mão.

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

  1. Rodei o código. Script com calcEndLabel copiada linha a linha da page, comparada com o mês real da última parcela derivado de nextChargeMonth — é a tabela acima. As duas linhas OK são o controle: grupos cuja próxima parcela é o mês corrente batem, então o defeito é a âncora e não a aritmética (que está correta, inclusive na virada de ano — 2026-08 + 11 → 2027-07). Achado sobreviveu.
  2. Foi decisão deliberada? git log --oneline -- 'app/(app)/parcelas/page.tsx' devolve um único commit, d5a498b, o commit-base do histórico esmagado; não há commit posterior no arquivo nem comentário no código explicando a âncora. Achado sobreviveu.
  3. É a convenção do repo? Não — é o único site. Todo o resto do projeto deriva mês via lib/utils/date.ts (nextMonth, prevMonth, monthOptions, futureNMonths), e o CLAUDE.md proíbe explicitamente montar string de mês à mão. Achado sobreviveu.
  4. Já é coberto indiretamente? Não. __tests__/unit/queries-parcelas.test.ts e __tests__/unit/export-full-parcelas.test.ts cobrem a query e a exportação; grep -rn "calcEndLabel\|endLabel\|termina em" __tests__ devolve zero. A função vive dentro do arquivo de page e nem é exportada, então nenhum teste consegue alcançá-la hoje. Achado sobreviveu.

Superfície tem consumidor

ParcelasToolbar é importado em app/(app)/parcelas/page.tsx:6 e renderizado em :230, recebendo groupsWithEnd; ele renderiza InstallmentGroupCard (components/parcelas/ParcelasToolbar.tsx:12 e :135), que exibe group.endLabel em :130-134. Os dois KPIs de desktop exibem lastEnd em :94 e :167. As três superfícies são renderizadas.

Impacto

Usuário com cartão de crédito que registra uma compra parcelada depois do dia de fechamento — o caso majoritário, já que a janela "depois do fechamento" cobre a maior parte do mês. Durante todo o mês entre a compra e a 1ª parcela, o card daquela compra e os dois KPIs de topo do /parcelas mostram o término um mês antes do real; no mês seguinte o valor se corrige sozinho, o que torna o erro difícil de perceber e impossível de reproduzir depois.

O desvio é permanente, e maior que um mês, para parcelamento registrado com startDate futuro: installmentSchema (lib/validations/transactions.ts:46) usa dateSchema, que não tem teto, então uma compra lançada para daqui a quatro meses fica quatro meses errada até a 1ª parcela chegar.

Cobertura

Não existe teste (item 4 acima), e a função não é exportável de dentro da page — extrair é pré-requisito para cobrir.

Caso que só a correção certa passa:

Com o mês corrente fixado em 2026-08 (vi.useFakeTimers({ toFake: ['Date'] }), conforme .claude/testing.md), um grupo com nextChargeMonth = '2026-09' e remainingInstallments = 12 deve terminar em 2027-08.

Hoje devolve 2027-07. E, principalmente: a correção errada mais provável — manter a âncora em currentYM e apenas trocar a aritmética manual pelo helper, "porque o CLAUDE.md pede o helper" — continua devolvendo 2027-07 e reprova neste caso. Um teste escrito com um grupo cujo nextChargeMonth é o próprio mês corrente passa com o bug intacto (são as duas linhas OK da varredura) e não cobre nada.

Incluir também o grupo com nextChargeMonth = null (possível quando parcelas individuais foram excluídas: remainingInstallments > 0 sem nenhuma transação futura), assertando o fallback.

Proposta

  1. Ancorar em g.nextChargeMonth em vez de currentYM, com fallback para currentYM quando for null:
    const endYM = addMonthsToYearMonth(g.nextChargeMonth ?? currentYM, g.remainingInstallments - 1).
  2. Mover a aritmética para lib/utils/date.ts como addMonthsToYearMonth(yearMonth: string, n: number): string, implementada com addMonths + format do date-fns — as mesmas peças de nextMonth (date.ts:96-98), do qual ela é a generalização.

Por que um helper novo e não os que já existem: futureNMonths(n) é o helper óbvio e é exatamente o errado aqui — ele ancora em startOfMonth(new Date()), ou seja, no mês atual, que é a âncora que produziu o bug. monthOptions(center, back, forward).at(-1) daria o valor certo, mas materializa a faixa inteira e o contrato dele é "lista de opções para um seletor de mês", não aritmética. nextMonth só anda um mês. Não mexer em getInstallmentTimeline (lib/queries/parcelas.ts:106-108), que usa futureNMonths(12): ali a âncora em hoje é o comportamento correto, porque a linha do tempo é dos próximos 12 meses de calendário.

Não alterar paidInstallments / remainingInstallments (lib/queries/parcelas.ts:58-61): eles estão corretos e são consumidos também pela exportação (lib/export/full/parcelas.ts:29-30).

Custo estimado

M (2 arquivos de código — app/(app)/parcelas/page.tsx e lib/utils/date.ts — + 1 de teste)

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

    arquiteturaclaude-auditAchado da auditoria automáticaclaude-wipJá tem PR aberto, não pegar de novo

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions