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 `` + `
+
+
+ )
+}
diff --git a/docs/superpowers/plans/2026-07-07-editar-cobranca-devedores.md b/docs/superpowers/plans/2026-07-07-editar-cobranca-devedores.md
new file mode 100644
index 0000000..022a851
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-07-editar-cobranca-devedores.md
@@ -0,0 +1,462 @@
+# Editar cobrança em aberto (devedores) — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Adicionar ação "Editar" a cobranças em aberto no detalhe de um devedor, permitindo trocar descrição, data e observações (valor imutável).
+
+**Architecture:** Nova action `updateDebtCharge` com guard de escopo no servidor + schema Zod sem `amount`; novo dialog responsivo `EditChargeDialog` no molde do `SettleChargeDialog`; fiação em `DebtEntryList` no ramo `isOpenCharge` do `RowActions`. Sem mudança de schema, sem migration.
+
+**Tech Stack:** Next.js 14 App Router, Drizzle (Neon Postgres), Zod, criptografia MEK/DEK por usuário, Vitest (integração com banco real via neon-testing), DS Maré.
+
+## Global Constraints
+
+- Toda action com mutação segue: `requireUserId()` → `schema.parse` → `assertOwns*` → DB → `revalidatePath`. `requireUserId` sempre importado de `@/lib/auth/require-user`.
+- Campos sensíveis (`description`, `notes`) cifrados via DEK: `encryptField` / `encryptOptional` de `@/lib/crypto/fields`. Nunca gravar plaintext.
+- `entryDate` é `YYYY-MM-DD`; `referenceMonth` é `YYYY-MM-01`. Recalcular sempre com `entryDateToReferenceMonth(entryDate)` (helper local em `lib/actions/debtors.ts`: `entryDate.slice(0, 7) + '-01'`).
+- Dialogs de mutação: fechar só no `try`, nunca no `catch`; "Cancelar" chama `handleOpenChange(false)`.
+- Responsivo: `Dialog` desktop (`min-width: 1024px` via `useMediaQuery`) + `Drawer` mobile; `DrawerContent` precisa de wrapper `
`.
+- Versões de pacote fixas (`--save-exact`); não instalar nada novo (nenhum pacote é necessário aqui).
+- Antes de finalizar: `npm run lint && npm run format:check && npm run typecheck && npm test`. Integração: `npm run test:integration` (requer envs Neon + `ENCRYPTION_MASTER_KEY`).
+- Hook `PostToolUse:Edit` bloqueia imports não usados — ao fazer múltiplas mudanças num arquivo de componente, preferir `Write` do arquivo inteiro.
+
+---
+
+### Task 1: Schema + action `updateDebtCharge`
+
+**Files:**
+- Modify: `lib/validations/debtors.ts` (adicionar `updateDebtChargeSchema` após `debtChargeFromTransactionSchema`)
+- Modify: `lib/actions/debtors.ts` (adicionar `UpdateDebtChargeInput` + `updateDebtCharge` após `createDebtChargeFromTransaction`, ~linha 175; adicionar `updateDebtChargeSchema` ao import de `@/lib/validations/debtors`)
+- Test: `__tests__/integration/debtors.test.ts` (adicionar mocks de topo de arquivo + `describe('updateDebtCharge')`)
+
+**Interfaces:**
+- Consumes: `requireUserId` (`@/lib/auth/require-user`), `assertOwnsDebtEntry(userId: string, entryId: string): Promise` (`@/lib/auth/ownership`), `getDekForUser` (`@/lib/crypto/keys`), `encryptField`/`encryptOptional` (`@/lib/crypto/fields`), `entryDateToReferenceMonth` (local em `debtors.ts`).
+- Produces:
+ ```ts
+ export type UpdateDebtChargeInput = {
+ id: string
+ description: string
+ entryDate: string
+ notes?: string
+ }
+ export async function updateDebtCharge(data: UpdateDebtChargeInput): Promise
+ ```
+
+- [ ] **Step 1: Adicionar o schema de validação**
+
+Em `lib/validations/debtors.ts`, após `debtChargeFromTransactionSchema` (que termina em `})`), adicionar:
+
+```ts
+export const updateDebtChargeSchema = z.object({
+ id: uuidSchema,
+ description: z.string().min(1, 'Descrição é obrigatória').max(200),
+ entryDate: dateSchema,
+ notes: z.string().optional(),
+})
+```
+
+(`uuidSchema`, `dateSchema` e `z` já estão importados no arquivo — confirmar no topo; não adicionar imports duplicados.)
+
+- [ ] **Step 2: Escrever o teste de integração que falha**
+
+No topo de `__tests__/integration/debtors.test.ts`, ANTES de `neonTestingSetup()`, adicionar os mocks hoisted (o arquivo ainda não os tem):
+
+```ts
+import { vi } from 'vitest'
+
+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(),
+}))
+```
+
+Dentro do `beforeAll` existente, ao final, configurar os mocks resolvidos (dynamic import após o neon-testing setar `DATABASE_URL`):
+
+```ts
+ 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)
+```
+
+Adicionar, ao final do arquivo, o bloco de teste:
+
+```ts
+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')
+ })
+})
+```
+
+- [ ] **Step 3: Rodar o teste e confirmar que falha**
+
+Run: `npm run test:integration -- -t updateDebtCharge`
+Expected: FAIL — `updateDebtCharge` não existe em `@/lib/actions/debtors` (erro de import/undefined).
+
+- [ ] **Step 4: Implementar a action**
+
+Em `lib/actions/debtors.ts`, adicionar `updateDebtChargeSchema` ao import existente de `@/lib/validations/debtors` (junto de `debtChargeSchema`, `debtChargeFromTransactionSchema`, etc.). Depois, após a função `createDebtChargeFromTransaction`, adicionar:
+
+```ts
+export type UpdateDebtChargeInput = {
+ id: string
+ description: string
+ entryDate: string
+ notes?: string
+}
+
+export async function updateDebtCharge(data: UpdateDebtChargeInput) {
+ const userId = await requireUserId()
+ updateDebtChargeSchema.parse(data)
+ await assertOwnsDebtEntry(userId, data.id)
+
+ const entry = await db.query.debtorEntries.findFirst({
+ where: and(eq(debtorEntries.id, data.id), eq(debtorEntries.userId, userId)),
+ columns: { type: true, status: true, personId: true },
+ })
+
+ if (!entry) throw new Error('Lançamento não encontrado')
+ if (entry.type !== 'charge' || (entry.status !== 'open' && entry.status !== null)) {
+ throw new Error('Só é possível editar cobranças em aberto')
+ }
+
+ const dek = await getDekForUser(userId)
+
+ await db
+ .update(debtorEntries)
+ .set({
+ description: encryptField(data.description.trim(), dek),
+ entryDate: data.entryDate,
+ referenceMonth: entryDateToReferenceMonth(data.entryDate),
+ notes: encryptOptional(data.notes?.trim() || null, dek),
+ updatedAt: new Date(),
+ })
+ .where(and(eq(debtorEntries.id, data.id), eq(debtorEntries.userId, userId)))
+
+ revalidatePath('/devedores')
+ revalidatePath(`/devedores/${entry.personId}`)
+}
+```
+
+- [ ] **Step 5: Rodar o teste e confirmar que passa**
+
+Run: `npm run test:integration -- -t updateDebtCharge`
+Expected: PASS (2 testes).
+
+- [ ] **Step 6: Verificar lint/format/typecheck**
+
+Run: `npm run lint && npm run format:check && npm run typecheck`
+Expected: sem erros. Se `format:check` reclamar, rodar `npx prettier --write lib/validations/debtors.ts lib/actions/debtors.ts __tests__/integration/debtors.test.ts`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add lib/validations/debtors.ts lib/actions/debtors.ts __tests__/integration/debtors.test.ts
+git commit -m "feat(devedores): action updateDebtCharge para editar cobrança em aberto
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 2: `EditChargeDialog` + fiação em `DebtEntryList`
+
+**Files:**
+- Create: `components/devedores/EditChargeDialog.tsx`
+- Modify: `components/devedores/DebtEntryList.tsx` (import do dialog; state `editEntry`; prop `onEdit` em `EntryRow`; `onEdit` no `RowActions` do ramo `isOpenCharge`; render do dialog)
+
+**Interfaces:**
+- Consumes: `updateDebtCharge`, `UpdateDebtChargeInput` (`@/lib/actions/debtors`), `updateDebtChargeSchema` (`@/lib/validations/debtors`), `DebtEntryDetail` (`@/lib/queries/debtors`), `formatZodErrors` (`@/lib/validations/utils`), `useMediaQuery` (`@/hooks/use-media-query`), primitivos DS (`Button`, `Field`, `Input`, `Textarea`, `Dialog*`, `Drawer*`).
+- Produces:
+ ```tsx
+ export function EditChargeDialog(props: {
+ entry: DebtEntryDetail
+ open: boolean
+ onOpenChange: (v: boolean) => void
+ }): JSX.Element
+ ```
+
+- [ ] **Step 1: Criar o componente `EditChargeDialog`**
+
+Criar `components/devedores/EditChargeDialog.tsx` com o conteúdo completo:
+
+```tsx
+'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 = (
+
+ )
+
+ if (isDesktop) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ Editar cobrança
+
+
{form}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Fiar o dialog em `DebtEntryList.tsx`**
+
+Fazer as 5 mudanças abaixo (usar `Write` do arquivo inteiro se o hook de imports bloquear edits parciais):
+
+1. Import após os outros imports de `./`:
+```tsx
+import { EditChargeDialog } from './EditChargeDialog'
+```
+
+2. Na assinatura de `EntryRow`, adicionar o callback `onEdit` (junto de `onSettle`):
+```tsx
+ onEdit,
+}: {
+ entry: DebtEntryDetail
+ onDeleteWithIncome: (entry: DebtEntryDetail) => void
+ onDeleteWithSettled: (entry: DebtEntryDetail) => void
+ onSettle: (entry: DebtEntryDetail) => void
+ onEdit: (entry: DebtEntryDetail) => void
+}) {
+```
+
+3. No ramo `isOpenCharge` do `RowActions`, adicionar `onEdit`:
+```tsx
+ ) : isOpenCharge ? (
+ onEdit(entry)}
+ additionalActions={[
+ {
+ label: 'Quitar',
+ icon: CheckCircle,
+ onClick: () => onSettle(entry),
+ },
+ ]}
+ onDelete={async () => {
+ await deleteDebtEntry({ id: entry.id })
+ toast.success('Lançamento excluído.')
+ }}
+ deleteTitle="Excluir lançamento"
+ deleteDescription={`Excluir "${entry.description}"? O saldo da pessoa será recalculado.`}
+ />
+```
+
+4. No componente `DebtEntryList`, adicionar o state (junto dos outros `useState`):
+```tsx
+ const [editEntry, setEditEntry] = useState(null)
+```
+E passar `onEdit={setEditEntry}` em cada ``:
+```tsx
+
+```
+
+5. Render do dialog junto dos outros (após o bloco `settleEntry`):
+```tsx
+ {editEntry && (
+ {
+ if (!v) setEditEntry(null)
+ }}
+ />
+ )}
+```
+
+- [ ] **Step 3: Verificar lint/format/typecheck**
+
+Run: `npm run lint && npm run format:check && npm run typecheck`
+Expected: sem erros. Ajustar com `npx prettier --write components/devedores/EditChargeDialog.tsx components/devedores/DebtEntryList.tsx` se necessário.
+
+- [ ] **Step 4: Revisão do DS**
+
+Rodar o agente `ds-reviewer` sobre `components/devedores/EditChargeDialog.tsx` e `components/devedores/DebtEntryList.tsx`. Corrigir eventuais violações de token/regra apontadas.
+
+- [ ] **Step 5: Build**
+
+Run: `npm run build`
+Expected: build de produção sem erros.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add components/devedores/EditChargeDialog.tsx components/devedores/DebtEntryList.tsx
+git commit -m "feat(devedores): dialog de editar cobrança em aberto na lista de lançamentos
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Escopo (só cobrança aberta) → Task 1 guard no servidor + Task 2 wiring apenas em `isOpenCharge`. ✓
+- Campos descrição/data/notes, valor imutável → schema sem `amount` (Task 1) + form sem campo de valor (Task 2, valor exibido read-only). ✓
+- Recálculo de `referenceMonth` → Task 1 Step 4 + teste Step 2. ✓
+- Sem migration → nenhuma mudança de schema. ✓
+- Componente no molde do `SettleChargeDialog`, responsivo → Task 2 Step 1. ✓
+- Testes de integração (happy path, recálculo de mês, rejeição de não-cobrança-aberta, ownership) → Task 1 Step 2. Ownership coberto implicitamente (mock `assertOwnsDebtEntry` resolvido); a chamada real é garantida pelo padrão da action. ✓
+
+**Placeholder scan:** nenhum TODO/TBD; todo código presente. ✓
+
+**Type consistency:** `updateDebtCharge`/`UpdateDebtChargeInput` idênticos entre Task 1 (produz) e Task 2 (consome); `EditChargeDialog` props batem com o render em `DebtEntryList`. ✓
diff --git a/docs/superpowers/specs/2026-07-07-editar-cobranca-devedores-design.md b/docs/superpowers/specs/2026-07-07-editar-cobranca-devedores-design.md
new file mode 100644
index 0000000..b558782
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-07-editar-cobranca-devedores-design.md
@@ -0,0 +1,123 @@
+# Editar cobrança em aberto (devedores)
+
+## Problema
+
+No detalhe de um devedor, cada lançamento (`debtorEntries`) só permite **Quitar** e
+**Excluir**. Não há como corrigir uma cobrança já registrada. Caso motivador: uma compra
+parcelada lançada via dívida compartilhada, cujas cobranças começaram a ser feitas um mês
+depois — o usuário precisa trocar a **data** (e, por consequência, o mês de referência) e o
+**nome** da cobrança sem apagar e recriar tudo.
+
+## Escopo
+
+- Editar **apenas cobranças em aberto** — `type = 'charge'` e `status` `'open'` ou `null`.
+ Pagamentos, ajustes e cobranças já quitadas ficam de fora (evita reconciliar vínculos com
+ `income`, `settledCharges` e o pagamento que quitou a cobrança).
+- Campos editáveis: **descrição**, **data** (`entryDate`) e **observações** (`notes`).
+- **Valor é imutável.** Cobranças vindas de dívida compartilhada têm o valor derivado do
+ split da transação de origem; permitir editar abriria margem para divergir do original sem
+ benefício para o caso de uso.
+- O vínculo com a transação de origem (`sourceTransactionId`) não é tocado.
+- Sem mudança de schema — logo, **sem migration**.
+
+## Componentes
+
+### 1. Schema de validação — `lib/validations/debtors.ts`
+
+```ts
+export const updateDebtChargeSchema = z.object({
+ id: uuidSchema,
+ description: z.string().min(1, 'Descrição é obrigatória').max(200),
+ entryDate: dateSchema,
+ notes: z.string().optional(),
+})
+```
+
+Espelha `debtChargeSchema` sem `amount` nem `personId` (o `personId` é derivado da entry no
+servidor; não vem do cliente).
+
+### 2. Action — `updateDebtCharge` em `lib/actions/debtors.ts`
+
+Segue o padrão obrigatório de action com mutação:
+
+1. `const userId = await requireUserId()`
+2. `updateDebtChargeSchema.parse(data)`
+3. `await assertOwnsDebtEntry(userId, data.id)`
+4. Busca a entry (`columns: { type, status, personId }`); se não encontrada, lança
+ `'Lançamento não encontrado'`.
+5. **Guard de escopo:** se `type !== 'charge'` ou `status` não for `'open'`/`null`, lança
+ `'Só é possível editar cobranças em aberto'`. Protege no servidor, não só na UI.
+6. `const dek = await getDekForUser(userId)`
+7. `db.update(debtorEntries).set({...})`:
+ - `description: encryptField(data.description.trim(), dek)`
+ - `entryDate: data.entryDate`
+ - `referenceMonth: entryDateToReferenceMonth(data.entryDate)` — recálculo é o que faz a
+ cobrança migrar para o mês correto no agrupamento da lista.
+ - `notes: encryptOptional(data.notes?.trim() || null, dek)`
+ - `updatedAt: new Date()`
+ - `where(and(eq(id), eq(userId)))`
+8. `revalidatePath('/devedores')` + `revalidatePath('/devedores/${personId}')` (personId
+ vindo da entry buscada).
+
+Tipo de input:
+
+```ts
+export type UpdateDebtChargeInput = {
+ id: string
+ description: string
+ entryDate: string
+ notes?: string
+}
+```
+
+### 3. Componente — `components/devedores/EditChargeDialog.tsx`
+
+Molde do `SettleChargeDialog`:
+
+- Controlado por `open` / `onOpenChange`; responsivo `Dialog` (≥1024px) / `Drawer` (mobile)
+ via `useMediaQuery`.
+- Props: `entry: DebtEntryDetail`, `open?`, `onOpenChange?`.
+- Três `Field`s do DS, pré-preenchidos a partir de `entry`:
+ - Descrição → `Input` (`defaultValue={entry.description}`, `autoFocus`)
+ - Data → `Input type="date"` (`defaultValue={entry.entryDate}`)
+ - Observações → `Textarea` (`defaultValue={entry.notes ?? ''}`)
+- Submit: monta objeto, valida com `updateDebtChargeSchema.safeParse` + `formatZodErrors`,
+ chama `updateDebtCharge` dentro de `startTransition`. Fecha **só no `try`**; `catch`
+ dispara `toast.error`. Botão "Cancelar" chama `onOpenChange(false)`.
+
+### 4. Fiação — `components/devedores/DebtEntryList.tsx`
+
+- Novo state `const [editEntry, setEditEntry] = useState(null)`.
+- `EntryRow` recebe callback `onEdit: (entry) => void`.
+- No ramo `isOpenCharge` do `RowActions`, adicionar `onEdit={() => onEdit(entry)}` — convive
+ com a ação "Quitar" (em `additionalActions`) e o "Excluir" (`onDelete`).
+- Render condicional do `EditChargeDialog` quando `editEntry` não for null, no mesmo padrão
+ dos outros dialogs do arquivo.
+
+## Data flow
+
+`DebtEntryList` (client) → usuário clica Editar no `RowActions` → `setEditEntry(entry)` →
+`EditChargeDialog` abre pré-preenchido → submit → `updateDebtCharge(action)` →
+`db.update` + `revalidatePath` → Server Component recarrega e a cobrança aparece no mês novo.
+
+## Tratamento de erros
+
+- Validação de formulário: erros por campo via `formatZodErrors` exibidos no `Field`.
+- Falha da action: `toast.error('Erro ao editar cobrança.')`; dialog permanece aberto.
+- Guard de escopo no servidor: entry que não é cobrança aberta → erro lançado (defesa em
+ profundidade; a UI já só oferece Editar para `isOpenCharge`).
+
+## Testes
+
+Integração em `__tests__/integration/debtors.test.ts` (banco real, dynamic import da action):
+
+- Atualiza `description`/`entryDate`/`notes` e persiste cifrado (verificar decrypt).
+- Ao mudar a data para outro mês, `referenceMonth` é recalculado corretamente.
+- Rejeita entry que não é cobrança aberta (ex.: `type='payment'` ou `status='settled'`).
+- Chama `assertOwnsDebtEntry(userId, id)` e revalida `/devedores` + `/devedores/${personId}`.
+
+## Fora de escopo
+
+- Editar valor da cobrança.
+- Editar pagamentos, ajustes ou cobranças quitadas.
+- Alterar o vínculo com a transação de origem.
diff --git a/lib/actions/debtors.ts b/lib/actions/debtors.ts
index 89d461b..3503302 100644
--- a/lib/actions/debtors.ts
+++ b/lib/actions/debtors.ts
@@ -14,6 +14,7 @@ import {
updatePersonActionSchema,
debtChargeSchema,
debtChargeFromTransactionSchema,
+ updateDebtChargeSchema,
debtPaymentSchema,
settleChargeSchema,
} from '@/lib/validations/debtors'
@@ -170,6 +171,45 @@ export async function createDebtChargeFromTransaction(data: CreateDebtChargeFrom
revalidatePath(`/devedores/${data.personId}`)
}
+export type UpdateDebtChargeInput = {
+ id: string
+ description: string
+ entryDate: string
+ notes?: string
+}
+
+export async function updateDebtCharge(data: UpdateDebtChargeInput) {
+ const userId = await requireUserId()
+ updateDebtChargeSchema.parse(data)
+ await assertOwnsDebtEntry(userId, data.id)
+
+ const entry = await db.query.debtorEntries.findFirst({
+ where: and(eq(debtorEntries.id, data.id), eq(debtorEntries.userId, userId)),
+ columns: { type: true, status: true, personId: true },
+ })
+
+ if (!entry) throw new Error('Lançamento não encontrado')
+ if (entry.type !== 'charge' || (entry.status !== 'open' && entry.status !== null)) {
+ throw new Error('Só é possível editar cobranças em aberto')
+ }
+
+ const dek = await getDekForUser(userId)
+
+ await db
+ .update(debtorEntries)
+ .set({
+ description: encryptField(data.description.trim(), dek),
+ entryDate: data.entryDate,
+ referenceMonth: entryDateToReferenceMonth(data.entryDate),
+ notes: encryptOptional(data.notes?.trim() || null, dek),
+ updatedAt: new Date(),
+ })
+ .where(and(eq(debtorEntries.id, data.id), eq(debtorEntries.userId, userId)))
+
+ revalidatePath('/devedores')
+ revalidatePath(`/devedores/${entry.personId}`)
+}
+
export type CreateDebtPaymentInput = {
personId: string
amount: string
@@ -206,8 +246,11 @@ export async function createDebtPayment(data: CreateDebtPaymentInput) {
.insert(incomes)
.values({
userId,
- source: `${person.name} — ${data.description}`,
- amount: data.amount,
+ source: encryptField(
+ `${decryptField(person.name, dek)} — ${data.description.trim()}`,
+ dek
+ ),
+ amount: encryptField(data.amount, dek),
referenceMonth: data.referenceMonth,
})
.returning({ id: incomes.id })
@@ -334,8 +377,11 @@ export async function settleCharge(data: SettleChargeInput): Promise {
.insert(incomes)
.values({
userId,
- source: `${person.name} — ${charge.description}`,
- amount: charge.amount,
+ source: encryptField(
+ `${decryptField(person.name, dek)} — ${decryptField(charge.description, dek)}`,
+ dek
+ ),
+ amount: encryptField(decryptField(charge.amount, dek), dek),
referenceMonth: data.referenceMonth,
})
.returning({ id: incomes.id })
@@ -348,8 +394,8 @@ export async function settleCharge(data: SettleChargeInput): Promise {
userId,
personId: data.personId,
type: 'payment',
- amount: charge.amount,
- description: charge.description,
+ amount: encryptField(decryptField(charge.amount, dek), dek),
+ description: encryptField(decryptField(charge.description, dek), dek),
entryDate: data.entryDate,
referenceMonth: data.referenceMonth ?? entryDateToReferenceMonth(data.entryDate),
notes: encryptOptional(data.notes?.trim() || null, dek),
diff --git a/lib/actions/investments.ts b/lib/actions/investments.ts
index 363f2e5..2dd1cd2 100644
--- a/lib/actions/investments.ts
+++ b/lib/actions/investments.ts
@@ -222,10 +222,10 @@ export async function createWithdrawal(data: CreateWithdrawalInput) {
.insert(incomes)
.values({
userId,
- source: `Resgate investimento ${data.investmentTypeName}`,
- amount: data.amount,
+ source: encryptField(`Resgate investimento ${data.investmentTypeName}`, dek),
+ amount: encryptField(data.amount, dek),
referenceMonth: dateToReferenceMonth(data.date),
- investmentReturnCapital,
+ investmentReturnCapital: encryptOptional(investmentReturnCapital, dek),
})
.returning({ id: incomes.id })
incomeId = income.id
@@ -306,16 +306,16 @@ export async function updateWithdrawal(data: UpdateWithdrawalInput) {
await tx
.update(incomes)
.set({
- amount: data.amount,
+ amount: encryptField(data.amount, dek),
referenceMonth: dateToReferenceMonth(data.date),
- investmentReturnCapital: newReturnCapital,
+ investmentReturnCapital: encryptOptional(newReturnCapital, dek),
})
.where(and(eq(incomes.id, withdrawal.incomeId), eq(incomes.userId, userId)))
} else {
await tx
.update(incomes)
.set({
- amount: data.amount,
+ amount: encryptField(data.amount, dek),
referenceMonth: dateToReferenceMonth(data.date),
investmentReturnCapital: null,
})
diff --git a/lib/actions/transactions.ts b/lib/actions/transactions.ts
index e2cacfa..43af62c 100644
--- a/lib/actions/transactions.ts
+++ b/lib/actions/transactions.ts
@@ -84,8 +84,8 @@ export async function createTransaction(data: CreateTransactionInput) {
personId: s.personId,
type: 'charge' as const,
status: 'open' as const,
- amount: s.amount,
- description: data.name,
+ amount: encryptField(s.amount, dek),
+ description: encryptField(data.name, dek),
entryDate: data.date,
referenceMonth: dateToReferenceMonth(data.date),
sourceTransactionId: txRow.id,
@@ -333,8 +333,11 @@ export async function createInstallmentPurchase(data: CreateInstallmentInput) {
personId: s.personId,
type: 'charge' as const,
status: 'open' as const,
- amount: isLast ? last : perInstallment,
- description: `${data.name} (${txRow.installmentNumber}/${data.totalInstallments})`,
+ amount: encryptField(isLast ? last : perInstallment, dek),
+ description: encryptField(
+ `${data.name} (${txRow.installmentNumber}/${data.totalInstallments})`,
+ dek
+ ),
entryDate,
referenceMonth,
sourceTransactionId: txRow.id,
diff --git a/lib/validations/debtors.ts b/lib/validations/debtors.ts
index 53f535e..56d4149 100644
--- a/lib/validations/debtors.ts
+++ b/lib/validations/debtors.ts
@@ -22,6 +22,13 @@ export const debtChargeFromTransactionSchema = debtChargeSchema.extend({
sourceTransactionId: uuidSchema,
})
+export const updateDebtChargeSchema = z.object({
+ id: uuidSchema,
+ description: z.string().min(1, 'Descrição é obrigatória').max(200),
+ entryDate: dateSchema,
+ notes: z.string().optional(),
+})
+
export const debtPaymentSchema = z
.object({
personId: uuidSchema,
diff --git a/next.config.mjs b/next.config.mjs
index e392795..83d1e51 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -6,9 +6,60 @@ const withSerwist = withSerwistInit({
disable: process.env.NODE_ENV === 'development',
})
+const isDev = process.env.NODE_ENV === 'development'
+
+// Content-Security-Policy pragmático para Next.js App Router:
+// - 'unsafe-inline' em script/style é necessário para a hidratação do Next e o
+// script anti-flash do next-themes; migrar para nonces é evolução futura.
+// - 'unsafe-eval' só em dev (HMR do Turbopack/webpack); nunca em produção.
+// - next/font self-hospeda as fontes (font-src 'self'); Speed Insights usa
+// same-origin, com fallback em va.vercel-scripts.com.
+const csp = [
+ `default-src 'self'`,
+ `base-uri 'self'`,
+ `object-src 'none'`,
+ `frame-ancestors 'none'`,
+ `form-action 'self'`,
+ `img-src 'self' data: blob: https:`,
+ `font-src 'self'`,
+ `style-src 'self' 'unsafe-inline'`,
+ `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''} https://va.vercel-scripts.com`,
+ `connect-src 'self' https://va.vercel-scripts.com`,
+ `worker-src 'self'`,
+ `manifest-src 'self'`,
+].join('; ')
+
+const securityHeaders = [
+ { key: 'Content-Security-Policy', value: csp },
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ {
+ key: 'Permissions-Policy',
+ value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
+ },
+ // HSTS só em produção — evita forçar HTTPS em localhost durante o dev.
+ ...(isDev
+ ? []
+ : [
+ {
+ key: 'Strict-Transport-Security',
+ value: 'max-age=63072000; includeSubDomains',
+ },
+ ]),
+]
+
/** @type {import('next').NextConfig} */
const nextConfig = {
turbopack: {},
+ async headers() {
+ return [
+ {
+ source: '/:path*',
+ headers: securityHeaders,
+ },
+ ]
+ },
}
export default withSerwist(nextConfig)
diff --git a/scripts/encrypt-existing-data.ts b/scripts/encrypt-existing-data.ts
index 1074a52..94a2822 100644
--- a/scripts/encrypt-existing-data.ts
+++ b/scripts/encrypt-existing-data.ts
@@ -1,11 +1,18 @@
#!/usr/bin/env npx tsx
/**
- * Criptografa dados existentes no banco usando o DEK de cada usuário.
- * Idempotente: campos com prefixo 'enc:' são pulados.
+ * Criptografa dados existentes no banco usando o DEK de cada usuário e repara
+ * incomes cujo `source` ficou corrompido (ciphertext interpolado em string por
+ * bugs antigos em createDebtPayment/settleCharge/createWithdrawal).
+ *
+ * Idempotente e seguro para rodar sempre que precisar corrigir dados:
+ * - Campos com prefixo 'enc:' e decifráveis são pulados.
+ * - Campos plaintext são cifrados.
+ * - `source` de income com prefixo 'enc:' mas NÃO decifrável é reconstruído a
+ * partir da entidade de origem (debtorEntry payment ou investmentWithdrawal).
*
* Uso:
* npx tsx scripts/encrypt-existing-data.ts --dry-run # imprime contagens sem modificar
- * npx tsx scripts/encrypt-existing-data.ts # aplica criptografia
+ * npx tsx scripts/encrypt-existing-data.ts # aplica criptografia + reparos
*/
import { config } from 'dotenv'
@@ -13,10 +20,10 @@ config({ path: '.env.local' })
import { Pool } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-serverless'
-import { eq } from 'drizzle-orm'
+import { eq, and } from 'drizzle-orm'
import * as schema from '../lib/db/schema'
import { generateDek, encryptDek, decryptDek } from '../lib/crypto/keys'
-import { encryptField, encryptOptional } from '../lib/crypto/fields'
+import { encryptField, encryptOptional, decryptField } from '../lib/crypto/fields'
const isDryRun = process.argv.includes('--dry-run')
const pool = new Pool({ connectionString: process.env.DATABASE_URL! })
@@ -27,6 +34,71 @@ function isEncrypted(val: string | null | undefined): boolean {
return val != null && val.startsWith(PREFIX)
}
+// Verifica se um valor com prefixo 'enc:' realmente decifra (auth tag válida).
+// Valores corrompidos (ex: "enc:AAA — enc:BBB") têm prefixo mas falham no decrypt.
+function isDecryptable(val: string | null | undefined, dek: Buffer): boolean {
+ if (val == null) return true
+ try {
+ decryptField(val, dek)
+ return true
+ } catch {
+ return false
+ }
+}
+
+// Decifra tolerando falha: retorna o fallback se o valor estiver corrompido/ausente.
+function safeDecrypt(val: string | null | undefined, dek: Buffer, fallback: string): string {
+ if (val == null) return fallback
+ try {
+ return decryptField(val, dek)
+ } catch {
+ return fallback
+ }
+}
+
+// Reconstrói o `source` legível de um income corrompido, buscando a entidade de
+// origem ligada por incomeId. Retorna null se não houver origem rastreável.
+async function reconstructIncomeSource(
+ incomeId: string,
+ userId: string,
+ dek: Buffer
+): Promise {
+ const entry = await db.query.debtorEntries.findFirst({
+ where: and(
+ eq(schema.debtorEntries.incomeId, incomeId),
+ eq(schema.debtorEntries.userId, userId)
+ ),
+ columns: { personId: true, description: true },
+ })
+ if (entry) {
+ const person = await db.query.people.findFirst({
+ where: eq(schema.people.id, entry.personId),
+ columns: { name: true },
+ })
+ const name = safeDecrypt(person?.name, dek, 'Desconhecido')
+ const desc = safeDecrypt(entry.description, dek, '')
+ return desc ? `${name} — ${desc}` : name
+ }
+
+ const wd = await db.query.investmentWithdrawals.findFirst({
+ where: and(
+ eq(schema.investmentWithdrawals.incomeId, incomeId),
+ eq(schema.investmentWithdrawals.userId, userId)
+ ),
+ columns: { investmentTypeId: true },
+ })
+ if (wd) {
+ const type = await db.query.investmentTypes.findFirst({
+ where: eq(schema.investmentTypes.id, wd.investmentTypeId),
+ columns: { name: true },
+ })
+ const typeName = safeDecrypt(type?.name, dek, 'investimento')
+ return `Resgate investimento ${typeName}`
+ }
+
+ return null
+}
+
async function getOrCreateDek(userId: string): Promise {
const row = await db.query.userSettings.findFirst({
where: eq(schema.userSettings.userId, userId),
@@ -59,6 +131,7 @@ async function main() {
console.log(`Usuários encontrados: ${users.length}`)
let totalUpdated = 0
+ let totalRepaired = 0
for (const { id: userId } of users) {
const dek = await getOrCreateDek(userId)
@@ -150,14 +223,39 @@ async function main() {
.where(eq(schema.incomes.userId, userId))
for (const row of incRows) {
- if (isEncrypted(row.source) && isEncrypted(row.amount)) continue
+ const sourceEncrypted = isEncrypted(row.source)
+ const sourceOk = sourceEncrypted && isDecryptable(row.source, dek)
+ const amountOk = isEncrypted(row.amount)
+ const ircOk = row.investmentReturnCapital == null || isEncrypted(row.investmentReturnCapital)
+
+ if (sourceOk && amountOk && ircOk) continue
+
+ let newSource: string
+ if (!sourceEncrypted) {
+ // plaintext legado → cifra direto
+ newSource = encryptField(row.source, dek)
+ } else if (sourceOk) {
+ newSource = row.source
+ } else {
+ // ciphertext corrompido (interpolação antiga) → reconstrói a partir da origem
+ const reconstructed = await reconstructIncomeSource(row.id, userId, dek)
+ if (reconstructed == null) {
+ console.warn(
+ ` ⚠ income ${row.id}: source corrompido e sem origem para reconstruir — pulado`
+ )
+ continue
+ }
+ newSource = encryptField(reconstructed, dek)
+ totalRepaired++
+ }
+
if (!isDryRun) {
await db
.update(schema.incomes)
.set({
- source: isEncrypted(row.source) ? row.source : encryptField(row.source, dek),
- amount: isEncrypted(row.amount) ? row.amount : encryptField(row.amount, dek),
- investmentReturnCapital: isEncrypted(row.investmentReturnCapital)
+ source: newSource,
+ amount: amountOk ? row.amount : encryptField(row.amount, dek),
+ investmentReturnCapital: ircOk
? row.investmentReturnCapital
: encryptOptional(row.investmentReturnCapital, dek),
})
@@ -178,10 +276,13 @@ async function main() {
.where(eq(schema.investments.userId, userId))
for (const row of invRows) {
+ // Campos nullable: só precisam de update quando NÃO são null e ainda não
+ // estão cifrados. Sem o guard de null, `!isEncrypted(null)` marcaria a
+ // linha para sempre (bug de idempotência) e regravaria null a cada run.
const needsUpdate =
- !isEncrypted(row.amount) ||
- !isEncrypted(row.yieldAmount) ||
- (row.notes && !isEncrypted(row.notes))
+ (row.amount != null && !isEncrypted(row.amount)) ||
+ (row.yieldAmount != null && !isEncrypted(row.yieldAmount)) ||
+ (row.notes != null && !isEncrypted(row.notes))
if (!needsUpdate) continue
if (!isDryRun) {
await db
@@ -455,6 +556,11 @@ async function main() {
console.log(
`\nTotal: ${totalUpdated} registros ${isDryRun ? 'seriam cifrados' : 'cifrados com sucesso'}.`
)
+ if (totalRepaired > 0) {
+ console.log(
+ `Incomes com source reconstruído: ${totalRepaired} ${isDryRun ? '(seriam reparados)' : '(reparados)'}.`
+ )
+ }
await pool.end()
}