From 54f7254714803255d375b036f5b27d441847b245 Mon Sep 17 00:00:00 2001 From: Guiroos Date: Tue, 7 Jul 2026 21:09:54 -0300 Subject: [PATCH 1/6] =?UTF-8?q?docs(devedores):=20design=20de=20editar=20c?= =?UTF-8?q?obran=C3=A7a=20em=20aberto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...-07-07-editar-cobranca-devedores-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-editar-cobranca-devedores-design.md 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. From b94c84c7b0e1ec9439033dd0d672beef1f6f553b Mon Sep 17 00:00:00 2001 From: Guiroos Date: Tue, 7 Jul 2026 21:12:24 -0300 Subject: [PATCH 2/6] =?UTF-8?q?docs(devedores):=20plano=20de=20implementa?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20editar=20cobran=C3=A7a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../2026-07-07-editar-cobranca-devedores.md | 462 ++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-editar-cobranca-devedores.md 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 = ( +
+ +

+ {formatCurrency(entry.amount)} +

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