Understand your personal finances. Forget Excels, try Whisper Money.
Go to file
Víctor Falcón f4c21147f1
feat(budgets): count shared accounts at the owner's percentage (#786)
## Why

#750 made a shared account count only your share of every transaction —
on the
dashboard and on the cashflow screen. Budgets were left out and shipped
as a
known gap: `budget_transactions.amount` is a snapshot written when a
transaction
is assigned, so a 50% joint account still spent **100%** of every
expense against
its budget. The same category could read €400 on the dashboard and €800
in
Budgets.

This closes that gap.

## What

A €100 expense on an account you own 50% of now counts €50 towards your
budgets.

Because every budget reader funnels through
`BudgetPeriod::spentAmount()` — a sum
of those snapshots — weighing the snapshot covers the budget cards, the
detail
page, the spending chart, carry-over, the limit alert emails and the MCP
tools in
one move.

- **Assignment writes the owner's share.** Both paths (the
per-transaction
listener and the historical backfill) go through one `recordSnapshot()`.
- **Changing an account's share rewrites its history.** A single SQL
`UPDATE`
  re-weighs every budget row of that account, in every period, past ones
  included.
- **A migration re-weighs the rows written before this**, so existing
shared
  accounts are correct on deploy instead of on the next edit.

## How

The share is computed at the same two choke points #750 established:

- **PHP** — `Transaction::ownerShareOf()` (extracted from
`ConvertsTransactionCurrency`, which was doing the same null dance
inline)
  feeds `BudgetTransactionService::recordSnapshot()`.
- **SQL** — `BudgetTransactionService::reweighAccountSnapshots()` reuses
`Transaction::OWNED_AMOUNT_SQL`, so the rounding matches what PHP would
have
  written. A test pins both to the same answer on an uneven share.

The re-weigh hangs off `Account::booted()` rather than
`AccountController`, so a
seeder, an artisan command or a future MCP write tool cannot silently
skip it.
It also clears the period's `close_to_limit_notified` /
`over_limit_notified`
flags, the same way a refund that drops a budget back under its limit
does —
otherwise a budget that fell out of "over limit" would stay claimed and
never
alert on the next real crossing.

The owning account is eager loaded **withTrashed** everywhere the
snapshot is
written, because `OWNED_AMOUNT_SQL` joins `accounts` without the
soft-delete
scope; without it a transaction whose account was deleted would snapshot
at 100%
in PHP and at the real share in SQL.

## Deliberate boundaries

- **Transaction rows still show the real bank amount** — #750's rule.
The budget
detail page is the one screen where a weighted total sits directly above
its
own itemised list, so it now says so in a line under the chart. The
alert email
  had the same mismatch inside one message and now quotes your share.
- **`carried_over_amount` is not re-derived.** It is a second snapshot
taken when
a period closes. `remainingAmount()` deliberately ignores it and the UI
only
types the field; it surfaces solely through MCP. Left alone rather than
adding
  a second re-derivation path.
- The migration's `down()` restores the full transaction amount, which
is what
  those rows held — it cannot know a share an account no longer has.

## Testing

`tests/Feature/SharedAccountOwnershipTest.php` gains the budget cases:
assignment,
the historical backfill, the re-weigh through the settings screen,
PHP/SQL
rounding parity on 33% of 3333, and the alert flags being cleared.
`tests/Feature/WeighBudgetTransactionsMigrationTest.php` covers the
backfill,
including that it is idempotent and that it keeps refunds negative (the
fix from
`2026_02_24_193117`). Fixing that test needed the `BudgetTransaction`
factory,
which had been pointing at a `BudgetPeriodAllocation` model that no
longer exists.

Manual QA on real local data — budget "Miami Flight", account "Daily"
set to 50%:

| | Before | At 50% | Back at 100% |
|---|---|---|---|
| Miami Flight spent | €3,229.40 | **€1,955.79** | €3,229.40 |
| Yearly Padel spent | €1,514.91 | €785.49 | €1,514.91 |

€1,955.79 is €3,229.40 − €1,273.61, exactly half of the €2,547.24 that
account
had in the budget. The round trip lands back on the original figure to
the cent,
so the re-weigh is idempotent on real data too.

## Demo

<!-- PLACEHOLDER: drag the QA video here -->


https://github.com/user-attachments/assets/1fa5b98a-2a11-4cad-b927-496b434d3295
2026-08-12 12:47:43 +02:00
.agents/skills
.claude
.cursor
.github
.opencode/skills
.pi
app feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
bootstrap
config feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
database feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
docker
docs
experiments
lang feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
public
resources feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +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 feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
.crap-ignore.json
.dockerignore
.editorconfig
.env.example feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
.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 feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
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

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 supportsgemini, 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.