Você toca uma empresa pequena e o livro-caixa é uma planilha. Funciona até duas pessoas editarem ao mesmo tempo, até alguém querer o saldo corrido por dia, ou até você precisar de um relatório que uma fórmula não entrega. O passo seguinte é um app web — e no momento em que você diz “app web” está dizendo também login, banco de dados, validação e hospedagem.
Reconstruímos um dos nossos: um controle de fluxo de caixa com três telas — lançamentos por mês com saldo corrido após cada dia, receitas e despesas por categoria, contas — num backend Back4app e num container Back4app.
O lado do navegador é JavaScript puro. O lado do servidor são 13 Cloud functions atrás de três classes que ninguém mais consegue ler. O web app faz o deploy a partir do GitHub com o Node.js Buildpack, então não há Dockerfile nessa história.
Teste, faça o deploy, leia: a demo ao vivo está em cashflowtracker-yg1lw573.b4a.run — entre com demo / bluefin-demo-2026, somente leitura, dados fictícios. O repositório completo está em github.com/back4app-templates/cashflow-tracker.
Vai construir com um agente de IA? Aponte-o para AGENTS.md e docs/api.md no repositório: os passos manuais, o que devolver a um humano e o contrato exato de cada função.
Tudo abaixo foi executado em 8 e 9 de outubro de 2026. Os números vêm dessas execuções, e os logs estão no repositório em docs/evidence/.
O que o controle de fluxo de caixa faz?
Ele mantém o livro-caixa de uma organização: contas com saldo inicial, categorias e lançamentos que são receita, despesa ou transferência entre duas contas. Cada lançamento tem uma data de vencimento, uma data de competência e, quando liquidado, uma data de pagamento. Esse é o modelo inteiro — três classes.
A tela Transactions mostra um mês por vez. Os cartões no topo trazem líquido, despesas e receitas com o que ainda falta pagar ou receber. As linhas são agrupadas por dia, e após cada dia a tela imprime o saldo corrido duas vezes: realizado (só as linhas pagas) e projetado (tudo, pendentes incluídas).
Um selo de status — Paid, Pending, Overdue — alterna o pagamento com um clique. A busca e um filtro de conta estreitam a lista.
A tela Report agrupa receitas e despesas por categoria em qualquer intervalo de até 24 meses, em regime de caixa (pela data de pagamento, só pagos) ou regime de competência (pela data de competência, pendentes incluídos), com um gráfico de rosca e linhas expansíveis. Accounts & categories é a tela de CRUD, com o saldo atual de cada conta.
![]()
O que ele não é: SaaS multi-tenant, sincronização bancária, trilha de auditoria ou contabilidade de partidas dobradas. A última seção diz quando recorrer a isso em vez dele.
O que fica no navegador, o que fica no backend e o que fica no container?
O navegador renderiza e formata; o backend decide; o container serve. Esta é a divisão, porque é a decisão de design da qual o resto do post depende.
| Camada | Faz | Não faz |
|---|---|---|
Navegador (public/, JS puro, sem bundler) | Renderiza 3 telas e a tela de entrar; chama Cloud functions pelo Parse JS SDK; esconde os controles que a role não pode usar | Tocar em qualquer classe diretamente; decidir qualquer coisa por conta própria |
Backend (Back4app, cloud/main.js) | Usuários, sessões, roles; toda a validação em hooks beforeSave/beforeDelete; saldos e relatórios com pipelines de aggregate; um job noturno de snapshot | Expor classes aos clientes: as permissões por classe não permitem ninguém |
Container (server.js, 40 linhas de Express) | Serve o app estático, serve o /config.js montado a partir de variáveis de ambiente, responde /healthz com a versão em execução | Guardar a master key; rodar lógica de negócio |
A Back4app fornece o banco de dados gerenciado, a infraestrutura de usuários e sessões, o runtime do Cloud Code, a hospedagem e o TLS. Você ainda escreve os 13 endpoints, sua autorização, validação e lógica, e o fluxo de entrar do cliente — o script de setup e os passos na dashboard abaixo são a lista completa.
Por que um container de 40 linhas em vez de hospedagem estática? Três razões concretas. As chaves públicas vêm de variáveis de ambiente, então um leitor faz o deploy da própria cópia definindo quatro valores, sem editar código. /healthz devolve a versão em execução, que é como você distingue um deploy novo de um antigo saudável. E é o caminho que não precisa de Dockerfile.
Se nada disso importa para você, o Web Deployment serve os mesmos arquivos por menos.
Como fazer o deploy pelo caminho rápido?
Pré-requisitos: uma conta na Back4app (gratuita), Node.js 22 ou mais novo na sua máquina, Git e uma conta no GitHub para o deploy do container.
-
Crie o backend. Dashboard → New App → Build your Backend → dê um nome → Create. Fica pronto em menos de dez segundos.
-
Copie as chaves. App Settings → Security & Keys. Copie
.env.examplepara.enve preenchaAPP_ID,JS_KEY,REST_KEYeMASTER_KEY(clique em Show key para a última). -
Configure o backend pelo terminal. Nada para clicar: classes, permissões fechadas, roles, os primeiros usuários e a config são criados por um único script idempotente.
npm install
npm run setup -- --demo --reference-date 2026-09-30
Saída esperada, literal da nossa execução:
schema Account: created
schema Category: created
schema Transaction: created
schema Snapshot: created
schema _User: updated (1 new field(s))
schema _Role: updated (0 new field(s))
role finance: created (d5a0JwFETo)
role viewer: created (M7t0oAO8G8)
user finance: created (sizFAZuX6S); password saved to .credentials.local.json
user demo: created (UWj2a8VtZo); password saved to .credentials.local.json
config: orgTimezone=America/New_York demo=true referenceDate=2026-09-30
Tire --demo --reference-date para um livro-caixa real: demo controla o banner e o reseed noturno, referenceDate congela o “hoje” para que a suíte de testes seja reproduzível.
- Faça o deploy do Cloud Code. Cloud Code na dashboard → Upload → Upload Files →
cloud/main.jsecloud/generate.js→ espere o badge Files pending deploy (2) → Deploy. Depois prove com uma requisição, não com o diálogo — o motivo está na seção de achados:
curl -s -X POST https://parseapi.back4app.com/functions/base \
-H "X-Parse-Application-Id: $APP_ID" -H "X-Parse-REST-API-Key: $REST_KEY" \
-H "Content-Type: application/json" -d '{}'
{"code":209,"error":"Sign in to use the ledger."}
Um 209 é a resposta certa: a função existe e recusa quem chama sem sessão. {"code":141,"error":"Invalid function: \"base\""} significa que nada foi enviado.
- Carregue os dados e verifique.
npm run seed -- --reference-date 2026-09-30carrega os seis meses fictícios (144 linhas, 4,6 s na nossa execução; toda linha passa pelos mesmos hooks que o app usa).npm run check -- --reference-date 2026-09-30recalcula tudo a partir do dataset e compara:
PASS setPaid uses today (2026-09-30), not the due date (2026-05-20)
PASS transfer USD → EUR → 142
PASS delete an account in use → 119
PASS report over 24 months → 142 LIMIT_EXCEEDED
159 passed, 0 failed
- Faça o deploy do web app. Envie o repositório ao GitHub. Containers → Deploy a web app → escolha o repositório → Build & run → Edit → Node.js Buildpack → runtime
22, start commandnode server.js(o formulário lê dopackage.jsone oferece Use it), porta8080, health check/healthz→ Deploy settings → Edit → Paste .env com quatro linhas:
APP_ID=…
JS_KEY=…
SERVER_URL=https://parseapi.back4app.com
DEMO=false
Crie o app. curl https://<your-app>.b4a.run/healthz devolve {"ok":true,"version":"0.1.1",…} quando está no ar. Entre com o usuário finance de .credentials.local.json.
![]()
Por que toda requisição passa por Cloud functions em vez das classes?
Porque este é o livro-caixa compartilhado de uma organização, não um monte de linhas que cada usuário possui. O padrão BaaS tradicional — SDK no cliente mais uma ACL por objeto — é o jeito mais rápido de iterar um app de consumo em que cada usuário vê só os próprios dados.
Um livro-caixa tem a forma oposta: um punhado de pessoas compartilha todas as linhas, toda regra precisa valer seja qual for o cliente que escreveu, e a JavaScript key pública está no código-fonte da página.
Então as três classes recebem permissões por classe que não permitem nada — nem público, nem autenticado — e só a master key, que só o Cloud Code tem, consegue lê-las ou escrevê-las. O script de setup define isso pela schema API:
// File: scripts/setup.mjs (excerpt) · Node 22
const MASTER_ONLY = { find: {}, count: {}, get: {}, create: {}, update: {}, delete: {}, addField: {}, protectedFields: {} };
// … for each of Account, Category, Transaction, Snapshot:
await rest(env, 'POST', '/schemas', { className, fields, indexes, classLevelPermissions: MASTER_ONLY }, { master: true });
Toda função então faz as mesmas duas coisas primeiro: exige um usuário logado e exige uma role. finance pode ler e escrever; viewer só pode chamar base, listMonth e report.
// File: cloud/main.js (excerpt) · Parse Server 7.5.2
async function requireRole(request, roles) {
if (!request.user) fail(E.INVALID_SESSION_TOKEN, 'Sign in to use the ledger.'); // 209
const q = new Parse.Query(Parse.Role);
q.containedIn('name', roles);
q.equalTo('users', request.user);
const role = await q.first({ useMasterKey: true });
if (!role) forbidden(`This action needs the ${roles.join(' or ')} role.`); // 119
return role.get('name');
}
As identidades são protegidas do mesmo jeito. O cadastro público é recusado por um beforeSave em _User, a menos que a master key crie a conta.
Usuários comuns podem trocar a própria senha e o e-mail; uma conta locked — o login compartilhado da demo é uma — não pode mudar nada sobre si mesma, então um visitante não sequestra a demo do visitante seguinte. _Role é só master key.
O npm run check:permissions do repositório é a prova: quatro identidades (anônimo, logado sem role, viewer, finance) contra as 13 funções, leituras e escritas diretas nas classes com a JavaScript key, edições de role, edições de identidade, autoexclusão e cadastro. Oitenta e duas verificações; as linhas que mais importam:
PASS anonymous base → 209 (expected 209)
PASS viewer createTransaction → 119 (expected 119)
PASS viewer GET /classes/Transaction → 119 (expected 119)
PASS locked user changes own password → 119 (expected 119)
PASS public sign-up → 119 (expected 119)
82 passed, 0 failed
Uma JavaScript key vazada é o estado normal de um app web. Projete para que ela não revele nada.
Este é o padrão do nosso post sobre como proteger o backend que o frontend acessa, aplicado a uma aplicação completa. Onde ele custa: toda tela nova precisa de uma função, e os métodos de conveniência do SDK do cliente ficam de fora. Para uma ferramenta interna com um conjunto estável de operações, é a troca certa.
Como o backend calcula saldos corridos sem SQL?
Duas decisões fazem o trabalho: datas são strings, e um único pipeline de aggregate soma tudo.
Datas são strings YYYY-MM-DD, não objetos Date. Uma data de negócio não tem hora nem fuso; como strings, elas se comparam lexicograficamente, então “setembro” é date >= '2026-09-01' AND date < '2026-10-01', uma única query de intervalo indexada.
O campo date não vem do cliente: um hook beforeSave o define como paidDate ?? dueDate em toda escrita. É o padrão NoSQL de pré-calcular na escrita um valor muito lido — e, para ser direto, também porque uma query Parse não tem COALESCE. Se você adaptar isto e passar para datas nativas, reescreva o hook e o pipeline juntos.
// File: cloud/main.js (excerpt) · Parse Server 7.5.2
Parse.Cloud.beforeSave('Transaction', async (request) => {
const o = request.object;
// … type, amount, dates, pointers and currency are validated above this line …
o.set('date', paidDate || dueDate); // the month list is one indexed range query
o.set('accountId', account.id); // plain ids mirror the pointers so the pipeline can group on them
o.set('toAccountId', toAccountId);
o.set('categoryId', categoryId);
});
Um pipeline, um fold. “Saldo antes deste mês” é o saldo inicial mais o efeito com sinal de todo lançamento anterior ao primeiro dia. Um $match seleciona as linhas e um $group soma os valores por tipo, conta e conta de destino.
Um fold de dez linhas então transforma os grupos num número com sinal — para todas as contas, onde as transferências se anulam, ou para uma conta, onde uma transferência é −valor na origem e +valor no destino.
// File: cloud/main.js (excerpt) · Parse Server 7.5.2
async function groupedEffects(match) {
const pipeline = [
{ $match: match },
{ $group: { _id: { type: '$type', accountId: '$accountId', toAccountId: '$toAccountId' }, total: { $sum: '$amount' } } },
];
const rows = await new Parse.Query(Transaction).aggregate(pipeline, { useMasterKey: true });
return rows.map((r) => ({ ...(r.objectId || r._id || {}), total: r.total || 0 }));
}
const PAID = { paidDate: { $gte: '0' } }; // null sorts below strings in MongoDB, so $lt alone would match pending rows
Essa última linha é o tipo de coisa que só se aprende rodando: na ordem de comparação do MongoDB, null fica abaixo de qualquer string, então paidDate: { $lt: '2026-09-01' } contaria alegremente as linhas pendentes como pagas. Exigir um valor do tipo string corrige isso.
Dinheiro é inteiro em centavos em todo lugar; o navegador formata com Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) e em nenhum outro lugar.
Os resultados são completos ou são um erro. Os totais sempre vêm do pipeline sobre o dataset inteiro. As listas são paginadas no servidor até o fim, e uma requisição que ultrapassaria 2.000 linhas num mês ou 20.000 itens de relatório devolve 142 LIMIT_EXCEEDED com a contagem, em vez de uma página truncada.
Testamos com um dataset gerado de 3.744 linhas: a lista do mês recusou (3.627 linhas), e os totais do relatório continuaram exatos.
![]()
Quais regras decidimos em vez de herdar?
A ferramenta antiga era a especificação, mas não a autoridade. Cada regra é uma frase no código, uma frase aqui e uma asserção em check.mjs.
- Saldo inicial conta antes de todo lançamento e não tem data; mudá-lo reescreve todos os saldos, e a tela avisa.
- Uma transferência é −valor na origem e +valor no destino, então se anula entre todas as contas; transferências nunca aparecem em relatórios de receita ou despesa.
- Saldo realizado no dia D = inicial + lançamentos pagos com
paidDate ≤ D. Projetado = inicial + todo lançamento comdate ≤ D. - Relatórios de competência incluem linhas pendentes; relatórios de caixa incluem só as pagas.
- Marcar como pago registra o hoje no fuso da organização, editável — não
min(dueDate, today), que a ferramenta antiga usava e que pode registrar um pagamento antes de ele acontecer. - Atrasado = pendente e
dueDate < today, onde “hoje” é calculado no servidor a partir deConfig.orgTimezone, nunca enviado pelo cliente.
O que os hooks não garantem, dito com clareza: eles são validação, não constraints de banco. npm run check:concurrency dispara dez creates paralelos com o mesmo nome — um passou, nove foram recusados, o que é timing, não garantia.
Depois ele corre um delete contra dez creates que referenciam o registro: os creates venceram, o delete foi recusado, o livro-caixa ficou consistente.
Ele também envia o mesmo lançamento duas vezes ao mesmo tempo: os dois são gravados, porque não há chave de idempotência. Um índice único pelo gerenciador de índices da dashboard fecha o primeiro caso; os outros são o preço de não ter transações, e o artigo estaria mentindo se os escondesse.
Como foi o deploy sem Dockerfile?
O repositório tem um Dockerfile, e ele não foi usado. Na etapa Configure app, Build & run ofereceu dois métodos de build: Dockerfile — you control the runtime, dependencies and environment e Node.js Buildpack — Back4app builds and updates the image for you — no Dockerfile needed.
Escolher o segundo mostrou um campo de versão do runtime já sugerindo >=22 a partir do engines do package.json, e a linha We read this project as starting with node server.js — Use it.
O log do deploy, da plataforma, do início ao fim:
14:34:40.830 PREPARING DEPLOYMENT...
14:34:41.180 Build Method: Buildpacks — detecting your app stack
14:34:43.109 Back4app: using Node 22, pinned in your app's settings.
14:34:51.893 - Detected Node.js version range: `22`
14:34:51.902 - Resolved Node.js version: `22.23.2`
14:34:54.344 - Running `npm ci`
14:34:55.600 added 68 packages, and audited 69 packages in 1s
14:34:56.242 - Adding default web process for `npm start`
14:34:56.263 Back4app: default web process set to: node server.js
14:34:57.574 Pushing image...
14:35:19.249 LAUNCHING CONTAINER...
14:35:37.917 CHECKING HEALTH...
14:35:39.603 DEPLOYMENT READY
Como medimos: relógio de parede do clique em Create app (relógio local) até a linha DEPLOYMENT READY no log da própria plataforma, uma execução, plano Free. A linha do health check diz trying to hit the 8080 port using http — é uma verificação de porta, então não leia /healthz como algo que a plataforma interpreta; ele é para você.
![]()
Quão rápido é, e quanto custa?
De um notebook no Brasil até o backend em USA East, logado como viewer, 100 chamadas sequenciais de cada função após 10 de aquecimento, em 9 de outubro de 2026:
| Função | p50 | p95 | mín / máx |
|---|---|---|---|
listMonth (um mês, ~30 linhas, dois aggregates) | 439 ms | 514 ms | 420 / 541 ms |
report (seis meses, regime de caixa) | 442 ms | 542 ms | 424 / 602 ms |
Toda observação está em docs/evidence/latency-2026-10-09.csv. A diferença entre as duas funções é pequena porque as duas são dominadas pela ida e volta, não pelo trabalho.
Custo, em outubro de 2026. O container: gratuito com uma URL de 60 minutos, ou Starter a US$ 5 por mês. O backend: o plano Free inclui 25.000 requisições por mês, 250 MB de banco de dados e 1 GB de arquivos — nossa demo roda nele, e a dashboard marca um app gratuito que passa da cota como Usage Exceeded.
O plano de backend seguinte, MVP, custa US$ 15 por app para 500.000 requisições, com requisições extras cobradas a US$ 5 por 100.000 (página de preços). Este template não adiciona rate limiting: ele limita o custo de uma requisição, pelos tetos de 24 meses e 2.000 linhas, não quantas requisições alguém envia.
O que observamos enquanto construíamos?
O primeiro deploy de Cloud Code não envia nada. Num backend criado minutos antes, o Deploy do editor informou Success on deploying your changes! enquanto Logs → System imprimia Warning: main.js not found, e /functions/base respondeu Invalid function pelos três minutos seguintes. Aconteceu nos dois backends que criamos para este post (9 de outubro de 2026).
Um segundo deploy, clicado enquanto o badge Files pending deploy estava ausente, também não enviou nada. O que funcionou, duas vezes: recarregar a página do Cloud Code, abrir cada arquivo e alterá-lo de novo para que o badge reapareça, depois Deploy — a função respondeu dois segundos depois. Trate o diálogo como um evento de UI e a requisição como a prova.
Um repositório criado depois de o GitHub App ser instalado não apareceu na lista de fontes do Containers — nem após um push, nem após mudar a instalação para All repositories. Ele apareceu no momento em que o GitHub redirecionou de volta ao Containers com setup_action=update (“Importing repositories…”).
Esse redirecionamento acontece quando você muda a seleção de repositórios na página de instalação do GitHub e salva. Se o seu repositório novo está faltando na lista, essa ida e volta é a correção.
$lt num campo string anulável conta os nulls. Descrito acima; a guarda $gte: '0' é a correção, e a suíte de testes foi o que pegou.
O “hoje” voltou como 10/09/2026. O Cloud Code calculava a data da organização com Intl.DateTimeFormat('en-CA', …).format(), um atalho comum que gera YYYY-MM-DD num Node com ICU completo — e gera, num notebook. O runtime do Cloud Code formatou como MM/DD/YYYY, o que teria quebrado toda verificação de atraso e o padrão de pagamento.
Uma data de teste congelada tinha escondido isso; tirar essa data para a demo ao vivo expôs o problema em um minuto. A correção monta a string a partir de formatToParts e se recusa a rodar se o resultado não for uma data de calendário.
![]()
Quais erros você vai ver, e o que eles significam?
{"code":141,"error":"Invalid function: \"base\""} — o deploy do Cloud Code não foi feito, ou o deploy não enviou nada. Recarregue a página do Cloud Code, deixe os arquivos pendentes de novo, faça o deploy e chame a função.
{"code":209,"error":"Sign in to use the ledger."} — nenhum session token na requisição. No navegador isso significa que a sessão expirou; o app desconecta você e mostra a tela de entrar.
{"code":119,"error":"This action needs the finance role."} — o usuário existe, mas é viewer ou não tem role. Adicione a role com npm run users -- add <username> finance ou mova o usuário pelo editor de roles da dashboard.
{"code":142,"error":"LIMIT_EXCEEDED: Month 2026-09 matches 3627 rows; the limit is 2000. Narrow the range."} — a requisição ultrapassaria um teto. Filtre por conta ou encurte o intervalo do relatório; os totais continuam exatos.
Missing env vars: APP_ID, JS_KEY no log do container — as variáveis de ambiente não foram definidas no app do Containers; o servidor encerra de propósito em vez de servir uma página que não consegue entrar.
Quando isto é a resposta errada?
- Você quer contabilidade, não um livro-caixa. Sem partidas dobradas, sem trilha de auditoria, sem sincronização bancária, sem multimoeda na interface. QuickBooks ou Xero vencem no momento em que um contador entra na história.
- Cada usuário é dono das próprias linhas. Aí o padrão tradicional — SDK no cliente com ACLs por objeto — é mais rápido de construir e não precisa de uma função por tela.
- Invariantes entre registros precisam valer sob escritas concorrentes. Os hooks não dão isso. Use um banco de dados com transações, ou serialize as escritas por uma fila.
- Uma página estática basta. Se você não precisa de configuração por variáveis de ambiente nem de um endpoint de health com versão, o Web Deployment serve os mesmos arquivos sem container.
- Você já tem um Dockerfile em que confia. O buildpack é para quem não tem um; ele não é melhor do que um Dockerfile que você ajustou.
O que vem depois?
O padrão vai além de finanças: classes fechadas, funções como única porta, pré-cálculo na escrita para a query quente e um host que faz o build a partir do package.json. Qualquer ferramenta interna com um conjunto estável de operações cabe nele, e o script da matriz de permissões do repositório é reutilizável como está.
Clone o repositório, rode o caminho rápido e aponte o AGENTS.md para o seu agente se preferir assistir a digitar. Depois troque a fictícia Bluefin Studio pelas suas próprias contas — os saldos iniciais são os únicos números que você precisa trazer.