Pular para o conteúdo

Modelo de dados — prumia

Fonte de verdade: api/migrations/ (00001_init.sql e as seguintes). Este documento explica o porquê das decisões; se os dois divergirem, a migration vence e este arquivo deve ser corrigido.

Regra de ouro: o schema é a decisão mais cara de reverter. Qualquer mudança vira uma nova migration (nunca editar uma já aplicada) e deve evitar backfill: prefira colunas nullable + default + constraint depois.

TemaDecisãoMotivo
PKuuid, gerado na aplicação (UUIDv7); gen_random_uuid() é só rede de segurançaUUIDv7 é ordenável por tempo (bom para índices) e não vaza contagem de registros
DinheiroBIGINT em centavos, sempre BRLsem erro de ponto flutuante; R$ 660,00 = 66000
DatasDATE sem fusovencimento, pagamento e competência são dias de calendário, não instantes
MêsDATE do dia 1 (2026-10-01), garantido por CHECKchave simples para agrupar e comparar meses
Auditoriacreated_at, updated_at (timestamptz)instantes reais usam fuso
Exclusãodeleted_at (lógica), com “desfazer” na UIexigência da spec
EnumsTEXT + CHECKalterar um CHECK é trivial; alterar ENUM do Postgres não é
Multi-casahousehold_id em toda tabela de negócio + FKs compostas (household_id, id)o banco recusa referência entre casas, mesmo se o código errar

Toda tabela de negócio declara UNIQUE (household_id, id) e as referências usam o par. Assim, uma invoice da casa A não consegue apontar para categoria, compra ou membro da casa B: o Postgres rejeita. É a última linha de defesa do isolamento entre casas (a primeira é o escopo household_id aplicado em todo repository).

  • Regras de negócio (e-mail, nome de categoria) usam WHERE deleted_at IS NULL: apagar libera o nome.
  • Séries (parcelas e recorrências) incluem linhas apagadas de propósito: se o usuário excluiu a parcela 3, o gerador não a recria.
  • users: e-mail único case-insensitive entre ativos. password_hash = argon2id. theme (migration 00005, ampliada na 00006): tema de cor da conta (marfim, solarized, latte, dracula, nord, gruvbox, rosepine, contraste, sistema), padrão marfim (quem tinha grafite voltou ao Marfim); lido em GET /auth/me e gravado em PATCH /auth/me. Trocar a senha (POST /auth/password) apaga as outras sessões do usuário.
  • sessions: o cookie carrega o token; o banco guarda só o SHA-256 (vazamento do banco não entrega sessões).
  • households: a unidade de dados.
  • household_members: PK (household_id, user_id); role ∈ admin|member|viewer. Nunca apaga: quem sai ganha left_at, para que invoices antigas com esse responsável continuem válidas. Regra de serviço: sempre ≥ 1 admin ativo.
  • household_invites: convite por link; guarda hash do token, role, validade, uso (used_at/used_by andam juntos) e revogação.
  • password_reset_tokens: link de redefinição gerado por admin (sem e-mail). Regra de serviço: só permitido quando todas as casas ativas do usuário-alvo têm o solicitante como admin (evita tomada de conta entre casas).

Por casa. default_wallet_id (FK composta para wallets) é só sugestão ao pagar (Mercado → “Benefício”). is_essential alimenta, no pós-MVP, a meta da reserva de emergência (meses de gasto essencial). Em uso → arquivar (archived_at), não apagar.

categories.parent_id (opcional, FK composta (household_id, parent_id), CHECK parent_id <> id). Dois níveis: a mãe é sempre de topo, regra no serviço (checkParent), não no banco. A conta/parcela/recorrência fica ligada à categoria mais específica; a soma da mãe é feita na leitura (web/src/lib/categories.ts), então o resumo do servidor continua por categoria folha. O nome é único dentro da mãe (índice categories_name_uq com COALESCE(parent_id, 0)); a ordem (sort_order) é entre irmãs. Arquivar a mãe arquiva as filhas com o mesmo instante; restaurar a mãe traz de volta só as que têm esse instante (filha arquivada à parte antes continua arquivada). Excluir mãe com filhas: 409 category_has_children. Down: as filhas voltam ao topo (pode colidir nomes).

Favorecido: quem recebe o pagamento, com o nome que a casa escolhe (name: “Gás”) e, opcionalmente, cnpj (só dígitos, validado pelos dígitos verificadores no serviço; único por casa entre os não excluídos), legal_name (razão social, informativa) e default_category_id (sugestão ao lançar a conta). Mesmo ciclo das categorias: em uso → arquivar (archived_at), não apagar. invoices.payee_id é nullable (FK composta (household_id, payee_id)) e nunca obrigatório; só a avulsa e a edição o usam por ora. Racional: boleto-extraction.md (o reconhecimento pelo CNPJ do boleto vem num PR seguinte).

Cabeçalho que responde “quando comprei o notebook e quanto custou?”: purchase_date, total_amount_cents, installments_count. As parcelas são linhas de invoices. Sem agrupamento por fatura de cartão (decisão do usuário).

Gera uma invoice por mês até um horizonte. generated_through = último mês materializado; EnsureHorizon (sob demanda, mais o comando api horizon) estende o horizonte. Ver series-generation.md. day_of_month = 31 vira o último dia de meses curtos.

Registro de cada encerramento feito por “excluir esta e as seguintes”: batch_deleted_at (= deleted_at das ocorrências do lote), previous_end_month (fim antes do corte; NULL = sem fim) e cut_end_month (NULL = o corte excluiu a recorrência). Serve só para o Desfazer em lote devolver o fim anterior. PK (recurrence_id, batch_deleted_at), FK composta com a casa.

  • due_date (vencimento) e competence_month (a que mês o gasto pertence; o resumo mensal usa este).
  • Série (no máx. uma): purchase_id + installment_number ou recurrence_id + occurrence_month. Os CHECKs garantem os pares e a exclusividade.
  • Pagamento (tudo ou nada): paid_at, paid_wallet_id (FK composta para wallets), paid_amount_cents (pode diferir do valor: juros, multa, desconto), paid_by_user_id. Estado é derivado: pago = paid_at IS NOT NULL; atrasada = pendente com due_date < hoje. Não existe coluna status, para não haver dois jeitos de dizer a mesma coisa.
  • Índices: lista do mês (competence_month), fila de pendentes (due_date parcial) e pagos (paid_at parcial).

Só os dados extraídos (linha digitável 47–48 dígitos, código de barras 44, beneficiário, valor e vencimento do documento). Nunca o arquivo. Duplicidade de linha digitável é verificada no serviço.

  • income_sources: fonte recorrente de uma pessoa (salário, VR). Define a previsão: bruto + descontos de template, dia do mês, horizonte (generated_through). Satisfaz o requisito de não relançar salário todo mês.
  • income_source_deductions: descontos do template (INSS, IRRF, plano, coparticipação média…).
  • incomes: lançamento mensal. source_id nulo = receita avulsa. received_at nulo = previsto; preenchido = recebido. É editável mês a mês (coparticipação varia).
  • income_deductions: descontos do mês, copiados do template e ajustáveis.
  • Líquido = bruto − soma dos descontos, calculado, não gravado (evita inconsistência).
  • wallet_id (NOT NULL, FK composta (household_id, wallet_id) para wallets) em incomes e income_sources. Desde a 00008 não há mais CHECK entre tipo e carteira: qualquer tipo vai para qualquer carteira.
  • O app não calcula impostos.

Migration 00007 (receitas v2, aditiva): income_sources ganha amount_mode (fixed|per_unit), unit_label, unit_price_cents, date_rule (day_of_month|nth_business_day|last_business_day), date_rule_n e weekend_adjust (forward|backward|none); incomes ganha held_at (“não caiu”), quantity, unit_price_cents, unit_label e a cópia da regra de data; nova tabela income_source_cuts (Desfazer em lote). received_at continua existindo (recebido de propósito, antes da data); o estado “recebida” é derivado. Detalhes em incomes-and-wallets.md.

Migration 00010: income_sources.date_rule_offset e incomes.date_rule_offset (smallint NOT NULL DEFAULT 0, CHECK (IN (-1, 0))): em que mês, em relação à competência, a regra de data é aplicada (0 = o da competência; -1 = o anterior, como o VR que cai no fim do mês anterior). Backfill 0. O Down só remove as colunas.

Tabela wallets (migration 00008): carteiras configuráveis da casa, um rótulo com saldo inicial (o saldo em si é calculado, não gravado).

ColunaDetalhe
id, household_idPK uuid; UNIQUE (household_id, id) para as FKs compostas
nameCHECK de 1 a 40 caracteres; único por casa entre as não arquivadas (wallets_name_uq, lower(name), parcial)
owner_user_idopcional; FK composta (household_id, owner_user_id) para household_members (só etiqueta)
is_defaultuma por casa (wallets_default_uq, índice único parcial WHERE is_default); CHECK (NOT is_default OR archived_at IS NULL)
warn_on_overdraftliga o aviso de saldo insuficiente ao pagar (padrão true)
opening_balance_centsbigint, pode ser negativo
opening_balance_monthdate NULL (migration 00011), CHECK dia 1: mês a partir do qual o saldo inicial vale; NULL = desde o início. O Down descarta a coluna
sort_order, archived_at, created_at, updated_atsem deleted_at: não há exclusão, só arquivar

Referências: incomes.wallet_id e income_sources.wallet_id (NOT NULL), invoices.paid_wallet_id (junto de paid_at, no invoices_paid_all_or_none) e categories.default_wallet_id (opcional), todas FKs compostas (household_id, ...): o banco recusa carteira de outra casa. A 00008 faz o backfill (toda casa ganha “Geral” padrão e “Benefício”; free → Geral, benefit → Benefício), remove as colunas de texto e os CHECKs kind/wallet. O Down volta ao texto com perda (benefit = carteira chamada “Benefício”).

Saldo = saldo inicial + receitas recebidas na carteira − invoices pagas com ela, acumulado entre os meses (a partir de opening_balance_month, se houver). Saldo insuficiente só avisa, não bloqueia (é estimativa, não controle bancário). Definições exatas em incomes-and-wallets.md.

Transferências entre carteiras da casa. Não é receita nem despesa: só entra no cálculo do saldo (ver incomes-and-wallets.md, seção 5.3).

ColunaDetalhe
id, household_idPK uuid; FK para households
from_wallet_id, to_wallet_idFKs compostas (household_id, ...) para wallets: as duas carteiras são da mesma casa da transferência. CHECK (from_wallet_id <> to_wallet_id)
amount_centsbigint, CHECK (> 0)
transfer_datedate (dia da transferência)
competence_monthdate, CHECK dia 1; é o mês em que ela conta nos saldos
notesopcional
created_by, created_atquem criou (FK users) e quando
deleted_atexclusão lógica (o Desfazer limpa); sem updated_at, porque não há edição

Índices parciais (WHERE deleted_at IS NULL): (household_id, competence_month) para o resumo e (household_id, from_wallet_id) / (household_id, to_wallet_id) para o saldo por carteira. O Down apaga a tabela (as transferências se perdem; contas, receitas e carteiras ficam).

Ajustes de saldo (“Conferir saldo”): a diferença entre o saldo real informado e o calculado, num mês. Não é receita nem despesa: só entra no cálculo do saldo (ver incomes-and-wallets.md, seção 5.4).

ColunaDetalhe
id, household_idPK uuid; FK para households
wallet_idFK composta (household_id, wallet_id) para wallets: a carteira é da mesma casa do ajuste
competence_monthdate, CHECK dia 1; o mês conferido (o ajuste conta nele e, pelo saldo anterior, nos seguintes)
amount_centsbigint com sinal, CHECK (<> 0): positivo = havia mais do que o calculado; negativo = menos
notesopcional
created_by, created_atquem conferiu (FK users) e quando
deleted_atexclusão lógica (o Desfazer limpa); sem updated_at, porque não há edição

Índices parciais (WHERE deleted_at IS NULL): (household_id, competence_month) e (household_id, wallet_id). O Down apaga a tabela (os ajustes se perdem; contas, receitas, transferências e carteiras ficam).

signup_invites e users.is_system_owner (migration 00013)

Seção intitulada “signup_invites e users.is_system_owner (migration 00013)”

Convites de cadastro (conta e casa próprias), gerados pelo dono do sistema (ver authentication.md, “Convite de cadastro”).

ColunaDetalhe
users.is_system_ownerboolean NOT NULL DEFAULT false; índice único parcial users_one_system_owner (no máximo um dono). Backfill: a conta mais antiga não excluída
idPK uuid
token_hashbytea NOT NULL UNIQUE (SHA-256 do token; o token em claro nunca é gravado)
created_byFK users: o dono do sistema que gerou
created_at, expires_atCHECK (expires_at > created_at)
used_at, used_byuso único; CHECK ((used_at IS NULL) = (used_by IS NULL)); used_by é a conta criada
revoked_atrevogação

Índice parcial (created_by) WHERE used_at IS NULL AND revoked_at IS NULL para contar os pendentes. O Down apaga a tabela e a coluna (o cadastro volta a valer só para o primeiro usuário).

households.reserve_target_months e wallets.counts_as_reserve (migration 00014)

Seção intitulada “households.reserve_target_months e wallets.counts_as_reserve (migration 00014)”

Meta da reserva de emergência (ver incomes-and-wallets.md, seção 5.5). households.reserve_target_months: smallint, CHECK (NULL ou 1 a 36); NULL = sem meta. wallets.counts_as_reserve: boolean NOT NULL DEFAULT false. Nenhuma tabela nova; o progresso é calculado. O Down remove as duas colunas (a configuração se perde; saldos e lançamentos ficam).

wallets.kind e wallets.institution (migration 00015)

Seção intitulada “wallets.kind e wallets.institution (migration 00015)”

Investimento como carteira especial (ver incomes-and-wallets.md, seção 5.6). kind: text NOT NULL DEFAULT 'regular', CHECK (kind IN ('regular', 'investment')); institution: text, CHECK (NULL ou 1 a 60 caracteres); e wallets_default_not_investment: CHECK (NOT (is_default AND kind = 'investment')). Sem backfill (toda carteira existente é regular). O Down remove as colunas e o CHECK: os investimentos viram carteiras comuns, com saldo e histórico intactos.

6. O que o schema ainda NÃO cobre (pós-MVP, de propósito)

Seção intitulada “6. O que o schema ainda NÃO cobre (pós-MVP, de propósito)”

Taxa contratada de investimentos e cotação de ações e fundos (a reserva de emergência e os investimentos já existem: 00014 e 00015).