## 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
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
## 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
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.
## 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
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 **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
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
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"
/>
## 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).
## 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
`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
Same XSRF-TOKEN cookie-parsing snippet was duplicated inline in **12
files** (some as local `getCsrfToken()` copies, some inline
`decodeURIComponent(...)` blocks).
Extracted to single util: `resources/js/lib/csrf.ts`, with vitest
coverage.
## Why
Part of duplication-removal series — PRs that mostly delete code.
## Stats
- App code: **-89 / +23 lines**
- 12 files now import one shared util
- New: `lib/csrf.ts` (8 lines) + `lib/csrf.test.ts`
## Checks
- `bun run test` — 154 pass (3 new)
- `bun run lint` — clean (1 pre-existing warning in chart.tsx)
- `bun run format` — applied
- `tsc --noEmit` — 59 pre-existing errors on main, same 59 on branch
(none introduced)
## Summary
Adds a **Notifications** section to the account settings page
(`/settings/account`, between Profile information and Update password)
where users can opt out of the daily "new transactions synced" email.
- New per-user preference `notify_on_bank_transactions_synced` on
`user_settings` (defaults to `true`, opt-out).
- `SendDailyBankTransactionsSyncedEmailJob` skips users who disabled it.
- Single generic `PATCH /settings/notifications` endpoint updates any
notification type via a key→column allowlist in
`NotificationPreferenceController::PREFERENCES`. Future notifications
only need a new entry there — no new route/controller.
- Email footer now links back to the settings section so users can
manage preferences.
## Testing
- `tests/Feature/Settings/NotificationPreferenceTest.php` — update,
unknown-key rejection, invalid value, auth, create-when-missing,
default-true, inertia prop.
- `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php` — email
not sent when disabled.
## Summary
Adds **COP** (Colombian Peso) and **DOP** (Dominican Peso) to the
supported currency list for both accounts and user primary currency.
## Compatibility
Both are supported by the conversion API (fawazahmed0 currency-api):
- 1 USD = 3686.83 COP
- 1 USD = 58.74 DOP
No migration needed — `currency_code` columns are plain strings and
validation reads the whitelist from `config/currencies.php`.
## Changes
- `config/currencies.php` — add COP + DOP (primary + account)
- `resources/js/utils/currency.ts` — add `RD$` symbol for DOP
- `tests/Feature/Settings/AccountTest.php` — acceptance tests for both
## Testing
`php artisan test --filter="Colombian|Dominican"` → passing
## Summary
Adds a compact `YYYYMMDD` (no separators) date format to the
transactions and balance importers.
- New `DateFormat.YearMonthDayCompact = 'YYYYMMDD'` enum value
- `parseDate` parses the 8-digit form (`20241231` → `2024-12-31`)
- `autoDetectDateFormat` recognizes it (unambiguous, no separators)
- Both mapping UIs (transactions + balances) expose it as a selectable
option
## Testing
- `bunx vitest run resources/js/lib/file-parser.test.ts` — 34 passed
- Added tests: compact auto-detection + compact conversion
- `bun run format`, `bun run lint` clean
## What
Gate Sentry error reporting on the production environment so nothing is
sent from local, staging, or testing.
- **Backend** (`config/sentry.php`): DSN resolves to `null` unless
`APP_ENV=production`. A null DSN disables the Laravel SDK entirely.
- **Frontend** (`resources/js/app.tsx`): `enabled` now requires
`import.meta.env.MODE === 'production'` (plus a DSN).
## Summary
Budgets previously tracked a single category **or** label (mutually
exclusive). This lets a budget span **multiple** categories and labels
at once, all pooling spend against one allocated amount per period.
Scope decided with the requester:
- **Shared pool** — one allocated amount; any tracked category/label
counts against it.
- **Multi categories + multi labels** on a single budget.
- **Create-only** — tracking is chosen at creation and locked afterward
(edit dialog shows it read-only).
## Changes
**Backend**
- New `budget_category` + `budget_label` pivot tables; data migration
copies existing `category_id`/`label_id` into them, then drops those
columns.
- `Budget` model: `categories()` / `labels()` belongsToMany.
- `BudgetTransactionService` matches transactions across **all** tracked
categories OR labels (live assignment + historical backfill).
- `StoreBudgetRequest` accepts `category_ids` / `label_ids` arrays,
requires ≥1 across both, validates ownership. `update` no longer touches
tracking.
**Frontend**
- Reusable `MultiSelect` (popover + command + badge).
- Create dialog uses multi-selects; cards and show page render tracked
categories/labels as badges; edit dialog shows them read-only.
- Dexie bumped to v10 (drops unused per-category allocations table, adds
`budget_labels`).
## Testing
- Updated all budget/transaction/listener/browser tests for the pivot
model; added cases for multi-category matching, mixed category+label
pooling, and store validation (empty selection + foreign-ownership).
- Added the new `__()` strings to `lang/es.json`.
- Local `pint`, `lint`, `format` pass. Relying on CI for the full suite.
## What
Reworks the two top summary cards on the **Cashflow** page so they no
longer both show net flow.
### Net Cashflow (card 1)
- Shows the net amount **and** its share of income (%).
- Compares **both** the amount and the percentage with the previous
period.
### Saved & Invested (card 2, replaces "Savings Rate")
- Headline = money moved to savings + investment categories (amount).
- Percentage = that total as a share of **net cashflow** (income −
expenses).
- Compares amount and percentage vs. the previous period.
- Keeps the per-category Saved / Invested breakdown at the bottom.
- Adds a **click-triggered help popover** (shadcn `Popover`, works on
touch + desktop, dismisses on outside click / Escape) explaining the
numbers come from transactions using a **"saving"** or **"investment"**
category type.
### i18n
- The card's "Saved" label uses a dedicated key so it renders
**"Ahorrado"** on the cashflow card while form buttons keep
**"Guardado"**. A minimal `lang/en.json` keeps the English label as
"Saved".
- Spanish strings added for the new card + popover copy.
## Testing
- `vitest` — net-cashflow + saved-invested card specs (amount/percentage
comparisons, click-to-reveal popover)
- Full `pest` suite (excl. Browser) — green; localization key-coverage
test passes
- `pint --test`, `prettier --check`, `eslint` — clean
- production `bun run build` — succeeds
## What
Route PostHog through the managed reverse proxy at `t.whisper.money` to
dodge ad blockers and keep analytics first-party.
We use the **JS library method** (`posthog-js` in
`resources/js/lib/posthog.ts`), so only that init needed changes — no
snippet.
## Changes
- `api_host` default → `https://t.whisper.money` (proxy domain)
- Add `ui_host` (default `https://eu.posthog.com`) so dashboard links
resolve back to PostHog correctly
- Both stay env-overridable: `VITE_POSTHOG_HOST`, new
`VITE_POSTHOG_UI_HOST`
- Update `.env.example`
## Test
- `posthog.test.ts` passes
- format + lint clean (one pre-existing unrelated warning)
## Problem
Some users reported they couldn't register: they filled the form
correctly, pressed the button, saw a loading spinner, then got bounced
back to the registration page with **no error message**. Meanwhile 20-30
users register fine every day, and the issue wasn't reproducible by the
team.
## Root cause
The register form sends a **hidden, browser-auto-detected `timezone`**
field (`Intl.DateTimeFormat().resolvedOptions().timeZone`). The backend
validated it with Laravel's `timezone` rule:
```php
'timezone' => ['nullable', 'string', 'timezone'],
```
That rule rejects **legacy IANA aliases** that many browsers/OSes still
emit — e.g. `Asia/Calcutta`, `Asia/Saigon`, `Asia/Rangoon`,
`US/Pacific`. Proof on PHP 8.4:
```
in_array("Asia/Calcutta", DateTimeZone::listIdentifiers()) → false
```
When validation failed, Fortify redirected back with a `timezone` error
— but the form had **no error display** for timezone (every other field
has one). Result: spinner → silent bounce → no message → and the user
can't fix a hidden field anyway. The team couldn't reproduce because
their browsers send canonical zones that pass. Production tzdata may
reject even more zones.
## Fix
- Stop validating the auto-detected hidden field against the strict
timezone list.
- Add `normalizeTimezone()`: keep recognized zones (including BC aliases
via `DateTimeZone::ALL_WITH_BC`), drop anything unrecognized to `null`.
Registration can never be blocked by it again.
- Surface `errors.timezone` in the form as a safety net so a
hidden-field failure can never be silent in the future.
## Tests
- Legacy alias `Asia/Calcutta` → registers, stored as-is.
- Unrecognized zone → registers, stored `null`.
- All existing registration tests pass (13 passed).
## What
- Bump the account page transaction list page size from **10 → 50**
(`Accounts/Show.tsx`).
- Add a **View in Transactions** button next to *Load more* (only on the
account page) that opens the Transactions page pre-filtered to the
current account via `account_ids`.
- Add Spanish translation `View in Transactions` → `Ver en
Transacciones`.
## Notes
- The account list is synced client-side (IndexedDB); `pageSize`
controls how many rows render before *Load more*, so no backend change
needed.
- The Transactions index already defaults to `per_page = 50` and reads
`account_ids` from the query string.
## Testing
- `vitest` Accounts/Show tests pass.
- `bun run format`, `bun run lint`, `bun run build` pass.
## Problem
CSV imports only carry a date (no timestamp). Rows are newest-on-top and
we import top-to-bottom. Three balance bugs:
1. **Same-day balance wrong.** The store loop did `Map.set(date,
balance)` per row, so when a day had 2+ transactions the **last**
(bottom = oldest) row overwrote the newest. The correct daily balance is
the **first** (newest) row.
2. **Zero balance dropped.** The parser guard `row[mapping.balance]`
treated numeric `0` as falsy, so a real `0` balance became `null`. `0`
is valid.
3. **Negative balance lost its sign.** `parseAmount` stripped `-` via
`replace(/[^\d]/g,'')`, so negative balances (and amounts) came out
positive.
## Fix
- `import-transactions-drawer.tsx`: keep the **first** occurrence per
date (`!balancesToImport.has(date)`).
- `file-parser.ts`: guard balance on null/undefined/empty-string only,
so `0` is kept.
- `file-parser.ts`: `parseAmount` now detects a leading `-` (or `(...)`)
and applies the sign — fixes negative balances and negative amounts.
## Tests
Added 4 cases to `file-parser.test.ts`: zero (number), zero (string),
negative, empty→null. Full suite: **144 passed**.
> Note: the same-day "keep first" logic lives in the React component and
relies on `newTransactions` staying in CSV order (top = newest), which
holds in current code.
## Problem
Update button in edit account modal showed hardcoded English (`Update` /
`Updating...`) instead of translated text.
## Fix
Wrap button label in `__()` so it resolves the existing `es.json` keys
(`Actualizar` / `Actualizando...`).
## Test
`edit-account-dialog.test.tsx` passes.
## What
Show the debtor and creditor names (when present) on the categorization
page (`/transactions/categorize`).
## Changes
- `TransactionController@categorize`: select `creditor_name` and
`debtor_name` — they were never sent to the frontend.
- `categorizer-card.tsx`: render Creditor/Debtor rows below the account
when present, using the same i18n labels as the transaction columns.
- `TransactionTest`: assert both names are exposed on the categorize
page.
## Testing
- `php artisan test --filter="debtor and creditor"` — passes.
- pint, format, lint clean.
Swap Discord invite link `discord.gg/2WZmDW9QZ8` →
`discord.gg/m8hUhx6D9D` across all files (README, header, user menu,
connections page, welcome email, test).
## Summary
- Always show a simplified Cashflow and charts explanation in the
category form
- Explain how each category type affects cashflow, income/spending
charts, top spending categories, savings, and investments
- For transfer categories, keep a cashflow chart visibility selector so
users can choose hidden/inflow/outflow
- Show savings and investment categories as outflows in the cashflow
chart and store default/new categories as outflow
- Add a new migration to update existing production savings/investment
categories to outflow
- Avoid user-facing Sankey terminology and update Spanish copy to use
dinero/saldo instead of efectivo
- Add missing Spanish translations and browser/feature coverage
## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
- php artisan test --compact
tests/Feature/Console/ResetUserCategoriesCommandTest.php
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter="migration updates existing default saving and investment
categories|migration sets saving and investment categories to cashflow
outflow|users can create savings and investment categories|default
categories are created when user registers"
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
--filter="sankey includes savings and investment categories on the
expense side|sankey includes outflow transfer categories on the expense
side"
- php artisan test --compact tests/Browser/CategoriesTest.php
--filter="explains savings and investment category cashflow impact in
the create dialog|can create a new transfer category with a cashflow
analytics direction"
- php artisan test --compact tests/Browser/CategoriesTest.php
- ./vendor/bin/pest --testsuite=Performance --filter="account show page
does not exceed query threshold"
- vendor/bin/pint --dirty --format agent
- npx prettier --write
resources/js/components/categories/category-cashflow-direction-fields.tsx
- npm run build (assets emitted; Sentry sourcemap upload reports local
SSL error)
## Video
- screenshots/category-create-cashflow-setting.webm
## Notes
- npm run types still fails on existing unrelated TypeScript errors.
## Summary
- Move Community link from sidebar footer into user dropdown above
Feedback.
- Hide empty sidebar footer nav group.
- Add tests for dropdown order and footer removal.
## Tests
- `npm test -- --run resources/js/components/user-menu-content.test.tsx
resources/js/providers/menu-item-provider.test.ts`
Note: `npm run types` currently fails on unrelated existing TypeScript
errors.
## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns
## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/OpenBanking/TransactionSyncServiceTest.php
tests/Feature/TransactionFilterTest.php
- npm test -- resources/js/lib/file-parser.test.ts --run
- vendor/bin/pint --dirty --format agent
Note: `npm run types` still has pre-existing unrelated errors; no
creditor/debtor/import-related errors remained in filtered output.
## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>
## Summary
- add disclaimer for locked period and carry-over budget fields
- cover disclaimer rendering with Vitest
## Tests
- npm run test --
resources/js/components/budgets/edit-budget-dialog.test.tsx
## Summary
- translate cashflow period type labels during render
- keep Spanish labels as Mes / Trimestre / Año
- add regression coverage for period label translation
## Tests
- npx eslint resources/js/components/cashflow/period-navigation.tsx
resources/js/components/cashflow/period-navigation.test.tsx
- npx vitest run
resources/js/components/cashflow/period-navigation.test.tsx
## Summary
- stack cashflow header on mobile
- make period type and period selector rows full width on mobile
## Tests
- npx eslint resources/js/pages/cashflow/index.tsx
resources/js/components/cashflow/period-navigation.tsx
## Summary
- add paywall settings CTA for onboarded users with ended canceled
subscriptions and bank connections
- keep onboarding and normal unpaid connected users out of this escape
path
- cover paywall props and connection settings access
## Tests
- php artisan test --compact tests/Feature/SubscriptionTest.php
- npx eslint resources/js/pages/subscription/paywall.tsx
## Summary
- keep Stripe past_due subscriptions active during retry window
- share payment issue state with Inertia and show persistent toast
- send toast action directly to Stripe Billing Portal
- allow canceled users to fall back to free plan, while paid-only bank
connection access remains blocked
- add Spanish translations for new payment issue messages
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SubscriptionTest.php
tests/Feature/InertiaSharedDataTest.php
- php artisan test --compact tests/Feature/LocalizationTest.php
- npm test -- subscription-payment-issue-toast
Note: npm run types still fails on pre-existing unrelated TypeScript
errors.
## Summary
- fix budget spending chart today marker on first period
- use local date keys for budget chart dates
- add today marker tests
## Tests
- npm test --
resources/js/components/budgets/budget-spending-chart.test.ts
- npm run types (fails: existing unrelated TypeScript errors)
## Why
Some banks return transactions without a balance column. The CSV/Excel
importer happily creates the transactions but leaves the account without
any balance history, making the account look incomplete.
## What
Opt-in flow that derives per-day balances from the transactions
themselves, using a single reference point (the balance on the date of
the latest transaction).
### Mapping step
- New checkbox **"Calculate balances from transactions"** next to the
Balance column select.
- Available only when no balance column is mapped (disabled + visually
faded otherwise).
- When checked, a balance input appears labeled with the latest
transaction date using a relative format:
- `Today`
- `Yesterday`
- `Monday, 3 of Jun`
- If an `AccountBalance` already exists for that date, it is pre-filled
automatically (no need to ask) with a small hint.
- The reference balance is **mandatory** when the checkbox is checked;
the Next button is disabled until it is provided.
### Preview step
- A new `Balance` column shows the balance about to be created for each
date, regardless of whether it came from the file or was calculated.
### Persistence
- Balances are computed by walking backwards across distinct transaction
dates and subtracting each day's net movement.
- They flow through the existing import path that POSTs to
`AccountBalanceController@store`, so no new endpoint was needed.
## Feature flag
Hidden behind a new Pennant feature, **off by default**:
```bash
php artisan feature:enable CalculateBalancesOnImport user@example.com
# or
php artisan feature:enable CalculateBalancesOnImport all
```
The flag is exposed to the frontend via Inertia shared props
(`features.calculateBalancesOnImport`).
## Tests
- New vitest cases for `getLatestTransactionDate` and
`calculateBalancesFromTransactions` (sparse dates, reference date
without txns, empty list).
- `LocalizationTest` passes (new ES strings added).
- Full vitest suite green (106 tests).
## Summary
- remove CoinbaseIntegration Pennant feature flag
- always show Coinbase in open banking institution picker
- drop shared Coinbase feature flag type
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/InertiaSharedDataTest.php
- php artisan test --compact
tests/Feature/OpenBanking/CoinbaseControllerTest.php
- npx eslint
resources/js/components/open-banking/connect-account-inline.tsx
resources/js/components/open-banking/connect-account-dialog.tsx
resources/js/types/index.d.ts
Note: npm run types still fails on existing project-wide Wayfinder/type
issues.
## Summary
- only auto-select the sole import account during onboarding
- hide the upload-step Back button after onboarding auto-select
- add component coverage for account selection and hidden back button
## Tests
- npm test --
resources/js/components/transactions/import-step-account.test.tsx
resources/js/components/transactions/import-step-upload.test.tsx
- npx eslint
resources/js/components/transactions/import-step-account.tsx
resources/js/components/transactions/import-step-upload.tsx
resources/js/components/transactions/import-transactions-drawer.tsx
resources/js/components/onboarding/step-import-transactions.tsx
resources/js/lib/import-config-storage.ts resources/js/types/import.ts
resources/js/components/transactions/import-step-account.test.tsx
resources/js/components/transactions/import-step-upload.test.tsx
## Notes
- npm run types still fails on existing unrelated project-wide
TypeScript errors.
## Summary
- format imported dates from local date parts instead of UTC ISO strings
- add regression coverage for Europe/Madrid date imports
## Tests
- npm test -- resources/js/lib/file-parser.test.ts
## Summary
- show Automatize toast after category assignment in categorizer,
transactions table, and edit modal
- add modal flow for creating a new automation rule or updating an
existing one
- extract shared automation rule form and add rule helper tests
## Verification
- npm run test -- resources/js/lib/rule-builder-utils.test.ts
- npx eslint changed frontend files
- npm run build (app compiled; Sentry sourcemap upload failed with TLS
error)
https://github.com/user-attachments/assets/1f7d90e8-520a-4eaf-9fc4-4124adb848a0
## Summary
- Add yearly budget period option on backend and frontend
- Generate yearly budget periods for Jan 1 through Dec 31
- Add feature coverage for yearly budget creation and period generation
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/BudgetPeriodServiceTest.php
tests/Feature/BudgetTest.php
## Notes
- npm run types fails on existing unrelated TypeScript errors: missing
generated Wayfinder modules and pre-existing type mismatches
## Sentry
- Issue: PHP-LARAVEL-1T
- URL: https://whisper-money.sentry.io/issues/PHP-LARAVEL-1T
## Root cause
Cashflow breakdown UI assumed every API row has a category object.
Production returned a breakdown row with `category: null`, so rendering
read `item.category.icon` and crashed.
## Fix
- Allow breakdown rows to carry nullable category/category_id.
- Render null categories as Uncategorized with HelpCircle/gray fallback.
- Add focused Vitest regression.
- Enable Wayfinder Vite plugin in Vitest so components importing
generated actions resolve in tests.
## Verification
- `npm test -- resources/js/components/cashflow/breakdown-card.test.tsx`
- `npx prettier --write
resources/js/components/cashflow/breakdown-card.tsx
resources/js/components/cashflow/breakdown-card.test.tsx
resources/js/hooks/use-cashflow-data.ts vitest.config.ts`
- `npx eslint resources/js/components/cashflow/breakdown-card.tsx
resources/js/components/cashflow/breakdown-card.test.tsx
resources/js/hooks/use-cashflow-data.ts vitest.config.ts --fix`
## Notes
- `npm run types` currently fails on existing unrelated TypeScript
errors across account, transaction, chart, auth, crypto, and dexie
files.
## Sentry issue
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1S
## Root cause
- Browser/page translation mutated React-managed DOM on /onboarding by
injecting font wrappers. React later attempted to unmount a node that
translation had moved, causing NotFoundError removeChild.
## Fix
- Mark the Inertia root document as not translatable with
translate="no", notranslate class, and google notranslate meta tag.
- Add regression coverage for the root template markers.
## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## Sentry
- Fixes PHP-LARAVEL-1Q
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1Q
## Root cause
- Service worker registration promise rejected in production and had no
rejection handler.
- Browser reported unhandled promise rejection: Error: Rejected.
## Fix
- Add a catch handler to service worker registration so
unsupported/intercepted registration failures do not become unhandled
global errors.
- Add regression coverage asserting registration has rejection handling.
## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## Summary
- reload once when a stale Vite dynamic import chunk fails
- suppress recoverable chunk load failures from Sentry
- add Vitest coverage for recovery and Sentry filtering
Fixes PHP-LARAVEL-1P
## Tests
- npm run test -- resources/js/lib/chunk-load-recovery.test.ts
resources/js/lib/sentry.test.ts
- npm run types *(fails: existing TypeScript errors outside this
change)*
## Summary
- keep newly created labels available in the combobox without reload
- propagate created labels through transaction forms and bulk actions
- add browser coverage for creating a label from transaction label
dropdown
## Tests
- php artisan test --compact tests/Browser/TransactionsTest.php
--filter='newly created labels'
- vendor/bin/pint --dirty --format agent
- npm run build
Note: `npm run types` still fails on pre-existing TypeScript errors
outside this change.
## Summary
- hide connected-account plan warning and price after the user chooses
connected setup once
- seed onboarding state from existing connected accounts
- add hook unit coverage and browser coverage for warning/price
visibility
## Tests
- bun run test -- resources/js/hooks/use-onboarding-state.test.tsx
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter="hides the connected plan warning"
- npm run types -- --pretty false 2>&1 | rg
"resources/js/(components/onboarding/step-create-account|hooks/use-onboarding-state|pages/onboarding/index)"
(no onboarding errors; repo has unrelated existing TS errors)
## Why merge this
Production bug
**[PHP-LARAVEL-1B](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1B)**
— `/budgets/{budget}` throws a 500 (`UniqueConstraintViolationException`
on `budget_periods_budget_id_start_date_unique`) for the affected user.
Page is unusable for them until fixed.
## Root cause
The Custom period type let users create budgets with arbitrary
`period_duration`. Combined with `calculatePeriodDates()`'s day-of-month
snap (`startDate->day(period_start_day)`), short durations produced
periods where `end_date == start_date == period_start_day`. On the next
visit after that day, regeneration computed a new period whose start
snapped backward to the same day → unique key collision.
Concrete trace from the Sentry event:
- Budget: `Seguro de Salud`, `period_type=custom, period_duration=1,
period_start_day=1`, created `2026-05-05`.
- Initial period created at budget creation: `start=2026-05-01,
end=2026-05-01` (already 4 days stale because Custom snapped
`now()=05-05` back to day 1, then `addDays(1).subDay()` produced same
date).
- Today `05-05` → `getCurrentPeriod()` returns null → `generatePeriod()`
snaps back to `05-01` again → `INSERT (budget_id, 05-01)` → 1062.
Verified in prod DB: this is the **only** budget in the entire database
with `period_type='custom'` and the only period row with `end_date <=
start_date`.
## Fix
Custom is the only period type that exposes this snap-back collision
(Monthly/Weekly/Biweekly all use a self-consistent advance). It also has
no defensible UX — every legitimate use case is covered by
Monthly/Weekly/Biweekly. So we remove it end-to-end:
- `BudgetPeriodType::Custom` enum case dropped.
- `BudgetPeriodService`: Custom branches removed from
`calculatePeriodDates()` and `calculateNextPeriodStartDate()`.
- `Budget` model: `period_duration` removed from `$fillable` and
`casts()`.
- `StoreBudgetRequest` / `UpdateBudgetRequest`: `period_duration` rule
removed.
- `BudgetController`: `period_duration` no longer passed in
store/update.
- Create + edit budget dialogs: Custom option and `period_duration`
input removed.
- `ResetDemoAccountCommand`: Custom switch case removed.
- `BudgetFactory`: `custom()` state and `period_duration` default
removed.
- `types/budget.ts`: `'custom'` removed from `BUDGET_PERIOD_TYPES` and
`Budget.period_duration` field dropped.
- Migration `2026_05_05_132023_convert_custom_budgets_to_monthly`:
rewrites any existing `custom` rows to `monthly` with
`period_duration=null, period_start_day=1` so the dropped enum case
can't crash on hydration.
Period generation logic for Monthly/Weekly/Biweekly is **unchanged**.
## Manual prod cleanup (separate, after merge)
The single buggy budget in prod will be deleted manually:
```sql
DELETE FROM budget_periods WHERE id = '019df7fd-6073-731b-9c08-f3723515292b';
DELETE FROM budgets WHERE id = '019df7fd-6071-7219-b190-ece22ccdb63f';
```
## Tests
`tests/Feature/BudgetPeriodServiceTest.php` covers Monthly + Weekly
advance and Monthly first-period generation. Existing
`BudgetPeriodDateTest` unaffected.
## Risk
- `period_duration` column kept in DB (nullable) to avoid data loss; no
migration needed.
- Migration ensures any environment with `period_type='custom'` rows is
converted before the enum case is removed, preventing hydration errors.
- No customers actively rely on Custom: prod query confirmed only the
single broken budget uses it, and that one is being deleted.
Fixes PHP-LARAVEL-1B
## Problem
Production SSR crashes repeatedly when rendering the Onboarding page:
```
ReferenceError: window is not defined
at Onboarding (/app/bootstrap/ssr/assets/index-BEtdcHXd.js:2295)
```
The `useMemo` reading `?step=` from the URL accessed
`window.location.search` directly, which fails during Inertia SSR.
Observed ~7+ times in production logs between 18:35–21:37 UTC.
## Fix
Add `typeof window === 'undefined'` guard before reading the URL.
Returns `undefined` on the server so the step initializer falls back to
default behavior; client hydration re-runs and reads the URL.
## Related logs
- `Inertia\Ssr\SsrException` — Onboarding page
- Other SSR-touching files (`welcome.tsx`, `auth/login.tsx`) verified
safe (window inside `useEffect` or already guarded).
## Summary
Wires up the launch flow: each waitlist lead gets a per-cohort
invitation email, a personal single-use Stripe promo code matching their
reward, and a signed landing link that unlocks register/install.
## Cohorts (resolved at send-time, by queue rank ASC, ignoring
`position` null/0)
| Cohort | Rule | Reward | Stripe coupon |
|---|---|---|---|
| `founder` | ranks 1–10 | Free forever | `wm_founder_forever` (100%
off, forever) |
| `founder_referrer` | referred any current founder (overrides rank) |
Free forever | `wm_founder_forever` |
| `early_bird` | ranks 11–100 | 2 mo free monthly / 3 mo free yearly
first year | `wm_earlybird_monthly` (100% off, 2 mo, monthly only) +
`wm_earlybird_yearly` (25% off once, yearly only) |
| `waitlist` | ranks 101+ | same as early bird | same coupons |
## What's added
- **DB**: `cohort`, `promo_code_monthly`, `promo_code_yearly`,
`invitation_sent_at` on `user_leads`.
- **Services**: `LeadCohortResolver`, `LeadPromoCodeAllocator`.
- **Commands**:
- `php artisan stripe:ensure-launch-coupons` — idempotent Stripe coupon
setup (run once per env).
- `php artisan leads:send-invitations --limit=N [--cohort=…] [--dry-run]
[--force]` — wave-by-wave delivery, idempotent across runs
(`invitation_sent_at` gate). Lazily generates Stripe promo codes per
lead it touches.
- **Checkout**: `SubscriptionController::checkout` resolves the auth
user's `UserLead` by email and applies the matching promo code
(`monthly`/`yearly`) via Cashier's `withPromotionCode()`.
- **Invite link**:
`LandingAuthOverrideService::generateInvitationUrl($leadId, days: 30)` —
signed lead-bound URL that unlocks auth buttons (existing override
cookie) and stores `invited_lead_id` in session. The register view
prefills the email from the lead.
- **Emails**: 4 cohort markdown templates (`founder`,
`founder-referrer`, `early-bird`, `waitlist`) with per-cohort subject +
body. Locale set from `$lead->preferredLocale()`. 28 Spanish keys added.
## Tests (Pest)
- `LeadCohortResolverTest` — boundaries + founder-referrer override +
null/0 position skip.
- `SendUserLeadInvitationsTest` — batch ordering, idempotent across
runs, ignores null/0, persists cohort.
- `UserLeadInvitationTest` — per-cohort body content, signed lead-bound
signup URL, Spanish locale.
- `LandingAuthOverrideTest` — invitation URL unlocks + stores session
lead.
Full suite green (1262 passed).
## Launch checklist
1. Merge + deploy.
2. `php artisan stripe:ensure-launch-coupons` against staging Stripe →
verify in dashboard → run on prod.
3. Optional preflight: `php artisan leads:send-invitations --limit=10
--dry-run`.
4. Wave-by-wave: `php artisan leads:send-invitations --limit=50` per
day.
5. Confirm `HIDE_AUTH_BUTTONS=true` on prod so only signed invite links
unlock register/install.
## Notes / non-goals
- No `User → UserLead` foreign key — checkout matches by email per
request.
- Existing `FOUNDER` promo flow is untouched and still kicks in if a
user has no lead-specific code.
- `founder_referrer` cohort is empty in current prod data; logic is in
place for when founders start referring.
Real estate account chart now appends 12 months of projected data when
the property has a revaluation percentage and/or a linked loan:
- Market value projected forward via the configured revaluation rate
(compounded monthly).
- Mortgage balance projected via LoanAmortizationService on the linked
loan, so the mortgage decline is visible alongside the property
revaluation in the same chart.
Backend: DashboardAnalyticsController appends projected points with
display-currency variants. Frontend: chart adds dashed projection lines
for value and mortgage_balance plus a Today reference line. Tooltip and
currency-mode swap propagate the new projected_mortgage_balance field.
## Summary
Adds a dashed 'Today' vertical reference line on the account balance
chart when a loan amortization projection is rendered. Makes it obvious
which months on the chart are historical (carry-forward of the last
recorded balance) and which are future projections.
## Context
Loan accounts extend their balance-evolution series with 12 months of
projected amortization values (see
`DashboardAnalyticsController::accountBalanceEvolution`). Without a
marker, past carry-forward months and future projected months looked
like a single continuous history.
## Changes
- `resources/js/components/accounts/account-balance-chart.tsx`
- Import `ReferenceLine` from recharts.
- Compute `todayMarker` (`yyyy-MM` monthly, `yyyy-MM-dd` daily).
- Render `<ReferenceLine>` inside the projected `ComposedChart` branch.
- Styling matches the budget spending chart (1px dashed, foreground
stroke, 'Today' label on top).
## Screenshots
_Visual change only — renders a dashed vertical line at the current date
on loan account charts with projection._
## Summary
Follow-up to #317. Portaled tooltip uses `position: fixed`; on scroll it
stayed anchored to stale viewport coords.
- Listen to `scroll` (capture) and `resize`, flip a `hidden` flag to
hide the tooltip.
- Reset flag whenever Recharts reports a new `coordinate` (pointer back
on a bar).
- Replace `visibility` binary toggle with `opacity` + 120ms transition
so the whole tooltip (bg, border, text) fades as one layer instead of
revealing staggered unmount order.
## Test plan
- Hover a bar → tooltip fades in at cursor.
- Scroll the page while hovering → tooltip fades out smoothly, whole
element at once.
- Hover another bar after scrolling → tooltip reappears.
## Problem
On mobile, the sparkline tooltip on the dashboard account cards covered
almost the entire card, making it very hard to dismiss — you had to tap
very specific spots inside the card to clear it.
## Fix
Listen for `pointerdown` events outside the chart wrapper and unmount
the Recharts `<Tooltip>` to dismiss it. Because only the Tooltip is
toggled (not `<Line>`), there's no redraw animation.
Gated behind `matchMedia('(hover: hover)')` so desktop hover behavior is
left alone — tooltip still appears on hover without any click needed.
Works across multiple cards: tapping one dismisses tooltips on all
others.
## Summary
- Portal chart tooltip to `document.body` to escape ancestor
`overflow-hidden` (CardContent, scroll container). Positioned via
Recharts' `coordinate` prop + `.recharts-wrapper` viewport rect, with
viewport-edge flipping.
- Cap tooltip width and truncate long account/liability names with
ellipsis; values stay nowrap.
- Force `grid-cols-[minmax(0,1fr)]` on tooltip grid tracks so `truncate`
actually engages (grid items default to min-width auto).
## Test plan
- Hover net-worth chart: tooltip no longer clipped by container.
- Position follows cursor; flips near viewport edges.
- Long liability/account names show ellipsis within tooltip bounds.
## Summary
- soft-delete users by adding `deleted_at` to `users`
- rename deleted user emails with a timestamp prefix so original email
can be reused
- block email sends and banking follow-up work for deleted users while
preserving data
## Testing
- php artisan test --compact tests/Feature/DeleteUserCommandTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php
tests/Feature/Console/SendUpdateEmailCommandTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- redirect guest access to protected routes based on returning-user
cookie
- send new PWA guests to registration and returning guests to login
- persist returning-user cookie after register, login, and 2FA login
- keep explicit login links working via `force=1`
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/Auth/AuthenticationTest.php
tests/Feature/DashboardTest.php tests/Feature/Auth/RegistrationTest.php
## Summary
- add signed landing links that unlock auth buttons while
HIDE_AUTH_BUTTONS is enabled
- persist the unlock in a secure cookie so desktop and installed PWA
users can still sign up
- add artisan command to generate signed landing auth links and test
coverage for the flow
## Testing
- php artisan test --compact tests/Feature/LandingAuthOverrideTest.php
tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php
tests/Feature/Auth/RegistrationTest.php
- php artisan test --compact tests/Feature/WelcomeBanksOrderingTest.php
tests/Feature/SubscriptionTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- remove the `real-estate` Pennant flag definition, request gating,
local auto-activation middleware, and frontend shared flag plumbing
- make real-estate account creation and onboarding availability the
default path for all users
- update feature and browser tests to cover default availability instead
of flag enable/disable behavior
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/RealEstateTest.php
- php artisan test --compact
tests/Feature/RealEstateAvailabilityTest.php
- php artisan test --compact tests/Browser/RealEstateAccountTest.php
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
- php artisan test --compact --filter=\"can create a real estate account
linked to an existing loan\" tests/Browser/BankAccountsTest.php
## Notes
- `npm run build` completed the client build, but the Sentry sourcemap
upload step failed with an SSL/TLS error from the configured plugin
- deploy follow-up: run `php artisan pennant:purge real-estate` to
remove stale stored flag values
## Summary
- allow `/register?force=1` to bypass the hidden-auth redirect and
render the registration page
- preserve the `force=1` query on form submit so hidden signup still
works end-to-end
- enforce hidden-signup blocking in the Fortify user creation path and
cover the forced/unforced flows with tests
## Summary
- Fixes
[PHP-LARAVEL-9](https://whisper-money.sentry.io/issues/PHP-LARAVEL-9):
`ReferenceError: window is not defined` during Inertia SSR of
`CashflowPage`.
- `CashflowController` now reads + validates the `period` query param
(`YYYY-MM`) and passes it as an Inertia prop.
- Client `useState` initializer reads from `usePage().props.period`
instead of `window.location.search`, making SSR safe and avoiding
hydration mismatch.
- `useEffect` URL sync now compares against the prop, not
`window.location.search`.
## Why (c) not a `typeof window` guard
Server-provided params avoid hydration mismatch and keep URL as single
source of truth.
## Tests
New `tests/Feature/CashflowPageTest.php`:
- guest redirect
- no query param → `period` prop `null`
- valid `?period=2025-03` → prop `'2025-03'`
- invalid value → `null`
- malformed format (`2025-3`) → `null`
All 5 pass (50 assertions).
## Scope
Cashflow only. Other pages with similar `window.location.search` in
`useState` initializers (`onboarding/index.tsx`, `welcome.tsx`,
`auth/login.tsx`) left for follow-up.
Fixes PHP-LARAVEL-9
## Summary
- remove the Pennant-based `open-banking` flag and middleware gating so
open banking is always available for authenticated users
- simplify landing, onboarding, settings, and subscription flows to use
always-on open-banking behavior and remove stale frontend/shared flag
plumbing
- update open-banking tests and purge stored `open-banking` Pennant rows
## Testing
- `php artisan test --compact
tests/Feature/OpenBanking/InstitutionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BinanceControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BitpandaControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/ConnectionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact
tests/Feature/WelcomeBanksOrderingTest.php`
- `vendor/bin/pint --dirty --format agent`
## Notes
- `php artisan pennant:purge open-banking` was run to remove stale
stored values
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php`
still has one unrelated real-estate onboarding browser failure (`it
creates a real estate account during onboarding when feature is
enabled`)
## Summary
- store browser-detected IANA timezones on users during registration and
share the value to the frontend
- silently backfill missing timezones for authenticated users without
overwriting existing saved values
- add focused feature coverage for registration, shared props, and the
timezone backfill endpoint
## Testing
- php artisan test --compact tests/Feature/Auth/RegistrationTest.php
tests/Feature/InertiaSharedDataTest.php
tests/Feature/Settings/TimezoneTest.php
- npm test -- resources/js/utils/currency.test.ts
## Summary
- add month-end command to revoke old Enable Banking sessions for users
without a paid plan while keeping their linked accounts as manual
accounts
- extract banking disconnect flow into a reusable action so manual
disconnects and scheduled cleanup share same revoke and detach behavior
- add feature coverage for free-user cleanup, paid-user skips,
under-6-hour skips, non-Enable Banking skips, and revoke failure
fallback
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php
tests/Feature/OpenBanking/ConnectionControllerTest.php
## Summary
- update subscription pricing to the final release amounts for monthly
and annual billing
- keep billing UI formatting aligned with the configured currency so
1.99 €/month displays correctly
- refresh subscription and Stripe sync tests to lock the new values in
place
## Summary
- **Auto-calculates Annual Revaluation %** (CAGR) on the frontend when
purchase price, purchase date, and current market value are all provided
— pre-fills the revaluation field while still allowing manual override
- **Generates historical monthly balance records** via linear
interpolation from purchase date to today when creating a real estate
account with complete purchase data
- New `RealEstateBalanceGeneratorService` handles balance generation
with dates on the 1st of each month (matching the existing
`ApplyRealEstateRevaluationCommand` convention)
## Changes
### Backend
- **`app/Services/RealEstateBalanceGeneratorService.php`** (new) —
Linear interpolation service that builds balance dates (purchase date,
1st of each intermediate month, today) and creates `AccountBalance`
records using `updateOrCreate`
- **`app/Http/Controllers/Settings/AccountController.php`** — Calls the
service after real estate detail creation when all three inputs are
present
### Frontend
- **`resources/js/components/accounts/account-form.tsx`** — Added CAGR
auto-calculation via `useEffect` with a `useRef` flag
(`isRevaluationManuallySet`) to avoid overwriting manual user input
### Tests
- **`tests/Feature/Services/RealEstateBalanceGeneratorServiceTest.php`**
(new) — 7 unit tests covering interpolation, edge cases (same-day
purchase, flat values, single month spans)
- **`tests/Feature/RealEstateTest.php`** — 6 new feature tests covering
historical balance generation through the controller (with/without
purchase data, same-day, flat values)
All 38 tests in `RealEstateTest.php` and all 7 service tests pass.
## Summary
- gate waitlist signup behind email confirmation so only verified leads
receive a queue position and referral code
- add signed lead verification routes, a check-email page, and a
dedicated verification email for the waitlist flow
- update lead syncing and feature tests so only verified leads are
exported to Resend and referral movement happens after verification
## Testing
- php artisan test --compact tests/Feature/UserLeadTest.php
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
- php artisan test --compact tests/Feature/MailSenderTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- add safe-area CSS variables for the viewport top and bottom insets
- apply safe-area-aware collision padding to the shared popover
primitive so iOS overlays stay below the notch
- add a focused regression test for the popover safe-area guard
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PopoverSafeAreaTest.php
## Summary
- switch the iOS standalone PWA status bar style from
`black-translucent` to `default`
- keep installed iOS app content below the status bar and notch area
- extend the PWA template test to guard against regressing to
translucent overlap
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## Summary
- move the top safe-area inset from the mobile header to the shared
authenticated content shell
- keep scrolling content on iOS sidebar pages from sliding under the
notch
- add a focused regression test for the shared layout safe-area
placement
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/AuthenticatedLayoutSafeAreaTest.php
## Summary
- add an optional linked property selector when creating loan accounts
in both the main create dialog and onboarding
- validate that only the current user's unlinked real-estate accounts
can be selected and persist the reciprocal link after loan creation
- expose linked property state in shared account props and cover the new
flow with focused loan feature tests
## Testing
- php artisan test --compact tests/Feature/LoanTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- default the onboarding account setup choice to the connected Standard
option when open banking is available
- change the Standard onboarding card and connected-bank step accents
from blue to emerald for consistent plan styling
- update the onboarding browser test comment to reflect the new default
selection
## Verification
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php` *(3
unrelated existing failures in browser assertions)*
## Summary
- update the create account modal copy to explicitly mention loans and
property accounts
- clarify the manual option so users understand it also covers manually
created real-estate accounts
- update the browser test assertion to match the revised dialog copy
## Summary
- add real estate to the onboarding account type explainer and update
investment copy to mention crypto and cold wallets
- align onboarding manual account creation with the real-estate feature
flag and real-estate payload requirements
- cover the onboarding copy and real-estate creation flow with browser
tests
## Testing
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
## Summary
- add a hamburger icon to the mobile settings dropdown trigger so it
reads more clearly as navigation
- reduce the mobile spacing between the settings subtitle and the
dropdown trigger
- keep the desktop settings sidebar unchanged
## Testing
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
## Summary
- add an Appearance shortcut to the user dropdown between Privacy mode
and Settings
- use the existing appearance route so users can jump straight to
`/settings/appearance`
- add a focused frontend test covering the new menu item and its order
## Summary
- default account charts to the user's currency when a currency toggle
is available
- reverse the account chart currency toggle order so the user currency
appears first
- add a focused frontend test covering the toggle order and selected
state
## Testing
- npm test -- chart-currency-toggle
## Summary
- keep two decimal places in net worth and account chart amounts instead
of rounding to whole units
- update shared chart tooltip, trend tooltip, and MoM tooltip currency
formatting so related chart values stay consistent
- add targeted formatter coverage for chart-style balances with decimals
## Testing
- npm test -- resources/js/utils/currency.test.ts
## Summary
- update mail signatures to show Álvaro before Víctor across the Blade
mail templates
- add a focused mail test that guards the signature order so the old
ordering does not reappear
- verified with `php artisan test --compact
tests/Feature/MailSenderTest.php`
## Summary
- **Store `invested_amount` in user's currency** (e.g. EUR) instead of
the account's currency (e.g. BTC), since it represents "how much of my
money did I put in" — a concept tied to the user's home currency.
- **Flip `display_invested_amount`** in API responses: now converts from
user currency → account currency (previously account → user), for the
chart's account-currency toggle mode.
- **Remove redundant conversions** in `AccountMetricsService` since
invested amounts are already in user currency after sync.
## Changes
### Backend (5 files)
- **BinanceBalanceSyncService** — convert to
`$account->user->currency_code` instead of `$account->currency_code`
- **BitpandaBalanceSyncService** — same currency target change
- **IndexaCapitalBalanceSyncService** — inject
`CurrencyConversionService`, convert invested amount float to user
currency before storing as cents
- **DashboardAnalyticsController** — flip `display_invested_amount`
conversion direction (user→account) in both monthly and daily endpoints
- **AccountMetricsService** — remove 4 now-redundant invested_amount
conversion spots (values already in user currency)
### Frontend (6 files)
- **update-balance-dialog.tsx** — invested amount input uses user
currency
- **balances-modal.tsx** — invested amount display and edit uses user
currency
- **account-balance-chart.tsx** — currency toggle: user-currency mode
keeps invested_amount as-is; account-currency mode swaps to
`display_invested_amount`
- **import-balances-drawer.tsx** — passes `investedAmountCurrencyCode`
(user currency) to sub-components
- **import-balance-step-mapping.tsx** /
**import-balance-step-preview.tsx** — format invested amounts in user
currency
### Tests (2 files)
- **IndexaCapitalBalanceSyncTest** — use `app()` instead of `new`
(constructor now has DI), pin account currency to USD for deterministic
assertions
- **DashboardAnalyticsTest** — add EUR-based exchange rate, flip
assertion from `/ 0.90` to `* 0.90`
## Summary
- Makes `bank_id` nullable for all account types in both
`StoreAccountRequest` and `UpdateAccountRequest` (previously it was only
nullable for real estate)
- Shows the bank combobox field for all account types in the
`AccountForm` component, including real estate
- Removes the `required` attribute from the hidden bank input and the
logic that cleared bank selection when switching to real estate type
## Test changes
- Updated "validates required fields" test to no longer expect `bank_id`
as a required field
- Added "can create a new account without a bank" test in `AccountTest`
- Updated `RealEstateTest` to verify non-real-estate accounts can also
be created without a bank (was previously asserting the opposite)
## Summary
- Adds the missing `:8000` port to the frontend Sentry/Bugsink DSN in
`app.tsx`, so errors are sent to the correct endpoint at
`bugsink.whisper.money:8000`.
## Summary
- **Bug fix:** Dashboard and accounts index cards displayed the
account's original currency code (e.g. `BTC`) even though balances were
already converted to the user's currency (e.g. `EUR`). Now passes
`displayCurrencyCode` to card components so the label matches the
converted amount.
- **Feature:** Added a currency toggle on the account detail chart to
switch between the account's native currency and the user's main
currency. Extends the balance evolution APIs with `display_*` fields
when conversion applies.
- **First-account restriction:** Restricts first-account creation to
primary (fiat) currencies only, ensuring the user's base currency is
always widely supported.
## Changes
### Bug fix — currency label on cards
- `AccountBalanceCard` / `AccountListCard`: Added `displayCurrencyCode`
prop; all amount renders now use it instead of `account.currency_code`
- `dashboard.tsx`: Passes `netWorthEvolution.currency_code` as
`displayCurrencyCode`
- `Accounts/Index.tsx`: Passes `auth.user.currency_code` as
`displayCurrencyCode`
### Backend — API extension
- `DashboardAnalyticsController`: Both `accountBalanceEvolution()` and
`accountDailyBalanceEvolution()` now return `display_value`,
`display_invested_amount`, `display_mortgage_balance` per data point and
a top-level `display_currency_code` when the account currency differs
from the user's
### Frontend — currency toggle
- New `ChartCurrencyToggle` component with `ToggleGroup` showing
currency code labels (e.g. `BTC` / `EUR`)
- `ChartSettingsPopover`: Extended with optional `currencyToggle` prop
for mobile
- `AccountBalanceChart`: Full integration — all amounts, trends,
tooltips, MoM chart, and equity swap to `display_*` values when toggle
is set to user currency
### First-account currency restriction
- `StoreAccountRequest`: First account limited to primary currency codes
- `AccountForm` / `CreateAccountDialog` / `StepCreateAccount`: Pass
`usePrimaryCurrenciesOnly` when applicable
### Tests
- 5 new Pest tests for `display_*` fields in balance evolution endpoints
- 3 new Vitest tests for `deriveAccountMetrics` (mixed-currency, empty
data, invested amounts)
- 1 new Pest test for first-account BTC rejection
## Summary
- Removes the `account-mapping` Pennant feature flag entirely, making
the account mapping flow (pending accounts data + map-accounts page) the
default and only code path
- Removes the old direct-creation branches from all banking controllers
(Authorization, Bitpanda, Binance, IndexaCapital)
- Extracts a shared `CreatesAccountsFromPending` trait for the
onboarding auto-create logic
- Updates all controller tests to reflect the always-on mapping behavior
## Changes
### Backend
- **AppServiceProvider** — removed `account-mapping` flag definition
- **AuthorizationController** — removed flag check +
`createAccountsFromSession()` dead code; always stores
`pending_accounts_data` and redirects to mapping
- **BitpandaController / BinanceController / IndexaCapitalController** —
removed flag checks, old inline account creation, and unused imports
- **HandleInertiaRequests / ActivateDevelopmentFeatures /
ResolvesFeatures** — removed `account-mapping` from flag arrays
- **New `CreatesAccountsFromPending` trait** — shared auto-create logic
for onboarding path (to be consumed next)
### Frontend
- Removed `'account-mapping'` from the TypeScript `Features` interface
### Tests
- Merged flag-specific test variants into single always-on tests
- Removed redundant tests that tested the old disabled-flag code path
- Updated assertions to check `pending_accounts_data` instead of direct
account creation
## Summary
- **Clickable toggle rows**: The full row in the chart settings popover
now toggles the checkbox, not just the checkbox itself.
- **Chart stacking order**: When toggling "Include loans" or "Include
real estate", the chart now re-mounts so accounts appear in the correct
sorted position (biggest at bottom) instead of being appended on top.
- **Scheme-aware liability dot**: The red liability indicator dot in the
chart tooltip now respects the user's chart color scheme (neutral, blue,
pink) instead of always using the destructive/red color.
Extend the dashboard to hide linked loan accounts and display merged
real estate cards showing equity as the primary balance, a dual-line
sparkline (solid market value, dashed mortgage owed), 'Mortgage at'
subtitle with bank logo, and a rich tooltip with Market Value,
Mortgage Owed, and color-coded Equity.
Combine real estate and linked loan accounts into a unified card on the
accounts list, showing equity as the primary balance with a dual-line
sparkline (solid for market value, dashed for mortgage owed). Hide linked
loans from the loan group and display mortgage bank info in the subtitle.
On the detail page, render LoanDetailsCard for real estate accounts with
linked loans and add header actions for editing loan details and updating
owed amounts via dialogs.
## Summary
- **Market Value**, **Mortgage Owed**, and **Equity** sparkline cards
and the main real estate balance chart now respect the user's selected
chart color scheme (neutral, blue, pink, colorful) instead of hardcoding
colorful-only colors (amber-500, emerald-500).
- Added `mortgageLineColor` and `equityLineColor` to the
`useChartColors()` hook so all real estate chart components derive
colors from a single source of truth.
- Updated `RealEstateTooltipContent` and the `ComposedChart` in
`AccountBalanceChart` to use the hook colors as well.
## Summary
- Adds loan detail tracking (interest rate, term, start date, original
amount) to loan-type accounts, following the existing
`real_estate_details` pattern
- Implements `LoanAmortizationService` with standard amortization math
to automatically project month-to-month balance evolution
- Shows projected future balances as a dashed line on the account
balance chart, visually distinct from historical data
- Adds a scheduled command (`loans:generate-balances`) to auto-generate
monthly balance entries for loan accounts
- Includes 36 tests (10 unit for pure math, 26 feature for CRUD, API
projections, command, and authorization)
## Changes
### Backend
- **Migration**: `loan_details` table (UUID PK, unique `account_id` FK,
interest rate, term, start date, original amount)
- **LoanDetail model** + factory with `Account::loanDetail()` HasOne
relationship
- **LoanAmortizationService**: `calculateMonthlyPayment`,
`calculateRemainingBalance`, `generateProjection`, `projectFromBalance`,
`calculateRemainingMonths`, `getBalanceAtDate`
- **GenerateMonthlyLoanBalances** command: runs monthly on 1st, skips
existing entries
- **LoanDetailController**: PATCH endpoint for editing loan details from
show page
- **Settings\AccountController**: creates/updates `LoanDetail` on
store/update
- **DashboardAnalyticsController**: appends projected data points with
`projected: true` flag
- **Validation**: loan-specific rules in StoreAccountRequest and
UpdateAccountRequest
### Frontend
- **AccountForm**: loan fields (rate, term, start date, original amount)
shown when `type === 'loan'`
- **Create/Edit dialogs**: pass loan data fields in POST/PATCH payloads
- **Show page**: `LoanDetailsCard` component with edit/read modes,
computed monthly payment and remaining months
- **Balance chart**: new `ComposedChart` branch with dashed `Line`
overlay for projected data points
- **Types**: `LoanDetail` interface in `account.ts`
## Summary
- Allow setting the **current market value** (balance) when creating a
real estate account — the balance field existed for other account types
but was hidden for real estate
- Add an **annual revaluation percentage** field to real estate
accounts, editable from both the creation form and the property details
card on the account show page
- Create a scheduled command (`real-estate:apply-revaluation`) that runs
monthly on the 1st to automatically adjust property values by
`revaluation_percentage / 12 / 100`
## Details
### Backend
- Migration adds `decimal('revaluation_percentage', 5, 2)` nullable
column to `real_estate_details`
- Validation: nullable numeric, range -100 to +100 (negative for
depreciation)
- `ApplyRealEstateRevaluationCommand` finds all accounts with a
non-null/non-zero percentage, gets the latest balance, and upserts the
new balance for today
- Scheduled `->monthlyOn(1, '00:00')` in `routes/console.php`
### Frontend
- Added `real_estate` to `BALANCE_ACCOUNT_TYPES` so the Market Value
field appears during creation
- Added Annual Revaluation (%) input to both the creation form and the
property details card
- Read-mode displays formatted percentage with sign (e.g.,
"+3.50%/year")
### Tests
- 6 new tests in `RealEstateTest.php` covering creation with balance,
revaluation %, negative %, validation, PATCH update, and clearing to
null
- 8 tests in `ApplyRealEstateRevaluationTest.php` covering
positive/negative revaluation, skip conditions, latest balance usage,
multiple accounts, and upsert behavior
## Summary
- Add mortgage balance as a dashed line overlay on the property value
chart when a real estate account has a linked loan
- Display equity (market value - mortgage owed) in the chart header and
as a 3-column summary strip (Market Value | Mortgage Owed | Equity) in
the property details card
- Extend balance evolution API endpoints to include `mortgage_balance`
data points for linked loan accounts
- Add 6 new tests covering mortgage/equity display in account show page
and balance evolution endpoints
## Summary
- Adds **real estate** as a new account type (`real_estate`) for
tracking property assets within the existing Accounts page
- Properties store metadata (type, address, purchase price/date, area,
notes) in a dedicated `real_estate_details` table with a one-to-one
relationship to accounts
- Properties can be **linked to a loan account** (mortgage) to
implicitly calculate equity — property value counts as asset (+), linked
loan counts as liability (-)
- Market value is tracked as the account balance and uses "market value"
terminology throughout the UI
## What's included
### Backend
- `PropertyType` enum (Residential, Commercial, Land, Vacation, Other)
- `RealEstateDetail` model, factory, migration, policy
- `StoreRealEstateDetailRequest` and `UpdateRealEstateDetailRequest`
form requests
- `RealEstateDetailController` with `update()` for editing property
details
- Extended `Settings\AccountController@store` to create
`RealEstateDetail` when type is `real_estate`
- Extended `AccountController@show` to load real estate detail, linked
loan with bank info, and available loan accounts
- Extended `AccountController@index` SQL ordering to include
`real_estate`
- Conditional validation rules in `StoreAccountRequest` for real estate
fields
### Frontend
- New `real_estate` type in `account.ts` with `PropertyType`,
`AreaUnit`, `RealEstateDetail` interface, and helper functions
- Conditional real estate fields in `account-form.tsx` (property type,
address, purchase price, purchase date, area, linked loan, notes)
- `PropertyDetailsCard` component in `Accounts/Show.tsx` with view and
inline edit modes
- "Update market value" terminology in balance update buttons for real
estate accounts
- `real_estate` added to account type ordering and groups on the Index
page
- Wayfinder routes regenerated
### Tests
- 22 feature tests covering creation, validation, show page, index
ordering, updating details, IDOR protection, model relationships, and
soft delete behavior
- Unit test updates for `AccountType` enum (`reducesNetWorth`,
`isNonTransactional`)
### Translations
- 32 new Spanish translation strings for all real estate UI
## Design decisions
- Real estate accounts are **non-transactional** (like
investment/retirement) — balance-only tracking for now. Future iteration
will link transactions directly for rental income/expense tracking
- **Single loan per property** via direct FK from
`real_estate_details.linked_loan_account_id`
- No encryption on address/notes fields (opted out per discussion)
- No separate sidebar page — real estate is a grouped section within the
existing Accounts page
## Summary
- Rework the neutral (default) chart color scheme for light mode to
improve contrast distribution and visual variety
- Adjusts `--chart-1` through `--chart-9` zinc shade assignments for
better differentiation between chart segments
## Summary
- Adds an optional balance input field to the account creation modal for
**investment**, **loan**, **retirement**, and **savings** account types.
- When a balance is provided, an `AccountBalance` record is created for
today's date upon account creation.
- The balance label adapts to the account type (e.g., "Owed Amount" for
loans, "Balance" for others).
## Changes
- **`account-form.tsx`** — Shows `AmountInput` when a balance-tracking
type is selected and currency is set.
- **`create-account-dialog.tsx`** — Passes `balance` in the POST payload
when present.
- **`StoreAccountRequest.php`** — Adds optional `balance` (nullable
integer) validation.
- **`AccountController::store`** — Creates an `AccountBalance` record
when balance is provided.
- **`AccountTest.php`** — 3 new tests covering creation with balance,
without balance, and invalid balance validation.
## Screenshots
<img width="1017" height="649" alt="image"
src="https://github.com/user-attachments/assets/4bf1530e-0faf-45a4-a3d1-d0ec49d5b412"
/>
## Summary
Transfer categories with `cashflow_direction` set to `outflow` or
`inflow` now appear in the **Sankey diagram** instead of the 12-month
trend chart. This gives a clearer picture of money flow (e.g.
investments, savings transfers) without inflating the income/expense
trend lines. Hidden transfers remain excluded from everything.
## Changes
- **Sankey endpoint**: Updated join to include tracked transfer
categories on the matching flow side (outflow → expense side, inflow →
income side)
- **Trend endpoint**: Removed `transfer_inflow` and `transfer_outflow`
columns from the SQL query and API response
- **Trend chart UI**: Removed tracked inflow/outflow bars, tooltip rows,
legend items, and related config
- **TypeScript types**: Removed `transfer_inflow`/`transfer_outflow`
from `TrendDataPoint`
- **Category form**: Updated direction field description to reference
"Sankey chart" instead of "monthly trend"
- **Tests**: Updated 4 existing tests and added 2 new tests covering
tracked transfers in Sankey breakdown
## Design decision
Tracked transfers appear **only in the Sankey diagram** — they do not
affect summary totals, savings rate, or breakdown cards. This keeps the
high-level numbers accurate while still visualizing where money flows
internally.
## Summary
- Makes Income Sources and Expense Categories rows on the Cashflow page
clickable, navigating to the Transactions page pre-filtered by category
and date range
- Follows the exact same pattern as the dashboard's Top Spending
Categories card (`top-categories-card.tsx`)
- Fixes progress bar hover visibility by adding the `group` Tailwind
class to the link wrapper
## Changes
- **`resources/js/components/cashflow/breakdown-card.tsx`**: Added
optional `period` prop; each category row renders as an Inertia `<Link>`
with `category_ids`, `date_from`, and `date_to` query params when period
is provided; falls back to plain `div` otherwise
- **`resources/js/pages/cashflow/index.tsx`**: Passes `period` to both
`BreakdownCard` instances (income and expense)
- **`tests/Browser/CashflowCategoryNavigationTest.php`**: Browser tests
verifying clicking a category navigates to `/transactions` with the
correct query params
## Summary
- guard the shared label combobox against missing `value` and `labels`
props so account transaction tools cannot crash while data is still
loading
- wire account transaction bulk label actions to pass label data through
consistently and keep optimistic updates in sync
- add regression coverage for the combobox normalization helper and the
account show payload shared props
## Summary
- replace direct `web-haptics/react` imports with a local hook that
manages the `WebHaptics` instance safely in-app
- keep existing haptic triggers working across the sidebar, mobile back
button, connect account flow, and transaction categorizer
- add focused Vitest coverage for the wrapper lifecycle and trigger
proxying
## Testing
- npm run test -- resources/js/hooks/use-web-haptics.test.tsx
- npm run lint -- resources/js/hooks/use-web-haptics.ts
resources/js/hooks/use-web-haptics.test.tsx
resources/js/components/app-sidebar.tsx
resources/js/components/nav-main.tsx
resources/js/components/mobile-back-button.tsx
resources/js/components/open-banking/connect-account-inline.tsx
resources/js/pages/transactions/categorize.tsx
- npm run build
## Summary
- Corrects the `/accounts/{id}` endpoint to `/accounts/{id}/details` in
`EnableBankingProvider`.
- Gracefully handles 404 responses (expired/revoked sessions) during
IBAN backfill, skipping them without counting as failures.
- Updates the \"Re-evaluate All Expenses\" UI label to \"Update
categories automatically\" (EN) / \"Actualizar categorías
automáticamente\" (ES).
## Summary
- Sorts stacked chart accounts by descending average balance across all
data points, so the largest accounts sit at the bottom of the chart and
smaller ones appear on top.
- Uses the per-account average (rather than any single period's balance)
to guarantee a consistent ordering across every month/day in the chart —
no mixing of order between periods.
- Applies to both monthly (`StackedBarChart`) and daily
(`StackedAreaChart`) granularities since they share the same `dataKeys`.
## Summary
- Add `reauthorize` endpoint and reconnect detection in OAuth
`callback()` so users can re-authorize a revoked EnableBanking session
without losing their accounts or transaction history
- Replace "Retry" with "Reconnect" button (dropdown + error panel) for
EnableBanking authentication errors; catch-up sync of missed
transactions is handled automatically by the existing
`SyncBankingConnectionJob` via `last_synced_at`
- Add 5 missing Spanish translations (`Reconectar`, `Autenticación
fallida...`, `Error al iniciar la reautorización.`, `Error al
reconectar...`, `Cuenta bancaria reconectada exitosamente.`) and wrap
the reconnect flash message in `__()`
- 7 new Pest tests covering all reauthorize scenarios and the reconnect
callback path (15 total passing)
## Summary
- When a user already has a connection to a bank/provider, that
institution is now hidden from the list when they open the \"Connect
Bank\" dialog
- Applies to all providers: EnableBanking institutions (matched by
`aspsp_name`), Binance, Bitpanda, and Indexa Capital (matched by
`provider`)
- `ConnectAccountDialog` accepts a new `connections` prop (passed from
the connections settings page where it's already available);
`ConnectAccountInline` (used in onboarding) accepts an optional
`connections` prop defaulting to `[]`
## Tests
- Added 2 backend tests to `ConnectionControllerTest` verifying the data
contract: the connections page sends `provider` and `aspsp_name` fields
for all provider types, which the frontend filtering logic depends on
## Summary
- Adds `whitespace-normal` to the description column's cell class to
override the base `TableCell`'s `whitespace-nowrap`, which was
preventing `max-width` from being respected in HTML auto table layout
- Reduces the description column max-width from unlimited (`max-w-none`)
to `300px` at `md` breakpoint and `250px` at `sm`, eliminating
horizontal scroll at ~1220px viewport widths
## Summary
- Adds a **Monthly / Yearly** billing period toggle to the pricing
section on the welcome page, shown only when the `open-banking` feature
flag is active and both monthly and yearly plan variants exist in the
pricing config.
- Defaults to **Yearly**; the Yearly button displays a dynamically
computed **Save X%** badge (currently 50%, derived from actual plan
prices).
- Plan cards filter to the selected billing period; the Free plan card
is always visible regardless of the selected period.
- Enables `open-banking` for unauthenticated landing page visitors so
the richer variant of the page (bank connections section + pricing
toggle) is always shown.
## Summary
- Adds a **Connect Your Banks** feature card (2-col wide) shown only
when the `open-banking` Pennant flag is active
- When **open-banking is ON**: row 1 = Connect Your Banks (col-span-2) +
Import in Seconds (col-span-1), row 2 = three feature cards
- When **open-banking is OFF**: row 1 = three feature cards, row 2 =
Import in Seconds (full width, unchanged behaviour)
- **Cashflow at a Glance** moved from its own standalone section into
the feature grid as a full-width row 3 (always visible)
https://github.com/user-attachments/assets/5151c141-60e2-4117-b1f2-629f2ef4c9ed
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
- add a new `banks:check-logos` console command that validates all
non-null bank logo URLs weekly
- set broken/invalid bank logos to `null` and send an admin report email
to `ADMIN_EMAIL` when updates occur
- add weekly scheduling, admin mail config wiring, and feature tests for
valid/broken/head-fallback flows
## Testing
- vendor/bin/pint --dirty
- php artisan test tests/Feature/Console/CheckBankLogosCommandTest.php
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>