Commit Graph

1073 Commits

Author SHA1 Message Date
Víctor Falcón 4930405e79 fix(budgets): keep labelled expenses out of the catch-all budget
A catch-all budget only treated categories as "claimed" by other budgets, so
every expense tracked by a label-only budget was absorbed by it as well.

Also adds budgets:reassign-labelled to re-derive the assignments the bug
already wrote.
2026-08-11 17:10:30 +02:00
Víctor Falcón b2ff1664e2
feat(mcp): budget tools — read, create, edit and delete (#779)
Budgets were the one part of the app a connected agent could not see. It
could
list transactions, categories and labels, but "am I still inside my food
budget
this month" had no answer, and setting a budget up meant leaving the
conversation. This adds the four tools that close that gap.

## Tools

| Tool | What it does |
| --- | --- |
| `list_budgets` | Every budget with the period in progress: allocated,
carried over, spent, remaining, plus the categories and labels it tracks
|
| `create_budget` | A limit per period over categories and/or labels, or
the single catch-all budget |
| `update_budget` | Rename, or change the limit |
| `delete_budget` | Remove a budget; the transactions it was watching
are untouched |

Budgets hang off the user rather than off a space — that is how the app
already
decides which budgets a transaction feeds — so these tools take no
`space`
argument and cover the whole account, like the cashflow and net-worth
ones.

`update_budget` deliberately only takes `name` and `allocated_amount`.
The web
edit dialog locks the period length, start day and rollover behind an
explicit
message ("budgets are calculated historically"), and for good reason:
changing
them afterwards lets the next generated period overlap the one in
progress, so
the same transaction gets counted twice. To change those, or the tracked
categories, the agent deletes the budget and creates it again.

## Shared service, and one web behaviour change

Creating a budget seeds two periods and dispatches the historical
backfill for
both; that logic now lives in `BudgetService`, called by
`BudgetController` and
by the tools, instead of being copied into the MCP layer.

**This changes one thing on the web:** editing a budget's amount now
applies to
the period in progress as well as future ones. The old filter was
`start_date >= today`, which skipped a period that had already started —
so on
any day but the first of the period, saving a new amount appeared to do
nothing.
The edit dialog already promised the new behaviour ("This will update
the
allocated amount for the current and future periods") and prefills the
current
period's amount, so the code was the side that was wrong. Covered by a
new test
in `BudgetTest`.

## Fix: a start day that could hang period generation

`period_start_day` means a day of the month for monthly budgets and a
day of the
week for weekly ones. A weekly budget carrying a value above 6 sent
`calculatePeriodDates()` into `while ($date->dayOfWeek !== $dayOfWeek)`,
walking
backwards forever looking for a day that cannot exist. The web form caps
the
input at 6, but the server-side rule never did, so the tools were the
first
surface that could reach it — and once such a row exists, the daily
`budgets:generate-periods` command hits it and stops generating periods
**for
every user**.

Two changes: the tools validate the range against `period_type`, and the
generator takes the value modulo 7 so no caller can spin. Verified
against a row
doctored to `weekly` + `15`: it now resolves to a normal week instead of
hanging.

## Reviews

Both review passes ran; the notable ones applied beyond the two above:

- `remaining_amount` no longer adds the carried-over amount. The budget
cards,
the spending chart and the limit emails all measure against the
allocated
amount alone, so the old formula meant an agent quoting a number no
screen
  shows. `carried_over_amount` is still reported as its own field.
- The period reports `processing_historical`, so an agent can tell
"nothing
spent" from "backfill still running" instead of trusting a prose caveat.
- The category/label hints say the ids must be ones the user owns —
`list_categories` is space-scoped and can return a co-member's, which a
budget
  cannot track.
- Request id normalisation moved onto `McpTool` as `requestedIds()`,
shared with
`labelsInSpace`, which no longer runs a query just to build an empty
result.

Deliberately not applied: the "at least one category or label" and "only
one
catch-all" rules stay duplicated between `StoreBudgetRequest` and
`create_budget`. Unifying them would change the web error key
`selection`, which
the create dialog renders, and the two audiences want different wording
— the
agent gets told what to send next.

## QA

Driven over the real `/mcp` endpoint with a Sanctum token, since there
is no UI
surface:

- `tools/list` advertises all four with the right annotations and
schemas
  (`delete_budget` destructive, `list_budgets` read-only).
- `create_budget` → the response comes back with `processing_historical:
true`;
after draining the queue, the €120 transaction already on the account is
  attached and `spent_amount` reads 12000, `remaining_amount` 38000.
- `update_budget` to 60000 → the August period moves to 60000, the
closed July
  period keeps 50000.
- `delete_budget` → gone from `list_budgets`, transaction still there.
- Refused as expected: a read-only token, a weekly budget with
`period_start_day`
  15, a budget tracking nothing, and an unknown budget id.
- `mcp_tool_calls` recorded only the four calls that did something, not
the
  rejections.

`chatgpt-app-submission.json` gains the four tools with their hint
justifications, plus a budget test case, so the submitted manifest keeps
matching
what the server serves (`ToolAnnotationsTest` enforces the count).
2026-08-11 14:28:53 +00:00
Víctor Falcón 12cab755e3
feat(roadmap): add a public roadmap page mirrored from UserJot (#778)
## What

A public `/roadmap` page in the landing's own style, listing the UserJot
board so visitors can see what is being built, and links to it from the
landing header (before GitHub and Discord) and footer.

<!-- Drop pr-roadmap-light.png and pr-roadmap-dark.png here -->

## How

UserJot has no public API, so the page reads the same tRPC endpoints its
own frontend calls (`x1.roadmap.getPage`, `x1.submission.getPage`, both
keyed by an `x-host` header) and caches the whole result for a day.

- **Dates.** The roadmap listing only knows when a submission was
*opened*, which for shipped work is months before it moved — nearly
every item would read "Jan 2026". The date a submission last changed
status lives on its detail endpoint, so those are fetched concurrently
with `Http::pool` and fall back to the creation date when a submission
has never moved.
- **Order.** Planned → in progress → shipped, and within a status the
most recently moved item first.
- **Bodies.** Submissions are written in Markdown by whoever opened
them. Rather than pull in a renderer for text we only show three lines
of, the emphasis markers are stripped and the body is collapsed into a
single paragraph, clamped with `line-clamp-3`. Each title links to its
ticket (new tab), so the full text is one click away.
- **Failure.** An unreachable UserJot renders the empty state with a
link to the board, not a 500, and the failure is not cached.

The page is also in `sitemap.xml`.

## Notes

- The mobile pill header hides its GitHub and Discord buttons (`hidden
sm:flex` inside an `sm:hidden` container), so a link there would never
be visible. The footer link is how mobile visitors reach the page; its
row got `flex-wrap` since it now holds five items.
- No pagination: there are 20 submissions and the request limit is 50.

## Testing
<img width="1280" height="1100" alt="pr-roadmap-dark"
src="https://github.com/user-attachments/assets/db07cb16-7e0f-4de8-960b-464bf0419d59"
/>
<img width="1280" height="1100" alt="pr-roadmap-light"
src="https://github.com/user-attachments/assets/a107e5bb-2deb-4f37-b511-7c188552c1e1"
/>

- `tests/Feature/RoadmapTest.php` covers status grouping, the
status-change date and its fallback, the ordering, the one-fetch-per-day
cache, and the unreachable-UserJot empty state.
- QA'd in Chrome: desktop light and dark, and mobile.
- `pint`, `prettier`, `eslint`, `jscpd` and `crap` all clean.
2026-08-11 14:14:59 +00:00
Víctor Falcón 5ee992644c
feat(landing): stop auto-redirecting signed-in visitors to the dashboard (#777)a
## Why

Landing on `/` while signed in mounted a dialog that counted down from 3
seconds
and then navigated to the dashboard. It had no close button, no
`onOpenChange`
and no overlay dismissal, so the only way to read the public page was to
catch
the `Cancel` button in time — an involuntary navigation and a keyboard
trap.

`/` is now a normal public page: it behaves like `/privacy` and
`/terms`, which
never mounted the dialog.

## What changed

- Deleted `authenticated-redirect-dialog.tsx` and its vitest file, its
usage in
  `welcome.tsx`, and the `auth` prop read that had no other consumer.
- Pruned the 4 orphaned translation keys from `lang/es.json` and
`lang/fr.json`
  (`Redirect progress`, `Redirecting to your dashboard`,
`You are being redirected in 3 seconds.`, `Go now`). `lang/en.json`
never had
them, and `Redirecting...` was kept — the onboarding step still uses it.
- Pointed the in-page CTAs at the dashboard for a signed-in visitor,
labelled
`Go to Dashboard`, and hid the demo link. `/register` and `/login` sit
behind
Fortify's guest middleware, so those CTAs were the remaining trampoline:
clicking `Get Started Free` bounced you to the dashboard under copy
promising
a signup, and `Check Demo` (`/login?demo=1`) silently did nothing
because the
demo credentials are prefilled on a page a signed-in user never reaches.
This
  follows the pattern already in `components/partials/header.tsx`.
- Added `tests/Browser/LandingPageTest.php`. The redirect was
client-side, so a
Feature test asserting `GET /` renders `welcome` would have passed
before this
change too — the browser test waits past the old 3s timer and asserts
the path
  is still `/`.

## Deliberately out of scope

- **The PWA still redirects.** `welcome.tsx` sends anyone in standalone
display-mode to `/dashboard`, guests included. That one is auth-agnostic
and
  serves a different purpose — the installed app should not open on the
marketing page, and `site.webmanifest` pins `start_url` to `/dashboard`.
Left
  as is.
- **The error boundary still targets `dashboard()`, not `/`.** The
comment
explaining "not `/`" is gone because its premise was this dialog, but
the
target is unchanged: `/` is the marketing page, the dashboard is the app
entry
point. Note the PWA trampoline above before anyone "simplifies" it to
`/`.
- **Plan-card CTAs for a signed-in visitor without a subscription** land
on
  `/dashboard` and are then bounced to the paywall by the `subscribed`
middleware — same destination as before, one hop fewer. Routing them
straight
  to `/subscribe` with an upsell source would be a separate change.

## QA

Real browser run against the dev server, signed in as a verified,
onboarded,
subscribed user — 14/14 checks:

- Guest: hero and plan CTAs still point at `/register`, `Get Started`
copy
  intact, `Check Demo` present, no `Go to Dashboard` in the body.
- Signed in: no dialog appears, still on `/` after ~8 seconds, page
content
  readable with no modal overlay.
- Signed in: every body CTA points at `/dashboard` and reads `Go to
Dashboard`,
  no `/register` or `/login` link left in the body, `Check Demo` hidden.
- Clicking a CTA lands on the dashboard. No uncaught JS errors.

`tests/Browser/LandingPageTest.php` passes locally (1 test, 3
assertions).
`pint --test`, `format:check`, `lint`, `dry` and `crap
--base=origin/main` all
pass.

## Demo

<!-- PLACEHOLDER: drag the QA video here -->
2026-08-11 14:02:18 +00:00
Víctor Falcón 6803f2cf0e
refactor(accounts): split store/update in the account controller (#776)
## Why

`Settings\AccountController` held two of the remaining complexity
offenders — `store` at **16** and `update` at **11** — and the two
clones jscpd reports inside the file:

```
AccountController.php [75:9 - 83:47]  ↔  [201:9 - 209:47]   (the nine real-estate fields)
AccountController.php [115:9 - 125:35] ↔ [217:9 - 227:35]   (the three loan fields)
```

Both actions were doing four jobs at once: map the request onto columns,
create or update the type-specific detail row, backfill balance history,
and answer.

## What changed

**The shared mapping** — `accountAttributes()`, `realEstateAttributes()`
and `loanAttributes()`, used by both actions. That is both clones gone.

**`store`'s type-specific work** moves out:

| new method | job |
|---|---|
| `createRealEstateDetail()` | the detail row + its balance history |
| `createLoanDetail()` | the detail row + its balance history |
| `linkToRealEstateAccount()` | points the property back at its new
mortgage |
| `backfillHistoricalBalances()` | "last twelve months now, older on the
queue" — both paths ended in this same dance |

**`update`'s loan branch** (`syncLoanDetail()`) returns the missing
fields instead of `return to_route(...)->withErrors(...)` from inside a
nested `else`, so the redirect decision stays in the action.

## Metrics

| | before | after |
|---|---|---|
| methods over complexity 10 | 30 | 28 |
| `store` | 16 | 5 |
| `update` | 11 | 4 |
| clones in this file | 2 | 0 |

## Testing

`AccountControllerTest`, `LoanTest`, `RealEstateTest`,
`RealEstateAvailabilityTest`, `AccountBalanceControllerTest`,
`AccountUserCurrencyServiceTest` — 148 tests green. PHPStan clean.

Three of the branches I moved had no coverage, so this adds it:

- creating a loan that started four years ago **queues**
`GenerateHistoricalLoanBalancesJob` and still writes the recent balances
inline (so the chart is not empty while the queue catches up)
- one that started two months ago queues **nothing** — the `isBefore`
condition that is the whole point of `backfillHistoricalBalances`
- adding loan details with only one of the three required fields comes
back with errors on the other two and writes no row. Worth noting these
errors come from the controller, not the request: `loanDetailRules()`
marks all three `nullable`.

The mortgage back-link was already covered by `LoanTest`.
2026-08-11 13:58:47 +00:00
Víctor Falcón 1553325c4f
refactor(settings): move the last two settings tables onto the shared table and hook (#775)
## Why

#770 extracted `SettingsTable` for the categories and labels pages and
noted that `accounts.tsx` and `automation-rules.tsx` repeat the same
markup. This is that follow-up.

Two clusters remained across the four settings pages:

1. **The table markup** — 30 lines of header groups, body, and empty
row, still copied in `accounts` and `automation-rules`.
2. **The table setup** — all four declared the same four pieces of state
and the same `useReactTable` call with the same six options, differing
only in `data` and (for labels) the initial sort.

## What changed

**`useSettingsTable(data, columns, initialSorting?)`** — a new hook
owning the sorting / filtering / column-visibility state and the
tanstack setup. All four pages use it, so the setup exists once:

```tsx
const table = useSettingsTable(accounts, columns);
const table = useSettingsTable(labels, columns, [{ id: 'name', desc: false }]);
```

**`SettingsTable`** now renders `accounts` and `automation-rules` too,
each passing its own row component through `renderRow`.

## Behaviour

One visual change: `SettingsTable`'s container goes from
`overflow-hidden` to `overflow-x-auto` — which is what `accounts.tsx`
already used, being the widest of the four tables. Keeping
`overflow-hidden` would have clipped it on narrow screens; the other
three pages only notice when their table outgrows the container, and
scrolling is the better answer there than cutting content off.

Everything else is a move: same columns, same rows, same empty messages,
same client-side sorting and filtering.

## Metrics

Measured against `main` (which already has #766–#770):

| | before | after |
|---|---|---|
| duplicated lines (total) | 4.83% | 4.71% |
| duplicated lines (tsx) | 5.03% | 4.82% |
| clones | 302 | 297 |

Since the start of this series, total duplication has gone 5.34% →
4.71%.

## Testing

ESLint and `tsc --noEmit` clean. These pages are covered by the browser
suite (`AccountsPageTest`, `AutomationRuleBuilderTest`,
`CategoriesTest`), which needs a production asset build I don't run
locally — so CI's `browser-tests-matrix` is the verification, as in
#770.
2026-08-11 13:54:18 +00:00
Víctor Falcón c9a70ffa1a
feat(accounts): count shared accounts at the owner's percentage (#750)
## Why

Some accounts are shared. A joint account funded 50/50 with a partner
holds money that is only half yours, so its expenses and income should
only count towards your figures by half — otherwise the dashboard and
the cashflow screen overstate both sides of every month.

## What

An account can now be configured with **the share of it you actually
own**, in Settings → Accounts → Edit account.

- **Transactions are always weighted** by that percentage. This covers
the cashflow screen (summary, sankey, trend, category breakdown) and the
dashboard (cashflow summary, monthly spending, top categories).
- **Balances stay at face value by default**, so an account keeps
matching what the bank shows. An opt-in checkbox — only offered below
100% — applies the same percentage to the balance as well, which then
flows through net worth, its evolution, and the account cards.

Defaults are 100% / opt-in off, so existing users see no change at all.

## How

The share is computed at exactly two choke points, one per code path:

- **PHP** — `ConvertsTransactionCurrency::convertTransactionAmount()`
applies `Account::shareOfAmount()` after the currency conversion,
covering every row-by-row analytics consumer.
- **SQL** — `Transaction::OWNED_AMOUNT_SQL` plus the
`joinOwningAccount()` scope weigh the four aggregate queries that never
hydrate models.

Balances go through `BalanceLookup::forAccounts()`, the single point
every net-worth, evolution and account-metrics reader already shares —
so they all stay consistent for free, while the surfaces that must show
the real bank figure (the balance editor, imports, bank sync) read
`account_balances` directly and are untouched.

`ownership_percentage` is a **signed** `tinyInteger` on purpose: MySQL
promotes `signed * unsigned` to `BIGINT UNSIGNED`, which overflows on
the negative amount of an expense. A test caught this.

## Deliberate boundaries

- **Transaction rows keep showing the real amount.** A row should say
what the bank actually charged; only totals are your share.
- **The balance editor writes the full amount**, and now says so when
the account is shared — that is what stops a shared balance being halved
again on every correction.
- **Percentages are whole numbers, 1–100.** A three-way split (33.33%)
is not expressible yet.
- **Configurable on edit only.** `StoreAccountRequest` and the create
form are unchanged, so a new joint account counts at 100% until you edit
it.

## Known gap (follow-up)

**Budgets are not weighted.** `budget_transactions.amount` is a snapshot
written at assignment time, so a 50% account still counts at 100% in
budget spend, carry-over and threshold alerts. Fixing it properly needs
the write path weighted, a re-snapshot when the percentage changes, and
a backfill of existing rows — a separate PR rather than a line in this
one. Until then a shared account can read €400 on the dashboard and €800
in Budgets for the same category.

Loan/mortgage *projections* also seed off the raw latest balance, so a
shared **loan** with the balance opt-in on draws a step at today's date.
Narrow, and follow-up material.

## Testing

`tests/Feature/SharedAccountOwnershipTest.php` covers every weighted
endpoint, net worth with and without the balance opt-in, the 1–100
validation, and pins the PHP and SQL rounding to the same answer on an
uneven share (33% of an odd amount).

Manual QA on real local data (June 2026, account "Daily" set to 50%):

| | Before | After |
|---|---|---|
| Cashflow income | €4,460 | €4,057 (−€403 = half of the account's
€805.80) |
| Cashflow expenses | €4,122 | €3,217 (−€905 = half of the account's
€1,809.85) |
| Net worth (opt-in off) | 30,516,453 | 30,516,453 — unchanged |
| Net worth (opt-in on) | 30,516,453 | 30,470,797 (−45,656 = half of the
€913.12 balance) |

Also verified: the balance editor still prefills the real €244,310.08
and shows the shared-account notice; an empty percentage field leaves
the setting untouched instead of resetting to 100%; out-of-range values
are rejected.

## Demo


https://github.com/user-attachments/assets/de47e41a-ff72-4b2b-8e5b-51076e048504


<!-- PLACEHOLDER: drag the QA video here -->
2026-08-11 13:26:54 +00:00
Víctor Falcón ed157f6f8a
refactor(frontend): call backend routes through Wayfinder instead of literal URLs (#772)
Follow-up to the Inertia v3 upgrade (#769). While auditing whether the
frontend used Wayfinder's current API, I found **32 HTTP call sites
across 16 files that built their URL by hand** — every one of which
already had a generated Wayfinder action sitting unused.

## Why this matters

Wayfinder exists so a route rename fails at **compile** time. A
hand-written `'/api/transactions/bulk'` fails at **runtime**, in
production, on a path a unit test can't catch because the tests mock
`axios`. The worst offender was `services/transaction-sync.ts` — the
offline sync service — which had five of them.

I verified each URL against `php artisan route:list` before changing it;
there were no missing routes, only unused generated ones.

## What changed

| File | Sites | Now uses |
| --- | --- | --- |
| `services/transaction-sync.ts` | 6 | `TransactionController` +
`Sync/TransactionSyncController` |
| `components/transactions/saved-filters.tsx` | 4 |
`Api/SavedFilterController` |
| `hooks/use-cashflow-data.ts` | 5 | `Api/CashflowAnalyticsController` |
| `components/transactions/import-transactions-drawer.tsx` | 3 |
`TransactionController@categorize` |
| `components/accounts/account-balance-chart.tsx` | 2 |
`Api/DashboardAnalyticsController` |
| `hooks/use-decrypt-account-names.ts` | 2 | `Api/AccountController` |
| `components/dashboard/net-worth-chart.tsx` | 2 | the two net-worth
preference controllers |
| `pages/transactions/index.tsx` | 2 |
`TransactionController@bulkUpdate` |
| `lib/import-config-storage.ts` | 2 |
`Api/AccountImportConfigController` |
| `app.tsx`, `use-decrypt-transactions.ts`,
`encryption-key-context.tsx`, `import-step-preview.tsx`,
`import-transactions-button.tsx`, `category-analysis-drawer.tsx`,
`settings/appearance.tsx` | 1 each | respective controllers |

## Query strings got simpler

The endpoints with parameters were assembling `URLSearchParams` by hand.
The generated `.url({ query })` helper does it, so that scaffolding is
gone:

```diff
-const periodParams = new URLSearchParams({ from: fromStr, to: toStr });
-const periodQuery = `?${periodParams.toString()}`;
-fetch(`/api/cashflow/breakdown${periodQuery}&type=income`)
+const periodQuery = { from: fromStr, to: toStr };
+fetch(cashflowBreakdown.url({ query: { ...periodQuery, type: 'income' } }))
```

`lib/import-config-storage.ts` also loses its local `configUrl()`
helper, which only existed to interpolate an account id.

## Two aliases, on purpose

`transaction-sync.ts` and `import-transactions-button.tsx` import with
`as` aliases because the plain names collide with a method (`update`,
`store`, `destroy`) and a `useState` variable (`importData`) already in
those files. Named imports are kept everywhere so tree-shaking still
works.

## Verification

- `bun run test` — **356/356, with zero test changes.** That is the
useful signal here: `transaction-sync.test.ts` asserts `axios.delete`
was called with the literal `'/transactions/txn-1'`, and it still
passes, so the generated URLs are byte-identical to the strings they
replaced.
- `bun run types` — no new errors. (`transaction-sync.ts:45` and the
`.test.tsx` matcher errors are pre-existing; the former just shifted
line number as imports were added.)
- `bun run build`, `bun run lint`, `bun run format`, `bun run dry` —
green.

### Browser check

Tests mock `axios`, so a wrong URL would still pass them. I exercised
the rewritten endpoints in a real browser and captured the actual
network traffic — all **200**, query strings identical to what the
manual code produced:

```
200 /api/cashflow/summary?from=2026-08-01&to=2026-08-31
200 /api/cashflow/trend?months=12&to=2026-08-31
200 /api/cashflow/breakdown?from=2026-08-01&to=2026-08-31&type=income
200 /api/cashflow/breakdown?from=2026-08-01&to=2026-08-31&type=expense
200 /api/dashboard/account/{id}/balance-evolution?from=2025-08-11&to=2026-08-11
200 /api/saved-filters
```

0 failed requests, 0 console errors across cashflow, transactions,
accounts, account detail and appearance.

## Out of scope

11 hardcoded **navigation** URLs remain (`href="/register"`,
`href="/privacy"`, `router.visit('/dashboard')`), mostly on the
marketing pages. They are static routes with a much lower rename risk,
so I left them for a separate pass rather than widen this diff.
2026-08-11 13:26:10 +00:00
Víctor Falcón fa6f6e2be3
refactor(transactions): build the bulk selection once in bulkUpdate (#774)
## Why

`TransactionController::bulkUpdate` sat at cyclomatic complexity **18**,
and the cause was a duplicated query. "Which transactions does this
apply to?" — explicit ids, else the active filters, else everything —
was written twice: once to load the rows, once to run the mass update.

```php
// to load them
if ($transactionIds && count($transactionIds) > 0) { $query->whereIn('id', $transactionIds); … }
elseif ($filters !== null) { $query->applyFilters($filters); … }
else { … }

// …and again, 40 lines later, to update them
$updateQuery = Transaction::query()->where('user_id', $user->id);
if ($transactionIds && count($transactionIds) > 0) { $updateQuery->whereIn('id', $transactionIds); }
elseif ($filters !== null) { $updateQuery->applyFilters($filters); }
```

Two copies of the selection rule is exactly the kind of thing that
drifts: change one and the update touches a different set of rows than
the check validated.

## What changed

| new method | job |
|---|---|
| `bulkSelection()` | the selection rule, in one place, called for both
the read and the update |
| `bulkUpdateAttributes()` | the columns to write; records the category
override first, while the rows still hold the old value |
| `syncLabels()` | replaces the label relations row by row |

`bulkUpdate` itself is now the flow: select, authorize, collect, update,
respond.

## Behaviour

No change. One thing the refactor surfaced: the old code guarded with
`$transactionIds && count($transactionIds) > 0`, treating an empty array
as "no selection". That branch is unreachable —
`BulkUpdateTransactionsRequest` validates `transaction_ids` as
`['array', 'min:1']`, so an empty array is a 422 before the controller
runs. The guard is now a plain `!== null` and a comment names the rule
it leans on.

## Metrics

| | before | after |
|---|---|---|
| `bulkUpdate` | 18 | 8 |
| `bulkSelection` / `bulkUpdateAttributes` / `syncLabels` (new) | — | 3
/ 6 / 2 |

## Testing

`BulkUpdateTransactionsTest`, `TransactionTest`, `TransactionFilterTest`
— 92 tests green, plus one new. The existing suite covers every
selection path (by ids, by date/amount/category filters, everything when
neither is given) and the 403 for ids the caller does not own, which is
the check most at risk from touching the selection.

The new test pins the empty-array case: the request rules reject it with
422 rather than letting it through as an empty selection, which is what
makes the simplified guard safe. PHPStan clean.
2026-08-11 13:14:19 +00:00
Víctor Falcón 6b441a0f02
refactor(inertia): group the shared user props and name the inline conditions (#773)
## Why

`HandleInertiaRequests::share` was at cyclomatic complexity **18** — the
highest left after #771. Almost all of it was one shape repeated seven
times:

```php
'bankingConnections' => fn () => $user ? $user->bankingConnections()->get(...)->map(...) : [],
'accounts'           => fn () => $user ? $user->accounts()->...->get() : [],
'categories'         => fn () => $user ? $user->categories()->forDisplay()->get() : [],
// ...four more
```

Each of those ternaries is a branch, and the guest answer is the same
every time.

## What changed

**`userCollectionProps()`** owns the seven deferred props. The guest
case is answered once, up front:

```php
if ($user === null) {
    return array_fill_keys(['expiredBankingConnections', 'bankingConnections', /* ... */], fn () => []);
}
```

…so the seven queries below read as queries rather than as ternaries.
`automationRules` was the odd one out (a full closure with an early
`return []`) and now matches its siblings.

**Three conditions got names** instead of living inline in the props
array:

| method | was |
|---|---|
| `isDemoAccount()` | `$user?->isDemoAccount() && !
app()->environment('local')` |
| `demoCredentials()` | `config(...) && ($isDemoQuery \|\|
$isDemoAccount) ? [...] : null` |
| `hasResidualEncryptionArtifacts()` | the four-way `&&` chain gating
the cleanup job |

The comments now say *why* each one is what it is — that a demo account
is only treated as one outside local, and that the cleanup job fires
when the salt outlived the encrypted rows.

## Behaviour

One deliberate change: `expiredBankingConnections` and
`bankingConnections` end in `->all()`, returning a plain list instead of
a `Collection`. The JSON is identical (a Collection of arrays serializes
to the same array), and it keeps the prop's type expressible outside
`share()` — `Collection`'s `TValue` is invariant, so phpstan cannot
match `Closure(): Collection<int, array{…}>` against itself once the
array literal lives in its own method.

Everything else is a move.

## Metrics

| | before | after |
|---|---|---|
| `share` | 18 | 4 |
| `userCollectionProps` (new) | — | 2 |
| `demoCredentials` / `hasResidualEncryptionArtifacts` / `isDemoAccount`
(new) | — | 3 / 4 / 2 |

## Testing

This middleware runs on every page, so: **the whole Feature suite — 1958
tests, 1957 passed, 1 skipped.** PHPStan clean.

`InertiaSharedDataTest` covers exactly what moved: the guest path, the
authenticated path, both encryption-cleanup branches (queued when the
salt is residual, not queued when there is no salt), the
expired-connection reconnect links and the connections prop — the two
props whose return type changed.
2026-08-11 13:09:55 +00:00
Víctor Falcón a912fb2f45
refactor(transactions): split the category/label filter out of applyFilters (#771)
## Why

`Transaction::scopeApplyFilters` was the highest-complexity method left
in the report at **19** (after the trial-experiment code #762 deletes,
and the two already handled in #766 / #767): eighteen `if`s in one body,
thirteen of which are a single comparison each.

## What changed

**The one-comparison filters use `when()`.** Dates, amounts, account
ids, category source, creditor/debtor name and free-text search are now
a declarative chain, so the scope reads as the list of filters it is
instead of eighteen mutations of `$query`.

**The branching moved out.** The only part with real logic is the
category/label pair, now `applyCategoryAndLabelFilters()`, with the
subtree widening in `expandToDescendants()`. Naming them also documents
two things that were previously implicit: that `uncategorized` is a
pseudo id the UI sends for "no category at all", and that picking a
category picks everything under it.

## Behaviour

One structural change worth reviewing: the where-group is flat now. The
old code wrapped the category conditions in their own nested group and
ORed the label condition outside it — `((category IN (...) OR category
IS NULL) OR EXISTS labels)`. All three branches are ORed, so `(category
IN (...) OR category IS NULL OR EXISTS labels)` matches the same rows;
only the parentheses in the generated SQL differ.

Everything else is a straight move.

## Metrics

| | before | after |
|---|---|---|
| methods over complexity 10 | 35 | 34 |
| `scopeApplyFilters` | 19 | 1 |
| `applyCategoryAndLabelFilters` (new) | — | 8 |
| `expandToDescendants` (new) | — | 3 |

Part of that drop is the `when()` chain: the conditions still exist,
they just moved into Laravel's own conditional helper rather than being
counted as branches. The branching that actually needed taming is the
piece that now has its own name and sits at 8.

## Testing

`TransactionFilterTest`, `TransactionTest`, `SavedFilterTest`,
`BulkUpdateTransactionsTest` — 110 tests, all green. The filter
combinations that the flattened group could have broken are covered:
filter by category, by a parent (descendants), by `uncategorized`, by
multiple categories including `uncategorized`, by label, and the
explicit `category and label filters combine with OR`. PHPStan clean.
2026-08-11 14:58:59 +02:00
Víctor Falcón 8d58e0bf81
refactor(settings): dedupe the categories and labels pages (#770)
## Why

`settings/categories.tsx` and `settings/labels.tsx` were the last big
duplication cluster after the landing page (#768): 6 clone pairs between
and within them, ~100 duplicated lines.

They are the same page with a different record type:

- the trailing `...` dropdown with Edit / Delete
- the right-click context menu on each row, with the same two items
- the pair of dialog mounts wired to two `useState` flags — written
**twice per page**, once for the dropdown and once for the context menu
- 39 lines of table markup (header groups, body, empty row) differing
only in the row component and the empty message

## What changed

Three shared pieces:

| component | replaces |
|---|---|
| `RowActionsDropdown` | the `...` cell + its two dialogs, in both pages
|
| `RowWithActionsContextMenu` | the row + context menu + its two
dialogs, in both pages |
| `SettingsTable` | the table markup, in both pages |

The dialogs come in as render props (`renderEditDialog` /
`renderDeleteDialog`), so each page keeps its own dialog components and
their extra props — `categories` needs the full category list, labels
needs nothing — while the menus own the open state.

`categories.tsx` −86 lines, `labels.tsx` −100 lines.

### Why not the existing DataTable

`components/ui/data-table.tsx` already renders a table, but it is
**virtualized**: its `renderRow` hands back a `VirtualItem` and the
virtualizer, and rows are expected to attach `measureElement`. These
settings lists are short and their rows are wrapped in a
`ContextMenuTrigger asChild`, so adopting it would mean threading
measurement through the context menu to gain nothing. `SettingsTable` is
the plain version, and `accounts.tsx` / `automation-rules.tsx` (which
repeat the same markup) can move onto it next.

## Metrics

Cumulative with the other refactor PRs in flight; measured against
`main`:

| | before | after |
|---|---|---|
| duplicated lines (tsx) | 5.82% | 5.40% |
| duplicated lines (total) | 5.34% | 5.11% |
| clones | 322 | 314 |

## Testing

ESLint and `tsc --noEmit` clean. No behaviour change: same markup, same
handlers, same dialogs — only their location moved.

The browser suite is what actually exercises these pages
(`CategoriesTest` covers viewing, creating, filtering, the empty state
and cell alignment). I could not run it locally without a production
asset build, so it is verified here by CI's `browser-tests-matrix`.
2026-08-11 14:37:23 +02:00
Víctor Falcón 952ca41eb1
refactor(analytics): dedupe the two account balance evolution endpoints (#766)
## Why

We just landed the CRAP and DRY checks. `DashboardAnalyticsController`
was the worst offender after the trial-experiment code (which #762
deletes anyway):

- `accountBalanceEvolution` — cyclomatic complexity **30**
- `accountDailyBalanceEvolution` — cyclomatic complexity **12**

And the two were near-identical copies of each other, so it was a
duplication problem wearing a complexity costume.

## What changed

The monthly and daily series shared everything except the dates they
iterate and the projections the monthly one appends: authorization,
range validation, per-point assembly (balance, invested amount, linked
mortgage, display-currency equivalents) and the response envelope. Those
are now shared methods:

| new method | what it owns |
|---|---|
| `authorizedDateRange` | 403 guard + `from`/`to` validation + parsing |
| `displayCurrencyFor` | the user currency, or null when it matches the
account |
| `balanceLookupFor` | the `BalanceLookup` over the account and its
linked loan |
| `balancePointAt` | one point: value, invested amount, mortgage,
display fields |
| `projectedPoints` → `loanProjection` / `realEstateProjection` /
`projectedPoint` | the future months |
| `evolutionResponse` | the `data` + `account` + `display_currency_code`
envelope |

Also collapsed the three byte-identical "sum transactions joined to a
category of type X" queries in `calculateSpending`/`calculateCashFlow`
behind `sumByCategoryType`.

## Behaviour

No change. One thing worth naming: the daily series used to read the
balance at **start** of day and the invested amount at **end** of day
(`endOfDay()` mutates the Carbon instance mid-method). It now reads both
at end of day — same result, because `BalanceLookup` compares
`toDateString()`, i.e. by day.

## Metrics

| | before | after |
|---|---|---|
| methods over complexity 10 | 35 | 33 |
| `accountBalanceEvolution` | 30 | 4 |
| `accountDailyBalanceEvolution` | 12 | 4 |
| duplicated lines (php) | 5.92% | 5.68% |
| duplicated lines (total) | 5.34% | 5.25% |

## Testing

`DashboardAnalyticsTest` (41), `LoanTest` + `AccountControllerTest` (68)
— all green, unchanged. Between them they cover both projection paths,
both currency-conversion paths and the mortgage overlay, so the refactor
is verified by the existing suite; no new tests were needed.

## Noticed, not changed

`display_invested_amount` converts **from** the user currency **to** the
account currency, while every sibling field converts the other way.
Looks like an inverted argument pair, but fixing it changes numbers on
the invested-amount chart, so it stays out of a no-behaviour-change PR.
Worth a follow-up.
2026-08-11 14:36:52 +02:00
Víctor Falcón 77d55f88df
refactor(mcp): dedupe record resolution and field updates in the write tools (#767)
## Why

Second pass at the complexity report. After the trial-experiment code
(deleted by #762) and the analytics controller (#766), the worst
offenders were the MCP write tools:

- `UpdateTransaction::write` — cyclomatic complexity **20**
- `UpdateAutomationRule::write` — cyclomatic complexity **13**

Both for the same reason: a long wall of `if ($request->has('x')) {
$model->x = ...; }` blocks, one per optional field.

## What changed

Two shapes moved into `WriteTool`:

**`modelInSpace()`** — resolving a record in the space was written five
times (account, transaction, category, label, plus a rule resolver that
existed twice, once in `UpdateAutomationRule` and once in
`DeleteAutomationRule`), each a copy of the same query + null check +
message. The four public helpers are now one-liners over it, and
`ruleInSpace()` is shared instead of duplicated.

**`applyFields()`** — assigns only the fields the request actually
carries, which is the "only what you pass changes" contract these tools
document. Values are closures, so resolving a related model
(`accountInSpace`, `categoryInSpace` — either can throw a validation
error) still happens only when its field is present.

## Metrics

| | before | after |
|---|---|---|
| methods over complexity 10 | 35 | 33 |
| `UpdateTransaction::write` | 20 | 7 |
| `UpdateAutomationRule::write` | 13 | 6 |
| duplicated lines | 5.34% | 5.34% |

Duplication does not move, and it's worth being straight about why: what
jscpd still flags across `app/Mcp/Tools` is **file headers** — the `use`
block, the `#[Description]` attribute, the class line and the opening of
`schema()`. Nine near-identical lines per tool that no extraction can
remove. Deleting the duplicated `ruleInSpace` shortened
`DeleteAutomationRule` enough that its header became a clone pair with
another tool's, so the counter stayed flat while the real duplication
went away.

## Testing

`tests/Feature/Mcp` — 46 tests green, plus a new one. Nothing asserted
the resolver failure messages before, and this PR changes how they are
built (a shared template plus an optional hint), so the new test pins
both branches: the message that points at a listing tool (`Call
search_transactions to find ids.`) and the one that has no hint to
append.
2026-08-11 14:35:01 +02:00
Víctor Falcón 6a0c00dce8
refactor(landing): extract the scroll-progress hook every preview duplicated (#768)
## Why

`welcome.tsx` was the single biggest source of duplication in the repo:
**8 clone pairs, ~200 duplicated lines**, all of them the same thing
copied nine times.

Every scroll-animated preview on the landing page carried its own copy
of:

```tsx
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
    const updateProgress = () => { /* measure, clamp 0..1, setState */ };
    updateProgress();
    window.addEventListener('scroll', updateProgress, { passive: true });
    window.addEventListener('resize', updateProgress);
    return () => { /* remove both */ };
}, []);
```

Two of them also duplicated a second effect that measures how far a list
can travel before its last row clears the container.

## What changed

New `resources/js/hooks/use-scroll-progress.ts`:

- **`useScrollProgress()`** — returns `{ ref, progress }`, where
progress goes 0 → 1 as the element crosses the viewport. Nine call
sites.
- **`useScrollTranslate({ speed, padding })`** — the self-scrolling
lists (banks, transactions): adds the travel measurement and returns
`translateY` derived from progress instead of keeping it in a second
piece of state. Two call sites.

`welcome.tsx` goes from 2912 to 2647 lines. No visual or behavioural
change intended: same listeners, same passive flag, same clamping, same
values.

## Metrics

| | before | after |
|---|---|---|
| duplicated lines (tsx) | 5.82% | 5.45% |
| duplicated lines (total) | 5.34% | 5.13% |
| clones | 322 | 313 |
| clones in `welcome.tsx` | 8 | 0 |

## Testing

**Unit** — `use-scroll-progress.test.ts` pins the clamped progress at
three positions (below the fold → 0, passed above → 1, mid-viewport →
the expected ratio). Nothing covered this before.

**Browser** — loaded the landing locally and scrolled every preview into
view, asserting each one's inline styles actually change between "just
entering" and "in view". All nine still animate: the eight that move
`transform` / `opacity` / `top`, plus the cashflow chart, which animates
bar `height`. No JS console errors (only external gravatar/placeholder
404s, pre-existing).
2026-08-11 14:34:24 +02:00
Víctor Falcón a190d37307
feat(deps): upgrade Inertia.js from v2 to v3 (#769)
Upgrades Inertia.js from v2 to v3 on both sides — the major deliberately
left out of #764.

| Package | From | To |
| --- | --- | --- |
| `inertiajs/inertia-laravel` | 2.0.24 | **3.3.1** |
| `@inertiajs/react` | 2.3.17 | **3.6.1** |
| `axios` | transitive | **direct dep** (1.19.0) |

## Why axios becomes a direct dependency

The app imports `axios` in 18 runtime files, but it was never declared —
it arrived transitively through `@inertiajs/core` v2. v3 drops axios in
favour of its own XHR client, so without this the whole app would fail
to resolve the import.

Nothing relied on Inertia configuring axios: v2 never touched
`axios.defaults`, and there are no interceptors. `^1.19.0` also
satisfies core's optional peer range (`^1.15.2`).

## Breaking changes swept

Every v2→v3 breaking change from the [official upgrade
guide](https://inertiajs.com/upgrade-guide) was checked against the
codebase:

| Change | Call sites |
| --- | --- |
| `<title inertia>` → `<title data-inertia>` | fixed (`app.blade.php`) |
| `testing.page_paths/page_extensions` → `pages.paths/extensions` |
fixed (`config/inertia.php`) |
| `ComponentResolver` no longer accepts `Promise<{default}>` | fixed
(`app.tsx`, `ssr.tsx`) |
| `router.on('invalid'/'exception')` → `httpException`/`networkError` |
none — only `'navigate'` is used |
| `router.cancel()` → `cancelAll()` | none |
| `Inertia::lazy()` → `optional()` | none |
| `future` options block | none |
| `hideProgress`/`revealProgress` | none |
| arrow-fn `.layout =` → `[Layout]` | none — no page sets `.layout` |
| deprecated testing traits (`Has`/`Matching`/`Debugging`) | none |
| `qs` / `lodash-es` imports | none |

React 19.2.4, Laravel 13 and PHP 8.4 already satisfy v3's raised floors.

## DevTools kept opt-in

v3 adds a DevTools recorder that writes every request's page props to
`storage/inertia-devtools/` and **defaults to on in local**. On this app
that produced 38 dumps, the largest ~1 MB, holding whole transaction and
balance payloads — redaction only covers credential-shaped keys. It is
gitignored by the package, so it never reaches the repo, but for a
privacy-first product it shouldn't be the default. Gated behind
`INERTIA_DEVTOOLS_ENABLED` (set it to `true` in your own `.env` when you
need it).

## Deliberate behaviour change: `<Deferred>` on partial reloads

v3 no longer resets `<Deferred>` to its `fallback` during a partial
reload — existing content stays visible while fresh data loads. This
affects the dashboard (`onBalanceUpdated`) and account detail
(`handleTransactionCreated`).

**Kept as-is, on purpose.** Two reasons:

1. `dashboard.tsx:131-133` already carried a workaround for exactly this
v2 behaviour — it requests an unrelated cheap prop purely so the
deferred prop isn't refetched into a skeleton. The team already treated
the skeleton flash as a problem and v3 fixes it upstream, so restoring
it via the new `reloading` slot prop would undo an improvement. That
workaround is now redundant and can be simplified in a follow-up.
2. The "user can't tell it worked and re-submits" risk doesn't apply:
`edit-transaction-dialog.tsx:390` fires `toast.success('Transaction
created successfully')` *before* the reload, so the confirmation is
explicit and independent of the list.

## Verification

Full CI set, locally:

- `bun run build` — green
- `bun run test` — **353/353**
- `bun run types` — zero new errors (advisory step; pre-existing backlog
unchanged)
- `bun run format`, `bun run lint`, `bun run dry` — green
- `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` — green
- `php artisan crap --base=origin/main` — 0 methods above complexity 10
- `pest --exclude-testsuite=Browser,Performance` — **2086/2088**
- `pest --testsuite=Browser` — **132/133**

Both test failures are pre-existing and reproduce identically on `main`
with Inertia v2 — I ran each one there to confirm:

- `DashboardTest::dashboard top categories roll child spending up into
the parent` — 409, the known local Inertia asset-version artifact
- `BudgetsFeatureNavigationTest::user cannot access another users
budget` — `Unknown column 'category_id'` on `budgets`, a factory/schema
mismatch

## Browser QA

Real browser sweep against the production build over dashboard,
transactions, accounts, budgets, settings and billing: **0 console
errors, 0 uncaught exceptions, 0 failed requests**. Page titles apply,
deferred props resolve, sidebar `<Link>` navigation stays client-side
with no document reloads, back/forward restores from Inertia history,
and a profile form submit persisted correctly.

## SSR: measured, left alone

v3 routes SSR through the Vite dev server (`/__inertia_ssr`) whenever
Vite is running hot, and we don't use the `@inertiajs/vite` plugin — so
in theory local dev makes a doomed POST per render. Measured it rather
than guessed: with `bun run dev` up, page loads are **16 ms** and the
failed attempt is a connection-refused costing ~0.6 ms, then it falls
back to client rendering. Dev mode renders correctly (React mounts, 0
console errors). Not worth a config knob.

Production is untouched — `Vite::isRunningHot()` is false there, so it
uses the real SSR server from `docker/supervisor/supervisord.conf`
exactly as on v2.

## Notes

- Wayfinder needed nothing: #764 already brought it to v0.1.21 (latest),
and `@laravel/vite-plugin-wayfinder` is on 0.1.7 (latest).
- Pre-existing bug both reviews surfaced, left for its own PR:
`auth/register.tsx:40` passes an `async` handler to `<Form onBefore>`,
which Inertia never awaits (same in v2 and v3), so
`transactionSyncService.clearAll()` is fire-and-forget on registration.
- `config/inertia.php` is hand-patched rather than republished. The v3
stub is ~190 lines; the unset keys resolve to package defaults via
`mergeConfigFrom`. Adopting the whole stub is a separate, reviewable
change.
- Remaining "Inertia v2" strings live inside Boost-generated blocks
(`CLAUDE.md`'s guidelines block and the vendored `SKILL.md` copies).
Hand-edits there get overwritten, so they should come from a Boost
regeneration. The three hand-written references are updated here.

## Demo


https://github.com/user-attachments/assets/3f0e7f6d-621a-4c3a-a2a0-68473ec59f10


<!-- PLACEHOLDER: drag the QA video in here -->
2026-08-11 14:33:31 +02:00
Víctor Falcón 026e61cd6e
ci: add duplication and complexity quality checks (#765)
Adds two code-quality signals to CI, with deliberately different
strengths.

## Duplication (blocks merges)

`bun run dry` runs jscpd as a step in the `linter` job, a required
check, so
copy-paste that pushes duplication above the threshold in `.jscpd.json`
blocks
the merge. Baseline today is 5.36% (PHP 6.01%, TSX 5.82%) and the
threshold sits
at 5.4. jscpd is pinned as a devDependency rather than run through
`bunx`: a
version bump changes the reported number and would fail unrelated PRs.

## Complexity (goes red, does not block)

`php artisan crap` reports cyclomatic complexity per method. A separate
`crap`
job reports the methods a PR touched that exceed complexity 10. It is
**not** a
required check: it goes red so the number is visible, but never blocks a
merge.
Folding it into the `linter` job would turn it into a gate, which is not
the
intent — please keep it out of branch protection.

### Why complexity decides the verdict and not CRAP

CRAP is `c² × (1 − coverage)³ + c`, so it is complexity penalised by
missing
tests. Measured here, it is the wrong signal for readability:

| | Methods over 30 | crapLoad | totalCrap |
|---|---:|---:|---:|
| Whole suite with real coverage | 15 (0.99%) | 100 | 5,031 |

Because `app/` is well covered, CRAP mostly ranks what is untested. The
two
rankings barely overlap:

| Rank | By CRAP | By complexity |
|---|---|---|
| 1 | `VerifyRefundFlowCommand::handle` (c=14, 0%) |
`ExperimentFunnelCollector::collect` (c=35, 99%) |
| 2 | `StripeCustomerResolver::label` (c=10, 0%) |
`DashboardAnalyticsController::accountBalanceEvolution` (c=30, 91%) |
| 3 | `WiseTransactionSyncService::parseActivity` (c=8, 0%) |
`UpdateTransaction::write` (c=20, 75%) |

The most complex method in the codebase — 35 branches — is 99% covered,
so it
scores CRAP 35 and lands 11th of 15, below an untested enum `label()` of
complexity 6. An agent guided by CRAP would write tests for a console
command
and leave the 35-branch method alone. So complexity triggers the check,
and CRAP
plus per-method coverage travel in the output as context for *how* to
fix it.

### Threshold

10, McCabe's number, just above this codebase's p95 of 8 (median 1, p99
14,
max 35). It fires only on methods a diff touches, so the 35 existing
offenders
only matter when someone edits them.

## The command

```bash
php artisan crap                                      # whole project, ranked
php artisan crap --base=origin/main --no-coverage     # what CI checks, <1s
php artisan crap --base=origin/main --json            # for agents
```

- `--no-coverage` skips the coverage report and gives the same verdict,
since
complexity alone decides it. Without it, a crap4j report is required and
the
command refuses to guess when it is missing, printing the exact command
to
  generate one.
- Exemptions live in `.crap-ignore.json` keyed by method, with a reason
that is
read in review. Entries that are no longer needed get reported for
deletion.
- Untracked files count whole — a new feature is mostly new files and
would
  otherwise sail through unmeasured.
- Exit codes: 0 clean, 1 over threshold, 2 unusable input.

The counter is php-code-coverage's own
`CyclomaticComplexityCalculatingVisitor`,
so the numbers match the CRAP it reports: **1502 of 1503 methods
agree**. Its
wrapping visitor is not reused because that one asserts a method's
parent is a
class or a trait, which fails on enums.

## Notes for review

- `pcov.directory` is set explicitly in the tests job. Left to
autodetect, pcov
picks `src`, which does not exist in a Laravel app, and every method
silently
reports 0% coverage — which is how the first measurements of this metric
came
  out wrong, with plausible-looking numbers.
- The `crap` job skips rather than fails when `tests` fails: without the
coverage
  artifact, a red `crap` job would say nothing about complexity.
- The two checks cover each other's blind spot. Splitting a complex
method into
near-identical pieces to lower complexity raises duplication, and that
check
  does block.
- **Not verified:** that paratest merges coverage across its 4 processes
in CI.
Locally only the serial run works — in parallel each worker starts its
own
MySQL testcontainer and they time out. If it misbehaves, the symptom is
empty
  crap/coverage context columns, not a wrong verdict.
- Scope is PHP only. `resources/js` (392 files, 72k lines vs 435/37k in
`app/`)
has no complexity pipeline; the JSON says so explicitly rather than
letting
  "whole project" be assumed.

## Testing

10 Pest tests covering the threshold verdict, the CCN counting rules
(including
match arms and closures nested in a method), enum methods, exemptions,
stale
exemptions, the missing-report refusal, the crap4j join, and the
reported scope.
`pint --test` and the full `phpstan` run are clean.
2026-08-11 13:29:37 +02:00
Víctor Falcón 2428ecc2c1
chore(deps): update composer dependencies to latest (#764)
Brings every composer dependency to its latest available version,
Laravel included.

## What moved

Laravel 13.1.1 -> 13.24.0, plus every other in-range minor/patch
(cashier 16.5 -> 16.7, fortify, pennant, sanctum, tinker, sentry,
aws-sdk, resend 1.1 -> 1.7, larastan, pint, sail...).

Four majors that were being held back:

| Package | From | To | Note |
| --- | --- | --- | --- |
| laravel/mcp | 0.6.7 | 0.9.3 | Also unblocks boost 2.5.3 + wayfinder
0.1.21, which both conflict with mcp < 0.7 |
| laravel/ai | 0.7.2 | 0.10.3 | |
| pestphp/pest | 4.7.8 | 5.1.0 | Pulls phpunit 13; suite runs 25% faster
(380s -> 296s) |
| intervention/image | 3.11.8 | 4.2.1 | |

## Code the upgrades required

- **`SetBankLogoCommand`** — intervention v4 dropped
`ImageManager::read()` and `Image::toPng()`; now `decodeBinary()` and
`encode(new PngEncoder)`.
- **`GenerateStripePromotionCodesCommand`** — cashier 16.7 pulls
stripe-php v17.6 -> v20.3.1, which pins the API to `2026-06-24.dahlia`.
Promotion codes there take a nested `promotion: {type, coupon}` instead
of a top-level `coupon`. Caught by phpstan, not by a test.
- **`McpOAuthTest`** — mcp 0.9 returns 201 for dynamic client
registration (RFC 7591 says Created) instead of 200.
- **`SubscriptionExperimentTest`** — phpunit 12.5.33 stopped reindexing
arrays inside `assertEqualsCanonicalizing`, so the gapped keys
`array_unique` leaves behind broke the comparison. No entry in phpunit's
changelog, so this looks unintentional on their side; `array_values`
sidesteps it either way.

## Stripe API version

Worth flagging because it is the money path: the SDK sends
`Stripe-Version` on every request, so our own calls now speak dahlia
while webhooks keep arriving in whatever version the account is pinned
to. All 13 direct call sites were reviewed against the v20 shapes
(prices, promotionCodes, customers, subscriptions, paymentIntents,
coupons) — promotion codes were the only breakage, and phpstan is clean.

Separately, `PostStripeEventToDiscord` reads `current_period_end` off
the subscription object, which recent API versions moved onto
`items.data[]`. Pre-existing and cosmetic (a Discord line is silently
skipped), so it is left alone here.

## Left out

`inertiajs/inertia-laravel` 2.0 -> 3.3. It needs `@inertiajs/react` 3
alongside it plus a full UI pass, so it gets its own PR.

## Verification

- Full suite (Browser excluded): 2102 passed, 1 skipped, 1 incomplete
- Performance suite: 25 passed
- phpstan: clean · pint: clean · prettier/eslint: clean
- Browser suite (pest-plugin-browser 4.3 -> 5.0) and `bun run types` are
left to CI — the worktree has no build, and local `--parallel` can't run
because each paratest worker boots its own testcontainer whose `testing`
user lacks CREATE.
2026-08-11 11:15:27 +00:00
Víctor Falcón e6cb0459f6
fix(binance): value past days from Binance's own snapshot total (#763)
Last of the crypto-pricing family, after #759 and #761. The issues queue
has produced nothing new for four cycles, so this came out of reading
the code the logs pointed at.

## What was wrong

`syncHistoricalBalances` rebuilds past daily rows in `account_balances`
— the data behind the net-worth chart. It priced each holding by handing
its raw ticker to `CurrencyConversionService`:

```php
$converted = $this->currencyConverter->convert($asset, $targetCurrency, $quantity, $date);

if ($converted == 0.0) {
    $skippedAssets[$asset] = true;   // and the holding leaves the day's total
    continue;
}
```

That provider carries fiat currencies and a couple of majors. Everything
it did not recognise was dropped from the day's total, so an altcoin
portfolio produced a chart history far below what the user actually held
— while **today's** balance was correct, because the live path prices
through Binance's own tickers. The new test demonstrates it: a day
holding 10 SOL comes out as **0** on the old code.

I first went looking for the USD hop that fixed the Coinbase equivalent,
and there isn't one to add here — a past day needs a *dated* price, and
both the asset leg and any USD leg would come from the same provider
that does not know the asset.

## What it does instead

The snapshot already carried the answer and the service was throwing it
away. Binance sends `totalAssetOfBtc` — what it valued the whole spot
account at, on that day — in the same `data` object as the balances. One
BTC→fiat conversion, on the one ticker the rate provider always covers,
replaces the per-asset loop and covers every holding including the ones
nothing else can price.

Both are spot-only (`accountSnapshot?type=SPOT` and `api/v3/account`),
so historical and current days stay on the same basis — no discontinuity
at the join.

Second commit is a review follow-up: the day's balance now rests
entirely on that one field, so a response that stops carrying it gets
its own counter in the summary log rather than looking like a day with
no snapshot.

## ⚠️ Correction to the ops advice I gave in #759 and #761

I twice suggested `php artisan banking:sync --full` to repair the
understated Coinbase rows. **Do not run that unscoped.** With no filter
it forces `isFirstSync = true` for *every* active connection, which
would also rewrite up to 180 days of history for all 11 Binance accounts
in one shot as a side effect. `--connection=<id>` already exists — use
it:

```
php artisan banking:sync --full --connection=<coinbase-connection-id>
```

For Binance the same command is the repair, but it should be a
deliberate decision per connection, not collateral damage. **Nothing
repairs the existing 11 accounts automatically**: incremental sync only
fills the gap after `MAX(balance_date)` and never revisits older rows,
so their charts stay understated until someone asks for it.

## Deliberate behaviour changes, called out

- **An empty day now charts as zero.** Previously a snapshot with no
balances was skipped, leaving a gap that the frontend forward-fills — so
a day the account was actually empty showed the *previous* non-zero
value. It now writes 0. More correct, but it is a change beyond the
stated fix.
- **A day whose BTC value is positive but unconvertible is left alone**
rather than written as zero, so it disappears into the frontend's
forward-fill instead of reading as a portfolio that briefly vanished.

## Tests

`historical sync values a holding the rate provider cannot price` — a
day holding SOL with deliberately no `sol` rate available. **Verified to
fail on the old implementation: 0 instead of 100000.**

The three touched historical fixtures gained `totalAssetOfBtc` and keep
their original expected cents, recomputed on the new basis (2.0 BTC ÷
0.000019 = 10526316; 1.0 ÷ 0.000018 = 5555556; 0.02 ÷ 0.00002 = 100000).

`tests/Feature/OpenBanking` green at 341/341 locally.

## Not covered

Coinbase's historical path already prices via dated per-asset candles
with a USD route, so it is not exposed to this at the same scale, and
Coinbase exposes no portfolio-level BTC equivalent to substitute.
Bitpanda has no historical sync at all.
2026-08-11 11:04:06 +00:00
Víctor Falcón 9e493dc75b
feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760)
## What

We had no idea whether anyone actually uses the MCP server. This records
one row per tool call and adds a report to read it back: which tools get
used, by which users, and how much.

**Storage** — `mcp_tool_calls`: `user_id`, `tool`, `created_at`. Raw
rows rather than pre-aggregated counters, because the volume is a
handful of calls per Pro user per day and a `GROUP BY` then answers
whatever we want to ask later. No arguments and no financial content are
stored.

**Recording** — one `rescue()`d insert in `McpTool::handle()`, the base
class all 24 tools inherit with no overrides, so coverage is complete by
construction. It sits *after* `respond()` and skips error responses, so
the number means "calls that did something": a plan-gate rejection, a
read-only token rejected on a write, or a `ValidationException` for an
unreachable id is an attempt, not usage. `rescue()` keeps a failed
insert from ever breaking a working tool call while still reporting to
Sentry.

**Reading** — `php artisan stats:mcp-usage [--days=30] [--top=20]`:

```
MCP usage — last 30 days (since 2026-07-13)
  Calls: 15   Users: 2

By tool
+---------------------+-------+-------+-------+
| Tool                | Calls | %     | Users |
+---------------------+-------+-------+-------+
| search_transactions | 10    | 66.7% | 2     |
| get_cashflow        | 2     | 13.3% | 1     |
| get_net_worth       | 2     | 13.3% | 1     |
| create_transaction  | 1     | 6.7%  | 1     |
+---------------------+-------+-------+-------+

By user (top 20)
+--------------------------------------------+-------+-------+---------------------+
| User                                       | Calls | Tools | Last call           |
+--------------------------------------------+-------+-------+---------------------+
| ana@example.com                            | 12    | 4     | 2026-08-11 08:06:59 |
| 20260811080659_bruno@example.com (deleted)  | 3     | 1     | 2026-08-11 08:06:59 |
+--------------------------------------------+-------+-------+---------------------+

By day
+------------+-------+-------+
| Day        | Calls | Users |
+------------+-------+-------+
| 2026-08-08 | 2     | 1     |
| 2026-08-11 | 13    | 2     |
+------------+-------+-------+
```

The per-user table joins `users` instead of eager-loading the relation:
`user:delete` soft-deletes, so the FK cascade never fires and the
relation's `deleted_at is null` scope would silently blank out exactly
the users we most want to see — the ones who churned. They render with a
`(deleted)` marker.

## QA

Driven through the real `/mcp` HTTP endpoint with real Sanctum bearer
tokens, against MySQL:

- 5 successful calls by a read+write user → 5 rows, right user, right
tool names.
- `get_net_worth` with missing arguments and `search_transactions` on an
unreachable space → both rejected, neither recorded.
- A read-only token: `list_accounts` recorded; `create_label` rejected
with "This token is read-only" and not recorded.
- Report checked at `--days` 1 / 30 / 200, with `--top 1` truncation,
with a churned (`markAsDeleted()`) user, and with no data at all.

48 MCP tests green, `pint` and `phpstan` clean.

## Deliberately left out

- **No Discord post or schedule.** The other `stats:*` commands post
weekly; whether MCP usage is worth that noise is a product call, and
it's one `Schedule::command()` line whenever we want it.
- **No client column.** `Auth::getDefaultDriver()` would tell us OAuth
(Claude Desktop / ChatGPT) vs personal token (Claude Code) at the
insert. It's not recoverable after the fact, so it's worth knowing we
skipped it — but it wasn't asked for.
- **No pruning.** Noted in the migration; add it if the table ever gets
big.
- **No collector service.** The sibling report commands extract one
because they feed both the console and Discord. This has one consumer.
2026-08-11 10:27:32 +02:00
Víctor Falcón 1a0e80a342
fix(coinbase): stop one unlisted stablecoin from unpricing the whole portfolio (#761)
Follow-up to #759, which turned out to be **inert in production**.
Confirmed from the logs after it deployed.

## What is actually happening

`fetchPriceMap` builds `{ASSET}-{QUOTE}` product ids for a batched
`best_bid_ask` call. **EURC** — Circle's euro stablecoin — is not an ISO
4217 code, so `isFiatCurrency()` rejects it, and the stablecoin list was
USD-only, so it went out as an ordinary crypto product id.

Coinbase does not list `EURC-EUR`, and it refuses the **entire** request
over one unknown id:

```
invalid product_id provided: "EURC-EUR"
```

Since #759 that happens twice per run — once for the fiat pass, once for
the new USD retry (`invalid product_id provided: "EURC-USD"`). So
`fetchPriceMap` has been returning an empty map on **every** sync,
#759's USD hop never contributed anything, and every holding fell
through to the per-asset `CurrencyConversionService`, which prices
almost no crypto. The post-#759 log signature is 2× `Coinbase API error`
(400) + 2× `Coinbase best_bid_ask failed` per run, and `Could not price
Coinbase asset` never dropped.

The balance is still written, so net worth and the chart have been
showing a badly understated number.

## Three commits

1. **EURC settles at its euro peg.** The USD-only list became a peg map
(`asset => peg currency`), which also collapses three duplicated
`in_array` branches into one lookup that carries the peg.
USDT/USDC/DAI/PYUSD/GUSD are byte-for-byte unchanged.
2. **A rejected batch retries one asset at a time.** Fixing EURC removes
today's bad id, but not the shape of the failure: one unlisted holding
takes the price of every other holding with it, and the next unlisted
token would do it again. The batch is now an optimisation rather than a
single point of failure.
3. **Fan out only when the batch itself was refused.** Both reviews
caught this independently, and it was a real bug I introduced in commit
2: retrying on *any* throwable multiplies `CoinbaseClient`'s own 429
backoff (10s/30s/60s ≈ 100s per call) by the number of holdings, against
`SyncBankingConnectionJob`'s 120s timeout — trading a fast wrong answer
for a sync that hangs and dies. The fan-out now requires a 4xx that is
not 429.

## Tests

Three new, each verified to fail without its fix:

| test | without the fix |
|---|---|
| `settles EURC at its euro peg instead of asking Coinbase to quote it`
| 5 000 000 instead of 5 010 000 — the EURC vanishes |
| `prices the rest of the portfolio when Coinbase rejects one product
id` | **0** — the entire portfolio unpriced, which is what production
does today |
| `gives up on a rate-limited price batch rather than retrying every
asset` | fans out and sends the per-asset requests |

The 429 test fakes `Sleep`; without it the real backoff makes the file
take 209s instead of 10s.

`tests/Feature/OpenBanking` is green at 341/341 locally.

## Deliberately not here

- **Already-written rows stay understated.** `needsHistoricalBackfill`
is already satisfied by the wrong rows, so the next sync only heals
today's. The chart will show a discontinuity on deploy day. No new code
needed to repair it — `SyncBankingConnectionJob` already has a
`fullSync` flag that forces the historical pass, reachable as `php
artisan banking:sync --full`. Replaying a year of pricing on real
accounts is your call, not something this loop should do unattended.
Blast radius: 2 Coinbase connections, both EUR.
- **A USD-currency user's EURC** used to be quoted against the real
`EURC-USD` pair and now goes peg + FX instead. Inert today (all crypto
connections in production are EUR) and consistent with how USDT/USDC are
already handled, but it is a silent pricing change for a future USD
holder.
- **Anything not in the peg map and not quotable is still silently
zero.** This fixes EURC by name; the general "report an incomplete
balance as if it were complete" behaviour needs a product decision.
- **`BinanceBalanceSyncService`'s historical path** still hands a raw
crypto ticker to the fiat converter with no USD hop (its live path is
fine). Same class, own PR — I started on it this cycle and dropped it
when the logs showed this was live and Binance's was not.
2026-08-11 08:24:14 +00:00
Víctor Falcón 5eb43067f8
fix(banking): stop crypto holdings quoted only in USD from vanishing from the balance (#759)
Found in the Sentry **logs**, not the issues queue — this class of bug
never throws, it just quietly reports the wrong number. `Could not price
Coinbase asset` has been firing steadily, 24 times in the last 7 days.

## What happens

`CoinbaseBalanceSyncService::fetchPriceMap` only ever asked Coinbase for
`{ASSET}-{USER_CURRENCY}` price books. Coinbase lists most assets
against USD alone, so for a EUR user those come back empty.
`convertCryptoAssets` then falls through to
`CurrencyConversionService::convert('SOL', 'EUR', …)` — a **fiat** FX
service that has no rate for a crypto ticker and returns `0.0`.

The holding contributes nothing. The balance is still written to
`account_balances`, so it feeds net worth and the balance chart as a
confidently wrong, lower number — indistinguishable from the user
genuinely holding less.

The historical backfill never had this problem:
`fetchHistoricalPricesForAsset` already retries against USD and
converts. So the same wallet is priced correctly for every past month
and collapses on today's balance — a cliff at the right-hand edge of the
chart rather than an obviously broken figure.

**All three Coinbase/Bitpanda/Binance users in production are on EUR**,
so the Coinbase half of this is live for both Coinbase connections.

## The commits

1. **Coinbase**: the live path takes the same USD hop the historical
path already takes, for whatever the fiat pricebook did not cover — one
extra batched request, and only when something is actually missing.
2. **Bitpanda**: same defect one service over. `getTickerPrice` read
`$ticker[$symbol][$targetCurrency]` and gave up on a miss, and the
caller logs and `continue`s, dropping the wallet entirely. Bitpanda's
ticker only quotes EUR, USD, CHF, GBP and TRY — so every other supported
currency reads **zero for every crypto wallet**, which now includes DKK
(added in #754). No user is on such a currency with a Bitpanda
connection today, so this one is a landmine rather than a live bug; I
fixed it because it is the same one-line shape and it silently
misreports money.
3. **Review follow-ups** (no behaviour change): stop quoting stablecoins
that `convertCryptoAssets` settles at 1 USD before it ever reads the
map; guard the empty batch inside `fetchBestBidAskPrices` rather than
relying on the caller; drop the now-untrue "falling back to per-asset
USD conversion" tail from the catch message.
4. **Mixed-batch test** — the single-asset test never exercises the
`array_diff` that decides what the USD pass asks for.

## Safety

- USD users short-circuit before any USD hop. Assets that already have a
fiat book are excluded from the retry and never touch the new path.
Stablecoins are intercepted before the map is consulted.
- If the second request throws, or the FX rate for the date is missing,
the `$targetPrice > 0` guard drops it and the asset falls through to
exactly today's behaviour. The change can only add a price, never
replace a good one.
- Money math verified against `CurrencyConversionService::convert`,
which **divides** by a rate keyed to the target currency as base: 100
USD ÷ 2.0 = 50 EUR per unit, then multiplied by quantity.

## Two things this does NOT do

**Already-written rows stay wrong.** The next sync overwrites only
today's `balance_date` row. `needsHistoricalBackfill` re-runs the
historical pass only once, so every daily row written since the account
was connected keeps its understated value — 102 rows on one Coinbase
account, 16 on the other. The existing `php artisan banking:sync --full`
re-runs the historical pass and would repair them; I did not run it,
since replaying a year of pricing for real accounts is an ops call, not
something an autonomous loop should do unattended.

**An asset Coinbase does not quote in USD either still vanishes
silently.** It logs and contributes zero, with nothing surfaced to the
user. Pricing an unquotable asset is not solvable here, but reporting a
balance we know is incomplete as if it were complete is a product
decision worth taking: flagging the account as partially priced would be
the honest behaviour.

## Follow-up found in review, deliberately not here

`BinanceBalanceSyncService`'s **historical** path (`:145`) also hands a
raw ticker to the fiat converter with no USD hop, dropping zeros into a
`skipped_assets` log. Its live path is correct, so today's balance is
right and only the one-time historical anchors are affected — a smaller,
different blast radius that deserves its own change.

## Tests

`prices an asset Coinbase only quotes in USD instead of dropping it from
the balance`, `mixes fiat-quoted and USD-only assets in one balance`
(asserts the second request carries `SOL-USD` and does not re-quote
`BTC`), and `prices a wallet Bitpanda does not quote in the user
currency via USD` (a DKK user).

**I could not run the PHP suite locally — the local Docker daemon is
wedged, so the MySQL testcontainer never starts. Relying on CI.** Pint
and `php -l` are clean.
2026-08-11 02:56:03 +00:00
Víctor Falcón a500c00b2b
fix(ui): show a recoverable screen instead of a blank page when render throws (#758)
Relates to
[PHP-LARAVEL-5A](https://whisper-money.sentry.io/issues/PHP-LARAVEL-5A).

## Why

The app has never had a React error boundary. Any throw during render or
in an effect unmounts the whole tree, so the user gets a blank white
page — no message, no way out, and nothing telling them to reload.

That is not hypothetical, it is how **every** frontend crash we have
fixed this year presented itself: #41 (`addEventListener` on old
Safari), #43 (missing `indexedDB`), #47 (recharts render loop), #57 /
#4Y (`localStorage` null in a restricted webview), #675
(`colorClasses.bg`). Each got a targeted fix, and the next unguarded
property access white-screened the app again. PHP-LARAVEL-5A is the
current one: `props.features` arrives undefined on `/dashboard` and
`props.features.cashflow` takes the page down.

**This does not fix 5A's root cause.** I could not confirm why
`props.features` goes missing — the server always shares it, and the
only mechanism I found is Inertia core's `mergeProps` bailing out when a
partial response's component differs from the current page, which I
could not reproduce. What this does is stop that class of bug from
costing the user their whole session.

## What it does

Wraps the tree in `@sentry/react`'s `ErrorBoundary` — already a
dependency, no new packages — with a fallback offering a reload and a
way to the dashboard.

Two things the boundary has to take over, because once React handles an
error it never reaches `window.onerror`:

- **Reporting.** `Sentry.ErrorBoundary` captures on its own via
`captureReactException`, and adds the React component stack. Note this
will fingerprint as a *new* issue rather than continuing 5A, since the
exception now carries a synthetic cause.
- **The stale-chunk reload.** `chunk-load-recovery`'s global listeners
only see what React did not catch, so `onError` re-runs
`reloadOnChunkLoadError`. Its actual reload-once behaviour stays covered
by `chunk-load-recovery.test.ts`.

## The two follow-up commits are the interesting ones

Both came out of review and both were real bugs in the first commit:

**`handled` was silently flipped.** The SDK derives `handled` from
whether a fallback was supplied (`errorboundary.js:51`), so simply
adding one would have reclassified every caught crash as handled — they
would stop counting against crash-free sessions and stop matching any
`error.handled:false` alert. Now passes `handled={false}` explicitly.

**Both escape hatches were dead ends.**
- "Back to home" pointed at `/`, which renders the marketing page — and
that page mounts `AuthenticatedRedirectDialog`, which `router.visit()`s
a signed-in user to the dashboard on a 3s timer. The escape hatch
trampolined the user straight back into whatever crashed. It now goes to
the dashboard directly, as a document load.
- The **back button** was worse. Inertia's popstate listener lives at
module scope and outlives React unmounting the page, so going back
changed the URL and painted nothing — the screen just looked frozen. The
fallback now reloads on `popstate`.

## Scope, honestly

- `initializeTheme()` and `initializeChartColorScheme()` run at module
scope, **before** the boundary mounts, so the boot-crash class (#57 /
#4Y) is still outside its reach. Those are already fixed by #743's
`safe-storage`; worth knowing the boundary is not a second net for them.
- One boundary at the root means a crash anywhere replaces the whole app
rather than just the page. Since React unmounts the entire tree on an
uncaught error anyway, the trade is white screen → error screen, not
working page → error screen. A second boundary around `<App>` only would
let the shell survive and make `resetError()` meaningful — worth doing
if this ever fires often enough to matter.
- `ssr.tsx` keeps its own unwrapped copy of the provider tree.
Pre-existing duplication, now one element wider; extracting a shared
`AppProviders` is the fix.
- No reload-loop guard. "Try again" is user-initiated, not automatic, so
a deterministic crash means the button does nothing rather than looping
— and the dashboard button is the real way out.

## Tests

`app-error-boundary.test.tsx`: children render normally; a throwing
child produces the recovery screen with both actions instead of nothing;
the error is handed to the chunk-load recovery; the popstate listener is
registered and cleaned up. 4/4 locally, plus
`chunk-load-recovery.test.ts` 5/5.

Copy uses existing `lang/es.json` keys except one new line, which is
added.
2026-08-10 15:02:39 +00:00
Víctor Falcón 81c32b887b
fix(banking): stop Wise outages from silently parking a bank connection (#757)
Fixes
[PHP-LARAVEL-58](https://whisper-money.sentry.io/issues/PHP-LARAVEL-58).

## What production looks like

There is exactly one Wise connection in production and it has **never
synced** — `last_synced_at` is null. Its entire history is three failed
attempts:

| when | error |
|---|---|
| 2026-07-29 | `ConnectionException` — cURL error 28, timed out after
30s |
| 2026-08-03 | `ConnectionException` — cURL error 28, timed out after
30s |
| 2026-08-10 | `RequestException` — HTTP 500 from
`/v1/profiles/{id}/activities`, mid-cursor |

All three are upstream. All three were charged to
`consecutive_sync_failures`, now at **2**. At `MAX_SCHEDULED_RETRIES =
3` both `SyncAllBankingConnectionsJob` and `sync:banking` stop
dispatching the connection — permanently, with no email and no reconnect
notice in the UI. The next Wise outage would have been the last sync
this user ever got.

## Three commits

**1. Classify Wise timeouts and 5xx as transient.** `WiseClient` rethrew
everything raw, so an outage looked like an application bug: reported to
Sentry at error level and logged as `Wise API error`. Timeouts and 5xx
now raise `TransientBankingProviderException`, matching
`EnableBankingProvider` (PR #678). The job already understands that type
— warning instead of error, `ShouldntReport`, and a "temporarily
unavailable" message for the user. **401/403 and 429 deliberately stay
raw**: `isAuthError` and `isRateLimitError` match on `RequestException`,
and `resolveRateLimitBackoffUntil` reads `Retry-After` off
`$e->response`. The three request methods now share one private `get()`,
which is where the classification lives.

**2. Keep an empty body from becoming a TypeError.** Introduced by
commit 1: routing everything through `get(): array` turned a 200 with an
empty body into a `TypeError` — neither transient nor suppressed, i.e.
exactly the noise this branch removes. It used to degrade to `[]` via
`$accounts[0] ?? []`. Caught by review.

**3. Stop provider outages from parking a connection for good.** Without
this the branch is only a log-level change: `handleTemporaryError`
incremented the counter for any non-auth throwable, so the
classification never reached the user. A transient failure now surfaces
on the connection (status and message unchanged) without spending a
scheduled retry. Unclassified failures still count, so a genuine defect
still parks the connection.

No data migration needed — the connection is at 2, still under the cap,
so it stays eligible and the counter resets on its first success.

## Trade-off, stated plainly

A provider that is down forever is now retried forever: one job per
cycle, surfacing as a repeating `Banking sync failed` warning. That is
cheap and visible, and strictly better than silently never syncing a
user's bank. Marked with a `ponytail:` comment naming the ceiling.

The flip side is that a **persistent** Wise breakage (an API contract
change, say) now produces no Sentry issue — only warn-level logs and
`banking_sync_logs` rows. Worth knowing before assuming silence means
health.

## Tests

- `WiseTransientErrorTest`: 500 mid-cursor and a cURL 28 timeout both
become `TransientBankingProviderException`; 401/403/429 (dataset) stay
`RequestException`; an empty body still degrades to `[]`.
- `SyncRetryAndLoggingTest`: a transient failure at
`consecutive_sync_failures = 2` leaves it at 2 and below the cap; a
**non**-transient one still takes it to 3.

Local: 490/490 across `tests/Feature/OpenBanking`, `tests/Feature/Jobs`,
`tests/Unit`.

## Follow-ups (found in review, deliberately not here)

- `WiseClient` sets no timeouts; `EnableBankingProvider` uses
`timeout(20)->connectTimeout(5)`, IB uses 5/15. Two of the three prod
failures were 30s timeouts, so bounding them is worth a look — but
shortening the window on a 12-month first sync needs its own thinking.
- `EnableBankingProvider` logs its 5xx at `error` while raising the same
transient exception. Wise is the consistent one here; EB should follow.
- The `ConnectionException` + 5xx catch skeleton now exists three times.
Rule of three is reached — a shared trait would also let
Binance/Coinbase/Bitpanda/IndexaCapital adopt it.
- Both credential-validation call sites catch `\Throwable`, so
connecting Wise during an outage still tells the user "Invalid API
token". Now cheaply fixable by catching the transient type first.
2026-08-10 16:40:09 +02:00
Víctor Falcón 5baa677c2a
fix(banking): stop requesting a year of Enable Banking history on every sync (#755)
## Why

Enable Banking connections re-requested **a year of transaction history
every 6 hours**. Trade Republic connections failed ~70% of their syncs
with HTTP 429 (220 of 307 attempts in the last 7 days, against 1–4% for
other ASPSPs), and the cause was ours:

1. `EnableBankingSyncer::sync()` persisted the transactions, then the
balance call threw a 429, the exception bubbled up to
`SyncBankingConnectionJob`, and the whole run was marked failed.
2. `last_synced_at` is only written after a clean `sync()`, so it never
got written. 15 of 16 Trade Republic connections have zero rows in
`account_balances` and `last_synced_at = NULL` weeks after connecting.
3. `$isFirstSync = ! $connection->last_synced_at || $this->fullSync` was
therefore permanently true, so every run used `now()->subYear()` with
`strategy = 'longest'` plus `calculateHistoricalBalances()`. Paginating
a year of history four times a day is what trips the rate limit.

This is **not Trade Republic specific**: CaixaBank and Eurocaja Rural
show the same 429 pattern at lower volume, and the fix applies to every
Enable Banking ASPSP.

## What changed

All in `app/Services/Banking/Sync/EnableBankingSyncer.php`.

**1. The fetch window comes from the transaction watermark, not from
`last_synced_at`.**

The `linked` branch already did this; the lookup is now shared by both
branches:

- **Watermark found** → `date_from` = that transaction's date minus a
3-day overlap (banks post transactions with retroactive value dates, so
the previous no-overlap watermark could silently miss them), no
`longest` strategy.
- **No watermark** → unchanged: one year back with `strategy =
'longest'`. That is the genuine first sync.

This is the fuse: even if everything else fails, a routine sync asks for
a few days instead of a year. The two branches' differing balance
handling (`saveDailyBalances: false` for linked accounts,
`calculateHistoricalBalances()` on first sync for unlinked) is
untouched.

**2. A failing balance call no longer fails the whole sync.** It is
logged, counted, and surfaced as `balance_failed` in the array `sync()`
returns, so it lands in `banking_sync_logs.metadata` instead of being
silently swallowed. The run finishes clean → `last_synced_at` gets
written → `$isFirstSync` stops being permanently true →
`calculateHistoricalBalances()` stops running every 6 hours.

**Two exceptions are deliberately still fatal** (both raised by review,
see below): an expired session, and a **429**.

## Deviation from the brief, worth a look

The brief asked for *every* balance failure to be non-fatal, including
the 429. Both review passes flagged the same problem with that: a 429
escapes `EnableBankingProvider` as a raw `RequestException`, and that is
exactly what `SyncBankingConnectionJob::isRateLimitError()` matches to
set `rate_limited_until`. Swallowing it would have removed the only
backoff — and Enable Banking quotas are **per-consent daily access
counts** (`Maximum daily access exceeded`, `Allowed number of accesses
exceeded for consent`), so a connection that lost its backoff would keep
burning the remaining quota on every 6-hourly cycle and never get its
balances.

So a balance 429 is re-thrown and the existing backoff (untouched, as
the brief required) applies. The transactions from that run are still
persisted, and the connection stays `active`. Every other balance
failure is non-fatal as specified.

Three more findings from review, fixed in the second commit:

- **`--full` still forces the year-wide window.** A first sync on a
connection that has already synced can only come from that flag, so it
beats the watermark. Without this, `banking:sync --full` had become a
no-op for the window — the operator's only remedy for a gap in history.
- **Windows reaching back more than 90 days keep `strategy =
'longest'`.** A dormant account with an old watermark would otherwise be
rejected (422) and walked down the `[90, 30, 7]` narrowing ladder, which
advances the watermark past the span it never fetched — a silent,
permanent gap.
- **The watermark counts trashed rows** (the dedup already uses
`withTrashed()`, so re-fetching them creates nothing), and the
future-date clamp no longer eats the 3-day overlap.

## Out of scope

No migration, no new watermark column, no change to the
`rate_limited_until` backoff, no manual reset of the broken production
connections — the first clean sync clears `error_message` on its own.

Two things worth a follow-up, not fixed here:
`calculateHistoricalBalances()` is still gated on the connection-level
`$isFirstSync` while the window is now per-account, so an account added
to an already-synced connection pulls a year of transactions without a
balance backfill; and 61 accounts have never received a bank transaction
at all, so they stay on the year-wide window until one lands.

## QA

Ran the whole chain with nothing mocked but the network (real
`EnableBankingProvider`, `Http::fake`), checking the request that
actually leaves for the bank:

| Case | Request that goes out |
|---|---|
| Account with a watermark at `2026-08-08`, today `2026-08-10` |
`…/transactions?date_from=2026-08-05&date_to=2026-08-10` — 5 days, no
`strategy` |
| Account with no bank transactions |
`…/transactions?date_from=2025-08-10&date_to=2026-08-10&strategy=longest`
— unchanged |
| Balances returns 429 `Maximum daily access exceeded` | transactions
persisted, `status = active`, `rate_limited_until = 2026-08-11 00:00:00`
(next UTC midnight) |

Against the production database (read-only), for the 475 syncable Enable
Banking accounts:

| After the fix | Accounts | Avg. days requested |
|---|---|---|
| Watermark → short window | 382 | 13.7 |
| No watermark → 1 year + `longest` | 61 | 365 |
| Watermark older than 90d → wide + `longest` | 32 | 173 |

All 12 Trade Republic accounts have a watermark, averaging **7.1 days**
— down from 365 on every run.

## Tests

`tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php`:

- an account with existing Enable Banking transactions asks for
`watermark - 3 days`, not a year (the test that matters)
- an account without them still asks for the year with `longest`
- `--full` beats the watermark
- a non-429 balance failure → connection stays `active`,
`last_synced_at` written, the run's transactions persisted,
`balance_failed: 1` in the sync log metadata
- a 429 balance failure → transactions persisted and the backoff still
applied
- an expired session during the balance call is not swallowed

Full suite green (2080 passed), `pint` and `phpstan` clean.
2026-08-10 15:56:45 +02:00
Víctor Falcón 33fc058f3c
fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756)
Fixes
[PHP-LARAVEL-5B](https://whisper-money.sentry.io/issues/PHP-LARAVEL-5B).

## What broke

`demo:reset` seeds a fabricated Stripe subscription so the demo account
gets Pro access without touching Stripe. The id was the hardcoded
literal `sub_demo_free_forever`, and `subscriptions.stripe_id` is
unique.

`--email` (#753, shipped this morning) made a second seeded account
possible. `createSubscription()` deletes only the *current* user's
subscriptions before inserting, so the first `demo:reset --email` run
after the public demo account existed died on:

```
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry
'sub_demo_free_forever' for key 'subscriptions.subscriptions_stripe_id_unique'
```

`createSubscription()` is the last step of `handle()`, so the reviewer
account was left fully seeded but with **no subscription at all** — an
app-store reviewer signing in lands on the paywall instead of the Pro
app.

## The fix

Derive the fake id from the user: `sub_demo_{$user->id}`. Matches what
`E2eBankingFixtureCommand` already does (`sub_e2e_.$user->id`). Nothing
reads the old literal — entitlement goes through Cashier's
`subscribed('default')`, which only looks at `stripe_status`. Production
is self-healing: the next run replaces the old row, and the demo account
keeps working until then.

## Second commit: seeded accounts and the Stripe billing paths

Fixing the collision means reviewer accounts now genuinely have
`hasProPlan() === true`, which switches on paths that were unreachable
while they were paywalled. `isDemoAccount()` is equality against
`config('app.demo.email')`, so an `--email` account is **not** a demo
account and skipped every existing guard:

- `SubscriptionController::billingPortal` would call
`createAsStripeCustomer()` — creating a real Stripe customer in
production — and drop the reviewer on an empty portal.
- Bucketed into `PAY_NOW`, `canSelfRefund()` returns true, so the refund
box renders; `RefundSelfServe::handle` then calls
`$subscription->latestPayment()` on `sub_demo_<uuid>` → Stripe 404,
thrown above the `try`, 500 plus a false `🔴 Self-service refund FAILED —
the user may have been charged without a refund` Discord alert.

`User::hasSeededSubscription()` keys off the fabricated id prefix rather
than one hardcoded email, so it also covers the e2e fixture account.
Real Stripe ids are `sub_` + alphanumerics with no further underscore,
so no paying user can match it.

## Tests

- `demo:reset subscribes a named account even when the public demo
account already exists` — seeds the public demo account, then a named
one. Verified it fails on the pre-fix code with the **identical**
SQLSTATE 1062 message from the Sentry event, and asserts the two ids
differ so a regression back to a shared literal is caught.
- `a seeded reviewer account cannot reach the billing portal`, `blocks a
self-refund on a seeded demo subscription`.

Local: `ResetDemoAccountCommandTest` 7/7, `SelfServeRefundTest` +
`DemoAccountRestrictionsTest` + `SubscriptionTest` 59/59.

## Not fixed here (found during review, out of scope)

- `ResetDemoAccountCommand` falls back to
`Bank::factory()->create(['user_id' => null])` when a named bank is
missing, permanently adding randomly-named **global** banks visible to
every user. `firstOrCreate(['name' => …, 'user_id' => null])` would fix
it.
- Seeded accounts count as paid in `SubscriptionFunnelCollector` /
`ExperimentFunnelCollector` unless their email is in
`AI_SUGGESTIONS_REPORT_EXCLUDED_EMAILS`.
- `handle()` runs without a transaction, so a failure mid-reseed still
leaves a half-seeded account.
2026-08-10 12:48:13 +00:00
Víctor Falcón 02b6892489
feat(currencies): add the Danish Krone (DKK) (#754)
## What

Adds the **Danish Krone (DKK)** as both a user primary currency and an
account currency.

Per `docs/adding-a-currency.md` this is a config change — validation
(`ProfileUpdateRequest`, `StoreAccountRequest`, `UpdateAccountRequest`),
the Inertia dropdown props and conversion all derive from
`config/currencies.php`.

Both pre-checks in the doc pass:

- `DKK` is the current ISO 4217 code (no deprecated-code trap like
`GHC`/`GHS`).
- The provider covers it: `EUR→DKK = 7.4754`, consistent with the ERM II
peg (~7.46).

## Closing the translation gap

Currency names are translated in PHP by `CurrencyOptions`, so they never
appear as literal `__()` keys in the TS/TSX source — `LocalizationTest`
never saw them, and a missing Spanish name silently shipped the English
one. The doc even warned about it.

`LocalizationTest` now feeds the configured currency names into its
translatable-key check, so Spanish is enforced and French warns, exactly
like every other key. That surfaced pre-existing gaps, now filled:

- Spanish: PKR, BRL, DOP, SAR
- French: those four plus NGN

## Also

Dropped the docs' "add a symbol" step. `getCurrencySymbol` has no
callers left — every consumer of `utils/currency` imports
`formatCurrency` only — so entries in its map change nothing on screen.
Removing the function itself is a separate cleanup.

## QA

Real browser QA against the running app, in Spanish:

- `DKK - Corona danesa` appears in the profile currency select; saving
persists `users.currency_code = 'DKK'` and survives a reload.
- `DKK - Corona danesa` appears in the create-account currency select;
created a `Danske Bank Private` account and confirmed
`accounts.currency_code = 'DKK'`.
- Conversion both ways: `100 EUR → 747.68 DKK`, `100 DKK → 13.38 EUR`.
- `LocalizationTest` + `CurrencyConversionServiceTest` green; `pint`,
`phpstan`, `prettier`, `eslint` clean.

## Demo

<!-- PLACEHOLDER: drag the video here -->
**⬆️ Attach `~/Downloads/dkk-currency-qa.mp4` here.**

## Follow-up (out of scope)

Prod already holds accounts and users on currencies that aren't in the
config (PLN, MAD, PHP).
`AccountUserCurrencyService::resolveImportedCurrency` accepts whatever a
bank reports and `forceFill`s it onto `users.currency_code`, bypassing
validation — so those users can't save **any** profile change, and their
currency select renders empty. Worth a separate issue: either
validate/fall back on import, or make the selects tolerate an
out-of-list current value.
2026-08-10 12:08:56 +02:00
Víctor Falcón fb692dabf6
feat(demo): seed named accounts with demo:reset --email (#753)
## Why

The ChatGPT app directory review needs a reviewer account: fully
featured, pre-loaded with sample data, reachable with an email and
password, no 2FA, and on the Pro plan our MCP tools require.
`demo:reset` already builds that dataset — 6 accounts, ~2k transactions,
12 months of balances, categories, labels, rules, budgets — and attaches
an active subscription, so it only needed to target an email other than
the public demo user.

## What

- `--email` / `--password` create or reset an arbitrary account instead
of the configured demo user. An explicit `--email` skips the
`app.demo.enabled` gate, since a named account is not the public demo.
- The account keeps `isDemoAccount() === false` (that check compares
against `app.demo.email`), so none of the demo UI restrictions apply to
it.
- `--imported` marks one account's transactions as bank-imported, so a
reviewer can verify that `update_transaction` and `delete_transaction`
refuse to touch synced data — one of the negative test cases in the
submission. That account gets no banking connection, so no sync ever
runs on it.
- The public `demo:reset` path is unchanged: same config, same gate,
same data.

Usage:

```
php artisan demo:reset --email=openai-review@whisper.money --password='...' --imported
```

## Testing

`tests/Feature/Console/ResetDemoAccountCommandTest.php` adds two cases
on top of the existing ones: a named account comes out on the Pro plan,
not flagged as the demo account, and holding both manual and imported
transactions; and `--email` without `--password` fails without creating
anything.
2026-08-10 09:03:14 +00:00
Víctor Falcón fe747c4472
feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752)
## What

The admin Discord channel is Spanish, but the four scheduled `stats:*`
reports posted English. They now post Spanish, and the three
cohort/experiment reports open with a short AI-written summary so a
reader understands the situation without decoding the table.

### 1. Spanish reports

`stats:daily-report`, `stats:subscription-funnel`,
`stats:experiment-funnel` and `stats:ai-cohort-report` now post Spanish
embed titles, field names, ASCII table headers, legends and disclaimers,
with Spanish dates (`sáb., 13 jun. 2026`). Table headers and cells stay
ASCII (`Semana`, `Variante`, `UMad`, `pdte`, …) so `sprintf`'s byte
padding keeps the columns aligned inside the code block.

Hardcoded, not `__()`: this is an internal channel, not user-facing UI,
so it never needs a second language and doesn't belong in
`lang/es.json`. Everything else — code, comments, PHPDoc, command
descriptions, `$this->info()` — stays English.

### 2. AI summary (best-effort)

New `ReportSummarizer` + `ReportSummaryAgent` (laravel/ai, same
Gemini-Flash pattern and `AI_PROVIDER` switch as the other AI features,
config in `config/ai_reports.php`). It prepends a few sentences to the
embed description that compare the current period against the previous
one (week over week for the two weekly reports, month over month for the
monthly cohort one), say what got better or worse, and call out
explicitly when a figure isn't conclusive — small sample, immature
cohort, signup surge week, or no previous period.

- **Data**: only what each collector already computes. The two cohort
reports pass their weekly series with the per-metric maturity flags; the
experiment report passes the per-variant figures plus the
already-rendered significance verdict (one source of truth), with money
figures nulled exactly where the table renders `—`, so the summary can't
report a zero where the reader sees no data.
- **Previous period**: the experiment report has no time series, so each
run caches its figures (with a capture timestamp) as the next run's
baseline. A same-day manual re-run doesn't overwrite it, and the model
is told to flag a gap that isn't roughly one period.
- **Degrades safely**: no API key, a provider outage, a bad provider
name or a slow response (30s timeout) → the report is posted unchanged,
without the summary. Transient provider errors are logged; anything else
is reported, matching `CategorizeTransactions` /
`LaravelAiRuleSuggestionGenerator`.

### 3. Fix found while reviewing: Discord's embed limits

The translated "Cómo leerlo" field came out at 1152 characters, past
Discord's 1024-character cap on a field value — Discord rejects the
**whole** payload, so the experiment funnel would have silently stopped
appearing in the channel every Monday (`DiscordWebhook` only logs the
400). Both long fields are now tighter (745 and 898 chars),
`DiscordWebhook` trims anything still over the limit instead of losing
the report, and a test measures every scheduled report's embed so
growing copy fails in CI rather than in production.

## Testing

- `tests/Feature/Ai/ReportSummarizerTest.php`: baseline in/out, same-day
re-run, dry run, backtick stripping, truncation, empty answer,
transient-vs-reported failures.
- `tests/Feature/DiscordReportEmbedLimitsTest.php`: all four embeds
inside Discord's limits, plus the trimming fallback.
- The four command tests: Spanish assertions, summary is the first thing
in the description, and the report is still posted when the AI throws.
- 64 tests / 338 assertions pass locally, plus PHPStan and Pint.

## QA

Ran all four commands against the local DB with `--no-discord` (and
dumped the real webhook payloads with the Discord call faked). Real
Gemini output, e.g. the experiment funnel on the second run:

> No se ha producido ningún cambio en las métricas del experimento
respecto a la ejecución previa del 10 de agosto de 2026. La tasa de
conversión sobre usuarios maduros se mantiene en el 4,7 % para la
variante reducida, el 4,0 % para pay_now y el 3,7 % para el control. Las
cifras no son conclusivas ya que las diferencias entre variantes no son
estadísticamente significativas, con un p-valor de 0,593 frente al
umbral α de 0,017.

Also verified with an unreachable provider: the report renders in full,
without the summary.

## Not included

- `stats:stuck-cohort-report` is still English. It posts to the same
webhook but isn't scheduled in `routes/console.php`, so it was out of
the four active reports; worth its own decision.
- The `—` and `` cells are still a couple of bytes off inside the code
block (multibyte in `sprintf`). Pre-existing on `main`, unchanged here.
2026-08-10 10:13:40 +02:00
Víctor Falcón da9032a76e
fix(mcp): declare all three MCP hints on every tool (#751)
## Why

The ChatGPT app directory rejects the submission with:

> Every MCP tool must set readOnlyHint, openWorldHint, destructiveHint
to true or false.

We only ever declared one hint per tool — `#[IsReadOnly]` on the reads,
`#[IsDestructive]` on the writes — so the other two were absent from
`tools/list` and the portal's scan flagged all 23 tools.

## What

- `McpTool::annotations()` now defaults all three hints, so every tool
reports `readOnlyHint`, `destructiveHint` and `openWorldHint`
explicitly. The attributes still override: `#[IsReadOnly]` on the eight
read tools, `#[IsDestructive]` on the four deletes.
- `openWorldHint` is always `false`: every tool reads or writes the
user's own account, never the open web.
- `destructiveHint` drops to `false` on the eleven
create/update/categorize/label tools. Marking them destructive was wrong
— the directory reserves it for irreversible operations — and it made
ChatGPT ask for confirmation on every write, including recategorizing a
transaction.
- Tool descriptions trimmed to the portal's 200-character cap (nine were
longer, `create_automation_rule` ran to 524). The cuts are facts the
server instructions already state — amounts in minor units,
whole-account scope. The JsonLogic variable list and example move to the
`rules_json` schema field, which the model still reads and the form does
not cap.
- `chatgpt-app-submission.json` is the submission-import file the portal
accepts, carrying the listing metadata, the per-tool hints with their
required justifications, and the positive/negative test cases.

## Testing

`tests/Unit/Mcp/ToolAnnotationsTest.php` pins both contracts: every tool
declares all three hints with `readOnlyHint`/`destructiveHint` matching
the expected tool lists, and no description exceeds 200 characters.
`tests/Feature/Mcp` still passes.
2026-08-10 08:06:00 +00:00
Víctor Falcón 7260e86817
feat(mcp): serve the ChatGPT app directory domain challenge (#749)
## Why

Publishing the MCP server to the ChatGPT app directory requires proving
we own the host that serves it. The submission portal issues a token and
fetches `https://whisper.money/.well-known/openai-apps-challenge`,
expecting the bare token back. That path currently 404s.

## What

- `GET /.well-known/openai-apps-challenge` returns
`OPENAI_APPS_CHALLENGE` as `text/plain`, nothing else — no JSON
envelope, no extra tokens.
- Aborts with 404 when the token is unset, so a host without the
variable configured cannot answer with an empty body that the verifier
would read as a mismatched token.
- Token lives in config/env rather than the repo: it is per-plugin and
rotates independently of the code.

## Testing

`tests/Feature/OpenAiAppsChallengeTest.php` covers both branches: the
token is served verbatim when configured, and the route 404s when it is
not.

The production env var is already set, so the endpoint answers once this
deploys.
2026-08-10 07:46:49 +00:00
Víctor Falcón 698aaffbba
feat(feedback): move feedback and roadmap links from Canny to UserJot (#748)
## What

Replaces Canny (`whisper-money.canny.io`) with UserJot
(`whispermoney.userjot.com`) everywhere it is linked in the app.

| Surface | Before | After |
| --- | --- | --- |
| User dropdown → **Feedback** |
`whisper-money.canny.io/feature-requests` | `whispermoney.userjot.com/`
|
| User dropdown → **Roadmap** | `whisper-money.canny.io/` |
`whispermoney.userjot.com/roadmap` |
| Jan 2026 update email | both Canny URLs | `/roadmap` + root |

Canny's board root was the feedback list and `/feature-requests` the
submission board; UserJot inverts that. The root is the cross-board "All
Feedback" feed — it covers both the Features and Bugs boards and carries
the *Give Feedback* button, so it is a superset of the old
`/feature-requests` destination. `/roadmap` is a real dedicated page,
which Canny never had.

A repo-wide grep confirms no `canny` reference remains. Nothing else
linked it — not the README, the landing page, the privacy/terms pages,
or the drip emails.

The new vitest case pins both hrefs, so an accidental revert fails the
build.

## Testing

- `user-menu-content.test.tsx` — 3/3 green.
- Browser QA on the running app: logged in, opened the dropdown, clicked
**Feedback** and **Roadmap**, and confirmed each opens the right UserJot
page in a new tab (`rel="noopener noreferrer"` intact). Repeated at a
390×844 mobile viewport. See the demo below.

## Demo

<!-- PLACEHOLDER: drag the video here -->

## Follow-ups (outside this repo)

- **The UserJot workspace still has demo seed posts.** "Shared access
for family accounts" (Emily Roberts) is the only card under **In
Progress**, so every user clicking Roadmap sees a fake item presented as
actively being built — and it duplicates the real imported post
"Multi-tenant support for families or companies". "Custom tags for
transactions" (Sam Wilson) is in the feed too. Worth deleting before
this ships.
- **Canny is still live** and still serves its 17 posts. It has no URL
redirect feature, so already-sent emails, Discord pins and search
results will keep landing there. Close or rename the boards with a
pointer to UserJot, and reconcile the import (Canny 17 vs UserJot
Planned 5 / In Progress 1 / Done 11) before decommissioning.
- **The Bugs board has no entry point.** The Support dialog still routes
bug reports to Discord/email, so `/board/bugs` sits empty. Either point
Support at it or drop the board.
- **No SSO.** UserJot supports JWT identification; without it users need
a second account to vote. Same as Canny, but the migration is the
natural moment to add it.
2026-08-10 06:54:21 +00:00
Víctor Falcón 85c74604b2
feat(transactions): allow manual transactions on bank-connected accounts (#747)
## Why

You could not add a transaction by hand to a bank-connected account.
There was no good reason for it: bank sync **only inserts** rows it has
not seen before (dedup runs on `dedup_fingerprint` /
`external_transaction_id`, both `null` on manual rows) and **never
deletes or updates**, so a hand-entered transaction survives every later
sync untouched.

The one thing that genuinely does not make sense is letting a user set a
**balance** on a connected account, because the next sync overwrites it.
That restriction stays.

## What was actually blocking it

Less than it looked. The HTTP endpoint already allowed it,
`ManualBalanceAdjuster` already skipped connected accounts, and the
transaction dialog already handled them (it forces `updateBalance:
false`). Only two surfaces blocked it:

- **MCP** — `WriteTool::writableAccount()` rejected every connected
account for *all* writes. Split into `accountInSpace()` (no connection
check — used by `create_transaction` / `update_transaction`) and
`balanceWritableAccount()` (still rejects connected — used by
`create_balance`).
- **The account detail page** — hid its "Add transaction" button for
connected accounts, even though the same dialog on the transactions page
already offered them in its account picker.

## Also in here

Two problems the change surfaced, both fixed:

- **`EnableBankingSyncer` linked-account watermark** took the newest
transaction of *any* source. With manual rows now able to land on a
connected account, one dated later than the bank's last posting would
shrink the fetch window and skip the bank history in between —
permanently, since the watermark only moves forward. On the QA data this
would have skipped **36 days**. Now restricted to bank-sourced rows.
- **`calculateHistoricalBalances`** derives history by walking back from
a bank-provided reference balance, summing transactions unfiltered.
Counting a hand-entered row subtracts money the bank never had. Now
walks bank-sourced rows only. (Safe before only because manual rows
could not reach a connected account.)

Plus the honesty/copy work:

- `ManualBalanceAdjuster` returns whether it shifted anything, so
`create` / `update` / `delete_transaction` all report `balance_updated`
instead of silently no-opping and letting the agent claim a balance
moved.
- The **OAuth consent screen** and the AI Connector settings page said
"bank-connected accounts stay read-only". That was a trust statement,
and it is no longer true — both now say bank-*synced transactions*
cannot be edited or deleted and connected balances stay untouched.
- `create_balance`'s description no longer contradicts the server
instructions shipped alongside it.
- The transaction dialog now *explains* why the "Update account balance"
checkbox is absent on a connected account instead of just hiding it (it
defaults to on and is localStorage-persisted, so it used to vanish
mid-form with no reason given).

## What stays blocked

- Balances on connected accounts — MCP `create_balance` rejects them,
the adjuster no-ops, the UI shows the explanation instead of the
checkbox.
- Editing or deleting bank/imported transactions — still gated on
`source === manually_created`, unchanged.

## Testing

- `create_transaction` on a connected account leaves its balances alone;
moving a transaction onto a connected account unwinds only the manual
side it came from.
- A manual row survives a sync and does not block the bank's own rows —
the invariant the whole change rests on, previously untested.
- A manual transaction does not move the linked-account sync window.
- The account page offers "Add transaction" on connected accounts, and
hides it on non-transactional ones.
- Full suite: 2047 passing, phpstan clean.

QA'd in the browser end to end (create from a connected account, edit it
afterwards, contrast with a manual account, switch accounts mid-form)
and over real MCP calls against the running server (`create_transaction`
returned `balance_updated: false` on connected / `true` on manual;
`create_balance` refused the connected account).

## Demo


https://github.com/user-attachments/assets/7a7f1cbb-8f68-402d-ba35-3288dd7ea77f


<!-- PLACEHOLDER: drag the video here -->
2026-08-10 05:32:40 +02:00
Víctor Falcón eb60f2eb30
fix(open-banking): unblock account mapping when the bank reports accounts without a uid (#746)
## Problem

A paying user reported that "Save & Sync" on the account mapping screen
did nothing — no error, no navigation, nothing.

EnableBanking returned their Société Générale card (`CB Visa`) with
`uid: null`:

```json
{"uid": null, "name": "CB Visa", "cash_account_type": "CARD", "account_id": {"iban": null, "other": {"scheme_name": "CPAN", "identification": "************8210"}}}
```

The page renders one mapping row per pending account, so the form posted
`bank_account_uid: null`. `MapAccountsRequest` requires it, the request
422'd, and since the page never rendered validation errors the button
looked dead. The user retried the connection six times.

A second problem kept them from doing what they actually wanted: both
accounts reported `currency: "XXX"` — ISO 4217 for "no currency".
`getCompatibleAccounts` filtered their existing EUR accounts against
`"XXX"`, matched nothing, and the "Link to existing account" option was
never rendered. The backend already knew `XXX` isn't a currency
(`AccountUserCurrencyService::resolveImportedCurrency`); the frontend
didn't.

## Fix

- Accounts without a uid are filtered out of the mapping screen. They
can never be synced anyway — every sync service early-returns on an
empty `external_account_id` — and `CreatesAccountsFromPending` already
skipped them during onboarding. That rule now lives in one place,
`BankingConnection::mappablePendingAccounts()`.
- The skipped accounts are named on the page, so users don't go hunting
for an account they can see in their bank app.
- A connection where *no* account has a uid is closed out instead of
parking on a mapping page that could only ever 422.
- Validation errors surface as a toast, with app-authored messages in
`MapAccountsRequest::messages()` instead of raw field paths.
- `XXX` (and a blank/lowercase variant) is treated as "unknown
currency": the code isn't displayed, and it no longer filters out every
linkable account.

## Scope

One user affected in production (the reporter). Their connection has one
valid account alongside the card, so this fix unblocks them on deploy —
no data change needed.

## Testing

- `tests/Feature/OpenBanking` — 315 passed, including two new cases:
uid-less accounts are hidden and named, and an all-uid-less connection
is closed rather than left awaiting mapping.
- Browser QA against a local reproduction of the exact production
payload: only `Compte Bancaire` is offered, `CB Visa` is named as
skipped, "Link to existing account" appears despite the `XXX` currency,
submitting without picking an account toasts the error, and picking
`Dany SG` links it (`external_account_id` set, connection `active`).

## Demo


https://github.com/user-attachments/assets/6275834a-a518-47b9-9015-a698e5926d71
<!-- PLACEHOLDER: drag account-mapping-fix-qa.mp4 here -->

## Not in this PR

`AuthorizationController::refreshAccountIds()` consumes the raw pending
list on reconnect; its positional fallback can pair a legacy account
with a uid-less entry. Not reachable for any account in production
today, it belongs to a different flow, and it deserves its own test —
filed separately rather than smuggled in here.
2026-08-09 18:41:35 +02:00
Víctor Falcón a3eafecf60
fix(onboarding): don't trap users on the syncing step when a bank sync fails (#745)
Reported by a user who spent over an hour and a half on the onboarding
syncing step, on two devices, without ever getting into the app.

## What was happening

When Enable Banking rate limits a connection, `SyncBankingConnectionJob`
records the error and backs off, but leaves the connection **Active with
`last_synced_at` still NULL** — the exact shape `syncStatus` treated as
"still syncing". The step polled that endpoint forever, and there was no
deadline or escape on the client.

Confirmed in production for the reporter: a Trade Republic connection
returned `429` on every scheduled sync for two days straight. They
unblocked themselves by deleting the connection — `onboarded_at` was set
four seconds later. Three other users are in the same state right now,
one of them from today.

## What changed

- **`syncStatus` no longer waits on a connection that already failed**,
and reports it as `failed` separately from `pending`.
- **The step says so instead of pretending it worked.** Advancing
silently dropped the user on a "You are all set!" screen with an empty
dashboard and no way to find out why. Now they get a short explanation
and a Continue button.
- **Client deadline raised to 5 minutes** — the previous 2 was under the
worst case of a healthy first sync (3 attempts of a 120s job, 30s apart)
— and the status request now has a timeout, so a hanging poll can't
stall the step either. A failing status check keeps polling until the
deadline rather than skipping the user ahead.
- **`AccountMappingController` clears the stale error** when it
reactivates a connection, so a genuine sync in flight isn't reported as
failed.

## Testing

- Feature tests for the rate-limited case, for a healthy connection
syncing alongside a failed one, and for the failed flag; a
`rateLimited()` factory state replaces the hand-rolled attributes.
- Vitest coverage for the three client branches: failed, deadline
reached, and the normal advance.
- Browser QA against the real app, reproducing the production state: the
honest screen appears instead of the spinner, Continue moves on, the
user completes onboarding and reaches the app; and a genuinely pending
sync still spins and then advances by itself once the connection syncs.
No console errors.

## Demo


https://github.com/user-attachments/assets/e54e029d-db7a-47b0-8b93-0da5569aa363


<!-- PLACEHOLDER: drag the QA video here -->

## Not in this PR

`settings/connections.tsx` has the same `active && !last_synced_at` =
"syncing" heuristic, so a rate-limited connection shows a permanent
green "Syncing" badge there and its error is never rendered (the error
block is gated on `status === 'error'`). Same bug class, separate
surface — worth a follow-up.
2026-08-09 18:40:49 +02:00
Víctor Falcón b35968b456
fix(banking): strip the ISO 20022 remittance tag from transaction descriptions (#744)
## Why

A support ticket: Bankinter transactions arrive with the raw ISO 20022
remittance tag in front of the text, so AI categorization reads the tag
instead of the merchant.

```
/TXT/D|SumUp *GELATERIA SALV
/TXT/H|TRANSF NOMI /AIGUA DE RIGAT, S      ← the payroll that got categorized as Fuel
/TXT/CONDIS SANT JUST DESV07/07/26|20260714
```

`/TXT/` is the unstructured remittance tag, `D|`/`H|` is the debe/haber
marker (which only repeats the sign of the amount), and card payments
append the purchase and settlement dates. None of it describes the
transaction.

In production this hits **7297 transactions across 20 users** —
Bankinter (6569) and Unicaja Banco (728), which ships the same shape.

## What

**`RemittanceTagFormatter`** strips the tag, the credit/debit marker,
the card dates and the `#` marker on card charges. The tag identifies
itself, so the formatter is keyed on the **description** rather than on
a bank name — it works for any bank shipping the same shape instead of
needing a new class per bank. `BankFormatter::matches()` now takes the
description as well; `BbvaFormatter` keeps matching on the bank name.

**`banking:backfill-descriptions`** fixes the rows that are already
imported. It also rewrites the automation rules that match on the raw
text: **48 user-authored rules across 4 users** contain literals like
`/TXT/D|RECIBO VISA CLASICA`, and rewriting descriptions without
rewriting those rules would silently stop them from ever matching again.
A test pins that a rule still matches its transaction after both are
rewritten.

```
banking:backfill-descriptions [--user=email] [--dry-run] [-v]
```

## QA

Ran the formatter over **all 2755 distinct tagged descriptions in
production**: 0 no-ops, 0 leftover tags/dates/markers, 0 degenerate
output.

Ran the command against a database seeded with production-shaped rows
and rules:

```
===== DRY RUN (-v) =====
DRY RUN — no changes will be saved.
  /TXT/D|SumUp *GELATERIA SALV            →  SumUp *GELATERIA SALV
  /TXT/H|TRANSF NOMI /AIGUA DE RIGAT, S   →  TRANSF NOMI /AIGUA DE RIGAT, S
  /TXT/EL CLANDESTI 27/07/26|20260803     →  EL CLANDESTI
  /TXT/CONDIS SANT JUST DESV07/07/26|20260714  →  CONDIS SANT JUST DESV
  /TXT/RECIBO MES TARJETA|20260806        →  RECIBO MES TARJETA
  /TXT/D|#RECOBRO RECIBO VISA             →  RECOBRO RECIBO VISA
  /TXT/OPENAI *CHATGPT SUBSCR MP          →  OPENAI *CHATGPT SUBSCR MP
  rule …080: {"in":["RECIBO VISA CLASICA",{"var":"description"}]}
8 transaction(s) and 2 automation rule(s) would be reformatted.

===== SECOND RUN =====
0 transaction(s) and 0 automation rule(s) reformatted.     ← idempotent
```

The raw text is kept in `original_description`; an already-stored
original is never overwritten; untagged descriptions and rules are left
alone; transactions the server cannot read (`description_iv`) are
skipped.

## Not in this PR

**Already-categorized transactions keep their category.** The backfill
fixes the text, not the past AI decisions — 4005 of the affected rows
already have a category (1024 of them AI-sourced), and
`ai:categorize-backfill` only picks up uncategorized ones.
Rule-categorized rows are unaffected because the rules are migrated. If
we want the AI ones re-run, that's a separate deliberate step: clear
`category_source = 'ai'` on the affected rows, then run the existing
backfill command.

## Rollout

```bash
php artisan banking:backfill-descriptions --dry-run -v     # inspect
php artisan banking:backfill-descriptions                  # apply
```
2026-08-09 18:11:05 +02:00
Víctor Falcón 275b77e640
fix(ai): stop a learned rule title from 500-ing the user's category change (#741)
## The bug

Sentry
[PHP-LARAVEL-53](https://whisper-money.sentry.io/issues/PHP-LARAVEL-53)
— `SQLSTATE[22001]: Data too long for column 'title'` on `PATCH
/transactions/{transaction}`. 6 events, 1 user.

Changing a transaction's category learns a forward-looking automation
rule, and `AiRuleLearner::title()` rebuilds the rule's title from the
merchant names it matches. This user's bank puts the **entire statement
line** in `creditor_name`:

```
15/06 12/06 Pago Con Tarjeta En Discos, Libros, Fotos Y Pc's -59,00 189,27 | 4940197152806468 Classpass* Monthly
```

Two corrections into the same category produced a 279-char title against
a `varchar(255)` column, so `$rule->save()` threw and took the whole
request with it. Since `creditor_name` is itself capped at 255 on
ingest, a single correction can overflow it too.

## The fix

1. **`AiRuleLearner::title()`** caps each merchant token (40 chars) and
the category name (60), so the title stays readable instead of merely
short — the `→ Category` tail is the part that carries the meaning.
2. **A `title` mutator on `AutomationRule`** truncates to the column
width. Both reviews flagged that fixing the learner alone leaves the
sibling generator, `ApplyRuleSuggestions::title()`, unguarded: it joins
three AI match tokens that are each validated at `max:255`, so it can
overflow the same column and 500 the accept-suggestions step **during
onboarding**. The mutator covers every generator, present and future, in
one place. User-authored titles never reach it —
`StoreAutomationRuleRequest` already rejects longer ones with a 422.
3. **Learning can no longer abort the correction.**
`CategoryOverrideHandler::record()` runs before the transaction is saved
and outside any DB transaction, so the incident left the correction
logged and the ai rules stripped while the category the user asked for
was never written. `bulkUpdate` is worse: it records every transaction
before the mass update, so one throw mutated rules for the earlier ones
and categorized none of them. A learning failure is now reported and
swallowed — both callers already handle "nothing learned".

## Tests

- `AiRuleLearnerTest` — replays the exact production payload; reproduces
the identical `SQLSTATE[22001]` on the pre-fix code and asserts the
title stays inside the column with the category still visible.
- `AutomationRuleTest` — the mutator truncates rather than failing the
write.
- `CategoryOverrideHandlerTest` — a throwing learner is reported, not
propagated.

191 tests green locally (`tests/Feature/Ai` + the three automation-rule
suites) against the MySQL testcontainer; `pint --test` clean.

## Deliberately out of scope

- **Dead-weight rules.** With such a merchant name the learned clause is
`creditor_name == "…-59,00 189,27 | …"` — it contains amounts and a
running balance, so it can never match again, yet it accumulates one
clause per correction and the UI toasts *"Learned: similar transactions
will be categorized automatically"*. The review's prod data says length
is the wrong guard (62 long merchant names DO repeat, across 490
transactions); the right one is rejecting the statement-line *signature*
(an embedded `dd/mm` or `nn,nn`) in `merchantKey()` so it falls through
to the already-guarded description-token path. Worth its own PR.
- **Rules list UI**: a long title sits in a `whitespace-nowrap` cell and
pushes the actions column off-screen (`settings/automation-rules.tsx`).
- **Unbounded writes of the same class elsewhere**, found while
reviewing: `transactions.currency_code` `varchar(3)` written unvalidated
from the Enable Banking payload (`TransactionSyncService.php:179`), and
`banking_connections.aspsp_name`/`aspsp_logo` with no `max:` rule in
`StartAuthorizationRequest`.

Fixes PHP-LARAVEL-53
2026-08-09 15:39:14 +00:00
Víctor Falcón 239317f7f9
fix(ui): stop unusable web storage from white-screening the app (#743)
## The bug

Two Sentry issues share one minified boot frame, `vq` →
`initializeTheme()`:

-
[PHP-LARAVEL-57](https://whisper-money.sentry.io/issues/PHP-LARAVEL-57)
— `TypeError: Cannot read properties of null (reading 'getItem')`.
Chrome Mobile WebView 131 / Android 15: the host app disabled DOM
storage, so `window.localStorage` is `null`.
-
[PHP-LARAVEL-4Y](https://whisper-money.sentry.io/issues/PHP-LARAVEL-4Y)
— `SecurityError: The operation is insecure.`, 17 events. Blocking
cookies/site data makes the very first access throw.

`app.tsx` runs `initializeTheme()` and `initializeChartColorScheme()` at
module scope, before React mounts, so either one white-screens the app.
Same class as PHP-LARAVEL-41 (Safari <14 `addEventListener`), which is
why `lib/media-query.ts` exists — `lib/safe-storage.ts` is its sibling.

## What both reviews caught

My first pass only routed the two initializers, and **that would not
have closed either issue**. Both events carry url
`https://whisper.money/`, and the landing page reads `localStorage`
unguarded in a mount effect (`welcome.tsx:2105`) — those users would
have white-screened a few milliseconds later instead. There is also **no
error boundary anywhere in this app**, so any throw in render or an
effect unmounts the whole root; there is no "broken widget" failure
mode.

So the fix also covers:

- **`welcome.tsx`** — the landing page's locale effect, the url both
issues carry.
- **`chunk-load-recovery.ts:61,71`** — `window.sessionStorage` was
evaluated in an argument list, *outside* the try/catch that guards its
use. For the SecurityError cohort the global `error` listener itself
threw, and that throw was reported as another error event, re-entering
the same listener. It also meant chunk-load auto-recovery was dead
precisely on the browsers most likely to hold stale assets.
- **`edit-transaction-dialog.tsx:104`** — a read inside a lazy
`useState`, i.e. during render, on `/transactions` and account pages.
Its `typeof window !== 'undefined'` guard checks the wrong axis:
`window` exists, `window.localStorage` is null.

## Not just "doesn't crash" — the preference still works

Making the crash survivable exposed what happens next. The `appearance`
cookie is unencrypted, `HandleAppearance` renders the page from it, and
the blade applies it before any JS runs. Then `initializeTheme` read an
empty localStorage, fell back to `'system'`, and stripped the `dark`
class again — so a no-storage user who chose Dark got light on every
load. The mount effect was worse: it called `updateAppearance`, which
writes the cookie, so simply opening the app reset the one channel that
still persisted for these users.

Now the cookie is read as a fallback, the mount effect hydrates without
writing back, and `initializeChartColorScheme` only overrides the
blade-rendered `data-chart-color` when something is actually stored
(previously it reset everyone to `colorful`, leaving the CSS palette
fighting the colors the charts draw with).

For users whose storage works, behaviour is unchanged: `readStoredValue`
returns exactly what `getItem` returned, `null` included.

## Tests

7 vitest cases. The helper's four (working storage, null global,
throwing getter, throwing `setItem`) plus three that pin the invariant
that actually regressed — the boot initializers survive a null and a
throwing storage, and leave the server's theme alone when nothing is
stored. Verified they fail if `initializeTheme` goes back to a bare
`localStorage` call; the helper-only tests do not.

## Follow-ups (not here)

- **An error boundary in `app.tsx`** — both reviews called this the
highest-leverage item in the whole change. ~10 unguarded storage sites
remain, and without a boundary each is a fresh white screen rather than
a contained failure plus a Sentry report. It needs fallback UI and a
visual check, so it deserves its own PR.
- **The remaining unguarded sites**, in blast-radius order:
`lib/debug.ts:4` (called ~30× per rule evaluation from the rule engine,
so auto-categorization dies for these users),
`transaction-analysis-drawer.tsx` / `category-analysis-drawer.tsx`
(drawers fail to open), `lib/key-storage.ts` (on a 1s interval, but
gated behind the deprecated encryption setup — and note storage that
silently fails to persist is the *wrong* policy for a key),
`hooks/use-admin.tsx` (no call sites — delete it).
- Six call sites already hand-roll this exact try/catch idiom, which is
the argument for the helper existing; folding them in would also remove
three duplicated localStorage stubs from the test suite.

Fixes PHP-LARAVEL-57
Fixes PHP-LARAVEL-4Y
2026-08-09 15:33:22 +00:00
Víctor Falcón 5e38c583b9
fix(banking): stop unclassified bank responses from silently killing a connection (#742)
> **Draft on purpose.** The migration can put connections into
`Expired`, which sends `BankingConnectionExpiredEmail` to real users —
up to ~18 of them shortly after deploy. That is the right outcome (they
have been broken for weeks without being told), but outbound email to
real users should be a human's call, not an autonomous one. Everything
else here is ready.

## The bug

Sentry
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
— `RequestException: HTTP 403` in
`EnableBankingProvider::getTransactions`. 45 events, 10 users,
regressed.

`EnableBankingProvider` classifies known upstream failures into domain
exceptions and rethrows the rest as a bare `RequestException`. Two
responses were unclassified:

- `401 {"error":"CLOSED_SESSION"}`
- `403 {"detail":{"error_name":"PsuActionRequiredException"}}`

`SyncBankingConnectionJob` reads any bare 401/403 as an auth failure:
the connection goes to `Error` with `consecutive_sync_failures =
MAX_SCHEDULED_RETRIES + 1`. Both the scheduler and `banking:sync` filter
that out, so **the connection is never dispatched again**. Enable
Banking's syncer has `notifiesOnAuthFailure() === false`, and the
"reconnect your bank" notice only counts `Expired` connections — so
nothing told the user. Their bank sync was dead, silently.

Prod confirms the damage: **18 connections across 17 users** stranded
that way, 14 with this bug's exact error message, the oldest not synced
since **2026-05-06**.

## What each response now does

**`401 CLOSED_SESSION` → expired session.** The session is genuinely
gone (revoked at the bank, or superseded by a newer authorization) and
only the user can fix it. It now joins `EXPIRED_SESSION`: connection
marked `Expired`, reconnect email sent, notice shown, not reported as an
application error.

**`403 PsuActionRequiredException` → transient.** My first pass expired
these too; the product review pulled the prod data and it says
otherwise. The nine connections that hit this on 2026-08-05 had all
synced cleanly at 18:00 the evening before, failed inside the same
**nine-minute** window, all had months of consent left, and none has
recurred in the four days since. Nine users at nine different banks do
not revoke consent inside nine minutes — that is the provider faulting
behind a generic `"Internal error."` envelope.

Expiring is a one-way door: expired connections are never dispatched,
manual sync is refused, and the only way out is a full reauthorization
with SCA. Expiring on first sight would have pushed nine users through a
re-consent they did not need. So it is classified transient: the
connection stays schedulable and self-heals next cycle, and the error
logs as a warning instead of being reported. If a PSU action really is
required, the consent lapses on its own and the 401 path takes over.

A 403 or 401 the bank sends for any other reason still surfaces exactly
as before — tested, because widening either check to "any 401/403" would
silently expire every connection during a credentials bug of our own.

## Rescuing the stranded connections

The classification fix cannot reach the 18 already-parked rows: they are
excluded from dispatch before any of this code runs. The migration
clears their failure counter so the next scheduled cycle re-evaluates
them through the fixed path — they resume, or they expire properly, with
the email and the notice the user should have had months ago.

Also: the reconnect email's five keys were missing from `lang/es.json`,
so Spanish users got it in English. Unenforced because the localization
test only scans `resources/js`.

## Tests

`tests/Feature/OpenBanking` — 319 green, including 6 new provider cases
(both new codes on `getTransactions` and `getBalances`, plus negative
cases for an unrecognised 401 code, a bare 403, and a non-object
`detail`). The job-level behaviour — `Expired` + email + not reported —
is already covered end to end in `SyncRetryAndLoggingTest`, through the
real syncer.

## Follow-ups (not in this PR)

- **`Expired` badge next to a future "Expires:" date.** `markExpired()`
doesn't touch `valid_until`, and this fix expires connections whose
window hasn't lapsed, so `settings/connections.tsx:411` will show both.
Hide the line for expired connections or backdate the field.
- **Forensics gap.** `logSyncAttempt` records no
`error_class`/`error_message` on the expired path, so a provider-wide
wave becomes indistinguishable from ordinary consent churn in
`banking_sync_logs` — that is exactly the data that disproved my first
reading here.
- **Reconnect loop.** `AuthorizationController` sets the connection back
to `Active` and syncs immediately; if the bank refuses again the user
gets a second "reconnect" email seconds later.
- **`banking:sync --connection=<id>` obeys the health filter**, so ops
has no escape hatch for a stranded connection.
- **Duplicated catch ladders** in `getTransactions`/`getBalances` — the
shape that let a code get classified in one and not the other.

Fixes PHP-LARAVEL-3J
2026-08-09 17:26:34 +02:00
Jesús Mejías Leiva df9d62f76c
fix(docker): keep the client port in the URLs the app generates (#739)
## The bug

Self-hosted production image published on a non-default port (`docker
run -p 8080:80`, the default `APP_PORT` in
`docker-compose.production.yml`) serves the page but with **no CSS and
no JS**: every Vite asset is requested from
`http://<host>/build/assets/...` — port 80, where nothing listens —
while the page itself is on `:8080`.

## Root cause

Debian's nginx package now ships `/etc/nginx/fastcgi_params` with a
security workaround:

```nginx
# !!! Security workaround !!!
# Do not use HTTP_HOST as "$http_host".
...
# Note: this changes behaviour compared to previous versions, because "$host"
# does not preserve the client-supplied port [...] Existing deployments that
# rely on "$http_host" containing a port number may therefore break.
fastcgi_param  HTTP_HOST        $host;
```

`docker/nginx/nginx.conf` does `include fastcgi_params;`, so PHP
receives `HTTP_HOST=192.168.20.46` instead of `192.168.20.46:8080`.
`Request::getPort()` then falls back to the scheme default,
`$request->root()` loses the port, and every absolute URL Laravel builds
— Vite assets, the `Link: rel=preload` header, redirects, mail links,
OAuth redirect URIs — points at port 80.

Deployments behind a reverse proxy on 80/443 don't see it: the proxy
sends `X-Forwarded-Port`, which takes precedence.

## The fix

Ship our own `docker/nginx/fastcgi_params` with `HTTP_HOST $http_host`,
so the value no longer depends on what the base image happens to
install.

## Verification

Reproduced in a minimal container (`php:8.4-fpm` + apt nginx + this
repo's `nginx.conf`, repo bind-mounted at `/app`), printing
`$_SERVER['HTTP_HOST']` and `app('url')->asset('build/assets/app.css')`:

| fastcgi_params | `HTTP_HOST` | `asset()` |
| --- | --- | --- |
| from the base image | `127.0.0.1` |
`http://127.0.0.1/build/assets/app.css`  |
| this PR | `127.0.0.1:8096` |
`http://127.0.0.1:8096/build/assets/app.css`  |

`nginx -t` passes, and a request with a domain `Host` (no port) still
generates `http://whispermoney.example/build/...` unchanged.

Guarded by `tests/Unit/ProductionNginxConfigTest.php`.

## Note

Restoring `$http_host` re-exposes the case Debian's workaround targets
(a raw client `Host` that differs from an absolute-form request target).
The image already forwards the client `Host` — `server_name _` accepts
anything and there is no `TrustHosts` middleware — so this doesn't widen
the surface, but closing it properly would mean an explicit
`server_name` plus a `default_server` that rejects unknown hosts.

---------

Co-authored-by: Víctor Falcón <victor.falcon@factorial.co>
2026-08-09 14:25:19 +00:00
Ideal bd0481486d
feat(currency): add THB (Thai Baht) (#740)
## What
Add the Thai Baht as a supported currency.

## Compatibility
Provider covers `thb` (standard ISO 4217). The conversion service
lowercases codes and fetches `thb.min.json` — same path RSD/NZD use.
`exchange_rates` stores rates as JSON, so any 3-letter code works.
Validation rules and Inertia currency props auto-derive from config.

## Changes
- `config/currencies.php` — THB entry (`allows_primary` +
`allows_account`)
- `lang/es.json` — Spanish translation
- `lang/fr.json` — French translation
- `resources/js/utils/currency.ts` — short symbol (฿) for THB

Follows the RSD addition (#567) exactly; no code changes needed.
2026-08-09 14:24:58 +00:00
Víctor Falcón 616495d8e4
chore: add /release slash command (#738)
Adds `.claude/commands/release.md`, a `/release` slash command that cuts
a new release (patch by default, `/release minor|major` to override).

`bun run release` on its own can't work here: `main` is protected (PR
required + 5 required status checks), so release-it always dies at the
push step and rolls back. The command encodes the flow that does work:

1. Release branch, pushed first — release-it errors out with no upstream
2. `bun run release -- <bump> --ci --no-git.push --no-git.tag
--no-github.release` for the version bump + changelog
3. PR titled `chore: release vX.Y.Z`, wait for CI, squash-merge
4. Tag and GitHub release created on `main` after the merge

Docs only, no runtime code touched.
2026-07-26 16:00:45 +00:00
Víctor Falcón 007fb58d41
chore: release v0.2.7 (#737)
Patch release cut with `release-it`.

- Bumps `package.json` to `0.2.7`
- Regenerates `CHANGELOG.md` (conventional-changelog, angular preset)
and enriches it via `scripts/enrich-changelog.js`

Tag `v0.2.7` and the GitHub release will be created on `main` once this
is merged.
2026-07-26 17:14:23 +02:00
Víctor Falcón d32d1ff40b
feat(transactions): add a monthly trend view to the analysis drawer (#736)
## Why

The analysis drawer was built for **bounded** filters — a trip, a
project — where "total spent" and "avg / day" are the answer.

Filter by something open-ended instead, like a single category, and it
surfaces a year or two of expenses. There both numbers stop meaning
anything: the cumulative line only ever climbs, `€12.32 / day` is not a
figure anyone decides on, and the largest-expenses table fills with the
same monthly transfer repeated five times.

## What

A fourth analysis view that answers the open-ended question instead:
**the monthly rate, and where the recent months sit against it**.
Automatic picks it past a 120-day span; the existing per-filter override
now carries four options, each named after the number it produces.

`Automatic` · `Total spent` · `Income & expenses` · **`Monthly trend`**

| Panel | Bounded view | Monthly trend |
|---|---|---|
| Rate | Total spent + avg / day | **Monthly average** and **last 3
months** with the change between them |
| Chart | Bars + cumulative line | **Monthly bars**, dashed line at the
average, no cumulative |
| Largest expenses | top 10 | **not rendered** — across an open-ended
span the biggest single transaction is trivia, and the payee and
category breakdowns already answer where the money went |
| Category · payee · account · label | unchanged | unchanged |

Footer names the months covered rather than counting days. When income
is a meaningful share the cards report net figures and the chart adds
income bars.

### Only whole months count

Every monthly figure covers calendar months the series spans end to end.
Both edges would otherwise drag the average down, in the same direction:

- The **trailing** month is still in progress — on the 2nd it holds two
days of spending.
- The **leading** month is partial whenever the series does not start on
its 1st, whether a filter clipped it or the first transaction simply
landed on the 18th.

Skipping only the trailing one manufactured a rise: flat €300/month over
a 120-day span starting 18 March reported **+16%** in amber on spending
that never changed. Both are excluded now, which is what the one new
backend field (`summary.first_date`) is for. The part-month bars are
faded rather than dropped, so the solid bars are exactly the months
behind the average.

The average card states how many whole months it spans and the
percentage says it is against the average — two figures that otherwise
silently covered less than they appeared to.

### Edge cases

- Forcing the view onto a span with no whole month falls back to the
bounded shape, toggle label included, and the popover says why.
- The recent card is hidden when whole months do not outnumber its own
window, rather than repeating the overall average.
- A **day override** is the user stating the real duration, so it
decides the automatic view too: a trip whose transactions posted over
eight months stays bounded instead of jumping to trend and leaving that
override inert.

## Cost

**Backend: one enum case and one summary field.** No migration —
`analysis_mode` is already a nullable string behind `Rule::enum`. Every
other figure is derived in the browser from the `over_time` series the
endpoint already returned.

## Tests

`resolveAnalysisView`, `monthlyRates` and `isAdverseChange` are pure and
unit-tested directly — the part-month cutoff, the change against a
negative average, the zero-average guard, and the adverse-direction rule
that inverts for net. 32 component tests, plus feature coverage for the
new field and the persisted mode.

## QA

Browser QA against a year of grocery spending on the demo account (352
transactions, July 2025 – July 2026): the trend view resolves
automatically, all four options switch correctly, and a 30-day date
filter still lands on the bounded view. 22 assertions, no console
errors.

## Demo


https://github.com/user-attachments/assets/f4a14349-3d58-4da4-b85c-9a4ca076551f
2026-07-26 17:03:52 +02:00
Víctor Falcón 1bd2d80b14
fix(budgets): keep per-transaction email notifications opt-in (#735)
Budget email notifications shipped opt-in, then #733 turned all three on
by default. The per-transaction notice is the wrong one to force on: it
emails on **every single transaction** assigned to a budget, and a
catch-all budget matches all of them. This puts it back to opt-in and
leaves the limit alerts (close to limit, over limit) on by default.

## Why a second migration instead of editing the first one

`2026_07_24_100000_enable_budget_notifications_by_default` **has already
run in production** (batch 76). Laravel never re-runs an applied
migration, so editing it in place would have been inert: 382 budgets and
174 settings rows would keep the flag on, and the `user_settings` column
default would stay `true`.

That default matters beyond new signups: every settings controller
writes its own column through `setting()->updateOrCreate(...)`, so a
user who merely changed their chart colour scheme would get a settings
row with `budget_notify_on_new_transaction = 1` filled in by MySQL —
silently opted in, with the checkbox rendering as checked.

So the original migration is restored to its merged form and a new one
resets the flag: column default back to `false`, plus a one-off reset of
existing `user_settings` and `budgets` rows. It uses the query builder,
so `updated_at` is untouched — same as the force-enable did. 381 of the
382 affected budgets had not been touched by their owner since the
deploy, so the reset undoes a force-enable rather than a user's choice.

## Changes

- New migration resetting
`user_settings.budget_notify_on_new_transaction` (default + rows) and
`budgets.notify_on_new_transaction`.
- `BudgetController::store` and
`NotificationPreferenceController::index` fall back to `false` for the
per-transaction flag when a user has no settings row.
- `UserSettingFactory` matches the new default.

Users who want the per-transaction notice can still turn it on per
budget, or as their default for new budgets, in Settings →
Notifications.

## QA

Ran the migration against a real database and checked the end state:
`budget_notify_on_new_transaction` default `0` with all 151 settings
rows and 333 budgets reset, both limit flags default `1` and left on.

Then drove real transactions through the listener and queue on a live
budget (limit €300) and read the resulting mail off Mailhog:

| Event | Spent / limit | Email |
| --- | --- | --- |
| New transaction, default settings | €50 / €300 | none |
| Reaches 90% | €270 / €300 | «Presupuesto de Ocio» está cerca de su
límite |
| Exceeds the limit | €320 / €300 | «Presupuesto de Ocio» ha superado su
límite |
| New transaction, after opting in | €10 / €5000 | Nueva transacción en
«Presupuesto de Ocio» |

Assignment still happens in every case — only the email is suppressed.
265 tests green across the Budget/Setting/Notification suites, including
new assertions that the rendered defaults have the per-transaction flag
off and that a settings row created without the budget columns does not
opt the user in.
2026-07-25 12:36:13 +02:00
Víctor Falcón 21b1b74fb5
feat(landing): add Marta Bordiu testimonial (#734)
## What
Adds a testimonial from Marta Bordiu to the landing page testimonial
wall, placed just before the co-owner's entry.

Sourced from a user email: she'd held off on finance apps over
data-privacy concerns, trusted Whisper Money enough to go premium, and
loves centralizing everything (investments included).

## Changes
- `resources/js/pages/welcome.tsx` — new testimonial entry (name +
Gravatar hash, no email stored in source)
- `lang/es.json` — Spanish translation for the new `__()` string

## Notes
Only the display name and the Gravatar MD5 hash are stored — no customer
email is exposed.
2026-07-24 13:45:50 +00:00
Víctor Falcón 045466f70d
feat(budgets): enable email notifications by default and coalesce alerts per transaction (#733)
## What

Follow-up to #731 (per-budget email notifications). Two changes:

1. **Enable budget email notifications by default.** They shipped opt-in
with every toggle defaulting to `false`, so no one received them. They
are now on by default.
2. **Coalesce budget alerts into one email per transaction.** A single
transaction that crossed a budget's limit could send two emails for the
same budget (the "new transaction" notice *and* the over/close-limit
alert). Now at most one email is sent per budget per assignment.

## Enable by default

- `user_settings.budget_notify_on_*` column defaults flipped to `true`,
and **all existing `user_settings` and `budgets` rows are backfilled to
`true`** so current users and budgets start notifying — not just new
ones.
- `BudgetController::store` and
`NotificationPreferenceController::index` fall back to `true` when a
user has no settings row yet.
- `UserSettingFactory` reflects the new default.
- The `budgets` columns keep their own `false` DB default on purpose:
new budgets are seeded from the user's `user_settings` defaults at
creation, so only the one-off backfill is needed there.

## Coalescing

`BudgetNotificationService::processPeriod` now sends **at most one email
per budget per transaction assignment**, picking the most severe
applicable event with priority **over-limit > close-to-limit >
new-transaction**, and passes the triggering transaction through to
whichever fires. So a transaction that pushes a budget over its limit
produces a single over-limit email that still names the transaction and
shows how far over the budget is.

Preserved from #731: the atomic per-period `close_to_limit_notified` /
`over_limit_notified` claim (one email per crossing, safe under
concurrent workers), the reset-and-re-notify path when spending drops
back below the threshold, and the no-emails-on-historical-backfill
guarantee. Coalescing is **per budget** — a transaction matching two
different budgets still sends one email to each.

## Trade-offs (deliberate)

- **`notify_on_new_transaction` is on by default**, which means one
email per transaction assigned to any budget (no batching yet — flagged
in #731). A catch-all budget matches every expense, so this can be
high-volume; the whole-user-base backfill also produces a send spike on
first sync after deploy. Batching the new-transaction type into a daily
digest (mirroring the bank-sync digest) is the natural follow-up if
volume proves noisy.
- The migration backfill is intentionally unconditional and one-way for
data: `down()` restores the column defaults but not the backfilled row
values.

## Every email links to the settings page

Each budget email already carries a "Manage notifications in
notification settings" link (`route('notifications.index')`) so users
can turn any of them off. A render-test assertion now locks this in.

## Tests

- Coalescing: a single limit-crossing transaction sends exactly one
over-limit email that references the triggering transaction.
- On-by-default: a new budget created without custom preferences enables
all three types.
- All existing #731 sending/preference tests still pass (dedup,
re-notify after drop, no-limit budgets, historical backfill sends
nothing, enabling while already over).

QA (backend): rendered the coalesced over-limit email and confirmed it
names the transaction, shows "Over by", and links to the settings page;
verified the migration backfill flips pre-existing `false` rows to
`true` against a real DB.
2026-07-24 15:33:14 +02:00
Jesús Mejías Leiva 24fcdef098
feat(docker): run the Laravel scheduler in the production image (#732)
## Problem

The production image runs the queue workers via supervisor but **nothing
runs the Laravel scheduler** (there is no `schedule:work` program and no
cron running `schedule:run`). As a result, none of the commands in
`routes/console.php` ever fire automatically — most notably
`banking:sync` (every 6h), so self-hosted users have to trigger bank
synchronization by hand. The manual "sync" button works because it
dispatches the same job to the queue, which the existing workers pick
up; only the scheduled trigger was missing.

## Changes

- **`docker/supervisor/supervisord.conf`**: add a `[program:scheduler]`
running `php artisan schedule:work`, mirroring the existing worker
programs (autostart/autorestart, dedicated log). This is what makes
every scheduled command fire on its own cadence.
- **Gate the operator-only stats reports** (`stats:daily-report`,
`stats:subscription-funnel`, `stats:ai-cohort-report`,
`stats:experiment-funnel`) behind `config('subscriptions.enabled')` with
an early return. These are subscription-conversion telemetry destined
for the admin Discord; on a self-hosted instance
(`subscriptions.enabled=false`) `stats:daily-report` would otherwise
make an unnecessary live Stripe API call, and the others would build
reports for an unconfigured Discord channel. On the SaaS (subscriptions
enabled) behavior is unchanged.

The guard lives **inside each command** (not in `routes/console.php`) so
it stays unit-testable. `banking:cancel-free-enablebanking` was left
alone (already a safe no-op via `hasProPlan()` when subscriptions are
off), as were the drip-email commands (already gated by
`mail.drip_emails_enabled`).

## Testing

- `vendor/bin/pint --test` passes.
- New Pest test per gated command asserting it skips and makes no
external call when subscriptions are disabled; existing tests updated to
enable subscriptions so they still exercise the real path.
- Full suite green except 5 unrelated pre-existing environment failures
(timezone alias, dashboard 409, Passport OAuth key) that fail
identically on `main`.
- Verified end-to-end with a real supervisor run: the `scheduler`
program reaches `RUNNING`, `schedule:work` boots, and autorestart
resurrects it; `php artisan schedule:test --name=banking:sync`
dispatches successfully.

> Takes effect after rebuilding the production image.
2026-07-24 13:30:33 +02:00
Víctor Falcón 0351dbfb38
feat(budgets): add per-budget email notifications (#731)
## What

Adds opt-in **email notifications per budget** for three events, plus a
dedicated **Notifications** settings page.

Each budget can independently enable:
- **New transaction** — a transaction was assigned to the budget
- **Close to limit** — spending crossed 90% of the limit
- **Over limit** — spending reached/passed the limit

A **Default (new budgets)** row lets you set the toggles that newly
created budgets inherit.

The existing *bank-transactions-synced* email toggle was **moved** from
the account page into this new Notifications page, so all email
preferences live in one place.

## UI

`Settings → Notifications`: one "Email notifications" section containing
the bank-sync toggle and a "Budgets" table (rows = Default + each
budget, columns = the three events).

<img width="1265" height="638" alt="budget-notifications-settings"
src="https://github.com/user-attachments/assets/b8386410-98dd-4a5e-9dee-6e5bf7effa31"
/>

## How it works

- Notifications fire from the **live transaction-assignment path**
(`BudgetTransactionService::assignTransaction`, already a queued
listener), never from historical backfill when a budget is created.
- Only the budget's **current period** is considered (the emails
describe the live "available before the limit" state).
- **New transaction** fires for a genuinely newly-assigned transaction;
the email includes the transaction.
- **Close/over** use two atomic per-period flags
(`close_to_limit_notified` / `over_limit_notified`) flipped with a
compare-and-set `UPDATE`, so exactly one email is sent per crossing even
under concurrent queue workers. The flags reset once spending drops back
below the close threshold, so a later crossing can notify again.
- Budgets with no limit (`allocated_amount <= 0`) never send close/over
emails; the "new transaction" email for such a budget omits the
limit/available rows.
- Every email shows the budget's current status (period, spent, limit,
available or over-by). Subcopy links back to the Notifications page.
- New budgets inherit the user's default toggles at creation time.

## Data model

- `budgets`: `notify_on_new_transaction`, `notify_on_close_to_limit`,
`notify_on_over_limit` (default `false`)
- `user_settings`: `budget_notify_on_*` defaults for new budgets
(default `false`)
- `budget_periods`: `close_to_limit_notified`, `over_limit_notified`
(dedup flags)

## Deliberate decisions

- **Threshold is a fixed 90%** for "close to limit" (not configurable) —
kept simple; easy to make per-budget later if requested.
- **"New transaction" sends one email per assigned transaction.** It is
opt-in and defaults to off. A bank sync importing many matching
transactions into an opted-in budget will therefore send several emails;
if this proves noisy we can batch per sync run (mirroring the daily
bank-sync digest). Flagged here rather than pre-building batching.
- **Limit basis is `allocated_amount`** (matches the budget
cards/spending chart), carry-over excluded.

## Tests

- Preference endpoints: page renders budgets + defaults, per-budget
toggle update, cross-user update forbidden, user-settings defaults
update, new budget inherits defaults.
- Sending: new-transaction opt-in/out, over-limit send, close-limit at
90%, no-resend dedup, re-notify after dropping below and crossing again,
no-limit budget skips close/over, historical backfill sends nothing,
notify after enabling the preference while already over. Plus a render
test covering all three email variants.

## Notes

- CI-enforced `es.json` keys for the new page and emails are included.
- All new PHP follows existing mail/queue conventions (`ShouldQueue`
mailable, `emails` queue, `RateLimited` middleware).
2026-07-24 12:52:03 +02:00
Víctor Falcón 2be0df25c6
feat(billing): explain what AI categorization does and the data it sends (#730)
## What

Makes the **AI Categorization** feature more transparent on the Manage
Plan screen:

- **Moves the AI Categorization section to the top** of the page — it's
an important, privacy-relevant choice and shouldn't be buried below the
plan details.
- **Spells out exactly what enabling it does**, as two ordered steps:
1. **Categorize new transactions** that don't match any of your
automation rules.
2. **Learn your automation rules** — suggest and create automation rules
for recurring transactions, based on the corrections you make.
- **States exactly what data leaves the app**: each transaction's
description, amount, and merchant/sender name, plus the names of your
categories so the model can pick one. It also makes clear we **never
send your full financial picture** and your **data is never used to
train AI models**.

The copy was verified against the actual payload sent to the provider
(`app/Services/Ai/CategorizeTransactions.php`), which sends
`description`, `amount`, `creditor_name`/`debtor_name`, and the user's
category list.

## Screenshots
<img width="2840" height="2636" alt="manage-plan-ai-light"
src="https://github.com/user-attachments/assets/87e1451b-d224-444e-957d-2b401e4c766a"
/>
<img width="2840" height="2636" alt="manage-plan-ai-dark"
src="https://github.com/user-attachments/assets/807d715a-61f7-493a-b4fd-2fb014966564"
/>

Manage Plan → AI Categorization (light & dark), section now at the top
with the two steps and the transparency note.

## Part of #725

This delivers the **transparency** half of #725 (a plain-language
explanation of what the AI does and what data it receives). Still open
from that issue:

- The dedicated **"AI" settings section visible regardless of
`SUBSCRIPTIONS_ENABLED`** — this copy currently lives on the
(subscription-gated) Manage Plan screen, not a standalone AI section.
- The **connection status / "Test connection" health check** for the
configured provider.

## Testing

- `bun run format`, `bun run lint` clean.
- New `__()` keys added to `lang/es.json` and `lang/fr.json`; retired
the now-unused consent string.
- QA'd in the browser (light + dark) on the demo Pro account.
2026-07-24 08:22:10 +00:00