Mirror real requests: the settings form only renders (and submits) the
field while CustomMonthStartDay is active, so the currency and email
tests should not send it with the flag off.
- make PeriodComparator::previous() period-aware: shift by whole months
derived from the period length so variable-length salary months,
quarters, and years compute the correct previous period (and an
end-inclusive end-of-day bound no longer leaks an extra day)
- snap trend() from/to to the user's period boundaries so emitted buckets
match how transactions are grouped, and resolve the start day once
- share the dashboard top-categories period so the drill-down, links, and
description use the same window as the parent rows
- gate the custom start day read path behind CustomMonthStartDay so rolling
the flag back reverts to calendar months instead of stranding users
- relax month_start_day validation to sometimes|required
- resolve "today" in the user timezone for the cashflow page so the client
and server agree on the current period near boundaries
- bucket the category monthly breakdown drawer by the user's period
- cover previous-period math across short months, quarters, years, trend
alignment, and flag rollback
- batch CalculateBalancesOnImport and CustomMonthStartDay into a single
Pennant values() lookup so the settings page stays under its query
threshold
- include customMonthStartDay in the shared feature flags assertion
- add CustomMonthStartDay Pennant feature (off by default)
- hide the Start of month setting in account settings unless enabled
- only validate month_start_day when the feature is active so other
profile updates keep working while the option is hidden
- share the flag to the frontend and cover both enabled/disabled paths
- compute current/trend now() against user timezone so period
boundaries are correct near midnight and month edges
- make end_inclusive endOfDay to match frontend and stay correct
if compared against datetime columns
- add user-periods.ts unit tests covering custom-start-day month,
quarter, and year ranges, shifting, and boundary equality
## Summary
Adds a **category-analysis drawer** so you can see how much you spend on
a single category, month by month, over the last 12 months — answering
"am I spending more or less on food delivery?".
Reachable via an **Analyze** button on three cards:
- Dashboard → Top spending categories
- Cashflow → Income Sources
- Cashflow → Expense Categories
Each button opens a shared drawer with a full-width category chooser
(all categories) and a 12-month stacked bar chart.
## Chart semantics
- X = last 12 rolling months (gaps filled with zero). Y = user display
currency.
- Stacked segments = the chosen category's immediate children
(grandchildren rolled in) + a **Direct** segment (spend on the parent
itself) + an **Other** segment (children beyond the top 6, ranked by
total).
- A leaf category renders a single series named after the category.
- Amounts convert to the user currency and **net per month**, oriented
by category type so the expected direction reads as a positive bar; an
against-grain month (e.g. refunds > spend) dips below zero.
- Colors use the theme-aware chart palette (same as the net-worth
chart).
## Summary stats
Two glowing cards above the chart: **Monthly average** and **Trend**
(recent 6-month average vs earlier 6-month average; null when there's no
earlier baseline).
## Persistence
Each widget remembers its own chosen category in `localStorage`
(category only), prefilling the first category of its list otherwise; a
remembered-but-deleted category falls back gracefully.
## Tests
- 11 Pest feature tests (rollup, Direct/Other, net-with-refunds,
12-month window, currency conversion, income orientation, summary
average + trend).
- 5 vitest tests for the prefill/persistence precedence.
- es.json keys added.
## What
Long category/label names in the shared bar-list breakdowns (cash-flow
top expenses/income, dashboard top categories, analysis drawer
categories/tags/accounts) spilled the amount out past the widget's right
edge.
## Why
The name span already had `truncate min-w-0 flex-1`, but its `grow` row
wrapper (the `<Link>`/`<div>`) had no `min-w-0`. Flex items default to
`min-width: auto`, so the wrapper refused to shrink below its content —
the inner `truncate` never engaged.
## Change
Add `min-w-0` to both `grow` wrappers so the row can shrink and the
existing `truncate` clamps the name with an ellipsis, keeping the amount
inside the widget.
Visual-only CSS class change.
## What
Unifies the "top categories"-style bar lists behind one reusable
component and applies it to the transaction **analysis drawer**.
### Shared component
New `resources/js/components/shared/category-breakdown-list.tsx` —
generic `CategoryBreakdownRow<T>` + adapter (leading marker, truncated
name, optional trend + %, amount, proportional bar, expand/collapse
recursion). Now used by:
- Dashboard **Top spending categories** (was a local `CategoryRow`)
- Cash-flow **Income Sources / Expense Categories** (was a local
`BreakdownRow`)
- Analysis drawer breakdowns
### Analysis drawer
- **Top categories** — pie+list → bar list, now with **expand/collapse**
of subcategories (children already in the payload, served through
`useExpandableCategories` with no extra fetch).
- **Top tags** — recharts bar → bar list, colour-dot leading marker.
- **Top accounts** — plain list → bar list, with the **bank/account
logo** as the leading marker instead of a category icon.
### Largest expenses table
Long category names pushed the amount onto two lines (minus sign split
from the digits). Category + description columns are now capped to the
same width, the chip truncates, and the amount cell is
`whitespace-nowrap`.
### Backend
`icon` added to the category breakdown payload (parent, child,
Uncategorized) so the drawer can render the icon circle.
## Tests
- Drawer: category expand/collapse + tag/account render tests.
- Backend: asserts `color`/`icon` on the category breakdown.
- Full JS suite (191) + analysis feature tests green; pint / eslint /
prettier clean.
## Note
Dashboard card has no test file — refactor preserves its public
behaviour; worth a visual check.
## What
Adds two timestamp columns to `users`:
- **`last_logged_in_at`** — set by an `UpdateLastLoggedInAt` listener on
Laravel's `Login` event. Records each explicit authentication (login
form or remember-me re-auth). Does **not** update on plain session-based
requests.
- **`last_active_at`** — set by a `TrackLastActiveAt` middleware on
authenticated web requests. Records the last moment the user did
anything. Throttled to at most one write per 5 minutes to avoid a DB
write on every request.
## Why
`last_logged_in_at` answers "when did they last authenticate";
`last_active_at` answers "when were they last using the app" (any
screen/activity), which a session-based login does not capture.
## Tests
- Login records `last_logged_in_at`
- Authenticated request records `last_active_at`
- Throttle window respected (no rewrite within 5 min) and refreshed once
it passes
Migrations not yet run on environments.
## What
In the transaction **analysis drawer**, the summary cards' amounts
weren't lining up horizontally: the **Avg / day** card's label row holds
the day-editor button (a 24px icon button), making that row taller than
a plain text label, so its amount sat lower than **Total spent**.
## Fix
Give every summary card's label row a fixed `h-6` (matching the
icon-button height), so all label rows are the same height and the
amounts share a common baseline. Amounts were already the same size
(`text-lg`); this just equalizes the vertical space above them.
Applies to all summary tiles — expense mode (Total spent / Avg per day)
and income mode (Income / Expenses / Net / Margin).
## Before / After
- Before: Avg/day amount offset below Total spent.
- After: both amounts aligned on the same row.
Frontend tests (9) pass; eslint + prettier clean.
## What
Reworks the filtered **transaction analysis drawer** to behave like a
*project view* — a coherent set of related transactions (a trip, a
rental, a renovation, a side hustle) — and surfaces data that was
already computable but never shown.
### Two shapes, auto-detected
The drawer adapts to whether the set is **expense-only** or **income +
expense**, detected from the income share (`income >= 15% of expense`).
A stray refund won't flip a trip into a P&L view; a rental's rent will.
- **Expense mode:** total spent, daily average, cumulative spend line.
- **Income mode:** net result + margin %, income vs expense bars,
cumulative **net** line.
The mode can be overridden per filter (Auto / Expenses only / Income &
expenses), mirroring the existing daily-average override exactly:
remembered in the browser per filter fingerprint and synced to a
matching saved filter via a new `analysis_mode` column + PATCH endpoint.
### New panels (all from existing data)
- **Largest expenses** — top 5, expandable to 10, biggest spends first.
- **Spending by payee** — horizontal bars, gated to ≥2 named payees.
- **Spending by account** — list, gated to ≥2 accounts.
### Smarter table
The largest-expenses table hides any column the filter or the rows have
pinned to a single value (e.g. filtering by one label drops the Labels
column; a one-category result drops the Category column).
## Tests
- **Backend:** new Pest coverage for `largest_expenses`, payee/account
breakdowns, `cumulative_net`, and the `analysis_mode` endpoint
(`TransactionAnalysisTest`, `SavedFilterTest`) — all green.
- **Frontend:** 9 vitest cases covering mode detection/override, the day
override, and column hiding.
- New `__()` keys added to `lang/es.json`.
## Notes
- Adds migration `add_analysis_mode_to_saved_filters_table`.
## What
Two mobile-header fixes:
- **Logo no longer wraps/clips.** The brand row had no `shrink-0`, so
flexbox squeezed it and "Whisper Money" wrapped to two lines or got cut
off by the buttons. Now pinned with `shrink-0` + `whitespace-nowrap`,
and the bird icon is bumped `size-4` → `size-5`.
- **Auth buttons fit when logged out.** Log in + Register both as text
buttons overflowed the narrow pill. Log in is now an icon-only ghost
button (`LogIn` icon, `aria-label` for a11y), so Register keeps its full
primary-CTA pill.
Desktop header is untouched.
## Test plan
- [ ] Logged-out mobile: logo on one line, login icon + Register both
visible, no overflow
- [ ] Logged-in mobile: Dashboard button unaffected
- [ ] Desktop unchanged
## Why
Bank connection via Enable Banking is a critical flow that must keep
working in **both onboarding and settings**, including reconnecting an
expired consent. This adds regression coverage so it never silently
breaks.
## What
Two complementary layers:
### 1. CI regression — mocked Pest browser tests
`tests/Browser/BankConnectionFlowTest.php` drives the full UI →
`authorize` → `callback` → account mapping → sync flow for all three
scenarios, with the banking provider faked
(`tests/Support/FakeBankingProvider.php`). Deterministic, no external
calls.
- ✅ connect from onboarding (accounts auto-created)
- ✅ connect from settings (+ account mapping)
- ✅ reconnect an expired connection (re-matches accounts by IBAN)
**Runs on CI** in the existing `browser-tests` job (distributed across
shards automatically as a new test; `shards.json` timing entry will be
added next time `update-browser-shards` runs).
### 2. Live-sandbox verification — standalone Playwright script
`tests/Browser/live/connect-bank.mjs` runs the same three flows against
the **real Enable Banking sandbox** (Banco de Sabadell + BBVA) via the
dev server, for on-demand / nightly checks. Backed by a local-only
helper command `app/Console/Commands/E2eBankingFixtureCommand.php`.
**Does not run on CI** (needs the dev server, live sandbox, and
secrets). See `tests/Browser/live/README.md`.
#### Why the live flow can't be a normal Pest test
Enable Banking only redirects to the fixed registered URI
(`https://whisper.money.local/open-banking/callback`). A
`RefreshDatabase` Pest test runs an ephemeral server on a random port
with a transactional DB the external redirect can never reach. The
script instead drives the persistent dev server, captures the
`code`+`state` the bank redirects with, and replays the callback against
the dev server (the callback is stateless — keyed by `state_token`).
## Verification
- Mocked Pest tests: **3 passing**.
- Live script: **all 3 scenarios PASS** against the real sandbox
(Sabadell → 5 accounts + 328 tx synced; BBVA onboarding → 7 accounts;
expired → reconnect refreshes `valid_until` and retains accounts).
- `pint --test`, eslint, prettier clean.
> Note: while verifying locally, the dev DB was missing the
`state_token` migration (`2026_06_05_185212`), which made
`/open-banking/authorize` 500. Worth confirming it's applied in other
environments.
## What
Tidies the transactions filter bar so it lays out as **two balanced
rows** — a control on the left and on the right of each — instead of
wrapping awkwardly once **Transaction analysis** is enabled (the
saved-filters button was crowding the row).
- **Row 1:** `Filters` (left) · search input (grows, middle) ·
saved-filters bookmark (right)
- **Row 2:** `Analysis / Categorize / + Transaction / ▾` (left) ·
`Columns` (right)
Actions row dropped `flex-wrap` + `sm:justify-end` in favour of a clean
`justify-between`; the saved-filters/clear group is now grouped to the
right with `ml-auto`.
## Screenshots
**Desktop**

**Mobile**

## Notes
- Screenshots are hosted on a throwaway
`assets/transaction-filters-screenshots` branch so they render here
without landing in `main`. Safe to delete after merge.
## What
Clicking **Analysis** on the transactions page opens a chart of spending
by category. Two improvements:
### Sub-category breakdown
Previously every expense rolled up to its top-level category. Now each
parent shows its total with the sub-categories that carry spending
nested beneath it.
- New `CategoryTree::spendingBreakdown()` builds a two-level tree: each
root with its rolled-up total + immediate sub-categories. Grand-children
fold into their level-2 ancestor. Spend booked directly on a parent that
is *also* split across sub-categories surfaces as a **Direct** child, so
the children always sum to the parent total.
- `by_category` slices now carry a `children[]` array.
- The legend renders each parent row, then indented sub-category rows
(name, % of parent, amount). The donut stays at parent level.
### Clearer colors
Monochrome schemes (blue, pink, neutral) run darkest→lightest, so
adjacent slices were nearly identical. A new `alternateContrast()` zips
the two palette halves (`chart-1,5,2,6,3,7,4,8`) so neighbouring slices
alternate between dark and light ends. Sub-category dots reuse the
parent color at reduced opacity.
## Tests
Added coverage for sub-category nesting, the Direct child, grand-child
folding, and the flat (no-children) case. All analysis + CategoryTree
tests pass.
## Summary
Adds an **Analysis** button to the transactions toolbar (left of
Categorize) that opens a bottom-sheet dashboard summarizing the
transactions matching the current filters. Built around the Miami-trip
use case: tag a trip, filter to it, and see where the money went.
Everything is behind the existing `TransactionAnalysis` feature flag
(button hidden + endpoint returns 403 when off).
## Behavior
- **Button**: hidden unless `features.transactionAnalysis`. Disabled
until at least one filter is applied — desktop shows a hover tooltip,
mobile a tap dialog, both reading *"Apply a filter to enable this
button."*
- **Drawer** (vaul bottom-sheet, like the importer): fetches
`/api/transactions/analysis` with the current filters on open. Skeleton
+ empty/error states.
- **Charts**: KPI cards · spending over time (income/expense bars +
cumulative line, auto daily/monthly bucketing) · category donut + ranked
list (only when >1 category) · per-tag bars (only when >1 label).
- **Manual day override**: the *Avg / day* card has an editor to
override the date span used for the daily average (tickets bought months
ahead skew the span). Resolution precedence: matching saved filter's
`analysis_days` → `localStorage[fingerprint]` → auto span. Edits persist
to localStorage always, and sync to the saved filter when the current
filters match one.
## Backend
- `Api/TransactionAnalysisController@summary` — reuses
`Transaction::applyFilters()` + `IndexTransactionRequest`, converts to
the user's base currency via `ExchangeRateService` (rates preloaded),
rolls categories up through `CategoryTree`.
- `GET /api/transactions/analysis`.
- `analysis_days` (nullable) on `saved_filters` + `PATCH
/api/saved-filters/{id}/analysis-days`.
## Tests
- Pest: analysis endpoint (flag gating, totals, category/tag breakdown,
day/month bucketing, cumulative) + saved-filter day override
(set/clear/forbidden/invalid).
- Vitest: button gating (hidden/disabled/opens) + day-override
resolution (auto / saved-precedence / localStorage fallback / persists
to saved filter).
- New `lang/es.json` keys for all added strings.
## Notes
- Data is always sourced from the backend.
- The flag is still off by default — enable `TransactionAnalysis`
per-user to try it.
## Summary
On the landing page, logged-in users see a modal with a three-second
progress bar that redirects them to the dashboard. This adds a primary
**Go now** button so users can skip the wait.
- Move **Cancel** button to the left, add primary **Go now** button on
the right (`sm:justify-between`)
- `Go now` calls `router.visit(dashboard())` immediately
- Added `Go now` Spanish translation (CI enforces `es.json`)
## Testing
- Added test: redirects immediately when clicking go now
- All 4 dialog tests pass
## What
Enlarge and color-code the headline numbers in the first two cash-flow
cards so users can spot a weak savings net or low allocation at a
glance.
- **Net Cashflow**: headline number bumped to `4xl`; green when net ≥ 0,
red when negative.
- **Saved & Invested**: headline number bumped to `4xl`; colored by
allocation rate — green ≥50%, yellow ≥25%, orange ≥0%, red below.
## Why
The numbers were uniformly black, making it hard to tell at a glance
when net cashflow is negative or when only a little is being set aside.
## Testing
- `vitest run` on `net-cashflow-card.test.tsx` ✅
- `bun run format` / `bun run lint` clean
## What
Hardens `CurrencyConversionService` against a slow/unreachable external
FX-rate CDN, which was crashing `/api/cashflow/trend`.
## Sentry
- Fixes PHP-LARAVEL-2X — `RuntimeException: Failed to fetch currency
rates ... cURL error 28: Operation timed out`
- Fixes PHP-LARAVEL-31 — `FatalError: Maximum execution time of 30
seconds exceeded` (Guzzle CurlFactory)
## Root cause
For a historical date the service walked up to 8 lookback dates × 2 URLs
at a **10s timeout each** (~160s worst case → 30s PHP fatal), and a
single network timeout threw a `RuntimeException` that 500'd the whole
request.
## Changes
- **Bounded timeouts**: 3s connect / 5s total per request.
- **Abort on unreachable source**: a connection/timeout error stops the
date walk (after trying the fallback host) instead of repeating the same
timeout for every candidate date. 404s still walk back to earlier
releases.
- **Graceful degradation**: failures return an empty rate map instead of
throwing — conversions already fall back to `0.0` on missing rates, so
the trend endpoint no longer 500s.
- **Persistent cache**: historical releases cached 30d (immutable),
`latest` 6h, unavailable results 10m (dampens retries during an outage).
## Tests
- Updated the former "throws" test to assert graceful degradation to
`0.0`.
- Added: timeout aborts the date walk after 2 attempts; rates cache
across service instances.
- All 12 tests pass.
## What
Removes `resetOnSuccess={['password']}` from the login form.
## Sentry
- Fixes PHP-LARAVEL-2Y — `TypeError: Failed to construct 'FormData':
parameter 1 is not of type 'HTMLFormElement'.` (6 users, on `/login`)
## Root cause
On a successful login the server redirects to the dashboard, which
**unmounts the login form**. `resetOnSuccess` then fires a form reset,
and Inertia's reset handler constructs `new FormData(ref)` from the form
ref — now stale/null — which throws.
The reset serves no purpose on login (the user navigates away
regardless), so removing it eliminates the crash.
## Tests
- Added `resources/js/pages/auth/login.test.tsx`: renders the login page
and asserts the `<Form>` is not configured to reset on success.
## Problem
`GET /transactions` throws `InvalidArgumentException: Illegal operator
and value combination` (Sentry
[PHP-LARAVEL-34](https://whisper-money.sentry.io/issues/PHP-LARAVEL-34)
— escalating, 76 occurrences, 2 users).
## Root cause
Laravel's `cursorPaginate()` builds `where(orderColumn, '>',
cursorValue)` for the next page. When sorting by a **nullable** column
(`creditor_name` / `debtor_name`) and the boundary row's value is
`null`, the cursor encodes `null` → `prepareValueAndOperator` rejects
`where(col, '>', null)`. Default sort (`transaction_date`, NOT NULL)
never hits it.
## Fix
When sorting by a nullable column, select `COALESCE(col, '') as
col_sort` and order by the alias. Cursor reads the coalesced
(never-null) value; Laravel rewrites the WHERE to the `COALESCE`
expression. Alias is hidden from serialization.
## Tests
- Reproduces cross-page cursor pagination with null values in the sort
column (500 → 200).
- Asserts the `*_sort` alias is not leaked to the frontend.
Fixes PHP-LARAVEL-34
## Why
iOS PWAs hand the bank redirect back to the system browser (Safari),
where the app session does not exist. The old `/open-banking/callback`
sat behind `auth` + `verified` middleware, so Safari hit it with no
session → bounced to `/login`, the callback body never ran, and the
connection stayed `pending` forever (4 stale rows observed in prod).
## What
Make the bank connection flow session-independent end to end.
### 1. State-token callback (session-independent)
- `store()` generates `Str::random(40)`, persists it on the connection,
and passes it to `startAuthorization` as `state`.
- `/open-banking/callback` is now **public**. It resolves the connection
from `state_token` (`resolveConnectionFromState`) and derives the owner
from the connection, not the session.
- Connection is finalized server-side (`session_id`, status,
`state_token = NULL`) before any redirect.
- New migration adds the nullable, unique `state_token` column; the
column is hidden on the model.
### 2. Completion page instead of login bounce
When the callback runs without a session (the Safari case), the
connection is already finalized server-side. Instead of bouncing the
user to `/login`, render a standalone "go back to the app" page — their
session lives in the PWA, not here.
- New page `resources/js/pages/open-banking/connection-complete.tsx`
(success / error states, dark mode).
- `finishRedirect()` renders the page when `!Auth::check()`; logged-in
users still get the normal redirect.
### 3. Onboarding: land on the connections step + poll cross-browser
- A logged-in user finishing the flow lands straight on the onboarding
connections step (`?step=create-account`), now resolved server-side via
a validated `initialStep` prop so the step is deterministic.
- While on that step the page polls every 4s (`usePoll`, `only:
['accounts']`), so a connection finalized in another browser (PWA →
Safari) shows up without a manual refresh.
## Flow
```
POST /authorize → stateToken = Str::random(40)
→ startAuthorization(state = stateToken)
→ create BankingConnection(pending, state_token)
EnableBanking → callback?code&state=stateToken (PUBLIC, no session needed)
→ find connection WHERE state_token = state
→ createSession(code) → finalize (session_id, status, state_token = NULL)
→ session present? redirect to connections step / mapping
: render "go back to the app" completion page
PWA (logged in) → onboarding?step=create-account → polls accounts every 4s
```
## Tests
36 passing. Covers:
- state-token persistence and session-less finalization
- owner resolved from the connection regardless of who is authenticated
- completion page rendered (success + error) when no session; login
fallback when no connection resolves
- logged-in user redirected directly to the onboarding connections step
- `?step=create-account` lands on that step; unknown step falls back to
default
## What
Collapse the two Pennant feature-flag checks in
`HandleInertiaRequests::resolveFeatureFlags()` into a single
`Feature::for($user)->values([...])` call.
## Why
The `transactionAnalysis` flag added in #496 introduced a **second**
Pennant DB lookup that runs on **every** page. That pushed the account
show page from 18 → 19 queries, breaking the `performance-tests` job on
`main`:
```
Account Show: Expected at most 18 queries, but 19 were executed.
```
Batching keeps shared data at a single query regardless of how many
flags we add, so this fixes the regression without bumping the
threshold.
## Testing
- `./vendor/bin/pest --testsuite=Performance` — 25 passed
- `tests/Feature/InertiaSharedDataTest.php` — 8 passed
- Pint clean
## What
Add an eye icon to every password field that toggles between masked and
plaintext, so users can verify what they typed.
New reusable `PasswordInput` component
(`components/ui/password-input.tsx`) wrapping the base `Input` with an
`Eye`/`EyeOff` toggle.
## Where
- **Auth**: login, register (password + confirm), reset password
(password + confirm)
- **Settings**: password form & account form (current + new + confirm)
- **Dialogs**: delete-account confirmation, encryption unlock dialog
## Details
- Toggle is keyboard-skipped (`tabIndex={-1}`), mirrors the input's
`disabled` state.
- Accessible: `aria-pressed` + `aria-label` (`Show password` / `Hide
password`), translated and added to `lang/es.json`.
- Dark-mode aware via existing `text-muted-foreground` /
`hover:text-foreground` tokens.
## Tests
- New vitest suite for `PasswordInput` (mask default, toggle, ref
forwarding, disabled state) — 4 passing.
- `LocalizationTest` passing (new translation keys present).
- eslint + prettier clean.
## Not included
Left out (can add on request): `confirm-password.tsx`,
`setup-encryption.tsx`, and the open-banking API key/secret fields.
## What
Adds a `banking:disconnect` artisan command to disconnect (soft-delete)
one or more banking connections by ID, for ops use on prod.
```bash
php artisan banking:disconnect <id1>,<id2>,<id3>
php artisan banking:disconnect <id1>,<id2> --delete-accounts
```
## How
- Parses comma-separated IDs (trims spaces, dedups).
- Reuses the existing `DisconnectBankingConnection` action, which:
- Revokes the session on Enable Banking's side (`DELETE /sessions/{id}`)
when the connection is an active Enable Banking one.
- Sets status to `Revoked` and soft-deletes the connection.
- Default behavior unlinks linked accounts (kept as manual accounts).
`--delete-accounts` hard-deletes accounts, transactions and balances.
- Warns on unknown IDs, reports per-connection results, and exits with
failure if any ID was missing or any disconnect threw.
- A provider-side revoke failure does not block the soft-delete (action
catches + logs a warning), matching the existing
`banking:cancel-free-enablebanking` behavior.
## Tests
`tests/Feature/DisconnectBankingConnectionsCommandTest.php` — covers
multi-ID disconnect + session revoke, `--delete-accounts`, missing IDs,
and no-match. All pass.
## What
Adds **saved filters** on the transactions page so users can name a set
of filters and reuse them later. The whole feature is gated behind a new
\`transaction-analysis\` Pennant feature flag (off by default).
## Why
Reusing the same filter combinations (e.g. "Japan trip", "Utilities") is
currently manual every time. This ports the saved-filters work from the
\`analysis-page\` branch, scoped down to **only** the transactions page
— the analysis screen and unrelated query changes from that branch are
intentionally left out.
## Changes
**Feature flag**
- \`App\Features\TransactionAnalysis\` — class-based flag, resolves
\`false\` by default.
- Shared to the frontend as \`features.transactionAnalysis\` and added
to the \`Features\` type.
**Backend (saved filters)**
- \`SavedFilter\` model (UUID, \`filters\` json cast, user belongsTo) +
migration + factory.
- \`Api\SavedFilterController\` — user-scoped index/store/update/destroy
under \`/api/saved-filters\`, ownership enforced with \`abort_unless\`.
- \`StoreSavedFilterRequest\` / \`UpdateSavedFilterRequest\` — name
unique per user, snake_case filter rules.
**Frontend**
- \`transaction-filter-serialization.ts\` —
serialize/deserialize/fingerprint between UI filter state and the
persisted snake_case shape.
- \`SavedFilters\` dropdown — load/save/update/delete with active +
dirty indicators.
- Integrated into the filter bar via an opt-in \`enableSavedFilters\`
prop, so it renders **only** on the transactions page (budgets/accounts
reuse the same component and stay unchanged). Render is also gated by
the feature flag.
**Filter bar UX**
- Split search + filters + saved searches onto their own row, separate
from the actions row (Categorize / Add transactions / columns), which
were getting cramped.
- On mobile, the text search moves into the filters popover.
## Reviewer notes
- Backend endpoints are **not** flag-gated; only the UI is, per the
request.
- Two-layer scoping: page opt-in prop **and** feature flag — flag off
means no UI anywhere.
- Translation keys fall back to the key (English); \`es.json\` entries
not added, matching the source branch.
## Testing
- \`tests/Feature/SavedFilterTest.php\` — 9 passing (auth, per-user
listing/uniqueness, ownership on update/delete).
- Pint, Prettier, ESLint clean. No new TypeScript errors introduced.
## Problem
On the transactions page, the **Category** and **Labels** filters were
combined with **AND**. Selecting both a category and a label only showed
transactions that matched the category *and* carried that label.
## Fix
In `Transaction::scopeApplyFilters`, both filters are now wrapped in a
single `where` group with the label condition using `orWhereHas`, so
they combine as **OR**: a transaction matches if it's in a selected
category **or** has a selected label.
- Multi-category OR, category-tree expansion, and the *uncategorized*
option are preserved.
- Other filters (date, amount, account, search) remain AND.
## Tests
Added `category and label filters combine with OR` to
`TransactionFilterTest`. All 34 filter + bulk-update tests pass; pint
clean.
## Problem
Importing a CSV with `DD/MM/YYYY` dates (e.g. `02/06/2026`) parsed them
as `MM/DD/YYYY`, and the **Date Format** selector either didn't appear
or had no effect — the preview showed the same wrong date regardless of
the chosen format.
## Root cause
The `xlsx` parser coerced CSV date-like strings into Excel **serial
numbers** at read time, guessing the format itself. `parseDate` then hit
its numeric short-circuit and **ignored the selected format entirely**,
so the Date Format radio never changed the preview.
On top of that, ambiguous dates (valid as both DD/MM and MM/DD) were
resolved silently by browser locale, and a saved import config force-hid
the selector — so a wrong guess couldn't be corrected.
## Changes
- **`parseFile` reads with `raw: true`** — CSV cells stay as their
original strings; `parseDate` applies the chosen format. Native
`.xls/.xlsx` dates still arrive as numbers and use the serial path.
- **`detectDateFormat()`** now returns `{ format, ambiguous }`. When
multiple formats parse the sample equally, the importer keeps the Date
Format selector visible (defaulting to the locale/saved best guess) —
even when a saved config exists — so a previously-saved wrong format can
be fixed. `autoDetectDateFormat()` kept as a thin wrapper.
- Applied to both the transactions and account-balances import drawers.
- **UI:** moved the Date Format selector above the preview and switched
the mapping form from broken Tailwind v4 `space-y`/`space-x` to
`flex`/`gap`.
## Tests
- New `parseFile` regression test: `02/06/2026` stays a string and
parses to June (DD-MM) vs February (MM-DD).
- New `detectDateFormat` tests for the `ambiguous` flag.
- All 39 `file-parser` tests pass; lint/format clean.
## Summary
- Replaces the placeholder landing testimonials with **11 real ones**
sourced from welcome-email replies and the Discord community.
- All testimonials shown in **English** with **Spanish translations**
added to `lang/es.json`.
- Adds a testimonial from Víctor Falcón (co-owner).
## Avatars
- Uses **Gravatar** with `d=404`; on miss, Radix `Avatar` falls back to
**`<Facehash>`** (same config as `user-info.tsx`).
- Privacy: raw emails are **not** stored in the repo — only precomputed
MD5 hashes.
## Layout
- Testimonials render in **two marquee rows**, same direction but with
different speeds + a phase offset so cards don't line up into a grid.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
Standardizes how models are serialized across web, API, and Sync
responses by relying on Eloquent `$hidden` + accessors on the models
themselves, instead of ad-hoc `select` scopes and per-controller field
picking.
Touches 9 models (`Account`, `Bank`, `Budget`, `Category`, `Label`,
`LoanDetail`, `RealEstateDetail`, `Transaction`, plus pivot hiding) and
the controllers/middleware that consumed the old ad-hoc shapes.
## Why
- Single source of truth for response shape lives on the model, aligned
with Wayfinder model typegen.
- Removes duplicated field-selection logic scattered across controllers.
- Continues the duplication-removal PR series (#475–#483).
## How
- Hide internal columns and pivots via `$hidden`; expose computed fields
via accessors.
- Controllers return full models / load full relations rather than
hand-picked columns.
- `HandleInertiaRequests` slimmed down to match.
## Notes for reviewers
- Per-commit breakdown: each model standardized in its own commit for
easy review.
- Tests added/updated for each model to assert the serialized shape
(hidden columns absent, relations present).
- Full suite: 1382 passed / 1 skipped / 0 failed. `pint` clean.
## What
When deleting a transaction that belongs to a **manual**
(non-bank-connected) account, the user can now opt to update the
account's **current balance** for today, so it stays accurate without
re-syncing.
- Deleting an **expense** (amount < 0) → balance **increases** by the
amount.
- Deleting **income** (amount > 0) → balance **decreases** by the
amount.
- Formula: `new_balance = current_balance − transaction.amount`. If
today has no balance row, it's seeded from the latest known balance.
- Available for **single delete** and **bulk delete** (a checkbox in the
confirmation dialog).
- Connected accounts are never touched — their balances come from bank
sync.
- On the account page, the balance display refreshes automatically after
the delete (no page reload).
## How
- New `ManualBalanceAdjuster` service; `TransactionController@destroy`
calls it when the request carries `update_balance` and the account is
manual.
- Frontend `transactionSyncService.delete/deleteMany` accept an
`updateBalance` option; both delete dialogs show the checkbox only when
a manual account is affected.
- `TransactionList` gains an `onBalanceUpdated` callback;
`Accounts/Show` uses it to bump the balance chart's refresh key.
- Added `banking_connection_id` to the accounts payload (transactions,
budgets, and the shared Inertia props feeding accounts/show) so the
frontend can identify manual accounts.
<img width="896" height="400" alt="image"
src="https://github.com/user-attachments/assets/e7f35ccb-13a1-40a1-bf04-615752bddf1a"
/>
## Problem
When the verification email link opens in a browser where the user is
not logged in (common on phones, where the link escapes the app), the
`verification.verify` route's `auth` middleware redirects to login and
the email is never verified.
## Fix
The signed verification URL already self-secures via `id` + `hash` +
signature + expiry, so the `auth` requirement was redundant. This adds a
public, signed-only verify route and points the verification email at
it.
- `Auth/VerifyEmailController` — resolves the user by `id`, validates
`hash` with `hash_equals(sha1(email))`, marks verified and fires
`Verified`. Guests → `login` with a success status; the same logged-in
user → `dashboard?verified=1`.
- New route `GET /verify-email/{id}/{hash}` with `signed` + `throttle`
(name `verification.verify.public`). Distinct URI/name so it doesn't
clash with Fortify's existing auth-protected route, which stays intact.
- `VerifyEmailNotification::verificationUrl()` overridden to sign the
public route.
Now the link verifies regardless of login state, and the user can return
to the app and sign in.
## Tests
- 6 new cases in `EmailVerificationTest` (logged-out verify, logged-in
verify, bad hash, unsigned request, already-verified no refire).
- 1 new case in `VerificationNotificationTest` asserting the email links
the public signed route.
- All pass; pint clean.
Reduce deploy webhook retries to 3 attempts with longer waits so a slow
Coolify rebuild can finish before giving up.
- Attempt 1 → fail → wait 10 min
- Attempt 2 → fail → wait 20 min
- Attempt 3 → fail → give up
Total window ~30 min (was 10 attempts / ~7 min). Connection-level
timeouts (curl exit 28) were exhausting retries before the box came
back.
## Summary
Removes the `CategoryTree` Pennant feature flag so the parent/child
category tree UI is active for all existing and new users.
## Changes
- Delete `app/Features/CategoryTree.php` (flag resolved `false`).
- `CategoryController` — drop `Feature`/`CategoryTreeFeature` imports
and the `categoryTreeEnabled` Inertia prop.
- `parent-category-field.tsx` — remove the `usePage` flag read and `if
(!enabled) return null` gate; the parent field now always renders.
## Testing
- `vendor/bin/pint --dirty` — pass
- `bun run lint` — clean (1 pre-existing unrelated warning in
`chart.tsx`)
- `php artisan test tests/Feature/Settings/CategoryTreeTest.php` — 11
passed
- `php artisan test tests/Feature/Settings/CategoryTest.php` — 32 passed
## What
- Expanding a category on the Sankey chart now **collapses any other
open category**, so only one subcategory column is shown at a time.
- Grow the SVG \`viewBox\` to the real content bounds so tall child
stacks no longer get **clipped above or below** the canvas.
## Why
Multiple simultaneously-expanded categories cluttered the chart, and a
parent with 3+ children produced a stack taller than the canvas — the
top entries were cut off (e.g. "Traveling €2,011" rendered half-hidden).
## Tests
- Added a test asserting opening a second parent collapses the first.
- Existing expand/collapse tests still pass (5/5).
## What
Expand/collapse child categories inline in three places:
- Dashboard → **Top spending categories**
- Cashflow → **Income sources**
- Cashflow → **Expense categories**
## How it looks
<img width="1225" height="928" alt="image"
src="https://github.com/user-attachments/assets/aadce904-bfdd-46eb-b919-8695577845d7"
/>
## Implementation
**Frontend**
- `useExpandableCategories` hook: tracks expanded set, lazy-fetches
children once, caches, resets on period change.
- `AnimatedCollapse` component for the height animation.
- Recursive rows in `BreakdownCard` and `TopCategoriesCard`.
**Backend**
- Extracted `rollUpByTree`/`displayNodeFor` out of
`CashflowAnalyticsController` into `CategoryTree::rollUp` (now shared by
cashflow + dashboard).
- Dashboard `top-categories` emits `has_children`/`is_direct` and
accepts `?parent` to drill into a category's children (children + a
direct "Parent" node), mirroring the existing cashflow breakdown drill.
- Added Spanish translations for the new toggle labels.
## Tests
- Backend: `has_children` true/false + parent-drill split (children +
direct node) for dashboard top categories.
- Component: chevron expands, lazily fetches `parent=…`, renders the
child, flips to "Hide subcategories".
- Full suite green; pint / eslint / prettier clean.
> Note: children fetch lazily on first expand (same pattern as the
Sankey inline expand).
## What
Clicking a parent category in the cashflow **Money Flow** Sankey now
expands its children inline as an extra column branching off the parent
— expenses grow to the right, income to the left — instead of swapping
the whole chart for a drilled-in view.
<img width="782" height="392" alt="image"
src="https://github.com/user-attachments/assets/6ce7d925-19d2-4744-a5da-6884a9583a4d"
/>
## Behaviour
- Toggle: click a parent to expand, click again to collapse. Multiple
parents can be open at once.
- One level deep (a child with its own children is not further
expandable).
- The parent stays as an intermediate node and its flow splits into the
children + a node for transactions booked directly on the parent
(labelled **Parent**).
- Changing the period resets expansions.
## Implementation
- `sankey-chart.tsx`: dynamic column layout (was fixed 3-column); lazily
fetches children via the existing `/api/cashflow/sankey?parent=`
endpoint and caches them; label + amount now render in a `foreignObject`
so flexbox handles spacing, vertical centering, and CSS-ellipsis
truncation (full name kept in a `title`); expand affordance is a
`ChevronsRight` icon.
- Child nodes use the same `MIN_NODE_HEIGHT` and gap as top-level nodes,
centered on the parent so links fan out cleanly.
- Removed the old drill-replace + breadcrumb flow (and the now-unused
`sankeyParent` hook option).
- Backend: the direct-transactions node is renamed from `"<name>
(direct)"` to `"Parent"` (+ `es` translation).
## Problem
The deploy step intermittently fails on CI with only:
```
Response:
HTTP Status: 000
Deployment request failed with status 000
```
`000` means curl never completed a connection — the Coolify box (which
rebuilds the image from git on deploy) has its API unreachable while a
prior build runs. But the step gave no way to confirm that: `-s`
silenced curl's error message and `|| true` swallowed the exit code.
## Changes
- **Surface the real cause** — `-sS` plus `--write-out
%{exitcode}`/`%{errormsg}`/timing, so failures print the curl exit code
(7 refused, 28 timeout, …) and message instead of a blind `000`.
- **Robust parsing** — body/metadata split on an explicit
`===CURL_META===` marker instead of fragile `tail`/`sed` line counting.
- **No false timeouts** — `--max-time` raised 120→300s so a
slow-but-alive box doesn't abort mid-trigger and report a phantom `000`.
- **Wider retry window** — 10 attempts with backoff capped at 60s (~8
min) instead of 5 with a 240s dead sleep, to ride out a multi-minute
rebuild.
- **Fail fast on 4xx** — a bad token/uuid is not retryable, so don't
waste the window on it.
## Note
This makes the trigger robust and observable; the structural fix (let
Coolify pull the prebuilt ghcr image instead of rebuilding on the box)
is out of scope here.
## Summary
Adds nested categories (parent → child, up to **3 levels**) across the
app. Children inherit their parent's type and cashflow direction, and
every category selector now renders the hierarchy as an indented tree.
Gated behind the `CategoryTree` Pennant flag (off by default).
## Backend
- **Migration**: nullable self-referencing `parent_id`; uniqueness
scoped per-parent via a `parent_unique_marker` virtual column (root
names stay unique). New composite unique created before dropping the old
one so the `user_id` FK keeps a supporting index.
- **`CategoryTree` service**: descendant/ancestor resolution, depth &
cycle checks, type cascade, subtree deletion.
- **Validation**: depth limit, cycle prevention, inherited + locked
child type.
- **Delete strategies**: reparent (default), promote to root, or cascade
(uncategorizes affected transactions).
- **Transaction filter** expands a selected parent to its descendants.
- **Cashflow Sankey & breakdown** roll up to top-level parents with
click-to-drill (children + a parent "direct" node).
- **Budgets** tracking a parent also count their children's
transactions.
- **Unified** the frontend category query behind
`Category::forDisplay()` / `FRONTEND_COLUMNS` (8 call sites) so every
selector receives the full Category shape, including `parent_id`.
## Frontend
- New `category-tree.ts` helpers (build/flatten/descendants/path,
tree-aware selection toggle + tri-state).
- **Settings page**: indented tree, sortable by name/color/type
(siblings sorted, hierarchy preserved), parent picker, delete-strategy
dialog.
- **Combobox** (transaction table cell + edit/create modal + parent
picker): indented tree, search keeps matches with their ancestors.
- **Transaction filter**: indented tree, tri-state cascading selection
(parent ↔ children), themed checkboxes, selected branches float to top
on open, ancestor-aware search.
- **Categorizer palette** and **budget multi-select**: same indented
tree + ancestor-aware search.
- **Sankey**: click a parent node to drill into its children, with
breadcrumb.
- Pennant `CategoryTree` flag gates the parent UI.
## Tests
- Pest: model/validation, delete strategies, filter expansion, cashflow
rollup/drill, budget child inclusion.
- Vitest: tree-aware selection logic.
- All green; Pint / ESLint / Prettier clean.
## Rollout
Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
## What
Real-estate detail rule block (~10 fields) was duplicated in **4
requests**; loan detail block in **2**. Variants differ only by
`sometimes` on `property_type` and presence of `revaluation_percentage`.
New `ValidatesAccountDetailRules` trait:
- `realEstateDetailRules(propertyTypeSometimes:, withRevaluation:)`
- `loanDetailRules()`
## Stats
- **-115 / +83 lines**; one source of truth for these field rules
## Checks
- `php artisan test --filter="Account|RealEstate|Loan"` — 355 passed
(1744 assertions)
- `vendor/bin/pint --dirty` — pass
Part of duplication-removal series (#475–#480).
## What
`use-dashboard-data.ts` had a private `formatMonth()` duplicating
`utils/date.ts` `formatMonthFromYearMonth()`.
Removed the local copy; dashboard now uses the shared util.
**Tiny visual change:** non-current-year labels on dashboard net-worth
chart render `Jan '26` instead of `Jan 26` — now consistent with
cashflow-trend and account-balance charts which already use the shared
util.
## Stats
- **-14 / +2 lines**
## Checks
- `bun run test` — 154 passed
- `bun run lint` — clean (1 pre-existing warning)
Part of duplication-removal series (#475–#481).
## What
The "public banks + user's own banks" query was repeated in **5
controllers** (Transaction ×2, Settings/Bank, Budget, Cashflow,
Onboarding).
New `Bank::availableForUser($user)` scope; all 5 sites use it.
Bonus: the Onboarding variant had no `where()` grouping around
`whereNull/orWhere` — harmless today (no other constraints) but a latent
precedence bug. The scope always groups.
## Stats
- **-31 / +24 lines**, 5 call sites → 1 definition
## Checks
- `php artisan test
--filter="Bank|Transaction|Budget|Cashflow|Onboarding"` — 612 passed
(2707 assertions), 1 skipped
- `vendor/bin/pint --dirty` — pass
Part of duplication-removal series (#475–#479).
## What
`StoreCategoryRequest` and `UpdateCategoryRequest` had identical 24-line
`prepareForValidation()` deriving `cashflow_direction` from category
type.
Moved to `app/Http/Requests/Concerns/ResolvesCategoryCashflowDirection`
trait.
## Stats
- **-48 / +43 lines** (net small, removes a whole duplicated logic block
that must stay in sync)
## Checks
- `php artisan test --filter=Categor` — 104 passed (565 assertions)
- `vendor/bin/pint --dirty` — pass
Part of duplication-removal series (#475–#478).
## What
`UpdateAutomationRuleRequest` was **byte-identical** to
`StoreAutomationRuleRequest` (verified with `diff`): same `rules()`,
`passedValidation()`, `withValidator()`.
Now it simply extends the Store request.
## Stats
- **-74 / +1 lines**
## Checks
- `php artisan test --filter=AutomationRule` — 63 passed (211
assertions)
- `vendor/bin/pint --dirty` — applied
Part of duplication-removal series (#475 was first).