Paycel: modelagem de despesa recorrente, tempo real e a disciplina por trás
Como uma conta que se repete todo mês é guardada uma vez só, por que o salário nunca vai ao servidor, e os critérios de versionamento e de modelagem de dados que aplico em todo projeto.

O Paycel é uma ferramenta de controle de despesas pessoal e familiar. Ele responde uma pergunta doméstica e chata: para onde foi o dinheiro do mês, e o que ainda vai vencer.
É o único projeto do meu portfólio que eu uso porque preciso, e não porque construí. Isso muda o tipo de decisão que aparece nele: quase tudo que está descrito abaixo saiu de um incômodo real de uso, e não de um requisito imaginado.
Este texto descreve a modelagem que sustenta o produto, a arquitetura de leitura em tempo real, as decisões de privacidade e os critérios de engenharia que eu aplico aqui e nos outros projetos.
O que a plataforma faz
Quatro superfícies, lendo a mesma base.
| Superfície | O que entrega |
|---|---|
| Visão geral | Total do mês, saldo do salário, pago, a pagar, vencidas e próximas de vencer |
| Despesas | Lista completa, filtros, edição, baixa e exportação em PDF |
| Agenda | Calendário mensal com as ocorrências por dia e totais do dia selecionado |
| Configurações | Salário de referência, perfil, e-mail, senha e exclusão de conta |
As despesas são categorizadas em sete grupos com cor própria: casa, saúde, alimentação, transporte, lazer, educação e outros. A categoria não é campo livre porque o painel precisa agregar, e agregação sobre texto digitado é agregação sobre erro de digitação.
A conta que se repete
A primeira versão do produto foi a mais óbvia possível: uma lista de despesas, cada uma com valor e vencimento. Ela quebrou no segundo mês.
O aluguel vence todo dia dez. A internet, todo dia cinco. Numa lista simples, ou você cadastra as duas de novo a cada mês, ou marca como paga e perde o registro do mês anterior. As duas saídas são ruins, e a segunda é pior porque parece que funcionou.
A tentação seguinte é resolver gerando linhas: cadastrou uma despesa recorrente, o sistema cria doze registros. É simples de escrever e envelhece mal. Quando o valor do aluguel muda, você tem doze documentos com o valor antigo e nenhuma forma óbvia de saber quais ainda deveriam mudar.
O Paycel guarda a despesa uma vez só e calcula as ocorrências na hora de mostrar o mês:
if (!expense.recurring) {
if (start !== period) continue
result.push({ ...expense, key: expense.id, period })
continue
}
if (start > period) continue
result.push({
key: `${expense.id}:${period}`,
id: expense.id,
dueDate: dueDateInPeriod(period, parseDueDate(expense.dueDate).getDate()),
isPaid: expense.paidPeriods.includes(period),
recurring: true,
period,
})O documento tem um dueDate, que marca quando aquela conta passou a existir, e um paidPeriods, que é a lista dos meses em que ela foi quitada. Fora isso, nada é duplicado.
Três consequências caem de graça daí.
Editar é editar um campo. Mudar o valor do aluguel toca um documento, e o mês que ainda não chegou já nasce com o valor novo.
A baixa é por período, e não colide. Marcar um mês específico não toca em nenhum outro:
await updateDoc(ref, {
paidPeriods: isPaid ? arrayUnion(occurrence.period) : arrayRemove(occurrence.period),
})arrayUnion e arrayRemove são operações sobre o elemento, não sobre a lista inteira. Dois dispositivos marcando meses diferentes ao mesmo tempo não sobrescrevem um ao outro, o que resolve concorrência sem transação explícita.
Meses curtos não transbordam. Uma conta que vence dia 31 não existe em fevereiro, então a projeção limita o dia ao último dia daquele mês em vez de deixar a data escorregar para março.
Documento ou relacional
A modelagem acima é de banco de documentos, e a escolha foi deliberada. Vale registrar o critério, porque ele é o mesmo que aplico quando a resposta é a outra.
O domínio do Paycel é raso e agregado por dono: um usuário tem despesas, e praticamente toda leitura é “as despesas deste usuário”. Não há junção entre entidades independentes, não há relatório cruzando várias tabelas, e a sincronização entre dispositivos importa mais que a expressividade da consulta. Nesse formato, documento com sincronização em tempo real entrega mais do que custa.
Quando o domínio é relacional de verdade, com entidades independentes que se cruzam, o modelo relacional é mais honesto, e nele existem decisões que eu trato como não negociáveis:
CREATE TABLE lancamento (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
usuario_id BIGINT UNSIGNED NOT NULL,
categoria_id BIGINT UNSIGNED NOT NULL,
valor_centavos INT NOT NULL,
ocorrido_em DATE NOT NULL,
criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_lancamento_usuario
FOREIGN KEY (usuario_id) REFERENCES usuario(id) ON DELETE CASCADE,
INDEX idx_lancamento_usuario_data (usuario_id, ocorrido_em)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;Cinco decisões estão embutidas aí.
- Dinheiro em inteiro de centavos. Ponto flutuante para valor monetário acumula erro de arredondamento. Não é opinião.
- Chave estrangeira declarada. Integridade referencial no banco, não na camada de aplicação, que é onde ela vaza.
- Índice composto espelhando a consulta real. A pergunta é sempre “lançamentos deste usuário neste período”, e o índice diz exatamente isso. Uma consulta que varre a tabela inteira parece instantânea com quinhentas linhas e trava com quinhentas mil.
utf8mb4. Outf8do MySQL não cobre todo o Unicode, e acento e emoji quebram.- Migração versionada. Alteração de esquema é código: entra em Git e é revisada como código.
A escolha do motor segue a mesma lógica de adequação. SQLite quando o banco é um arquivo que acompanha a aplicação, sem servidor e sem operação, o que serve muito bem a ferramenta local e a aplicação de desktop. PostgreSQL quando há concorrência real, tipos ricos e consulta analítica. MySQL quando o ambiente já é esse. A pergunta que decide não é qual é o melhor banco, é qual é o formato de acesso e quem vai operar aquilo.
Uma assinatura, três telas
O painel, a lista e o calendário mostram a mesma informação de três ângulos. A tentação é fazer cada tela buscar o que precisa, e o resultado disso é três estados que discordam entre si.
Todas leem a mesma assinatura em tempo real e derivam o resto em memória:
const q = query(
collection(db, 'expenses'),
where('userId', '==', requireUserId()),
orderBy('dueDate', 'asc')
)
return onSnapshot(q, (snapshot) => onData(snapshot.docs.map(toExpense)), onError)O calendário agrupa por dia, o painel soma por situação, a lista ordena por vencimento. Marcar uma conta como paga em qualquer uma das telas atualiza as três, porque nenhuma tem cópia própria do estado.
Vale registrar o custo: where e orderBy em campos diferentes exigem índice composto declarado no Firestore. É a mesma disciplina de índice do modelo relacional, com a diferença de que o aviso aparece como uma URL no console em vez de um plano de execução.
O erro também é tratado por caso, e não como uma tela genérica: permissão negada diz que faltou permissão, falha de leitura diz que não foi possível carregar, e ambos oferecem uma nova tentativa que reassina a consulta.
O que não vai para o servidor
O painel mostra quanto sobrou do salário depois das contas do mês, e para isso precisa saber o salário. Essa foi a decisão que eu mais demorei a tomar.
O salário fica no localStorage, nunca no banco. A tela de ajustes diz isso com todas as letras: salvo apenas neste navegador. Trocar de dispositivo significa digitar de novo, e esse é o custo que eu escolhi pagar.
O raciocínio é de proporcionalidade. O app não precisa desse número para funcionar; ele precisa dele para fazer uma divisão na tela. Guardar no servidor um dado sensível que só serve a um cálculo local é assumir risco em troca de conveniência, e num app doméstico de finanças esse risco é assimétrico. As despesas, que precisam sincronizar entre dispositivos e sobreviver à limpeza do navegador, ficam no banco. O salário, não.
A leitura desse valor usa useSyncExternalStore com um evento próprio, o que mantém abas diferentes em acordo sem precisar de estado global nem de recarregar a página.
A mesma proporcionalidade guia o resto da conta: a plataforma oferece troca de e-mail com verificação prévia, troca de senha com reautenticação e exclusão definitiva da conta. Poder sair é parte de poder entrar.
Exportação sem servidor
O relatório do mês sai em PDF gerado no próprio navegador, com a tabela de ocorrências, situação e totais. A biblioteca é carregada sob demanda, no momento em que o usuário clica:
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
import('jspdf'),
import('jspdf-autotable'),
])Duas coisas boas saem daí. O peso da biblioteca não entra no carregamento inicial de quem nunca exporta, e o documento nunca sai do dispositivo, o que mantém a mesma política de dados do resto do app.
Versionamento como parte do produto
O padrão que eu sigo hoje começa antes do código:
git switch -c feat/despesa-recorrente
git add -p
git commit -m "feat: ocorrencia derivada em vez de linha por mes"
git push -u origin feat/despesa-recorrenteQuatro regras, em ordem de importância.
Uma branch por intenção. O nome declara o que aquele conjunto de mudanças resolve, e isso é a primeira linha de documentação que alguém lê.
Todo commit compila. Um ponto do histórico onde o build quebra é um ponto para o qual não dá para voltar, e um histórico onde não dá para voltar não serve para nada.
main sempre publicável. Com deploy automático, isso deixa de ser disciplina e vira consequência: o que entra em main vai para produção.
Mensagem que descreve o porquê. O diff já mostra o quê. O que ele não mostra é a razão, e é a razão que você procura seis meses depois.
O git add -p é a mudança de hábito com melhor retorno da lista. Ele obriga a olhar cada trecho antes de versionar, e é onde eu pego a maior parte dos console.log esquecidos. Antes de abrir merge, o mesmo checklist curto: build passa, tipos passam sem any novo, nenhum segredo no diff, mensagem no imperativo descrevendo a intenção.
O mesmo critério vale para licença nos projetos abertos. GPL-3.0 obriga trabalhos derivados a permanecerem abertos, MIT não obriga nada, e nenhuma das duas é a certa por padrão. A pergunta é o que você quer que aconteça com o seu código depois.
A pilha
| Camada | Tecnologia | Papel |
|---|---|---|
| Aplicação | Next.js 16 e React 19 | Rotas agrupadas por autenticação |
| Persistência | Cloud Firestore | Despesas, com leitura em tempo real |
| Autenticação | Firebase Auth com e-mail, senha e Google | Sessão, recuperação e gestão de conta |
| Interface | Headless UI e Tailwind CSS 4 | Componentes acessíveis e estilo |
| Movimento | Framer Motion | Transições de entrada contidas |
| Relatório | jsPDF e jsPDF AutoTable | Exportação no cliente |
Próximos passos
- Valor com vigência. Hoje editar o valor de uma recorrente muda também a leitura dos meses passados. Guardar uma pequena série de vigências resolveria sem voltar a duplicar documento.
- Recorrência não mensal. O modelo assume mês. Bimestral e anual cabem na mesma estrutura, com um campo de intervalo.
- Orçamento por categoria. O painel já agrega por categoria; falta o limite e o aviso de quando ele é ultrapassado.
Onde está
O Paycel está em paycel.vercel.app, com painel, lista, agenda, exportação e gestão de conta.
O que ele demonstra melhor não é a interface, é a modelagem: uma despesa recorrente é um fato só, e o mês é um recorte de leitura. Acertar isso no começo foi o que evitou que a lista envelhecesse junto com o projeto.
Continue lendo
- 12 min
jStudio: aplicativo de desktop em Tauri e Rust para o Roblox Studio
Ponte local com o plugin do Studio, agente com onze ferramentas e revisão obrigatória, cliente MCP embutido e um pipeline que reenvia animações pela Open Cloud. A arquitetura completa. - 13 min
SAML: a arquitetura de um framework de futebol competitivo no Roblox
Hyperflow, a camada de replicação binária. SAC, o anti-cheat autoritativo. Física de bola própria, posse com rewind por ping e um goleiro que calcula o rebote. - 14 min
Minha evolução: de scripts em Lua a aplicativos de desktop em Rust
O caminho de 2017 até hoje, tecnologia por tecnologia e decisão por decisão: HTML, EJS, Next.js, Electron, Tauri, Capacitor, os bancos que uso e por quê, e o papel exato que a IA ocupa no meu trabalho.