Personal finance dashboard for one user. Reads balances from BankSync, snapshots them into Neon Postgres, and renders a mobile-first dashboard on Vercel.
The architecture and behavior live as an OpenSpec change at
openspec/changes/init-balances-dashboard/.
- Next.js 16 (App Router) + React 19 + TypeScript
- Tailwind v4
- Postgres + Drizzle ORM (Neon HTTP driver in production,
node-postgresfor local Docker — chosen automatically bylib/db/client.tsfrom theDATABASE_URL) - Zod-validated BankSync REST client (no SDK exists yet)
- Vercel hosting + Vercel Cron + Deployment Protection (password) for auth
Spins up Postgres 16 and the Next.js dev server side-by-side. App is exposed at http://localhost:7777.
cp .env.example .env.local # only BANKSYNC_API_KEY is needed locally
docker compose up --build # builds image and starts both services
# in a second terminal — apply schema to the local Postgres
docker compose exec web pnpm db:pushdocker-compose.yml injects a local DATABASE_URL automatically and sets a
dev-only CRON_SECRET. The repo is bind-mounted into /app so edits hot-
reload as usual.
Postgres is also exposed on localhost:5777 (user/pass/db: banksync) for
psql / TablePlus / etc.
pnpm install
cp .env.example .env.local
# Fill in BANKSYNC_API_KEY, DATABASE_URL, CRON_SECRET
pnpm db:push # apply schema
pnpm smoke # optional: hit BankSync end-to-end
pnpm devBrowser → Next.js (RSC) → Postgres (read path, instant)
▲
│
Vercel Cron ──▶ /api/refresh ──▶ BankSync API (write path, every 6 hours)
"Refresh now" ▶ server action ─▶ runSync()
- Pages never call BankSync. Only
lib/sync.ts::runSync()does, called from/api/refresh(cron) or the server action behind the "Refresh now" button. The API key never reaches the browser. - Snapshots are append-only.
balance_snapshotshas a unique constraint on(account_id, as_of)so re-syncing within an institution's update window is a no-op. - Money is
numericend-to-end. No JSnumberbetween BankSync and Postgres.
- Create a new Neon project at https://console.neon.tech.
- From the project dashboard, copy the pooled connection string
(host contains
-pooler). It looks like:postgres://user:pass@ep-xxx-pooler.region.neon.tech/dbname?sslmode=require. The HTTP driver inlib/db/client.tsauto-activates when it sees aneon.techhost, so this is the only DB knob you need to set. - (Optional but recommended for previews) install the Neon Vercel integration so each preview deploy gets its own DB branch.
pnpm dlx vercel@latest link # link the repo to a new/existing projectSet the three production env vars (dashboard or CLI):
vercel env add BANKSYNC_API_KEY production
vercel env add DATABASE_URL production
vercel env add CRON_SECRET production # openssl rand -hex 32Vercel Cron will automatically send Authorization: Bearer ${CRON_SECRET}
to /api/refresh — no extra wiring needed.
Project Settings → Deployment Protection → enable Password Protection for All Deployments. This is the only auth layer the app has; there is no user table or sign-in flow.
vercel --prodOn every production build the prebuild hook in package.json runs
pnpm db:migrate, applying any pending Drizzle migrations from ./drizzle/
to DATABASE_URL before next build starts. Preview and local builds skip
migrations so a feature branch never mutates the prod schema. To run
migrations against previews too, install the Neon Vercel integration and
broaden the check in scripts/prebuild.ts.
- Hit the deployed URL, satisfy the password prompt.
- Trigger a first sync from the dashboard (
REFRESHbutton) and confirm accounts + balances populate. vercel.jsonschedules/api/refreshevery 6 hours; the next cron run shows up in Vercel → Deployments → Cron Jobs.
/api/refresh declares export const maxDuration = 60 (Hobby ceiling).
On Pro, you can raise this to 300 in app/api/refresh/route.ts if the
sync ever grows past a minute.
pnpm lint
pnpm build
pnpm drizzle-kit generate # produce a migration after editing lib/db/schema.ts
pnpm drizzle-kit push # apply schema directly (dev convenience)