Commit Graph

1039 Commits

Author SHA1 Message Date
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
Víctor Falcón 8382a56e98
feat(ai): add command to delete a user's AI consent by email (#729)
## What

Adds an Artisan command to delete a specific user's AI consent by email:

```bash
php artisan ai:delete-consent user@example.com
```

It looks up the user by email and **hard-deletes** all their AI consent
records — as if they had never granted it (no `revoked_at` trail left
behind). Since `AiCategorizationGate` requires `hasActiveAiConsent()`,
AI stops being used for that account immediately (queued categorization
jobs re-check the gate at execution time and early-return).

The command is idempotent: it reports "nothing to do" when there's no
consent on record and fails cleanly when the email doesn't exist.

## Why

Two goals:

1. **Stop using AI for a given user** on request, with no lingering
consent record.
2. **Let users who enabled AI by mistake fall back to a free plan.**
Non-Pro users with active AI consent are pushed toward the paid plan by
`EnsureUserIsSubscribed`. Deleting consent moves them back into the
free-plan-eligible bucket. This billing-gating change is **intended**,
not a side effect.

## Notes / scope decisions

- **Hard delete, not revoke.** Removes every consent row for the user
(all scopes/versions) so there's no trace they ever consented.
- **Prompt flag untouched.** `ai_consent_prompt_dismissed_at` is left
as-is, so the in-app consent banner does not re-appear to nudge them
back on.
- **Live users only.** Deleted (soft-deleted) accounts are out of scope
— they can't use the app, so their consent is moot.
- **No subscription changes.** The command only touches AI consent; it
doesn't cancel Stripe subscriptions (`user:delete` already handles that
separately).

## Tests

New Pest feature test covering all three paths (delete all consent
records incl. a revoked one / no consent on record / user not found).
Green locally.
2026-07-23 08:35:28 +00:00
Víctor Falcón ca084ce5d1
feat(commands): delete transactions and balances of a user's non-connected accounts (#728)
## What

Adds an artisan command that deletes all transactions and balances of a
specific user's **non-connected** (manual) accounts, identified by
email.

```bash
php artisan user:delete-manual-account-data user@example.com
```

## Behavior

- Looks up the user by email (`withTrashed`, matching `user:delete`).
- Selects accounts with `banking_connection_id IS NULL` (manual
accounts).
- Shows a confirmation prompt with the transaction/balance counts and
the number of affected accounts.
- On confirm, deletes inside a `DB::transaction`:
- Transactions via `forceDelete()` (they use `SoftDeletes`, so this also
purges already soft-deleted rows).
- Balances via `delete()` (`AccountBalance` has no `SoftDeletes`, so
it's a hard delete).
- The accounts themselves are **not** deleted — only their transactions
and balances.

Related `label_transaction`, `budget_transactions` and
`category_corrections` rows are cleaned up by existing
`cascadeOnDelete()` foreign keys.

## Tests

Pest feature test covering: manual-only deletion (connected accounts
untouched), cross-user isolation, soft-deleted transactions counted and
purged, connected-only user reports zero, cancellation, and
user-not-found.

QA skipped per request (backend maintenance command, covered by feature
tests).
2026-07-23 08:26:11 +00:00
Víctor Falcón bd1ad633f8
feat(mcp): filter search_transactions by label (#724)
## What

Adds label filtering to the `search_transactions` MCP tool. It's the one
filter the web transactions view has that the MCP surface was missing.

- New optional `label_ids` array param. Returns transactions carrying
**any** of the given labels (OR-semantics), matching the web app's label
filter (`Transaction::scopeApplyFilters`).
- Combines with the other filters (account, category, date, amount) with
AND, consistent with how the tool already treats them — so `category_id`
+ `label_ids` means "in that category **and** carrying one of these
labels".
- Each returned transaction now also exposes its `labels` (`id`,
`name`), so the agent can see why a row matched, consistent with what
the write tools already return.

## Validation & consistency

- `label_ids` are validated against the space via `labelsInSpace`,
promoted from `WriteTool` to the shared `McpTool` base so read and write
tools use one implementation. An unknown or stale label id now returns
the same actionable *"call list_labels"* error the rest of the MCP
surface gives, instead of a silent empty result.
- The tool `#[Description]` now mentions `label`, so agents discover the
capability without inspecting each param.

## Tests

- `search_transactions` filters by label id (matching row in, unlabelled
row out).
- A foreign/unknown label id is rejected with an error.
- Full MCP read + write suites stay green (26 tests) — confirms moving
`labelsInSpace` to the base didn't regress the write tools.

Pint, Larastan and the affected Pest suites pass locally.
2026-07-22 15:13:03 +02:00
Víctor Falcón fb5027b321
fix(oauth): stop reporting client-facing OAuthServerException to Sentry (#723)
## Problem
Sentry **PHP-LARAVEL-4Q** fires on POST `/mcp/oauth`: a client presents
a bearer token with a bad/expired/wrong JWT signature ("Token signature
mismatch"), Passport's `TokenGuard` → league/oauth2-server throws
`OAuthServerException::accessDenied` (HTTP **401**). Laravel returns the
correct 401 to the client (`handled: yes`) **but also reports it to
Sentry as an error** — noise, not a server fault. It's recurring (fresh
events after the initial ones).

These OAuth exceptions are, by design, client-facing 4xx protocol
responses (expired/invalid tokens, denied consent, bad grants). Any
public OAuth endpoint — and the MCP endpoint accepts
dynamically-registered Claude/ChatGPT clients — sees a steady stream of
them. They should not page as errors.

`OAuthServerException` does not implement `HttpExceptionInterface`, so
Laravel's built-in `internalDontReport` does not already cover it.

## Fix
Add a `dontReportWhen` rule in `bootstrap/app.php` for
`OAuthServerException` whose HTTP status is **< 500**, mirroring the
existing `MaxAttemptsExceededException` guard right above it.
`serverError` (500) — the only 5xx variant, the genuine "unexpected
condition" case — still reports, so real OAuth-flow faults stay visible.

**Reporting-only change — no user-facing behavior changes.** Clients
still receive the same 4xx OAuth error responses (status/body/headers
unchanged).

## Tests
`tests/Feature/Mcp/McpOAuthTest.php`: assert
`ExceptionHandler::shouldReport()` is `false` for `accessDenied()` (401)
and `invalidCredentials()` (400), and `true` for `serverError()` (500).

## Notes
- Two independent reviews (architecture + product/bug): no must-fix,
both "ship as-is".
- Follow-up (not code): a mass signing-key mismatch would fail as
`access_denied` (401) and thus be suppressed here — the right way to
catch that is an MCP success-rate / 401-rate alert, not error-count.
Worth adding as an alert later.
2026-07-22 12:40:24 +00:00
Víctor Falcón 50d0fccd17
fix(dashboard): render negative net worth as a downward bar (#722)
## Problem

On the dashboard **Net Worth Evolution** monthly bar chart, users with a
large negative net worth (lots of debt) saw an **empty chart** — only
the x-axis month labels rendered, no bars.

Root cause: the chart scales each asset segment so the stacked bar
height equals net worth. For negative net worth the scale factor was
clamped to zero:

```ts
const scaleFactor = hasLiabs && totalAssets > 0
    ? Math.max(0, netWorth / totalAssets) // negative net worth -> 0 -> every bar collapses
    : 1;
```

A stack of positive asset segments simply can't represent a negative
total, so everything collapsed to zero height.

## Fix

- Extract the scaling into a tested pure helper
`computeNetWorthBarScaling`.
- When net worth is **negative**, hide the (meaningless) scaled asset
segments and draw the deficit as a **single downward bar** from a **zero
baseline** (dashed reference line).
- **Positive** periods still stack assets upward. Both directions round
only at their far end and meet the zero line flush, so upward and
downward bars look symmetric.
- Extend the same handling to the **daily area chart**, which shared the
bug through the same scaled dataset.
- Share a single `NetWorthMode` type across the bar/area charts and
tooltip (was duplicated in three places).
- Translate the tooltip **Net Worth / Total** labels (were hardcoded
English; added `Net Worth` to `lang/es.json`).
- Render the chart for **liability-only** users (a debtor with just a
loan) instead of the "No account data available" empty state.

## Tests

- Unit tests for `computeNetWorthBarScaling`: no-liabilities, positive,
negative-with-assets, liabilities-only, net-worth-exactly-zero, and
no-assets cases.
- `bun run test`, `prettier --check`, `eslint` all green on the touched
files.

## QA

Verified in the running app (Playwright) with two seeded users:

- **Heavily indebted** (net worth −56.250,00 €): the chart now renders
red downward bars instead of an empty area.
- **Net worth crossing zero**: negative months draw downward, positive
months stack upward, sharing the zero baseline; tooltip shows
per-account balances + the (now translated) "Patrimonio Neto" total.

## Demo


https://github.com/user-attachments/assets/7a9b167f-c037-4c82-8b68-f32932be99fe

## Screenshots

**Before** — empty chart, only x-axis labels:

<!-- PLACEHOLDER: before screenshot -->
<img width="1440" height="900" alt="net-worth-before"
src="https://github.com/user-attachments/assets/3bf9b3e3-ae75-4230-bca7-e7246a9a35d9"
/>


**After** — negative net worth as downward bars:
<img width="1440" height="900" alt="net-worth-after-tooltip"
src="https://github.com/user-attachments/assets/85302f0a-e427-4cbf-baba-5b1414049e9f"
/>
<img width="1440" height="900" alt="net-worth-after-mixed"
src="https://github.com/user-attachments/assets/41f08331-f055-4245-beeb-8d071c0d5d6d"
/>
<img width="1440" height="900" alt="net-worth-after-negative"
src="https://github.com/user-attachments/assets/c8610b03-dcff-46de-8987-d47c477ddd85"
/>
2026-07-22 09:32:18 +00:00
Víctor Falcón 9e1aedcccd
feat(settings): make mobile settings nav read as a tappable menu (#721)
## Problem

On mobile, many users don't realize the dropdown at the top of Settings
is a navigation menu — they don't know they can tap it. The trigger was
a shadcn `Select` left with the default form-field look (transparent
background, faint chevron), so once you're on a page it reads as a
static label rather than a control.

## Change

Restyle the mobile settings-nav `SelectTrigger` (in
`resources/js/layouts/settings/layout.tsx`) so it reads as a tappable
menu button:

- `bg-muted` — filled background instead of a transparent input.
- `h-11` — 44px touch target (recommended mobile minimum, up from 36px).
- `font-medium` — the label reads as a control, not plain text.
- `[&>svg:last-child]:opacity-100` — the chevron goes from
barely-visible to a clear "this opens" affordance.

Scope is the mobile block only (`lg:hidden`); the desktop sidebar is
untouched. Uses theme tokens, so light and dark mode are both covered.

## Demo
<img width="390" height="844" alt="settings-menu-dark-open"
src="https://github.com/user-attachments/assets/9ed7856a-0048-40a2-8e6e-07b24b4cb844"
/>
<img width="390" height="844" alt="settings-menu-dark-closed"
src="https://github.com/user-attachments/assets/5b8b2b7a-c31c-4c55-b224-1f87f554e361"
/>
<img width="390" height="844" alt="settings-menu-light-open"
src="https://github.com/user-attachments/assets/4f6abb55-38b8-4e38-9322-12b555082dad"
/>
<img width="390" height="844" alt="settings-menu-light-closed"
src="https://github.com/user-attachments/assets/e08babf7-fe9d-46d7-abd6-329962d34eaf"
/>

## Testing

Pure Tailwind class change with no behavior change (the `Select` →
`router.visit` navigation is unchanged), so no unit test is added — a
CSS-class assertion would be brittle. Verified live in the browser at
mobile width in both light and dark mode, closed and open.
2026-07-22 11:03:27 +02:00
Víctor Falcón 87c63f2100
fix(auth): make build deterministic when REGISTRATION_ENABLED=false (#720)
## Problem

`bun run build` failed whenever `REGISTRATION_ENABLED=false`.

`config/fortify.php` only added `Features::registration()` when the env
flag
was truthy, so with the flag off Fortify never registered the
`/register`
routes. Wayfinder only generates helpers for registered routes, so the
`register` / `register.store` helpers imported by
`resources/js/pages/auth/login.tsx`
and `resources/js/pages/auth/register.tsx` no longer resolved and the
build
broke. The build outcome depended on an env flag — not deterministic.

## Fix

Decouple *route registration* from *whether sign-ups are accepted*:

- **Always register** `Features::registration()` in
`config/fortify.php`, so the
`/register` routes (and their Wayfinder helpers) always exist and the
build is
  deterministic regardless of the flag.
- Gate acceptance at **runtime** via a new
`config('auth.registration_enabled')`
value (mapped from `env('REGISTRATION_ENABLED', true)`), the single
source of truth.
- When registration is disabled:
- `GET /register` (Fortify register view) and `POST /register`
(`CreateNewUser`)
    return **403**.
- Registration CTAs stay hidden via `canRegister` (login view + landing
page).
- Guests are redirected to `/login`
(`AuthEntryPointService::guestRedirectRoute`).
- With the flag enabled (the default), registration behaves exactly as
before.

## Tests

- Rewrote `tests/Feature/Auth/RegistrationDisabledTest.php` to the
runtime
mechanism: registration enabled by default, route helpers always
registered
regardless of the flag, `GET`/`POST /register` return 403 when disabled
(and no
  user is created), and the landing/login CTAs hide.
- Updated the `DashboardTest` disabled-registration case to toggle
`config('auth.registration_enabled')` instead of filtering the Fortify
feature.
- Verified `bun run build` succeeds with `REGISTRATION_ENABLED=false`
and that the
  `register` / `register.store` Wayfinder helpers are generated.

## Docs

- Updated `README.md` and `.env.example` to describe the 403 behavior
(routes stay
  registered rather than being removed).
2026-07-22 07:04:20 +00:00
Jesús Mejías Leiva 350e0031f1
feat(ai): make the AI provider configurable (any laravel/ai provider, incl. local Ollama) (#718)
## Summary

Closes #716.

Both AI execution paths hard-coded `provider: Lab::Gemini`, so even
though `laravel/ai` already understands `OLLAMA_URL` and the model was
env-overridable, no other provider could ever be reached by the actual
UI features. This makes the **AI provider configurable**, keeping
**Gemini as the default** so existing deployments are unaffected.

Although issue #716 asked specifically for **Ollama**, the fix is
generic: because the provider is resolved through the
`Laravel\Ai\Enums\Lab` enum, this unlocks **any text provider
`laravel/ai` supports** — `gemini`, `openai`, `anthropic`, `azure`,
`groq`, `xai`, `deepseek`, `mistral`, and self-hosted `ollama`. Ollama
is the headline case (fully local, private processing), but nothing in
the code is Ollama-specific.

> Note: each provider still needs its own credentials configured for
`laravel/ai` (e.g. `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_URL`),
and only text-capable providers apply — a non-text or unknown provider
fails fast.

## Changes

- **`config/ai_suggestions.php` / `config/ai_categorization.php`** — add
a `provider` key. Each reads its own `AI_SUGGESTIONS_PROVIDER` /
`AI_CATEGORIZATION_PROVIDER`, both falling back to a shared
`AI_PROVIDER` and finally `gemini`. So `AI_PROVIDER=<provider>` flips
every AI feature at once, and either feature can still be overridden
individually.
- **`app/Services/Ai/CategorizeTransactions.php` /
`app/Services/Ai/LaravelAiRuleSuggestionGenerator.php`** — resolve the
configured provider to the `Laravel\Ai\Enums\Lab` enum via
`Lab::from((string) config('...provider'))` and pass it to `prompt()`
(the SDK recommends referencing providers by the `Lab` enum rather than
a plain string). `Lab::from()` also **validates** the value: an unknown
provider fails fast with a clear `ValueError` instead of erroring deep
in the provider stack.
- **`.env.example`** — document the provider vars plus an Ollama block
(`OLLAMA_URL`, `OLLAMA_API_KEY`, `AI_CATEGORIZATION_MODEL`), kept
commented so defaults stay Gemini.
- **`README.md`** — new *AI Provider* section: the generic provider
switch, the list of supported text providers, and a local-Ollama
example.
- **Tests** — cover the `gemini` default and a non-Gemini (`ollama`)
override for both the categorization and rule-suggestion paths, plus the
fail-fast `ValueError` on an unknown provider.

## Usage

Any supported provider follows the same pattern — set `AI_PROVIDER`,
that provider's credentials, and the `*_MODEL` vars. Example, fully
local/private with Ollama:

```dotenv
AI_PROVIDER=ollama
OLLAMA_URL=http://ollama.example.local:11434
AI_SUGGESTIONS_MODEL=gemma3:12b
AI_CATEGORIZATION_MODEL=gemma3:12b
```

## Validation

- `./vendor/bin/pest tests/Feature/Ai` → **128 passed**.
- `vendor/bin/pint` and `vendor/bin/phpstan` (level 5) → clean.
- **End-to-end against a real Ollama server** (`gemma3:12b`), through
the actual application code (not faked):
  - Categorization: `MERCADONA COMPRA` → *Groceries*, confidence 0.95.
- Rule suggestion: `netflix` → *Subscriptions*, structured output
intact.

## Backward compatibility

Default provider is unchanged (`gemini`); no env changes are required
for existing installs.
2026-07-22 09:01:51 +02:00
Víctor Falcón a5117aaae6
refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717)
## What & why

The `HIDE_AUTH_BUTTONS` flag and its signed-link auth-override existed
only to gate registration/login behind a waitlist during the launch
period. That period is long over, so this removes the flag and the whole
waitlist apparatus. Registration and login are now always open, still
governed by `REGISTRATION_ENABLED` (Fortify's registration feature),
which is untouched.

## Removed

- **`HIDE_AUTH_BUTTONS`** config/env and
**`LandingAuthOverrideService`** — the signed-link override, override
cookie, and `?force=` bypass — plus its
`GenerateLandingAuthLinkCommand`.
- **Waitlist lead-capture + invitation apparatus**: `UserLeadController`
+ `waitlist.*`/`user-leads.*` routes, `StoreUserLeadRequest`, the
landing `WaitlistForm`, `waitlist/*` pages, waitlist/invitation mails,
invitation & re-invitation commands, the lead verification notification,
`LeadCohort`/`LeadCohortResolver`/`LeadPromoCodeAllocator`, and the
lead→Resend segment sync (scheduled tasks removed too).
- Frontend `hideAuthButtons`/`forcedRegistration` threading and the
`?force=` query on auth links (header, welcome, register, login).
- All tests for the removed features; `landing.hide_auth_buttons` config
stubs stripped from surviving tests.

## Kept on purpose (data preservation)

The `user_leads` **table, its migrations, and a lean `UserLead` model
are retained** — no destructive migration. Pre-launch contacts are not
lost, and new signups whose email matches a preserved lead still receive
that lead's promo code at Stripe checkout
(`SubscriptionController::resolveLeadPromotionCodeId`, unchanged).

## Testing

- Full non-Browser Pest suite green locally (**1948 passed**), plus
Pint, Prettier, ESLint.
- Browser QA (Playwright) on the running app: landing no longer shows
the waitlist form and renders the register/login CTAs; `/register`
renders the form (previously returned null when hidden); `/login` shows
the "Sign up" link pointing to `/register` with no `?force`. 16/16
functional checks passed; only pre-existing external-resource 404s in
console (gravatar `d=404`, `via.placeholder.com` seed images).

## Demo


https://github.com/user-attachments/assets/18a70f6d-b82f-4d92-b9de-da2610dc017a
2026-07-22 08:51:48 +02:00
Jesús Mejías Leiva d55ee5bdcd
feat(auth): add REGISTRATION_ENABLED env to disable public sign-ups (#713)
## Summary

Adds a dedicated `REGISTRATION_ENABLED` env var (default `true`) so
self-hosters on a publicly reachable instance can **close public
sign-ups while keeping login open** — the exact "sign-ups closed, login
open, permanently" case from #711 that `HIDE_AUTH_BUTTONS` can't cover.

Closes #711.

## What changed

- **`config/fortify.php`** — the Fortify `registration()` feature is now
conditional on `env('REGISTRATION_ENABLED', true)`. When disabled:
- Fortify **never registers the `/register` routes** (GET form + POST
submit → `404`), i.e. a real server-side block, not just a hidden
button.
- The existing `canRegister =
Features::enabled(Features::registration())` flag flips to `false`
automatically everywhere it's used (`routes/web.php`,
`FortifyServiceProvider`, login page).
- **`resources/js/pages/welcome.tsx`** — the hardcoded `/register` CTAs
(hero button, Free/paid pricing cards, final "Ready to take control"
CTA) now respect `canRegister`, falling back to a `Log in` → `/login`
button when sign-ups are closed. The header already gated its `Register`
button on `canRegister`.
- **`.env.example` / `README.md`** — document the new variable.

**Backward compatible / optional:** `env('REGISTRATION_ENABLED', true)`
defaults to `true`, so existing installs that upgrade without setting
the var keep public sign-ups exactly as today. It's shown as a
commented, optional example in `.env.example`.

`/login` and the "Iniciar sesión / Log in" button stay fully available
in all cases. `AuthEntryPointService::guestRedirectRoute()` already
redirects guests to `/login` when registration is disabled, so no dead
`route('register')` calls remain.

## Tests

New `tests/Feature/Auth/RegistrationDisabledTest.php`:

- registration feature present by default / removed when
`REGISTRATION_ENABLED=false` (config-level).
- landing page exposes `canRegister=true` by default and `false` when
the feature is disabled.
- login page hides its sign-up link when registration is disabled.
- **`/register` GET + POST return 404 and the named routes are gone**
when disabled (via `refreshApplication()` with the env set), while
`/login` still returns 200.

All new tests pass; the wider `tests/Feature/Auth` + landing-override
suites stay green (the one unrelated `Asia/Calcutta` legacy-timezone
failure is a pre-existing tzdata quirk of the CI-less local box, not
touched here). `pint`, `prettier --check`, and `eslint` are clean.

## Manual QA

Verified against a running instance (headless Chromium) in both states:

| `REGISTRATION_ENABLED=true` (default) | `REGISTRATION_ENABLED=false` |
| --- | --- |
| Header: `Log in` + `Register`; hero CTA `Get Started` → `/register` |
Header: `Register` gone, only `Log in`; hero CTA `Log in` → `/login` |
| `GET /register` → `200` | `GET /register` → `404`, `GET /login` →
`200`, `Route::has('register')` → `false` |
2026-07-21 16:22:45 +02:00
Víctor Falcón d1f3b0203b
fix(mcp): correct the ChatGPT and Claude connector steps (#714)
Fixes the step-by-step instructions on the AI Connector screen to match
the current app UIs.

**ChatGPT**
- Developer mode is under **Settings → Security and login → Developer
mode** (was: Settings → Connectors → Advanced).
- Add a connector via the **+ button in Plugins, top right** (was: a
"Create" button under Connectors).

**Claude Desktop**
- Click **"Add" in the top right, then "Add custom connector"** (was:
just "Add custom connector").

Spanish translations updated in `lang/es.json` accordingly.
2026-07-21 13:37:47 +00:00
Víctor Falcón 2827df9685
feat(mcp): recommend desktop for AI connector setup (#712)
## What

Adds a short note at the top of the "How to connect" card on the AI
Connector screen (`settings/mcp.tsx`):

> **Set this up on a computer.** Signing in and approving works fine in
a desktop browser, but usually breaks in a phone's in-app browser. Once
it's connected, you can chat with Whisper Money from Claude or ChatGPT
on your phone as usual.

## Why

The OAuth sign-in + consent step is unreliable inside a phone's in-app
browser (the installed PWA captures the authorize link on Android).
Rather than fight every mobile edge case, steer people to create the
connection on desktop, where it works cleanly — the connection then
carries over, so they can chat from the mobile apps normally.

Copy run through the humanizer (no em dashes, plain and direct). Spanish
translation added to `lang/es.json` (required by the i18n CI check).
2026-07-21 12:58:13 +00:00
Víctor Falcón 2041181dc2
fix(mcp): serve OAuth authorize on a dedicated host so the PWA can't capture it (#709)
## Problem

Connecting the MCP connector from **ChatGPT on Android** fails. The
installed Whisper Money PWA is a Chrome **WebAPK** that auto-verifies as
an Android **App Link handler** for the whole app origin (manifest
`scope: "/"`), so `https://whisper.money/oauth/authorize` gets routed
**into the app**. Once inside the standalone app, the redirect back to
the OAuth client can't complete → the connection fails. (Claude works
because it opens OAuth in a Custom Tab.)

Confirmed on an Android emulator: the WebAPK shows `AutoVerify=true`,
`whisper.money: verified`. DB evidence: ChatGPT registers + reaches
consent (auth codes issued) but never exchanges a token.

## Why not `handle_links`

`handle_links: "not-preferred"` (tried in #707, reverted in #708) is
**origin-wide** — it would push *every* `whisper.money` link (bank-auth
callback, email verification, shared deep links) to the browser, not
just `/oauth`. We want links to keep opening the installed app.

## Fix (surgical)

Move the OAuth **authorization server** to a dedicated host outside the
PWA scope. `config('mcp.authorization_server')` becomes env-driven
(`MCP_AUTHORIZATION_SERVER`); in prod → `https://oauth.whisper.money`
(DNS already points at the same app).

Every endpoint derives from the request host (no forced root URL), so
pointing the auth server at the subdomain makes `issuer` +
`authorize`/`token`/`register` all resolve to `oauth.whisper.money` —
**same origin as each other**, no cross-origin metadata mismatch. The
protected resource (`/mcp/oauth`) and **all other app links stay on
`whisper.money`**, so deep-linking into the app is fully preserved. Only
the OAuth flow leaves the app — into the browser, where the round-trip
completes.

## Activation (after merge + deploy)

1. Set `MCP_AUTHORIZATION_SERVER=https://oauth.whisper.money` in prod
env, redeploy.
2. I'll curl the discovery chain to confirm it resolves to the
subdomain.
3. Test the ChatGPT connect on a real phone.

Safe until step 1: env unset → `authorization_server` stays `null` →
current behavior. No effect on local/dev.

## Tests

Added a Pest test: with `mcp.authorization_server` configured,
protected-resource metadata advertises the dedicated host and
auth-server metadata (fetched from that host) keeps `issuer` + all
endpoints on it. App has no `TrustHosts` restriction (already serves the
subdomain) and `SESSION_DOMAIN=null` (host-only cookies — subdomain gets
its own session, no security downgrade).
2026-07-21 13:51:03 +02:00
Víctor Falcón 13360ce0b8
revert(pwa): drop handle_links to keep app deep-linking (#708)
Reverts the `handle_links: "not-preferred"` manifest change from #707.

It's origin-wide: it would route **every** `whisper.money` link (bank
auth callback, email verification, shared deep links) to the browser
instead of the installed app — not just `/oauth`. We want links to keep
opening the app when it's installed.

The OAuth-capture fix will instead be surgical: move the interactive
`/oauth/authorize` endpoint to a dedicated host (`oauth.whisper.money`)
outside the PWA scope, so only OAuth leaves the app and all other
deep-linking is untouched. That lands in a follow-up PR (needs DNS +
testing).

This restores the manifest to its original state (no PWA link-handling
hacks).
2026-07-21 13:40:13 +02:00
Víctor Falcón 1b30e7edc0
fix(pwa): open OAuth links in the browser instead of the installed app (#707)
## Problem

Connecting the MCP connector from **ChatGPT on Android mobile** fails.
Claude works because it opens the OAuth flow in an in-app browser
(Custom Tab); ChatGPT's link gets routed into the installed Whisper
Money PWA, and once the flow is inside the standalone app the redirect
back to the OAuth client can't complete.

## Root cause (confirmed on an Android emulator)

The installed PWA is a Chrome **WebAPK** that auto-verifies as an
**Android App Link handler** for the whole origin (`scope: "/"`):

```
AutoVerify=true
Domain verification state: whisper.money: verified
```

So Android routes `https://whisper.money/*` — including
`/oauth/authorize` — into the app instead of a browser. Narrowing
`scope` to exclude `/oauth` isn't viable: the app's routes are flat and
share no prefix.

## Fix

Set `"handle_links": "not-preferred"` in the web app manifest, which
tells the browser **not** to route in-scope links into the installed app
— they open in the browser, where the OAuth round-trip (authorize →
consent → redirect back to the client) completes. The app still launches
normally from its home-screen icon.

This **replaces** the `launch_handler` + `launchQueue` approach from
#701, which targeted the wrong stage (it kept the flow *inside* the
PWA). That change is reverted here.

## Verification / caveat

`handle_links` on Android WebAPKs has incomplete/uncertain support, and
it couldn't be validated in isolation on the emulator (a throwaway test
domain never minted its own WebAPK). It's a safe, additive, reversible
manifest field — worst case it's a no-op.

**Must be verified on a real device:** after deploy, reinstall the PWA
on the phone (forces the WebAPK to re-mint from the new manifest) and
retry the ChatGPT connect → the authorize page should open in the
browser, not the app.

If Android ignores `handle_links`, the guaranteed fallback is narrowing
the PWA `scope` so `/oauth` is out of it (at the cost of the browser
toolbar appearing on hard reloads of non-`/dashboard` pages).
2026-07-21 13:24:50 +02:00
Jesús Mejías Leiva a2dfdb7442
fix(ci): publish the production image as :latest so Docker/Coolify deploys work (#706)
## Problem

Following the official production deployment instructions (Docker
Compose or Coolify), the app container enters a restart loop with:

```
Could not open input file: artisan
```

Reproducible outside Coolify by pulling the published image directly:

```bash
docker run --rm --entrypoint sh \
  ghcr.io/whisper-money/whisper-money:latest \
  -c 'ls -la /app; test -f /app/artisan && echo OK || echo MISSING'
# /app is empty -> ARTISAN_MISSING
```

## Root cause

The `:latest` tag (and the bare `:<sha>` tag) were assigned to the
**development** image, not the production one:

| Image | Dockerfile | `COPY`s code into `/app`? | Tags (before) |
|---|---|---|---|
| Development | `Dockerfile` (`php:8.4-cli`) |  No — relies on the dev
`compose.yaml` bind-mount `.:/app` | **`latest`**, `<sha>` |
| Production | `Dockerfile.production` |  Yes (`COPY . /app/.`) |
`production`, `<sha>-production`, `v<ver>-production` |

The development image never copies the code into the container (it only
works with the `compose.yaml` bind-mount). Pulled standalone, `/app` is
empty and `artisan` is missing, hence the boot crash.

Both official deployment entrypoints point at that tag:

- `docker-compose.production.yml` → `image:
${WHISPER_IMAGE:-ghcr.io/whisper-money/whisper-money:latest}`
- `templates/coolify/whisper-money.yaml` → `image:
ghcr.io/whisper-money/whisper-money:latest`

Nothing in the repo consumes the development `:latest`/`:<sha>` (the dev
`compose.yaml` builds locally, it doesn't `pull`), so publishing the
code-less image under `:latest` was purely a footgun.

## Fix

Reassign the tags to the industry-standard convention (`:latest` = the
deployable production image), touching only the `docker/metadata-action`
metadata blocks in `ci.yml`:

- `:latest` and bare `:<sha>` → **production image**
(`Dockerfile.production`).
- The development image is now published as `:development` /
`:<sha>-development`.
- Existing `:production` and `:v<version>-production` tags are kept, so
current pins remain backward compatible.

Applied to both jobs (`build-image` amd64 and `build-arm64-images`
arm64) so the multi-platform manifests stay consistent.

With this, `docker pull …:latest`, `docker-compose.production.yml`, and
the Coolify template all work with no further changes.
2026-07-21 08:28:42 +00:00
Víctor Falcón d3ec96b845
fix(onboarding): stop the AI rule-suggestion step from hanging forever (#705)
## Problem

A user reported being stuck forever on the onboarding "Looking for
patterns" step (the "Polishing your suggestions…" spinner). Confirmed in
prod: their `suggestion_runs` row has been stuck in `processing` for a
week, and `failed_jobs` shows `TimeoutExceededException:
GenerateRuleSuggestionsJob has timed out`.

### Root cause

- The user has 4,163 transactions — the largest onboarding dataset (max
with a completed run was 3,481). The Gemini generation exceeded the
job's `$timeout = 120`.
- The worker timeout is raised **outside** the `try/catch` in
`GenerateRuleSuggestions::run()`, so the code that sets `status =
Failed` never ran.
- `GenerateRuleSuggestionsJob` had **no `failed()` handler** (5 other
jobs do), so nothing flipped the run to `Failed`. It stayed
`processing`, `show()` kept returning `processing`, and the client
polled every 3s forever.
- The timeout never reached Sentry, so it was invisible. Only 1 user
affected, but it's a latent bug that recurs on any large account.

## Fix

**Backend** — add `failed()` to `GenerateRuleSuggestionsJob`: mark the
run `Failed` (only if not already terminal) and `report()` the exception
so these surface in Sentry.

**Frontend** (`step-ai-suggestions.tsx`) — belt-and-suspenders so the
client never spins forever even if the worker dies before it can mark
the run:
- `poll()` now guards against transient errors (network blip / expired
session / 5xx) instead of silently dying.
- A client-side deadline (3 min, just beyond the "up to two minutes"
copy) surfaces the existing "couldn't generate" screen with **Try again
/ Skip**.

Not included: raising the timeout / capping the number of groups sent to
Gemini so large accounts *complete* instead of just being able to skip.
Follow-up if we want big accounts to get suggestions.

## Tests

- `tests/Feature/Ai/GenerateRuleSuggestionsJobTest.php` — `failed()`
marks the run `Failed` + reports; doesn't overwrite an already-terminal
run.
-
`resources/js/components/onboarding/step-ai-suggestions-timeout.test.tsx`
— after the deadline the spinner gives way to the failed screen.

Green locally: pint, prettier, eslint, backend + frontend tests.
2026-07-20 18:10:51 +00:00
Víctor Falcón 8172ca78d7
feat(welcome): add Miguel Ángel SB testimonial to the landing page (#703)
## What

Adds a new user testimonial from **Miguel Ángel SB** to the landing page
testimonials marquee (`resources/js/pages/welcome.tsx`), plus its
Spanish translation in `lang/es.json`.

The quote is a distilled English version of the positive closing of a
feedback email he sent us. The Spanish translation stays faithful to his
original wording.

> I love the style, the intuitive interface, how easy it is to use. If
it keeps growing like this, Whisper Money will be my finance app of
choice. You can tell it is built with passion — and things made with
passion can only end up a success.

## Notes

- Inserted just before the `Víctor Falcón (co-owner)` entry, which stays
last.
- `gravatar` is the MD5 of his email; he has no Gravatar, so the card
falls back to the generated Facehash avatar (same `d=404` mechanism as
most other entries).
- The English `__()` key is mirrored in `lang/es.json`, so the
localization test passes and Spanish users see the translated copy.

## QA

-  English card renders with the quote and Facehash fallback avatar.
-  Spanish (`?lang=es`) renders the translated quote — no English
fallback.
-  Row-splitting balances correctly with the new count; co-owner entry
remains last.
-  No new console errors (the Gravatar 404s are the intended fallback
path, shared by existing entries).
-  `bun run format` and `bun run lint` clean.

## Demo


https://github.com/user-attachments/assets/f7973c65-b719-4db5-801f-724784947e2b
2026-07-20 13:34:53 +00:00
Víctor Falcón 2cca291773
fix(pwa): keep mobile OAuth consent from being swallowed by the installed app (#701)
## Problem

On mobile, starting an OAuth connection from ChatGPT (MCP connector)
opens the **installed Whisper Money PWA** and lands on the **dashboard**
instead of the OAuth consent screen — so the connection can never be
approved.

## Cause

The web app manifest declares `scope: "/"`, so the installed PWA claims
the entire origin, including `/oauth/authorize`. On Android the OAuth
link gets captured into the app, and the default launch behavior just
**focuses the already-open window (the dashboard) and drops the incoming
URL** — the consent page never loads.

Narrowing `scope` to exclude `/oauth` isn't viable: the app's routes are
flat (`/dashboard`, `/transactions`, `/accounts`, …) and share no
prefix, so a narrower scope would push half the app out to the browser.

## Fix

- `site.webmanifest`: add `launch_handler.client_mode: "focus-existing"`
so the launcher hands the app the captured target URL via the launch
queue.
- `app.tsx`: a `launchQueue` consumer that navigates to the target URL
when it's an `/oauth/` path. Every other launch is a no-op (keeps its
place).

Low blast radius — additive manifest field + a guard scoped to `/oauth/`
paths; existing (desktop) OAuth is unaffected.

## Verification

Cannot be tested off-device. After deploy, on the phone:
1. **Reinstall the PWA** (remove from home screen, re-add) so Android
re-fetches the manifest.
2. Retry the ChatGPT OAuth connect → should land on the "Connect ChatGPT
to Whisper Money" consent screen.

Depends on `launch_handler`/`launchQueue` (Chrome/Edge 102+, covers
nearly all Android).
2026-07-18 19:37:04 +02:00
Víctor Falcón 4c83cb8b33
feat(import): persist per-account import configuration on the backend (#698)
## What & why

Import configuration (the CSV/Excel column mapping and date format a
user
sets up per account) was stored in the browser's `localStorage`, so it
never
followed the user to another device. Importing the same account's
statement on
mobile — or on a new machine — meant reconfiguring the mapping from
scratch.

This moves that configuration to the backend, keyed per account and per
import
type, so it's preconfigured and loaded automatically wherever the user
imports.

## Demo

Cross-device QA: import transactions on "device 1" with a custom column
mapping, then clear **all** cookies + local/session storage (simulating
a
fresh device), log back in, and start a new import — the mapping
(Description → *Movement*, Amount → *How Much*, date format
*DD-MM-YYYY*)
is auto-loaded from the backend with no manual setup.


https://github.com/user-attachments/assets/e449dfe9-3970-48d4-97ca-9e415c73835b



## How

- New `account_import_configs` table: one row per `(account_id, type)`
where
`type` is `transaction` or `balance`. The mapping + date format are
stored as
  an opaque `config` JSON blob (exactly what the client sends).
- `GET/PUT /api/accounts/{account}/import-config`
(`AccountImportConfigController`),
authorized via the existing `AccountPolicy` (`view`/`update`) — mirrors
  `AccountBalanceController`. `PUT` upserts on `(account_id, type)`.
- Frontend: the two import-config storage helpers now read/write the
endpoint
via `axios` instead of `localStorage` (merged into a single module — the
  transaction and balance variants shared the same logic).
- The saved config is fetched **off the file-parse critical path**: the
parsed
file shows immediately with auto-detected columns, and the saved mapping
is
applied when it arrives (guarded so a slow load can't clobber a file
picked
afterwards). A slow or hanging request never blocks the preview or Next
  button. When no config exists or the request fails, it falls back to
  auto-detection exactly as before.

## Notes / decisions

- **No localStorage migration.** Existing per-device configs aren't
migrated;
on the next import the mapping is auto-detected and re-saved to the
backend,
so it self-heals after one import. The old `import_config_account_*`
keys are
  simply no longer read.
- The mapping-validity check against the actual file headers stays
client-side
  (it depends on the just-parsed file).

## Testing

- `tests/Feature/AccountImportConfigTest.php` (9 tests): auth required,
  cross-user 403 on read and write, save→persist→load round-trip, upsert
  de-duplication, transaction/balance independence, and validation
  (unknown type, missing column mapping).
- Sibling suites green (`AccountBalanceControllerTest`,
`SavedFilterTest`) —
no regression from the new `Account::importConfigs()` relation or
routes.
2026-07-18 16:10:40 +02:00
Víctor Falcón 2b2e4c4a87
feat: reuse the upgrade modal at more upsell points and attribute revenue (#699)
> **Stacked on #696.** That PR introduced the AI-categorization upgrade
dialog this one generalizes. It targets `main` so CI runs, so until #696
merges this PR's diff also contains #696's commit — review/merge #696
first, then this diff resolves to just its own three commits.

## What & why

The contextual "this is a paid feature → pick a plan → checkout" modal
built for AI categorization is now a **shared component** reused at two
more Pro-feature entry points, and every checkout it starts is
**attributed to the upsell point** so we can measure revenue per point.

### 1. Reusable upgrade modal

Extracted `AiUpgradeDialog` (+ `PlanCard`) out of `settings/billing.tsx`
into a shared `UpgradeDialog`
(`resources/js/components/subscription/upgrade-dialog.tsx`). It reads
`pricing`/`locale` from `usePage`, takes `title` / `description` /
`source`, renders the plan picker, and links to Stripe checkout. Used
at:

| Point | Trigger | Copy |
|---|---|---|
| AI categorization | Manage Plan toggle (unchanged) | "AI
categorization is a paid feature" |
| **Connections** | "Connect Bank" | "Bank connections are a paid
feature" |
| **Connected accounts** | Create Account → "Connected" | "Connected
accounts are a paid feature" |

Both new points already gated on `isFreePlan`, so the modal only shows
to free users. The old plain `UpgradeConnectionDialog` (which just
routed to billing) is deleted.

### 2. Revenue attribution

The upsell `source` is captured two ways:

- **Intent** — a PostHog `upgrade_checkout_started` event (`{ source,
plan }`) fires on click, matching the repo's existing `{ source }` event
convention.
- **Revenue** — `source` rides the checkout URL (`?plan=&source=`), and
`SubscriptionController::checkout` validates it against the new
`App\Enums\UpsellSource` enum and attaches it as Stripe **subscription
metadata** (`withMetadata`). When the subscription webhook lands,
`PersistUpsellSourceFromStripe` (on Cashier's `WebhookHandled`, so the
row already exists) copies it onto a new `subscriptions.upsell_source`
column — **write-once** (`whereNull`), so a later `subscription.updated`
never overwrites the original attribution.

Measuring revenue per point is then a group-by on
`subscriptions.upsell_source` joined to invoices, using existing local
tooling.

### Scoping notes (from review)
- **Paywall & the billing on-page upgrade button are intentionally left
untagged** (`upsell_source = NULL`). "Upsell source" means a
*feature-gate nudge*; the paywall/billing pages are the baseline upgrade
surface, not a nudge — so they form the null/baseline bucket rather than
getting their own enum value.
- The listener is **synchronous** (not `ShouldQueue` like the sibling
Discord listener) on purpose: a single indexed, idempotent `UPDATE`
doesn't warrant a queue-worker dependency for attribution.
- The PostHog event fires just before a full-page navigation; posthog-js
flushes via beacon but the event (analytics only, not the revenue source
of truth) could occasionally be lost. Cheap to harden later if the
funnel proves lossy.

## Tests
- `upgrade-dialog.test.tsx` — renders per-feature copy, checkout link
carries `plan` + `source`, click fires the PostHog event.
- `SubscriptionTest` — checkout tags valid sources as Stripe metadata
and ignores unknown ones.
- `PersistUpsellSourceFromStripeTest` — persists from webhook metadata,
doesn't overwrite an existing attribution, ignores unknown/absent
sources.

## Demo

Free user hitting both new upsell points (Connections → "Connect Bank",
Accounts → "Connected"):

<!-- 📎 PLACEHOLDER: drag in
~/Downloads/upsell-points-connections-accounts.mp4 -->


https://github.com/user-attachments/assets/a27435d9-2296-4dc9-ae7f-753b6f7950f0



> Note: the clip ends at the modal (doesn't cross into Stripe) because
this local dev env has a pre-existing Stripe tax-rate 500 on
`/subscribe/checkout`, unrelated to this change. The `source` reaching
the checkout URL is verified in the browser and by the tests.
2026-07-18 12:53:20 +00:00
Víctor Falcón 52aae3fd67
feat: prompt free users to subscribe when enabling AI categorization (#696)
## Problem

On **Settings › Manage Plan**, a free (non-subscribed) user who ticked
**"Allow AI categorization"** silently recorded AI consent (`POST
/ai/consent`). That flipped `hasActiveAiConsent()` to `true`, which
caused two things on the **next navigation / refresh**:

1. `EnsureUserIsSubscribed` hard-redirected them to `/subscribe`, on
every gated page.
2. The paywall's `canUseFreePlan` (`!hasBankConnections &&
!hasActiveAiConsent`) became `false`, so the **"Continue for free"**
escape hatch disappeared.

Net effect: enabling AI locked a free user out of the app behind the
paywall, with no explanation and no way back.

## Fix

For a **free user**, ticking the box no longer records consent. Instead
it opens a contextual dialog that explains AI is a paid feature and
offers **Monthly / Annual** plans that start Stripe checkout. Because no
consent is recorded:

- Nothing locks them out — dismissing the dialog ("Maybe later") leaves
the app fully usable.
- **Pro users** keep the existing direct-consent behavior.
- A free user who somehow already has consent can still **untick to
revoke** and escape the lock.

The now-inaccurate "you can give consent now…" note was removed (a free
user can no longer give consent from here).

## Tests

- New `resources/js/pages/settings/billing.test.tsx` covers three paths:
free user → modal opens & **no consent POSTed**; pro user → consent
recorded directly; free user with existing consent → untick **revokes**.

## Demo

**Before** — enabling AI silently locks the free user behind the paywall
(no "Continue for free"):

<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-paywall.mp4 -->


https://github.com/user-attachments/assets/a72790a4-2b2d-4af1-9f8f-4a16f821eaef



**After** — enabling AI shows a contextual subscribe modal; dismissing
it keeps the app working (no lock):

<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-new-behavior.mp4
-->


https://github.com/user-attachments/assets/4a1c76d6-6c4c-4e94-baea-99e7c302b3df



> Note: the "after" clip ends at the modal + "no lock" proof rather than
crossing into Stripe Checkout, because this local dev env has a
pre-existing Stripe misconfig (`No such tax rate 'txr_…'`) that 500s on
`/subscribe/checkout` — unrelated to this change (the dialog's Upgrade
button reuses the same `checkout.url()` as the existing on-page Upgrade
button).
2026-07-18 14:45:56 +02:00
Víctor Falcón 89174af4e6
feat(transactions): add delete button to the transaction edit modal (#697)
## Why

Until now the only way to delete a transaction was the row's three-dots
menu in the list. Many users click a transaction to open the edit modal
and look for a delete option there — but there wasn't one. This adds a
**Delete** button inside the edit modal, with the same confirmation
step.

## What

- Adds a destructive **Delete** button to the transaction edit modal
footer (`edit-transaction-dialog.tsx`), shown only in **edit** mode.
- Clicking it closes the modal and opens the **existing**
delete-confirmation dialog — the same `AlertDialog` (and manual-account
"update balance" checkbox) already used by the three-dots menu. No new
delete logic, confirmation UI, or copy was introduced.
- Wired via a new optional `onDelete` prop, passed as
`setDeleteTransaction` from the two parents that render the edit modal
(`transaction-list.tsx`, `pages/transactions/index.tsx`). Because
`transaction-list` also backs the Accounts and Budgets pages, the button
appears consistently everywhere the edit modal opens.

## Reviews applied

Ran technical + product reviews on the diff. Applied: added
`dark:hover:bg-destructive/20` to match the sibling delete button's
dark-mode hover. Dropped (with reason): premature shared-component
extraction, pre-existing cross-file duplication, and pre-existing "no
toast on delete failure" — all out of scope for this change.

## QA

Real browser QA (Playwright) against the running app on real data:

- Delete button appears in the edit modal (verified on an imported
transaction).
- Delete → confirmation dialog opens and the edit modal closes.
- **Cancel** returns to the list; page stays fully interactive (`body`
pointer-events `auto` — no stuck Radix scroll-lock).
- **Confirm** removes the row from the list and soft-deletes it in the
DB (verified `deleted_at` was set). Restored afterward.
- Page remains interactive after the stacked-dialog flow (reopened
another transaction cleanly).

Unit tests added in `edit-transaction-dialog.test.tsx` cover: click
delegates to `onDelete` + closes the modal; button hidden without a
handler; button hidden in create mode.

## Demo


https://github.com/user-attachments/assets/e13dad30-0779-46f2-8cc4-84ea70568741
2026-07-18 11:03:09 +00:00
Víctor Falcón 2d7e905ddd
feat(mcp): lead with Claude Desktop/ChatGPT connect steps, collapse tokens (#695)
## What & why

The **AI Connector** settings page was built token-first, but tokens are
only needed for **Claude Code**. Most people connect via **Claude
Desktop** or **ChatGPT**, which sign in with OAuth (no token). This
reorders the page around that reality.

## Changes

- **Lead with the connection instructions.** A **Claude Desktop /
ChatGPT** tab selector (`ToggleGroup`) is now the first card. Each tab
renders the exact ordered steps for that app, with the copyable
connector URL inline.
- **ChatGPT developer mode.** The ChatGPT tab's first step tells the
user to turn on developer mode, which is currently required to add a
custom connector.
- **Collapse the token stuff.** Token management (Create a token, Your
tokens, rotate/revoke) and the Claude Code CLI command moved into a
**"Connect with Claude Code"** section that is **collapsed by default**.
It auto-opens right after a token is minted so the one-time secret and
the list stay visible.
- **Copy tweaks.** The one-time-secret alert stays pinned at the top;
the non-Pro alert copy no longer references "create a token" as the
primary action.

## Review notes (findings applied)

- **Bug fix:** rotate/revoke used `router` visits without
`preserveState`, which remounted the page and snapped the collapsed
section shut (and reset the selected tab). Both now pass `preserveState:
true`.
- Trigger markup made valid (no block elements inside `<button>`),
consistency nits (`cursor-pointer`, chevron animation), and orphaned
`es.json` keys removed.

## Trade-off to confirm

Per the request, rotate/revoke now live inside the collapsed "Connect
with Claude Code" section. Revocation is therefore one expand away
rather than always visible. Flagging in case we want to surface revoke
outside the collapsible later.

## Demo


https://github.com/user-attachments/assets/82b4060d-b7fe-40b3-a3a6-3d9d5425800c



## QA

Browser QA (Playwright, Pro account) covering: initial layout
(instructions first, token section collapsed), switching Claude Desktop
↔ ChatGPT tabs, expanding the developer section, creating a token
(one-time secret shown), and **revoking a token with the section staying
open** (verifies the `preserveState` fix). All steps passed.
2026-07-18 09:49:32 +00:00
Víctor Falcón 5621e90879
feat: streamline the subscription paywall and add a support escape hatch (#694)
## What

Several related tweaks to the subscription paywall shown before a user
subscribes:

1. **Remove the Balance stat** from the top stat card.
2. **Mobile dismiss (X)** replacing the bottom "Continue for free"
button on small screens (when the free plan is available).
3. **Support button** when the paywall *can't* be skipped.
4. **Copy updates** to the social-proof slider.

## 1. Remove the Balance stat

The Balance column showed summed account balances per currency (e.g.
`58.031 MXN 65 US$`). As a variable-length, multi-currency string it
overflowed the stat card on mobile and broke the four-column layout. The
remaining stats (Accounts, Transactions, Categories) are short integer
counts, so it now reads as a clean three-column row.

- `SubscriptionController@getUserStats`: also drops the
`balancesByCurrency` computation — which ran an **N+1 query** (one
`AccountBalance` lookup per account) — and the never-rendered
`automationRulesCount`.

## 2. Mobile dismiss (X)

The "Continue for free" escape (shown only when `canUseFreePlan`) now
renders on **mobile** as a dismiss **X** fixed to the top-right corner
instead of a full-width bottom button, matching the common
mobile-paywall pattern. Same 5s delayed fade-in, same action (continue
on the free plan). On **desktop (md+)** the bottom button is unchanged.
Reuses the app's `MobileBackButton` treatment (44px target, rounded
pill, border/shadow/backdrop-blur) for legibility.

## 3. Support escape hatch (`!canUseFreePlan`)

When the user has **no** free-plan escape, the paywall previously
offered no way out. It now shows a subtle **help/support** affordance in
the same slots the free-plan escape uses — bottom button on desktop,
top-right corner on mobile. It fades in slightly later than the
free-plan escape (**7s vs 5s**) and is deliberately low-key (muted
ghost, no pill) so it doesn't compete with the subscribe CTA. It opens
the existing `SupportDialog` (join the community / email support) — the
same one behind the user-menu "Support" entry.

The two escapes are mutually exclusive per page load, so the free-button
timer collapses into one `escapeVisible` timer whose delay depends on
`canUseFreePlan`.

## 4. Copy updates

Shortened the social-proof lines (`taking control of their finances` →
`trusting us`, etc.), bumped the user count to `2,500+ users`, slightly
reduced the proof icon. `lang/es.json` updated to match.

## QA

Real browser QA across all four states (see Demo), each ending by
exercising the actual action:
- **With free plan** → the X (mobile) / "Continue for free" (desktop)
navigates to `/dashboard`.
- **Without free plan** → the "Need help?" button opens the support
modal (Join the community / Email support).

No console errors from the paywall.

## Demo

**Desktop — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-desktop-with-free.mp4 here -->


https://github.com/user-attachments/assets/f3c943d8-5dfd-4b08-8f94-1683d38b11f7



**Desktop — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-desktop-no-free.mp4 here -->


https://github.com/user-attachments/assets/f7542b41-a0d1-491e-ab8b-4734d1c77af2



**Mobile — with "Continue for free":**
<!-- PLACEHOLDER: drag paywall-qa-mobile-with-free.mp4 here -->


https://github.com/user-attachments/assets/6265ab86-cea2-4ade-9720-17ea8b003b1c



**Mobile — without "Continue for free" (support button):**
<!-- PLACEHOLDER: drag paywall-qa-mobile-no-free.mp4 here -->


https://github.com/user-attachments/assets/e53d68ee-3ff9-4091-bba8-028489dd0b8d
2026-07-18 11:13:14 +02:00
Víctor Falcón 41ac3e90e1
fix(mcp): keep Passport signing keys at 600 in production (#693)
## Problem
Production logs a warning on every `/oauth/authorize` and `/mcp/oauth`
request (Sentry PHP-LARAVEL-4N / PHP-LARAVEL-4M):

> Key file "file:///app/storage/oauth-private.key" permissions are not
correct, recommend changing to 600 or 660 instead of 775

The keys exist (the Docker entrypoint generates them), but the
entrypoint's recursive `chmod -R 775 /app/storage` — which runs *after*
key generation to fix storage ownership — widens
`oauth-private.key`/`oauth-public.key` to 775. league/oauth2-server
(used by Passport) checks the key-file mode when it loads the key and
rejects anything but 600/660, emitting this warning. A 775 private
signing key is also world/group-readable, which it should never be.

## Fix
Re-tighten the two Passport key files to `600` in
`docker/entrypoint.sh`, right after the recursive storage `chmod`. It
runs on every boot, so it also repairs keys already sitting at 775 on a
persisted `storage/` volume.

## Notes
- No app-code change — deploy script only.
- CI (`passport:keys`) and local/worktree key generation already produce
600 keys and never run the recursive 775 chmod, so this is
production-entrypoint-only.
- Follow-up to #691 (OAuth 2.1 for Claude Desktop & ChatGPT).
2026-07-17 21:20:00 +02:00
Víctor Falcón 6d5f440727
feat(mcp): add OAuth 2.1 for Claude Desktop & ChatGPT connectors (Phase 3) (#691)
## MCP Phase 3 — OAuth 2.1 for Claude Desktop/web & ChatGPT connectors

Phase 1 shipped a read-only MCP server (#689); Phase 2 added write tools
+ the read/read_write token scope (#690). This phase adds **OAuth 2.1
(Authorization Code + PKCE)** so Anthropic's Claude Desktop/web custom
connectors and OpenAI's ChatGPT connectors can authenticate — those
clients sign in with OAuth rather than pasting a static bearer token, so
until now they only saw a "coming soon" note.

It reuses `laravel/mcp`'s built-in OAuth support (inert until Passport
is installed) wired to `laravel/passport ^13`. We do not hand-write the
authorization server, discovery endpoints, DCR endpoint, or the
`WWW-Authenticate` challenge — the package provides all of it.

### What's in it
- **`laravel/passport ^13`** + an `api` (passport) guard alongside the
existing session `web` guard; Passport migrations (UUID user columns),
config, and signing keys.
- **`Mcp::oauthRoutes()`** — RFC 8414/9728 discovery, RFC 7591 DCR
(`oauth/register`), and the `mcp:use` scope.
- **A second MCP endpoint `POST /mcp/oauth`** guarded by `auth:api`. The
existing Sanctum `/mcp` endpoint (Claude Code static PAT) is left 100%
unchanged.
- **On-brand OAuth consent screen** (Blade, light + dark, localized)
naming the connecting client and its redirect host, and stating plainly
what the connection can do (read/analyse + make changes, bank-connected
data excepted).
- **Settings UI**: the "Claude Desktop & ChatGPT" block now shows real
connect instructions (the `/mcp/oauth` URL to add as a custom connector,
no token needed) instead of "coming soon".

## Decision #1 — OAuth connections have read + write access

`laravel/mcp` advertises and uses a single `mcp:use` scope; it has no
read/write granularity, so there is no per-connection scope choice over
OAuth. **OAuth connections get full read + write access**, gated by the
user explicitly approving the connection on the Whisper Money consent
screen. (An earlier revision made them read-only; that restriction has
been lifted per request.)

`WriteTool` (`app/Mcp/Tools/WriteTool.php`) grants writes when the
request resolves through the `api` (Passport) guard **or** carries a
Sanctum `mcp:write` ability; a read-only Sanctum PAT is still rejected.
Bank-connected accounts and their transactions remain read-only for
every caller (only manual data can be created/edited/deleted; any
transaction can still be categorised/labelled). The consent screen and
settings copy state the read + write capability and the bank-connected
exception.

Possible follow-up: a consent-time read-only/read-write toggle, if
per-connection granularity is wanted (not offered by the standard MCP
OAuth flow's single scope).

## Other locked decisions
- **Route topology**: a separate `/mcp/oauth` endpoint rather than
multi-guarding `/mcp`. Keeps the Claude Code path unchanged (its
`abilities:mcp:read` gate would 403 an OAuth `mcp:use` token) and gives
each client type a clean documented URL. The package's nested discovery
`/.well-known/oauth-protected-resource/mcp/oauth` returns `resource =
url('/mcp/oauth')`.
- **Registration**: ship DCR (`oauth/register`). Redirect allowlist
tightened to `https://claude.ai` and `https://chatgpt.com` only — no
wildcard. CIMD is a possible later enhancement; both clients accept DCR.

## Deviation from the original plan — the User model is untouched
The plan proposed aliasing Passport's `HasApiTokens` trait alongside
Sanctum's (with `insteadof`/`as`) and implementing `OAuthenticatable`.
**Both are impossible here and, it turns out, unnecessary:**
- The two `HasApiTokens` traits declare an **incompatible `$accessToken`
property** (Sanctum untyped vs Passport `?ScopeAuthorizable`), which is
a hard PHP fatal that `insteadof` cannot resolve (it only resolves
methods).
- `OAuthenticatable::tokens(): HasMany` is incompatible with Sanctum's
canonical `tokens(): MorphMany`, and the Claude Code PAT suite depends
on Sanctum's `tokens()`. The interface is never enforced at runtime by
Passport (docblock-only).
- Passport's resource guard only calls `$user->withAccessToken()`, which
Sanctum already provides (untyped, so it accepts the Passport
`AccessToken`); and Passport's `AccessToken::can()` makes
`tokenCan('mcp:write')` behave correctly for OAuth tokens. So Sanctum
stays canonical and the Claude Code PAT path is genuinely unchanged.

## Signing keys (deploy note)
Passport signs OAuth tokens with a key pair. This PR provisions it
everywhere it's needed: CI (`passport:keys` before tests), the
production Docker entrypoint (generates into the persisted `storage/`
volume unless provided via `PASSPORT_PRIVATE_KEY`/`PASSPORT_PUBLIC_KEY`
env), `worktree.sh`, and a documented `.env.example` entry. **For a
multi-instance deployment, set `PASSPORT_*` env** so every instance
validates tokens with the same key.

## Tests (`tests/Feature/Mcp/McpOAuthTest.php`)
Discovery metadata (RFC 9728/8414), the mandatory **401 bootstrap**
challenge + `WWW-Authenticate` header, DCR (allowed + rejected redirect
URIs), the full **Authorization Code + PKCE** flow reaching a read tool,
and **write access over OAuth** (an OAuth connection calling
`create_label` succeeds and the row is created). The existing
`McpTokenTest` / `Mcp/*` suites (incl. the read-only Sanctum PAT
guardrail) and `LocalizationTest` still pass unchanged.

## QA
- **Protocol** (curl, over HTTPS): both discovery endpoints return the
exact required JSON; unauthenticated `POST /mcp/oauth` returns `401` +
`WWW-Authenticate: Bearer …
resource_metadata="…/.well-known/oauth-protected-resource/mcp/oauth"`;
DCR accepts `claude.ai`/`chatgpt.com` callbacks and rejects others with
`400 invalid_redirect_uri`.
- **Browser**: consent screen verified in light and dark mode (client
name, signed-in email, redirect host, read + write capability +
bank-connected read-only note, Cancel/Connect); updated settings page
verified. No JS errors.
- Full PKCE token exchange + a write tool call is covered by the green
Pest e2e test.

## Fast-follows (not in this PR)
- **"Connected apps" revoke UI** — `McpTokenController` manages only
Sanctum PATs today, so there's no in-app revoke for OAuth grants yet.
The consent copy says "disconnect from the connected app" for now; a
Passport-grant list + revoke is the top follow-up (more important now
that OAuth grants can write).
- CIMD registration; optional consent-time read-only/read-write toggle.

## Stacking
Was developed stacked on `mcp-write-tools` (#690), itself on #689.
**Both have since merged to `main`**, so this branch was rebased onto
`main` (`git rebase --onto origin/main mcp-write-tools`) and targets
`main` directly.
2026-07-17 19:10:48 +02:00
Víctor Falcón 5d7b655111
feat(mcp): add write tools (Phase 2) (#690)
## MCP Phase 2 — write tools

> **Stacked on #689** (`mcp-functionality`). Base this PR on
`mcp-functionality`, not `main`, and merge it **after** #689.

Phase 1 shipped a read-only MCP server for Pro accounts. This adds the
**write** surface and re-enables the read/read-write token scope the UI
dropped in PR1.

### Write tools
A new `WriteTool` base extends `McpTool`: on top of the Pro-plan gate it
requires the calling token to carry `mcp:write`, returning a clear error
for read-only tokens. Each concrete tool is annotated `#[IsDestructive]`
(PHP attributes aren't inherited, so the annotation lives on each tool,
not the base — a docblock on `WriteTool` notes this).

- `create_transaction` — manual (non-connected) accounts only; forces
`source = manually_created`.
- `update_transaction` / `delete_transaction` — manually-created
transactions only; bank/imported ones stay locked.
- `categorize_transaction` — sets/clears the category on **any**
transaction (imported included), marking it `category_source = manual`.
- `label_transaction` — add/remove labels on **any** transaction.
- `create_balance` — balance snapshot on manual accounts only.
- `create_category` / `update_category` / `delete_category` — mirrors
the settings controller (parent/depth/cycle rules, cashflow derivation,
child strategies).
- `create_label` / `update_label` / `delete_label`.
- `create_automation_rule` / `update_automation_rule` /
`delete_automation_rule` — JsonLogic conditions + category/label
actions, at least one action required.
- `list_labels` — a small **read** tool added so label ids are
discoverable (label/automation tools are unusable without it).

### Guardrails
Write tools never touch bank-sourced data: the existing
`TransactionSource` enum and `Account::isConnected()` are the barriers,
reused not reinvented. There is no server-side write confirmation
(client-controlled, accepted decision) — hence `#[IsDestructive]`.

### Token scope
`StoreMcpTokenRequest` re-adds `scope` (`read` | `read_write`); the
controller grants `['mcp:read']` or `['mcp:read', 'mcp:write']`. The
settings page gets its scope selector back with honest copy (new strings
added to `lang/es.json`). The `/mcp` route stays gated on
`abilities:mcp:read` — any MCP token can connect and read; the per-tool
`mcp:write` check is what blocks writes.

### Tests
Happy path + guardrail failures for every write tool, the
read-only-token rejection (via a real read-only PAT so the `tokenCan`
gate runs exactly as over HTTP), cross-user isolation, the inherited Pro
gate, and read/read_write scope validation.

### Notes
- `AutomationRule::labels()` gained a generic return annotation (needed
for larastan level 5 on the new label mapping).

### Verification
- `vendor/bin/pint --test` 
- `vendor/bin/phpstan analyse` (larastan level 5) — 0 errors 
- `php artisan test tests/Feature/Mcp
tests/Feature/Settings/McpTokenTest.php
tests/Feature/LocalizationTest.php` 
- `prettier --check` / `eslint` on `settings/mcp.tsx` 
2026-07-17 15:25:03 +00:00
Víctor Falcón fb1adfc484
feat(mcp): read-only MCP server for Pro accounts (#689)
## What & why

Adds a **read-only MCP server** so a paid ("Pro") user can connect
Whisper Money to their own AI assistant (Claude web/desktop, Claude
Code, ChatGPT) and analyse their own finances — spending, cashflow, net
worth, transactions.

This is **Phase 1 (PR1): read-only**. Write tools (create/edit/delete
transactions, categories, labels, rules, balances) are a deliberate
follow-up (PR2); the token plumbing already reserves an `mcp:write`
ability for them.

## How it works

- **Transport:** remote streamable HTTP server via `laravel/mcp`,
mounted at `/mcp` (`routes/ai.php`).
- **Auth:** Sanctum personal access tokens with **MCP-only abilities**
(`mcp:read`). The route is gated by `auth:sanctum` +
`abilities:mcp:read` + `throttle:60,1`, so a future public-API token
(different ability) can't reach it and vice versa.
- **Pro gating** is enforced **per request inside the tools**
(`User::canUseFeature(PlanFeature::McpAccess)`), so a lapsed
subscription stops working on its own without the user revoking the
token. Free users can still create tokens (marked **PRO** in the UI) but
every call returns a "paid plan required" error with an upgrade URL.
- **Consent:** connecting is the consent — a clearly-weighted
data-egress disclaimer + per-client connection instructions on the
settings page. No separate checkbox (by design).

## Tools (all read-only)

| Tool | Scope |
|------|-------|
| `search_transactions` | space-scoped (optional `space`, defaults to
personal) |
| `list_accounts`, `list_categories`, `list_spaces` | space-scoped |
| `spending_by_category`, `get_cashflow`, `get_net_worth` | user's whole
account (reuse existing analytics services/controllers) |

Recurring-charge detection is left to the agent over
`search_transactions` results (no dedicated tool).

## Settings → MCP access

New page to create / rotate / revoke tokens (name + one-time secret
reveal), with `last_used_at`, a PRO badge, the egress disclaimer, and
copy-paste connection instructions for Claude (web/desktop), Claude Code
and ChatGPT.

## Tests

- Tool behaviour + Pro gating + **cross-user / cross-space isolation**
(`tests/Feature/Mcp/McpToolsTest.php`).
- HTTP auth boundary: 401 without a token, 403 without `mcp:read`, 200
with it (`tests/Feature/Mcp/McpEndpointAuthTest.php`).
- Token CRUD + ownership + free-tier creation
(`tests/Feature/Settings/McpTokenTest.php`).

## Reviewed & adjusted

Ran technical + product reviews and applied the fixes: kept PR1 strictly
read-only (dropped a UI scope selector that promised non-existent
write), routed gating through the `PlanFeature` convention, put token
rotation behind a confirmation, removed a silent on-load clipboard copy,
weighted the egress disclaimer, and fixed a `list_spaces` N+1.

### Known, deliberate tradeoffs
- `get_cashflow` / `get_net_worth` / `spending_by_category` reuse the
existing **user-scoped** analytics controllers/services, so they cover
the whole account rather than a single space (documented in the server
instructions). Per-space analytics is a follow-up.
- Space tools scope by `space_id` gated by membership
(`accessibleSpaces`) — the intended shared-tenant model — rather than a
per-row `user_id` filter.

## Not runnable in this environment
Browser QA of the settings page wasn't run here (no local
`node_modules`); that surface relies on CI build/typecheck/lint and
follows existing settings-page conventions.

---

## Updates since opening

- **Behind a feature flag.** New `App\Features\Mcp` (default off) hides
the whole settings screen — the nav item and every `settings/mcp*` route
(404 when off). Pro-plan gating still happens per request. Enable it
with `php artisan feature:enable "App\Features\Mcp" <email|all|25%>`.
- **Renamed** the user-facing page from "MCP access" to **"AI
Connector"** (nav, title, breadcrumb) so non-technical users understand
it. Route names, files and the feature stay internal.
- **Shared `ProBadge`** component (amber), now used on both the AI
Connector and billing pages instead of an inline badge.
- **Softer data-egress notice** (amber shield icon instead of a red
alert) and plainer copy throughout.
- **Accurate connection instructions (important).** Verified against the
official docs: a personal access token works with **Claude Code** today.
**Claude Desktop** and **ChatGPT** custom connectors authenticate over
**OAuth** and do not accept a static token, so they're now marked
**"coming soon"**. OAuth is the real unlock for those clients and is the
recommended follow-up (it also maps to the deferred write-tools work).
Sources: [Claude custom
connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp),
[ChatGPT developer
mode](https://developers.openai.com/api/docs/guides/developer-mode).
- UI reviewed in a real browser; layout/alignment checked across states
(empty, new-token reveal, token list).
2026-07-17 16:54:15 +02:00
Víctor Falcón 782ec2f2e9
feat(currencies): add Hong Kong Dollar (HKD) (#688)
Adds HKD (Hong Kong Dollar) as a selectable currency for both users
(primary/display) and individual accounts, following
`docs/adding-a-currency.md`.

## Changes
- `config/currencies.php` — new entry with `allows_primary => true`,
`allows_account => true`
- `lang/es.json` — Spanish name ("Dólar de Hong Kong")
- `lang/fr.json` — French name ("Dollar de Hong Kong")

## Verification
- Provider covers `hkd` at a sane rate (EUR→HKD ≈ 8.99) via
`@fawazahmed0/currency-api`.
- No custom symbol added — `Intl.NumberFormat` renders "HK$" on its own
(symbol map is optional per the doc).
- Validation, dropdowns, and conversion all derive from config
automatically; no code changes needed.
2026-07-17 11:46:21 +00:00
Víctor Falcón 4cd7619791
feat(encryption): report count of users still holding encrypted data (#687)
## What

`encryption:notify-removal` now prints, on every run, the total number
of **non-deleted** users who still hold legacy browser-encrypted data:

```
4 non-deleted user(s) still have encrypted data.
```

The count is taken before the billing exclusion, so it reflects the full
remaining scope of browser-encrypted data. Soft-deleted users are
excluded (model default scope), as are users whose data is already
plaintext.

## Why

It's the signal for deciding when the browser-side encryption code can
finally be removed: once this reaches zero, nothing in the app depends
on client-side encrypted columns anymore.

## Not changed

Email-sending logic is untouched. Subscribed users are still never
warned — the recipient set continues to go through
`excludeBilledUsers()` exactly as before. This PR only adds a reporting
line.

## Testing

- New test asserts the count spans everyone with encrypted data
(subscribed included), while excluding soft-deleted and plaintext users,
and that sending still skips the subscribed user.
- Full `NotifyEncryptedDataRemovalCommandTest` suite green.
2026-07-17 09:29:33 +00:00
Víctor Falcón 808d41eee2
feat(currencies): add Guatemalan Quetzal (GTQ) (#685)
## What

Adds the **Guatemalan Quetzal (GTQ)** as a selectable currency, both as
a primary/display currency and as an account currency.

Following `docs/adding-a-currency.md`, this is a config-driven change:
validation, the Inertia dropdown props, and conversion all derive from
the config entry automatically.

## Changes

- `config/currencies.php` — new `GTQ` entry (`allows_primary` +
`allows_account` both `true`)
- `lang/es.json` — Spanish name: `Quetzal guatemalteco` (enforced
locale)
- `lang/fr.json` — French name: `Quetzal guatémaltèque` (optional
locale)

## Compatibility with the conversion system (verified)

- **ISO 4217:** `GTQ` is the current code (not a
deprecated/pre-redenomination code — avoids the GHC-style ×10000 trap).
- **Provider coverage:** `@fawazahmed0/currency-api` serves `gtq` at a
sane rate (`1 GTQ ≈ 0.131 USD` → ~7.6 GTQ/USD).
- **Live conversion (tinker):** `100 GTQ → 13.13 USD` and `100 EUR →
873.19 GTQ` — both directions return real converted amounts, not the
unconverted passthrough.
- **Options + translation:** `CurrencyOptions` returns GTQ in both
`primaryOptions()` and `accountOptions()`, and renders `Quetzal
guatemalteco` under the `es` locale.

## Tests

```
php artisan test --compact tests/Feature/CurrencyConversionServiceTest.php tests/Feature/LocalizationTest.php
# 16 passed
```

Skipped the optional custom symbol in `resources/js/utils/currency.ts` —
`getCurrencySymbol` falls back to the code and `Intl.NumberFormat`
renders its own glyph (GHS skipped it too).
2026-07-16 17:23:37 +02:00
Víctor Falcón 7aa32dab0f
feat(currencies): add Swedish Krona (SEK) (#684)
## What

Adds **Swedish Krona (SEK)** as a currency available for both user
primary/display currencies and individual account currencies (including
bank accounts).

## Why

Config-driven per `docs/adding-a-currency.md`.
`App\Services\CurrencyOptions` reads `config/currencies.php` and feeds
validation (`in:` rules), the Inertia dropdown props, and conversion —
so a single config entry wires the whole feature.

## Changes

- `config/currencies.php` — SEK entry (`allows_primary: true`,
`allows_account: true`)
- `lang/es.json` — `"Swedish Krona": "Corona sueca"` (enforced locale)
- `lang/fr.json` — `"Swedish Krona": "Couronne suédoise"` (optional)
- `resources/js/utils/currency.ts` — `SEK: 'kr'` short symbol

## Verification

Confirmed the `@fawazahmed0/currency-api` provider covers `sek` at a
sane rate (SEK→EUR ≈ 0.091 → EUR→SEK ≈ 11).

## Test

```bash
php artisan test --compact tests/Feature/CurrencyConversionServiceTest.php tests/Feature/LocalizationTest.php
```
2026-07-16 14:25:33 +00:00
Víctor Falcón a873582191
feat(transactions): allow editing all fields of manual transactions (#683)
## What & why

Until now only the **category, notes, labels and (for manual
transactions) the description** could be changed after a transaction was
created — the **amount and date were immutable and the account was
create-only**. Users creating transactions by hand had no way to fix a
wrong amount or date.

This lets **manually created transactions edit every field at any time
after creation**: account, date, description and amount, on top of
category/labels/notes. **Imported / bank-synced transactions keep
amount, date, account, currency and description locked** to their source
data.

## Changes

**Backend**
- `UpdateTransactionRequest` now validates `amount`, `transaction_date`,
`account_id` and `currency_code` **only when the transaction's `source`
is `manually_created`**. For imported transactions those keys are not
validated, so `validated()` drops them and they can't be changed even
via a crafted request.
- `TransactionController::update()` moves the manual account balance to
match an edited amount/date/account, **opt-in via the same
`update_balance` flag used by create/delete**. It snapshots the pre-edit
state, and only rebalances when one of amount/date/account actually
changed. Connected accounts are skipped inside the adjuster.
- `ManualBalanceAdjuster` was refactored to a shared private `adjust()`
primitive (removing duplication between the existing create/delete
paths) and gains `reverseCreatedTransaction()` so an edit can reverse
the old contribution and apply the new one.

**Frontend**
- `edit-transaction-dialog.tsx` unifies the create/edit editability
decision behind a single `canEditAllFields` flag and renders the
account, date and amount inputs (plus the "update account balance"
checkbox) when editing a manual transaction.
- `transaction-sync.ts` `update()` gains an `{ updateBalance }` option,
mirroring `create()`.

## Known limitation (by design)

Like create/delete, the balance update trusts the opt-in flag and nudges
a **single dated snapshot** — it doesn't cascade to later snapshots and
keeps no record of whether creation adjusted the balance. So it's exact
for the common case (recent transaction, flag used consistently) and can
drift otherwise. This matches the app's existing snapshot-based balance
model; a transaction-derived balance would be a separate, larger change.
Documented with a `ponytail:` comment at the call site.

## Tests

- **Feature (`tests/Feature/TransactionTest.php`)**: manual transaction
edits amount/date/account/currency; imported transaction cannot; balance
moves by the delta on amount change; no change when not requested;
moving between accounts reverses the old and credits the new;
currency-only edit doesn't rebalance; connected accounts never change.
All green locally (55 passed).
- **Component (`edit-transaction-dialog.test.tsx`)**: manual transaction
shows editable amount/date/description; imported keeps them read-only.

## QA

Real browser QA (Playwright) against the running app with the
`demo@whisper.money` data:
- Opened a manual transaction → edited date, description and amount,
kept "update account balance" checked, saved. Verified in the DB:
`amount -4200 → -7550`, `date 2026-07-10 → 2026-07-12`, description
updated, and the account balance snapshots moved accordingly.
- Opened an imported transaction → amount and description render
read-only (locked).

## Demo

https://github.com/user-attachments/assets/1c8790e8-31f5-4283-b260-353650ad007c
2026-07-16 08:59:10 +02:00