Commit Graph

143 Commits

Author SHA1 Message Date
Víctor Falcón 3e087bdcd7
fix(static-analysis): clear phpstan-baseline by fixing all suppressed errors (#183)
## 🚪 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
2026-03-02 12:22:30 +00:00
Víctor Falcón 0493b87562
feat(Budgets): add period navigation and unify period selector UI (#171)
## 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"
/>
2026-03-01 12:25:01 +00:00
Víctor Falcón 0d9fc5a2b9
feat(transactions): re-add select all matching filters to bulk actions bar (#169)
## 🚪 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
2026-03-01 10:27:43 +00:00
Víctor Falcón eda72d4304
feat(rules): move automation rule evaluation to the backend (#168)
## 🚪 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
2026-03-01 10:37:12 +01:00
Víctor Falcón 77b225d747
feat(categories): add Self-Employment Income income category (#164)
## 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
2026-02-28 18:04:21 +00:00
Víctor Falcón 9bb835e79b
fix(categorizer): fetch uncategorized transactions from backend instead of IndexedDB (#165)
## 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
2026-02-28 16:30:54 +00:00
Víctor Falcón 0c5ba916bf
Add chart color scheme setting (#101)
## 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
2026-02-28 12:58:21 +01:00
Víctor Falcón 79dd24b23e
fix(ux): improve status badge, hide balance update for connected accounts, localize delete confirm (#159)
## 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"
/>
2026-02-27 22:37:16 +00:00
Víctor Falcón ae2a8c0118
fix(open-banking): use net_amounts for Indexa Capital invested amount calculation (#156)
## 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
2026-02-25 15:45:31 +01:00
Víctor Falcón 690be20f21
feat(open-banking): add update credentials flow for API-key connections (#154)
## 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
2026-02-25 13:41:24 +01:00
Víctor Falcón f2a7f955e6
fix(budgets): handle refunds correctly in budget spending calculations (#152)
## 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
2026-02-24 21:12:36 +01:00
Víctor Falcón b661255d09
refactor: extract AccountMetricsService to deduplicate balance computation logic (#147)
## 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
2026-02-24 09:07:42 +01:00
Víctor Falcón ae81e20a66
perf(dashboard): optimize query performance and eliminate redundant requests (#146)
## 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.
2026-02-24 08:43:48 +01:00
Víctor Falcón ce9574aa14
perf(accounts): replace client-side API calls with Inertia deferred prop (#144)
## 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
2026-02-23 20:08:41 +01:00
Víctor Falcón 0a9ca5b606
feat: enable invested amount tracking for savings accounts (#142)
## 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.
2026-02-23 17:19:33 +01:00
Víctor Falcón d48fea15b2
perf: make banking syncs incremental on subsequent runs (#141)
## 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
2026-02-23 15:10:40 +01:00
Víctor Falcón 299b8a56d8
feat: investment benefits — show gains/losses on investment accounts (#140)
## 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"
/>
2026-02-23 13:59:10 +01:00
Víctor Falcón b743cad803
feat: Add multi-currency conversion for net worth charts (#138)
## 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"
/>
2026-02-20 10:45:23 +01:00
Víctor Falcón 900cf41e31
feat: Add daily granularity toggle with area visualization to net worth chart (#136)
## 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"
/>
2026-02-19 15:35:22 +01:00
Víctor Falcón 126f7f7e72
feat: Add daily balance chart with area visualization for account pages (#135)
## 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"
/>
2026-02-19 14:55:05 +01:00
Víctor Falcón fe76c2e43d
feat: Add Bitpanda exchange integration (#132)
## 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
2026-02-19 09:26:31 +01:00
Víctor Falcón df9fc38562
feat: Add Binance integration (#131)
## 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
2026-02-18 15:23:46 +01:00
Víctor Falcón 3f541ca4d6
feat: Add Indexa Capital integration (#130)
## 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
2026-02-18 10:42:13 +01:00
Víctor Falcón a53e2be57b
Remove encryption from browser tests and demo user (#129)
## 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`)
2026-02-17 11:45:27 +01:00
Víctor Falcón d1efc03fc5
Move transaction filtering from client-side to server-side (#126)
## 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
2026-02-16 12:49:04 +01:00
Víctor Falcón 6abec95d0e
feat: Decrypt encrypted transactions on key unlock (#123)
## 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
2026-02-16 10:37:43 +01:00
Víctor Falcón b9abf49617
feat: Add --user and --connection filters to banking:sync command (#122)
## 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
2026-02-13 17:35:07 +01:00
Víctor Falcón 6b05de173a
Remove plaintext-transactions feature flag & E2E references (#116)
## 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`)
2026-02-13 11:10:21 +01:00
Víctor Falcón 9242b3fe5f
feat: Add per-bank description formatter for bank-synced transactions (#120)
## 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
2026-02-13 09:54:58 +01:00
Víctor Falcón 79164345d9
Fix: Ensure demo account is always email-verified (#118)
## 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
2026-02-13 07:36:54 +01:00
Víctor Falcón d1ba18932e
fix: Prevent re-syncing deleted bank transactions (#114)
## 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
2026-02-12 20:53:52 +01:00
Víctor Falcón 8ce0adf8ae
feat: Apply automation rules to bank-synced transactions (#112)
## Summary

- Adds backend automation rule evaluation for bank-synced transactions
using `jwadhams/json-logic-php` (same engine as the frontend
`json-logic-js`) for exact rule parity
- Synchronous `ApplyAutomationRules` listener on `TransactionCreated`
runs before `AssignTransactionToBudget`, auto-assigning categories and
labels via `saveQuietly()` / `syncWithoutDetaching()`
- Skips encrypted transactions and `action_note` (encrypted, can't
handle server-side)

## Test plan

- [x] 17 new Pest tests covering all JsonLogic operators, category/label
assignment, encrypted transaction skip, priority ordering,
case-insensitive matching, user scoping, bank_name matching, amount in
dollars, no `TransactionUpdated` event dispatch, compound rules, and
end-to-end listener integration
- [x] Existing `TransactionSyncServiceTest` passes (7 tests)
- [x] Full test suite passes (546 tests, 0 failures)
- [x] Pint formatting clean
2026-02-12 14:20:55 +01:00
Víctor Falcón 40d4b3cfe7
Email notification for bank-synced transactions (#113)
## 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
2026-02-12 14:05:02 +01:00
Víctor Falcón c7f3f1a978
fix: Delete pending connection and show toast on cancelled bank authorization (#111)
## 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`
2026-02-12 11:10:15 +01:00
Víctor Falcón d1d1be7586
Remove budgets feature flag (#108)
## 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`
2026-02-12 09:58:01 +01:00
Víctor Falcón db7b6e4da7
feat: Integrate EnableBanking as open banking provider (#106)
## 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`
2026-02-12 09:09:28 +01:00
Víctor Falcón e35f7125b3
feat: Plaintext transactions behind feature flag (#105)
## 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`
2026-02-10 14:31:24 +01:00
Víctor Falcón 2dc8cb554c
Remove encryption from bank account names (#104)
## 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
2026-02-09 14:15:26 +01:00
Víctor Falcón 70b603e901
feat: Spanish localization (#74)
## Pending
- [x] Translate landing page
- [x] Dashboard
- [x] Accounts list page
- [x] Account page
- [x] Cashflow
- [x] Budgets
- [x] Transactions
- [x] Settings

## Screenshots
<img width="1210" height="969" alt="image"
src="https://github.com/user-attachments/assets/c7935e5c-488d-4941-8f19-8834e5668257"
/>
<img width="1211" height="972" alt="image"
src="https://github.com/user-attachments/assets/e94e1daf-233a-4a49-aa65-5678c772d178"
/>
2026-02-08 11:58:08 +01:00
Víctor Falcón 1c425da330 chore: Remove MetricsWave tracking integration
MetricsWave visit tracker, event webhooks, and all funnel tracking
(lead, onboarding, import) are no longer needed.
2026-02-07 18:53:21 +01:00
Víctor Falcón 1b0f3ba24d fix: Disable email verification on dev/local 2026-02-07 17:52:53 +01:00
Víctor Falcón 444c81c5aa
chore: Sync users to Resend after email verification (#98)
## 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
2026-02-03 22:59:28 +01:00
Víctor Falcón 370d388d99
feat: Enable email verification on sign up (#97)
## 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

![Email verification
page](https://raw.githubusercontent.com/whisper-money/whisper-money/mail-verification/storage/videos/email-verification-page.png)

## 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
2026-02-03 10:15:07 +01:00
Víctor Falcón 9bbd91ac12
feat: Add previous period comparison to budget chart (#93)
## 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
![Budget chart - Light
mode](https://raw.githubusercontent.com/whisper-money/whisper-money/1cad899e771752e46183f8bb882f6eec4e3dd5de/.github/budget-final-light.png)

### Dark mode
![Budget chart - Dark
mode](https://raw.githubusercontent.com/whisper-money/whisper-money/1cad899e771752e46183f8bb882f6eec4e3dd5de/.github/budget-final-dark.png)

## 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
2026-02-01 14:21:13 +01:00
Víctor Falcón 00b2ca7c55 fix: Budget period not found on last day of period (#91)
## 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
2026-02-01 11:33:10 +01:00
Víctor Falcón cc666a3111 test: Fix Carbon month overflow in date calculations (#87)
## 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
2026-02-01 11:33:09 +01:00
Víctor Falcón 952a5d4be7
feat: Sync new users to Resend contacts (#85)
## 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)
2026-01-28 21:25:58 +01:00
Víctor Falcón a9b889b145
feat: Release budgets feature to all users (#84)
## 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)
2026-01-28 09:56:52 +01:00
Víctor Falcón a6a2a0d58c
fix: Apply automation rule labels on transaction creation and import (#79)
## 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
2026-01-27 11:11:29 +01:00
Víctor Falcón 8a8d5962b5
refactor: Simplify transaction endpoints architecture (#76)
## Summary

Simplifies transaction endpoints by separating sync API (read-only) from
web routes (mutations). This creates clearer architectural boundaries
and fixes inconsistent label behavior.

## Architecture Changes

### Before
- Sync API handled both sync (GET) and mutations (POST/PATCH/DELETE)
- Frontend used sync API for all operations
- Bulk updates merged labels, single updates replaced them

### After  
- **Sync API**: Read-only GET endpoint for IndexedDB sync
- **Web Routes**: All mutations (create, update, delete, bulk
operations)
- **Consistent behavior**: All label updates replace instead of merge

## Endpoint Mapping

| Operation | Old Endpoint | New Endpoint |
|-----------|-------------|--------------|
| Fetch/Sync | `GET /api/sync/transactions` | `GET
/api/sync/transactions`  |
| Create | `POST /api/sync/transactions` | `POST /transactions` |
| Update | `PATCH /api/sync/transactions/{id}` | `PATCH
/transactions/{id}` |
| Delete | `DELETE /api/sync/transactions/{id}` | `DELETE
/transactions/{id}` |
| Bulk Update | `PATCH /transactions/bulk` | `PATCH /transactions/bulk`
 |

## Backend Changes

### TransactionSyncController
-  Simplified to read-only `index()` method
-  Removed `store()`, `update()`, `destroy()` methods
-  Added docblock clarifying purpose

### TransactionController
-  Fixed `store()` to return labels with `id, name, color`
-  Fixed `update()` to return labels with `id, name, color`  
-  Changed `bulkUpdate()` to replace labels instead of merging

### Cleanup
-  Removed `UpdateTransactionSyncRequest` (no longer needed)
-  Updated `routes/api.php` to only have GET for sync

## Frontend Changes

### transaction-sync.ts
-  Updated `create()` → `POST /transactions`
-  Updated `update()` → `PATCH /transactions/{id}`
-  Updated `delete()` → `DELETE /transactions/{id}`
-  Replaced all `fetch()` calls with `axios`
-  Removed manual CSRF token handling

## Test Changes

### TransactionSyncTest
-  Removed create/update/delete tests
-  Kept only read-only sync tests
-  Added test for labels format

### BulkUpdateTransactionsTest  
-  Added test verifying label replacement behavior

## Test Results

All tests passing! 

```
Tests:    42 passed (235 assertions)
Duration: 6.17s

✓ TransactionTest: 29 tests
✓ TransactionSyncTest: 4 tests
✓ BulkUpdateTransactionsTest: 9 tests
```

## Benefits

1. **Clear separation of concerns**: Sync API is read-only, web routes
handle mutations
2. **Consistent label behavior**: All updates replace labels (not merge)
3. **Standardized HTTP client**: Axios everywhere, automatic CSRF
handling
4. **Reduced complexity**: Removed duplicate form request class
5. **Better architecture**: Aligns with intended design

## Breaking Changes

None - All changes are internal to how the frontend calls the backend.
The functionality remains the same from the user's perspective.

## Files Changed

- `app/Http/Controllers/Sync/TransactionSyncController.php`
- `app/Http/Controllers/TransactionController.php`
- `app/Http/Requests/UpdateTransactionSyncRequest.php` (deleted)
- `resources/js/services/transaction-sync.ts`
- `routes/api.php`
- `tests/Feature/Sync/TransactionSyncTest.php`
- `tests/Feature/BulkUpdateTransactionsTest.php`
2026-01-25 16:15:17 +01:00