## Summary Starts from a security finding on the background-job status endpoints and folds in the most important issues surfaced by a follow-up review of the same feature area (categorization backfill, bulk rule re-evaluation, automation-rule apply). Each change is its own commit. ## Changes ### 1. `fix(security)` — scope job-status cache keys to the owning user The categorization, bulk re-evaluation, and apply status endpoints looked jobs up by a bare job UUID with no user scoping. Any authenticated user who obtained another user's job id could poll its progress payload. Cache keys now include the owning user's id, so a status request keyed by the polling user's id resolves only that user's own jobs — a mismatched owner falls through to the existing 404. No ownership store or extra lookup. Cross-user isolation tests added for all three endpoints. ### 2. `fix(automation-rules)` — re-check `only_uncategorized` at apply time The apply flow cached a match snapshot for up to 15 min (keyed only by `rule.updated_at`) and applied the rule's category to every id in it without re-checking eligibility. A transaction categorized *after* the snapshot (by the user, a sibling rule, or a concurrent AI backfill) was silently re-categorized and stamped `category_source=Rule`. Now re-filtered through `shouldSkipForOnlyUncategorized()` at apply time; skipped rows are no longer counted as changed. ### 3. `perf(automation-rules)` — memoize rule set per user in `applyRules()` Bulk re-evaluation calls `applyRules()` once per transaction, and each call re-queried the user's whole rule set + labels — an N+1 scaling with transaction count (the sibling apply job already loaded rules once). Memoized per user for the service instance's lifetime (resolved fresh per job, so rules created mid-run are intentionally not seen). Query-count test asserts one `automation_rules` query regardless of transaction count. ### 4. `fix(transactions)` — stop polling when the job never starts The re-evaluate and apply pollers rescheduled on any non-terminal status, including `pending`. If the queue worker is down the job never runs, `failed()` never fires, and the client polled for the full hour-long TTL with a stuck spinner. Now gives up after 30 consecutive `pending` ticks (~30s), mirroring the guard the AI-categorization poller already has. Long `processing` runs are unaffected. ### 5. `test(automation-rules)` — cover apply job execution and failure branches `ApplySingleAutomationRuleJob` was only asserted to be *pushed*; its `handle()` body and `failed()` branch had no coverage, and `ReEvaluateTransactionRulesJob::failed()` was untested too. Added direct `handle()`/`failed()` tests pinning the progress-cache payloads. ## Deferred follow-ups (surfaced by review, not in this PR) - **Consolidate the three frontend pollers onto `usePollJobStatus`** — they hand-roll `setTimeout` loops and don't tear down on unmount (can `setState`/`onClose` after the dialog closes). The hook already exists and is unmount-safe; routing all three through it would also make the `pending` cap unit-testable. The cap in change #4 currently mirrors the already-shipped AI poller and is not separately unit-tested. - **`noteAlreadyPresent()` uses a substring match** (`AutomationRuleService`) — a rule note that is a substring of an existing note is silently not appended. Should compare note lines exactly. - **No dedup/lock on concurrent apply/re-evaluate jobs** — note appends are a non-atomic read-then-write, so two concurrent jobs can duplicate a note. Category/label writes are already idempotent. - **Transient 404 → false "failed"** — a mid-run cache eviction/TTL-expiry (or the deploy window of change #1, where an in-flight job's pre-deploy key is orphaned) makes the poller surface a false failure. Retry a few times on transient errors before giving up. - **`matches()` pagination** advances `next_offset` by fetched rows, not page-window size — deleted ids can stall infinite scroll before reaching `total`. - **"Apply to N" count / encrypted-skip counts** can be stale or under-report vs what's actually processed. - **Job-trait convention drift** between the three jobs (modern `Queueable` vs legacy trait stack). ## Testing - `vendor/bin/pint --test` — pass - `bun run format` / `bun run lint` — pass (one pre-existing unrelated warning in `chart.tsx`) - Affected Pest suites (apply, re-evaluate, evaluation, rule, categorization, apply-rule-suggestions) — 94 tests pass |
||
|---|---|---|
| .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 | ||
| 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) |
HIDE_AUTH_BUTTONS |
false |
Hide login/register buttons on landing page |
SUBSCRIPTIONS_ENABLED |
false |
Enable Stripe subscriptions |
STRIPE_KEY |
- | Stripe publishable key |
STRIPE_SECRET |
- | Stripe secret key |
STRIPE_WEBHOOK_SECRET |
- | Stripe webhook signing secret |
Star History
License
This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.