diff --git a/.claude/auth.md b/.claude/auth.md index 1d43516..e5921b6 100644 --- a/.claude/auth.md +++ b/.claude/auth.md @@ -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. diff --git a/.claude/crypto.md b/.claude/crypto.md index 0320f6f..fcf2d32 100644 --- a/.claude/crypto.md +++ b/.claude/crypto.md @@ -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`. diff --git a/CLAUDE.md b/CLAUDE.md index 83a3741..31ffaae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` + `` 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 diff --git a/__tests__/integration/actions-debtors.test.ts b/__tests__/integration/actions-debtors.test.ts index af9375c..9389dc3 100644 --- a/__tests__/integration/actions-debtors.test.ts +++ b/__tests__/integration/actions-debtors.test.ts @@ -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() }) @@ -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') }) @@ -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') }) diff --git a/__tests__/integration/actions-investments.test.ts b/__tests__/integration/actions-investments.test.ts index f3ea409..4f909e1 100644 --- a/__tests__/integration/actions-investments.test.ts +++ b/__tests__/integration/actions-investments.test.ts @@ -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') }) @@ -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 () => { @@ -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 () => { @@ -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') }) }) diff --git a/__tests__/integration/debtors.test.ts b/__tests__/integration/debtors.test.ts index 21778ad..01f7127 100644 --- a/__tests__/integration/debtors.test.ts +++ b/__tests__/integration/debtors.test.ts @@ -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' @@ -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 @@ -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', () => { @@ -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') + }) +}) diff --git a/app/api/cron/rollover-fixed-expenses/route.ts b/app/api/cron/rollover-fixed-expenses/route.ts index c46464c..8c7d099 100644 --- a/app/api/cron/rollover-fixed-expenses/route.ts +++ b/app/api/cron/rollover-fixed-expenses/route.ts @@ -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 }) } diff --git a/components/devedores/DebtEntryList.tsx b/components/devedores/DebtEntryList.tsx index 8609fb4..e5d2689 100644 --- a/components/devedores/DebtEntryList.tsx +++ b/components/devedores/DebtEntryList.tsx @@ -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' @@ -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) @@ -168,6 +171,7 @@ function EntryRow({ /> ) : isOpenCharge ? ( onEdit(entry)} additionalActions={[ { label: 'Quitar', @@ -201,6 +205,7 @@ export function DebtEntryList({ entries, personId }: Props) { const [dialogEntry, setDialogEntry] = useState(null) const [settledDeleteEntry, setSettledDeleteEntry] = useState(null) const [settleEntry, setSettleEntry] = useState(null) + const [editEntry, setEditEntry] = useState(null) if (entries.length === 0) { return

Nenhum lançamento registrado.

@@ -280,6 +285,7 @@ export function DebtEntryList({ entries, personId }: Props) { onDeleteWithIncome={setDialogEntry} onDeleteWithSettled={setSettledDeleteEntry} onSettle={setSettleEntry} + onEdit={setEditEntry} /> ))} @@ -318,6 +324,16 @@ export function DebtEntryList({ entries, personId }: Props) { }} /> )} + + {editEntry && ( + { + if (!v) setEditEntry(null) + }} + /> + )} ) } diff --git a/components/devedores/EditChargeDialog.tsx b/components/devedores/EditChargeDialog.tsx new file mode 100644 index 0000000..a09d63a --- /dev/null +++ b/components/devedores/EditChargeDialog.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useState, useTransition } from 'react' +import { DebtEntryDetail } from '@/lib/queries/debtors' +import { updateDebtCharge } from '@/lib/actions/debtors' +import { updateDebtChargeSchema } from '@/lib/validations/debtors' +import { formatZodErrors } from '@/lib/validations/utils' +import { formatCurrency } from '@/lib/utils/currency' +import { Button } from '@/components/ui/button' +import { Field } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' +import { useMediaQuery } from '@/hooks/use-media-query' +import { toast } from 'sonner' + +type Props = { + entry: DebtEntryDetail + open: boolean + onOpenChange: (v: boolean) => void +} + +export function EditChargeDialog({ entry, open, onOpenChange }: Props) { + const [isPending, startTransition] = useTransition() + const [errors, setErrors] = useState>({}) + const isDesktop = useMediaQuery('(min-width: 1024px)') + + const handleOpenChange = (v: boolean) => { + onOpenChange(v) + if (!v) setErrors({}) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const fd = new FormData(e.currentTarget) + const data = { + id: entry.id, + description: (fd.get('description') as string).trim(), + entryDate: fd.get('entryDate') as string, + notes: (fd.get('notes') as string).trim() || undefined, + } + + const result = updateDebtChargeSchema.safeParse(data) + if (!result.success) { + setErrors(formatZodErrors(result.error)) + return + } + + setErrors({}) + startTransition(async () => { + try { + await updateDebtCharge(result.data) + toast.success('Cobrança atualizada.') + onOpenChange(false) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Erro ao editar cobrança.') + } + }) + } + + const form = ( +
+ +

+ {formatCurrency(entry.amount)} +

+
+ + + + + + + + + + +