An open-source, self-hosted net worth tracker: bank accounts, investment portfolio, and crypto holdings in one place, kept in sync automatically.
European PSD2 open banking (via Enable Banking — Revolut, Wise, Fineco and 2000+ other European banks) + manually-tracked brokerage/crypto holdings → your own Supabase (Postgres) project → deterministic rules + Gemini categorization → a TanStack Start dashboard.
Live demo (read-only, no login): myfinancesss.lovable.app/demo
This repo is the backend half — sync scripts, Supabase Edge Functions, and DB migrations. The frontend lives in a separate repo, my-wealth-view.
If this is useful to you, a ⭐ on both repos helps other people find them.
- Firefly III / Actual Budget — great for manual budgeting, but no automated bank sync or AI categorization out of the box.
- Ghostfolio — great for portfolio tracking, but it's investments-only: no bank accounts, no transaction categorization.
- This project — automated PSD2 bank sync + AI categorization + subscriptions (auto-detected billing frequency, normalized to a true monthly cost) + portfolio + crypto, all in one net worth number, self-hosted on your own Supabase project. Trade-off: European (PSD2) banks only, and you run the sync yourself (cron/launchd, not a managed service).
Runs on autopilot via local scheduled jobs (launchd on macOS, or cron/systemd timers anywhere else): hourly bank sync, FX rates, AI categorization, and price refresh for portfolio/crypto, plus a "worth it?" push notification job for discretionary spending. The reference deployment is published at myfinancesss.lovable.app.
Everything runs hourly through run-daily-sync.sh (name predates the schedule change — it loops over every linked user, in order):
sync-transactions.js— pulls new bank transactions since the last sync (Enable Banking API) and upserts them intotransactions.sync-networth.js— pulls current bank account balances intonet_worth_snapshots.fx-sync.js— pulls daily EUR exchange rates (Frankfurter/ECB) intofx_rates, used to convert every non-EUR balance/transaction to EUR across the dashboard.categorize.js— categorizes each transaction. A deterministic rules engine (category_rulestable, user-editable in the dashboard) runs first; anything unmatched falls through to Gemini.classify-type.js— classifies each transaction asSubscriptionorOne-time. Transfers are forced toOne-timedeterministically (no AI call); everything else goes through Gemini.classify-flow.js— classifies each transaction'sflow_type(spend/income/transfer), deterministic only, no AI call: a currency exchange or wallet top-up is atransfer, not real income, so it's excluded from both spend and income analytics.sync-portfolio-prices.js— fetches live prices for stock/ETF holdings (portfolio_holdings) from Yahoo Finance's free public quote endpoint (the same public-data source Ghostfolio uses) and writesportfolio_snapshots.sync-crypto-coinbase.js— pulls live Coinbase balances via the Advanced Trade API (read-only key, JWT auth signed with Ed25519) and upserts them intocrypto_holdings.sync-crypto-prices.js— fetches live prices for all crypto holdings (Coinbase-synced and manually-tracked Ledger holdings alike) from CoinGecko's free API and writescrypto_snapshots.
Two more jobs run independently:
notify-worth-it.js(every 30 min,run-notify.sh) — finds recent discretionary transactions (Shopping/Entertainment/Dine Out/Experiences, above a minimum amount, 3-24h old) with noworth_itanswer yet, and sends a Web Push "worth it?" prompt (bundled into one digest notification if there are several), so you reflect on a purchase after the initial urge has faded but while it's still fresh.portfolio-reminder.sh(monthly) — fires a native OS notification on the 1st, since brokerage holdings (portfolio_holdings) are updated manually rather than live-synced.
Self-serve bank linking (connecting a new bank from the dashboard, no CLI needed) runs as three Supabase Edge Functions — see supabase/functions/README.md.
Supabase project diwezyrtlwdbrsgegkay (org "Marcoo"). Every table is Row Level Security-scoped to auth.uid() = user_id.
Tables
| Table | Purpose |
|---|---|
accounts |
Bank accounts known to the pipeline (drives sync instead of hardcoded arrays), each with its own consent_valid_until since every linked bank's PSD2 consent expires independently. |
transactions |
One row per bank transaction — amount, currency, category, transaction_type, flow_type (spend/income/transfer), raw payload. personal_amount/owed_by/owed_settled split a transaction fronted for others out of your spend analytics. Update access is trigger-restricted (see Security notes). |
net_worth_snapshots |
Daily bank account balance snapshots. |
fx_rates |
Daily EUR conversion rates per currency. |
category_rules |
User-editable deterministic categorization rules, checked before Gemini. |
subscription_billing_overrides |
Manual override for a subscription's detected billing frequency (e.g. correcting a yearly prepayment that would otherwise look like a huge "monthly" charge). |
portfolio_holdings / portfolio_snapshots |
Stock/ETF holdings (manually maintained) and their daily priced value. |
crypto_holdings / crypto_snapshots |
Crypto holdings (Coinbase-synced or manually tracked) and their daily priced value. |
Views (all security_invoker=true, so RLS applies through them): v_net_worth_eur, v_net_worth_daily (unions bank/portfolio/crypto snapshot dates, each forward-filled independently), v_transactions_eur, v_spend_by_category_monthly, v_income_vs_expenses_monthly, v_subscriptions (auto-detects billing frequency from charge intervals), v_portfolio_latest, v_crypto_latest.
.env— Enable Banking app ID + PSD2 key path, Supabase URL + secret key, Gemini API key, Coinbase API credentials.*.pem— Enable Banking PSD2 private key.last-sync.json— per-account watermark so re-runs only pull new transactions.sync-log-*.txt— daily run logs.
Guided (recommended): clone this repo and the frontend repo as siblings, then run the wizard. It logs you into Supabase (browser), creates or links a project, applies the schema, walks you through Enable Banking's free signup, generates push-notification keys, deploys the Edge Functions, and writes both apps' .env files.
git clone https://github.com/marcodonghiaa/myfinances.git
git clone https://github.com/marcodonghiaa/my-wealth-view.git
cd myfinances
./setup.sh
docker compose up -d --buildThe one thing it can't do for you: Enable Banking's signup itself has no API, so you'll get sent to their site mid-wizard to create a free application and download a key. Everything else — Supabase, Edge Functions, .env files — is automated.
Manual, if you'd rather do it by hand:
- Supabase project. Create one at supabase.com, then apply the schema:
supabase link --project-ref <your-project-ref> supabase db push # runs supabase/migrations/
- Enable Banking. Sign up at enablebanking.com, create a Production application (not Sandbox), and set its redirect URL to
https://<your-project-ref>.supabase.co/functions/v1/bank-consent-callback— must match exactly, or bank linking fails later. Registering downloads the PSD2 private key. The app starts "Inactive"; in Enable Banking's own Control Panel, click "Activate by linking accounts" and link any one of your own real accounts through their UI — this is a one-time gate that unlocks the app for free, non-commercial "Restricted Production" use (see the ToS notes). It does not connect that account to this app — that's a separate step, below. - Edge Functions (self-serve bank linking from the dashboard): deploy the three functions in
supabase/functions/and setENABLE_BANKING_APP_ID/ENABLE_BANKING_PRIVATE_KEY/FRONTEND_URLas Supabase Edge Function secrets — seesupabase/functions/README.md. - Local scripts:
npm install cp .env.example .env # fill in Enable Banking, Supabase, Gemini, Coinbase (optional), VAPID keys
Link your first bank into the app itself (a separate step from activating the Enable Banking application above, even for the same account — Enable Banking's own activation never authorizes on this app's behalf). One-time per bank, or whenever its consent expires (session-check.js warns as expiry approaches). Easiest via the dashboard's "Connect a bank" button (Accounts page) once Edge Functions are deployed; or from the CLI:
node start-auth.js "<Bank Name>" <COUNTRY> # e.g. node start-auth.js "FinecoBank" IT
# open the printed URL, log in, approve consent — you'll be redirected to
# https://localhost:3000/callback?code=... (the page won't load, that's expected)
node exchange-code.js <code> # prints the linked account(s) — add them to the `accounts` tableRun a full sync manually:
bash run-daily-sync.shmacOS (launchd) is what the reference deployment uses — see com.marco.financesync.plist (hourly, runs run-daily-sync.sh) and com.marco.worthitnotify.plist (every 30 min, runs run-notify.sh) for the exact job definitions.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.marco.financesync.plist
launchctl print gui/$(id -u)/com.marco.financesync # check it's loaded
launchctl kickstart -p gui/$(id -u)/com.marco.financesync # trigger manually
launchctl bootout gui/$(id -u)/com.marco.financesync.plistOn Linux, a cron entry (0 * * * * cd /path/to/finance-app && bash run-daily-sync.sh) or a systemd timer does the same job — there's nothing macOS-specific in the scripts themselves.
Two containers (sync scheduler + frontend), both talking to your own Supabase Cloud project — not containerized itself. ./setup.sh (see Setup above) writes the .env files these need and finishes with the exact docker compose up -d --build command.
The frontend is served at http://localhost:3000; the sync container runs on its own internal schedule (scheduler.js, hourly sync + 30-minute notify check, no host cron needed).
This is a public portfolio piece handling real personal financial data, so it's built security-first:
.env,*.pem, andlast-sync.jsonare gitignored and must never be committed.- All Supabase tables are RLS-scoped to the authenticated user (
auth.uid() = user_id) — no table is readable across users. - The frontend never uses a service-role key — only the anon/publishable key, subject to RLS. Supabase's modern key system (
sb_publishable_.../sb_secret_...) is used throughout; the legacy JWT key pair that could bypass RLS has been fully disabled. transactionsis otherwise append-only from the pipeline's perspective — a Postgres trigger blocks user-initiated updates to every bank-sourced column (amount, currency, dates, account_uid, raw payload, etc.), leaving only the user-editable fields (category, transaction_type, worth_it, flow_type, personal_amount, owed_by, owed_settled) writable.EXECUTEon the trigger function itself is revoked fromanon/authenticatedso it can't be invoked directly via RPC.- The Coinbase integration uses a read-only ("View") API key — it can never place trades or move funds.
- The PSD2 private key (
*.pem) authenticates this app to Enable Banking; treat it like a password.
