Add a user-defined account order, shared between the dashboard and the
accounts page, persisted server-side.
- accounts table gets a `position` column, backfilled per user from the
previous type/name ordering so existing layouts are preserved
- both views order by `position` and expose a drag handle (GripVertical)
via a shared SortableGrid component built on @dnd-kit
- pointer drag starts after a small move; touch drag starts on a long
press, so quick swipes still scroll on mobile (single-column there)
- the accounts page is now a flat list (type grouping dropped) so the
order matches the dashboard exactly
- PATCH accounts/reorder persists the order; reorder is optimistic and
avoids refetching the deferred dashboard metrics
## Summary
Removes the `ManageBankAccounts` Pennant feature flag that gated the
per-connection "Manage Accounts" surface to the admin user, making the
flow available to all users.
## Changes
- Delete the `App\Features\ManageBankAccounts` feature class.
- `ConnectionAccountController`: drop the `ensureFeatureEnabled()` gate
(3 call sites + method) and the `Feature`/`ManageBankAccounts` imports.
Access stays guarded by `authorizeConnection()` (connection ownership).
- `HandleInertiaRequests`: stop sharing the `manageBankAccounts` Inertia
flag.
- Frontend: remove the flag from the `Features` type, the
`features.manageBankAccounts` conditional in `connections.tsx` (now
gated solely by `canManageAccounts`), and the now-unused `features`
destructure.
- Tests: update `InertiaSharedDataTest` and the `connections.test.tsx`
mock; simplify the `adminUser()` helper to `onboardedUser()` in
`ConnectionAccountTest` and remove the "forbidden when feature disabled"
test.
## Testing
- `vendor/bin/pint --dirty` — pass
- `php artisan test tests/Feature/OpenBanking/ConnectionAccountTest.php
tests/Feature/InertiaSharedDataTest.php` — 17 passed
- `bunx vitest run resources/js/pages/settings/connections.test.tsx` —
pass
- `bun run lint` / `bun run format` — clean
## Why
Transactions and account names used to be stored encrypted client-side.
They now live **unencrypted in the backend**, so the browser no longer
needs to encrypt, decrypt, or mask them. A handful of legacy accounts
may still have encrypted transactions, but the auto-run
decrypt-migration handles those — so this PR strips the now-dead
encryption machinery while keeping the migration path fully intact.
Net: **−1213 / +176** lines across 25 files.
## Removed (dead machinery)
- `setup-encryption` page, `POST /api/encryption/setup`,
`SetupEncryptionRequest`, and `encrypt()` / `generateSalt()` — new data
is never encrypted again
- `EncryptedText` and the decrypt-on-render description component →
replaced by a plaintext `TransactionDescription` that keeps the
privacy-mode fake labels
- All `isKeySet`-gated client decryption: transaction list (load /
search / sort), categorize flow, import dedup
- `isKeySet` masking / locking on amount display, app logo, import
button, add-transaction button, account page, and filters
- The unused `?encrypted=false` API filter and `isKeyPersistent()`
## Kept (legacy decrypt-migration)
- The two migration hooks, the unlock dialog, and the dashboard/login
unlock prompt
- `decrypt` / `importKey` + key derivation, key-storage,
`EncryptedMessage`, salt, `GET /api/encryption/message`,
`?encrypted=true`, and the middleware salt-cleanup
- `rule-engine` / edit / import-drawer account-name decryption —
plaintext-first (`if (!account.encrypted) return name`) legacy fallback
on the kept primitives; intentionally untouched
## Testing
- Pint ✅ · ESLint ✅ · Prettier ✅
- 184 vitest tests ✅
- 1425 PHP Feature/Unit tests ✅ (Browser suite not run locally — needs
`bun run build`; no Browser test references the encryption UI)
## Follow-up (out of scope)
`DecryptedTransaction.decryptedDescription` / `decryptedNotes` are now
just plaintext aliases. Collapsing that type touches 13+ files (rule
engine, categorizer, edit dialog) and was left as a separate cleanup.
## What
In the add-transaction modal, the **Update account balance** checkbox
now defaults to **on**. An explicit opt-out is still remembered in
`localStorage`, so users who turned it off keep their preference.
Balance updates also move from the client to the backend, mirroring the
existing delete flow
(`ManualBalanceAdjuster::reverseDeletedTransaction`).
## How the balance is applied
When the toggle is on, `TransactionController::store` calls the new
`ManualBalanceAdjuster::applyCreatedTransaction`, which adjusts the
balance on the **transaction's own date**:
- A balance already on that date → increased by the transaction amount.
- No balance on that date but an earlier one exists → a new balance for
that date is created from the **closest earlier balance** plus the
amount.
- No prior balances at all (first transaction on the account) → the
balance is set to the transaction amount.
Connected accounts are skipped, since their balances come from bank
sync.
## Why
The previous client-side flow fetched all balances, computed from the
**latest balance overall** (not the transaction's date), and stored via
a separate request. This replaces those three round-trips with one
atomic server-side write and fixes the wrong base balance.
## Tests
- Feature tests for all three balance cases plus the not-requested and
connected-account guards (`tests/Feature/TransactionTest.php`).
- Frontend test asserting the toggle is checked by default in create
mode.
Full suite green locally: Pest (1690 passed), vitest (221 passed), Pint,
ESLint, Prettier.
## What
Forces users who have accepted AI consent to choose a plan, the same way
connecting a bank already does.
## Why
AI suggestions are a paid-plan feature (`PlanFeature::AiSuggestions`),
and the onboarding notice tells the user *"AI suggestions are a Standard
Plan feature. You'll choose a plan at the end of the onboarding."* But
that was never enforced: `EnsureUserIsSubscribed` only gated on bank
connections, never on AI consent. A user who accepted AI without
connecting a bank saw the paywall once, got `paywall_seen_at` marked,
and then fell through to **free** access — making the notice a false
promise.
## Change
- `EnsureUserIsSubscribed`: a non-Pro user with an **active AI consent**
is now kept on the paywall on every request, exactly like a
bank-connected user (added `&& ! $user->hasActiveAiConsent()` to the
free-access branch).
- `SubscriptionController::index`: `canUseFreePlan` is now `false` when
the user has an active AI consent, so the paywall stops offering the
free option to these users (keeps page and middleware consistent).
- Revoking AI consent (`hasActiveAiConsent()` → false) restores
free-plan access — a clean escape hatch.
Onboarding itself is unaffected: the onboarding / AI-consent /
rule-suggestion routes live in the `auth,verified` group **without** the
`subscribed` middleware, so consenting mid-onboarding does not lock the
user out of finishing. The paywall only kicks in afterward, when
accessing the app — which is the intended behavior and matches the
notice.
## Tests
4 new tests in `SubscriptionTest` (forced to paywall even after seeing
it; `canUseFreePlan` false; subscribed users with consent still get
access; revoking consent restores free access). Full `SubscriptionTest`
green (38 passed). `pint` clean.
## ⚠️ Behavior change for existing users
This applies retroactively. From prod, **34 users currently have an
active AI consent**; any of them on the free plan today (no bank,
`paywall_seen_at` set) will be redirected to the paywall after deploy
and must subscribe or revoke consent. If we want to grandfather existing
consenters and only enforce this going forward, that needs an extra
condition (e.g. consent recorded after a cutoff date) — flag me and I'll
add it.
## Why
During the bank connection flow (Enable Banking) a user picks which
accounts to sync and can skip the rest. Until now there was **no way
back**: a skipped account couldn't be enabled later, a synced account
couldn't be moved or turned off, and accounts the bank added afterwards
were invisible. Skipped accounts aren't persisted anywhere —
`pending_accounts_data` is cleared once mapping completes — so the app
simply forgets they exist.
This adds a per-connection **Manage Accounts** screen that closes that
gap.
## What
- **Known accounts render from the DB** (no provider call). For each
synced account the user can:
- **Stop syncing** → the account is unlinked and becomes a regular
manual account. Non-destructive: it keeps all its imported transactions.
- **Change destination** → move syncing to another compatible manual
account (unlinks the previous holder, links the new one with incremental
sync).
- **Load accounts** button re-fetches the consent's account list from
the provider *on demand* (`getSession` + `getAccount` only for uids we
don't already know) to surface newly available / previously skipped
accounts, which can then be created as new accounts or linked to
existing manual ones.
- The provider is **only ever hit on refresh**; every mutation is
DB-only, so the screen still works (read + rearrange) when the consent
has expired.
### Design notes
- No new column / cache: the default view derives entirely from the
`accounts` table, and refresh is on-demand and ephemeral. Re-fetching is
mandatory anyway (existing connections have no stored snapshot, and only
a live call can reveal accounts the bank added later).
- Scoped to **Enable Banking** for now; the approach is column-free so
extending to the crypto/API-key providers later is trivial.
## Endpoints
- `GET open-banking/connections/{connection}/accounts` — manage screen
(`discoveredAccounts` computed only when `?refresh=1`)
- `POST open-banking/connections/{connection}/accounts/map` — create /
link / change destination for one bank account
- `POST open-banking/connections/{connection}/accounts/{account}/unlink`
— stop syncing
## Testing
- New `ConnectionAccountTest` (7 tests): index props, refresh discovery
(excludes already-synced uids), create,
change-destination/unlink-previous, non-destructive unlink keeps
transactions, ownership 403, account-mismatch 404.
- Full `tests/Feature/OpenBanking` suite green (268), plus pint, eslint,
prettier and the enforced `es` localization test.
## Deliberately skipped
- **Subscription gating** on refresh/map — matches the currently ungated
`disconnect`. Easy follow-up if free users shouldn't trigger live
provider calls.
- Other providers (Enable Banking only, as agreed).
## What
Prevent adding or modifying votes on requests marked as **not-doable**.
- **Backend** (`removeVote`): a vote cast before a request was marked
not-doable could still be removed, mutating a frozen tally. It now
returns `404`, mirroring `vote()` which already rejected not-doable
requests.
- **Frontend**: hide the unvote (chevron-down) button for not-doable
requests; the vote button was already disabled for them.
## Test
Added a feature test: vote → request becomes not-doable → `DELETE` vote
returns `404` and the tally is unchanged.
## Summary
- Raise the monthly integration-action quota for paying users: free plan
keeps **3** actions, pro plan gets **9** (resolved via
`User::hasProPlan()`, so self-hosted instances with subscriptions
disabled get the full quota).
- Votes are no longer toggles. Each vote spends one action and pushes
the request up the board, so a user can back the same integration
multiple times. The `(integration_request_id, user_id)` unique index is
dropped (with a standalone FK index added first, since MySQL relied on
the unique one).
- Let users undo a vote, **but only one cast in the current month**. The
refund maps back to the current quota and previous months' tallies stay
locked. The board exposes `can_unvote` and shows a `ChevronDown` control
next to the upvote button, backed by a new `DELETE
/integration-requests/{id}/vote` route.
## Tests
- Feature tests for the free (3) and pro (9) quotas, repeated voting up
to the limit, undoing a current-month vote (and recovering the action),
and the previous-month vote being non-removable.
- Updated the board fixture and added the `Remove one vote` Spanish
translation.
## What
- **Markdown comments**: admin comments on the integration board now
render as markdown (`react-markdown` + `remark-gfm`) instead of plain
text. Links open in a new tab; blockquotes, lists and bold are styled.
Comments are admin-only (set via CLI), so the content is trusted.
- **`in_progress` status**: new status, visible to everyone and votable
like `approved`, with an optional public comment. Lets the admin signal
an integration is actively being built.
- **`integration-requests:review` rework**:
- Any decision can now move a request to any status (approve / in
progress / reject / not doable), not just the pending ones.
- New `--all` flag prints the full list with a `#` column so the admin
can pick a request by number and change its status.
- `not doable` requires a comment; `in progress` allows an optional one;
approve/reject clears any stale public comment.
## New dependencies
- `react-markdown`, `remark-gfm` (approved with the author).
## Tests
- 24 feature tests passing, incl. `--all` status change, `in_progress`
visibility + voting, and updated review-command option sets.
- Frontend vitest covering markdown rendering (link + blockquote).
## Summary
Adds a `not_doable` status to integration requests for cases we've
reviewed but can't implement (e.g. no public API).
- **Applied with a comment** — the `integration-requests:review` command
now offers a `not doable` choice that requires a comment explaining why.
The comment is stored on the request and shown to users.
- **Shown at the end** — not-doable requests stay visible to everyone
but sink to the bottom of the board regardless of their votes, with the
comment rendered below the name.
- **Not votable** — voting is blocked both in the UI (disabled button)
and the backend (404), even for the author.
- **Rejected stays hidden** — rejected requests never appear in the list
(unchanged behaviour, now covered by a test).
## Changes
- `IntegrationRequestStatus` enum: new `NotDoable` case + label.
- Migration: nullable `comment` text column on `integration_requests`.
- `IntegrationRequestController`: list includes not-doable for everyone,
ordered last; vote guard rejects not-doable.
- `ReviewIntegrationRequestsCommand`: `not doable` option with a
required comment prompt.
- Board component: not-doable badge, comment, disabled voting.
- Factory `notDoable()` state, `es.json` translation, feature tests.
## Testing
- `php artisan test tests/Feature/IntegrationRequestTest.php` — 21
passed
- `vendor/bin/pint --dirty`, `bun run format`, `bun run lint` — clean
## What
The user matching \`ADMIN_EMAIL\` curates the integration-requests
board, so they get special treatment:
- **No monthly cap** — \`actionsRemaining()\` reports a full quota for
the admin, so neither the backend checks nor the frontend buttons ever
gate them.
- **Auto-approved proposals** — requests created by the admin are stored
as \`approved\` instead of \`pending\`, skipping moderation.
## How
- New \`User::isAdmin()\` helper, mirroring \`isDemoAccount()\`,
comparing against \`config('mail.admin_email')\` (already wired to
\`ADMIN_EMAIL\`).
- \`IntegrationRequestController::actionsRemaining()\` early-returns the
limit for the admin.
- \`IntegrationRequestController::store()\` sets \`status\` to
\`Approved\` for the admin.
## Tests
- Added a feature test covering the admin bypassing the monthly limit
and auto-approval. All 16 \`IntegrationRequestTest\` tests pass.
## What
A community board where users **propose** a bank/service (name + link)
and **vote** on the integrations they want most, so we can prioritise by
real demand.
## How it works
- **Global board**: a shared list sorted by votes desc. `has_voted` and
the monthly quota are per user.
- **Limit**: 3 combined actions (proposal + vote) per user per calendar
month. Creating a proposal **auto-votes** it → costs 2 actions.
- **Moderation**: proposals start as `pending` (visible only to their
author, with a "Pending review" badge); `approved` ones are visible to
everyone. You can't vote on what you can't see (404). The `php artisan
integration-requests:review` command approves/rejects pending ones.
- **Access**:
- Bottom drawer (Vaul, ~95vh) that opens the same board.
- Entry points: the **connect-bank** dialog (bank-selector step), the
**create-account** dialog, and a global **"Request integration"** item
in the user menu (above Support) → available on any screen.
- The `/integration-requests` URL renders the **dashboard** with the
drawer opened on top (behind auth + verified + onboarded + subscribed).
- **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and
Interactive Brokers as `approved` and self-voted by the earliest-created
user (idempotent, no-op when no users exist).
## Endpoints
- `GET /integration-requests` → dashboard + drawer.
- `GET /integration-requests/data` → board state as JSON (feeds the
drawer).
- `POST /integration-requests` → create a proposal (+ auto-vote).
- `POST /integration-requests/{id}/vote` → toggle vote.
## Tests
- 15 Feature tests (access, status visibility, monthly limit, auto-vote,
vote toggle, review command) plus the user-menu tests. All green;
`pint`/`eslint`/`prettier`/`tsc`/PHPStan clean.
## Notes / follow-ups
- No Pennant flag (ships to everyone). No dedup of duplicate proposals
(moderation covers it).
- Seeding 5 integrations to one user uses up that user's monthly quota
(only affects that account via the UI).
## What
Unifies keys that were repeated across the codebase into two PHP enums
as the single source of truth. Two independent commits:
### `refactor(banking)` — `BankingProvider` enum
- New `App\Enums\BankingProvider` (`indexacapital`, `binance`,
`bitpanda`, `coinbase`, `wise`, `enablebanking`).
- `BankingConnection`'s `provider` column is cast to the enum → magic
strings gone from the model, controllers (`match`), requests, jobs,
commands, factory and `where('provider', ...)`.
- Logic that was duplicated, now on the enum:
- `usesApiKey()` — API-key auth (everything except EnableBanking).
- `defaultAccountType()` — provider → account type (removes the
duplicated ternary in `CreatesAccountsFromPending` and
`AccountMappingController`).
### `refactor(i18n)` — `Locale` enum
- New `App\Enums\Locale` (`en`/`es`/`fr`).
- Unifies the `['en','es','fr']` list (validation in `SetLocale` and
`ProfileUpdateRequest` via `Rule::enum`) and the `Accept-Language`
detection (`Locale::detectFromHeader`), previously duplicated in
`SetLocale` and `CreateNewUser`.
## Interaction with Wise (PR #525)
Rebasing onto Wise surfaced two issues this PR fixes:
1. **`isWise()` was broken** once `provider` is cast (it compared `===
'wise'` against an enum → always `false`).
2. **Account type**: Wise uses an API key (✓ it gets the auth-failed
email) but is a **checking** account, not an investment one. Account
type now flows through `defaultAccountType()` (Wise/EnableBanking →
Checking; indexa/binance/bitpanda/coinbase → Investment).
## Behavior change (minor)
`CreateNewUser` now detects `fr` at registration (previously only
`es`/`en`), consistent with the existing French support.
## Out of scope
- Frontend (`['indexacapital','binance',...]`, `provider: string`):
syncing TS with the PHP enums would need typegen. It still receives the
string via `->value`.
- `TransactionSource::Wise/EnableBanking`: a separate enum (transaction
origin), not a duplicated check.
## Tests
- New: `tests/Unit/Enums/BankingProviderTest.php`,
`tests/Unit/Enums/LocaleTest.php`.
- `vendor/bin/pint --test` ✓ · Larastan ✓ · full suite (excluding
Browser) **1615/1616** ✓.
## Summary
Adds **Wise** as an API-token banking provider — connect flow,
transaction sync, and per-wallet balance sync — and fixes two bugs that
kept business/extra profiles and balances from working.
## Changes
- **Balance sync** — new `WiseBalanceSyncService` pulls
borderless-account balances on every sync. Previously Wise balances were
never fetched, so the displayed balance went stale (frozen at the last
value).
- **Multi-profile connect** — `WiseController` now creates accounts for
**every** profile on the token (personal *and* business), not just one.
- **ID-format fix** — `external_account_id` is now
`"{profileId}:{currency}"`. The connect flow previously wrote the
**borderless-account id**, but both sync services parse the **profile
id** — so any UI-connected account (and every business profile) silently
failed to sync.
- **Tests** — `WiseBalanceSyncTest` (currency matching, idempotent
today-balance, skip-when-unmapped) and `WiseControllerTest`
(multi-profile pending accounts, onboarding auto-create, invalid token).
## Test plan
- [x] `php artisan test
tests/Feature/OpenBanking/WiseBalanceSyncTest.php
tests/Feature/OpenBanking/WiseControllerTest.php` — 6 passing / 28
assertions
- [x] `vendor/bin/pint` — clean
- [ ] CI (full pest + lint + build)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
## Summary
Adds **catch-all budgets** — a budget flagged `is_catch_all` absorbs
every expense-category transaction not already claimed by another
(non-catch-all) budget, so out-of-budget spending is still tracked.
## Changes
- Migration: `is_catch_all` boolean (default false) on `budgets`.
- `Budget` model: fillable + boolean cast.
- `BudgetTransactionService`: a catch-all budget matches expense
transactions whose `category_id` is not claimed by any non-catch-all
budget; period assignment mirrors the same rule.
- `BudgetController`: supports the flag.
## Notes
- Pre-existing WIP committed as-is; CI is the validation gate.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
## What
During onboarding, the per-transaction AI categorization listener no
longer runs. The AI automation rules generated in the suggestions step
cover the bulk of the imported transactions (~70%). On completion, a
single batch pass categorizes whatever is still uncategorized.
## Why
Running per-transaction categorization on every transaction created
during onboarding is wasteful: most are about to be covered by the AI
rules generated at the end of the flow. Defer the AI spend to one batch
over the remainder.
## How
- **`CategorizeTransactionWithAi` listener** — skips when `!
$user->isOnboarded()`. Covers every txn-creation path, since it hangs
off the model `created` event.
- **`CategorizeOnboardingTransactionsJob`** (new) — dispatched from
`OnboardingController::complete` after `onboarded_at` is set. Snapshots
`whereNull('category_id')->whereNull('description_iv')`, so transactions
already categorized by the AI rules (or manually in the categorize step)
are excluded. Chunks by `group_batch_size` and runs `AiCategorizer` per
chunk. Runs on the dedicated `ai` queue.
- Gated by `AiCategorizationGate::allows()` (rollout flag) — automatic
system behavior, gated like the real-time tier, not the explicit
backfill.
## Flow
1. `syncing` → transactions created, **no** per-transaction AI
2. `ai-suggestions` → AI rules cover the bulk
3. `categorize-transactions` → user manually categorizes a few
4. `complete` → batch-categorize the rest, skipping anything already
labeled
## Tests
- Listener skips transactions created while onboarding; still
categorizes post-onboarding.
- Job categorizes the remainder, leaves rule/manual categories
untouched, no-ops for ineligible users.
- `complete` marks the user onboarded and queues the batch job.
Full Ai + Onboarding suites green (112/112). Pint clean.
## What
Auto-categorizes transactions with AI (Gemini) for **pro +
AI-consented** users when no automation rule already matched. Ships the
full backend **behind a Pennant flag, off by default**, so it's
mergeable and testable in isolation; the UI is a deliberate follow-up.
## Why / cost
Prod check first: ~20k txns/month, **~52% of pro-user transactions are
uncategorized** after rules. At Gemini Flash-Lite rates the cost is a
**rounding error** — ~$0.13–$0.75/month for all pro users, single-digit
dollars even on full Flash. So the model is chosen for accuracy, not
price; the real constraints are trust, accuracy and privacy.
## How it works
**Two tiers** (every transaction is covered, rules are an optimization
on top):
- **Tier 1 – label** — a queued listener runs *after* the synchronous
rules; if still uncategorized and the user is eligible, the model picks
a **leaf** category (referenced by numeric index, never a UUID, so it
can't hallucinate one). Auto-applied only above the **label bar**
(`0.7`); below → left blank, no nag. Tagged `category_source = ai` +
`ai_confidence`, fully reversible.
- **Tier 2 – learn** — above the higher **rule bar** (`0.85`) *and* a
clean merchant key *and* the model flags the merchant unambiguous → the
merchant is appended to a single **ai-owned** automation rule for that
category (OR'd conditions, not rule-sprawl), so future transactions
match for free and consistently. AI rules sit at the lowest priority;
**user-owned rules are never touched**.
**Self-heal + signal** — when a user overrides an AI category, a
`category_correction` is logged (calibration signal, bucketable by
confidence) and the offending merchant condition is dropped from the ai
rule (deleted if empty). User rules and manual categories are untouched.
**Safety** — config kill switch + pro + active consent + gradual Pennant
rollout. Dedicated `ai` queue so Gemini never blocks bank syncs.
Encrypted (client-side) transactions are never sent.
**Backfill** — `ai:categorize-backfill {user}`, explicit opt-in,
batched, learns rules as it goes.
## Data model
- `transactions`: `category_source`, `ai_confidence`,
`categorized_by_rule_id`
- `automation_rules`: `origin` (`user`/`ai`)
- new `category_corrections` table
## Screenshots
<img width="921" height="384" alt="image"
src="https://github.com/user-attachments/assets/f04c2a03-b39e-4a3d-81eb-ecf26eaefb83"
/>
## Sentry
Fixes
**[PHP-LARAVEL-3A](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3A)**
— `FatalError: Maximum execution time of 30 seconds exceeded` on `GET
/api/cashflow/trend`. Top frame in Carbon date math (`getUTCUnit`).
## Root cause
`trend()` caps the `months` param at 24, but the `from`/`to` branch
validated only `date` — **no span limit**:
```php
if (isset($validated['from'], $validated['to'])) {
$start = Carbon::parse($validated['from'])->startOfMonth();
$end = Carbon::parse($validated['to'])->endOfMonth();
}
```
The series is then built by `while ($current->lte($end)) { ...;
$current->addMonth(); }`. A request with a huge range (e.g.
`from=0001-01-01&to=9999-12-31`) runs that loop hundreds of thousands of
times — each iteration doing Carbon `format()`/`addMonth()` — and
exhausts the 30s timeout. (Sentry strips query strings, which is why the
captured URL looked param-less.)
The frontend uses the capped `?months=12&to=...` path for the month
view, but the uncapped `?from=...&to=...` path for other period types.
## Fix
Clamp the computed `start` to at most `MAX_TREND_MONTHS` (24) months
before `end`, regardless of branch. Bounds both the month loop and the
transaction query window. 24 matches the existing `months` ceiling.
## Test
`cashflow trend caps the window for unbounded date ranges` — requests
`from=0001-01-01&to=9999-12-31` and asserts exactly 24 data points
returned, in ~0.02s. Without the clamp this loops ~96k months and hangs.
## Problem
Self-deleting an account via **Settings → Delete account** only called
`markAsDeleted()`. It never checked or cancelled the Stripe
subscription, so a user could delete their account while still
subscribed and **keep getting billed** afterwards. The `user:delete` CLI
command already cancelled the subscription, but the web path never got
that logic.
## Fix
Block account deletion while the user is **on a trial** or holds a
**valid, uncancelled subscription**, and direct them to cancel via the
billing portal first.
- `User::hasActiveSubscriptionOrTrial()` — true on a trial or a
`valid()` subscription; **grace-period** (already-cancelled)
subscriptions are excluded since they won't re-bill, so deletion is
still allowed.
- `ProfileController::destroy()` — rejects with a `subscription` error
before deleting (server-side guard, also covers non-UI calls).
- Delete-account page now receives `hasActiveSubscriptionOrTrial`; when
set, the UI shows a warning + **Manage billing** link and hides the
delete dialog.
- Spanish translations added for the two new strings.
The `user:delete` CLI is unchanged — it keeps its
auto-cancel-with-confirmation flow for admin use.
## Tests
- Active subscription → deletion blocked
- Trialing subscription → deletion blocked
- Grace-period (cancelled) subscription → deletion allowed
- Delete-account page exposes the flag (true/false)
All passing locally, alongside Pint / Prettier / ESLint.
Suggests transaction categorization rules during onboarding.
After a sync or import, it groups the uncategorized transactions, asks
Gemini (via laravel/ai) to map the common merchants to categories, and
shows the results for review. The user edits or drops any and creates
the ones they want. During onboarding the accepted rules also categorize
existing transactions right away.
Off by default: it needs the `AiRuleSuggestions` Pennant flag and a
per-user AI consent. The model and thresholds are config-driven.
`ai:suggest-rules {user}` prints what a user would get.
The settings-page surface and monthly regeneration are a follow-up.
## 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
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
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
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.
## Problem
`GET /transactions` throws `InvalidArgumentException: Illegal operator
and value combination` (Sentry
[PHP-LARAVEL-34](https://whisper-money.sentry.io/issues/PHP-LARAVEL-34)
— escalating, 76 occurrences, 2 users).
## Root cause
Laravel's `cursorPaginate()` builds `where(orderColumn, '>',
cursorValue)` for the next page. When sorting by a **nullable** column
(`creditor_name` / `debtor_name`) and the boundary row's value is
`null`, the cursor encodes `null` → `prepareValueAndOperator` rejects
`where(col, '>', null)`. Default sort (`transaction_date`, NOT NULL)
never hits it.
## Fix
When sorting by a nullable column, select `COALESCE(col, '') as
col_sort` and order by the alias. Cursor reads the coalesced
(never-null) value; Laravel rewrites the WHERE to the `COALESCE`
expression. Alias is hidden from serialization.
## Tests
- Reproduces cross-page cursor pagination with null values in the sort
column (500 → 200).
- Asserts the `*_sort` alias is not leaked to the frontend.
Fixes PHP-LARAVEL-34
## Why
iOS PWAs hand the bank redirect back to the system browser (Safari),
where the app session does not exist. The old `/open-banking/callback`
sat behind `auth` + `verified` middleware, so Safari hit it with no
session → bounced to `/login`, the callback body never ran, and the
connection stayed `pending` forever (4 stale rows observed in prod).
## What
Make the bank connection flow session-independent end to end.
### 1. State-token callback (session-independent)
- `store()` generates `Str::random(40)`, persists it on the connection,
and passes it to `startAuthorization` as `state`.
- `/open-banking/callback` is now **public**. It resolves the connection
from `state_token` (`resolveConnectionFromState`) and derives the owner
from the connection, not the session.
- Connection is finalized server-side (`session_id`, status,
`state_token = NULL`) before any redirect.
- New migration adds the nullable, unique `state_token` column; the
column is hidden on the model.
### 2. Completion page instead of login bounce
When the callback runs without a session (the Safari case), the
connection is already finalized server-side. Instead of bouncing the
user to `/login`, render a standalone "go back to the app" page — their
session lives in the PWA, not here.
- New page `resources/js/pages/open-banking/connection-complete.tsx`
(success / error states, dark mode).
- `finishRedirect()` renders the page when `!Auth::check()`; logged-in
users still get the normal redirect.
### 3. Onboarding: land on the connections step + poll cross-browser
- A logged-in user finishing the flow lands straight on the onboarding
connections step (`?step=create-account`), now resolved server-side via
a validated `initialStep` prop so the step is deterministic.
- While on that step the page polls every 4s (`usePoll`, `only:
['accounts']`), so a connection finalized in another browser (PWA →
Safari) shows up without a manual refresh.
## Flow
```
POST /authorize → stateToken = Str::random(40)
→ startAuthorization(state = stateToken)
→ create BankingConnection(pending, state_token)
EnableBanking → callback?code&state=stateToken (PUBLIC, no session needed)
→ find connection WHERE state_token = state
→ createSession(code) → finalize (session_id, status, state_token = NULL)
→ session present? redirect to connections step / mapping
: render "go back to the app" completion page
PWA (logged in) → onboarding?step=create-account → polls accounts every 4s
```
## Tests
36 passing. Covers:
- state-token persistence and session-less finalization
- owner resolved from the connection regardless of who is authenticated
- completion page rendered (success + error) when no session; login
fallback when no connection resolves
- logged-in user redirected directly to the onboarding connections step
- `?step=create-account` lands on that step; unknown step falls back to
default
## What
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.
## What
Standardizes how models are serialized across web, API, and Sync
responses by relying on Eloquent `$hidden` + accessors on the models
themselves, instead of ad-hoc `select` scopes and per-controller field
picking.
Touches 9 models (`Account`, `Bank`, `Budget`, `Category`, `Label`,
`LoanDetail`, `RealEstateDetail`, `Transaction`, plus pivot hiding) and
the controllers/middleware that consumed the old ad-hoc shapes.
## Why
- Single source of truth for response shape lives on the model, aligned
with Wayfinder model typegen.
- Removes duplicated field-selection logic scattered across controllers.
- Continues the duplication-removal PR series (#475–#483).
## How
- Hide internal columns and pivots via `$hidden`; expose computed fields
via accessors.
- Controllers return full models / load full relations rather than
hand-picked columns.
- `HandleInertiaRequests` slimmed down to match.
## Notes for reviewers
- Per-commit breakdown: each model standardized in its own commit for
easy review.
- Tests added/updated for each model to assert the serialized shape
(hidden columns absent, relations present).
- Full suite: 1382 passed / 1 skipped / 0 failed. `pint` clean.
## What
When deleting a transaction that belongs to a **manual**
(non-bank-connected) account, the user can now opt to update the
account's **current balance** for today, so it stays accurate without
re-syncing.
- Deleting an **expense** (amount < 0) → balance **increases** by the
amount.
- Deleting **income** (amount > 0) → balance **decreases** by the
amount.
- Formula: `new_balance = current_balance − transaction.amount`. If
today has no balance row, it's seeded from the latest known balance.
- Available for **single delete** and **bulk delete** (a checkbox in the
confirmation dialog).
- Connected accounts are never touched — their balances come from bank
sync.
- On the account page, the balance display refreshes automatically after
the delete (no page reload).
## How
- New `ManualBalanceAdjuster` service; `TransactionController@destroy`
calls it when the request carries `update_balance` and the account is
manual.
- Frontend `transactionSyncService.delete/deleteMany` accept an
`updateBalance` option; both delete dialogs show the checkbox only when
a manual account is affected.
- `TransactionList` gains an `onBalanceUpdated` callback;
`Accounts/Show` uses it to bump the balance chart's refresh key.
- Added `banking_connection_id` to the accounts payload (transactions,
budgets, and the shared Inertia props feeding accounts/show) so the
frontend can identify manual accounts.
<img width="896" height="400" alt="image"
src="https://github.com/user-attachments/assets/e7f35ccb-13a1-40a1-bf04-615752bddf1a"
/>
## Problem
When the verification email link opens in a browser where the user is
not logged in (common on phones, where the link escapes the app), the
`verification.verify` route's `auth` middleware redirects to login and
the email is never verified.
## Fix
The signed verification URL already self-secures via `id` + `hash` +
signature + expiry, so the `auth` requirement was redundant. This adds a
public, signed-only verify route and points the verification email at
it.
- `Auth/VerifyEmailController` — resolves the user by `id`, validates
`hash` with `hash_equals(sha1(email))`, marks verified and fires
`Verified`. Guests → `login` with a success status; the same logged-in
user → `dashboard?verified=1`.
- New route `GET /verify-email/{id}/{hash}` with `signed` + `throttle`
(name `verification.verify.public`). Distinct URI/name so it doesn't
clash with Fortify's existing auth-protected route, which stays intact.
- `VerifyEmailNotification::verificationUrl()` overridden to sign the
public route.
Now the link verifies regardless of login state, and the user can return
to the app and sign in.
## Tests
- 6 new cases in `EmailVerificationTest` (logged-out verify, logged-in
verify, bad hash, unsigned request, already-verified no refire).
- 1 new case in `VerificationNotificationTest` asserting the email links
the public signed route.
- All pass; pint clean.
## 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
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
The "public banks + user's own banks" query was repeated in **5
controllers** (Transaction ×2, Settings/Bank, Budget, Cashflow,
Onboarding).
New `Bank::availableForUser($user)` scope; all 5 sites use it.
Bonus: the Onboarding variant had no `where()` grouping around
`whereNull/orWhere` — harmless today (no other constraints) but a latent
precedence bug. The scope always groups.
## Stats
- **-31 / +24 lines**, 5 call sites → 1 definition
## Checks
- `php artisan test
--filter="Bank|Transaction|Budget|Cashflow|Onboarding"` — 612 passed
(2707 assertions), 1 skipped
- `vendor/bin/pint --dirty` — pass
Part of duplication-removal series (#475–#479).
## 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
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
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.
## 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
- 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"
/>
## Sentry issue
- PHP-LARAVEL-2F: https://whisper-money.sentry.io/issues/122581787/
## Root cause
- Automation rule match preview eagerly loaded account, bank, category,
and labels for every 500-transaction chunk, even for rules that only
inspect description fields.
- Sentry flagged repeated account/bank eager-load queries across chunks
as an N+1 pattern on
`/settings/automation-rules/{automationRule}/matches`.
## Fix
- Detect variables used by an automation rule and eager load only
relationships required for evaluation.
- Keep label eager loading only when label-only skip logic needs it.
- Avoid lazy loading unused relationships while preserving full data
shape for rule evaluation.
- Update the Sentry prompt to use the `sentry` CLI workflow.
## Verification
- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact
tests/Feature/AutomationRuleApplicationTest.php`
- `php artisan test --compact
tests/Feature/AutomationRuleEvaluationTest.php
tests/Feature/AutomationRuleApplicationTest.php`
## Summary
- sync user currency from the first account, manual or connected
- reuse one service across manual creation and open banking
mapping/auto-create flows
- keep later accounts from overwriting user currency
- add browser signup + onboarding account creation coverage
## Prod check
- users with any account: 210
- first account currency mismatches user currency: 64
- USD user + EUR first account: 36
- first account manual users: 189
- manual-first mismatches: 61
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php
tests/Feature/OpenBanking/AuthorizationControllerTest.php
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php
tests/Feature/OpenBanking/BinanceControllerTest.php
tests/Feature/OpenBanking/BitpandaControllerTest.php
tests/Feature/OpenBanking/CoinbaseControllerTest.php
tests/Feature/Settings/AccountTest.php
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter='syncs user currency from first onboarding account after
signup'
## 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