SaldoMais
Carregando…
Documento 03 de 07

Dados & Storage

Estruturas de dados persistidas no localStorage, relacionamentos entre entidades, chaves de acesso, regras de integridade e formato do arquivo de backup.

Camada de persistência

O SaldoMais não tem backend. Todos os dados ficam no localStorage do navegador sob o domínio/origem que serve os arquivos. Dados são perdidos se o usuário limpar o armazenamento do navegador ou abrir o app em outro dispositivo sem importar backup.

Limite prático: O localStorage suporta ~5–10 MB dependendo do navegador. Para o volume esperado (dezenas de categorias, centenas de lançamentos), esse limite não é uma preocupação real.

Acesso via helpers

const get = key => JSON.parse(localStorage.getItem(key)) || [];
const set = (key, val) => localStorage.setItem(key, JSON.stringify(val));
get sempre retorna um array — nunca null. Se o item não existir ou o JSON for inválido, retorna [].

Chaves do localStorage

Definidas em core.js como STORAGE:

ConstanteChaveEntidade
STORAGE.categoriassaldomain_categoriasCategorias de gasto
STORAGE.orcamentossaldomain_orcamentosOrçamentos mensais
STORAGE.lancamentossaldomain_lancamentosTransações registradas

Entidade: Categoria

{
  id: number,          // timestamp Unix (Date.now() + índice na criação)
  nome: string,        // nome único (case-insensitive), máx. 30 caracteres
  percentual: number,  // inteiro 0-100, % do orçamento alocado
  cor_hex: string      // cor hexadecimal, ex: "#f59e0b"
}

Invariantes

  • A soma dos percentuais de todas as categorias deve ser 100 para que os limites sejam calculados corretamente. O app impede salvar percentuais que não somem 100.
  • nome é único (comparação case-insensitive). O app rejeita duplicatas na criação e edição.
  • Novas categorias criadas pelo usuário recebem percentual: 0 — o usuário deve ajustar manualmente os sliders.

Entidade: Orçamento

{
  id: number,                 // timestamp Unix gerado na criação
  mes_referencia: string,     // formato "YYYY-MM", ex: "2026-04"
  valor_total: number         // valor em reais (float), ex: 5000.00
}

Invariantes

  • Existe no máximo um orçamento por mes_referencia. Se o usuário salvar o orçamento num mês que já tem registro, o valor_total é atualizado (upsert).
  • resetarMes() zera valor_total para 0 mas não remove o registro — o orçamento do mês ainda existe, com valor zero.

Entidade: Lançamento

{
  id: number,               // timestamp Unix gerado na criação
  id_orcamento: number,     // FK → Orçamento.id
  id_categoria: number,     // FK → Categoria.id
  valor: number,            // valor em reais (float), sempre > 0
  descricao: string,        // texto livre, obrigatório
  data: string              // data no formato "YYYY-MM-DD" (opcional em registros antigos)
}

Invariantes

  • id_orcamento referencia o orçamento do mês em que o lançamento foi criado.
  • Ao deletar uma categoria, todos os lançamentos com aquele id_categoria são removidos em cascata.
  • O campo data é preenchido automaticamente com a data atual na criação. Pode ser editado posteriormente via editarLancamentoHandler().
  • Lançamentos são exibidos ordenados por data decrescente (mais recentes primeiro), com desempate por id.
  • Registros criados antes da introdução do campo data não terão essa propriedade — o código trata a ausência com fallback para string vazia.

Relacionamentos

Categoria (1) ──── (N) Lançamento
Orçamento (1) ──── (N) Lançamento

Não há foreign key enforced — as relações são resolvidas em memória nas funções de render e cálculo, sempre via find() ou filter() nos arrays lidos do storage.

Exemplo: join para calcular gasto por categoria

const o    = orcamentoAtual();
const cats = get(STORAGE.categorias);
const lanc = get(STORAGE.lancamentos).filter(l => l.id_orcamento === o.id);

cats.forEach(c => {
  const limite = o.valor_total * c.percentual / 100;
  const gasto  = lanc
    .filter(l => l.id_categoria === c.id)
    .reduce((s, l) => s + l.valor, 0);
});

IDs com Date.now()

Todos os IDs são gerados via Date.now() no momento da criação. Isso garante unicidade na prática (sem concorrência num app single-user), mas não é um UUID.

Risco de colisão: existe se dois registros forem criados no mesmo milissegundo. Nas categorias padrão isso é contornado com Date.now() + índice.

Formato do arquivo de backup

{
  "_versao": 1,
  "_exportado_em": "2026-04-18T14:30:00.000Z",
  "categorias": [
    { "id": 1713369600000, "nome": "Custos fixos", "percentual": 30, "cor_hex": "#f59e0b" }
  ],
  "orcamentos": [
    { "id": 1713369601000, "mes_referencia": "2026-04", "valor_total": 5000 }
  ],
  "lancamentos": [
    { "id": 1713369602000, "id_orcamento": 1713369601000, "id_categoria": 1713369600000, "valor": 150, "descricao": "Mercado", "data": "2026-04-18" }
  ]
}
Atenção: A importação sobrescreve completamente o storage — não faz merge. Dados existentes são perdidos. A validação verifica apenas a existência das chaves; não valida estrutura interna.

Isolamento de dados por mês

O app não filtra dados históricos por padrão — todos os lançamentos de todos os meses ficam no mesmo array. O isolamento acontece sempre que se filtra por id_orcamento:

// Apenas lançamentos do mês atual:
get(STORAGE.lancamentos).filter(l => l.id_orcamento === orcamentoAtual().id)

Isso significa que get(STORAGE.lancamentos) cresce indefinidamente com o tempo. Não há mecanismo de arquivamento automático — apenas o backup/restore e o resetarMes() (que afeta somente o mês atual).