Onde
app/(app)/parcelas/page.tsx:15-21 — calcEndLabel, aritmética de mês feita à mão dentro da page
app/(app)/parcelas/page.tsx:43 — uso por grupo; :46-47 — lastEnd, o máximo que alimenta os dois KPIs
lib/queries/parcelas.ts:63-77 — nextChargeMonth, 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)
- 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.
- 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.
- É 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.
- 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
- Ancorar em
g.nextChargeMonth em vez de currentYM, com fallback para currentYM quando for null:
const endYM = addMonthsToYearMonth(g.nextChargeMonth ?? currentYM, g.remainingInstallments - 1).
- 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)
Onde
app/(app)/parcelas/page.tsx:15-21—calcEndLabel, aritmética de mês feita à mão dentro da pageapp/(app)/parcelas/page.tsx:43— uso por grupo;:46-47—lastEnd, o máximo que alimenta os dois KPIslib/queries/parcelas.ts:63-77—nextChargeMonth, já calculado e devolvido pela query, e ignorado pela pagelib/utils/date.ts— o módulo que é dono da aritmética de mês no projetoapp/(app)/parcelas/page.tsx:94e:167("termina em") ecomponents/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:
A âncora
currentYMsó é correta quando a próxima parcela cai no mês corrente.collectInstallmentGroupsconta como pagas as parcelas comreferenceMonth < currentMonthStr(lib/queries/parcelas.ts:58), então um grupo cuja 1ª parcela ainda vai vencer tempaidInstallments = 0eremainingInstallments = 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 corrente2026-08:O erro se propaga para os dois cards de KPI, porque
lastEnd(:46-47) é o máximo dosendLabel.Além do valor errado, o trecho contraria a regra do
CLAUDE.md— "usar helpers delib/utils/date.ts— nunca construir strings de mês manualmente".grep -rn "year \* 12\|/ 12)"sobreapp/,components/elib/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)
calcEndLabelcopiada linha a linha da page, comparada com o mês real da última parcela derivado denextChargeMonth— é a tabela acima. As duas linhasOKsã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.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.lib/utils/date.ts(nextMonth,prevMonth,monthOptions,futureNMonths), e oCLAUDE.mdproíbe explicitamente montar string de mês à mão. Achado sobreviveu.__tests__/unit/queries-parcelas.test.tse__tests__/unit/export-full-parcelas.test.tscobrem 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 emapp/(app)/parcelas/page.tsx:6e renderizado em:230, recebendogroupsWithEnd; ele renderizaInstallmentGroupCard(components/parcelas/ParcelasToolbar.tsx:12e:135), que exibegroup.endLabelem:130-134. Os dois KPIs de desktop exibemlastEndem:94e: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
/parcelasmostram 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
startDatefuturo:installmentSchema(lib/validations/transactions.ts:46) usadateSchema, 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:
Hoje devolve
2027-07. E, principalmente: a correção errada mais provável — manter a âncora emcurrentYMe apenas trocar a aritmética manual pelo helper, "porque oCLAUDE.mdpede o helper" — continua devolvendo2027-07e reprova neste caso. Um teste escrito com um grupo cujonextChargeMonthé o próprio mês corrente passa com o bug intacto (são as duas linhasOKda varredura) e não cobre nada.Incluir também o grupo com
nextChargeMonth = null(possível quando parcelas individuais foram excluídas:remainingInstallments > 0sem nenhuma transação futura), assertando o fallback.Proposta
g.nextChargeMonthem vez decurrentYM, com fallback paracurrentYMquando fornull:const endYM = addMonthsToYearMonth(g.nextChargeMonth ?? currentYM, g.remainingInstallments - 1).lib/utils/date.tscomoaddMonthsToYearMonth(yearMonth: string, n: number): string, implementada comaddMonths+formatdodate-fns— as mesmas peças denextMonth(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 emstartOfMonth(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.nextMonthsó anda um mês. Não mexer emgetInstallmentTimeline(lib/queries/parcelas.ts:106-108), que usafutureNMonths(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.tsxelib/utils/date.ts— + 1 de teste)