## What
We had no idea whether anyone actually uses the MCP server. This records
one row per tool call and adds a report to read it back: which tools get
used, by which users, and how much.
**Storage** — `mcp_tool_calls`: `user_id`, `tool`, `created_at`. Raw
rows rather than pre-aggregated counters, because the volume is a
handful of calls per Pro user per day and a `GROUP BY` then answers
whatever we want to ask later. No arguments and no financial content are
stored.
**Recording** — one `rescue()`d insert in `McpTool::handle()`, the base
class all 24 tools inherit with no overrides, so coverage is complete by
construction. It sits *after* `respond()` and skips error responses, so
the number means "calls that did something": a plan-gate rejection, a
read-only token rejected on a write, or a `ValidationException` for an
unreachable id is an attempt, not usage. `rescue()` keeps a failed
insert from ever breaking a working tool call while still reporting to
Sentry.
**Reading** — `php artisan stats:mcp-usage [--days=30] [--top=20]`:
```
MCP usage — last 30 days (since 2026-07-13)
Calls: 15 Users: 2
By tool
+---------------------+-------+-------+-------+
| Tool | Calls | % | Users |
+---------------------+-------+-------+-------+
| search_transactions | 10 | 66.7% | 2 |
| get_cashflow | 2 | 13.3% | 1 |
| get_net_worth | 2 | 13.3% | 1 |
| create_transaction | 1 | 6.7% | 1 |
+---------------------+-------+-------+-------+
By user (top 20)
+--------------------------------------------+-------+-------+---------------------+
| User | Calls | Tools | Last call |
+--------------------------------------------+-------+-------+---------------------+
| ana@example.com | 12 | 4 | 2026-08-11 08:06:59 |
| 20260811080659_bruno@example.com (deleted) | 3 | 1 | 2026-08-11 08:06:59 |
+--------------------------------------------+-------+-------+---------------------+
By day
+------------+-------+-------+
| Day | Calls | Users |
+------------+-------+-------+
| 2026-08-08 | 2 | 1 |
| 2026-08-11 | 13 | 2 |
+------------+-------+-------+
```
The per-user table joins `users` instead of eager-loading the relation:
`user:delete` soft-deletes, so the FK cascade never fires and the
relation's `deleted_at is null` scope would silently blank out exactly
the users we most want to see — the ones who churned. They render with a
`(deleted)` marker.
## QA
Driven through the real `/mcp` HTTP endpoint with real Sanctum bearer
tokens, against MySQL:
- 5 successful calls by a read+write user → 5 rows, right user, right
tool names.
- `get_net_worth` with missing arguments and `search_transactions` on an
unreachable space → both rejected, neither recorded.
- A read-only token: `list_accounts` recorded; `create_label` rejected
with "This token is read-only" and not recorded.
- Report checked at `--days` 1 / 30 / 200, with `--top 1` truncation,
with a churned (`markAsDeleted()`) user, and with no data at all.
48 MCP tests green, `pint` and `phpstan` clean.
## Deliberately left out
- **No Discord post or schedule.** The other `stats:*` commands post
weekly; whether MCP usage is worth that noise is a product call, and
it's one `Schedule::command()` line whenever we want it.
- **No client column.** `Auth::getDefaultDriver()` would tell us OAuth
(Claude Desktop / ChatGPT) vs personal token (Claude Code) at the
insert. It's not recoverable after the fact, so it's worth knowing we
skipped it — but it wasn't asked for.
- **No pruning.** Noted in the migration; add it if the table ever gets
big.
- **No collector service.** The sibling report commands extract one
because they feed both the console and Discord. This has one consumer.
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.
> **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
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.
## 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.
## 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).
## 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
## 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.
> **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.
## 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.
## 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).
## Spaces / Business plan — Phase 0: invisible foundation
First of **three stacked PRs** introducing multi-tenant **Spaces** (the
basis for the Business plan). This one is a **pure, behaviour-preserving
foundation**: it can ship to production on its own with zero
user-visible change.
- **Stacked PRs:** this → `enterprise-spaces-ui` (Phase 1+2) →
`enterprise-spaces-invitations` (Phase 3).
### What a Space is
A Space groups its own accounts, connections, transactions, categories,
labels, budgets and rules. **Every user gets one invisible "Personal"
space**, provisioned automatically on creation — so the architecture is
identical for free, Standard and Business accounts, even though only
Business will ever see more than one.
### What this PR does (no behaviour change)
- `spaces`, `space_user`, `space_invitations` tables;
`users.current_space_id`; a nullable, indexed `space_id` on the 8 owned
tables (plain column, **no FK** — avoids a validating table-scan/lock on
`transactions` during a phased rollout).
- `Space` model + `BelongsToSpace` trait that stamps `space_id` on
create (from the row's user's current space; a transaction inherits its
account's space, so bank-sync lands rows correctly).
- Idempotent, chunked `spaces:backfill` command (run from a migration)
that gives every existing user a personal space and stamps their rows —
so the read switch in the next PR is safe.
- **Reads are untouched here** (still user-scoped); every user has
exactly one space, so behaviour is identical.
### Testing
- New `tests/Feature/Spaces/SpaceFoundationTest.php` (provisioning,
default-space stamping, account-anchored transaction space,
stale-pointer self-heal, backfill).
- Full suite green.
### Notes / deliberate simplifications
- `space_id` stays **nullable** (populated by backfill + on every
write); the NOT NULL constraint is deferred until prod is confirmed
fully backfilled.
- For very large `transactions` tables, `spaces:backfill` can be run
out-of-band before deploy so the migration's call is a no-op.
## What
Addresses Sentry **PHP-LARAVEL-3X** — a slow DB query in
`App\Jobs\SendDailyBankTransactionsSyncedEmailJob::handle()`.
The daily "transactions synced" email filters transactions by:
```php
Transaction::query()
->where('user_id', $this->user->id)
->where('source', TransactionSource::EnableBanking)
->when($lastSentMailLog?->sent_at, fn ($q, $at) => $q->where('created_at', '>', $at))
->whereHas('account.bankingConnection', ...) // EXISTS on PKs
->get();
```
The only `user_id`-leading index is `idx_transactions_budget_lookup
(user_id, transaction_date, category_id)` — its second column is
`transaction_date`, **not** `created_at`, so it can't serve
`source`/`created_at`.
## How
Add a composite index matching the predicate — two equalities then the
range:
```
idx_transactions_user_source_created (user_id, source, created_at)
```
The `whereHas` compiles to correlated `EXISTS` subqueries that join on
primary keys (`accounts.id`, `banking_connections.id`), so no extra
index on those tables is needed — the `transactions` index alone gives
the optimizer the selective driving path it currently lacks.
## Production verification (via read-only prod queries)
Confirmed the diagnosis and de-risked the deploy against the live
database:
- **The query is genuinely slow.** `EXPLAIN ANALYZE` for the heaviest
user (~6.1k EnableBanking transactions) runs in **~6 s**. The optimizer,
lacking a selective path, drives from a **full table scan of all ~2,500
accounts** and examines ~108k transaction rows (334 account loops × ~323
rows), filtering `user_id`/`source` only afterwards.
- **The index fixes it.** `(user_id, source, created_at)` lets the
optimizer drive from transactions — a seek to `(user_id,
'enablebanking')` (~6.1k rows) plus primary-key semi-joins — far cheaper
than the current ~108k-row plan, so it will be chosen.
- **The deploy is low-risk.** `transactions` holds **~297k rows** (not
millions). Adding a secondary index on InnoDB/MySQL 8 is online
(`ALGORITHM=INPLACE, LOCK=NONE`, no table rebuild), so at this size the
build is seconds and does not block the sync write path.
## Commits
1. `perf(db): index transactions for the daily synced-email query` — the
migration + a test asserting the index columns/order.
2. `test(db): assert the daily-email index name, not just its columns` —
lock the explicit index name (review feedback).
## Reviewed by two independent agents (architecture +
product/operational risk)
Both rated the change correct and shippable: column order right
(equalities before the range), migration reversible, index non-redundant
(an existing index can't be widened without breaking budget queries),
and no behavior/result change (a secondary btree index never alters
result sets; the job has no `ORDER BY`).
## Impact / risk
- **User impact:** indirect — a background email job (Sentry reports 0
interactive users), but the ~6 s query wastes queue-worker time and IO
on every run.
- **Complexity:** low — one additive, reversible index migration; no
application logic changed.
- **Write path:** one more index maintained per transaction insert (10th
on the table). Marginal, consistent with the existing UUID-leading index
cost profile.
Fixes PHP-LARAVEL-3X
## What
The transactions AI consent banner now lets users decline, tells them
the outcome, and stops appearing once they've decided.
- **Dismiss option**: an X button before "Enable AI" permanently hides
the banner without granting consent.
- **Show only until the first decision**: the choice is persisted on
`users.ai_consent_prompt_dismissed_at` (set on both accept and dismiss).
The banner shows only while the user hasn't responded and never
reappears afterwards — even if they later revoke consent from settings.
- **Clear feedback**: accepting or dismissing shows a toast stating
whether AI was enabled and that the choice can be changed anytime in
**Settings > Manage Plan**.
- **Full-width layout on desktop**: the banner content spans the full
available width (button pushed to the right, text on a single line). The
narrow stacked layout is kept for mobile only.
## How
- New `POST ai/consent/dismiss` endpoint
(`AiConsentController::dismiss`).
- `User::dismissAiConsentPrompt()` (idempotent) +
`hasDismissedAiConsentPrompt()`; `store` now also marks the prompt
dismissed on accept.
- Migration adds the nullable `ai_consent_prompt_dismissed_at`
timestamp.
- `TransactionController` passes `aiConsentPromptDismissed` to the page.
## Tests
- `AiConsentTest`: idempotent dismissal, dismissal without consent, and
accept marking the prompt dismissed.
- Full consent + backfill suite and the `create a transaction` browser
test pass; pint / lint / format clean.
## Problem
Users keep correcting the same transactions over and over. The AI
mislabels a merchant (e.g. supermarket → fuel), the user fixes it, and
the next near-identical transaction from that merchant gets mislabeled
the same way again. Today a correction is logged and the offending ai
rule is self-healed, but the system only *forgets* its mistake — it
never *remembers* the user's fix.
## Approach
A correction now becomes a deterministic, forward-looking
`AutomationRule` (new `RuleOrigin::Correction`). The next matching
transaction is categorized by that rule **before the model ever runs**
(`ApplyAutomationRules` is synchronous and runs ahead of AI
categorization), ending the loop. Zero model cost, instant, reuses the
existing rule engine.
**Matching key** (in order):
1. **Merchant** (`creditor_name`/`debtor_name`, exact `==`) when present
— stable even as the description varies.
2. Otherwise the **description's distinctive tokens** (`in` /
AND-of-`in`), extracted by the shared `DescriptionTokenizer` (noise
tokens dropped by document frequency, language-agnostic), **guarded**
against over-broad rules that could silently mis-file en masse. If
guarded out → nothing is learned, silently.
## Deliberate decisions (from a design walkthrough)
- **Forward-only**: never retroactively re-categorizes existing
transactions.
- **Learn only from system categorizations** (AI label, ai rule, or a
prior correction rule) — never from one-off manual filing, bank
categories, or the user's own hand-authored rules.
- **A key lives in exactly one correction rule**, so changing your mind
moves it to the new category. Correcting a transaction a prior
correction rule categorized is also learnable, so correction rules stay
fixable in-flow.
- Correcting to *uncategorized* learns nothing but still self-heals the
ai rule.
- **Safety net**: correction rules are visible/editable in
`settings/automation-rules` (marked with the AI sparkle, tooltip
"Learned from your correction"); the transactions table shows a toast
with an instant **Undo**.
## Review pass (two independent agents + live QA)
- **HIGH fix** (`67fc4293`): an ai rule could out-rank a freshly learned
correction and re-apply the wrong category (when the corrected
transaction was a *direct* model label with no rule id). Now every ai
rule holding the merchant is swept on correction. Regression test added.
- **Refactor** (`ca743fde`): collapsed duplicated clause-append logic;
removed a speculative unused enum helper.
- **Coverage** (`12ceb0f7`): debtor_name path, single-token description
clause, encrypted-description fail-safe.
- **Toast conflict fix** (`382c8169`, found in live QA): correcting an
AI transaction fired both the new "Learned …" toast and the pre-existing
"Transaction categorized → Automatize" prompt, which invited the user to
manually create the rule the correction had just created. Made them
mutually exclusive. Adds a browser test for the inline-correction flow.
- **Settings icon** (`f3f882b6`): correction rules now show the AI
sparkle in settings, like ai rules.
## Verified end-to-end (against a running instance)
Drove the real UI with a browser: correcting an AI-mislabeled
transaction creates the correction rule, shows the "Learned · Undo"
toast (no competing Automatize prompt), and Undo deletes the rule while
keeping the correction. Confirmed for **merchant** keys and the
**description-only** path — including that a later "practically
identical" description (different surrounding text, no merchant) is
caught by the rule, while a near-miss sharing only one distinctive token
is correctly **not** caught.
## Open question for reviewers
**`debtor_name` (P2P) as a rule key.** For incoming transfers the
merchant key falls back to the sender's name, so correcting one can
create a rule keyed on a person's name (useful for recurring transfers
from a roommate, but a possible privacy surprise; the name appears in
the rule title in settings). This matches the existing tier-2 learner's
behaviour. Keep as-is, or restrict correction rules to `creditor_name`
only? Happy to change.
## Testing
- `tests/Feature/Ai/CategoryOverrideHandlerTest.php`: merchant +
description learning, next-transaction match, over-broad rejection,
change-of-mind move, correct-to-null self-heal, the HIGH regression,
debtor_name, single-token, encrypted fail-safe.
- `tests/Browser/CategoryCorrectionLearningTest.php`: inline correction
→ toast → learned rule → undo.
- `automation-rule-title.test.tsx`: the AI sparkle shows for `ai` and
`correction`, not `user`.
- Full AI suite green (106 tests); transaction/bulk-update suites green
(62). Pint + Prettier + ESLint clean.
No new dependency, no migration (the `origin` column is a free-text
string). The feature is implicitly gated by AI categorization — with no
AI categorization there is nothing to correct and nothing is learned.
## What
The "new since last visit" highlight (#609) stored its marker in
`localStorage` (`transactions-last-visit`), so it was **per-device**:
each device kept its own last-visit timestamp and re-flagged the same
rows. This moves the marker to a per-user server column so a single
"last visit" is shared across all your devices.
## How
- New nullable `transactions_last_visited_at` column on `users` (same
pattern as `last_active_at` / `paywall_seen_at`).
- `TransactionController@index` reads the stored marker, renders the
list with the **old** value (`lastVisitAt` prop), then advances it
forward to the newest `created_at` it served.
- Frontend drops the `localStorage` read/write and just freezes the
`lastVisitAt` prop at mount; `isNewSince` per-row logic is unchanged.
## Why newest-served `created_at` and not `now()`
Advancing to `now()` would mark a back-dated synced row (old
`transaction_date`, lands on a later page that wasn't served) as seen,
so it would never be highlighted — the exact "hiding" failure the
per-row design avoids. Advancing only to what was actually served keeps
the feature's "err on showing, never hiding" stance. The marker only
ever moves forward.
## Notes
- Same filter caveat as before: opening the list with a filter applied
advances the marker using the filtered payload. Carried over from the
original `localStorage` behavior; not a regression.
- Removed the now-dead `loadLastVisit` / `saveLastVisit` /
`newestCreatedAt` helpers and their tests.
## Tests
- `NewTransactionsMarkerTest` (feature): null marker on first visit +
stores newest served; later visit sees previous marker + advances
forward; marker never moves backward.
- `new-transactions.test.ts` (`isNewSince`), Pint, ESLint, Prettier all
pass.
## What
Adds an **Edit** button (icon-only, top-right) to the dashboard accounts
grid, next to a new **Accounts** section title. It opens a dialog for
managing accounts:
```
Accounts [edit]
[ acc 1 ] [ acc 2 ]
[ ] [ ]
```
Each dialog row shows:
- the bank logo + account name
- an **eye toggle** (open/closed) to hide or show the account on the
dashboard grid
- a **drag handle** to reorder
## Behavior
- **Visibility**: hidden accounts disappear from the grid but stay in
the net worth chart/totals and remain in the dialog so they can be
re-enabled. Backed by a new `hidden_on_dashboard` column.
- **Reorder**: moved entirely into the dialog (over *all* accounts,
including hidden) instead of dragging the cards. This keeps a single
source of truth for `position` — dragging visible-only cards would have
left hidden accounts' positions inconsistent. The card-level drag handle
was removed.
- Both actions use optimistic UI, mirroring the existing reorder flow.
## Changes
- Migration: `hidden_on_dashboard` boolean on `accounts` (hidden from
default serialization like `position`, surfaced explicitly in the net
worth evolution payload).
- `AccountController::updateVisibility` +
`UpdateAccountVisibilityRequest` (owner-authorized) and the
`accounts.visibility` route.
- New `AccountsManagerDialog` component; dashboard header + plain grid;
removed the now-unused `dragHandle` prop from `AccountBalanceCard`.
- New `es.json` keys.
## Tests
Added feature tests for the visibility endpoint (toggle, validation,
ownership). Full `AccountControllerTest` and `DashboardAnalyticsTest`
suites pass; lint and types are clean on the touched files.
## Why
Prod warning logs were flooded with `Exchange rate not found, returning
unconverted amount` (≈500 lines from a single dashboard load). The
`ExchangeRateService` was behaving correctly — the source currency was
the ISO 4217 placeholder **`XXX`** ("no currency"), which has no
exchange rate, so conversions silently fell back to an **unconverted
(wrong) amount**.
Root cause: when importing accounts from a banking connection (Enable
Banking can report `currency: "XXX"`), the code did
`$accountData['currency'] ?? 'EUR'`. The `??` only catches
`null`/missing, not the literal `"XXX"`, so the placeholder was
persisted as `currency_code`.
Prod data confirmed `XXX` is the **only** account currency not covered
by the rates table (342 currencies, incl. BTC/VES/exotics, are all
present): 18 accounts (16 from bank links, 2 manual) across 14 users,
plus **5 users** whose base currency had itself become `XXX` (a
first-account `XXX` propagates to the user via `syncFromFirstAccount`).
## What
1. **Fix the source** —
`AccountUserCurrencyService::resolveImportedCurrency()` resolves a
bank-reported currency to: bank value → user base currency → app default
(`cashier.currency`), treating `XXX`/empty/missing as "no currency".
Wired into both creation paths (`CreatesAccountsFromPending`,
`AccountMappingController`); also covers Interactive Brokers imports.
2. **Backfill** — migration fixes existing rows in order: `XXX` users →
app default first, then `XXX` accounts → their owner's currency.
## Tests
- `AccountUserCurrencyServiceTest` — resolver chain (valid / uppercasing
/ XXX·empty·null → user / both missing → default).
- `AccountMappingTest` — mapping flow falls back to the user currency
when the bank reports `XXX`.
- `BackfillXxxAccountCurrenciesTest` — migration resolves both XXX
owners and XXX accounts end-to-end.
23 tests green; Pint and Larastan clean.
## What
A 3-way experiment on how the paid plan is offered, plus per-variant
measurement. New signups (on/after `SUBSCRIPTION_EXPERIMENT_STARTED_AT`)
are split evenly into:
- **control** — current 15-day trial.
- **reduced_trial** — shorter trial: 3 days monthly, 7 days yearly.
- **pay_now** — charged immediately (no trial), with a self-service
money-back guarantee for the first 3 days.
Earlier users stay **legacy** and keep the 15-day trial. **While
`started_at` is null the experiment is off and everyone behaves like
control — inert until activated via env.**
## How it works
- **Assignment** — `App\Features\SubscriptionExperiment` (Pennant),
deterministic even split by a stable hash of the user id. QA can force a
variant with `feature:enable`.
- **Offer policy** — `ExperimentOffer` is the single source of truth for
trial days per plan, the pay-now flag, the refund window and refund
eligibility; shared by checkout, paywall and billing.
- **Checkout** — trial length comes from the variant (`trialDays(0)` for
pay_now → immediate charge).
- **Onboarding clarity** — the paywall states the exact terms above the
CTA: trial length for the selected plan, or "charged €X today + 3-day
money-back guarantee" for pay_now.
- **Self-service refund (pay_now)** — Settings → Billing, within the
window: refunds the upfront charge, `cancelNow`, revokes bank
connections keeping imported data. `refunded_at` records it and blocks a
second refund. Crash-safe ordering: the refund is stamped before
cancel/disconnect, which run best-effort in a try/catch.
## Measurement
`stats:experiment-funnel` (weekly → Discord): per-variant funnel
(assigned, subscribed, status breakdown, refunds) with a **net-active
rate** gated by each variant's decision window (control 15d / reduced 7d
/ pay_now 3d) so cohorts are read at equal age. Attribution reads the
variant Pennant actually served each user, so the report can't drift
from what users experienced. It also reports **MRR** (monthly run-rate
of mature net-active subs, yearly normalised ÷12) and **ARPU** (MRR ÷
assigned) per variant — ARPU is the revenue metric for the winner
decision. Plus a winner can be pinned org-wide with
`SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT` (env, no deploy).
## Config (env)
- `SUBSCRIPTION_EXPERIMENT_STARTED_AT` — activates the experiment
(launch date). Null = off.
- `SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY` (3), `..._YEARLY` (7),
`..._REFUND_WINDOW_DAYS` (3)
## Tests
- **Feature/unit:** assignment, offer policy, checkout wiring, refund
eligibility, the refund action incl. idempotency + crash-safe ordering
(Stripe mocked), and the funnel collector/command. ES + FR translations.
- **Browser** (`tests/Browser/SubscriptionRefundTest.php`): the
self-service refund UX end to end — card visibility + deadline, two-step
confirm, back-out, the refund control disappearing after confirming, and
gating (window passed / non-pay_now hidden). The `RefundSelfServe`
action is doubled so it never hits Stripe but applies the same DB
effect. Screenshots: `refund-card-visible`, `refund-confirm-step`,
`refund-completed`.
- Full non-browser suite green (the one failing `DashboardTest` is
pre-existing on `main` — Inertia 409 from the unbuilt local manifest).
Pint + ESLint + tsc (changed files) clean.
## Two independent reviews — acted on
**Fixed:** refund atomicity/idempotency (major) · funnel attribution now
reads Pennant's served value instead of recomputing, killing
report-vs-runtime drift (major) · pay_now copy shows the exact amount
charged · throttle + block-demo on the refund route · `resolve(?User)`
nullable · French translations.
**Reviewer notes (deferred, low value):**
- `refunded_at` is not cast to Carbon on Cashier's `Subscription` (safe
today — only null-compared; would need a custom Cashier model).
- `ExperimentFunnelCollector` walks users in PHP via `chunkById`; fine
at current volume, can move to grouped SQL if it grows.
## Confidence: 85 / 100
The critical money path is now **verified live against the Stripe
sandbox** (see below), which removes the earlier cap. All gates are
green and the acceptance criteria are met. Held at 85 (not higher)
because the browser UI test runs in CI rather than locally, and the
pay_now *hosted-checkout + webhook* leg reuses the standard Cashier
checkout already proven by the control flow (only `trialDays(0)`
differs) but wasn't re-driven through the hosted page. Given it moves
money + disconnects accounts, a human glance is still warranted before
enabling.
## Sandbox verification (live Stripe test mode)
`php artisan stripe:verify-refund` creates a real immediately-charged
subscription with a test card, runs the actual `RefundSelfServe`, and
checks the Stripe API. Result:
```
PASS subscription active after immediate charge (pay_now, no trial)
PASS canSelfRefund is true before refund
PASS latestPayment() resolves a payment intent
PASS refunded_at is stamped
PASS subscription is canceled
PASS canSelfRefund is false after refund
PASS Stripe charge shows a full refund (refunded=true)
```
The command is committed and guarded to Stripe test keys /
non-production, so it can be re-run before each launch toggle.
## Launch checklist
1. Stripe-sandbox smoke: `php artisan stripe:verify-refund` (done —
passing). Optionally also drive the hosted pay_now checkout once for
monthly + yearly to confirm the webhook leg.
2. Set `SUBSCRIPTION_EXPERIMENT_STARTED_AT` to the launch date (set
once; don't backdate).
3. Watch `stats:experiment-funnel`; a clean cohort baseline lands once
each variant's window matures.
## Summary
- **Fix:** `integration-requests:review` crashed with `Attempt to read
property "email" on null` when a request's author no longer exists. The
command now renders a dash instead of assuming the `user` relation is
present.
- **Feature:** new terminal `done` status that marks an integration
request as shipped.
## The `done` status
Modeled after `not_doable` (a frozen, terminal state):
- Shown on the board, sinking to the bottom regardless of votes.
- Can no longer be voted on or unvoted (`removeVote` returns 404).
- Drops its public comment when set, so the board shows no stale note.
- Available in both `integration-requests:review` flows (pending review
and `--all`).
Frontend gets a `Done` badge and an `isFrozen()` helper that replaces
the scattered `=== 'not_doable'` checks gating the vote/unvote buttons.
## Tests
- 6 new feature tests: visibility without comment, bottom ordering,
vote/unvote blocked, command sets `done` and clears the comment, and the
command tolerating an orphaned author.
- `php artisan test tests/Feature/IntegrationRequestTest.php` → 34
passed.
- Board vitest suite, Pint, ESLint and Prettier all green.
## Why
We pay for AI transaction categorization on Gemini, but we couldn't tell
which model produced any given suggestion. The `gemini-flash-latest`
alias used by categorization is **floating** — it silently drifted from
the `flash-lite` tier up to `gemini-3.5-flash`, which is now the
dominant cost on the Gemini dashboard. Before swapping categorization to
a cheaper pinned model we need to measure accuracy per model, and for
that we have to know which model labelled each row.
We already store the AI-suggested category and its confidence on
`transactions`; this adds the missing third axis: the model.
## What
- New nullable `ai_model` column on `transactions`, written by
`CategorizeTransactions::recordOutcome` from
`config('ai_categorization.model')` whenever the model produces a
suggestion (above or below the label bar).
- The migration backfills existing AI-categorized rows
(`ai_suggested_category_at` not null) with `gemini-3.5-flash` — the
model `gemini-flash-latest` resolved to when those suggestions were
made. Done as a raw update so it doesn't bump every row's `updated_at`.
- `ai_model` added to `$fillable` and to `$hidden` (pure ops/measurement
metadata, no need to ship it to the frontend).
## Testing
- Extended `CategorizeTransactionsTest` to assert `ai_model` is stored
on both the auto-applied and below-bar paths.
- `php artisan test tests/Feature/Ai/CategorizeTransactionsTest.php` → 5
passed.
## Follow-ups (not in this PR)
- Pin `AI_CATEGORIZATION_MODEL` to an explicit cheaper tier (mirror the
existing `AI_SUGGESTIONS_MODEL=gemini-2.5-flash-lite`) and kill the
floating alias in the config defaults.
- Capture token usage from the SDK response for cost tracking.
## What
Adds **Interactive Brokers** as a banking sync provider (investment
account, balances only), mirroring the Indexa Capital integration.
It uses the **Flex Web Service** rather than IBKR's Web API: the Web API
requires registering as an IBKR third party (business entity, Compliance
approval, RSA-signed OAuth, ~3-5 weeks), which is overkill for read-only
balance sync. Flex is a read-only token + Query ID model that fits our
existing API-key provider shape.
### How the sync works
- The user creates an Activity Flex Query (NAV + Open Positions) and a
Flex Web Service token in their IBKR Client Portal, then pastes both.
- Client flow: `SendRequest` → reference code → poll `GetStatement` →
parse the XML statement.
- Mapping: `EquitySummaryByReportDateInBase@total` → `balance` (daily
rows give historical backfill on first sync); `Σ(OpenPosition
costBasisMoney × fxRateToBase) + cash` → `invested_amount`, so **profit
derives as `balance − invested_amount`** (unrealized P&L), like Indexa
Capital. Everything is already in base currency, so no FX conversion is
needed.
- One statement covers every account, so the syncer fetches once per
connection to respect IB's per-query rate limit.
- IB returns HTTP 200 with an error XML, so the client translates Flex
error codes into the exceptions the sync job already understands:
`RequestException(401)` for token problems, `RequestException(429)` for
throttling, `TransientBankingProviderException` otherwise.
### Connect flow
- New connect/update-credentials endpoints validate the credentials by
pulling a statement, then build pending accounts from it.
- Credentials reuse the encrypted `api_token` (Flex token) and
`api_secret` (Flex Query ID) columns — **no migration**.
- The IB option (two fields) is added to the connect dialog, inline
connect flow, and update-credentials dialog, with Spanish translations.
## Feature flag (why this is a draft)
Gated behind a Pennant feature `App\Features\InteractiveBrokers` (off by
default). It was built against documented/open-source Flex XML fixtures,
**not a live IBKR account** (we don't have one). Before enabling,
validate against a real account (beta tester or a free IBKR account):
```
php artisan feature:enable InteractiveBrokers user@example.com
```
If the parser needs tweaks against real XML, they should be minor
(field-name level).
## Tests
- Client + balance sync: NAV → balance, invested/profit, daily backfill,
since-date incremental, multi-account, GetStatement polling, token-401 /
rate-limit-429 mapping.
- Controller: feature-flag gate (403), valid/invalid credentials,
subscription gate, onboarding auto-create, validation.
- Factory wiring, enum cases, job-level sync, feature-flag visibility
(vitest).
- Spanish translations added (enforced by `LocalizationTest`).
All green: `pint --test`, `phpstan`, OpenBanking + localization suite,
vitest.
## What
Let users reorder their accounts by drag-and-drop. The order is shared
between the **dashboard** and the **accounts page**, and persisted
server-side.
## Why
The account order was fixed (by type, then name). Users want to put the
accounts they care about first, consistently across both views.
## How
**Backend**
- New `position` column on `accounts`, backfilled per user from the
previous type/name ordering so existing layouts are preserved.
- `PATCH /accounts/reorder` (`AccountController@reorder` +
`ReorderAccountsRequest`) persists the order and validates ownership of
every id.
- Dashboard and accounts queries now `orderBy('position')`. `position`
is cast to int and hidden from the serialized payload (order is conveyed
by array order).
**Frontend**
- Shared `SortableGrid` component built on `@dnd-kit` (new dependency).
Pointer drag starts after a small move (clicks still work); touch drag
starts on a long press, so quick swipes still scroll.
- The drag handle swaps with the account type icon on hover — top-right
on the dashboard card, bottom-left on the accounts card.
- The accounts page is now a flat list (type grouping dropped) so its
order matches the dashboard exactly.
- Reorder is optimistic and avoids refetching the deferred dashboard
metrics.
- Haptic feedback (`'selection'`, same as the mobile menu) fires when a
drag starts on touch.
- On mobile the accounts card stacks vertically (name / amount / trend)
and hides the redundant bank-name subtitle.
## Tests
- `reorder` persists positions and rejects accounts the user doesn't
own.
- Index ordering updated to assert `position` order.
- Existing account/dashboard/real-estate suites updated and green.
## Notes / follow-ups
- New accounts get `position = 0` (appear first) — can add `position =
max+1` on create later.
- On mobile the whole subtitle is hidden, including "Mortgage at X" for
real estate.
- Mobile drag-and-drop discoverability (the handle only shows on hover)
is still open — discussed but not yet decided.
## Why
Transactions and account names used to be stored encrypted client-side.
They now live **unencrypted in the backend**, so the browser no longer
needs to encrypt, decrypt, or mask them. A handful of legacy accounts
may still have encrypted transactions, but the auto-run
decrypt-migration handles those — so this PR strips the now-dead
encryption machinery while keeping the migration path fully intact.
Net: **−1213 / +176** lines across 25 files.
## Removed (dead machinery)
- `setup-encryption` page, `POST /api/encryption/setup`,
`SetupEncryptionRequest`, and `encrypt()` / `generateSalt()` — new data
is never encrypted again
- `EncryptedText` and the decrypt-on-render description component →
replaced by a plaintext `TransactionDescription` that keeps the
privacy-mode fake labels
- All `isKeySet`-gated client decryption: transaction list (load /
search / sort), categorize flow, import dedup
- `isKeySet` masking / locking on amount display, app logo, import
button, add-transaction button, account page, and filters
- The unused `?encrypted=false` API filter and `isKeyPersistent()`
## Kept (legacy decrypt-migration)
- The two migration hooks, the unlock dialog, and the dashboard/login
unlock prompt
- `decrypt` / `importKey` + key derivation, key-storage,
`EncryptedMessage`, salt, `GET /api/encryption/message`,
`?encrypted=true`, and the middleware salt-cleanup
- `rule-engine` / edit / import-drawer account-name decryption —
plaintext-first (`if (!account.encrypted) return name`) legacy fallback
on the kept primitives; intentionally untouched
## Testing
- Pint ✅ · ESLint ✅ · Prettier ✅
- 184 vitest tests ✅
- 1425 PHP Feature/Unit tests ✅ (Browser suite not run locally — needs
`bun run build`; no Browser test references the encryption UI)
## Follow-up (out of scope)
`DecryptedTransaction.decryptedDescription` / `decryptedNotes` are now
just plaintext aliases. Collapsing that type touches 13+ files (rule
engine, categorizer, edit dialog) and was left as a separate cleanup.
## What
Adds a weekly `stats:stuck-cohort-report` command that measures the
percentage of users "stuck" on the paywall, compares it to the previous
week, and posts the result to Discord.
## Definition of "stuck"
A user with a non-deleted banking connection and **no** valid
subscription:
- has a banking connection (`bankingConnections()` — SoftDeletes already
excludes deleted)
- has no subscription with `stripe_status` in `active` / `trialing` /
`past_due`, nor a `canceled` one still in grace (`ends_at > now()`)
Built with Eloquent (`whereHas` / `whereDoesntHave`), no raw SQL.
## Metric
`stuck_pct = stuck / onboarded users` (`onboarded_at` not null — the
population that reached the paywall). The Discord message also shows the
raw counts, not just the percentage.
## Week-over-week
Each run snapshots `date`, `onboarded_count`, `stuck_count`, `stuck_pct`
into a new `stuck_cohort_snapshots` table (`updateOrCreate` per day,
idempotent), then compares against the most recent prior snapshot to
report the delta in percentage points and stuck count. First run has no
prior → reports current only.
## Discord
Reuses the `SendAiCohortReportCommand` pattern: posts an embed via
`DiscordWebhook` to `config('services.discord.ai_cohort_webhook_url')`
(fallback `webhook_url`). No direct `env()`.
## Schedule
`Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid')`
in `routes/console.php`.
## Tests
7 tests (percentage calc across subscription statuses incl. canceled
in/out of grace, soft-deleted connection, non-onboarded excluded from
denominator, snapshot persistence, delta vs previous, single upsert per
day, Discord POST asserted via `Http::fake`). `pint` clean.
## What
Sends an automated follow-up email the day after a user finishes
onboarding but stays stuck on the paywall, asking why they didn't
continue and inviting a direct reply.
## Why
Users who connect a bank account during onboarding but never pick a plan
are permanently redirected to the paywall by `EnsureUserIsSubscribed` —
they finish onboarding and then can't access the app. This reaches out
to understand the friction and recover them.
## Who gets the email (the genuinely stuck cohort)
- `onboarded_at` is calendar-yesterday (`whereDate('onboarded_at',
today()->subDay())`)
- Has an active banking connection (`bankingConnections()->exists()`)
- No Pro plan (`hasProPlan()` is false)
- Hasn't already received this email (idempotent via `UserMailLog`)
Users **without** a bank connection are excluded: they fall through to
free access after seeing the paywall once, so they aren't stuck.
## How
- New `email:paywall-follow-up` command scans the cohort daily and
queues the job — scheduled `dailyAt('10:00')` Europe/Madrid in
`routes/console.php`.
- New `DripEmailType::PaywallFollowUp` (`paywall_follow_up`) +
idempotent send job following the existing drip pattern.
- Short, human Markdown email with an explicit `replyTo`
(`hi@whisper.money`) so replies reach a real inbox.
- Spanish copy added to `lang/es.json`.
## Tests
10 new tests (correct cohort matched; wrong day / no bank / Pro plan
excluded; no double-send; no-op when drip disabled). `pint` clean.
## Note
There was no existing user-scanning drip command (current drips dispatch
with `delay()` on `Registered`). Since "stuck on paywall" state isn't
known at registration time, a daily scanning command is the right fit.
## Summary
- Raise the monthly integration-action quota for paying users: free plan
keeps **3** actions, pro plan gets **9** (resolved via
`User::hasProPlan()`, so self-hosted instances with subscriptions
disabled get the full quota).
- Votes are no longer toggles. Each vote spends one action and pushes
the request up the board, so a user can back the same integration
multiple times. The `(integration_request_id, user_id)` unique index is
dropped (with a standalone FK index added first, since MySQL relied on
the unique one).
- Let users undo a vote, **but only one cast in the current month**. The
refund maps back to the current quota and previous months' tallies stay
locked. The board exposes `can_unvote` and shows a `ChevronDown` control
next to the upvote button, backed by a new `DELETE
/integration-requests/{id}/vote` route.
## Tests
- Feature tests for the free (3) and pro (9) quotas, repeated voting up
to the limit, undoing a current-month vote (and recovering the action),
and the previous-month vote being non-removable.
- Updated the board fixture and added the `Remove one vote` Spanish
translation.
## What
- **Markdown comments**: admin comments on the integration board now
render as markdown (`react-markdown` + `remark-gfm`) instead of plain
text. Links open in a new tab; blockquotes, lists and bold are styled.
Comments are admin-only (set via CLI), so the content is trusted.
- **`in_progress` status**: new status, visible to everyone and votable
like `approved`, with an optional public comment. Lets the admin signal
an integration is actively being built.
- **`integration-requests:review` rework**:
- Any decision can now move a request to any status (approve / in
progress / reject / not doable), not just the pending ones.
- New `--all` flag prints the full list with a `#` column so the admin
can pick a request by number and change its status.
- `not doable` requires a comment; `in progress` allows an optional one;
approve/reject clears any stale public comment.
## New dependencies
- `react-markdown`, `remark-gfm` (approved with the author).
## Tests
- 24 feature tests passing, incl. `--all` status change, `in_progress`
visibility + voting, and updated review-command option sets.
- Frontend vitest covering markdown rendering (link + blockquote).
## Summary
Adds a `not_doable` status to integration requests for cases we've
reviewed but can't implement (e.g. no public API).
- **Applied with a comment** — the `integration-requests:review` command
now offers a `not doable` choice that requires a comment explaining why.
The comment is stored on the request and shown to users.
- **Shown at the end** — not-doable requests stay visible to everyone
but sink to the bottom of the board regardless of their votes, with the
comment rendered below the name.
- **Not votable** — voting is blocked both in the UI (disabled button)
and the backend (404), even for the author.
- **Rejected stays hidden** — rejected requests never appear in the list
(unchanged behaviour, now covered by a test).
## Changes
- `IntegrationRequestStatus` enum: new `NotDoable` case + label.
- Migration: nullable `comment` text column on `integration_requests`.
- `IntegrationRequestController`: list includes not-doable for everyone,
ordered last; vote guard rejects not-doable.
- `ReviewIntegrationRequestsCommand`: `not doable` option with a
required comment prompt.
- Board component: not-doable badge, comment, disabled voting.
- Factory `notDoable()` state, `es.json` translation, feature tests.
## Testing
- `php artisan test tests/Feature/IntegrationRequestTest.php` — 21
passed
- `vendor/bin/pint --dirty`, `bun run format`, `bun run lint` — clean
## What
A community board where users **propose** a bank/service (name + link)
and **vote** on the integrations they want most, so we can prioritise by
real demand.
## How it works
- **Global board**: a shared list sorted by votes desc. `has_voted` and
the monthly quota are per user.
- **Limit**: 3 combined actions (proposal + vote) per user per calendar
month. Creating a proposal **auto-votes** it → costs 2 actions.
- **Moderation**: proposals start as `pending` (visible only to their
author, with a "Pending review" badge); `approved` ones are visible to
everyone. You can't vote on what you can't see (404). The `php artisan
integration-requests:review` command approves/rejects pending ones.
- **Access**:
- Bottom drawer (Vaul, ~95vh) that opens the same board.
- Entry points: the **connect-bank** dialog (bank-selector step), the
**create-account** dialog, and a global **"Request integration"** item
in the user menu (above Support) → available on any screen.
- The `/integration-requests` URL renders the **dashboard** with the
drawer opened on top (behind auth + verified + onboarded + subscribed).
- **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and
Interactive Brokers as `approved` and self-voted by the earliest-created
user (idempotent, no-op when no users exist).
## Endpoints
- `GET /integration-requests` → dashboard + drawer.
- `GET /integration-requests/data` → board state as JSON (feeds the
drawer).
- `POST /integration-requests` → create a proposal (+ auto-vote).
- `POST /integration-requests/{id}/vote` → toggle vote.
## Tests
- 15 Feature tests (access, status visibility, monthly limit, auto-vote,
vote toggle, review command) plus the user-menu tests. All green;
`pint`/`eslint`/`prettier`/`tsc`/PHPStan clean.
## Notes / follow-ups
- No Pennant flag (ships to everyone). No dedup of duplicate proposals
(moderation covers it).
- Seeding 5 integrations to one user uses up that user's monthly quota
(only affects that account via the UI).
## Contexto
El categorizador solo persistía un resultado cuando la confianza
superaba la barra (`ai_categorization.label_confidence`, 0.7). Las
sugerencias por debajo del umbral se calculaban y se descartaban,
dejándonos sin ninguna visibilidad de qué proponía el modelo para las
transacciones que no se autocategorizaban.
## Cambio
Se guarda la sugerencia del modelo en **toda** transacción que el modelo
coloca, mediante columnas tipadas (en vez de un blob JSON):
- `ai_suggested_category_id` (FK a `categories`, nullable)
- `ai_suggested_category_at` (timestamp, nullable)
- `ai_confidence` (ya existía) — ahora se rellena **siempre**, no solo
al aplicar
Por debajo de la barra la transacción sigue sin categorizar, pero la
sugerencia queda guardada; a partir de la barra, además se autoaplica la
categoría como hasta ahora.
## Para qué sirve
- **Afinar el umbral 0.7 con datos reales**: `where
ai_suggested_category_id is not null and category_id is null` muestra
exactamente qué nos perdemos.
- Habilita una futura UI de "la IA cree que es X, ¿confirmas?"
directamente desde el FK.
## Tests
- `CategorizeTransactionsTest`: el caso sub-umbral ahora verifica que la
sugerencia se persiste; el caso aplicado verifica que además se guardan
los campos de sugerencia.
- Suite de IA + tests que tocan transacciones en verde (111 tests).
## What
Unifies keys that were repeated across the codebase into two PHP enums
as the single source of truth. Two independent commits:
### `refactor(banking)` — `BankingProvider` enum
- New `App\Enums\BankingProvider` (`indexacapital`, `binance`,
`bitpanda`, `coinbase`, `wise`, `enablebanking`).
- `BankingConnection`'s `provider` column is cast to the enum → magic
strings gone from the model, controllers (`match`), requests, jobs,
commands, factory and `where('provider', ...)`.
- Logic that was duplicated, now on the enum:
- `usesApiKey()` — API-key auth (everything except EnableBanking).
- `defaultAccountType()` — provider → account type (removes the
duplicated ternary in `CreatesAccountsFromPending` and
`AccountMappingController`).
### `refactor(i18n)` — `Locale` enum
- New `App\Enums\Locale` (`en`/`es`/`fr`).
- Unifies the `['en','es','fr']` list (validation in `SetLocale` and
`ProfileUpdateRequest` via `Rule::enum`) and the `Accept-Language`
detection (`Locale::detectFromHeader`), previously duplicated in
`SetLocale` and `CreateNewUser`.
## Interaction with Wise (PR #525)
Rebasing onto Wise surfaced two issues this PR fixes:
1. **`isWise()` was broken** once `provider` is cast (it compared `===
'wise'` against an enum → always `false`).
2. **Account type**: Wise uses an API key (✓ it gets the auth-failed
email) but is a **checking** account, not an investment one. Account
type now flows through `defaultAccountType()` (Wise/EnableBanking →
Checking; indexa/binance/bitpanda/coinbase → Investment).
## Behavior change (minor)
`CreateNewUser` now detects `fr` at registration (previously only
`es`/`en`), consistent with the existing French support.
## Out of scope
- Frontend (`['indexacapital','binance',...]`, `provider: string`):
syncing TS with the PHP enums would need typegen. It still receives the
string via `->value`.
- `TransactionSource::Wise/EnableBanking`: a separate enum (transaction
origin), not a duplicated check.
## Tests
- New: `tests/Unit/Enums/BankingProviderTest.php`,
`tests/Unit/Enums/LocaleTest.php`.
- `vendor/bin/pint --test` ✓ · Larastan ✓ · full suite (excluding
Browser) **1615/1616** ✓.
## Summary
Adds **Wise** as an API-token banking provider — connect flow,
transaction sync, and per-wallet balance sync — and fixes two bugs that
kept business/extra profiles and balances from working.
## Changes
- **Balance sync** — new `WiseBalanceSyncService` pulls
borderless-account balances on every sync. Previously Wise balances were
never fetched, so the displayed balance went stale (frozen at the last
value).
- **Multi-profile connect** — `WiseController` now creates accounts for
**every** profile on the token (personal *and* business), not just one.
- **ID-format fix** — `external_account_id` is now
`"{profileId}:{currency}"`. The connect flow previously wrote the
**borderless-account id**, but both sync services parse the **profile
id** — so any UI-connected account (and every business profile) silently
failed to sync.
- **Tests** — `WiseBalanceSyncTest` (currency matching, idempotent
today-balance, skip-when-unmapped) and `WiseControllerTest`
(multi-profile pending accounts, onboarding auto-create, invalid token).
## Test plan
- [x] `php artisan test
tests/Feature/OpenBanking/WiseBalanceSyncTest.php
tests/Feature/OpenBanking/WiseControllerTest.php` — 6 passing / 28
assertions
- [x] `vendor/bin/pint` — clean
- [ ] CI (full pest + lint + build)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
## Summary
Adds **catch-all budgets** — a budget flagged `is_catch_all` absorbs
every expense-category transaction not already claimed by another
(non-catch-all) budget, so out-of-budget spending is still tracked.
## Changes
- Migration: `is_catch_all` boolean (default false) on `budgets`.
- `Budget` model: fillable + boolean cast.
- `BudgetTransactionService`: a catch-all budget matches expense
transactions whose `category_id` is not claimed by any non-catch-all
budget; period assignment mirrors the same rule.
- `BudgetController`: supports the flag.
## Notes
- Pre-existing WIP committed as-is; CI is the validation gate.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
## What
Auto-categorizes transactions with AI (Gemini) for **pro +
AI-consented** users when no automation rule already matched. Ships the
full backend **behind a Pennant flag, off by default**, so it's
mergeable and testable in isolation; the UI is a deliberate follow-up.
## Why / cost
Prod check first: ~20k txns/month, **~52% of pro-user transactions are
uncategorized** after rules. At Gemini Flash-Lite rates the cost is a
**rounding error** — ~$0.13–$0.75/month for all pro users, single-digit
dollars even on full Flash. So the model is chosen for accuracy, not
price; the real constraints are trust, accuracy and privacy.
## How it works
**Two tiers** (every transaction is covered, rules are an optimization
on top):
- **Tier 1 – label** — a queued listener runs *after* the synchronous
rules; if still uncategorized and the user is eligible, the model picks
a **leaf** category (referenced by numeric index, never a UUID, so it
can't hallucinate one). Auto-applied only above the **label bar**
(`0.7`); below → left blank, no nag. Tagged `category_source = ai` +
`ai_confidence`, fully reversible.
- **Tier 2 – learn** — above the higher **rule bar** (`0.85`) *and* a
clean merchant key *and* the model flags the merchant unambiguous → the
merchant is appended to a single **ai-owned** automation rule for that
category (OR'd conditions, not rule-sprawl), so future transactions
match for free and consistently. AI rules sit at the lowest priority;
**user-owned rules are never touched**.
**Self-heal + signal** — when a user overrides an AI category, a
`category_correction` is logged (calibration signal, bucketable by
confidence) and the offending merchant condition is dropped from the ai
rule (deleted if empty). User rules and manual categories are untouched.
**Safety** — config kill switch + pro + active consent + gradual Pennant
rollout. Dedicated `ai` queue so Gemini never blocks bank syncs.
Encrypted (client-side) transactions are never sent.
**Backfill** — `ai:categorize-backfill {user}`, explicit opt-in,
batched, learns rules as it goes.
## Data model
- `transactions`: `category_source`, `ai_confidence`,
`categorized_by_rule_id`
- `automation_rules`: `origin` (`user`/`ai`)
- new `category_corrections` table
## Screenshots
<img width="921" height="384" alt="image"
src="https://github.com/user-attachments/assets/f04c2a03-b39e-4a3d-81eb-ecf26eaefb83"
/>
Suggests transaction categorization rules during onboarding.
After a sync or import, it groups the uncategorized transactions, asks
Gemini (via laravel/ai) to map the common merchants to categories, and
shows the results for review. The user edits or drops any and creates
the ones they want. During onboarding the accepted rules also categorize
existing transactions right away.
Off by default: it needs the `AiRuleSuggestions` Pennant flag and a
per-user AI consent. The model and thresholds are config-driven.
`ai:suggest-rules {user}` prints what a user would get.
The settings-page surface and monthly regeneration are a follow-up.
## What
Adds two timestamp columns to `users`:
- **`last_logged_in_at`** — set by an `UpdateLastLoggedInAt` listener on
Laravel's `Login` event. Records each explicit authentication (login
form or remember-me re-auth). Does **not** update on plain session-based
requests.
- **`last_active_at`** — set by a `TrackLastActiveAt` middleware on
authenticated web requests. Records the last moment the user did
anything. Throttled to at most one write per 5 minutes to avoid a DB
write on every request.
## Why
`last_logged_in_at` answers "when did they last authenticate";
`last_active_at` answers "when were they last using the app" (any
screen/activity), which a session-based login does not capture.
## Tests
- Login records `last_logged_in_at`
- Authenticated request records `last_active_at`
- Throttle window respected (no rewrite within 5 min) and refreshed once
it passes
Migrations not yet run on environments.
## What
Reworks the filtered **transaction analysis drawer** to behave like a
*project view* — a coherent set of related transactions (a trip, a
rental, a renovation, a side hustle) — and surfaces data that was
already computable but never shown.
### Two shapes, auto-detected
The drawer adapts to whether the set is **expense-only** or **income +
expense**, detected from the income share (`income >= 15% of expense`).
A stray refund won't flip a trip into a P&L view; a rental's rent will.
- **Expense mode:** total spent, daily average, cumulative spend line.
- **Income mode:** net result + margin %, income vs expense bars,
cumulative **net** line.
The mode can be overridden per filter (Auto / Expenses only / Income &
expenses), mirroring the existing daily-average override exactly:
remembered in the browser per filter fingerprint and synced to a
matching saved filter via a new `analysis_mode` column + PATCH endpoint.
### New panels (all from existing data)
- **Largest expenses** — top 5, expandable to 10, biggest spends first.
- **Spending by payee** — horizontal bars, gated to ≥2 named payees.
- **Spending by account** — list, gated to ≥2 accounts.
### Smarter table
The largest-expenses table hides any column the filter or the rows have
pinned to a single value (e.g. filtering by one label drops the Labels
column; a one-category result drops the Category column).
## Tests
- **Backend:** new Pest coverage for `largest_expenses`, payee/account
breakdowns, `cumulative_net`, and the `analysis_mode` endpoint
(`TransactionAnalysisTest`, `SavedFilterTest`) — all green.
- **Frontend:** 9 vitest cases covering mode detection/override, the day
override, and column hiding.
- New `__()` keys added to `lang/es.json`.
## Notes
- Adds migration `add_analysis_mode_to_saved_filters_table`.
## Summary
Adds an **Analysis** button to the transactions toolbar (left of
Categorize) that opens a bottom-sheet dashboard summarizing the
transactions matching the current filters. Built around the Miami-trip
use case: tag a trip, filter to it, and see where the money went.
Everything is behind the existing `TransactionAnalysis` feature flag
(button hidden + endpoint returns 403 when off).
## Behavior
- **Button**: hidden unless `features.transactionAnalysis`. Disabled
until at least one filter is applied — desktop shows a hover tooltip,
mobile a tap dialog, both reading *"Apply a filter to enable this
button."*
- **Drawer** (vaul bottom-sheet, like the importer): fetches
`/api/transactions/analysis` with the current filters on open. Skeleton
+ empty/error states.
- **Charts**: KPI cards · spending over time (income/expense bars +
cumulative line, auto daily/monthly bucketing) · category donut + ranked
list (only when >1 category) · per-tag bars (only when >1 label).
- **Manual day override**: the *Avg / day* card has an editor to
override the date span used for the daily average (tickets bought months
ahead skew the span). Resolution precedence: matching saved filter's
`analysis_days` → `localStorage[fingerprint]` → auto span. Edits persist
to localStorage always, and sync to the saved filter when the current
filters match one.
## Backend
- `Api/TransactionAnalysisController@summary` — reuses
`Transaction::applyFilters()` + `IndexTransactionRequest`, converts to
the user's base currency via `ExchangeRateService` (rates preloaded),
rolls categories up through `CategoryTree`.
- `GET /api/transactions/analysis`.
- `analysis_days` (nullable) on `saved_filters` + `PATCH
/api/saved-filters/{id}/analysis-days`.
## Tests
- Pest: analysis endpoint (flag gating, totals, category/tag breakdown,
day/month bucketing, cumulative) + saved-filter day override
(set/clear/forbidden/invalid).
- Vitest: button gating (hidden/disabled/opens) + day-override
resolution (auto / saved-precedence / localStorage fallback / persists
to saved filter).
- New `lang/es.json` keys for all added strings.
## Notes
- Data is always sourced from the backend.
- The flag is still off by default — enable `TransactionAnalysis`
per-user to try it.
## Why
iOS PWAs hand the bank redirect back to the system browser (Safari),
where the app session does not exist. The old `/open-banking/callback`
sat behind `auth` + `verified` middleware, so Safari hit it with no
session → bounced to `/login`, the callback body never ran, and the
connection stayed `pending` forever (4 stale rows observed in prod).
## What
Make the bank connection flow session-independent end to end.
### 1. State-token callback (session-independent)
- `store()` generates `Str::random(40)`, persists it on the connection,
and passes it to `startAuthorization` as `state`.
- `/open-banking/callback` is now **public**. It resolves the connection
from `state_token` (`resolveConnectionFromState`) and derives the owner
from the connection, not the session.
- Connection is finalized server-side (`session_id`, status,
`state_token = NULL`) before any redirect.
- New migration adds the nullable, unique `state_token` column; the
column is hidden on the model.
### 2. Completion page instead of login bounce
When the callback runs without a session (the Safari case), the
connection is already finalized server-side. Instead of bouncing the
user to `/login`, render a standalone "go back to the app" page — their
session lives in the PWA, not here.
- New page `resources/js/pages/open-banking/connection-complete.tsx`
(success / error states, dark mode).
- `finishRedirect()` renders the page when `!Auth::check()`; logged-in
users still get the normal redirect.
### 3. Onboarding: land on the connections step + poll cross-browser
- A logged-in user finishing the flow lands straight on the onboarding
connections step (`?step=create-account`), now resolved server-side via
a validated `initialStep` prop so the step is deterministic.
- While on that step the page polls every 4s (`usePoll`, `only:
['accounts']`), so a connection finalized in another browser (PWA →
Safari) shows up without a manual refresh.
## Flow
```
POST /authorize → stateToken = Str::random(40)
→ startAuthorization(state = stateToken)
→ create BankingConnection(pending, state_token)
EnableBanking → callback?code&state=stateToken (PUBLIC, no session needed)
→ find connection WHERE state_token = state
→ createSession(code) → finalize (session_id, status, state_token = NULL)
→ session present? redirect to connections step / mapping
: render "go back to the app" completion page
PWA (logged in) → onboarding?step=create-account → polls accounts every 4s
```
## Tests
36 passing. Covers:
- state-token persistence and session-less finalization
- owner resolved from the connection regardless of who is authenticated
- completion page rendered (success + error) when no session; login
fallback when no connection resolves
- logged-in user redirected directly to the onboarding connections step
- `?step=create-account` lands on that step; unknown step falls back to
default
## What
Adds **saved filters** on the transactions page so users can name a set
of filters and reuse them later. The whole feature is gated behind a new
\`transaction-analysis\` Pennant feature flag (off by default).
## Why
Reusing the same filter combinations (e.g. "Japan trip", "Utilities") is
currently manual every time. This ports the saved-filters work from the
\`analysis-page\` branch, scoped down to **only** the transactions page
— the analysis screen and unrelated query changes from that branch are
intentionally left out.
## Changes
**Feature flag**
- \`App\Features\TransactionAnalysis\` — class-based flag, resolves
\`false\` by default.
- Shared to the frontend as \`features.transactionAnalysis\` and added
to the \`Features\` type.
**Backend (saved filters)**
- \`SavedFilter\` model (UUID, \`filters\` json cast, user belongsTo) +
migration + factory.
- \`Api\SavedFilterController\` — user-scoped index/store/update/destroy
under \`/api/saved-filters\`, ownership enforced with \`abort_unless\`.
- \`StoreSavedFilterRequest\` / \`UpdateSavedFilterRequest\` — name
unique per user, snake_case filter rules.
**Frontend**
- \`transaction-filter-serialization.ts\` —
serialize/deserialize/fingerprint between UI filter state and the
persisted snake_case shape.
- \`SavedFilters\` dropdown — load/save/update/delete with active +
dirty indicators.
- Integrated into the filter bar via an opt-in \`enableSavedFilters\`
prop, so it renders **only** on the transactions page (budgets/accounts
reuse the same component and stay unchanged). Render is also gated by
the feature flag.
**Filter bar UX**
- Split search + filters + saved searches onto their own row, separate
from the actions row (Categorize / Add transactions / columns), which
were getting cramped.
- On mobile, the text search moves into the filters popover.
## Reviewer notes
- Backend endpoints are **not** flag-gated; only the UI is, per the
request.
- Two-layer scoping: page opt-in prop **and** feature flag — flag off
means no UI anywhere.
- Translation keys fall back to the key (English); \`es.json\` entries
not added, matching the source branch.
## Testing
- \`tests/Feature/SavedFilterTest.php\` — 9 passing (auth, per-user
listing/uniqueness, ownership on update/delete).
- Pint, Prettier, ESLint clean. No new TypeScript errors introduced.
## Summary
Adds nested categories (parent → child, up to **3 levels**) across the
app. Children inherit their parent's type and cashflow direction, and
every category selector now renders the hierarchy as an indented tree.
Gated behind the `CategoryTree` Pennant flag (off by default).
## Backend
- **Migration**: nullable self-referencing `parent_id`; uniqueness
scoped per-parent via a `parent_unique_marker` virtual column (root
names stay unique). New composite unique created before dropping the old
one so the `user_id` FK keeps a supporting index.
- **`CategoryTree` service**: descendant/ancestor resolution, depth &
cycle checks, type cascade, subtree deletion.
- **Validation**: depth limit, cycle prevention, inherited + locked
child type.
- **Delete strategies**: reparent (default), promote to root, or cascade
(uncategorizes affected transactions).
- **Transaction filter** expands a selected parent to its descendants.
- **Cashflow Sankey & breakdown** roll up to top-level parents with
click-to-drill (children + a parent "direct" node).
- **Budgets** tracking a parent also count their children's
transactions.
- **Unified** the frontend category query behind
`Category::forDisplay()` / `FRONTEND_COLUMNS` (8 call sites) so every
selector receives the full Category shape, including `parent_id`.
## Frontend
- New `category-tree.ts` helpers (build/flatten/descendants/path,
tree-aware selection toggle + tri-state).
- **Settings page**: indented tree, sortable by name/color/type
(siblings sorted, hierarchy preserved), parent picker, delete-strategy
dialog.
- **Combobox** (transaction table cell + edit/create modal + parent
picker): indented tree, search keeps matches with their ancestors.
- **Transaction filter**: indented tree, tri-state cascading selection
(parent ↔ children), themed checkboxes, selected branches float to top
on open, ancestor-aware search.
- **Categorizer palette** and **budget multi-select**: same indented
tree + ancestor-aware search.
- **Sankey**: click a parent node to drill into its children, with
breadcrumb.
- Pennant `CategoryTree` flag gates the parent UI.
## Tests
- Pest: model/validation, delete strategies, filter expansion, cashflow
rollup/drill, budget child inclusion.
- Vitest: tree-aware selection logic.
- All green; Pint / ESLint / Prettier clean.
## Rollout
Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
## Summary
Adds a **Notifications** section to the account settings page
(`/settings/account`, between Profile information and Update password)
where users can opt out of the daily "new transactions synced" email.
- New per-user preference `notify_on_bank_transactions_synced` on
`user_settings` (defaults to `true`, opt-out).
- `SendDailyBankTransactionsSyncedEmailJob` skips users who disabled it.
- Single generic `PATCH /settings/notifications` endpoint updates any
notification type via a key→column allowlist in
`NotificationPreferenceController::PREFERENCES`. Future notifications
only need a new entry there — no new route/controller.
- Email footer now links back to the settings section so users can
manage preferences.
## Testing
- `tests/Feature/Settings/NotificationPreferenceTest.php` — update,
unknown-key rejection, invalid value, auth, create-when-missing,
default-true, inertia prop.
- `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php` — email
not sent when disabled.
## Summary
Budgets previously tracked a single category **or** label (mutually
exclusive). This lets a budget span **multiple** categories and labels
at once, all pooling spend against one allocated amount per period.
Scope decided with the requester:
- **Shared pool** — one allocated amount; any tracked category/label
counts against it.
- **Multi categories + multi labels** on a single budget.
- **Create-only** — tracking is chosen at creation and locked afterward
(edit dialog shows it read-only).
## Changes
**Backend**
- New `budget_category` + `budget_label` pivot tables; data migration
copies existing `category_id`/`label_id` into them, then drops those
columns.
- `Budget` model: `categories()` / `labels()` belongsToMany.
- `BudgetTransactionService` matches transactions across **all** tracked
categories OR labels (live assignment + historical backfill).
- `StoreBudgetRequest` accepts `category_ids` / `label_ids` arrays,
requires ≥1 across both, validates ownership. `update` no longer touches
tracking.
**Frontend**
- Reusable `MultiSelect` (popover + command + badge).
- Create dialog uses multi-selects; cards and show page render tracked
categories/labels as badges; edit dialog shows them read-only.
- Dexie bumped to v10 (drops unused per-category allocations table, adds
`budget_labels`).
## Testing
- Updated all budget/transaction/listener/browser tests for the pivot
model; added cases for multi-category matching, mixed category+label
pooling, and store validation (empty selection + foreign-ownership).
- Added the new `__()` strings to `lang/es.json`.
- Local `pint`, `lint`, `format` pass. Relying on CI for the full suite.
## Summary
- Always show a simplified Cashflow and charts explanation in the
category form
- Explain how each category type affects cashflow, income/spending
charts, top spending categories, savings, and investments
- For transfer categories, keep a cashflow chart visibility selector so
users can choose hidden/inflow/outflow
- Show savings and investment categories as outflows in the cashflow
chart and store default/new categories as outflow
- Add a new migration to update existing production savings/investment
categories to outflow
- Avoid user-facing Sankey terminology and update Spanish copy to use
dinero/saldo instead of efectivo
- Add missing Spanish translations and browser/feature coverage
## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
- php artisan test --compact
tests/Feature/Console/ResetUserCategoriesCommandTest.php
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter="migration updates existing default saving and investment
categories|migration sets saving and investment categories to cashflow
outflow|users can create savings and investment categories|default
categories are created when user registers"
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
--filter="sankey includes savings and investment categories on the
expense side|sankey includes outflow transfer categories on the expense
side"
- php artisan test --compact tests/Browser/CategoriesTest.php
--filter="explains savings and investment category cashflow impact in
the create dialog|can create a new transfer category with a cashflow
analytics direction"
- php artisan test --compact tests/Browser/CategoriesTest.php
- ./vendor/bin/pest --testsuite=Performance --filter="account show page
does not exceed query threshold"
- vendor/bin/pint --dirty --format agent
- npx prettier --write
resources/js/components/categories/category-cashflow-direction-fields.tsx
- npm run build (assets emitted; Sentry sourcemap upload reports local
SSL error)
## Video
- screenshots/category-create-cashflow-setting.webm
## Notes
- npm run types still fails on existing unrelated TypeScript errors.
## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns
## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/OpenBanking/TransactionSyncServiceTest.php
tests/Feature/TransactionFilterTest.php
- npm test -- resources/js/lib/file-parser.test.ts --run
- vendor/bin/pint --dirty --format agent
Note: `npm run types` still has pre-existing unrelated errors; no
creditor/debtor/import-related errors remained in filtered output.
## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>