A web client for Budget Monitor, sharing the same Supabase
project as the Qt desktop app for cross-platform data. Built with React + Vite +
supabase-js, plus a thin Node parsing backend for bank-statement imports.
┌─────────────────────┐ ┌──────────────────────────┐
│ React + Vite SPA │ │ Qt Desktop app │
│ localhost:5173 │ │ (unchanged) │
│ supabase-js + │ │ QPSQL direct connection │
│ anon key + RLS │ └────────────┬─────────────┘
└──────┬──────────────┘ │
│ REST (CRUD, auth, reports) │ direct Postgres
▼ ▼
┌────────────────────────────────────────┐
│ Supabase Postgres (SAME PROJECT) │
└────────────────────────────────────────┘
▲
│ inserts with the CALLER's access token (anon key + RLS)
┌──────┴──────────────┐
│ Parser backend │ Express on localhost:3000
│ localhost:3000 │ upload PDF/XLSX/CSV → parse → insert
└─────────────────────┘
The web app uses the anon key, so Row Level Security is mandatory. Open the
Supabase dashboard → SQL Editor and run
supabase/migrations/0001_enable_rls.sql.
This does not affect the desktop app (its privileged role bypasses RLS).
Then apply the remaining migrations in
supabase/migrations/ in filename order.
# 1. Frontend
cd frontend
npm install
npm run dev # → http://localhost:5173
# 2. Parser backend (separate terminal, only needed for file imports)
cd backend
npm install
npm run dev # → http://localhost:3000Create frontend/.env:
VITE_SUPABASE_URL=https://YOUR-PROJECT.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-keyand backend/.env:
PORT=3000
SUPABASE_URL=https://YOUR-PROJECT.supabase.co
SUPABASE_ANON_KEY=your-anon-key
PDF_PARSER_CMD= # optional; path to the Python used for PDF parsingBoth sides use the anon key — there is no service-role key anywhere in this project. See Security notes.
PDF imports additionally need the Python dependencies:
pip install -r backend/parsers/requirements-pdf.txtUpload on the Import page; the file extension picks the parser
(backend/parsers/index.js). Each Chilean bank
exports a different, position-based layout, so there is one parser per bank
rather than a generic reader.
| Bank | Spreadsheet (.xlsx / .xls / .csv) |
|
|---|---|---|
| BICE | debit, credit (national) | debit |
| Banco de Chile | debit, credit | debit, credit |
| BancoEstado | debit | debit |
| Santander | debit | debit |
| Wise | debit | — |
Imports are deduplicated on date + amount + account + original_description.
original_description is frozen at import and never rewritten, so re-importing a
statement collapses onto existing rows even after you have edited a row's
description. Category is deliberately excluded from that key, so
recategorizing a transaction never causes a re-import to duplicate it.
An installment purchase is expanded at import into one transaction per remaining
installment, dated a month apart, each labelled (n/total) in its description.
BICE credit states the purchase total in its Monto column, so each
installment bills Monto / total, with the integer-CLP remainder on the earliest
cuotas. Banco de Chile instead states the per-cuota amount already, so that
parser repeats it unchanged — the two are not interchangeable.
The per-cuota amount and the (n/total) label are both derived from the
installment's absolute index in the plan, so a given cuota looks identical no
matter which month's statement projected it, and next month's import dedups
against it instead of inserting a second copy.
This means the credit view's card usage is the sum of every cuota still to be
billed, while each monthly payment is only the cuotas falling due that month.
A card payment (MONTO CANCELADO) settles an earlier statement, so it is excluded
from both figures rather than netted against charges it never covered.
| Qt desktop | Web equivalent |
|---|---|
mainwindow |
App.jsx + routing |
| Supabase auth (Qt REST) | supabase.auth.* in auth/ |
QSqlTableModel reads |
supabase.from(...).select() |
| add/edit dialogs | components/TransactionEditor.jsx |
MonthlyReportService |
src/lib/reports.js |
libs/ C++ parsers |
backend/parsers/ |
SimpleClassifier |
backend/parsers/classifier.js (XLSX/CSV) |
The PDF path uses a separate classifier ported in
statement_extract.py, which mirrors the
desktop's own split and uses slightly different category names for some buckets.
- Everything uses the anon key, front and back. It is safe in the browser only because RLS is enabled — do Step 0 first.
- The backend does not hold elevated credentials. It trusts the user's access
token (sent as a
Bearerheader), resolves the user with it, and performs every insert through a client scoped to that token, so RLS enforces ownership. A request without a valid token is rejected before any parsing happens. - If you ever introduce a service-role key, it must stay server-side and never be returned to the browser — it bypasses RLS entirely.
- Never commit real
.envfiles or bank statements..gitignoreexcludes every.env*variant (including timestamped backups like.env.bak.20260730, which hold live keys) and all.pdf/.xlsx/.xls/.csvstatement samples, which are real personal financial data.