Understand your personal finances. Forget Excels, try Whisper Money.
Go to file
Víctor Falcón 27919027fe
chore: harden Inertia boundary, CI type-check, and test isolation (#640)
## 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.
2026-07-04 18:57:58 +00:00
.agents/skills feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
.claude feat(console): add agent:db command for querying local and prod DB (#522) 2026-06-12 18:35:14 +02:00
.cursor chore: add sentry mcp (#300) 2026-04-17 10:42:34 +02:00
.github chore: harden Inertia boundary, CI type-check, and test isolation (#640) 2026-07-04 18:57:58 +00: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 chore: harden Inertia boundary, CI type-check, and test isolation (#640) 2026-07-04 18:57:58 +00:00
bootstrap fix: address remaining security audit findings (round 2) (#628) 2026-07-03 15:04:03 +00:00
config feat(subscriptions): trial/pricing A/B/C experiment (#600) 2026-06-27 18:00:15 +02:00
database perf(db): index transactions for the daily synced-email slow query (PHP-LARAVEL-3X) (#622) 2026-07-02 15:51:15 +02:00
docker fix(queue): add supervisor worker for the ai queue (#546) 2026-06-17 07:08:25 +00:00
docs feat(banking): add Interactive Brokers sync via Flex Web Service (#581) 2026-06-23 11:39:24 +02:00
experiments feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
lang feat(welcome): add Francisco Montes testimonial (#636) 2026-07-04 14:08:33 +00:00
public fix(banking): keep the native green Wise logo, not the aggregator's (#590) 2026-06-24 09:34:37 +02:00
resources chore: harden Inertia boundary, CI type-check, and test isolation (#640) 2026-07-04 18:57:58 +00:00
routes fix: address remaining security audit findings (round 2) (#628) 2026-07-03 15:04:03 +00: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 chore: harden Inertia boundary, CI type-check, and test isolation (#640) 2026-07-04 18:57:58 +00: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(ai): add weekly AI-suggestions cohort report (#530) 2026-06-13 23:23:34 +02:00
.env.production.example fix: address remaining security audit findings (round 2) (#628) 2026-07-03 15:04:03 +00:00
.gitattributes Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
.gitignore chore: ignore .playwright-mcp directory (#511) 2026-06-09 12:05:21 +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 chore: update Laravel Boost skills and guidelines (#521) 2026-06-12 18:20:30 +02:00
CHANGELOG.md chore: release v0.2.5 (#539) 2026-06-15 16:48:25 +00:00
CLAUDE.md docs(claude): add laravel/ai package and ai-sdk-development skill to Boost guidelines (#638) 2026-07-04 17:34:11 +00:00
Dockerfile chore: Simplify IndexedDB sync by moving to Inertia shared props (#63) 2026-01-19 19:15:26 +01:00
Dockerfile.production ci: cap Docker image build time (#405) 2026-05-20 09:45:12 +01: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 chore: update Discord invite link (#452) 2026-05-29 15:56:48 +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 feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
bun.lock feat(accounts): reorder accounts with drag-and-drop (#575) 2026-06-21 11:17:45 +02: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(ai): manage AI consent outside onboarding with live backfill (#591) 2026-06-25 10:50:35 +02:00
composer.lock feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +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.5 (#539) 2026-06-15 16:48:25 +00:00
package.json feat(accounts): reorder accounts with drag-and-drop (#575) 2026-06-21 11:17:45 +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: selective retry of failed lead email jobs (#286) 2026-04-15 08:00:29 +01: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 fix(worktree): remove double slash in storage/keys copy path (#629) 2026-07-03 13:23:45 +00: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 v2, 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)
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

Star History Chart

License

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