Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .claude/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ Schemas de action vs formulário: quando input tem campos `number` (ex: `dueDay`
## Cron routes

Não usam `requireUserId()` — não há sessão. Autenticam via `Authorization: Bearer ${CRON_SECRET}`. Operam direto no `db` iterando `userSettings`. Usar `Promise.allSettled` para isolamento de falhas por usuário.

Comparar o secret com `crypto.timingSafeEqual` (não `!==`) e **recusar quando `CRON_SECRET` não estiver definido** — senão o header `Bearer undefined` bateria com a env não-setada e passaria. `timingSafeEqual` exige buffers de mesmo tamanho: guardar com `provided.length === expected.length` antes.
6 changes: 6 additions & 0 deletions .claude/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ Módulos: `lib/crypto/keys.ts` (MEK, DEK, `getDekForUser`) e `lib/crypto/fields.
```
- **Drizzle relational `with: {}` retorna array vazio silenciosamente** quando tabelas relacionadas têm colunas encriptadas. Substituir por `.select()` explícitos em paralelo + agrupamento com `Map` em JS.
- **Busca/filtro textual**: `WHERE col ILIKE '%termo%'` não funciona em ciphertext. Mover filtro para JS após decrypt; ou manter campo plaintext auxiliar para busca (sem dados sensíveis).

## Gotchas de escrita e backfill

- **Toda escrita derivada precisa cifrar**: fluxos que criam `incomes`/`debtorEntries` a partir de outra entidade (splits de transação em `transactions.ts`, `settleCharge`/`createDebtPayment` em `debtors.ts`, resgates em `investments.ts`) devem passar `source`/`amount`/`description` por `encryptField` — é fácil esquecer porque o insert não é o "principal" da action. `decryptField` é backward-compat e passa plaintext adiante, então o vazamento fica invisível na UI e só aparece olhando o banco.
- **Nunca interpolar ciphertext em string**: `` `${person.name} — ${charge.description}` `` com valores cifrados gera `enc:AAA — enc:BBB`, que começa com `enc:` mas **não decifra** (auth tag GCM falha) — corrompe o campo. Para derivar uma string legível de campos cifrados: `encryptField(\`${decryptField(a, dek)} — ${decryptField(b, dek)}\`, dek)`. Para copiar um valor que já vem cifrado do banco, `encryptField(decryptField(x, dek), dek)` normaliza (robusto para plaintext antigo ou já cifrado).
- **Backfill idempotente com campo nullable**: o guard "precisa cifrar?" de coluna nullable deve ser `campo != null && !isEncrypted(campo)`. Sem o `!= null`, `!isEncrypted(null)` é sempre `true` → a linha reaparece em todo run e `encryptOptional(null)` regrava `null` (falso-positivo eterno). Ver `scripts/encrypt-existing-data.ts`, que também repara `source` de income corrompido via `isDecryptable` (try/catch no decrypt) reconstruindo a partir de `debtorEntries.incomeId`/`investmentWithdrawals.incomeId`.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ NextAuth v4, Google provider, Drizzle adapter, JWT. Padrões de action e ownersh
- Hook `PostToolUse:Write` também dispara ds-reviewer (não só `Edit`) — ao fazer múltiplas edições em arquivos de componente (ex: Sidebar, BottomNav), preferir um único `Write` completo a vários `Edit` para minimizar interrupções
- `error.tsx` em `app/(app)/` não captura erros lançados dentro do `layout.tsx` do mesmo nível (ex: falha no `auth()`, crash em `Sidebar`) — para isso é necessário `app/global-error.tsx`, que deve incluir `<html>` + `<body>` pois substitui o root layout
- `'use server'` inline em body de função dentro de arquivo `'use client'` é inválido — Next.js não suporta server actions definidas inline em Client Components; definir em arquivo separado com `'use server'` no topo e importar
- Security headers via `headers()` em `next.config.mjs`: o CSP precisa de `'unsafe-inline'` em `script-src`/`style-src` (a hidratação do Next e o script anti-flash do `next-themes` quebram sem); `'unsafe-eval'` só em dev (HMR); HSTS só em prod (não forçar HTTPS no localhost). `next/font` self-hospeda as fontes (`font-src 'self'`), `@vercel/speed-insights` é same-origin com fallback em `va.vercel-scripts.com`

**UI:**
- `incomes` não tem `categoryId` — não exibir `CategoryPicker` para tipos não-despesa
Expand Down
15 changes: 12 additions & 3 deletions __tests__/integration/actions-debtors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ describe('settleCharge', () => {
})

expect(payment?.type).toBe('payment')
expect(payment?.amount).toBe('150.00')
const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptField } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(decryptField(payment!.amount, dek)).toBe('150.00')
expect(payment?.incomeId).toBeNull()
})

Expand Down Expand Up @@ -116,7 +119,10 @@ describe('settleCharge', () => {
})

expect(income).toBeDefined()
expect(income?.amount).toBe('200.00')
const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptField } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(decryptField(income!.amount, dek)).toBe('200.00')
expect(income?.referenceMonth).toBe('2025-04-01')
})

Expand Down Expand Up @@ -241,7 +247,10 @@ describe('createDebtPayment', () => {
})

expect(income).toBeDefined()
expect(income?.amount).toBe('300.00')
const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptField } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(decryptField(income!.amount, dek)).toBe('300.00')
expect(income?.referenceMonth).toBe('2025-08-01')
})

Expand Down
40 changes: 29 additions & 11 deletions __tests__/integration/actions-investments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,16 @@ describe('createWithdrawal', () => {
destination: 'income',
})

const withdrawal = await db.query.investmentWithdrawals.findFirst({
where: eq(schema.investmentWithdrawals.investmentTypeId, type.id),
})
const income = await db.query.incomes.findFirst({
where: and(eq(schema.incomes.userId, userId), eq(schema.incomes.amount, '1000.00')),
where: eq(schema.incomes.id, withdrawal!.incomeId!),
})

expect(income?.source).toBe('Resgate investimento CDB Banco Inter')
const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptField } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(decryptField(income!.source, dek)).toBe('Resgate investimento CDB Banco Inter')
expect(vi.mocked(revalidatePath)).toHaveBeenCalledWith('/panorama')
})

Expand Down Expand Up @@ -263,12 +268,17 @@ describe('createWithdrawal', () => {
destination: 'reinvest',
})

const withdrawal = await db.query.investmentWithdrawals.findFirst({
where: eq(schema.investmentWithdrawals.investmentTypeId, type.id),
})
const income = await db.query.incomes.findFirst({
where: and(eq(schema.incomes.userId, userId), eq(schema.incomes.amount, '3450.00')),
where: eq(schema.incomes.id, withdrawal!.incomeId!),
})

const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptOptional } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(income).toBeDefined()
expect(income?.investmentReturnCapital).toBe('3000.00')
expect(decryptOptional(income!.investmentReturnCapital, dek)).toBe('3000.00')
})

it('destination=reinvest com resgate menor que capital usa o valor do resgate', async () => {
Expand All @@ -292,11 +302,16 @@ describe('createWithdrawal', () => {
destination: 'reinvest',
})

const withdrawal = await db.query.investmentWithdrawals.findFirst({
where: eq(schema.investmentWithdrawals.investmentTypeId, type.id),
})
const income = await db.query.incomes.findFirst({
where: and(eq(schema.incomes.userId, userId), eq(schema.incomes.amount, '50.00')),
where: eq(schema.incomes.id, withdrawal!.incomeId!),
})

expect(income?.investmentReturnCapital).toBe('50.00')
const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptOptional } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(decryptOptional(income!.investmentReturnCapital, dek)).toBe('50.00')
})

it('destination=income NÃO seta investmentReturnCapital (emergência)', async () => {
Expand Down Expand Up @@ -378,7 +393,10 @@ describe('updateWithdrawal', () => {
})

// min(200, 3000) = 200
expect(income?.amount).toBe('200.00')
expect(income?.investmentReturnCapital).toBe('200.00')
const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptField, decryptOptional } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(decryptField(income!.amount, dek)).toBe('200.00')
expect(decryptOptional(income!.investmentReturnCapital, dek)).toBe('200.00')
})
})
53 changes: 52 additions & 1 deletion __tests__/integration/debtors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll } from 'vitest'
import { describe, it, expect, beforeAll, vi } from 'vitest'
import { and, eq, inArray } from 'drizzle-orm'
import * as schema from '@/lib/db/schema'
import { neonTestingSetup } from './setup'
Expand All @@ -16,6 +16,13 @@ import {
createIncome,
} from './helpers/factories'

vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth/require-user', () => ({ requireUserId: vi.fn() }))
vi.mock('@/lib/auth/ownership', () => ({
assertOwnsPerson: vi.fn(),
assertOwnsDebtEntry: vi.fn(),
}))

neonTestingSetup()

let db: TestDb
Expand All @@ -31,6 +38,11 @@ beforeAll(async () => {
;({ id: accountId } = await createAccount(db, userId))
const group = await createCategoryGroup(db, userId)
;({ id: categoryId } = await createCategory(db, userId, group.id))

const { requireUserId } = await import('@/lib/auth/require-user')
vi.mocked(requireUserId).mockResolvedValue(userId)
const { assertOwnsDebtEntry } = await import('@/lib/auth/ownership')
vi.mocked(assertOwnsDebtEntry).mockResolvedValue(undefined)
})

describe('pessoas', () => {
Expand Down Expand Up @@ -413,3 +425,42 @@ describe('getPersonDebtDetails — ajustes no saldo', () => {
expect(detail.summary.totalCharged).toBe(500)
})
})

describe('updateDebtCharge', () => {
it('atualiza descrição, data e recalcula referenceMonth ao mudar de mês', async () => {
const charge = await createCharge(db, userId, personId, {
description: 'Almoço',
entryDate: '2025-01-10',
referenceMonth: '2025-01-01',
})

const { updateDebtCharge } = await import('@/lib/actions/debtors')
await updateDebtCharge({
id: charge.id,
description: 'Almoço editado',
entryDate: '2025-02-15',
notes: 'movido um mês',
})

const row = await db.query.debtorEntries.findFirst({
where: eq(schema.debtorEntries.id, charge.id),
})
expect(row?.entryDate).toBe('2025-02-15')
expect(row?.referenceMonth).toBe('2025-02-01')

const { getDekForUser } = await import('@/lib/crypto/keys')
const { decryptField, decryptOptional } = await import('@/lib/crypto/fields')
const dek = await getDekForUser(userId)
expect(decryptField(row!.description, dek)).toBe('Almoço editado')
expect(decryptOptional(row!.notes, dek)).toBe('movido um mês')
})

it('rejeita edição de lançamento que não é cobrança aberta', async () => {
const payment = await createPayment(db, userId, personId)

const { updateDebtCharge } = await import('@/lib/actions/debtors')
await expect(
updateDebtCharge({ id: payment.id, description: 'x', entryDate: '2025-01-10' })
).rejects.toThrow('Só é possível editar cobranças em aberto')
})
})
11 changes: 10 additions & 1 deletion app/api/cron/rollover-fixed-expenses/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { timingSafeEqual } from 'crypto'
import { eq, and, count } from 'drizzle-orm'
import { db } from '@/lib/db'
import { fixedExpenses, userSettings } from '@/lib/db/schema'
import { currentYearMonth, prevMonth, yearMonthToReferenceMonth } from '@/lib/utils/date'

function isAuthorized(req: Request): boolean {
const secret = process.env.CRON_SECRET
if (!secret) return false // sem secret configurado, nega tudo (evita `Bearer undefined`)
const provided = Buffer.from(req.headers.get('authorization') ?? '')
const expected = Buffer.from(`Bearer ${secret}`)
return provided.length === expected.length && timingSafeEqual(provided, expected)
}

export async function GET(req: Request) {
if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
if (!isAuthorized(req)) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}

Expand Down
16 changes: 16 additions & 0 deletions components/devedores/DebtEntryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RowActions } from '@/components/ui/row-actions'
import { PaymentWithIncomeDeleteDialog } from './PaymentWithIncomeDeleteDialog'
import { PaymentWithSettledChargesDeleteDialog } from './PaymentWithSettledChargesDeleteDialog'
import { SettleChargeDialog } from './SettleChargeDialog'
import { EditChargeDialog } from './EditChargeDialog'
import { cn } from '@/lib/utils/cn'
import { toast } from 'sonner'

Expand Down Expand Up @@ -43,11 +44,13 @@ function EntryRow({
onDeleteWithIncome,
onDeleteWithSettled,
onSettle,
onEdit,
}: {
entry: DebtEntryDetail
onDeleteWithIncome: (entry: DebtEntryDetail) => void
onDeleteWithSettled: (entry: DebtEntryDetail) => void
onSettle: (entry: DebtEntryDetail) => void
onEdit: (entry: DebtEntryDetail) => void
}) {
const isSettled = entry.status === 'settled'
const isOpenCharge = entry.type === 'charge' && (entry.status === 'open' || entry.status === null)
Expand Down Expand Up @@ -168,6 +171,7 @@ function EntryRow({
/>
) : isOpenCharge ? (
<RowActions
onEdit={() => onEdit(entry)}
additionalActions={[
{
label: 'Quitar',
Expand Down Expand Up @@ -201,6 +205,7 @@ export function DebtEntryList({ entries, personId }: Props) {
const [dialogEntry, setDialogEntry] = useState<DebtEntryDetail | null>(null)
const [settledDeleteEntry, setSettledDeleteEntry] = useState<DebtEntryDetail | null>(null)
const [settleEntry, setSettleEntry] = useState<DebtEntryDetail | null>(null)
const [editEntry, setEditEntry] = useState<DebtEntryDetail | null>(null)

if (entries.length === 0) {
return <p className="text-small text-text-tertiary">Nenhum lançamento registrado.</p>
Expand Down Expand Up @@ -280,6 +285,7 @@ export function DebtEntryList({ entries, personId }: Props) {
onDeleteWithIncome={setDialogEntry}
onDeleteWithSettled={setSettledDeleteEntry}
onSettle={setSettleEntry}
onEdit={setEditEntry}
/>
))}
</div>
Expand Down Expand Up @@ -318,6 +324,16 @@ export function DebtEntryList({ entries, personId }: Props) {
}}
/>
)}

{editEntry && (
<EditChargeDialog
entry={editEntry}
open={!!editEntry}
onOpenChange={(v) => {
if (!v) setEditEntry(null)
}}
/>
)}
</>
)
}
Loading
Loading