## Summary
- Adds `leads:resend-verification-emails` artisan command that
dispatches `VerifyUserLeadEmailNotification` to all leads where
`email_verified_at IS NULL`
- Supports `--dry-run` flag to preview the count without sending
- Follows the same pattern as `leads:retry-failed-jobs` (progress bar,
summary table)
## Test plan
- [ ] `--dry-run` shows correct count without dispatching
- [ ] Command dispatches exactly one notification per unverified lead
- [ ] Verified leads are skipped
- [ ] Early exit when no unverified leads exist
## Summary
- Adds `leads:retry-failed-jobs` artisan command to selectively retry
failed `emails`-queue jobs after a Resend rate-limit incident
- Retries jobs for verified leads and `VerifyUserLeadEmailNotification`
for unverified leads; forgets jobs for deleted leads (DDoS cleanup) or
unverified leads receiving waitlist emails
- Adds `deleteWhenMissingModels = true` to all lead mail/notification
classes so future jobs for deleted leads are silently discarded instead
of failing
## Usage
```bash
# Preview (no changes)
php artisan leads:retry-failed-jobs --dry-run
# Execute
php artisan leads:retry-failed-jobs
```
## Test plan
- [x] `leads:retry-failed-jobs` forgets jobs for deleted leads
- [x] `leads:retry-failed-jobs` retries jobs for verified leads
- [x] `leads:retry-failed-jobs` forgets waitlist jobs for unverified
leads
- [x] `leads:retry-failed-jobs` retries
`VerifyUserLeadEmailNotification` for unverified leads
- [x] `leads:retry-failed-jobs` handles mixed job types correctly
- [x] `--dry-run` does not modify `failed_jobs`
## Summary
- gate waitlist signup behind email confirmation so only verified leads
receive a queue position and referral code
- add signed lead verification routes, a check-email page, and a
dedicated verification email for the waitlist flow
- update lead syncing and feature tests so only verified leads are
exported to Resend and referral movement happens after verification
## Testing
- php artisan test --compact tests/Feature/UserLeadTest.php
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
- php artisan test --compact tests/Feature/MailSenderTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- add a `resend:sync-leads` command that syncs all `user_leads` into the
Resend leads segment
- make lead sync idempotent by creating contacts with the segment and
falling back to adding existing contacts to the segment
- schedule the command daily at `03:00` UTC and cover the
command/fallback behavior with Pest tests
## Testing
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
## Summary
- add an optional linked property selector when creating loan accounts
in both the main create dialog and onboarding
- validate that only the current user's unlinked real-estate accounts
can be selected and persist the reciprocal link after loan creation
- expose linked property state in shared account props and cover the new
flow with focused loan feature tests
## Testing
- php artisan test --compact tests/Feature/LoanTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- remove the bank search result cap so exact matches are no longer
pushed out of the response
- rank exact name matches first, then prefix matches, then broader
substring matches
- add feature coverage for search ranking, full result sets, and
user-specific visibility
## Summary
- explicitly set the transaction sync mailable sender to the configured
default transactional address
- add a regression test so the transaction sync email cannot fall back
to an unintended sender
## Testing
- php artisan test --compact tests/Feature/MailSenderTest.php
## Summary
- **Store `invested_amount` in user's currency** (e.g. EUR) instead of
the account's currency (e.g. BTC), since it represents "how much of my
money did I put in" — a concept tied to the user's home currency.
- **Flip `display_invested_amount`** in API responses: now converts from
user currency → account currency (previously account → user), for the
chart's account-currency toggle mode.
- **Remove redundant conversions** in `AccountMetricsService` since
invested amounts are already in user currency after sync.
## Changes
### Backend (5 files)
- **BinanceBalanceSyncService** — convert to
`$account->user->currency_code` instead of `$account->currency_code`
- **BitpandaBalanceSyncService** — same currency target change
- **IndexaCapitalBalanceSyncService** — inject
`CurrencyConversionService`, convert invested amount float to user
currency before storing as cents
- **DashboardAnalyticsController** — flip `display_invested_amount`
conversion direction (user→account) in both monthly and daily endpoints
- **AccountMetricsService** — remove 4 now-redundant invested_amount
conversion spots (values already in user currency)
### Frontend (6 files)
- **update-balance-dialog.tsx** — invested amount input uses user
currency
- **balances-modal.tsx** — invested amount display and edit uses user
currency
- **account-balance-chart.tsx** — currency toggle: user-currency mode
keeps invested_amount as-is; account-currency mode swaps to
`display_invested_amount`
- **import-balances-drawer.tsx** — passes `investedAmountCurrencyCode`
(user currency) to sub-components
- **import-balance-step-mapping.tsx** /
**import-balance-step-preview.tsx** — format invested amounts in user
currency
### Tests (2 files)
- **IndexaCapitalBalanceSyncTest** — use `app()` instead of `new`
(constructor now has DI), pin account currency to USD for deterministic
assertions
- **DashboardAnalyticsTest** — add EUR-based exchange rate, flip
assertion from `/ 0.90` to `* 0.90`
## Summary
- route drip mailables through `Álvaro and Víctor <hi@whisper.money>`
and send the rest from `Whisper Money <no-reply@whisper.money>`
- remove legacy per-mailable `Victor` sender overrides so non-drip mail
falls back to the default sender consistently
- add focused sender coverage for drip, non-drip, and verification mail
paths
## Testing
- `php artisan test --compact tests/Feature/MailSenderTest.php
tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php`
## Summary
- Makes `bank_id` nullable for all account types in both
`StoreAccountRequest` and `UpdateAccountRequest` (previously it was only
nullable for real estate)
- Shows the bank combobox field for all account types in the
`AccountForm` component, including real estate
- Removes the `required` attribute from the hidden bank input and the
logic that cleared bank selection when switching to real estate type
## Test changes
- Updated "validates required fields" test to no longer expect `bank_id`
as a required field
- Added "can create a new account without a bank" test in `AccountTest`
- Updated `RealEstateTest` to verify non-real-estate accounts can also
be created without a bank (was previously asserting the opposite)
## Summary
- **Bug**: The `loans:generate-balances` command called
`getBalanceAtDate()` which always recalculated the remaining balance
from the **original loan parameters** (`original_amount`,
`annual_interest_rate`, `loan_term_months`, `start_date`), completely
ignoring existing actual balance entries. When the real balance was
lower than the theoretical amortization schedule (e.g. user made extra
payments), the auto-generated balance would **jump up** instead of
continuing to decrease.
- **Example**: Account `019d0b33-d3c5-7110-9033-51155ac93219` had a real
balance of ~4,489,670 (March 2026), but the command generated 5,700,356
(April 2026) — a jump **up** of ~1.2M cents instead of the expected ~16K
decrease.
- **Fix**: `getBalanceAtDate()` now checks for the latest
`AccountBalance` entry on or before the target date and projects forward
from it, falling back to the theoretical formula only when no entries
exist. This matches the method's own documented behavior and aligns with
how `generateProjection()` already works.
## Testing
Added 4 new tests:
- Projects from existing balance entries instead of original loan params
- Returns existing balance when target date is in the same month
- Generates correct monthly balance when existing entries differ from
theoretical
- All 44 existing loan tests continue to pass
## Summary
- **Bug fix:** Dashboard and accounts index cards displayed the
account's original currency code (e.g. `BTC`) even though balances were
already converted to the user's currency (e.g. `EUR`). Now passes
`displayCurrencyCode` to card components so the label matches the
converted amount.
- **Feature:** Added a currency toggle on the account detail chart to
switch between the account's native currency and the user's main
currency. Extends the balance evolution APIs with `display_*` fields
when conversion applies.
- **First-account restriction:** Restricts first-account creation to
primary (fiat) currencies only, ensuring the user's base currency is
always widely supported.
## Changes
### Bug fix — currency label on cards
- `AccountBalanceCard` / `AccountListCard`: Added `displayCurrencyCode`
prop; all amount renders now use it instead of `account.currency_code`
- `dashboard.tsx`: Passes `netWorthEvolution.currency_code` as
`displayCurrencyCode`
- `Accounts/Index.tsx`: Passes `auth.user.currency_code` as
`displayCurrencyCode`
### Backend — API extension
- `DashboardAnalyticsController`: Both `accountBalanceEvolution()` and
`accountDailyBalanceEvolution()` now return `display_value`,
`display_invested_amount`, `display_mortgage_balance` per data point and
a top-level `display_currency_code` when the account currency differs
from the user's
### Frontend — currency toggle
- New `ChartCurrencyToggle` component with `ToggleGroup` showing
currency code labels (e.g. `BTC` / `EUR`)
- `ChartSettingsPopover`: Extended with optional `currencyToggle` prop
for mobile
- `AccountBalanceChart`: Full integration — all amounts, trends,
tooltips, MoM chart, and equity swap to `display_*` values when toggle
is set to user currency
### First-account currency restriction
- `StoreAccountRequest`: First account limited to primary currency codes
- `AccountForm` / `CreateAccountDialog` / `StepCreateAccount`: Pass
`usePrimaryCurrenciesOnly` when applicable
### Tests
- 5 new Pest tests for `display_*` fields in balance evolution endpoints
- 3 new Vitest tests for `deriveAccountMetrics` (mixed-currency, empty
data, invested amounts)
- 1 new Pest test for first-account BTC rejection
## Summary
- net transfer categories in the cashflow Sankey on their configured
side instead of showing gross signed flows
- keep the existing mixed-sign behavior for non-transfer categories so
regular income and expense categories can still appear on both sides
- add regression coverage for zero-net inflow transfers and partial
inflow/outflow transfer netting
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
--filter=sankey
## Notes
- running the full `CashflowAnalyticsTest` file in this workspace still
hits two unrelated page tests because `public/build/manifest.json` is
missing locally
## Summary
- Removes the `account-mapping` Pennant feature flag entirely, making
the account mapping flow (pending accounts data + map-accounts page) the
default and only code path
- Removes the old direct-creation branches from all banking controllers
(Authorization, Bitpanda, Binance, IndexaCapital)
- Extracts a shared `CreatesAccountsFromPending` trait for the
onboarding auto-create logic
- Updates all controller tests to reflect the always-on mapping behavior
## Changes
### Backend
- **AppServiceProvider** — removed `account-mapping` flag definition
- **AuthorizationController** — removed flag check +
`createAccountsFromSession()` dead code; always stores
`pending_accounts_data` and redirects to mapping
- **BitpandaController / BinanceController / IndexaCapitalController** —
removed flag checks, old inline account creation, and unused imports
- **HandleInertiaRequests / ActivateDevelopmentFeatures /
ResolvesFeatures** — removed `account-mapping` from flag arrays
- **New `CreatesAccountsFromPending` trait** — shared auto-create logic
for onboarding path (to be consumed next)
### Frontend
- Removed `'account-mapping'` from the TypeScript `Features` interface
### Tests
- Merged flag-specific test variants into single always-on tests
- Removed redundant tests that tested the old disabled-flag code path
- Updated assertions to check `pending_accounts_data` instead of direct
account creation
## Summary
- **Fixes the broken retry mechanism** — after the first failed attempt
set status to `Error`, subsequent retry attempts bailed out because
`isActive()` returns `false` for `Error`. Now both `Active` and `Error`
statuses are syncable.
- **Adds auto-retry across scheduled runs** —
`SyncAllBankingConnectionsJob` and `banking:sync` command now include
`Error` connections where `consecutive_sync_failures < 3` (configurable
via `MAX_SCHEDULED_RETRIES`). After 3 full dispatch cycles, manual
intervention is required.
- **Logs every sync attempt to DB** — new `banking_sync_logs` table
records status (Success/Failed/Skipped), attempt number, error details,
duration, and metadata for each sync.
## Changes
### Core logic (`SyncBankingConnectionJob`)
- `isSyncableStatus()` allows both `Active` and `Error` through the gate
- Temporary errors: status only set to `Error` on the final attempt
(attempt 3); earlier attempts re-throw without changing status
- Permanent auth errors (401/403): `$this->fail()` called immediately,
`consecutive_sync_failures` set beyond the cap
- Rate limit (429): handled silently (existing behavior preserved)
- Every attempt is logged to `banking_sync_logs`
### Query updates
- `SyncAllBankingConnectionsJob`: includes `Error` connections under
retry cap
- `SyncBankingConnections` command: same query update for
`--user`/`--connection` filtered runs
### Controller updates
- `ConnectionController::sync()` and `updateCredentials()` reset
`consecutive_sync_failures` to 0
### New files
- `BankingSyncLogStatus` enum (Success, Failed, Skipped)
- `BankingSyncLog` model
- Two migrations: `add_consecutive_sync_failures` column,
`create_banking_sync_logs` table
### Tests
- Updated 3 existing auth error tests in `SyncBankingConnectionJobTest`
(24 pass)
- Added 17 new tests in `SyncRetryAndLoggingTest` covering retry
behavior, sync logging, scheduled retry inclusion/exclusion, and manual
retry reset
- All 10 `SyncBankingConnectionsCommandTest` tests still pass
Extend the dashboard to hide linked loan accounts and display merged
real estate cards showing equity as the primary balance, a dual-line
sparkline (solid market value, dashed mortgage owed), 'Mortgage at'
subtitle with bank logo, and a rich tooltip with Market Value,
Mortgage Owed, and color-coded Equity.
Combine real estate and linked loan accounts into a unified card on the
accounts list, showing equity as the primary balance with a dual-line
sparkline (solid for market value, dashed for mortgage owed). Hide linked
loans from the loan group and display mortgage bank info in the subtitle.
On the detail page, render LoanDetailsCard for real estate accounts with
linked loans and add header actions for editing loan details and updating
owed amounts via dialogs.
## Summary
- Adds loan detail tracking (interest rate, term, start date, original
amount) to loan-type accounts, following the existing
`real_estate_details` pattern
- Implements `LoanAmortizationService` with standard amortization math
to automatically project month-to-month balance evolution
- Shows projected future balances as a dashed line on the account
balance chart, visually distinct from historical data
- Adds a scheduled command (`loans:generate-balances`) to auto-generate
monthly balance entries for loan accounts
- Includes 36 tests (10 unit for pure math, 26 feature for CRUD, API
projections, command, and authorization)
## Changes
### Backend
- **Migration**: `loan_details` table (UUID PK, unique `account_id` FK,
interest rate, term, start date, original amount)
- **LoanDetail model** + factory with `Account::loanDetail()` HasOne
relationship
- **LoanAmortizationService**: `calculateMonthlyPayment`,
`calculateRemainingBalance`, `generateProjection`, `projectFromBalance`,
`calculateRemainingMonths`, `getBalanceAtDate`
- **GenerateMonthlyLoanBalances** command: runs monthly on 1st, skips
existing entries
- **LoanDetailController**: PATCH endpoint for editing loan details from
show page
- **Settings\AccountController**: creates/updates `LoanDetail` on
store/update
- **DashboardAnalyticsController**: appends projected data points with
`projected: true` flag
- **Validation**: loan-specific rules in StoreAccountRequest and
UpdateAccountRequest
### Frontend
- **AccountForm**: loan fields (rate, term, start date, original amount)
shown when `type === 'loan'`
- **Create/Edit dialogs**: pass loan data fields in POST/PATCH payloads
- **Show page**: `LoanDetailsCard` component with edit/read modes,
computed monthly payment and remaining months
- **Balance chart**: new `ComposedChart` branch with dashed `Line`
overlay for projected data points
- **Types**: `LoanDetail` interface in `account.ts`
## Summary
- Allow setting the **current market value** (balance) when creating a
real estate account — the balance field existed for other account types
but was hidden for real estate
- Add an **annual revaluation percentage** field to real estate
accounts, editable from both the creation form and the property details
card on the account show page
- Create a scheduled command (`real-estate:apply-revaluation`) that runs
monthly on the 1st to automatically adjust property values by
`revaluation_percentage / 12 / 100`
## Details
### Backend
- Migration adds `decimal('revaluation_percentage', 5, 2)` nullable
column to `real_estate_details`
- Validation: nullable numeric, range -100 to +100 (negative for
depreciation)
- `ApplyRealEstateRevaluationCommand` finds all accounts with a
non-null/non-zero percentage, gets the latest balance, and upserts the
new balance for today
- Scheduled `->monthlyOn(1, '00:00')` in `routes/console.php`
### Frontend
- Added `real_estate` to `BALANCE_ACCOUNT_TYPES` so the Market Value
field appears during creation
- Added Annual Revaluation (%) input to both the creation form and the
property details card
- Read-mode displays formatted percentage with sign (e.g.,
"+3.50%/year")
### Tests
- 6 new tests in `RealEstateTest.php` covering creation with balance,
revaluation %, negative %, validation, PATCH update, and clearing to
null
- 8 tests in `ApplyRealEstateRevaluationTest.php` covering
positive/negative revaluation, skip conditions, latest balance usage,
multiple accounts, and upsert behavior
## Summary
- Add mortgage balance as a dashed line overlay on the property value
chart when a real estate account has a linked loan
- Display equity (market value - mortgage owed) in the chart header and
as a 3-column summary strip (Market Value | Mortgage Owed | Equity) in
the property details card
- Extend balance evolution API endpoints to include `mortgage_balance`
data points for linked loan accounts
- Add 6 new tests covering mortgage/equity display in account show page
and balance evolution endpoints
## Summary
- Batch-load all Pennant feature flags via `Feature::load()` in
`HandleInertiaRequests`, reducing 3 individual SELECT+INSERT pairs (6
queries) to 1 batched SELECT + 1 bulk INSERT (2 queries)
- Batch `activate()` calls in `ActivateDevelopmentFeatures` middleware
using array syntax for a single UPSERT instead of 3
## Context
PR #241 (real estate feature) added a new Pennant feature flag check to
the shared Inertia data, pushing the top categories API endpoint from 12
to 13 queries and breaking the performance test threshold.
## Query impact
| Scenario | Before | After |
|---|---|---|
| Feature flag queries per request | 6 (3 SELECT + 3 INSERT) | 2 (1
SELECT + 1 INSERT) |
| Top categories API total | 13 | 9 |
| Future-proof | +2 queries per new flag | Constant regardless of flag
count |
## Summary
- Adds **real estate** as a new account type (`real_estate`) for
tracking property assets within the existing Accounts page
- Properties store metadata (type, address, purchase price/date, area,
notes) in a dedicated `real_estate_details` table with a one-to-one
relationship to accounts
- Properties can be **linked to a loan account** (mortgage) to
implicitly calculate equity — property value counts as asset (+), linked
loan counts as liability (-)
- Market value is tracked as the account balance and uses "market value"
terminology throughout the UI
## What's included
### Backend
- `PropertyType` enum (Residential, Commercial, Land, Vacation, Other)
- `RealEstateDetail` model, factory, migration, policy
- `StoreRealEstateDetailRequest` and `UpdateRealEstateDetailRequest`
form requests
- `RealEstateDetailController` with `update()` for editing property
details
- Extended `Settings\AccountController@store` to create
`RealEstateDetail` when type is `real_estate`
- Extended `AccountController@show` to load real estate detail, linked
loan with bank info, and available loan accounts
- Extended `AccountController@index` SQL ordering to include
`real_estate`
- Conditional validation rules in `StoreAccountRequest` for real estate
fields
### Frontend
- New `real_estate` type in `account.ts` with `PropertyType`,
`AreaUnit`, `RealEstateDetail` interface, and helper functions
- Conditional real estate fields in `account-form.tsx` (property type,
address, purchase price, purchase date, area, linked loan, notes)
- `PropertyDetailsCard` component in `Accounts/Show.tsx` with view and
inline edit modes
- "Update market value" terminology in balance update buttons for real
estate accounts
- `real_estate` added to account type ordering and groups on the Index
page
- Wayfinder routes regenerated
### Tests
- 22 feature tests covering creation, validation, show page, index
ordering, updating details, IDOR protection, model relationships, and
soft delete behavior
- Unit test updates for `AccountType` enum (`reducesNetWorth`,
`isNonTransactional`)
### Translations
- 32 new Spanish translation strings for all real estate UI
## Design decisions
- Real estate accounts are **non-transactional** (like
investment/retirement) — balance-only tracking for now. Future iteration
will link transactions directly for rental income/expense tracking
- **Single loan per property** via direct FK from
`real_estate_details.linked_loan_account_id`
- No encryption on address/notes fields (opted out per discussion)
- No separate sidebar page — real estate is a grouped section within the
existing Accounts page
## Summary
- Adds an optional balance input field to the account creation modal for
**investment**, **loan**, **retirement**, and **savings** account types.
- When a balance is provided, an `AccountBalance` record is created for
today's date upon account creation.
- The balance label adapts to the account type (e.g., "Owed Amount" for
loans, "Balance" for others).
## Changes
- **`account-form.tsx`** — Shows `AmountInput` when a balance-tracking
type is selected and currency is set.
- **`create-account-dialog.tsx`** — Passes `balance` in the POST payload
when present.
- **`StoreAccountRequest.php`** — Adds optional `balance` (nullable
integer) validation.
- **`AccountController::store`** — Creates an `AccountBalance` record
when balance is provided.
- **`AccountTest.php`** — 3 new tests covering creation with balance,
without balance, and invalid balance validation.
## Screenshots
<img width="1017" height="649" alt="image"
src="https://github.com/user-attachments/assets/4bf1530e-0faf-45a4-a3d1-d0ec49d5b412"
/>
## Summary
Transfer categories with `cashflow_direction` set to `outflow` or
`inflow` now appear in the **Sankey diagram** instead of the 12-month
trend chart. This gives a clearer picture of money flow (e.g.
investments, savings transfers) without inflating the income/expense
trend lines. Hidden transfers remain excluded from everything.
## Changes
- **Sankey endpoint**: Updated join to include tracked transfer
categories on the matching flow side (outflow → expense side, inflow →
income side)
- **Trend endpoint**: Removed `transfer_inflow` and `transfer_outflow`
columns from the SQL query and API response
- **Trend chart UI**: Removed tracked inflow/outflow bars, tooltip rows,
legend items, and related config
- **TypeScript types**: Removed `transfer_inflow`/`transfer_outflow`
from `TrendDataPoint`
- **Category form**: Updated direction field description to reference
"Sankey chart" instead of "monthly trend"
- **Tests**: Updated 4 existing tests and added 2 new tests covering
tracked transfers in Sankey breakdown
## Design decision
Tracked transfers appear **only in the Sankey diagram** — they do not
affect summary totals, savings rate, or breakdown cards. This keeps the
high-level numbers accurate while still visualizing where money flows
internally.
## Summary
- exclude transfer-type categories from the cashflow Sankey category
breakdown
- add a regression test to ensure transfer transactions never appear on
either side of the chart
- keep cashflow totals limited to real income and expense categories for
the Sankey response
## Problem
When an income category contained both incoming and outgoing
transactions (e.g. an \"Income from Rents\" category that also holds
property-related expense payments), the Sankey endpoint was summing
**all** amounts together and applying `abs()` to the net result.
Real example reported:
| Date | Description | Amount |
|------|-------------|--------|
| Mar 13 | BIZUM sent — Lavadora | -€124.03 |
| Mar 10 | BIZUM received — luz febrero | +€38.04 |
| Mar 9 | Endesa energy payment | -€38.04 |
| Mar 4 | BIZUM received — agua Febrero | +€41.34 |
| Mar 2 | Comunidad de Propietarios | -€105.92 |
- Net: **-€188.61** → `abs()` → **€188.61 shown as income** ❌
- Correct: sum of positive flows only → **€79.38** ✅
## Fix
Added a sign filter to `getCategoryBreakdown()` before the `GROUP BY`
aggregation:
- Income categories: `WHERE transactions.amount > 0`
- Expense categories: `WHERE transactions.amount < 0`
This ensures the Sankey shows the **actual gross flow** on each side
rather than a misleading abs-of-net.
## Test
Added a regression test in `CashflowAnalyticsTest` that reproduces the
exact five transactions from the reported scenario and asserts the
Sankey returns `7938` cents (€79.38) instead of the buggy `18861`
(€188.61).
## Summary
- A `429 ASPSP_RATE_LIMIT_EXCEEDED` response from the bank's API was
incorrectly marking connections as `status=error`, blocking all future
syncs.
- Rate limit errors are transient — the connection is still valid and
should be retried on the next scheduled sync.
- Added `isRateLimitError()` check in the `catch` block of
`SyncBankingConnectionJob`: on 429, the job returns early without
updating the connection status or error message.
## Problem
`banking:backfill-ibans` was calling `GET /accounts/{uid}` but the
correct Enable Banking endpoint is `GET /accounts/{uid}/details`. This
caused every account to return 404, and all 404s were counted as
failures making the command exit with failure status.
Additionally, accounts whose sessions have expired or been revoked will
always return 404 — this is expected and should not be treated as an
error.
## Changes
- **`EnableBankingProvider`** — fix endpoint from `/accounts/{uid}` to
`/accounts/{uid}/details`
- **`BackfillAccountIbans`** — catch `RequestException` 404s separately
and count them as `expired/revoked session` skips rather than failures;
command exits successfully when the only issues are expired sessions
- **Tests** — add test for the 404 expired-session path; update output
string assertions
## Expected output after fix
```
Found 5 account(s) with missing IBAN.
IBAN updated for 2 account(s). Skipped (no IBAN in API response): 0. Skipped (expired/revoked session): 3. Failed: 0.
```
The 2 active-session accounts will be backfilled; the 3 from the
expired/revoked connection will be skipped cleanly.
## Summary
- Adds `banking:backfill-ibans` artisan command to backfill null `iban`
values on Enable Banking accounts by calling `GET /accounts/{uid}` for
each affected account.
- Adds `getAccount()` to `BankingProviderInterface` and
`EnableBankingProvider`.
- 9 feature tests covering all paths: happy path, no-IBAN skip, dry-run,
user/connection filters, API failure handling.
## Why
Accounts connected before
[#220](https://github.com/whisper-money/whisper-money/pull/220) was
deployed have `null` IBANs. Without IBANs, reconnects fall back to
positional matching which is less reliable. Running this command once in
production populates the IBAN for all existing accounts so future
reconnects use the safer IBAN-based strategy.
## Usage
```bash
# Dry run first
php artisan banking:backfill-ibans --dry-run
# Run for all accounts
php artisan banking:backfill-ibans
# Scope to a specific user or connection
php artisan banking:backfill-ibans --user=user@example.com
php artisan banking:backfill-ibans --connection=<connection-id>
```
## Problem
Enable Banking issues **new account UIDs** (`external_account_id`) with
every new session. On reconnect, `AuthorizationController::callback()`
was correctly updating `session_id` on the `banking_connections` table
but **never refreshing `external_account_id` on child accounts**.
When the background `SyncBankingConnectionJob` ran after a reconnect, it
called `GET /accounts/{old-uid}/transactions` using stale UIDs from the
expired session, causing Enable Banking to return `401 EXPIRED_SESSION`
— surfaced to users as _"Authentication failed. Your credentials may
have expired or been revoked."_
## Changes
- **`AuthorizationController`** — added `refreshAccountIds()` private
method, called in the reconnect branch after `session_id` is updated and
before the sync job is dispatched. Matches existing accounts by **IBAN
first**, falls back to **positional order** (`created_at ASC`) for
legacy accounts without a stored IBAN.
- **`AuthorizationController`** — `createAccountsFromSession()` and
`createAccountsFromPending()` now persist the `iban` field on account
creation so future reconnects can use IBAN matching.
- **`Account` model** — `iban` added to `$fillable`.
- **Migration** — adds nullable `iban` column to `accounts` table.
## Tests
4 new feature tests in `AuthorizationControllerTest`:
- `reconnect callback updates external_account_id when enable banking
issues new account uids`
- `reconnect callback matches accounts by iban before falling back to
position`
- `reconnect callback uses positional fallback for accounts without
stored iban`
- `callback stores iban when creating accounts for the first time`
## Deploy notes
Run the migration after deploying:
```
php artisan migrate --force
```
## Summary
- Add `reauthorize` endpoint and reconnect detection in OAuth
`callback()` so users can re-authorize a revoked EnableBanking session
without losing their accounts or transaction history
- Replace "Retry" with "Reconnect" button (dropdown + error panel) for
EnableBanking authentication errors; catch-up sync of missed
transactions is handled automatically by the existing
`SyncBankingConnectionJob` via `last_synced_at`
- Add 5 missing Spanish translations (`Reconectar`, `Autenticación
fallida...`, `Error al iniciar la reautorización.`, `Error al
reconectar...`, `Cuenta bancaria reconectada exitosamente.`) and wrap
the reconnect flash message in `__()`
- 7 new Pest tests covering all reauthorize scenarios and the reconnect
callback path (15 total passing)
## Summary
- Fixes `banks:set-logo` for JPEG images by adding a `makeJpeg()` test
helper and a full JPEG test case — the pipeline was working but
completely untested, meaning any regression would go undetected
- Implements `PromptsForMissingInput` so the command interactively
prompts for `bank` and `url` when they are not passed as arguments
## Summary
- Adds `banks:set-logo {bank} {url}` artisan command to download,
process, and assign a logo to a bank by UUID
- Installs `intervention/image:^3.0` (GD driver) for image processing
- Image is validated to be square (returns an error if not), resized
down to max 250×250px if needed, stored as PNG on the `public` disk at
`banks/logos/{uuid}.png`, and the bank's `logo` field is updated with
the public URL
## Usage
```
php artisan banks:set-logo <bank-uuid> <image-url>
```
## Tests
7 feature tests covering success, no-resize, bank not found, HTTP
failure, non-image content type, non-square image, and invalid image
content.
## Summary
- add a new `banks:check-logos` console command that validates all
non-null bank logo URLs weekly
- set broken/invalid bank logos to `null` and send an admin report email
to `ADMIN_EMAIL` when updates occur
- add weekly scheduling, admin mail config wiring, and feature tests for
valid/broken/head-fallback flows
## Testing
- vendor/bin/pint --dirty
- php artisan test tests/Feature/Console/CheckBankLogosCommandTest.php
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
- **Gate bank connection flow** — free-tier users see an upgrade dialog
instead of the bank connection flow (connections page, account creation
dialog). Backend 402 guard added as safety net in
`AuthorizationController`.
- **Billing page upgrade UI** — non-subscribed users see a plan selector
with monthly/yearly options and an upgrade CTA; subscribed users see the
existing Stripe portal button. Connected Bank Accounts added as the
headline benefit.
- **Upgrade dialog** now redirects to `/settings/billing` (plan
selector) instead of the checkout page directly.
- **Spanish translations** added for all new UI strings.
## Screenshots
<img width="804" height="509" alt="image"
src="https://github.com/user-attachments/assets/6238f72a-a5e2-45f8-974a-7a664f976b83"
/>
<img width="1122" height="892" alt="image"
src="https://github.com/user-attachments/assets/8438bbe2-d3ec-48fb-b3c0-33a940dd94a7"
/>
## Summary
- Users without a Stripe customer ID (`stripe_id = null`) would hit an
`InvalidCustomer` exception when visiting the billing portal
- Added a `hasStripeId()` check before calling
`redirectToBillingPortal()`, creating the Stripe customer on-the-fly if
needed
- Added two tests covering both branches (with and without an existing
Stripe customer ID)
## Summary
- **Dynamic Stripe price resolution**: Replaces hardcoded
`stripe_price_id` env vars with lookup-key-based resolution
(`stripe_lookup_key`). A new `php artisan stripe:sync-prices` command
creates/updates Stripe prices from `config/subscriptions.php`
automatically.
- **Locale-aware currency formatting**: Replaces all
`getCurrencySymbol() + toFixed(2)` patterns with `formatCurrency()`
(backed by `Intl.NumberFormat`) across `welcome.tsx`, `paywall.tsx`,
`billing.tsx`, and `step-create-account.tsx`, so symbol position and
separators are correct for the user's locale (e.g. `3,90 €` in Spanish).
- **EUR defaults and updated plan prices**: Cashier currency defaulted
to EUR, plan prices updated to €7.80/month and €46.80/year, and
`pricing.currency` is now shared as an Inertia prop.
- **Promo/discount cleanup**: Removed all FOUNDER discount mentions and
Discord community links from the paywall, landing pricing section, and
invitation email.
## Summary
- Adds an intro screen to the categorize transactions onboarding step
that shows before the categorizer UI, explaining that at least 5
transactions must be categorized to continue
- Displays four benefit cards (see where you spend, build better
budgets, spot savings, automate over time) to motivate the user
- Adds missing Spanish translation for \"Back to accounts\" and all new
strings introduced by the intro screen
## Summary
- Adds an inline email capture form on the landing page when
`HIDE_AUTH_BUTTONS=true`, replacing the previous CTA buttons
- Each signup gets a queue position (starting at #500), a unique
referral link, and a welcome email from `victor@whisper.money`
- Referring 10 people via the personal link moves the referrer 10
positions forward in the queue (floor: 1), triggering a notification
email
- Full Spanish localisation for all UI strings and email copy; `locale`
is stored on each lead so emails are sent in the lead's own language
- 14 feature tests covering all waitlist behaviour (positions,
referrals, emails, thank-you page)
## How to activate
Set `HIDE_AUTH_BUTTONS=true` in `.env`. The waitlist form and thank-you
page are completely dormant otherwise.
## Summary
- Open Banking users who complete onboarding **without** connecting a
bank are no longer blocked at `/subscribe` — they can continue for free
via a new \"Continue for free\" button on the paywall
- Users who **did** connect a bank during onboarding see the standard
paywall with no free option (bank sync is a paid/Standard feature)
- Renamed the paid plan badge from **Pro** → **Standard** in the
onboarding UI
## Changes
### Backend
- `EnsureUserIsSubscribed` middleware: grants free access when
`open-banking` feature is active and the user has no
`banking_connections`
- `SubscriptionController::index()`: passes a `canUseFreePlan` boolean
prop to the paywall page
### Frontend
- `paywall.tsx`: accepts `canUseFreePlan` prop and renders a "Continue
for free" button that navigates to the dashboard
- `step-create-account.tsx`: badge and info text updated from "Pro" →
"Standard"
### Tests
- Two new browser tests in `OnboardingFlowTest.php`:
- Manual account flow → `/subscribe` shows "Continue for free"
- BBVA connected bank flow (EnableBanking sandbox) → `/subscribe` does
NOT show "Continue for free"
## 🚪 Why?
### Problem
The onboarding flow had no integration with connected (bank-linked)
accounts. Users who connected a bank via OAuth were redirected to a
separate account-mapping page, breaking the onboarding flow. After
returning, there was no way to resume at the correct step. Additionally,
connected accounts were incorrectly shown in the manual import steps
(import-transactions / import-balances), which don't apply to them.
## 🔑 What?
### Changes
- **Inline bank connect wizard**: Added `ConnectAccountInline` component
that embeds the full 3-step bank OAuth flow within the onboarding
`create-account` step, replacing the redirect to a separate page.
- **Manual / Connected selection UI**: `StepCreateAccount` now presents
a choice between manual and connected account setup before proceeding.
- **Auto-account creation on OAuth callback**:
`AccountMappingController` and `AuthorizationController` auto-create
accounts for non-onboarded users and redirect back to
`/onboarding?step=create-account` instead of showing the mapping UI.
- **`?step=` deep-linking**: The onboarding page reads a `?step=` query
param on mount and starts at that step, so returning from bank OAuth
lands at the right place.
- **Skip import steps for connected accounts**: `handleAccountCreated`
detects `connected: true` and skips `import-transactions` /
`import-balances`, going directly to `category-types` (first account) or
`more-accounts` (subsequent accounts).
- **Fix phantom account in more-accounts**: Connected accounts are no
longer added to `createdAccounts` state (they already appear via
`existingAccounts`), preventing a duplicate nameless entry in the final
account list.
- **Feature flags enabled in non-production**: `open-banking` and
`account-mapping` Pennant flags now resolve to `! app()->isProduction()`
instead of always `false`.
- **Open-banking routes accessible during onboarding**: Removed
`onboarded`/`subscribed` middleware from open-banking routes;
`EnsureOnboardingComplete` middleware explicitly allows `open-banking.*`
routes through.
## ✅ Verification
### Tests
- Updated `tests/Browser/OnboardingFlowTest.php` to reflect new UI
(manual/connected selection).
- Existing feature tests for open-banking controllers pass (pre-existing
failures in Binance/Bitpanda/IndexaCapital/AuthorizationController tests
are from a prior commit unrelated to this work).
### Manual Verification
- Connected account flow tested end-to-end: bank OAuth → auto-account
creation → redirect to `create-account` step → Continue → skip import
steps → `category-types`.
- 7-account BBVA user correctly shows all accounts in `more-accounts`
via `filteredExistingAccounts` with no phantom entry.
- Feature flags confirmed active in local environment via tinker.
## 🚪 Why?
### Problem
PHPStan was running with a baseline of 56 suppressed errors, meaning
static analysis was not enforcing type safety across a significant
portion of the codebase. These errors were real type mismatches,
redundant null-safety operators, and incorrect PHPDoc annotations that
could mask bugs and make the code harder to reason about.
## 🔑 What?
### Changes
- Add `@property` PHPDoc annotations to `Account`, `BankingConnection`,
`ExchangeRate`, and `Transaction` models so Enum casts and typed columns
are visible to PHPStan
- Add `instanceof User` guards in `ScheduleDripEmailsListener`,
`SyncUserToResendListener`, and `FortifyServiceProvider` to properly
narrow `Authenticatable` to `App\Models\User`
- Remove redundant `?? false` and unnecessary nullsafe `?->value` in
`HandleInertiaRequests`
- Fix `SyncBankingConnectionJob`: use `->name` instead of `?->name` on
an always-loaded `bank` relation
- Remove `is_countable()` guard in `BalanceLookup` (parameter is always
`Collection|array`, both countable)
- Remove `?? []` / `?? default` fallbacks on fully-typed array keys
across `BalanceSyncService`, `BinanceBalanceSyncService`,
`BinanceClient`, `BitpandaBalanceSyncService`, `BitpandaClient`,
`IndexaCapitalClient`, `IndexaCapitalBalanceSyncService`, and
`AuthorizationController`
- Fix `BinanceClient::publicClient()` `retry()` call: use `when:` named
argument and `\Throwable` type hint to match `PendingRequest::retry()`
signature
- Update `IndexaCapitalClient::getPerformance()` `@return` to include
`portfolios` and `net_amounts` keys; simplify sync service to remove
dead null checks
- Replace nullsafe chain with ternary in `BudgetPeriodService`
- Replace `match` statement in `SetupMainUser` with `if/else` to
eliminate always-true comparison
- Clear `phpstan-baseline.neon` entirely (was 56 suppressed errors, now
0)
## ✅ Verification
### Tests
- Existing tests pass: PHPStan level 5 reports 0 errors with empty
baseline
## Why
### Problem
Users viewing a budget had no way to look back at historical periods —
the show page always displayed the current period with no navigation.
The cashflow page's period selector also used a different visual style
(loose buttons with gaps) compared to the new grouped button pattern
established for budgets.
## What
### Changes
- **Budget period navigation**: new `BudgetPeriodNavigation` component
renders a `ButtonGroup` with `[<] [date range] [>]` buttons. Clicking
the label returns to the current period; arrows navigate between
existing periods.
- **Backend**: `BudgetController::show` accepts an optional
`?period=<uuid>` query param to serve a specific period. Future periods
are blocked at the query level on both direct access and `nextPeriod`
resolution.
- **Dropdown**: removed the split `ButtonGroup` from the budget header;
"Edit budget" and "Delete budget" now live together in a single `˅`
dropdown.
- **Cashflow period selector**: updated `PeriodNavigation` to use
`ButtonGroup` + `size="icon"` buttons to match the same visual style.
- **Tests**: 4 new feature tests covering period navigation,
future-period blocking, cross-budget access (404), and `nextPeriod`
boundary behaviour.
## Verification
<img width="921" height="683" alt="image"
src="https://github.com/user-attachments/assets/ceb5f70b-a15a-4a36-ae49-5d84054a62f9"
/>
## 🚪 Why?
### Problem
The bulk actions bar lost the \"Select all matching filters\"
capability, limiting users to acting only on the currently
visible/selected page of transactions. Users had no way to apply bulk
operations (category, labels, re-evaluate rules) to all transactions
matching their active filters.
## 🔑 What?
### Changes
- Re-adds a \"Select all matching filters\" icon button (with tooltip)
to the bulk actions bar — clicking it switches into a \"selecting all\"
mode
- When selecting all: the count label is replaced by a `CheckCheck` icon
with tooltip \"All matching filters\"; the Delete action is hidden
(backend-only safety)
- Bulk category and label updates in \"select all\" mode send `filters`
to `PATCH /transactions/bulk` and show a loading → success toast with
the updated count
- Re-evaluate rules in \"select all\" mode sends `filters` to the
re-evaluate bulk endpoint instead of `transaction_ids`
- Extends `BulkReEvaluateRulesRequest` and
`ReEvaluateTransactionRulesJob` to accept and apply a `filters` param
- Adds Spanish translations for new strings
- Wraps transaction count text in `whitespace-nowrap` to prevent it
splitting across two lines
## ✅ Verification
### Tests
- 2 new tests added in
`tests/Feature/ReEvaluateTransactionRulesTest.php`: verifies controller
passes filters to job, and job correctly scopes transactions by filters
- All 16 tests in `ReEvaluateTransactionRulesTest` pass
## 🚪 Why?
### Problem
Automation rule evaluation was happening entirely on the frontend,
relying on the client to fetch rules, loop over transactions, and apply
category/label/note changes. This meant evaluation was tied to
browser-side decryption logic, couldn't be triggered server-side, and
had no reliable progress reporting for bulk operations.
## 🔑 What?
### Changes
- **Single re-evaluation**: `POST
/transactions/{transaction}/re-evaluate-rules` applies all matching
rules immediately and returns the updated transaction; the frontend
updates local state from the response.
- **Bulk re-evaluation**: `POST /transactions/re-evaluate-rules`
dispatches a queued job and returns a `job_id`; the frontend polls `GET
/transactions/re-evaluate-rules/status/{jobId}` ~every second, updates a
progress toast, and refreshes the list when done.
- **Note support**: `AutomationRuleService` now applies plain
(unencrypted) `action_note` actions; encrypted notes are skipped since
the backend cannot decrypt them.
- **Dirty-only saves**: `AutomationRuleService::applyActions()` now
tracks whether any field changed and only calls `saveQuietly()` when
needed, avoiding unnecessary writes.
- **Encrypted transactions skipped**: Transactions with `description_iv`
are excluded from bulk queries and silently skipped on single
re-evaluation (existing behavior preserved).
- **Frontend cleanup**: Removed `evaluateRules`,
`appendNoteIfNotPresent`, and related crypto imports from
`transaction-list.tsx`, `index.tsx`,
`use-re-evaluate-all-transactions.tsx`, and
`transaction-actions-menu.tsx`.
## ✅ Verification
### Tests
- New: `tests/Feature/ReEvaluateTransactionRulesTest.php` — 14 passing
tests covering:
- Single re-evaluation applies category, label, and note
- Single re-evaluation skips encrypted transactions
- Bulk job dispatched and returns 202 with job_id
- Bulk status polling returns `processing` and `done`
- Partial bulk (specific transaction_ids) only processes selected
transactions
- Encrypted transactions excluded from bulk processing
- Unauthenticated requests are rejected (401)
### Manual Verification
- Row dropdown "Re-evaluate rules" calls backend and updates the row in
place
- Bulk "Re-evaluate All Expenses" dispatches job, shows progress toast,
refreshes list on completion
- Encrypted transactions are silently skipped in both flows
## Why
### Problem
The default income categories lacked coverage for self-employed
individuals (freelancers, contractors, sole traders). Users in this
segment had no accurate category to classify their primary income
source.
## What
### Changes
- Added `Self-Employment Income` income category (icon: `Briefcase`,
color: `green`) to `CreateDefaultCategories::getBaseCategories()`
- Added Spanish translation `Ingresos por trabajo autónomo` to
`getSpanishTranslations()`
- Updated category count assertions in `CategoryTest` from 63 → 64
## Verification
### Tests
- `php artisan test --compact --filter="default categories"` — 2 tests,
7 assertions, all passing
## Problem
The categorizer was reading transactions exclusively from the browser's
IndexedDB (Dexie), which is only populated via an on-demand sync
triggered from the import drawer. When an account was deleted
(soft-deleting all its transactions), those transactions remained in the
local IndexedDB and kept appearing in the categorizer on subsequent
visits.
## Solution
- **`TransactionController::categorize()`** now queries uncategorized
transactions directly from the database (`WHERE category_id IS NULL`)
with their account and bank eager-loaded, and passes them as an Inertia
prop alongside the existing `categories`, `accounts`, `banks`, and
`labels`.
- **`categorize.tsx`** drops all Dexie/`useLiveQuery` dependencies. The
transaction list now comes from the Inertia prop; client-side decryption
runs once on mount (the encryption key remains client-side only — the
server never sees it). Category assignment
(`transactionSyncService.update()`) is unchanged.
## Tests
Added 5 new feature tests covering:
- Categorize page is accessible to authenticated users and includes the
`transactions` prop
- Only uncategorized transactions are returned
- Transactions from deleted accounts are excluded
- Transactions from other users are not visible
- Guests are redirected to login
## Summary
- Add a new **Chart color scheme** dropdown in Settings > Appearance
allowing users to choose between 4 color palettes: **Colorful** (new
default), **Neutral** (previous zinc grayscale), **Blue**, and **Pink**
- Persist the setting in a new `user_settings` table with instant
client-side feedback via localStorage + cookie
- All charts across dashboard, categories, budgets, and cashflow update
instantly when switching schemes
- Reduced colorful palette intensity (shifted from 500/600 to 300/400
range) for lower contrast
## Why
### Problem
Three UX issues were identified:
1. **Connection status badge looked like a button** — the `<Badge>`
component has interactive-looking styles that made it visually ambiguous
as a status indicator rather than a purely informational element.
2. **"Update balance" was shown for bank-connected accounts** — accounts
synced via Open Banking have their balances managed automatically;
showing a manual update button was misleading and could create
confusion.
3. **Delete confirmation word was not localized** — the dialog required
typing `DELETE` even when the UI was in Spanish, where the placeholder
correctly said `ELIMINAR`. The check was hardcoded to `'DELETE'`.
## What
### Changes
<img width="756" height="407" alt="image"
src="https://github.com/user-attachments/assets/4866edf7-5329-486a-9f72-fcd69429f784"
/>
<img width="757" height="424" alt="image"
src="https://github.com/user-attachments/assets/bc9bda54-e0a4-402b-be32-a9fe1897cfd5"
/>
## Why
### Problem
The Indexa Capital dashboard shows +€11,339.01 profit for the Index
funds account, but our app shows +€9,680.43 — a €1,658.58 discrepancy.
The invested amount (and therefore the profit) is wrong.
### Root Cause
`calculateInvestedAmount()` used `instruments_cost + cash_amount` to
determine how much was invested. However, `instruments_cost` is the
**cost basis of currently held fund shares**, not the actual money
deposited. When Indexa rebalances portfolios, they sell shares
(realizing gains) and buy new ones at a higher cost basis. This inflates
`instruments_cost` over time without any new deposits, making the
"invested" figure too high and profit too low.
## What
### Changes
- Use `net_amounts` from the Indexa Capital API response instead of
`instruments_cost + cash_amount`
- `net_amounts` is an object keyed by date (`YYYYMMDD` format), where
each value is the cumulative net investment (inflows - outflows -
tax_outflows), matching Indexa Capital's own "investment" figure
- Keep `total_amount - return` as a fallback when `net_amounts` is
unavailable
- Return `null` when neither data source is available
### Concrete numbers (Index funds account, 2026-02-24)
| | Old (wrong) | New (correct) |
|---|---|---|
| Invested | €48,081.67 | €46,423.09 |
| Profit | €9,680.43 | €11,339.01 |
## Verification
### Tests
- Updated `stores invested_amount from net_amounts data` — verifies
invested_amount is sourced from `net_amounts`, not `instruments_cost +
cash_amount`
- Updated `stores null invested_amount when net_amounts is missing` —
verifies graceful null when API lacks `net_amounts`
- Updated `falls back to total_amount minus return when net_amounts is
missing` — verifies fallback path
- All 11 balance sync tests pass, all 23 SyncBankingConnectionJob tests
pass
## Why
### Problem
When API tokens for Indexa Capital, Binance, or Bitpanda expire or are
revoked, syncs fail silently with 401/403 errors. Users have no way to
replace their credentials without disconnecting and recreating the
entire connection (losing account mappings and history).
## What
### Changes
- **Auth failure email notification**: on the final retry attempt (3rd
of 3), if a sync job fails with 401/403 for an API-key provider, an
email is sent to the user with a link to the connections settings page
- **Update credentials endpoint**: `PATCH
/settings/connections/{connection}/credentials` validates new
credentials against the provider API before saving, then triggers a sync
- **Update credentials dialog**: provider-specific form (API token for
Indexa/Bitpanda, API key + secret for Binance) shown via an "Update
Credentials" button in the error banner and dropdown menu
- **Mailable**: `BankingConnectionAuthFailedEmail` follows existing
patterns (queued, rate-limited, Markdown template)
- **Form request**: `UpdateConnectionCredentialsRequest` with dynamic
validation rules per provider and authorization check
### Files changed
| File | Change |
|------|--------|
| `app/Jobs/SyncBankingConnectionJob.php` | Send auth failed email on
final attempt for auth errors on API-key providers |
| `app/Mail/BankingConnectionAuthFailedEmail.php` | New queued mailable
|
| `resources/views/mail/banking-connection-auth-failed.blade.php` |
Email template |
| `app/Http/Controllers/OpenBanking/ConnectionController.php` |
`updateCredentials()` action with provider credential validation |
| `app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php`
| Dynamic validation per provider |
| `routes/settings.php` | PATCH route for credential updates |
| `resources/js/components/open-banking/update-credentials-dialog.tsx` |
Dialog with provider-specific fields |
| `resources/js/pages/settings/connections.tsx` | Update Credentials
button in error state and dropdown |
## Verification
### Tests
- **12 new tests** across 2 test files, all passing:
- `SyncBankingConnectionJobTest`: 5 tests covering email sent on final
retry (Indexa 401, Binance 403), not sent before final retry, not sent
for non-auth errors, not sent for EnableBanking
- `ConnectionControllerTest`: 7 tests covering valid credential update
for each provider, invalid credentials, EnableBanking rejection,
authorization, feature flag, required field validation
- Full OpenBanking test suite: **145 tests, 473 assertions** passing
## Why
### Problem
When a refund (positive transaction amount) is assigned to a budget, it
incorrectly **increases** the cumulative spending instead of
**reducing** it. This causes:
- The spending chart line to go **up** on refunds instead of down
- The "Spent" amount to be inflated
- The "Remaining" amount to be understated
- Period rollover calculations to carry over incorrect amounts
### Root Cause
`BudgetTransactionService` uses `abs($transaction->amount)` when
creating budget transactions, which forces all amounts to be positive —
including refunds. Since expenses are stored as negative in the
`transactions` table and refunds as positive, `abs()` treats both as
spending.
## What
### Changes
- Replace `abs($transaction->amount)` with `-$transaction->amount` in
both `assignTransaction()` and `assignHistoricalTransactionsToPeriod()`
— expenses (`-5000`) become positive spending (`5000`), refunds
(`+1000`) become negative spending (`-1000`)
- Remove redundant `abs()` in `BudgetPeriodService::closePeriod()`
rollover calculation
- Add data migration to fix existing `budget_transactions` rows where
the original transaction was a refund
- No frontend changes needed — the chart and budget card already sum
`t.amount` directly
## Verification
### Tests
- Updated `assignHistoricalTransactionsToPeriod stores negated
transaction amount for expenses` — verifies expense sign is preserved
- Added `assignHistoricalTransactionsToPeriod stores refund as negative
amount` — verifies refunds reduce spending
- Added `budget spending correctly reflects mix of expenses and refunds`
— verifies net spending (expense - refund)
- Added `assignTransaction stores refund as negative budget transaction
amount` — verifies real-time assignment handles refunds
## Why
### Problem
Account balance metrics computation (12-month sparklines, net worth
evolution, currency conversion) was duplicated across three controllers:
- `AccountController` — account index page metrics
- `DashboardController` — dashboard net worth evolution
- `DashboardAnalyticsController` — analytics API net worth evolution
(monthly + daily)
Each had its own `convertBalance()` method, its own BalanceLookup
iteration loop, and its own invested amount handling. Additionally,
`AccountController::show()` re-queried `categories`, `accounts`, and
`banks` — all already available as shared Inertia props from
`HandleInertiaRequests` middleware.
## What
### Changes
- **Extract `AccountMetricsService`** with three public methods:
- `getAccountMetrics()` — per-account balance metrics with sparkline
history (used by accounts index)
- `getNetWorthEvolution()` — monthly net worth evolution with
per-account balances (used by dashboard + analytics API)
- `getNetWorthDailyEvolution()` — daily net worth evolution (used by
analytics API)
- **Refactor `AccountController`** — delegate to
`AccountMetricsService`, remove 3 private methods (`getAccountMetrics`,
`formatMonth`, `convertBalance`)
- **Refactor `DashboardController`** — delegate to
`AccountMetricsService`, remove `getNetWorthEvolution` loop and
`convertBalance`
- **Refactor `DashboardAnalyticsController`** — delegate to
`AccountMetricsService` for `netWorthEvolution` and
`netWorthDailyEvolution`, remove `convertBalance`, `getBalanceAt`,
`getInvestedAmountAt` private methods
- **Remove duplicate queries from `AccountController::show()`** —
`categories`, `accounts`, `banks` are already shared by middleware
- **Add missing `encrypted` column** to the middleware's shared
`accounts` query so components that need it (EditAccountDialog, etc.)
receive it
### Impact
- **Net -87 lines** across 5 files (285 added in new service, 372
removed from controllers)
- Single source of truth for balance metrics computation
- Eliminated 3 copies of `convertBalance()` and 2 copies of the net
worth evolution loop
- Removed 3 redundant database queries from account show page
## Verification
### Tests
- All 84 related tests pass (AccountController, AccountBalance,
Dashboard, DashboardAnalytics, BalanceLookup, CashflowAnalytics) with
529 assertions
- No test changes needed — the refactoring preserves exact output shapes
## Why
### Problem
The dashboard page takes ~4 seconds to load for users with many balance
records. Profiling revealed 29 queries across the initial page load + 3
separate API calls, a 576 KB wasted payload from unused bank records,
and a critical 3.7s bottleneck in `BalanceLookup` correlated subqueries.
## What
### Changes
**Query optimizations:**
- Replace correlated `MAX()` subqueries in `BalanceLookup` with
derived-table `joinSub` pattern — **48x faster** (3,693ms → 77ms) on
accounts with thousands of balance records
- Replace `whereHas` (EXISTS subqueries) with JOINs in
`DashboardAnalyticsController` and `CashflowAnalyticsController` — ~3x
faster per query
- Cache encryption check results in `HandleInertiaRequests` middleware
to avoid 2 duplicate queries per request
**Payload & request reduction:**
- Remove dead `banks` query from `DashboardController` (2,365 records,
576 KB never used by frontend)
- Remove duplicate `categories` and `accounts` queries already provided
by middleware shared props
- Consolidate 3 separate `fetch()` API calls into Inertia v2
`Inertia::defer()` props grouped under `'dashboard'` (single follow-up
request with skeleton fallbacks)
**Frontend:**
- Replace `useDashboardData` fetch hook with `usePage()` props +
`<Deferred>` components
- Convert `CashflowSummaryCard` from internal fetch to prop-based
- Use `router.reload({ only: [...] })` for balance update refetch
### Performance summary
| Metric | Before | After |
|--------|--------|-------|
| Initial page queries | 16 | 12 |
| Follow-up HTTP requests | 3 separate API calls | 1 Inertia deferred |
| BalanceLookup time | 3,693ms | 77ms |
| Wasted payload | 576 KB | 0 |
## Verification
### Tests
All 633 feature tests pass, including 40 dashboard/cashflow-specific
tests.
## Why
The `/accounts` page was making two extra client-side API calls via the
`useDashboardData()` hook:
- `/api/dashboard/net-worth-evolution` (~14.5s in dev)
- `/api/dashboard/top-categories` (~119ms, completely unused on this
page)
The backend logic itself is fast (~34ms), but the full middleware stack
cost of extra HTTP roundtrips in the dev environment was adding
significant overhead.
## What
### Backend (`AccountController.php`)
- Added `ExchangeRateService` constructor injection
- Added `Inertia::defer()` prop `accountMetrics` to the `index()` action
- Added `getAccountMetrics()`, `formatMonth()`, `convertBalance()`
private methods
- Uses `BalanceLookup::forAccounts()` for efficient batch loading (2 SQL
queries regardless of account count)
### Frontend (`Accounts/Index.tsx`)
- Removed `useDashboardData()` hook import and usage
- Added `accountMetrics` as an optional deferred prop (undefined until
resolved)
- Loading state derived from `!accountMetrics`, which naturally drives
the existing `loading` prop on `AccountListCard` (skeleton UI already
existed)
- Added `handleBalanceUpdated` callback using `router.reload({ only:
['accountMetrics'] })` for targeted refresh
## Verification
### Tests
- 3 new Pest tests covering deferred prop behavior:
- `accounts index defers account metrics` — verifies metrics are missing
from initial response, present after `loadDeferredProps()`, with correct
balance values
- `accounts index deferred metrics includes invested amount for
investment accounts` — verifies invested amount for investment account
types
- `accounts index deferred metrics returns null invested amount for
non-investment accounts` — verifies non-investment accounts get null
invested amount
- All 15 tests in `AccountControllerTest.php` pass
## Summary
- Adds `Savings` to the list of account types that support invested
amount tracking, alongside `Investment` and `Retirement`
- Decouples `supportsInvestedAmount()` from
`NON_TRANSACTIONAL_ACCOUNT_TYPES` in TypeScript, since savings accounts
are transactional but now also support invested amount tracking
- Updates unit tests to reflect savings accounts supporting invested
amounts
This enables remunerated savings accounts to track invested money vs.
current balance to visualize gains/losses — the same way investment and
pension accounts already do.
## Summary
- Subsequent syncs (every 6h) now only process recent data instead of
re-syncing full history, reducing unnecessary API calls and database
writes
- Full sync still runs automatically on first connection and can be
forced anytime with `banking:sync --full`
- Centralizes `isFirstSync` logic in `SyncBankingConnectionJob` and
propagates the `fullSync` flag through the entire chain: Command →
`SyncAllBankingConnectionsJob` → `SyncBankingConnectionJob` → provider
services
## Changes by provider
- **Indexa Capital**: Skips portfolio entries older than the last
recorded balance date on incremental syncs (the API doesn't support date
filtering, so filtering is done client-side)
- **Binance**: Reuses stored `invested_amount` from the database on
subsequent syncs instead of fetching up to 2 years of deposit/withdrawal
history in 90-day windows
- **EnableBanking / Bitpanda**: Already minimal — no changes needed
## Testing
- Fixed 6 existing Binance tests to pass `isFirstSync: true` for
invested amount calculation
- Added 7 new tests covering incremental sync behavior, full sync
override, and `--full` flag propagation
## Why
Investment and retirement accounts show balance over time, but there's
no way to see how much money was actually put in versus how much is
current value. Users can't tell at a glance whether their investments
are up or down.
## What
Adds an "invested amount" tracking system across the full stack:
**Backend**
- New `invested_amount` column on `account_balances` (nullable
bigInteger, cents, per-date)
- Auto-sync from providers: Indexa Capital (instruments_cost +
cash_amount), Bitpanda (fiat deposit/withdrawal history), Binance
(90-day windowed deposit/withdrawal with crypto→fiat conversion)
- Manual input support via Update Balance dialog
- Historical invested amount data in all balance evolution APIs (net
worth, account detail)
**Frontend**
- Dashed line on sparkline charts (dashboard + accounts page) showing
per-point historical invested amount alongside balance
- Dashed line on account detail charts (daily AreaChart + monthly
ComposedChart)
- Tooltips with labeled rows: Balance, Invested, Gain/loss (color-coded)
- Invested amount column in balances history modal
- Invested amount field in balance import wizard (CSV mapping)
- Demo account seeder updated with invested amount data
## Screenshots
<img width="1301" height="750" alt="image"
src="https://github.com/user-attachments/assets/0f05ecd0-8b98-47b4-9fa4-027f0311e3bb"
/>
<img width="744" height="374" alt="image"
src="https://github.com/user-attachments/assets/c4daa816-dee0-4f94-957f-317a13bc80d5"
/>
<img width="1267" height="738" alt="image"
src="https://github.com/user-attachments/assets/21df350c-6954-4ff5-8b3c-b858df3a8b3a"
/>
<img width="1301" height="828" alt="image"
src="https://github.com/user-attachments/assets/16f5f021-a926-4e8e-a999-c4ca32d1ea3d"
/>
<img width="1274" height="845" alt="image"
src="https://github.com/user-attachments/assets/62f2dfc0-04f0-4bdb-b072-cf7cd1be77d3"
/>
## Why
### Problem
Users with accounts in multiple currencies see raw balances on the net
worth chart without any conversion. A user with a USD-denominated
profile who also holds EUR and GBP accounts sees misleading totals — the
chart simply sums cents across currencies as if they were the same unit.
## What
### Changes
- **`ExchangeRateService`** — New service that wraps
`CurrencyConversionService` with DB-level caching. Checks the
`exchange_rates` table first, falls back to the external CDN API on
cache miss, and stores the full rate blob for future lookups.
- **`exchange_rates` migration + model** — New table with
`(base_currency, date)` unique constraint storing JSON rate blobs.
Includes factory.
- **`CurrencyConversionService`** — Made `getRatesForCurrency()` public
so `ExchangeRateService` can delegate to it.
- **`DashboardAnalyticsController`** — `netWorthEvolution` and
`netWorthDailyEvolution` endpoints now convert every account balance
from `account.currency_code` to `user.currency_code`. Response includes
`currency_code` field and `_original` data for foreign-currency
accounts.
- **Future date capping** — `ExchangeRateService::getRates()` caps any
future date to today, since the currency API only serves historical
rates. Prevents 500 errors when the chart requests end-of-month rates
for the current (incomplete) month.
- **Frontend charts** — Simplified to single-currency display. Removed
multi-currency `TotalDisplay` component; the chart header and tooltip
now show amounts in the user's currency. Foreign-currency accounts show
their original amount inline in the tooltip (e.g., "€2,000.00 →
$2,222.22").
- **`valueFormatter` type fix** — Changed return type from `string` to
`React.ReactNode` in chart component interfaces to match actual usage
with `AmountDisplay`.
## Screenshot
<img width="1266" height="649" alt="image"
src="https://github.com/user-attachments/assets/6f661840-3f65-49fe-9948-d4cfd6c6ef0e"
/>
## Summary
Adds daily granularity support to the dashboard Net Worth Evolution
chart, mirroring the daily balance feature added for individual account
pages in #135.
## Why
The net worth chart only supported monthly granularity, making it
impossible to see short-term net worth fluctuations. Users who track
daily balances need a way to visualize day-over-day net worth changes
across all accounts.
## Screenshots
<img width="1263" height="714" alt="image"
src="https://github.com/user-attachments/assets/646e7e21-5c1b-42dc-9a20-5e9b6d5d72b4"
/>
<img width="1261" height="715" alt="image"
src="https://github.com/user-attachments/assets/b9fe5080-55f3-485a-a566-1d25d83518c7"
/>
## Summary
- Adds a **Monthly / Daily granularity toggle** to the account balance
evolution chart on account pages
- Daily view renders as an **area chart** (line with gradient fill)
showing the last 30 days, while monthly keeps the existing bar chart
- All 3 chart view modes (stacked, period-over-period,
period-over-period %) work in both granularities
- Chart view toggle **tooltips update dynamically** based on granularity
(e.g. "Month over month change" → "Day over day change")
- Backend fills gaps in sparse `account_balances` data by carrying
forward the last known balance day-by-day
- Includes 3 new Pest tests for the daily endpoint (data points,
gap-filling, forbidden access)
## Screenshot
<img width="1271" height="743" alt="image"
src="https://github.com/user-attachments/assets/cca7a7ad-bdcc-45d2-a5e1-47b1cb855d49"
/>
<img width="1274" height="755" alt="image"
src="https://github.com/user-attachments/assets/14e1f2d8-41cd-49cc-b0f4-307edb847af8"
/>
## Summary
- Add Bitpanda as a new exchange provider using a single API key
(`X-Api-Key` header) to sync crypto and fiat wallet balances
- Follow the same architecture as Binance: provider string on
`banking_connections`, dedicated API client, balance sync service,
controller, form request, job wiring, factory state, route, and frontend
dialog
- Convert crypto wallet balances to the user's target currency via
`CurrencyConversionService`; fiat wallets are added directly or
converted if in a different currency
- Bitpanda appears in all countries in the connect dialog (same as
Binance)
- 18 new tests covering controller validation, balance sync scenarios,
sync job delegation, expiry handling, and email suppression
## Summary
- Adds Binance as a third banking provider alongside EnableBanking and
Indexa Capital
- Binance appears in the institution list for **all countries**, with
the full list sorted alphabetically
- Creates a single "Crypto Portfolio" investment account with total
portfolio value converted to the user's preferred currency
- Supports direct fiat pairs (e.g. BTCEUR), USD stablecoin 1:1 mapping,
and USDT fallback conversion
## Changes
- **Migration**: adds encrypted `api_secret` column to
`banking_connections`
- **BinanceClient**: HMAC-SHA256 authenticated API client for account
data and ticker prices
- **BinanceBalanceSyncService**: converts all non-zero balances to fiat
via direct pairs or USDT fallback
- **BinanceController + ConnectBinanceRequest**: validates credentials,
creates connection and single account
- **SyncBankingConnectionJob**: new `syncBinance()` branch
- **AccountMappingController**: Binance uses Investment account type
- **Frontend**: Binance institution for all countries, API Key + Secret
form fields, alphabetically sorted list
- **Factory**: `binance()` state on `BankingConnectionFactory`
## Test plan
- [x] `BinanceControllerTest` — 6 tests (valid connection, invalid
credentials, account-mapping flag, feature flag, validation, user
currency)
- [x] `BinanceBalanceSyncTest` — 7 tests (direct EUR pair, USDT
fallback, USD stablecoins, locked balances, same-date update, empty
balances, missing external ID)
- [x] Full test suite passes (545 tests)
- [x] Manual: open connection dialog → select any country → Binance
appears alphabetically → select Binance → API Key + Secret form →
connect
## Summary
- Allow users to connect their Indexa Capital (Spanish robo-advisor)
account via API token
- Adds "Indexa Capital" option when selecting Spain in the bank
connection dialog
- Token-based auth flow: user enters API token (instead of OAuth
redirect), validated against Indexa API, stored encrypted
- Syncs portfolio balance only (no transactions) from the
`/accounts/{id}/performance` endpoint
- Accounts created as `Investment` type
- Reuses existing account mapping flow when the `account-mapping`
feature flag is active
- Disconnect flow skips session revocation for Indexa (no OAuth session
to revoke)
## New files
- `IndexaCapitalClient` — HTTP client for Indexa Capital API
(`X-AUTH-TOKEN`)
- `IndexaCapitalBalanceSyncService` — Syncs portfolio value into
`account_balances`
- `IndexaCapitalController` — Token validation + connection creation
- `ConnectIndexaCapitalRequest` — Form request validation
- Migration adding encrypted `api_token` column to `banking_connections`
## Test plan
- [x] 6 controller tests (connect, invalid token, mapping, feature flag,
validation, multi-account)
- [x] 4 balance sync tests (sync, update, skip, missing field)
- [x] 3 sync job tests (balance-only, no expire, no email for Indexa)
- [x] 1 disconnect test (no revokeSession for Indexa)
- [x] All 530 existing tests still pass
## Summary
- Removed all `setupEncryptionKey()` / `visitWithEncryptionKey()` calls
from browser tests — encryption key setup in localStorage is no longer
needed since new users don't have encryption
- Removed `encryption_salt` from `UserFactory::onboarded()` state and
`OnboardingFlowTest` user creation
- Removed `name_iv` from `Account::factory()` calls in
`BankAccountsTest`
- Deleted `DemoEncryptionService` and its unit test — demo command now
stores plaintext account names and transaction descriptions
- Removed `demoEncryptionKey` Inertia shared prop and `encryption_key`
from demo config
- Removed encryption helper methods from `TestCase.php` and global
`setupEncryptionKey()` from `Pest.php`
## Test plan
- [x] Run `php artisan test --exclude-testsuite=Browser` — all
non-browser tests pass
- [x] Run `php artisan test --testsuite=Browser` — browser tests pass
without encryption key setup
- [x] Run `php artisan demo:reset` — demo account created with plaintext
data
- [x] Verify existing encryption migration tests still pass
(`EncryptionTest`, `DecryptTransactionsTest`,
`PlaintextTransactionsTest`)
## Summary
- **Server-side filtering/sorting/search**: With encryption removed, all
transaction filtering, sorting, and full-text search (LIKE on
description + notes) now happens via SQL queries instead of client-side
IndexedDB with decryption
- **Cursor pagination**: Transactions are returned with cursor-based
pagination (default 50 per page, "Load More" pattern) instead of loading
all into IndexedDB
- **Removed IndexedDB dependency**: The main transactions page no longer
depends on Dexie/IndexedDB for data or sync — other pages (categorize,
accounts/show, budgets/show) are unaffected and will be migrated
separately
## Changes
| File | Change |
|------|--------|
| `app/Models/Transaction.php` | Added `scopeApplyFilters` query scope |
| `app/Http/Requests/IndexTransactionRequest.php` | New form request for
filter validation |
| `app/Http/Controllers/TransactionController.php` | Server-filtered
cursor-paginated index, refactored bulkUpdate to reuse scope |
| `resources/js/pages/transactions/index.tsx` | Major rewrite —
server-driven filtering, sorting, pagination via `router.visit()` |
| `resources/js/components/transactions/transaction-filters.tsx` |
Search always enabled (removed encryption key dependency) |
| `resources/js/types/transaction.ts` | Added `ServerTransaction` type |
| `resources/js/contexts/sync-context.tsx` | Simplified — removed
transaction sync service |
| `tests/Feature/TransactionFilterTest.php` | 20 new Pest tests for all
filter scenarios |
## Test plan
- [x] Run `php artisan test` — all 523+ tests pass
- [x] Navigate to `/transactions` — paginated results load from server
- [x] Apply each filter type (date, amount, category, account, label,
search) and verify server round-trip
- [x] Test "uncategorized" category filter
- [x] Test combined filters
- [x] Test sorting by date, amount, description (ascending/descending)
- [x] Test "Load More" button for cursor pagination
- [x] Test bulk operations (select some → update, select all filtered →
update)
- [x] Verify URL persistence (refresh page with filters in URL)
- [x] Test search on both description and notes fields
## Summary
- Add `GET /api/transactions?encrypted=true` endpoint for paginated
listing of encrypted transactions
- Add `PATCH /api/transactions/bulk` endpoint for batch-updating
decrypted transaction data (max 50 per request, bypasses model events)
- Add `useDecryptTransactions` hook that mirrors the existing account
name decryption flow: fetches encrypted transactions page-by-page,
decrypts `description`/`notes` client-side via Web Crypto API, sends
plaintext back and clears IVs
- Wire the hook into `AppSidebarLayout` so decryption runs automatically
when the user unlocks their encryption key
- Once all transactions (and accounts) are decrypted, the encryption key
button in the header disappears automatically
## Test plan
- [x] 11 Pest tests covering encrypted/plaintext filtering, pagination,
user scoping, bulk update, authorization, validation, nullable notes,
guest access, and no-model-events behavior
- [x] Manual: create encrypted transactions, unlock encryption key,
verify transactions get decrypted and the key button disappears
## Summary
- Added `--user={email}` option to filter sync by a specific user's
email address
- Added `--connection={id}` option to filter sync by a specific banking
connection ID
- Both filters can be combined; when neither is provided, the command
dispatches the bulk sync job as before
- Only active, non-expired connections are synced when filters are used
## Test plan
- [x] 7 new Pest tests covering all filter scenarios (all passing)
- [x] Pint formatting passes
## Summary
- Removes the `plaintext-transactions` Pennant feature flag — plaintext
is now the default for all users
- Removes encryption guards and `isPlaintext` conditionals from
transaction create/edit/import flows, keeping only the plaintext code
paths
- Makes `EncryptionKeyButton` conditional — only shown when user has
legacy encrypted accounts or transactions
- Removes encryption onboarding steps (`step-encryption-explained`,
`step-encryption-setup`) and related state/props
- Updates landing page to replace E2E encryption marketing with
privacy-first messaging ("Your Data, Your Rules" section)
- Updates privacy policy to replace E2E encryption claims with accurate
security language (encryption at rest, TLS in transit, no third-party
sharing)
- Cleans up tests to remove feature flag assertions and
`Feature::activate()` calls
**22 files changed, 145 insertions, 730 deletions**
## Test plan
- [x] Create a transaction without encryption key unlocked — should
succeed
- [x] EncryptionKeyButton should not appear in header for users with no
encrypted data
- [x] EncryptionKeyButton should appear for users with legacy encrypted
transactions/accounts
- [x] Landing page has no E2E encryption references, shows new privacy
section
- [x] Onboarding flow has no encryption setup steps
- [x] Privacy policy reflects accurate security language
- [x] Frontend builds successfully (`bun run build`)
- [x] All linting passes (`bun run lint`, `vendor/bin/pint --dirty`)
## Summary
- Adds a `BankFormatter` interface with a `BbvaFormatter` implementation
that transforms ALL CAPS `//`-separated BBVA descriptions into readable
Title Case (preserving acronyms like SEPA, S.A., BIZUM and Spanish
stopwords)
- Stores the raw description in a new `original_description` column when
formatting changes it, keeping sync matching intact
- Existing non-BBVA banks and null-bank transactions pass through
unchanged
## Test plan
- [x] Unit tests for `BbvaFormatter` (11 tests: title case, acronyms,
stopwords, reference numbers, mixed-case passthrough, whitespace)
- [x] Unit tests for `TransactionDescriptionFormatter` dispatcher (4
tests: BBVA match, no-match, null bank)
- [x] Feature tests for BBVA sync integration (formatted description +
original stored)
- [x] Feature tests for non-BBVA sync (description unchanged, original
null)
- [x] All 25 existing + new tests pass
- [x] Pint formatting clean
## Summary
- Ensure `email_verified_at` is set when the demo account is created or
already exists but is unverified
- `email_verified_at` is not in the User model's `$fillable` array, so
set it directly on the model instead of via `create()`/`update()`
## Test plan
- [x] Added test that an existing unverified demo user gets verified on
`demo:reset`
- [x] Added assertion that newly created demo user has
`email_verified_at` set
- [x] All 3 demo reset tests pass
## Summary
- Added `withTrashed()` to the duplicate check in
`TransactionSyncService::importTransaction()` so soft-deleted
transactions are not re-created on subsequent bank syncs.
- Added test covering the soft-deleted transaction deduplication
scenario.
## Test plan
- [x] `php artisan test --filter=TransactionSyncServiceTest` — all 8
tests pass
## Summary
- Send a queued email notification to users after subsequent bank syncs
when new transactions are found
- Email includes a per-bank breakdown of imported transaction counts
with a link to the transactions page
- Skips notification on first sync and when zero new transactions are
created
## Test plan
- [x] Sends email when new transactions are synced on subsequent sync
- [x] Does not send email on first sync
- [x] Does not send email when zero new transactions
- [x] Aggregates multiple accounts under same bank
- [x] Lists different banks separately in email
- [x] Existing sync job tests still pass
## Summary
- When a user cancels the bank authorization flow (e.g. clicks cancel on
the bank's page), the pending `BankingConnection` is now soft-deleted so
it doesn't remain stuck in `pending` status
- Flash messages (`success`/`error`) are now shared with the frontend
via Inertia shared data
- The connections settings page shows a toast notification with the
error message on redirect
## Test plan
- [ ] Start a bank connection flow, cancel on the bank's authorization
page, and verify:
- A toast error appears on the connections page
- The pending connection is removed from the list
- [x] Complete a successful bank connection and verify the success toast
appears
- [x] Run `php artisan test
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
## Summary
- Remove the `budgets` Pennant feature flag — budgets is now enabled for
all users
- Delete `EnsureBudgetsFeature` middleware and its route guard
- Remove `budgets` from shared Inertia features and the `Features`
TypeScript interface
- Remove all `Feature::for($user)->activate('budgets')` calls from tests
- Delete `BudgetFeatureFlagTest` and feature-disabled test cases from
`BudgetsFeatureNavigationTest`
## Summary
- Adds **EnableBanking** as the first open banking provider, allowing
users to connect real bank accounts and automatically sync transactions
and balances
- Uses a **`BankingProviderInterface`** contract so future providers
(Plaid, GoCardless, etc.) can be added by implementing the same
interface
- Feature-flagged behind the **`open-banking`** Pennant flag (default:
off)
- Connected accounts are **unencrypted** and transactions have `source =
'enablebanking'`
### What's included
**Backend:**
- `BankingProviderInterface` contract + `EnableBankingProvider`
implementation (JWT RS256 auth)
- `BankingConnection` model with full lifecycle (pending →
awaiting_mapping → active → expired/revoked/error)
- `TransactionSyncService` — pagination, deduplication by
`external_transaction_id`, amount/date mapping
- `BalanceSyncService` — preferred balance type selection (CLBD → ITAV
fallback)
- Authorization flow: start auth → bank redirect → callback → session
creation → account mapping → sync
- `SyncBankingConnectionJob` (unique per connection, 3 retries) +
scheduled every 6 hours
- `banking:sync` artisan command
- 5 migrations: `banking_connections` table, account fields, transaction
`external_transaction_id`, `pending_accounts_data`, `linked_at`
**Frontend:**
- Manual vs Connected account choice in the create account dialog
- Multi-step bank connection dialog (country → bank selection →
confirmation → redirect)
- Account mapping page — map discovered bank accounts to existing
accounts, create new ones, or skip
- Settings/Connections page with status badges, sync/disconnect actions
- "Connected" badge on linked accounts in settings
**Tests:**
- 49 tests covering feature flags, controllers, account mapping,
transaction sync, balance sync, deduplication, and pagination
### Feature Flags
This PR introduces **two Pennant feature flags**:
1. **`open-banking`** — Gates the entire open banking feature
(institutions endpoint, authorization flow, connections page). When
disabled, all open banking routes return 404.
2. **`account-mapping`** — Controls whether users see an intermediate
account mapping step after connecting a bank. When **enabled**, users
are redirected to a mapping page where they can choose to create new
accounts, link to existing ones, or skip each discovered bank account.
When **disabled**, all discovered accounts are automatically created
(original behavior). Linked accounts only sync transactions from their
last transaction date and only update the current balance from the
provider (no historical balance calculation or daily balance tracking).
Enable per-user:
```bash
php artisan feature:enable open-banking user@example.com
php artisan feature:enable account-mapping user@example.com
```
Enable for all users:
```bash
php artisan feature:enable open-banking all
php artisan feature:enable account-mapping all
```
## Test plan
- [x] Enable feature flag: `php artisan feature:enable open-banking`
- [x] Verify "Connected" option appears in create account dialog
- [x] Start authorization flow and verify redirect to bank
- [x] With `account-mapping` **disabled**: verify callback creates
accounts directly and dispatches sync
- [x] With `account-mapping` **enabled**: verify callback redirects to
mapping page
- [x] Test mapping page: create new, link to existing, and skip actions
- [x] Verify linked accounts sync only from last transaction date and
only update current balance
- [x] Verify connections page shows "Setup Required" badge for
awaiting_mapping status
- [x] Run `php artisan banking:sync` and verify transactions sync
- [x] Verify connections page shows status, sync, and disconnect actions
- [x] Run full test suite: `php artisan test --compact`
## Summary
- Adds `plaintext-transactions` Pennant feature flag (defaults to
`false` / encryption ON)
- When active, new transactions are stored as plaintext (no client-side
encryption)
- Existing encrypted transactions continue to work — detection is based
on `description_iv` being NULL (plaintext) vs present (encrypted)
- Migration makes `description_iv` nullable on the transactions table
## Changes
**Backend:**
- Feature flag definition in `AppServiceProvider`, shared via
`HandleInertiaRequests`
- `StoreTransactionRequest` / `UpdateTransactionRequest` conditionally
require `description_iv`
- `TransactionFactory` gains a `plaintext()` state
**Frontend:**
- Edit dialog and import drawer skip encryption when flag is active
- `EncryptedTransactionDescription` renders plaintext directly when IV
is null
- All decryption loops handle both encrypted and plaintext transactions
- Rule re-evaluation stores notes as plaintext when flag is active
## Test plan
- [x] Run `php artisan migrate` to apply the migration
- [x] Activate flag: `php artisan feature:enable plaintext-transactions
all`
- [ ] Create a transaction — verify description is stored as plaintext
in DB (`description_iv` is NULL)
- [ ] Deactivate flag: `php artisan feature:disable
plaintext-transactions all`
- [ ] Create a transaction — verify encryption is still required (422
without IV)
- [x] Verify old encrypted transactions still display correctly
- [ ] Run `php artisan test --compact
tests/Feature/PlaintextTransactionsTest.php`
## Summary
- Store account names in plaintext instead of encrypting them
client-side. Encryption remains only for transaction descriptions and
notes.
- Existing encrypted account names are silently migrated when the user
unlocks their encryption key, via a new `useDecryptAccountNames` hook
that calls API endpoints to update each account.
- New `AccountName` component handles rendering both encrypted (legacy)
and plaintext accounts across the app.
## Test plan
- [x] Create a new account — name should be stored in plaintext
(`encrypted = false`, `name_iv = null`)
- [x] Unlock encryption key with existing encrypted accounts — they
should auto-migrate silently
- [x] After migration, verify accounts show `encrypted = false` in DB
and names are readable plaintext
- [x] Edit a migrated account — should work without encryption
- [x] Verify all account name displays work across dashboard, account
details, transactions, settings
## Summary
- Changed `SyncUserToResendListener` to listen to the `Verified` event
instead of `Registered`, so contacts are only created in Resend after
the user verifies their email address
- Updated corresponding tests to use the `Verified` event
## Test plan
- [x] `SyncUserToResendListenerTest` passes with `Verified` event
- [x] All auth and listener tests pass
## Summary
- Enables `MustVerifyEmail` on the `User` model so new users must verify
their email before accessing protected routes
- Unverified users are redirected to `/email/verify` when attempting to
access routes behind the `verified` middleware (subscribe, onboarding,
dashboard)
- A verification email is automatically sent on registration via Fortify
## Screenshot

## Video
https://github.com/whisper-money/whisper-money/raw/mail-verification/storage/videos/ac0945c527f014b9cd657f18c911f496.webm
## Test plan
- [x] New test: registration sends a `VerifyEmail` notification
- [x] New test: newly registered users have `email_verified_at` as null
- [x] New test: unverified users are redirected to `verification.notice`
from protected routes (subscribe, onboarding, dashboard)
- [x] New test: verified users are not redirected to verification notice
- [x] Existing email verification and notification tests still pass (31
auth tests, 52 settings tests)
- [x] Browser walkthrough: registration → redirected to `/email/verify`
page with "Resend verification email" button
## Summary
- Load the previous budget period (with transactions) in
`BudgetController::show()` and pass it to the frontend
- Overlay previous period's cumulative spending as a dashed line on the
budget spending chart
- Use day-of-period alignment (Day 1, Day 2...) when comparing so
periods of different lengths align
- Tooltip shows both "Spent" (current) and "Last period" values when
hovering
- Chart now shows the full period timeline (not just up to today)
- When no previous period exists, chart behavior is unchanged
## Screenshots
### Light mode

### Dark mode

## Test plan
- [x] Tests pass: `php artisan test --compact
tests/Feature/BudgetTest.php` (9 tests)
- [x] Pint formatting passes
- [x] ESLint + Prettier pass
- [ ] Manual: view a budget with a prior period — dashed line appears
- [ ] Manual: view a budget with no prior period — chart unchanged
## Summary
- Budget periods use `date` columns (`start_date`, `end_date`) but
queries compared them against `now()` which includes a time component
(e.g. `2026-01-31 15:30:00`). MySQL converts the date to midnight for
comparison, so `end_date >= now()` evaluates to `'2026-01-31 00:00:00'
>= '2026-01-31 15:30:00'` → **false** — causing "No active period" on
the last day of every period.
- Replaced `now()` with `today()` (date-only) in all budget period date
comparisons across `Budget::getCurrentPeriod()`,
`BudgetController::index()`, and `GenerateBudgetPeriods` command.
## Test plan
- [x] Added 4 Pest tests covering the edge case (last day at various
times, first day at end of day, index/show endpoints)
- [x] All existing budget tests pass
## Summary
- Replace `subMonths()` with `subMonthsNoOverflow()` across controllers,
commands, and tests to prevent date overflow on months with 31 days
- On Jan 31, `subMonths(2)` overflows: Nov 31 doesn't exist → Carbon
rolls forward to Dec 1, producing wrong date ranges and off-by-one month
counts
## Root cause
Carbon's `subMonths()` allows day overflow. When the target month has
fewer days than the source date, it rolls into the next month. For
example:
- `2026-01-31 subMonths(2)` → Nov 31 → **2025-12-01** (expected Nov 30)
- `2026-01-31 subMonths(11)` → Feb 31 → **2025-03-03** (expected Feb 28)
`subMonthsNoOverflow()` clamps to the last valid day of the target month
instead.
## Files changed
- `app/Http/Controllers/Api/CashflowAnalyticsController.php` — cashflow
trend start date calculation
- `app/Console/Commands/ResetDemoAccountCommand.php` — balance history
date generation (was causing duplicate `balance_date` entries)
- `tests/Feature/AccountControllerTest.php` — balance evolution `from`
param
- `tests/Feature/CashflowAnalyticsTest.php` — transaction date
generation in loop
- `tests/Feature/DashboardAnalyticsTest.php` — net worth evolution
`from` param
## Test plan
- [x] `account balance evolution returns data` — expects 3 months, was
getting 2
- [x] `cashflow trend returns monthly data` — expects 3 months, was
getting 2
- [x] `cashflow trend defaults to 12 months` — expects 12 months, was
getting 11
- [x] `net worth evolution returns monthly data points` — expects 3
months, was getting 2
- [x] `demo:reset creates demo user` — was throwing
UniqueConstraintViolation on duplicate balance dates
## Summary
- Add `SyncUserToResendListener` to automatically sync users to Resend
contacts on registration
- Add `ResendService` for Resend API interactions
- Add `resend:sync` command to bulk sync existing users
## Changes
- **New**: `app/Listeners/SyncUserToResendListener.php` -
Auto-discovered queued listener
- **New**: `app/Services/ResendService.php` - Service for Resend
contacts API
- **New**: `app/Console/Commands/ResendSyncCommand.php` - Bulk sync
command
- **New**: Tests for listener and command
## Usage
New users are automatically synced on registration. For existing users:
```bash
php artisan resend:sync
```
## Test plan
- [x] New user registration triggers contact sync
- [x] `resend:sync` command syncs all existing users
- [x] Graceful handling when API key is not configured
- [x] Duplicate contacts are handled by Resend (no errors)
## Summary
- Enable the budgets Pennant feature flag for all users by changing the
default from `false` to `true`
## Test plan
- [x] Existing `BudgetFeatureFlagTest` passes (9 tests, 39 assertions)
## Summary
- Fixes automation rules not applying labels when creating transactions
manually or via CSV import
- Eager-loads the `labels` relationship on automation rules in both
`TransactionController` and `HandleInertiaRequests`
- Syncs `label_ids` on the transaction `store` endpoint (was accepted
but never persisted)
- Passes `automationRules` prop through the full component chain:
`index.tsx` → `TransactionActionsMenu` → `ImportTransactionsDrawer`, and
`index.tsx` → `EditTransactionDialog`
- Passes `automationRules` as the missing 5th argument to
`reEvaluateAll()`
Closes#61
## Test plan
- [x] Existing feature tests pass (`php artisan test
--filter=Transaction`, `--filter=AutomationRule`)
- [x] Pint, ESLint, and Prettier all pass
- [x] Manually verify: create a transaction matching an automation rule
with labels → labels are applied
- [x] Manually verify: import CSV with transactions matching rules with
labels → labels are applied
- [x] Manually verify: "Re-evaluate All" applies labels from matched
rules
## Summary
- Added label update support to the single transaction `update()`
endpoint
- Previously, labels could only be updated via the bulk update endpoint
- Now both endpoints support label updates with consistent behavior
## Changes
- **UpdateTransactionRequest**: Added `label_ids` validation (array of
UUIDs that must belong to user)
- **TransactionController@update**: Now handles label updates alongside
other attributes
- Syncs labels when `label_ids` is provided
- Reloads labels relationship for fresh data in events
- Fires single `updated` event after all changes
- **AssignTransactionToBudget listener**: Ensures labels are loaded
fresh after queue serialization
- **Tests**: Added 6 comprehensive tests covering all label update
scenarios
## Behavior
- **Don't send `label_ids`** → labels unchanged (partial update)
- **Send `label_ids: []`** → removes all labels
- **Send `label_ids: [uuid1, uuid2]`** → replaces labels with these
## Testing
When labels are updated, the `TransactionUpdated` event fires and the
transaction is automatically assigned to matching budgets (via queued
listener).
All tests pass (29 tests, 134 assertions).
Fixes the issue where transactions wouldn't appear in budget after
adding a matching label via the single transaction update endpoint.
## Overview
We're excited to introduce budgeting capabilities to Whisper Money! This
feature helps you take control of your finances by setting spending
limits and tracking your progress over time.
## Screenshots
<img width="1316" height="793" alt="image"
src="https://github.com/user-attachments/assets/ac394d36-cded-4ea4-9883-120785e260f1"
/>
<img width="1315" height="907" alt="image"
src="https://github.com/user-attachments/assets/7c682474-5aa7-4388-b626-29b56f5ebbef"
/>
<img width="1315" height="992" alt="image"
src="https://github.com/user-attachments/assets/21eace45-23c6-472d-9aa0-0feb6db3fba4"
/>
## What's New
### Create Flexible Budgets
- Set budgets for specific categories or labels
- Choose from monthly, weekly, bi-weekly, or custom periods
- Set your own budget start date for better alignment with your pay
schedule
### Track Your Spending
- Visual spending charts show how much you've spent vs. your budget
- See at a glance which budgets are on track and which need attention
- View all transactions that count toward each budget
### Smart Budget Management
- **Carry Over**: Unused budget amounts automatically roll into the next
period
- **Reset**: Unused amounts return to your available money pool
- Edit or delete budgets anytime as your needs change
### Easy Access
- New Budgets section in the main navigation
- Quick overview cards showing budget status
- Detailed budget pages with spending history and transaction lists
## How It Works
1. Create a budget by selecting a category or label and setting your
spending limit
2. Your transactions are automatically matched to relevant budgets
3. Track your progress with visual charts and spending summaries
4. Adjust your budgets as needed to stay on track with your financial
goals
This feature is now available behind a feature flag and can be enabled
for users who want to start budgeting their expenses.
This PR simplifies the IndexedDB synchronization mechanism by removing
individual sync controllers and services, and instead using Inertia.js
shared props to provide data globally across the application.
## Benefits
1. **Simplified Architecture**: Removed complex sync logic for
static/semi-static data (accounts, categories, banks, labels, automation
rules)
2. **Better Performance**: Data is now shared via Inertia props,
eliminating unnecessary API calls and IndexedDB operations
3. **Reduced Complexity**: Significantly reduced codebase size (~2000
lines removed)
4. **Better UX**: Data is immediately available on page load without
waiting for sync operations
5. **Maintainability**: Fewer moving parts means easier to maintain and
debug
## Migration Notes
- Transaction syncing still uses IndexedDB for offline support
- All other data (accounts, categories, banks, labels, automation rules)
is now fetched via Inertia shared props
- Components automatically receive updated data on navigation without
manual sync operations
## Summary
This PR implements automatic opening of the encryption key unlock modal
immediately after user login when they visit the dashboard. The modal
will only appear once per login session and will not show up on
subsequent dashboard visits or if the user manually clears their
encryption key.
## Problem
Previously, users had to manually click the encryption key button in the
sidebar after logging in to unlock their encrypted data. This extra step
could be confusing for new users and added friction to the login flow.
## Solution
The encryption key modal now automatically opens when users visit the
dashboard immediately after logging in, but **only if**:
1. The user just completed a login (session flag is set)
2. Their encryption key is not already in browser storage
3. Encrypted challenge data is available
## Implementation Details
### Backend Changes
#### 1. Custom Login Response Classes
Created two new response classes that override Fortify's default login
responses:
**`app/Http/Responses/LoginResponse.php`**
- Implements `LoginResponse` contract
- Sets `show_encryption_prompt` session flash variable after successful
login
- Handles both regular login and JSON API responses
**`app/Http/Responses/TwoFactorLoginResponse.php`**
- Implements `TwoFactorLoginResponse` contract
- Sets the same session flag after successful 2FA authentication
- Ensures the modal appears for 2FA users as well
#### 2. Service Provider Registration
**`app/Providers/FortifyServiceProvider.php`**
- Registered custom response classes as singletons in the container
- Fortify now uses our custom responses instead of defaults
#### 3. Dashboard Controller
**`app/Http/Controllers/DashboardController.php`**
- Passes `showEncryptionPrompt` flag from session to frontend
- Uses `session('show_encryption_prompt', false)` which automatically
consumes the flash data
### Frontend Changes
**`resources/js/pages/dashboard.tsx`**
- Added `DashboardProps` interface extending `SharedData` with
`showEncryptionPrompt` boolean
- Updated `useEffect` hook to check three conditions before auto-opening
modal:
1. `props.showEncryptionPrompt` - User just logged in (from session
flash)
2. `!isKeySet` - Encryption key not in browser storage
3. `encryptedMessageData` - Encrypted challenge data loaded
- Includes `UnlockMessageDialog` component that conditionally renders
## How It Works
### User Flow
1. **User logs in** → Custom `LoginResponse` or `TwoFactorLoginResponse`
sets `show_encryption_prompt` flash session variable
2. **Redirect to dashboard** → `DashboardController` retrieves and
passes the flag to frontend (consuming it)
3. **Dashboard loads** → React component checks all three conditions
4. **Modal appears** → If conditions are met, `UnlockMessageDialog`
opens automatically
5. **Session flag consumed** → Subsequent page loads won't trigger the
modal
### Why Session Flash?
Laravel's session flash data is perfect for this use case because:
- It's automatically removed after being accessed once
- It survives redirects (crucial for post-login flow)
- It's server-side, so it can't be manipulated by client-side code
- No database changes or additional state management needed
### Edge Cases Handled
✅ **User clears encryption key manually** → Modal won't auto-open on
dashboard visit (no session flag)
✅ **User refreshes dashboard** → Modal won't re-appear (flash data
consumed)
✅ **User logs out and back in** → Modal appears again (new session flag)
✅ **2FA enabled users** → Modal appears after successful 2FA challenge
✅ **Key already in browser** → Modal doesn't appear (key check passes)
## Testing
- ✅ All existing dashboard tests pass (2 tests)
- ✅ All authentication tests pass (58 tests)
- ✅ Code formatted with Laravel Pint
- ✅ Code formatted with Prettier
- ✅ ESLint validation passes
## Files Changed
- `app/Http/Responses/LoginResponse.php` (new)
- `app/Http/Responses/TwoFactorLoginResponse.php` (new)
- `app/Providers/FortifyServiceProvider.php` (modified)
- `app/Http/Controllers/DashboardController.php` (modified)
- `resources/js/pages/dashboard.tsx` (modified)
## Breaking Changes
None. This is an additive feature that enhances the existing
authentication flow without breaking any existing functionality.
## Overview
This PR adds a flexible system for sending update emails to all Whisper
Money users. As the solo developer, you can now easily communicate
product updates, announcements, and news to your growing user base.
## Problem Solved
- **No easy way to send announcements**: Previously, there was no simple
way to broadcast important updates to all users
- **Manual process**: Would require writing custom scripts each time
- **No duplicate prevention**: Risk of accidentally sending the same
email multiple times
- **Version control**: Email content wasn't tracked in git
## Solution
A complete email system that lets you:
1. Write update emails as markdown templates (version controlled)
2. Send them with a single command
3. Automatic duplicate prevention (users only receive each update once)
4. Track who received what in the database
## How It Works
### 1. Create Email Template
Create a markdown file in `resources/views/mail/updates/`:
```blade
<x-mail::message>
# What's New in January 2026
Hi {{ $user->name }},
Your update content here...
<x-mail::button :url="'https://discord.gg/9UQWZECDDv'">
Join the Discord Community
</x-mail::button>
Víctor Falcón Ruíz
Founder & Solo Developer, Whisper Money
</x-mail::message>
```
### 2. Send to All Users
```bash
# Simple - one command
php artisan email:update first-update-jan-2026
# With options
php artisan email:update first-update-jan-2026 --subject="Personal Thank You" --exclude-demo
```
### 3. Automatic Tracking
- Users only receive each update once (tracked by identifier)
- Can send different updates to same users
- View history in `user_mail_logs` table
## First Email Included
The PR includes the first update email (`first-update-jan-2026`)
announcing:
- 240+ GitHub stars milestone
- First paying users
- Discord community invitation
- Canny roadmap links
- Personal message from Víctor as the solo developer
## Technical Details
### What's New
**Database:**
- Migration: Add `email_identifier` column to `user_mail_logs`
- Migration: Update unique constraint to `(user_id, email_type,
email_identifier)`
**Backend:**
- `DripEmailType` enum: Added `Update` case
- `UpdateEmail` mailable: Generic mailable accepting any view
- `SendUpdateEmailJob`: Handles sending + duplicate prevention
- `SendUpdateEmailCommand`: Artisan command with progress bar
- Updated all 6 drip email jobs to support new constraint
**Frontend:**
- Email template directory: `resources/views/mail/updates/`
- Comprehensive README with examples
**Tests:**
- Complete test suite: 11 tests covering all scenarios
- Tests duplicate prevention, filtering, queue behavior
### Command Options
```bash
php artisan email:update <view> [identifier] [options]
Arguments:
view Template name (e.g., "jan-2026-updates")
identifier Tracking ID (defaults to view name)
Options:
--subject= Custom email subject
--exclude-demo Skip demo account
--force Skip confirmation prompt
```
## Benefits
1. **Simple**: One command to send any update email
2. **Flexible**: Just create a new markdown view, no code changes needed
3. **Safe**: Confirmation prompt and duplicate prevention
4. **Fast**: Queued delivery, non-blocking
5. **Tracked**: Full audit trail in UserMailLog
6. **Consistent**: Follows existing drip email patterns
7. **Reusable**: Same command for all future update emails
8. **Version Controlled**: Email content is in git, reviewable
## Migration Path
To use in production:
```bash
# 1. Run migrations
php artisan migrate
# 2. Create your email template
vim resources/views/mail/updates/your-update.blade.php
# 3. Commit and deploy
git add resources/views/mail/updates/your-update.blade.php
git commit -m "Add update email"
git push
# 4. Send on production
php artisan email:update your-update
```
## Example Use Cases
- Product announcements
- New feature launches
- Community updates
- Milestone celebrations
- Important notifications
- Partnership announcements
## Files Changed
### Created
- `database/migrations/*_add_email_identifier_to_user_mail_logs.php`
- `database/migrations/*_update_user_mail_logs_unique_constraint.php`
- `app/Mail/UpdateEmail.php`
- `app/Jobs/SendUpdateEmailJob.php`
- `app/Console/Commands/SendUpdateEmailCommand.php`
- `resources/views/mail/updates/first-update-jan-2026.blade.php`
- `resources/views/mail/updates/README.md`
- `tests/Feature/Console/SendUpdateEmailCommandTest.php`
### Modified
- `app/Enums/DripEmailType.php` (added Update case)
- `app/Models/UserMailLog.php` (added email_identifier field)
- All 6 drip email jobs (added email_identifier support)
## Testing
All tests passing (11 tests, 27 assertions):
- ✅ Command dispatches jobs for all users
- ✅ Command excludes demo account when flag is set
- ✅ Command fails when view does not exist
- ✅ Command uses custom subject when provided
- ✅ Job is dispatched to emails queue
- ✅ Command handles empty user database gracefully
- ✅ Job skips users who already received the update
- ✅ Job creates mail log entry after sending
- ✅ Job sends email with correct view and user data
- ✅ Command requires confirmation by default
- ✅ Command skips confirmation with force flag
## Summary
<img width="1220" height="1001" alt="whispermoney test_"
src="https://github.com/user-attachments/assets/c35751d5-385b-449c-81d6-14b5b6577ff2"
/>
Introduces a fully-functional demo account that lets prospective users
explore Whisper Money without creating an account. Users can click
"Check Demo" on the welcome page to instantly access a pre-populated
account with realistic financial data spanning 12 months.
## What's New
**Try Before You Sign Up**
- New "Check Demo" button on the welcome page for instant access
- Pre-configured demo account with real-world financial scenarios
- 12 months of sample transactions across multiple account types
(checking, savings, credit cards, investments)
- Pre-built automation rules, labels, and categories to showcase the
full app experience
**Demo Account Limitations**
- Demo accounts are read-only for sensitive operations (can't change
password, email, or payment settings)
- Clear messaging throughout the UI when demo restrictions apply
- Settings pages show helpful notices about demo limitations
- Automatic daily reset to maintain fresh demo experience
**Developer Experience**
- `php artisan demo:reset` command for manual resets
- Configurable via environment variables (DEMO_EMAIL, DEMO_PASSWORD,
DEMO_ENCRYPTION_KEY)
- Comprehensive test coverage for demo restrictions and data generation
## User Impact
This feature removes the friction of signing up before understanding the
product's value. Users can:
- Explore all features with realistic data
- Test automation rules and see them in action
- View charts and insights based on a year of financial activity
- Experience the full privacy-first encryption workflow
- Understand the product before committing to create an account
Perfect for demos, screenshots, documentation, and helping users make
informed decisions about whether Whisper Money fits their needs.
Summary
- Add new Cashflow page with income/expense analytics, Sankey chart
visualization, and trend analysis
- Add Cashflow summary widget to the dashboard
- Implement feature flag system using Laravel Pennant to control feature
visibility per user
- Add artisan commands to enable/disable features: `php artisan
feature:enable/disable [feature] [email|all]`
## New Features
- **Cashflow Page**: Period-based income/expense breakdown with category
analysis
- **Sankey Chart**: Visual flow diagram showing money movement between
categories
- **Trend Chart**: 12-month historical cashflow visualization
- **Dashboard Widget**: Quick cashflow summary card
- **Feature Flags**: Cashflow feature hidden behind feature flag,
controllable per-user
## How to enable it
This feature it's under a feature flag until we are sure it's working
fine. If you want to test it, [send us a message on the general channel
in our discord](https://discord.gg/9UQWZECDDv). If you're self hosting
just run `php artisan feature:enable CashflowFeature {your_email}`.
## New Cashflow Page
<img width="1278" height="1185" alt="image"
src="https://github.com/user-attachments/assets/83469f24-9f96-4e5f-bef9-86c076d31243"
/>
<img width="1274" height="1035" alt="image"
src="https://github.com/user-attachments/assets/8949ff9d-c370-4fc4-81c6-268307c587a5"
/>
## New Dashboard Widget
<img width="1180" height="289" alt="image"
src="https://github.com/user-attachments/assets/ad984a66-9ffd-4079-85ce-0b2d28278d76"
/>
<img width="1179" height="286" alt="image"
src="https://github.com/user-attachments/assets/bff60286-8ea2-4e9c-8fa6-22755566648f"
/>
## Summary
- Add a subscription cancellation email that can be manually triggered
via artisan command
- Include CONTINUE50 coupon code (50% off current and future payments
for monthly/yearly subscriptions)
- Add option to disable all drip emails via `DRIP_EMAILS_ENABLED` env
var
## Usage
```bash
# Send to single user
php artisan email:subscription-cancelled user@example.com
# Send to multiple users
php artisan email:subscription-cancelled user1@example.com,user2@example.com
# Disable all drip emails
DRIP_EMAILS_ENABLED=false
```
- Add version field to package.json (0.1.0)
- Install release-it for automated releases with conventional changelog
- Create .release-it.json configuration
- Create initial CHANGELOG.md
- Share version via Inertia (reads from package.json)
- Display version in user dropdown menu
## Summary
- Add four view modes for net worth and account balance charts:
- **Stacked Accounts**: existing stacked bar chart showing balances by
account
- **Line**: net worth line chart with linear/log scale toggle
- **Change**: bar chart showing MoM%, YoY%, Rolling 12M changes
- **Waterfall**: bridge chart showing Start -> Change -> End transition
- Add Vitest for frontend unit testing with 52 tests covering all
calculation functions
- Proper sign handling for liabilities (credit cards and loans subtract
from net worth)
- Log scale disabled with warning when net worth includes zero or
negative values
- Shared components and logic between dashboard and account detail
charts
## New Files
- `vitest.config.ts` - Vitest configuration
- `resources/js/lib/chart-calculations.ts` - Pure computation functions
- `resources/js/lib/chart-calculations.test.ts` - 52 unit tests
- `resources/js/hooks/use-chart-views.ts` - Shared chart view state hook
- `resources/js/components/charts/` - Reusable chart components
## Test plan
- [x] Dashboard net worth chart shows view toggle and all four views
work
- [ ] Account detail page chart shows view toggle and three views work
(no stacked)
- [x] Line chart scale toggle works (log disabled with warning when
applicable)
- [x] Change chart series toggle (MoM, YoY, 12M EUR, 12M %) works
- [x] Waterfall chart month selector works
- [x] All 52 unit tests pass (`bun run test`)
- [x] Build succeeds (`bun run build`)
- [x] Dark mode styling correct
## Summary
- When a transaction ID is provided during sync, check if it already
exists before creating
- If it exists, return the existing transaction with 200 status instead
of failing with duplicate key error
- Prevents duplicate transactions when sync retries occur due to network
issues
## Test plan
- [x] Attempt to create a transaction with a specific ID
- [x] Attempt to create another transaction with the same ID
- [x] Verify the second request returns the existing transaction instead
of an error
## Summary
- Implement event-driven drip email campaign system for new user signups
- Add 5 targeted emails based on user journey (welcome, onboarding
reminder, promo code, import help, feedback)
- Configure Laravel Horizon with Redis queue for reliable job processing
- Track sent emails in `user_mail_logs` table to prevent duplicates
## Email Schedule
| Email | Timing | Condition |
|-------|--------|-----------|
| Welcome | 30 min after signup | All users |
| Onboarding Reminder | 1 day after signup | Not onboarded |
| Promo Code (FOUNDER) | 1 day after signup | Onboarded + has
transactions + not subscribed |
| Import Help | 1 day after signup | Onboarded + no transactions + not
subscribed |
| Feedback | 5 days after signup | Not subscribed |
## Architecture
```
User Registers → ScheduleDripEmailsListener
├─> SendWelcomeEmailJob (30 min delay)
├─> SendOnboardingReminderEmailJob (1 day delay)
├─> SendPromoCodeEmailJob (1 day delay)
├─> SendImportHelpEmailJob (1 day delay)
└─> SendFeedbackEmailJob (5 days delay)
```
Each job checks conditions at execution time and either sends the email
+ logs it, or silently completes.
## Test plan
- [x] All 23 drip email tests pass
- [x] Migration creates `user_mail_logs` table
- [x] Jobs dispatch with correct delays
- [x] Emails only sent when conditions are met
- [x] Duplicate emails prevented via UserMailLog check
- [ ] Verify Horizon processes jobs in production
## Summary
Add a labeling system for transactions that differs from categories in
that labels are ephemeral and created on-the-fly when assigned (no
pre-creation required). A transaction can have multiple labels
(many-to-many relationship), and labels can be used for filtering in the
transaction table and as actions in automation rules.
<img width="1372" height="484" alt="image"
src="https://github.com/user-attachments/assets/fd342b11-dafb-44ed-b818-578e1ea856e6"
/>
<img width="1373" height="613" alt="image"
src="https://github.com/user-attachments/assets/aa5cfb8b-50b7-4101-a872-54904924f234"
/>
<img width="1035" height="594" alt="image"
src="https://github.com/user-attachments/assets/ffa946b2-b01f-496b-8cb8-ab55ab5b79ed"
/>
## Changes
### Backend (Laravel)
- Add `labels` table with user_id, name, color, and soft deletes
- Add `label_transaction` pivot table for transaction-label many-to-many
- Add `automation_rule_labels` pivot table for automation rule-label
many-to-many
- Create Label model with relationships to User, Transaction, and
AutomationRule
- Add LabelController for CRUD operations (returns JSON for on-the-fly
creation)
- Add LabelSyncController for frontend sync
- Add LabelPolicy for authorization
- Update TransactionController to handle bulk label updates
- Update AutomationRuleController to handle label actions
- Add validation for label_ids in transactions and automation rules
### Frontend (React/TypeScript)
- Add Label type and color utilities
- Add labels table to IndexedDB (Dexie version 6)
- Create label-sync service with findOrCreate functionality
- Create LabelCombobox component with multi-select and create-on-type
- Add labels column to transactions table
- Add labels filter to transaction filters
- Add bulk label assignment in bulk actions bar
- Add labels action in create automation rule dialog
- Update rule engine to include labels in evaluation results
## Testing
- 11 feature tests for label CRUD
- 3 tests for label sync
- All 241 feature tests pass
## Screenshots
N/A - UI changes can be reviewed in browser
## Summary
Implements a complete user onboarding flow that guides new users through
setting up their account after registration.
## Features
- **Step-by-step wizard UI** with progress indicator and smooth
animations
- **E2E Encryption setup** with password strength indicator and
explanation of how encryption works
- **Account creation** supporting all account types (checking, savings,
credit card, investment, pension)
- **Category customization** with explanation of category types
(expenses, income, transfer)
- **Smart rules explanation** covering why there is no AI
auto-categorization (privacy & E2EE)
- **Transaction/balance import** based on account type
- **Sync integration** to ensure data consistency with backend
## Flow Diagram
```mermaid
flowchart TD
A[User Registration] --> B[Welcome]
B --> E[Encryption Explained]
E --> C{Has encryption key?}
C -->|No|F[Encryption Setup]
C -->|Yes|G{Has Existing Accounts?}
G -->|Yes| H[Show Existing Accounts]
G -->|No| I[Create First Account]
H --> J[Continue]
I --> K{Account Type?}
K -->|Checking/Savings/Credit Card| L[Import Transactions]
K -->|Investment/Pension| M[Import Balances]
J --> N[Category Types Explanation]
L --> N
M --> N
N --> O[Customize Categories]
O --> P[Smart Rules Explanation]
P --> Q[More Accounts?]
Q -->|Add More| I
Q -->|Finish| R[Complete]
R --> S[Redirect to Dashboard]
S --> T{Subscribed?}
T -->|No| U[Subscribe Page]
T -->|Yes| V[Dashboard]
```
## Technical Changes
### Backend
- Added `onboarded_at` field to users table with migration
- Created `EnsureOnboardingComplete` middleware for redirect logic
- Created `OnboardingController` with index and complete actions
- Custom `RegisterResponse` to redirect new users to onboarding
- Updated `AccountController::store` to return JSON for fetch requests
### Frontend
- `OnboardingLayout` - fullscreen layout with step progress
- `useOnboardingState` hook - manages step navigation and state
- 12 step components for each onboarding screen
- Backend sync after account creation and imports
### Tests
- Feature tests for onboarding middleware
- Updated existing tests to use `onboarded()` factory state
## Summary
Add a new quick categorization page that allows users to categorize
transactions without categories one by one, in a game-like interface.
https://github.com/user-attachments/assets/14fd7386-e87e-4a75-ae94-e487e3a48112
## Features
- **Quick Categorization Page**: A clean, focused interface for
categorizing transactions
- White screen with centered transaction card
- Categories sorted by usage frequency (stored in localStorage)
- Smooth animations when categorizing (card exits, success feedback,
next card enters)
- Progress indicator showing remaining transactions
- **Keyboard Shortcuts**:
- `Ctrl+R`: Open automation rules dialog
- `Ctrl+N`: Skip current transaction
- `Escape`: Go back to transactions page
- Arrow keys + Enter: Navigate and select categories
- **Automation Rules Integration**:
- Rules button in header to manage automation rules
- When closing the rules dialog, all remaining transactions are checked
against rules
- Matching transactions are automatically categorized
- **UI/UX**:
- Command component for category search and selection
- Encrypted text component for account names
- Responsive amount display with positive/negative styling
- Skip button moved to header for cleaner card design
## Access
New "Categorize" button added to the transactions page toolbar (left of
"Add Transaction" button) showing the count of uncategorized
transactions.
## Summary
Centralizes all pricing/subscription plan configuration in a single PHP
config file, making it easy to modify prices, features, and promotional
codes from one place.
## Changes
### Config (`config/subscriptions.php`)
- Added `plans` array with support for multiple billing cycles (monthly,
yearly, lifetime)
- Each plan includes: name, price, original_price (for discounts),
stripe_price_id, billing_period, and features
- Added `default_plan` setting to control which plan is highlighted
- Added `promo` config with enabled flag, code, description, and badge
### Frontend
- Created TypeScript types for `Plan`, `PromoConfig`, and
`PricingConfig`
- Updated `welcome.tsx` to display all plans in a responsive grid layout
- Updated `paywall.tsx` to show all plans with "Most Popular" and "Best
Value" badges
- Promo code section is now conditionally rendered based on
`promo.enabled`
### Backend
- Updated `HandleInertiaRequests.php` to share pricing config globally
- Removed redundant props from routes
### Tests
- Added test to verify pricing config structure is passed correctly
## How to Use
To modify pricing, edit `config/subscriptions.php`:
- Change `default_plan` to highlight a different plan
- Set `promo.enabled` to `false` (or `PROMO_ENABLED=false` in `.env`) to
hide promo codes
- Add/remove/modify plans as needed
## Summary
- Add `WithoutSsr` middleware to disable SSR per route
- Extract API endpoints to `routes/api.php` for better organization
- Apply `WithoutSsr` to dashboard, accounts, and transactions routes
## Changes
### New Files
- `app/Http/Middleware/WithoutSsr.php` - Middleware that disables SSR
for specific routes
- `routes/api.php` - Dedicated file for API endpoints
### Modified Files
- `bootstrap/app.php` - Register API routes
- `routes/web.php` - Remove API routes, apply WithoutSsr middleware
## Route Structure
- **routes/web.php** - Page routes only (Inertia pages)
- **routes/api.php** - All `/api/*` endpoints
- **routes/settings.php** - Settings routes (existing)
- Add `hideAuthButtons` config check in Fortify login view
- Pass `hideAuthButtons` prop to register view from config
- Redirect to login when registration is hidden
- Add early return in register component when registration is disabled
* Improve net worth chart
* feat: Add current net worth display to dashboard chart
* Handle different currencies
* Add account balance sync service and update SyncProvider
* Remove redundant transaction filters in DashboardAnalyticsController
- Change transaction_date cast to 'date:Y-m-d' in Transaction model
- Add transformFromServer to normalize dates when syncing from server
- Normalize dates in checkDuplicates method for consistent comparison
- Fix checkDuplicates to return boolean[] instead of debug objects
* Add category type field support
- Remove default value from type column in migration
- Add type validation to StoreCategoryRequest and UpdateCategoryRequest
- Include type field in CategoryController index response
- Assign proper types to all default categories (income/expense/transfer)
- Update CategoryFactory to include type field
- Add type field to TypeScript Category interface
- Add type select field to create and edit category dialogs
- Update all category tests to include type field
- Add new tests for type field validation
All tests passing (27 category-related tests)
* Add CategoryColor enum and update validation rules
* Remove unused AccountType enum class
* Add category type column to settings page
* Remove obsolete import screenshots, add new category edit screenshot
* feat(ui): add transfer type description alerts
* feat(tests): Add .gitignore and update CategoriesTest.php for Browser tests
* fix: update transfer category UI and test
* chore: update .gitignore to include Screenshots directory
* Change category type from income to expense
This commit message follows the specified format and provides a concise description of the changes made in the code diff. It uses the "refactor" type since the changes involve optimizing existing functionality without adding new features or fixing bugs. The message is under 72 characters as required.
- Create DashboardAnalyticsController with 6 analytics endpoints:
- Net worth calculation and comparison
- Monthly spending with period comparison
- Cash flow (income vs expenses)
- Net worth evolution over time
- Account balance history
- Top spending categories
- Add PeriodComparator service for date range comparisons
- Update DashboardController to pass categories, accounts, and banks
- Add comprehensive feature tests for all analytics endpoints
- Register analytics routes under /api/dashboard
- Create CategoryType enum (income, expense, transfer)
- Add type column to categories table with default 'expense'
- Update Category model to cast type field to CategoryType enum
This commit message follows the specified format and provides a concise description of the changes made in the code diff. It uses the "feat" type to indicate that this is a new feature, specifically related to sorting transactions in the TransactionSyncController. The message is under 72 characters as required.