> Sentry's MCP token is still expired, so this came from the production DB and `failed_jobs` again — the follow-up I flagged in #782. ## The bug A user connected Wise on 2026-07-29 and **has never received a completed sync in 14 days**. Their wallet holds 110 transactions but **zero rows in `account_balances`**, so it contributes nothing to their net worth, and the connection shows a red Error badge with no notification ever sent. ## Root cause: one word `WiseClient::getActivities` sent the pagination cursor as `cursor`. Wise *returns* it as `cursor` but only *reads* it as `nextCursor` — the docs say it outright ("Pass this value as the `nextCursor` query parameter"). We sent the wrong name, Wise ignored it, and **every request returned page one again**. The walk could never terminate. The production data says the same thing without the docs: | created_at | rows | transaction_date range | |---|---|---| | 2026-07-29 06:44:49 | 87 | 2025-12-23 → 2026-07-20 | | 2026-07-29 06:44:50 | 10 | 2025-12-19 → 2025-12-23 | | every run since | 1/day | that day only | 97 rows in two consecutive seconds — one `size=100` page after `CARD_CHECK`/non-EUR filtering — and in ~50 runs over 14 days **never a row older than 2025-12-19**. Page two has never been fetched. The timeouts were a symptom, not the cause: the loop hammered one endpoint for 120s straight, four times a day, until Wise started answering with cURL-28s and a 500. 65 of the 66 `TimeoutExceededException` job failures in `failed_jobs` over 14 days are this one connection, which also burns three 120s attempts plus three worker kills per cycle on the single `default` worker, delaying everyone else's jobs. **I had this wrong.** My first pass diagnosed "a year of history is too much to paginate" and capped the walk at 60s. That would have converted an infinite loop into a permanent 100-activity ceiling on every Wise account — masking the bug while looking like a fix. The product review caught it; I verified it against both the Wise docs and the import timestamps before rewriting. ## The commits 1. **`nextCursor`.** The root cause. The test keys its fake off `nextCursor`, so the old name looks like what it was — a one-page history that never ends. With the wrong name the test does not terminate (verified under an alarm); with the right one it pages twice and stops. 2. **A time budget, as a safety net rather than the fix.** With pagination working a normal wallet finishes in two requests, but a long history or a slow provider would still get the job killed, and `last_synced_at` is only written on success — which is exactly the never-converges state. The deadline is the *caller's*, passed in: Wise creates one account per currency per profile, so a budget per wallet multiplies straight past the job's 120s (a three-wallet connection reproduced the original bug verbatim — there is a test). Null lets `banking:sync --sync`, which runs in-process without the worker timeout, walk as far as it likes. `WiseClient` now owns 15s/5s timeouts instead of inheriting the framework's 30s, so "budget plus one in-flight request" is a bound this code can actually state. Matches the two sibling clients. 3. **Balance first, and not load-bearing.** The balance ran after the walk, which never returned — hence zero balance rows. Ordering it first is only half the fix: `getBorderlessAccount` throws on a 5xx and nothing caught it, so done naively it just swaps which half the user loses. It is wrapped, counted into the returned metadata like `EnableBankingSyncer` does, and skipped wallets are reported too. ## Verification `tests/Feature/OpenBanking`: 354 tests, 344 pass, and the **same 10 failures as clean main** (Inertia page-render tests hitting the SSR `/render` endpoint with no local server — baseline confirmed). 4 new tests, each verified to fail with only its own change reverted: per-wallet budget → the multi-wallet test; no try/catch → the balance test; wrong cursor name → non-termination. `pint`, `crap` (0 methods over 10 — `importPage` is extracted because the deadline pushed `sync` to 11) and `dry` all green. ## Not done, deliberately - **Wise has no historical-balance backfill**, unlike Coinbase/IBKR/EnableBanking — `WiseBalanceSyncService` only ever writes *today's* balance, and `BalanceLookup::getBalanceAt` returns 0 with no earlier row. So this wallet will read €0 across the whole 12-month sparkline and step to its real value the day this ships, next to 110 transactions going back to December. Pre-existing and true of any new Wise connection, but this fix is what makes it visible. Its own PR. - **N wallets on one profile each walk the identical list** — the activities endpoint is per profile, and `parseActivity` filters by currency afterwards. Fetch once per profile and fan out; real cost and rate-limit win, bigger change. - **Backfilling older history.** The next sync starts from the connection-level `last_synced_at`, so pages left behind on a budget stop are not revisited. `EnableBankingSyncer::resolveDateFrom` already has the cheap pattern (derive the window from the imported rows, no schema change) — for Wise's newest→oldest walk that means setting `until` to the oldest imported row. Noted as the upgrade path in the code rather than "persist a cursor", which needs a migration. - **14 days broken and silent.** No notification exists for a connection stuck in Error or never-synced, and since #757 correctly stopped counting transient failures there is no escalation either. Worth an alert on days-since-last-success; called out as a follow-up in #782 too. ## Auto-merge Enabled. The root cause is a one-word parameter name confirmed against the vendor docs and independently against production data; the other two commits are additive safety with tests that each fail without them. No migration, no data writes, no schema change, and the affected code path serves one production connection that is currently completely broken — the downside of being wrong is bounded by that, and the upside is a user who gets their account back. |
||
|---|---|---|
| .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 | ||
| .crap-ignore.json | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .env.production.example | ||
| .gitattributes | ||
| .gitignore | ||
| .jscpd.json | ||
| .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 v3, 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.