Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions __tests__/unit/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
calcBaseReferenceMonth,
calcInstallmentDate,
uniqueMonthsFromDates,
addMonthsToYearMonth,
installmentEndYearMonth,
} from '@/lib/utils/date'

describe('yearMonthToReferenceMonth', () => {
Expand Down Expand Up @@ -153,6 +155,41 @@ describe('nextMonth', () => {
})
})

describe('addMonthsToYearMonth', () => {
it('adds n months within the same year', () => {
expect(addMonthsToYearMonth('2025-03', 2)).toBe('2025-05')
})

it('n=0 returns the same month', () => {
expect(addMonthsToYearMonth('2025-06', 0)).toBe('2025-06')
})

it('crosses the year boundary', () => {
expect(addMonthsToYearMonth('2025-11', 3)).toBe('2026-02')
})
})

describe('installmentEndYearMonth', () => {
afterEach(() => {
vi.useRealTimers()
})

it('issue #114: ancora em nextChargeMonth, não no mês corrente', () => {
// Mês corrente 2026-08, mas a 1a parcela pendente só cai em 2026-09 (compra feita
// depois do fechamento do cartão). Ancorar no mês corrente daria 2027-07 — a
// correção precisa ancorar em nextChargeMonth e devolver 2027-08.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-08-15T12:00:00'))
expect(installmentEndYearMonth('2026-09', 12)).toBe('2027-08')
})

it('sem parcela pendente (nextChargeMonth null): cai no fallback do mês corrente', () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-08-15T12:00:00'))
expect(installmentEndYearMonth(null, 12)).toBe('2027-07')
})
})

describe('parseDate', () => {
it('parses YYYY-MM-DD without UTC offset shifting the day', () => {
const d = parseDate('2025-03-15')
Expand Down
63 changes: 63 additions & 0 deletions __tests__/unit/parcelas-toolbar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { applySort } from '@/components/parcelas/ParcelasToolbar'

type Group = Parameters<typeof applySort>[0][number]

const group = (id: string, overrides: Partial<Group> = {}): Group => ({
id,
name: `Grupo ${id}`,
categoryId: 'cat-1',
accountId: 'acc-1',
accountName: 'Conta',
categoryName: 'Categoria',
startDate: '2026-01-01',
nextChargeMonth: null,
nextChargeDate: null,
totalAmount: 0,
totalInstallments: 1,
paidInstallments: 0,
remainingInstallments: 1,
installmentAmount: 0,
remainingAmount: 0,
...overrides,
})

describe('applySort', () => {
it('soonest-end ordena por endYM (data real de término), não por remainingInstallments', () => {
// A: termina antes (out/26) mas tem mais parcelas restantes que B.
// B: termina depois (nov/26) mas tem menos parcelas restantes que A — a 1a
// parcela de B só cai em 2026-10, então "menos parcelas restantes" não significa
// "termina mais cedo" quando cada grupo parte de um nextChargeMonth diferente.
const a = group('A', { remainingInstallments: 3, endYM: '2026-10' })
const b = group('B', { remainingInstallments: 2, endYM: '2026-11' })

// Ordenar por remainingInstallments (o bug) devolveria [B, A] — B tem menos
// parcelas restantes (2 < 3) e apareceria primeiro, embora termine depois.
const sorted = applySort([b, a], 'soonest-end')

expect(sorted.map((g) => g.id)).toEqual(['A', 'B'])
})

it('soonest-end com endYM ausente cai para string vazia (não quebra o sort)', () => {
const a = group('A', { endYM: undefined })
const b = group('B', { endYM: '2026-01' })

const sorted = applySort([b, a], 'soonest-end')

expect(sorted.map((g) => g.id)).toEqual(['A', 'B'])
})

it('expensive ordena por installmentAmount decrescente', () => {
const a = group('A', { installmentAmount: 50 })
const b = group('B', { installmentAmount: 100 })

expect(applySort([a, b], 'expensive').map((g) => g.id)).toEqual(['B', 'A'])
})

it('highest-balance ordena por remainingAmount decrescente', () => {
const a = group('A', { remainingAmount: 200 })
const b = group('B', { remainingAmount: 500 })

expect(applySort([a, b], 'highest-balance').map((g) => g.id)).toEqual(['B', 'A'])
})
})
28 changes: 12 additions & 16 deletions app/(app)/parcelas/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,7 @@ import { Section } from '@/components/ui/section'
import { PageHeader } from '@/components/ui/page-header'
import { PageLayout } from '@/components/ui/page-layout'
import { formatCurrency } from '@/lib/utils/currency'
import { currentYearMonth, formatMonthShort } from '@/lib/utils/date'

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}`)
}
import { currentYearMonth, formatMonthShort, installmentEndYearMonth } from '@/lib/utils/date'

export default async function ParcelasPage() {
const session = await auth()
Expand All @@ -38,13 +30,17 @@ export default async function ParcelasPage() {
const totalAll = groups.reduce((sum, g) => sum + g.totalAmount, 0)
const paidPct = totalAll > 0 ? Math.round((totalPago / totalAll) * 100) : 0

const groupsWithEnd = groups.map((g) => ({
...g,
endLabel: calcEndLabel(currentYM, g.remainingInstallments),
}))

const endLabels = groupsWithEnd.map((g) => g.endLabel).sort()
const lastEnd = endLabels[endLabels.length - 1] ?? null
const groupsWithEnd = groups.map((g) => {
const endYM = installmentEndYearMonth(g.nextChargeMonth, g.remainingInstallments)
return { ...g, endYM, endLabel: formatMonthShort(endYM) }
Comment thread
Guiroos marked this conversation as resolved.
})

const lastEndYM =
groupsWithEnd
.map((g) => g.endYM)
.sort()
.at(-1) ?? null
const lastEnd = lastEndYM ? formatMonthShort(lastEndYM) : null

const categoryData = Object.values(
groups.reduce<Record<string, { name: string; value: number; color?: string }>>((acc, g) => {
Expand Down
5 changes: 3 additions & 2 deletions components/parcelas/ParcelasToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,20 @@ type Group = {
installmentAmount: number
remainingAmount: number
endLabel?: string
endYM?: string
}

type Sort = 'expensive' | 'highest-balance' | 'soonest-end'

function applySort(groups: Group[], sort: Sort): Group[] {
export function applySort(groups: Group[], sort: Sort): Group[] {
const copy = [...groups]
switch (sort) {
case 'expensive':
return copy.sort((a, b) => b.installmentAmount - a.installmentAmount)
case 'highest-balance':
return copy.sort((a, b) => b.remainingAmount - a.remainingAmount)
case 'soonest-end':
return copy.sort((a, b) => a.remainingInstallments - b.remainingInstallments)
return copy.sort((a, b) => (a.endYM ?? '').localeCompare(b.endYM ?? ''))
default:
return copy
}
Expand Down
17 changes: 17 additions & 0 deletions lib/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ export function nextMonth(yearMonth: string): string {
return format(addMonths(parseISO(`${yearMonth}-01`), 1), 'yyyy-MM')
}

/** Returns yearMonth plus n months, as YYYY-MM. Generalization of nextMonth. */
export function addMonthsToYearMonth(yearMonth: string, n: number): string {
return format(addMonths(parseISO(`${yearMonth}-01`), n), 'yyyy-MM')
}

/**
* Returns the YYYY-MM in which the last of `remainingInstallments` installments lands,
* anchored on `nextChargeMonth` (the month of the next pending installment). Falls back to
* the current month when there is no pending installment (`nextChargeMonth === null`).
*/
export function installmentEndYearMonth(
nextChargeMonth: string | null,
remainingInstallments: number
): string {
return addMonthsToYearMonth(nextChargeMonth ?? currentYearMonth(), remainingInstallments - 1)
}

/** Formats a YYYY-MM as "janeiro de 2025" (pt-BR). */
export function formatMonthName(yearMonth: string): string {
return fmt(parseISO(`${yearMonth}-01`), "MMMM 'de' yyyy")
Expand Down
Loading