Understand your personal finances. Forget Excels, try Whisper Money.
Go to file
Víctor Falcón 8fef50f829
fix(subscriptions): assign the price arm before registration, not after (#792)
Fixes a design flaw in #700, before the experiment has any exposure.

## The problem

#700 drew the arm from `crc32('price:' . $user->id)`, which can only
happen once the user exists. But the landing quotes a price to anonymous
visitors, and `plansFor(null)` had no arm to apply — so **every visitor
saw €3.99**, and half of them were switched to €8.99 after registering.

That biases the result in both directions at once:

- **Against `high`** — it pays a penalty that isn't price sensitivity
but a price that moved after being advertised. Someone who'd have
happily paid €8.99 quoted upfront leaves because they feel baited.
- **In favour of `high`** — its funnel only contains people who already
decided to sign up under a €3.99 promise. In a world where we actually
charge €8.99, the landing says €8.99 and some of them never register at
all. The experiment is blind to that drop-off.

Neither bias is recoverable from the data, and they don't cancel: a
narrow loss for `high` would be uninterpretable.

## The fix

Draw the arm for the **anonymous visitor** on their first page view,
keep it in a year-long cookie, and freeze it onto `users.price_arm` at
registration. The landing quotes what checkout will charge, and the
whole funnel is measured under one price.

| | before | after |
|---|---|---|
| assigned at | registration | first page view |
| source | `crc32('price:' . id) % 2` | random 50/50 draw |
| stored in | nothing (recomputed) | cookie → `users.price_arm` |
| landing shows | always control | the visitor's arm |

Details worth a look in review:

- **The draw is random, not a hash.** There's no stable identifier
before the user exists. This is what forces the column — the arm has to
outlive the cookie.
- **The middleware writes the arm onto the current request**, not just
the response cookie. The cookie only reaches the browser *after* this
response, and the first view is the landing — the one page that most
needs the right price.
- **Checkout reads `users.price_arm` only, never the cookie.** Editing
your cookie after signing up doesn't get you the cheap price. There's a
test that asserts exactly this, with the cookie set to `control` and the
stored arm `high`.
- **No arm = control.** Users from before the experiment, cookies
blocked, arriving at a deep link. `sanitize()` narrows both the cookie
and the column, since a user controls the former.
- **`force_variant` now also stops the draw** — a winner being rolled
out means the split is over.
- **`price_arm` is in `$hidden`.** The frontend has no use for it, and
it needn't be visible in the Inertia payload.

## Exposure

**None.** The experiment started at 14:00 UTC and had **0 signups past
the cutoff** when this was written, so no arm needs reconciling and no
user changes price. This is the last moment this change is free.

## Reading results

The CRC32 expression from #700 is obsolete:

```sql
SELECT COALESCE(price_arm, 'legacy') AS arm, COUNT(*)
FROM users WHERE created_at >= '<started_at>' GROUP BY arm;
```

## Still open

The landing now shows €8.99 to half of anonymous visitors, which means
the experiment can finally affect signup volume itself. That's the point
— but it also means a drop in registrations is a *result*, not a bug,
and shouldn't be rolled back on reflex.

## Tests

19 tests in `PriceExperimentTest`, rewritten around the new mechanism:
the draw, the gate, the forced winner, the cookie→prop path on the first
visit, freezing at registration, and checkout ignoring the cookie.
`Auth`, `SubscriptionTest`, `InertiaSharedDataTest`, `CashflowPageTest`
and `SyncStripePricesCommandTest` all green locally (122 tests). PHPStan
and `crap` clean.
2026-08-13 08:14:37 +00:00
.agents/skills chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
.claude
…
.cursor
…
.github ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
.opencode/skills
…
.pi
…
app fix(subscriptions): assign the price arm before registration, not after (#792) 2026-08-13 08:14:37 +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
…
lang feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
public
…
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
…
scripts
…
src/lib/crypto
…
storage
…
templates/coolify
…
tests fix(subscriptions): assign the price arm before registration, not after (#792) 2026-08-13 08:14:37 +00:00
.crap-ignore.json ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
.dockerignore
…
.editorconfig
…
.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
…
.gitattributes
…
.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
…
.php-cs-fixer.dist.php
…
.php-version
…
.prettierignore
…
.prettierrc
…
.release-it.json
…
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
…
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
…
LOCALIZATION.md
…
ONBOARDING.md
…
README.md feat(deps): upgrade Inertia.js from v2 to v3 (#769) 2026-08-11 14:33:31 +02:00
artisan
…
autoresearch-dashboard.md
…
autoresearch.jsonl
…
autoresearch.md
…
autoresearch.sh
…
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
…
compose.yaml
…
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
…
eslint.config.js
…
falcode.json
…
opencode.json
…
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
…
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
…
tsconfig.json
…
vite.config.ts
…
vitest.config.ts
…
vitest.setup.ts
…
whispermoney
…
worktree.sh
…

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.