## Why Enable Banking connections re-requested **a year of transaction history every 6 hours**. Trade Republic connections failed ~70% of their syncs with HTTP 429 (220 of 307 attempts in the last 7 days, against 1–4% for other ASPSPs), and the cause was ours: 1. `EnableBankingSyncer::sync()` persisted the transactions, then the balance call threw a 429, the exception bubbled up to `SyncBankingConnectionJob`, and the whole run was marked failed. 2. `last_synced_at` is only written after a clean `sync()`, so it never got written. 15 of 16 Trade Republic connections have zero rows in `account_balances` and `last_synced_at = NULL` weeks after connecting. 3. `$isFirstSync = ! $connection->last_synced_at || $this->fullSync` was therefore permanently true, so every run used `now()->subYear()` with `strategy = 'longest'` plus `calculateHistoricalBalances()`. Paginating a year of history four times a day is what trips the rate limit. This is **not Trade Republic specific**: CaixaBank and Eurocaja Rural show the same 429 pattern at lower volume, and the fix applies to every Enable Banking ASPSP. ## What changed All in `app/Services/Banking/Sync/EnableBankingSyncer.php`. **1. The fetch window comes from the transaction watermark, not from `last_synced_at`.** The `linked` branch already did this; the lookup is now shared by both branches: - **Watermark found** → `date_from` = that transaction's date minus a 3-day overlap (banks post transactions with retroactive value dates, so the previous no-overlap watermark could silently miss them), no `longest` strategy. - **No watermark** → unchanged: one year back with `strategy = 'longest'`. That is the genuine first sync. This is the fuse: even if everything else fails, a routine sync asks for a few days instead of a year. The two branches' differing balance handling (`saveDailyBalances: false` for linked accounts, `calculateHistoricalBalances()` on first sync for unlinked) is untouched. **2. A failing balance call no longer fails the whole sync.** It is logged, counted, and surfaced as `balance_failed` in the array `sync()` returns, so it lands in `banking_sync_logs.metadata` instead of being silently swallowed. The run finishes clean → `last_synced_at` gets written → `$isFirstSync` stops being permanently true → `calculateHistoricalBalances()` stops running every 6 hours. **Two exceptions are deliberately still fatal** (both raised by review, see below): an expired session, and a **429**. ## Deviation from the brief, worth a look The brief asked for *every* balance failure to be non-fatal, including the 429. Both review passes flagged the same problem with that: a 429 escapes `EnableBankingProvider` as a raw `RequestException`, and that is exactly what `SyncBankingConnectionJob::isRateLimitError()` matches to set `rate_limited_until`. Swallowing it would have removed the only backoff — and Enable Banking quotas are **per-consent daily access counts** (`Maximum daily access exceeded`, `Allowed number of accesses exceeded for consent`), so a connection that lost its backoff would keep burning the remaining quota on every 6-hourly cycle and never get its balances. So a balance 429 is re-thrown and the existing backoff (untouched, as the brief required) applies. The transactions from that run are still persisted, and the connection stays `active`. Every other balance failure is non-fatal as specified. Three more findings from review, fixed in the second commit: - **`--full` still forces the year-wide window.** A first sync on a connection that has already synced can only come from that flag, so it beats the watermark. Without this, `banking:sync --full` had become a no-op for the window — the operator's only remedy for a gap in history. - **Windows reaching back more than 90 days keep `strategy = 'longest'`.** A dormant account with an old watermark would otherwise be rejected (422) and walked down the `[90, 30, 7]` narrowing ladder, which advances the watermark past the span it never fetched — a silent, permanent gap. - **The watermark counts trashed rows** (the dedup already uses `withTrashed()`, so re-fetching them creates nothing), and the future-date clamp no longer eats the 3-day overlap. ## Out of scope No migration, no new watermark column, no change to the `rate_limited_until` backoff, no manual reset of the broken production connections — the first clean sync clears `error_message` on its own. Two things worth a follow-up, not fixed here: `calculateHistoricalBalances()` is still gated on the connection-level `$isFirstSync` while the window is now per-account, so an account added to an already-synced connection pulls a year of transactions without a balance backfill; and 61 accounts have never received a bank transaction at all, so they stay on the year-wide window until one lands. ## QA Ran the whole chain with nothing mocked but the network (real `EnableBankingProvider`, `Http::fake`), checking the request that actually leaves for the bank: | Case | Request that goes out | |---|---| | Account with a watermark at `2026-08-08`, today `2026-08-10` | `…/transactions?date_from=2026-08-05&date_to=2026-08-10` — 5 days, no `strategy` | | Account with no bank transactions | `…/transactions?date_from=2025-08-10&date_to=2026-08-10&strategy=longest` — unchanged | | Balances returns 429 `Maximum daily access exceeded` | transactions persisted, `status = active`, `rate_limited_until = 2026-08-11 00:00:00` (next UTC midnight) | Against the production database (read-only), for the 475 syncable Enable Banking accounts: | After the fix | Accounts | Avg. days requested | |---|---|---| | Watermark → short window | 382 | 13.7 | | No watermark → 1 year + `longest` | 61 | 365 | | Watermark older than 90d → wide + `longest` | 32 | 173 | All 12 Trade Republic accounts have a watermark, averaging **7.1 days** — down from 365 on every run. ## Tests `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php`: - an account with existing Enable Banking transactions asks for `watermark - 3 days`, not a year (the test that matters) - an account without them still asks for the year with `longest` - `--full` beats the watermark - a non-429 balance failure → connection stays `active`, `last_synced_at` written, the run's transactions persisted, `balance_failed: 1` in the sync log metadata - a 429 balance failure → transactions persisted and the backoff still applied - an expired session during the balance call is not swallowed Full suite green (2080 passed), `pint` and `phpstan` clean. |
||
|---|---|---|
| .agents/skills | ||
| .claude | ||
| .cursor | ||
| .github | ||
| .opencode/skills | ||
| .pi | ||
| app | ||
| bootstrap | ||
| config | ||
| database | ||
| docker | ||
| docs | ||
| experiments | ||
| lang | ||
| public | ||
| resources | ||
| routes | ||
| screenshots | ||
| scripts | ||
| src/lib/crypto | ||
| storage | ||
| templates/coolify | ||
| tests | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .env.production.example | ||
| .gitattributes | ||
| .gitignore | ||
| .mcp.json | ||
| .php-cs-fixer.dist.php | ||
| .php-version | ||
| .prettierignore | ||
| .prettierrc | ||
| .release-it.json | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| Dockerfile | ||
| Dockerfile.production | ||
| LICENSE.md | ||
| LOCALIZATION.md | ||
| ONBOARDING.md | ||
| README.md | ||
| artisan | ||
| autoresearch-dashboard.md | ||
| autoresearch.jsonl | ||
| autoresearch.md | ||
| autoresearch.sh | ||
| boost.json | ||
| bun.lock | ||
| chatgpt-app-submission.json | ||
| components.json | ||
| compose.yaml | ||
| composer.json | ||
| composer.lock | ||
| docker-compose.production.yml | ||
| eslint.config.js | ||
| falcode.json | ||
| opencode.json | ||
| package-lock.json | ||
| package.json | ||
| phpstan-baseline.neon | ||
| phpstan.neon | ||
| phpunit.xml | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
| vitest.setup.ts | ||
| whispermoney | ||
| worktree.sh | ||
README.md
Deutsch | Español | français | 日本語 | 한국어 | Português | Русский | 中文
Whisper Money
The most secure way to understand your finances.
Whisper Money is a privacy-first personal finance application that helps you track, categorize, and understand your spending—all while keeping your financial data encrypted and secure.
🎮 Try the Demo: Experience Whisper Money with our demo account - no registration required!
💬 Join our Community: Whether you're a user looking for help or a developer wanting to contribute, we'd love to have you in our Discord server! Share feedback, ask questions, discuss new features, or just hang out with fellow privacy enthusiasts.
Features
- 🔐 Privacy-first — Your data is never shared with third parties. You own it
- 🏦 Bank account management — Track multiple accounts in one place
- 📊 Transaction categorization — Automatic and manual categorization
- 🤖 Automation rules — Set up rules to auto-categorize transactions
- 📈 Financial insights — Understand your spending patterns
Tech Stack
- Backend: Laravel 12, PHP 8.4
- Frontend: React 19, Inertia.js v2, TypeScript
- Styling: Tailwind CSS v4
- Database: MySQL
- Cache/Queue: Redis
- Testing: Pest v4
Running Locally
Quick Start (Recommended)
The easiest way to get started is using our automated setup script:
bash <(curl -fsSL https://whisper.money/setup.sh)
After installation, just visit https://whisper.money.localhost in your browser.
Manual Setup
If you prefer to set up manually:
- Clone the repository:
git clone https://github.com/whisper-money/whisper-money.git
cd whisper-money
- Run the setup script:
whispermoney install
Available Commands
Important: You must run
whispermoney installbefore using any other command. If you skip the install step, commands likestartwill not work.
Once installed, you can use the whispermoney command for common tasks:
# Start all services
whispermoney start
# Stop all services
whispermoney stop
# Upgrade to latest version
whispermoney upgrade
# Interactive menu
whispermoney
Development Server
For active development with hot reloading:
composer run dev
This will concurrently start:
- PHP development server (via Portless HTTPS proxy)
- Queue worker
- Log viewer (Pail)
- Vite dev server
The application will be available at https://dev.whisper.money.localhost. In git worktrees, the branch name is automatically prepended (e.g. https://fix-ui.dev.whisper.money.localhost).
Running with Docker (Production Image)
For testing the production Docker image locally:
- Copy the production environment file:
cp .env.production.example .env
- Start the services:
docker compose -f docker-compose.production.yml up -d
The application will be available at http://localhost:8080.
To use a different port, set APP_PORT:
APP_PORT=3000 docker compose -f docker-compose.production.yml up -d
Deploying to Coolify
Whisper Money can be easily deployed to Coolify using our Docker Compose template.
Quick Deploy
- In Coolify, create a new resource and select Docker Compose
- Choose Empty Compose File as the source
- Paste the contents from our template: 👉 whisper-money.yaml
- Deploy!
The template includes:
- Whisper Money application container
- MySQL 8.0 database with health checks
- Persistent volumes for data and storage
- Auto-generated database credentials
Required Environment Variables
| Variable | Description |
|---|---|
RESEND_API_KEY |
Email service API key (for password resets, notifications) |
Note:
APP_KEYandAPP_URLare auto-configured. The container generates anAPP_KEYon first startup if not provided.
Optional Environment Variables
| Variable | Default | Description |
|---|---|---|
DRIP_EMAILS_ENABLED |
true |
Enable drip emails (welcome, onboarding, feedback) |
REGISTRATION_ENABLED |
true |
Set to false to close public sign-ups (the /register routes return a 403 and every registration CTA is hidden) while keeping /login open |
SUBSCRIPTIONS_ENABLED |
false |
Enable Stripe subscriptions |
STRIPE_KEY |
- | Stripe publishable key |
STRIPE_SECRET |
- | Stripe secret key |
STRIPE_WEBHOOK_SECRET |
- | Stripe webhook signing secret |
AI_PROVIDER |
gemini |
AI provider for every AI feature (gemini, ollama, openai, ...) |
AI Provider
Whisper Money's AI features (transaction categorization and automation-rule
suggestions) run on laravel/ai and default to
Google Gemini. The provider is configurable independently of the model, so
you can point the app at any text provider laravel/ai supports — gemini,
openai, anthropic, azure, groq, xai, deepseek, mistral, or a
self-hosted Ollama server. Ollama is the headline case
because it keeps AI processing fully local and private — data never leaves your
infrastructure — but the switch is generic.
Each provider needs its own credentials configured for laravel/ai (e.g.
GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, or OLLAMA_URL). An
unknown or non-text provider fails fast when the AI feature runs.
| Variable | Default | Description |
|---|---|---|
AI_PROVIDER |
gemini |
Provider for all AI features. Set once to switch everything. |
AI_SUGGESTIONS_PROVIDER |
AI_PROVIDER |
Override the provider for rule suggestions only. |
AI_CATEGORIZATION_PROVIDER |
AI_PROVIDER |
Override the provider for transaction categorization only. |
AI_REPORTS_PROVIDER |
AI_PROVIDER |
Override the provider for the stats-report summaries only. |
AI_SUGGESTIONS_MODEL |
gemini-flash-latest |
Model used for rule suggestions. |
AI_CATEGORIZATION_MODEL |
gemini-flash-latest |
Model used for transaction categorization. |
AI_REPORTS_MODEL |
gemini-flash-latest |
Model used for the stats-report summaries. |
AI_REPORTS_TIMEOUT |
30 |
Seconds before a report is posted without its AI summary. |
GEMINI_API_KEY |
- | Required when the provider is gemini. |
OLLAMA_URL |
http://localhost:11434 |
Ollama server URL (used when the provider is ollama). |
OLLAMA_API_KEY |
- | Optional; only needed behind an authenticating proxy. |
Example: fully local AI with Ollama
AI_PROVIDER=ollama
OLLAMA_URL=http://ollama.example.local:11434
AI_SUGGESTIONS_MODEL=gemma3:12b
AI_CATEGORIZATION_MODEL=gemma3:12b
Make sure the model is pulled on the Ollama server first (ollama pull gemma3:12b).
Any other provider follows the same pattern: set AI_PROVIDER, that provider's
credentials, and the *_MODEL vars to one of its models. Gemini remains the
default, so existing deployments are unaffected.
Star History
License
This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.