Understand your personal finances. Forget Excels, try Whisper Money.
Go to file
Víctor Falcón 57bcdf89c9
fix(banking): give every account its turn when the bank refuses one of them (#793)
> Sentry's MCP token is still expired, so this came from the production
DB again.

## The bug

`EnableBankingSyncer::sync` loops a connection's accounts doing
transactions-then-balances. The transaction call was wrapped, but only
for `InaccessibleBankAccountException` and
`WrongTransactionsPeriodException`. A
`TransientBankingProviderException` — what EnableBanking's HTTP 400
`{"error":"ASPSP_ERROR"}` becomes, i.e. "the bank's connector failed" —
propagated out and abandoned the loop, so **every account behind the
failing one was skipped, along with its balance, cycle after cycle**.

Verified on a CaixaBank connection with three accounts:

| account | transactions | balance days | last balance |
|---|---|---|---|
| 1 | 618 | 231 | 2026-07-19 |
| 2 | 0 | 13 | **2026-06-12** |
| 3 | 0 | 13 | **2026-06-12** |

2026-06-12 is the connection's `last_synced_at` — the last time a run
completed. Account 1 kept importing for another five weeks; 2 and 3
never got another turn.

**That user has since deleted their account, so this ships as a latent
fix, not a rescue.** 84 of the 260 live EnableBanking connections have
two or more accounts.

## Two things I had wrong

I opened this from a different pair of connections and the product
review took both apart with data I hadn't gathered — request durations.

- **Openbank (4 accounts, 0/0 on two of them)**: I read it as
starvation. It fails in **567–1,358 ms**, less than a single account's
work (a healthy Openbank account is ~3.7s), so it is failing on the
*first* call. And all four live Openbank connections stopped syncing
within four minutes of each other on 2026-08-11 18:03–18:07. That is a
**bank-wide connector outage**, not a per-account fault. Its earlier
zero-attempt days were 429s on the daily PSU quota, at 10.7s / 14.4s /
20.7s in — a different failure this diff deliberately does not touch.
- **Renta 4 (0 transactions in 67 days)**: 62 of those days had **zero
attempts**, because the connection had dropped out of the scheduled
rotation — the bug #782 fixed. Genuine consecutive retries: five days.

The mechanism is real; my examples of it weren't. The CaixaBank
connection is.

## Deliberately conservative

The first version let a partial run report success. Both reviews pushed
back and they were right, so it no longer does — the failure is raised
once every account has had its turn. The connection keeps its Error
state, its retries and its unset `last_synced_at` exactly as today.
**The only thing that changes is that the accounts behind the failing
one get attempted at all.**

What recording it as a success would have cost, all verified in the
code:

- **An Active badge and a fresh "Last synced" over an account that had
stopped updating.** `manage-accounts.tsx:285` renders every synced
account as `Syncing`, hardcoded; there is no per-account sync state
anywhere in the product, and the new metadata key had no reader. That is
a quieter dead end than the one being fixed.
- **Permanent loss of the failing account's derived balance history.**
`calculateHistoricalBalances` is gated on the *connection's* first sync.
On a partial first run it no-ops for the failing account (no
transactions yet), and once `last_synced_at` is set it is never called
again — so when that account finally backfills a year, its daily
balances are never computed while its siblings have them.
- **A "618 new transactions" email.** Stamping
`bank_transactions_email_cutoff_at` on a partial first sync means the
failing account's eventual backfill all lands after the cutoff, which is
precisely what the cutoff exists to suppress.
- The failing account's in-cycle retries would have dropped from 3 to 1.

A provider that never answered is rethrown immediately rather than
tolerated: `statusCode` is null only on the `ConnectionException` path,
and carrying on there spends the client's 20s timeout per account
against the job's 120s. Prod: a 26-account connection already takes 62s
when everything works, and a 5-account one has peaked at 67s. Without
this guard the fix would have turned a provider timeout into a killed
job.

## Verification

`tests/Feature/OpenBanking`: 358 tests, 348 pass, and the **same 10
failures as clean main** (Inertia page-render tests hitting the SSR
`/render` endpoint, no local server). 4 new tests driven through the
existing `runSync()` helper so they assert the job-level outcome, each
verified to fail with only its own change reverted:

- the starvation case (remove the catch → fails),
- the unreachable-provider guard (remove it → fails),
- **a 429 still reaches the job** — the property I was most worried
about. 429s arrive as a raw `RequestException`, never as
`TransientBankingProviderException`, so the new catch cannot swallow one
and keep burning a per-consent daily quota account after account.
Confirmed against 30 days of prod logs: 821 rate-limit failures, every
one recorded as `RequestException`, zero as the wrapped type.
- and that a partial run still leaves the connection in Error with
`last_synced_at` untouched.

`pint`, `dry` and `crap` green — `sync` was already at complexity 13
before this and the new branches took it to 16, so `resolveWindow`,
`recordAccountTransactionFailure` and `syncBalances` are extracted and
it now sits under 10.

## Follow-ups, not done here

- **The real systemic problem is quota, not this.** 260 rate-limit
events across 26 connections and 24 users in 7 days. The default backoff
is one hour when the message doesn't say "daily", and the scheduler runs
every six — so the backoff expires long before the next cycle and
changes nothing. Trade Republic connections 429 on every single cycle,
which is why 11 users have transactions but no balance at all. That
wants a design, not a patch, and it is the biggest thing in this
subsystem.
- **No per-account sync state.** Until that exists, a connection can
only be all-good or all-bad, which is what forced the conservative
choice above.
- **No escalation for a connection that never succeeds.** Since #757
correctly stopped counting transient failures, "The bank provider is
temporarily unavailable. We will try syncing again later." is a
permanent state with no threshold, no copy change and no email.
- **`WrongTransactionsPeriodException` still skips the balance call.**
The bank refused a date range; `/balances` takes none. Same argument as
this fix, one line, left out to keep the diff to one behaviour.
- **`WiseSyncer`'s transaction call is still unwrapped** — the same bug
class in the file #788 touched. Lower stakes (one token, one host) but
worth closing.

## Auto-merge

Enabled. The behaviour change is a single `catch` that lets the loop
finish, with every other observable — status, timestamp, retries,
notifications, first-sync side effects — deliberately identical to
today. It is additive for the accounts that were being skipped and a
no-op for single-account connections, which are 7,980 of the ~12,500
runs in the last 14 days.
2026-08-13 08:27:18 +00:00
.agents/skills chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
.claude chore: add /release slash command (#738) 2026-07-26 16:00:45 +00:00
.cursor chore: add sentry mcp (#300) 2026-04-17 10:42:34 +02:00
.github ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
.opencode/skills chore: Update larevel boot package 2026-01-27 10:55:46 +01:00
.pi fix(automation): avoid rule preview n+1 (#431) 2026-05-26 08:02:46 +02:00
app fix(banking): give every account its turn when the bank refuses one of them (#793) 2026-08-13 08:27:18 +00:00
bootstrap fix(subscriptions): assign the price arm before registration, not after (#792) 2026-08-13 08:14:37 +00:00
config fix(subscriptions): assign the price arm before registration, not after (#792) 2026-08-13 08:14:37 +00:00
database fix(subscriptions): assign the price arm before registration, not after (#792) 2026-08-13 08:14:37 +00:00
docker fix(docker): keep the client port in the URLs the app generates (#739) 2026-08-09 14:25:19 +00:00
docs feat(currencies): add the Danish Krone (DKK) (#754) 2026-08-10 12:08:56 +02:00
experiments feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
lang feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
public revert(pwa): drop handle_links to keep app deep-linking (#708) 2026-07-21 13:40:13 +02:00
resources fix(landing): keep the header from overflowing at mid widths (#791) 2026-08-12 15:47:03 +02:00
routes feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
screenshots fix: Add gap between filter/create button on mobile settings pages (#115) 2026-02-12 20:50:05 +01:00
scripts chore: release v0.2.5 (#539) 2026-06-15 16:48:25 +00:00
src/lib/crypto E2E Encryption 2025-11-07 14:21:25 +00:00
storage feat: Enable email verification on sign up (#97) 2026-02-03 10:15:07 +01:00
templates/coolify fix: split drip and default email senders (#263) 2026-04-06 12:16:47 +02:00
tests fix(banking): give every account its turn when the bank refuses one of them (#793) 2026-08-13 08:27:18 +00:00
.crap-ignore.json ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
.dockerignore fix: publish and use production Docker image (#393) 2026-05-20 07:19:31 +00:00
.editorconfig Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
.env.example feat(subscriptions): add an A/B price experiment (€3.99 control vs €8.99 high) (#700) 2026-08-12 13:36:49 +02:00
.env.production.example refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
.gitattributes Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
.gitignore ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
.jscpd.json ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
.mcp.json chore: add sentry mcp (#300) 2026-04-17 10:42:34 +02:00
.php-cs-fixer.dist.php Execute browser tests on CI (#10) 2025-12-03 16:26:30 +01:00
.php-version chore: ignore local .php-version 2026-02-07 18:48:04 +01:00
.prettierignore Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
.prettierrc Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
.release-it.json chore: release v0.2.5 (#539) 2026-06-15 16:48:25 +00:00
AGENTS.md ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
CHANGELOG.md chore: release v0.2.8 (#790) 2026-08-12 15:28:48 +02:00
CLAUDE.md feat(deps): upgrade Inertia.js from v2 to v3 (#769) 2026-08-11 14:33:31 +02:00
Dockerfile chore: Simplify IndexedDB sync by moving to Inertia shared props (#63) 2026-01-19 19:15:26 +01:00
Dockerfile.production fix(docker): keep the client port in the URLs the app generates (#739) 2026-08-09 14:25:19 +00:00
LICENSE.md Add Creative Commons license 2025-11-26 17:07:20 +01:00
LOCALIZATION.md feat: Spanish localization (#74) 2026-02-08 11:58:08 +01:00
ONBOARDING.md fix(banking): handle balance-fetch timeouts and silence handled retries (#450) 2026-05-29 14:58:38 +02:00
README.md feat(deps): upgrade Inertia.js from v2 to v3 (#769) 2026-08-11 14:33:31 +02:00
artisan Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
autoresearch-dashboard.md feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
autoresearch.jsonl feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
autoresearch.md feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
autoresearch.sh feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
boost.json chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
bun.lock feat(deps): upgrade Inertia.js from v2 to v3 (#769) 2026-08-11 14:33:31 +02:00
chatgpt-app-submission.json feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
components.json Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
compose.yaml chore: replace Caddy with Portless for local HTTPS proxy (#258) 2026-04-02 16:39:44 +01:00
composer.json feat(deps): upgrade Inertia.js from v2 to v3 (#769) 2026-08-11 14:33:31 +02:00
composer.lock feat(deps): upgrade Inertia.js from v2 to v3 (#769) 2026-08-11 14:33:31 +02:00
docker-compose.production.yml Add production Docker setup for easy self-hosting with the CI-built image (#42) 2025-12-30 07:22:19 +01:00
eslint.config.js Y3:0 2025-11-26 12:01:49 +01:00
falcode.json Add falcode config file 2026-03-11 15:28:52 +01:00
opencode.json chore: add sentry mcp (#300) 2026-04-17 10:42:34 +02:00
package-lock.json chore: release v0.2.8 (#790) 2026-08-12 15:28:48 +02:00
package.json chore: release v0.2.8 (#790) 2026-08-12 15:28:48 +02:00
phpstan-baseline.neon fix(static-analysis): clear phpstan-baseline by fixing all suppressed errors (#183) 2026-03-02 12:22:30 +00:00
phpstan.neon feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760) 2026-08-11 10:27:32 +02:00
phpunit.xml feat: use testcontainers for isolated MySQL in test runs (#153) 2026-02-25 10:14:20 +01:00
tsconfig.json Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
vite.config.ts chore(sentry): migrate Vite source map upload from Bugsink to Sentry (#630) 2026-07-03 13:36:13 +00:00
vitest.config.ts Fix cashflow null category rows (#382) 2026-05-11 18:54:26 +02:00
vitest.setup.ts feat: add multiple chart view modes for net worth evolution (#37) 2025-12-30 07:22:19 +01:00
whispermoney fix: Wrong whispermoney script path 2026-01-16 18:48:44 +01:00
worktree.sh feat(mcp): add OAuth 2.1 for Claude Desktop & ChatGPT connectors (Phase 3) (#691) 2026-07-17 19:10:48 +02:00

README.md

Whisper Money

Deutsch | Español | français | 日本語 | 한국어 | Português | Русский | 中文

Whisper Money

CC BY-NC 4.0

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 v3, TypeScript
  • Styling: Tailwind CSS v4
  • Database: MySQL
  • Cache/Queue: Redis
  • Testing: Pest v4

Running Locally

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:

  1. Clone the repository:
git clone https://github.com/whisper-money/whisper-money.git
cd whisper-money
  1. Run the setup script:
whispermoney install

Available Commands

Important: You must run whispermoney install before using any other command. If you skip the install step, commands like start will 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:

  1. Copy the production environment file:
cp .env.production.example .env
  1. 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

  1. In Coolify, create a new resource and select Docker Compose
  2. Choose Empty Compose File as the source
  3. Paste the contents from our template: 👉 whisper-money.yaml
  4. 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_KEY and APP_URL are auto-configured. The container generates an APP_KEY on 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

Star History Chart

License

This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.