## Summary Wave 1 hardening: privacy/security quick wins on the Inertia boundary, a CI safety net, and stricter test isolation. Four focused changes plus two review fixes, each in its own commit. No dependency or product-behavior changes. ## Changes (by commit) 1. **Hide sensitive User fields from serialization** — `User::$hidden` only covered password / 2FA / remember_token, leaving Cashier billing columns (`stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`) and the legacy `encryption_salt` exposed in every serialized User, including the Inertia-shared `auth.user` prop. None are read by the frontend and Cashier keeps reading them server-side, so hiding them is invisible to the UI and billing. 2. **Advisory frontend type-check in CI + duplicate `currency_code` fix** — the CI linter job never ran `tsc`, so type errors accumulated unseen. Adds a `Type Check Frontend` step running `bun run types`. It is `continue-on-error: true` on purpose: the codebase already carries ~157 pre-existing `tsc --noEmit` errors, so gating on it now would turn CI red on unrelated code. It surfaces type output today and should be flipped to blocking once the backlog is cleared. Also removes a duplicate `currency_code` member on the `User` TS interface (declared twice, `CurrencyCode` and `string | null`); the TS language server flags it, `tsc` masks it under `skipLibCheck`. 3. **Move residual-encryption cleanup out of Inertia `share()` into a queued job** — `share()` is a shared-data provider and must be read-only, but it ran a `DELETE`+`UPDATE` against the user on every non-API web GET to purge the leftover encryption salt / `EncryptedMessage` once a user had no encrypted data left. The existing `encryption:*` commands do not cover this case (both target users who *still* have encrypted data; this finalizes users who *finished* decrypting). The work now goes to a new idempotent `PurgeResidualEncryptionArtifactsJob` dispatched from `share()`, preserving the eventual-cleanup semantics without writing during the render. 4. **Block stray HTTP in the Feature test suite** — adds `Http::preventStrayRequests()` in a Feature-scoped `beforeEach` so any unfaked outbound request fails loudly instead of hitting the network. A representative HTTP-touching subset (open banking, exchange-rate/currency, AI categorization + AI/stats reports, analytics, Discord, Stripe, bank logos) was run with the guard active; no test relied on a real request, so no fakes had to be added. ### Review fixes (after the two-reviewer pass) 5. **Purge check uses `name_iv` source of truth, not the stale `encrypted` flag** — the destructive purge decided "no encrypted accounts" from `accounts.encrypted`, a flag the codebase already treats as unreliable (see the `align_accounts_encrypted_flag_with_plaintext_names` migration and `FindsUsersWithLegacyEncryption`, which key off `name_iv`). An account with an encrypted name but a stale `encrypted=false` flag could have its key material destroyed. The job's guard now matches the canonical `*_iv` predicate exactly; the loose flag gate in `share()` is kept only as a cheap dispatch filter, and the job re-verifies with the safe predicate before touching anything. 6. **Deduplicate purge dispatches with `ShouldBeUnique`** — `share()` runs on every web GET, so an affected user re-enqueued the job on each page load until a worker cleared the salt. The job is now `ShouldBeUnique` keyed by user id, collapsing repeat dispatches into one pending job. ## Test plan - New/updated tests, all green: - `InertiaSharedDataTest`: `auth.user` omits all sensitive fields; a web GET no longer mutates the user inline and instead queues the cleanup (and does not queue it when there is no salt). - `PurgeResidualEncryptionArtifactsJobTest`: clears salt + message when no `*_iv` data remains; keeps them when an encrypted transaction, an encrypted account name, or a stale-flag-but-encrypted-name account exists; no-op when salt already null. - `StrayHttpRequestGuardTest`: an unfaked request throws `StrayRequestException`; a matched fake still resolves. - Green locally: `vendor/bin/pint --test`, `bun run lint` (0 errors), `bun run format:check`, `bun run test` (254 frontend tests), targeted backend `--filter=InertiaSharedData|PurgeResidualEncryptionArtifactsJob|StrayHttpRequestGuard|Encryption` (24 tests). Ran ~700 HTTP-touching Feature tests with the stray-request guard active with no guard-induced failures. - `bun run types` still reports the ~157 pre-existing errors (unchanged set; this PR adds none) — that is exactly why the CI step is advisory for now. ## Reviewer findings — addressed vs deferred **Addressed** - 🟠 Destructive purge keyed off the stale `accounts.encrypted` flag instead of the `name_iv` source of truth → fixed in commit 5 (job now mirrors `FindsUsersWithLegacyEncryption`; added a regression test for the stale-flag case). - 🟡 Per-request dispatch amplification with no dedup → fixed in commit 6 via `ShouldBeUnique`. **Deferred (with rationale)** - 🟠 "Three divergent copies of the legacy-encryption query" — substantively resolved: the job now matches `FindsUsersWithLegacyEncryption` exactly. The only remaining `encrypted`-flag use is the `hasEncryptedAccounts` **UI prop** in `share()`, which is a separate, pre-existing frontend concern; changing it would alter which accounts the UI treats as encrypted and is out of scope here. A full extraction into one shared scope would require restructuring the trait (it builds a `User` query, not a per-model boolean) and is not a Wave 1 quick win. - 🟢 Redundant `->fresh()` / null guard in the job — kept deliberately: it is the idempotency guard that makes the re-check read committed state on the sync path (the second reviewer credited it as what makes repeat dispatches safe). - 🟢 `continue-on-error` shows the type-check step green — acknowledged; flipping to blocking (or failing on an increase over a committed baseline) is the follow-up once the ~157-error backlog is cleared. - 🟢 (Product review) Theoretical one-request SSR/client `hasEncryptionSetup` diff from async salt clearing — invisible in practice (the lock button gates on `hasEncryptedAccounts || hasEncryptedTransactions`, false in both SSR and client), and `ssr.tsx` is untouched. No action. Product-bug reviewer verdict: no user-facing regressions. Hiding the 5 fields does not affect Cashier (raw attribute access), `EncryptionController`, notifications, or any API/JSON path; the `currency_code` dedup is runtime-identical; the cleanup timing change is client-absorbed. |
||
|---|---|---|
| .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.