Commit Graph

924 Commits

Author SHA1 Message Date
Víctor Falcón ee69c51a84
feat(transactions): make new-transaction marker cross-device (#611)
## 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.
2026-06-29 19:11:37 +02:00
Víctor Falcón 884038c13b
feat(transactions): highlight new transactions since last visit (#609)
## What

Between visits, highlight the transactions that arrived since the user
last opened the list.

- The newest `created_at` from the initial page load is stored in
`localStorage` (`transactions-last-visit`), frozen at mount.
- Every transaction whose `created_at` is newer than that marker gets a
subtle accent on the row (left border + faint tint).

## Why per-row instead of a single divider line

The first cut drew one "Last visit" divider with new rows above it. A
code review found this is structurally wrong: the list is sorted by
`transaction_date` (when the money moved), but "new" is defined by
`created_at` (when the row was inserted). The two don't correlate — a
bank sync that imports a batch dated across several days scatters the
new rows throughout the list, so a single boundary lands at the first
older row and **hides every new row below it**, the exact miss the
feature was meant to prevent.

Per-row marking is correct regardless of sort order: no new transaction
can be hidden.

## Notes / limitations

- The marker is written **once at mount** from the initial payload. Rows
that arrive mid-visit (load-more, a sync/refresh, AI categorization) do
not advance it, so they are never recorded as "seen" before the user has
seen them. The trade-off is they may re-appear as new next visit (errs
on showing, never hiding).
- Purely client-side (localStorage), so the marker is per-device.

## Tests

- Unit tests for `isNewSince` and `newestCreatedAt`.
- `LocalizationTest`, ESLint, Prettier all pass.
2026-06-29 16:08:14 +00:00
Víctor Falcón 5ef3e01c89
fix(transactions): let date column size to its content (#610)
## What

Remove the fixed `max-w-[90px]` cap (and `pr-1`) on the transaction date
cell so the date column sizes to its content instead of wrapping
awkwardly.

## Why

The 90px cap forced the formatted date to wrap onto two lines in the
transactions table.
2026-06-29 15:51:51 +00:00
Víctor Falcón 300756e553
feat(stats): add --no-discord to the remaining report commands (#607)
## What

Follow-up to the `--no-discord` flag added to `stats:experiment-funnel`.
Extends the same flag to the **other stats report commands that post to
Discord**, so any of them can be run on demand (e.g. inside the prod
container) and just printed to the console without posting:

```bash
php artisan stats:subscription-funnel --no-discord
php artisan stats:ai-cohort-report   --no-discord
php artisan stats:stuck-cohort-report --no-discord
php artisan stats:daily-report       --no-discord
```

## How

- The two **funnel** commands (`subscription-funnel`) already printed
their table to the console, so `--no-discord` just short-circuits the
`->send()`.
- The **cohort/daily** commands only ever built a Discord embed. A small
`App\Console\Commands\Concerns\RendersReportToConsole` trait renders an
embed (title / description / fields) as plain text, so `--no-discord`
prints a readable report to the console instead of posting.
- Default behaviour is unchanged: without the flag (and on the scheduled
weekly/daily runs) they post to Discord exactly as before.

## Tests

A `--no-discord` test added to each command (`Http::assertNothingSent()`
+ the command succeeds). Pint + Larastan + all four command suites
green.

> Note: the commands query the default DB connection, so for
**production** numbers run them inside the prod container (Coolify
terminal).
2026-06-29 15:32:31 +02:00
Víctor Falcón 1db2871398
feat(stats): add --no-discord flag to stats:experiment-funnel (#606)
## What

Adds a `--no-discord` flag to `stats:experiment-funnel` so the
per-variant report can be run **on demand (e.g. in production) and only
printed to the console**, without posting to the Discord channel.

```bash
php artisan stats:experiment-funnel --no-discord
```

## Why

Handy for ad-hoc checks of the live experiment without spamming the
ops/Stripe Discord channel. The scheduled weekly run (and any run
without the flag) still posts to Discord exactly as before — default
behaviour is unchanged.

## Notes
- The console table already prints before the Discord step; the flag
just short-circuits the `->send()` and prints `Skipped Discord
(--no-discord).`
- Test added: with `--no-discord`, the command succeeds, prints the
table, and `Http::assertNothingSent()`.
- Pint + Larastan + the command's Pest suite green.

> Tip: the command queries the default DB connection, so to see
**production** numbers run it inside the prod container (e.g. via the
Coolify terminal).
2026-06-29 12:31:29 +02:00
Víctor Falcón 09d6e8ee6c
feat(subscriptions): reframe pay_now paywall copy around try-and-refund (#605)
## What

Follow-up to #600. Reframes the **pay_now** paywall message so the
emphasis is on *trying it risk-free*, not on being charged today.

**Before**
> You'll be charged €3.99 today
> Changed your mind? Get a full refund yourself from Settings within the
first 3 days — no questions asked.

**After**
> **Try it for 3 days**
> Don't like it? Get a full refund with one tap from Settings — no
questions asked.
> _You pay €3.99 today, refunded in full if you cancel within 3 days._
(muted footnote)

## Why

The upfront-charge headline reads as friction. Leading with "try it /
one-tap refund" frames pay_now as risk-free while the charge stays
**honestly disclosed** as a muted footnote — so it's never a surprise at
refund time (and avoids chargeback risk). Amount and day counts stay
dynamic per plan/variant.

## Notes
- Only the pay_now branch of `TrialTerms` changes; control/reduced
trials untouched.
- Spanish + French translations updated; the two orphaned old keys
removed.
- Lint, tsc (paywall), and `LocalizationTest` pass; no test asserted the
literal copy.
2026-06-28 10:40:59 +00:00
Víctor Falcón 777dfc07b2
feat(dashboard): add accounts manager dialog with visibility toggle and reorder (#604)
## 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.
2026-06-27 16:11:25 +00:00
Víctor Falcón 8bbff05b26
fix(banking): only log sync failures once the connection gives up (#603)
## Why

Production log dashboards were flooded with paired `Banking sync failed`
+ `EnableBanking API error` warnings for a handful of users, recurring
on every 6-hour `banking:sync` cycle for days. Investigation (prod
`BankingSyncLog` + connection state) showed the affected connections are
**healthy and syncing daily** (`last_synced_at` = today,
`consecutive_sync_failures = 0`): the first attempt hits a transient
`ASPSP_ERROR` / `429`, and the job's retry recovers it.

So the warnings are **noise, not breakage** — but
`SyncBankingConnectionJob` logged `Banking sync failed` inside the catch
block on *every* attempt, including non-final ones that later succeed.
That's one warning per cycle for connections that sync fine, which reads
as a failure and causes alert fatigue.

## What

Gate the `Log::log(...)` call so it only fires when the connection
actually gives up:

- the **final attempt** (`attempts() >= tries`), or
- a **permanent auth error** (`isAuthError`).

Transient errors recovered by the retry are no longer logged.
Per-attempt traceability is unchanged: `BankingSyncLog` still records
every attempt (Success / Failed) in the database.

## Tests

`tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`:
- non-final attempt → `Log::log` is **not** called
- final attempt → `Log::log('error', 'Banking sync failed', ...)` **is**
called once

Full file: 24/24 passing.
2026-06-27 16:04:36 +00:00
Víctor Falcón bc57eae5c3
fix(open-banking): stop storing the XXX no-currency placeholder on accounts (#602)
## 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.
2026-06-27 16:01:21 +00:00
Víctor Falcón e5350ff1a6
feat(subscriptions): trial/pricing A/B/C experiment (#600)
## 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.
2026-06-27 18:00:15 +02:00
Víctor Falcón e4be39be12
feat(integration-requests): add done status and fix review command crash on orphaned author (#601)
## 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.
2026-06-27 14:42:09 +00:00
Víctor Falcón 756b4816a6
feat(stats): add weekly subscription funnel report (#599)
## What

Adds a weekly **registration → subscription → paid** funnel so we can
baseline subscription conversion and, later, measure A/B tests against
it.

- `stats:subscription-funnel` artisan command — prints the cohort table
and posts it to Discord (same webhook as the other weekly reports).
- `SubscriptionFunnelCollector` — builds the age-normalized weekly
cohort series.
- Scheduled weekly, Mondays 09:15, next to the existing cohort reports.

## How the funnel is defined

Every stage is measured from each user's **own signup**, so weekly
cohorts are compared at the same age regardless of the calendar.

- **Registered** — signups that week.
- **Subscribed** — started a `default` plan ≤30d after signup (entered
checkout/trial).
- **Paid** — that plan billed past the 15d trial: currently `active`, or
`canceled` only after outliving the trial. `trial_ends_at` is cleared on
cancel, so subscription lifespan is the reliable "did it ever bill"
signal.

Both stages are bounded by the same subscription-creation window, which
guarantees the funnel invariant **registered ≥ subscribed ≥ paid** (a
test enforces it — I caught and fixed a window bug where `paid` could
exceed `subscribed`). Cohorts too young to score show `pend`; signup
surges (launch/marketing waves) are flagged with .

## Current baseline (prod, 2026-06-27)

| Stage | Count | Rate |
|---|---|---|
| Registered | 1,289 | — |
| Subscribed | 158 | 12.3% of registered |
| Trial resolved | 128 | conversion denominator |
| Paid after trial | 91 | **71%** of resolved · 7.1% of registered |
| Currently active | 75 | 82% of payers retained |

⚠️ ~940 of the 1,289 signups arrived in the last ~5 weeks (a
late-May/June surge); their trials haven't fully resolved, so
cohort-level conversion for that wave isn't measurable yet. The 71% is
status-based — a clean cohort baseline lands in ~4–6 weeks.

## Tests

5 Pest tests covering cohort counting, the attribution window, the
funnel invariant, maturity gating, and Discord delivery. All green, Pint
clean.
2026-06-27 15:40:11 +02:00
Víctor Falcón d7bc4e6707
feat(transactions): reorder filters and switch accounts to a logo dropdown (#598)
## What

Updates the transaction filters popover:

- **Reordered** the filter sections to: **Date, Amount, Category,
Labels, Accounts, AI, Counterparties**.
- **Accounts** now use a searchable dropdown (the same pattern as
Categories/Labels) instead of toggle badges. Each option shows the
**bank logo** as its icon (with a `Building2` fallback for accounts
without a logo/bank).

## Why

The badge list didn't scale well with many accounts and was visually
inconsistent with the other multi-select filters. A dropdown keeps the
popover compact and adds search.

## Notes

- Fixed `handleAccountToggle` parameter type (`number` → `UUID`) to
match `accountIds`.
- Each account `CommandItem` uses a unique `value` (`name + id`) so
accounts that share a name aren't collapsed by cmdk's default filtering.
- Added Spanish translations for the new keys (`Select accounts...`,
`Search accounts...`).

## Tests

- New `transaction-filters.test.tsx` covering select/deselect of an
account and the empty state.
- Full front-end suite green (244 tests), ESLint/Prettier clean,
localization test passes.
2026-06-26 20:09:02 +02:00
Víctor Falcón 4038e60fbc
fix(ai): handle transient AI provider overloads — stop the Sentry noise and retry the dropped work (#595)
## Why

Sentry issue **PHP-LARAVEL-3S** (`ProviderOverloadedException: AI
provider [gemini] is overloaded`) recurs whenever Gemini returns 503/429
under high demand — 7 events in 3h during the last surge, 0 users
impacted.

Two problems behind it:

1. **Sentry noise.** `CategorizeTransactions::resolveChunkWithRetry`
retries, `laravel/ai` fails over providers, and a still-failing chunk is
deliberately dropped so the rest of the backfill proceeds. But the catch
reported **every** dropped chunk via `report()`, so an expected,
self-healing transient condition floods Sentry and buries real bugs.
2. **Silently lost work.** A dropped chunk leaves those transactions
uncategorized (`category_id NULL`) with nothing to re-trigger them: the
backfill jobs are one-shot (`tries = 1`), there is no scheduled
backfill, and the real-time listener only handles *new* transactions.
They stay uncategorized until someone manually re-runs a backfill.

## What

**1. Stop reporting transient failures.** Catch `FailoverableException`
(the marker interface for `ProviderOverloadedException` /
`RateLimitedException`) separately and log a warning instead of
reporting it. Everything else still goes to `report()` unchanged, so
real failures (malformed responses, insufficient credits, …) keep
surfacing.

**2. Retry the dropped work.** On a transient failure, schedule a
deferred, per-user `RetryTransientAiCategorizationJob` that re-reads the
user's still-pending transactions once the provider has had time to
recover (`ai_categorization.retry_delay`, default 10 min).
- `ShouldBeUnique` per user collapses a surge of dropped chunks into a
**single** retry.
- The unique lock is held through processing, so a retry that overloads
again **cannot chain another** — exactly one deferred attempt per
failure, no infinite loop. Failed 503s aren't billed.
- Wired in `resolve()`, so it covers every entry point (backfill,
onboarding, real-time listener, admin command).
- Model cost is negligible (per config), so the retry re-runs the
existing backfill rather than tracking which exact chunks failed.

## Tests

- Transient overload → chunk dropped, nothing reported, retry scheduled
for the user.
- Unexpected failure → reported, **no** retry scheduled.
- Retry job → categorizes still-pending transactions for a consenting
user; no-op without consent (agent never prompted).

Full `tests/Feature/Ai` + listeners suite green (111 tests); Pint clean.

## Existing prod backlog

The transactions already dropped before this ships stay `pending`; an
`ai:categorize-backfill <user>` recovers them on demand.

Fixes PHP-LARAVEL-3S
2026-06-26 20:07:06 +02:00
Víctor Falcón d6ec9830df
feat(transactions): refine new transaction form layout and balance toggle (#597)
## What

Three tweaks to the **Add Transaction** form:

1. **Account selector moved to the top** of the create form, above the
date field.
2. **"Update account balance" checkbox hidden for connected accounts**
(accounts with a `banking_connection_id`). Their balance is kept in sync
by the banking connection, so the submit also skips the balance update
for them.
3. **Amount placeholder** now shows a negative example `-25.00` instead
of `0.00`.

## Testing

- Added a unit test covering that the checkbox is hidden for a connected
account.
- `bun run test edit-transaction-dialog` — 5 passed.
- `bun run format` / `bun run lint` clean (only a pre-existing warning
in `chart.tsx`).
2026-06-26 17:59:44 +00:00
Víctor Falcón 934d834ab3
feat(email): follow up after post-onboarding AI consent (#596)
## What

Adds a lifecycle email sent **2 days after a user records AI consent**,
but only when that consent was given **outside of onboarding** — i.e.
more than 3 days after sign-up. This targets users who deliberately
opted into AI later (e.g. via the billing toggle / transactions banner
gated by the `AiConsentSettings` flag), not those who consented during
initial setup.

## How

- **`email:ai-consent-follow-up`** scheduled command (daily, 10:15
Europe/Madrid) selects active consents accepted exactly 2 days ago,
skips onboarding-era ones (`accepted_at <= created_at + 3 days`), and
queues a job per user.
- **`SendAiConsentFollowUpEmailJob`** sends the mailable and records a
`UserMailLog`. Dedup, locale and the queued-email plumbing all reuse the
existing drip system — **no migration**.
- Email is localised automatically via the user's `HasLocalePreference`,
like every other drip email.

## Design notes

- **Scheduled command, not a 2-day delayed job** — survives worker
restarts and respects consent revoked in the meantime.
- **Exact "2 days ago" window**, matching the existing paywall drip. If
the scheduler misses a day that cohort isn't retried; kept consistent
with the other drips on purpose.
- Copy is a first draft — open to wording tweaks.

## Tests

`tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php` — 9 tests
covering the happy path, onboarding exclusion (incl. the exact-3-days
boundary), timing window, revoked consent, the disabled-drip gate,
single-send + mail log, no-resend, and locale rendering. All green, Pint
clean.
2026-06-26 17:56:06 +00:00
Víctor Falcón 291cfbe261
feat(ai): record the model behind each AI categorization (#594)
## 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.
2026-06-26 12:53:55 +00:00
Víctor Falcón 094ff4d5ac
feat(banking): enable Interactive Brokers for all users (#593)
## What

Removes the `InteractiveBrokers` Pennant feature flag. The integration
is now always available to every user.

## Changes

- Delete the `App\Features\InteractiveBrokers` flag class.
- Drop the per-user `abort_unless(...->active(...))` gate from
`InteractiveBrokersController`.
- Stop sharing the `interactiveBrokers` flag in Inertia props
(`HandleInertiaRequests`) and remove it from the `Features` type.
- Remove the now-unused `feature` gating mechanism from the
connect-provider registry and `useConnectFlow` — Interactive Brokers was
its only consumer.
- Update tests: drop the "blocked when flag off" case and all flag
activations; the connect dialog now asserts IB is always offered.

The Interactive Brokers integration itself (provider enum, client,
syncer) is untouched.

## Testing

- `php artisan test InteractiveBrokersControllerTest
InertiaSharedDataTest` — 17 passed
- `vitest connect-account-dialog` — 7 passed
- `bun run lint` / `bun run format` — clean
2026-06-26 11:03:21 +02:00
Víctor Falcón f72e2a64ca
feat(features): support percentage rollouts in feature:enable (#592)
## What

`feature:enable <feature> <target>` now accepts a percentage like `25%`
as the target, in addition to a user email or `all`.

```bash
php artisan feature:enable AiConsentSettings 25%
```

This activates the feature for a random `ceil(total * N/100)` sample of
the **current** users, persisted in Pennant's `features` table — no need
to edit the feature class and redeploy for a percentage-based rollout.

## Notes

- It targets the current user base. New users keep the feature's default
(`false`) unless the command is run again.
- Percentage is validated to be between 1 and 100.
- `feature:disable` was left unchanged — disabling for a random subset
has no clear use case.

## Test plan

- [x] `feature:enable AiConsentSettings 40%` enables the feature for
exactly 4 of 10 users
- [x] An out-of-range percentage (`0%`) fails
2026-06-25 10:46:30 +00:00
Víctor Falcón 9a458b1031
feat(ai): manage AI consent outside onboarding with live backfill (#591)
## Summary

Lets existing (non-onboarding) users discover and manage AI
categorization, gated behind a new Pennant feature flag. When a flagged
paid user grants consent, every uncategorized transaction is categorized
in the background with live, on-screen feedback.

Enable per user: `php artisan feature:enable AiConsentSettings
user@example.com`

## What's included

**Feature flag**
- New `AiConsentSettings` Pennant flag (off by default), exposed to the
frontend via the shared `features` prop.

**Billing settings (`/settings/billing`)**
- When the flag is active, a section to grant or revoke AI consent
(checkbox → `POST`/`DELETE /ai/consent`).

**Transactions page**
- A consent prompt rendered as the first row of the transactions table
(new `DataTable` `topRow` slot), styled with the shared gradient
`AiSparkleIcon`. Only shown to paid users with the flag who haven't
consented yet.
- An inline "Enable AI" button records consent without leaving the page.

**Backfill on consent**
- Granting consent dispatches a queued
`CategorizeUncategorizedTransactionsJob` that categorizes all of the
user's uncategorized transactions, **most recent first**, recording
progress in the cache.
- The transactions page polls a new status endpoint (`GET
/ai/categorization/{jobId}/status`): visible uncategorized rows show a
spinner + pulse while the backfill runs, categories stream in via
partial reloads, and a toast tracks `processed / total` until
completion.

## Notes / decisions

- **Polling, not websockets** — the project has no broadcasting
configured; this mirrors the existing `ReEvaluateTransactionRules` job +
status-endpoint pattern.
- The backfill job is kept separate from
`CategorizeOnboardingTransactionsJob` so the onboarding pass stays
progress-free (small, commented duplication).
- Reuses the existing `AiCategorizationGate` (config kill-switch + pro
plan + active consent) — free users and disabled-AI never dispatch the
job.
- Not included (YAGNI): cancelling an in-flight backfill on revoke;
persisting the spinner across full page reloads.

## Testing

- New Pest coverage: feature-flag exposure, billing/transactions consent
props, consent-triggered dispatch + gating, the status endpoint
(200/404), job progress recording, and recent-first ordering.
- `php artisan test --exclude-testsuite=Browser` passes (one unrelated,
pre-existing `DashboardTest` failure on `main`).
- Pint, Prettier and ESLint clean.
2026-06-25 10:50:35 +02:00
Víctor Falcón 578a9b44d8
fix(banking): keep the native green Wise logo, not the aggregator's (#590)
## Context

Follow-up to #589. In production, the deduped Wise entry rendered with
the **Enable Banking blue wordmark** instead of our own green "W" mark,
even though clicking it correctly starts our native (personal-token)
integration.

## Cause

#589 set the native Wise provider's `logo` to the Enable Banking brand
URL (`https://enablebanking.com/brands/BE/Wise/`) and deleted our local
asset — based on the mistaken belief that the green mark came from the
aggregator. It was the other way around: our asset
(`public/images/banks/logos/wise.png`, added in #525) **was** the green
mark; the Enable Banking URL serves the old blue wordmark.

## Fix

- Restore the green Wise asset.
- Point the native provider's `logo` back at
`/images/banks/logos/wise.png`.
- Update the regression test's expected logo accordingly.

Only the `logo` field was ever wrong — the dedup and unique-key fixes
from #589 are unaffected, so a single Wise entry still surfaces our
native integration.
2026-06-24 09:34:37 +02:00
Víctor Falcón ed5aac0c4a
fix(banking): stop Wise appearing multiple times in the connect list (#589)
## Problem

In production, searching for a bank like **Wise** in the *Connect Bank
Account* picker showed it many times, and **the count grew every time
the search box changed** (4 → 6 → …). Only one entry — the real native
integration — should appear.

## Root cause

Two bugs stacked on top of each other:

1. **Non-unique React key.** The list rendered each institution with
`key={institution.name}`. Wise is offered both natively (our own
API-token integration) and by the Enable Banking aggregator, so two rows
shared the key `"Wise"`. On every re-render of the filtered list, React
couldn't reconcile the duplicate keys and **leaked orphaned DOM nodes**
instead of replacing them — so the entry multiplied with each keystroke
(and rendered out of alphabetical order). React even warned: *"two
children with the same key, Wise … may cause children to be
duplicated"*.
2. **No dedup against native integrations.** Even without the growth,
Wise showed twice (aggregator + native). It should only surface through
our own integration.

The bank list is built from the Enable Banking API merged with our
native providers — it does **not** read from the `banks` table, which
was a red herring (only 2 non-duplicated Wise rows there).

## Changes

- Unique key (`name-country-index`) in both the dialog and inline
pickers.
- Drop aggregator institutions we already integrate natively, so Wise
only surfaces through its own integration (general — also covers
Binance, Coinbase, etc.).
- Point the native Wise logo at the official brand mark; remove the
now-unused local asset.

## Tests

Added regression tests covering dedup and repeated filtering. Reproduced
the bug first (counts grew `[3,4,5,6,7]`), then confirmed the fix holds
it at `1`. Full JS suite green (242 tests).
2026-06-23 16:20:59 +02:00
Víctor Falcón 619ed0f1db
feat(banking): let Wise credentials be updated (#588)
## What

Makes **Wise** connections support credential updates, like every other
API-key provider.

Wise was the only API-key provider not wired into the credential-update
path:
`ConnectionController::validateProviderCredentials()` had no Wise arm,
so it
fell to the `Unsupported provider` default, and the update dialog
(driven by the
provider registry's `updatable` flag) rendered nothing. A Wise
connection in an
auth-error state therefore showed an **Update Credentials** button that
opened
an empty, unusable dialog.

## Fix

- Add the Wise validation arm in `validateProviderCredentials`
  (`WiseClient::getProfiles()`). The validation rules and the
`api_token` → column mapping already come from `BankingProvider`'s
credential
  registry, so nothing else on the backend changes.
- Drop the now-unused `updatable` flag from the connect-providers
registry —
every connect provider is updatable — and simplify
`update-credentials-dialog`
  to render fields for any registry provider.

## Tests

- Updating Wise credentials with a valid token succeeds (status back to
active,
  token stored, sync dispatched).
- `BankingProviderTest`: every API-key provider declares credential
fields,
  guarding against adding a provider without an update path again.

Follow-up to #581 (this branch is off the updated `main`).
2026-06-23 09:54:31 +00:00
Víctor Falcón f60e6d7035
feat(banking): add Interactive Brokers sync via Flex Web Service (#581)
## 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.
2026-06-23 11:39:24 +02:00
Víctor Falcón 8e3871370a
fix(transactions): pad Category column when Date column is hidden (#585)
## Problem

On `/transactions`, hiding the Date column left the Category column
flush against the table edge (`pl-0`), despite #582/#583 trying to fix
exactly this.

## Root cause

The padding fix (commit `0424c737`) drove the Category left padding from
`isDateHidden`, but only wired it into `transaction-list.tsx` (used by
Accounts and budgets). The `/transactions` page builds its **own** table
and called `createTransactionColumns` without `isDateHidden`, so it
always defaulted to `false` → `pl-0`.

## Fix

Pass `isDateHidden: columnVisibility.transaction_date === false` (and
add `columnVisibility` to the `useMemo` deps) on the `/transactions`
page, mirroring `transaction-list.tsx`.

## Tests

`transaction-columns.test.tsx` already covers the `pl-0`/`pl-2` branch
driven by `isDateHidden`; the bug was missing wiring, not logic.
Verified manually on `/transactions` with the Date column hidden.
2026-06-22 17:21:15 +00:00
Víctor Falcón d2806b5887
fix(transactions): pad Category column when Date column is hidden (#584)
Closes #583

When the Date column is toggled off, the Category column becomes the
leftmost data column. It carried a static `pl-0` (to sit tight after
Date), so it ended up flush against the table edge with no left padding
— visible at `< md` widths where the `select` checkbox column is hidden.

#582 tried a CSS `first:pl-2`, but the `select` cell (`hidden
md:table-cell`) is `display:none` yet still the DOM `:first-child`, so
`first:` never matches Category. This drives the padding from the Date
column's visibility instead: keep `pl-0` while Date is shown, restore
`pl-2` when it is hidden (replacing the inert `first:pl-2`).

## Test
`createTransactionColumns` unit test asserting the Category
`cellClassName` flips `pl-0` ↔ `pl-2` with `isDateHidden`.
2026-06-22 16:51:50 +00:00
Víctor Falcón ae6f869611
feat(transactions): release transaction analysis to all users (#579)
## Summary

Removes the `TransactionAnalysis` Pennant feature flag and all its
gating. After this PR the transaction analysis feature (analysis drawer
+ saved filters) is available to every user.

## Changes

- Delete `app/Features/TransactionAnalysis.php`.
- `TransactionAnalysisController` — drop the `abort_unless(...403)` flag
gate and Pennant imports.
- `HandleInertiaRequests` — stop sharing the `transactionAnalysis` flag
in Inertia props.
- `Features` TS type — remove the `transactionAnalysis` field.
- `transaction-actions-menu.tsx` / `transaction-filters.tsx` — remove
the `features.transactionAnalysis &&` gates so the Analysis button, the
analysis drawer, and saved filters always render.
- Tests updated: dropped the endpoint-gating test and the "hidden when
flag off" UI test; adjusted shared-flag expectations.

## Verification

- `./vendor/bin/pest` — 23 passed (affected suites)
- Vitest — affected component/page tests pass
- Pint, Prettier, ESLint clean

## Notes

- The orphaned value left in Pennant's `features` DB table is harmless
and not addressed here.
2026-06-22 16:09:09 +02:00
Víctor Falcón d11aa2dfe5
fix(transactions): restore left padding when category is first column (#582)
## What

The category column in the transactions table drops its left padding
(`pl-0`) so it aligns flush when preceded by other columns. But when the
user hides the columns to its left (select, date), the category becomes
the first column and loses the table's default left padding, sitting
flush against the table edge.

## Fix

Add a `first:pl-2` Tailwind variant to the category cell. When the cell
is `:first-child` (no column to its left), it restores the default
`pl-2` padding; otherwise it keeps `pl-0`. The `:first-child`
pseudo-class gives it higher specificity than `pl-0`, so it wins only in
that case.

Columns hidden via view options are removed from the DOM (TanStack), so
the category is `:first-child` exactly when it genuinely has no column
to its left. Applies to both the header and body cells, since both
consume `meta.cellClassName`.
2026-06-22 14:03:44 +00:00
Víctor Falcón a346566fd0
feat(demo): gate demo account access behind a config flag (#580)
## What

Adds a `DEMO_ENABLED` env var (`config('app.demo.enabled')`, default
`true`) to fully toggle the demo account. Setting `DEMO_ENABLED=false`
in production blocks it without code changes.

When disabled:
- **Login is blocked** — `Fortify::authenticateUsing` rejects the demo
account with a generic credentials error (doesn't reveal the demo is
off). Regular users and 2FA are unaffected.
- **Landing link hidden** — the "Check Demo" button on the landing page
is removed (shared `demoEnabled` prop).
- **No credential prefill** — `demoCredentials` is only shared when
enabled, so `/login?demo=1` no longer autofills.

## Why

The demo account is publicly shared and gets abused (e.g. duplicate
votes on integration requests). This gives us a kill switch.

## Tests

Added to `DemoAccountRestrictionsTest`:
- demo account cannot log in when disabled
- demo account can log in when enabled
- regular user can still log in when demo is disabled

Existing auth + 2FA tests still pass.
2026-06-22 11:01:27 +00:00
Víctor Falcón 5db6cdc6d2
fix(accounts): stop second long-press haptic on drag handle (#578)
## What

Reordering accounts (dashboard + accounts page) buzzed **twice** when
you tap-and-hold the drag handle on mobile: once immediately, then a
second time after a short delay.

The immediate buzz is ours — `trigger('selection')` on the handle's
`pointerdown` (added in #576). The delayed second buzz is **Android
Chrome's native long-press haptic** (~500ms): pressing and holding any
interactive element fires the system long-press/context-menu feedback.

It can't be the drag itself — the `PointerSensor` activates by
**distance** (8px), not time, so a delayed buzz on a *stationary* hold
can only come from the platform's long-press gesture. `touch-action:
none` stops scrolling/panning but not the long-press menu.

## Changes

- `SortableGrid`: add `select-none` and a prevented `onContextMenu` to
the drag handle, opting it out of the native long-press gesture (and its
haptic).

## Testing

- Added `sortable-grid.test.tsx`: asserts the selection haptic fires
exactly once on `pointerdown`, and that the handle prevents the
`contextmenu` default.
- The native long-press haptic itself can't run in jsdom — needs a
manual check on a real Android device: tap-and-hold the handle should
vibrate once (on touch), with no second buzz.
2026-06-22 11:20:57 +02:00
Víctor Falcón b0e74fac2c
feat(welcome): add Haru testimonial with Discord avatar (#577)
## Summary
- Update the existing **Haru** testimonial on the landing page with
their Discord feedback
- Use Haru's Discord avatar via a new optional `avatar` field on
testimonials (falls back to Gravatar when absent)
- Update the matching `es.json` translation

## Notes
The Discord CDN avatar degrades gracefully to the `Facehash` fallback if
the URL ever expires (Discord rotates the hash when a user changes their
avatar).
2026-06-22 09:10:43 +00:00
Víctor Falcón c75e834b89
fix(accounts): fire drag haptic on first tap, not after dragging (#576)
## What

On mobile, reordering accounts (dashboard + accounts page) triggered the
selection haptic only **after** the drag actually started — i.e. after
the `PointerSensor`'s 8px activation distance was met. This felt like
you had to long-press/drag before the vibration kicked in.

This moves the haptic to the drag handle's `pointerdown`, so it fires
the moment the handle is touched. dnd-kit's own `onPointerDown` is
chained afterwards so dragging keeps working.

## Changes

- `SortableGrid`: drop `onDragStart` haptic on `DndContext`.
- `SortableItem`: accept an `onActivate` callback and fire it on the
handle's `onPointerDown`, then delegate to dnd-kit's listener.

## Testing

Haptics rely on `web-haptics` (`navigator.vibrate`), which doesn't run
in jsdom, so this needs a manual check on a real mobile device: tapping
the drag handle should vibrate immediately.
2026-06-22 07:09:46 +00:00
Víctor Falcón cd3080ec52
feat(accounts): reorder accounts with drag-and-drop (#575)
## 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.
2026-06-21 11:17:45 +02:00
Víctor Falcón 0ea30a3ce8
ci: remove production deploy job (#574)
Removes the `deploy` job from CI. Deployment is no longer triggered from
GitHub Actions.

The job handled the Coolify webhook trigger and Sentry release tracking
on pushes to `main`. Build/test/lint/static-analysis and the image build
jobs are untouched.
2026-06-20 18:17:14 +00:00
Víctor Falcón 57747ef34c
ci: simplify Coolify deploy trigger to a single curl (#573)
## What

Replace the ~60-line bash retry loop in the deploy step with native curl
flags.

## Why

The old block reimplemented in shell what curl does natively (timeouts,
retries, error handling), plus manual body/timing parsing with a marker
separator.

- `--max-time 15` caps each attempt so the job can't hang for ~20 min on
an unreachable box.
- `--retry-all-errors` turns a timed-out attempt into the next retry (a
plain `--retry` doesn't always treat exit 28 as retryable).
- `--fail` keeps a webhook error (4xx/5xx) from passing the step
silently.

## Notes

- Now uses the `COOLIFY_WEBHOOK_URL` secret instead of
`DEPLOYMENT_TOKEN` + hardcoded uuid URL. **This secret must exist in
GitHub Actions before merge or the deploy breaks.**
- Drops the 10–20 min retry waits that gave a prior rebuild time to
finish. Fine if the webhook queues the deploy; add back if it rejects
requests during an in-progress rebuild.
2026-06-20 17:40:09 +00:00
Víctor Falcón 0f3cdd41aa
feat(open-banking): enable manage bank accounts for everyone (#572)
## Summary

Removes the `ManageBankAccounts` Pennant feature flag that gated the
per-connection "Manage Accounts" surface to the admin user, making the
flow available to all users.

## Changes

- Delete the `App\Features\ManageBankAccounts` feature class.
- `ConnectionAccountController`: drop the `ensureFeatureEnabled()` gate
(3 call sites + method) and the `Feature`/`ManageBankAccounts` imports.
Access stays guarded by `authorizeConnection()` (connection ownership).
- `HandleInertiaRequests`: stop sharing the `manageBankAccounts` Inertia
flag.
- Frontend: remove the flag from the `Features` type, the
`features.manageBankAccounts` conditional in `connections.tsx` (now
gated solely by `canManageAccounts`), and the now-unused `features`
destructure.
- Tests: update `InertiaSharedDataTest` and the `connections.test.tsx`
mock; simplify the `adminUser()` helper to `onboardedUser()` in
`ConnectionAccountTest` and remove the "forbidden when feature disabled"
test.

## Testing

- `vendor/bin/pint --dirty` — pass
- `php artisan test tests/Feature/OpenBanking/ConnectionAccountTest.php
tests/Feature/InertiaSharedDataTest.php` — 17 passed
- `bunx vitest run resources/js/pages/settings/connections.test.tsx` —
pass
- `bun run lint` / `bun run format` — clean
2026-06-20 17:35:43 +00:00
Víctor Falcón 83a5e9657e
fix(ui): make input borders visible in dark mode (#571)
## What

Bump the dark-mode `--input` color from `oklch(0.269)` to `oklch(0.41)`.

## Why

In dark mode the `--input` value sat almost on top of the background
(`oklch(0.225)`), so the shared `border-input` was nearly invisible on
every form control. Checkboxes were especially hard to spot.

Since inputs, checkboxes, radios, selects and textareas all share
`border-input`, this single variable raises their border contrast at
once (Δ luminosity vs background goes from 0.044 → 0.185) while staying
below the focus ring (`0.439`), so focus still stands out and every
control keeps a consistent design.

As a nice side effect, the subtle `bg-input/30` and `bg-input/50` fills
(radio, outline button) also read a touch more clearly.
2026-06-20 17:30:19 +00:00
Víctor Falcón 64827fabae
feat(open-banking): allow re-connecting a bank behind a replace warning (#570)
## Why

Users with several accounts at the same bank (for example a partner's
account at the same bank) need to connect the same Enable Banking ASPSP
more than once. Previously an already-connected bank was hard-blocked:
disabled in the connect dialog list, and filtered out entirely from the
onboarding inline flow with a "you already have this connected, go
reconnect" message.

## What changed

- The already-connected bank stays **selectable** and is marked with an
**"Already connected"** badge instead of being blocked/hidden.
- On the confirm step, when the selected bank already has a **live**
connection, a destructive warning explains that authorizing with the
**same bank login replaces the existing session and stops it working** —
continue only when adding a *different* account.
- The **Connect** button is gated behind an acknowledgement checkbox ("I
understand the existing connection may stop working") so a working
connection can't be replaced by accident.
- New shared `ReplaceConnectionWarning` component keeps the dialog and
inline flows from drifting apart (the same divergence #569 unified).

## Notes

- **No backend changes.** `AuthorizationController@store` already
created a fresh connection for a repeated bank; the block was
frontend-only.
- Out of scope: the previous `BankingConnection` row stays `active` in
our DB after the user re-authorizes the same login, even though Enable
Banking has invalidated it. It self-corrects on the next sync failure.
Auto-marking/cleaning the superseded connection on callback is a
separate backend follow-up.

## Testing

- New `connect-account-dialog.test.tsx`: already-connected bank is
selectable + badged, Connect is gated behind the acknowledgement, and a
fresh bank shows no warning.
- Full JS suite (234 tests) and `LocalizationTest` pass; lint and format
clean.
2026-06-20 17:23:49 +00:00
Víctor Falcón 7fb190683c
refactor(encryption): strip client-side transaction encryption (#514)
## 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.
2026-06-20 16:13:26 +00:00
Víctor Falcón 14c4598cda
fix(open-banking): only block re-adding a bank when a live connection exists (#569)
## What

When creating a new connection / connected account, the bank list
filtered out any bank the user already had a connection to. The check
was too broad:

- It blocked a bank whenever a **non-pending** connection existed, so
**expired, errored and revoked-but-not-deleted** connections kept
blocking re-connection.
- The inline variant was even broader — it blocked on **stale pending**
attempts too.

## Fix

A bank is now treated as "already connected" only when a connection is
genuinely **live**, applying three checks:

- **Right provider** — only `enablebanking` connections gate
EnableBanking banks; the exact provider gates
Binance/Bitpanda/Coinbase/Indexa Capital.
- **Active** — only `active` or `awaiting_mapping` statuses block.
`expired`, `error`, `revoked` and `pending` no longer block, so the user
can start a fresh connection.
- **Not deleted** — soft-deleted connections never reach the frontend
(no query uses `withTrashed`), so a deleted connection never blocks.

The two divergent filter implementations (`connect-account-dialog.tsx`
and `connect-account-inline.tsx`) are unified into a single helper in
`resources/js/lib/banking-connections.ts`.

### Covered scenarios

- Manual BBVA account → not a `BankingConnection`, never blocked → can
connect BBVA. ✓
- Deleted BBVA connection → soft-deleted, absent from the frontend → can
connect Enable Banking + BBVA. ✓

## Tests

- New `resources/js/lib/banking-connections.test.ts` (replaces the old
`connect-account-dialog.test.tsx`).

## Note

`awaiting_mapping` is treated as live (bank just authorized, accounts
not yet mapped) to avoid duplicate connections. If only `active` should
block, drop it from `LIVE_STATUSES`.
2026-06-20 13:09:04 +02:00
Víctor Falcón 52708f940d
fix(sentry): drop browser-extension noise before sending events (#568)
## What

Adds an `isBrowserExtensionNoise` predicate to the frontend Sentry
`beforeSend` chain that drops errors originating from injected
browser-extension scripts.

## Why

Triaging Sentry surfaced recurring, unactionable issues caused entirely
by users' browser extensions, not our code:

- **PHP-LARAVEL-21** — `i: Failed to connect to MetaMask`
(`chrome-extension://…/inpage.js`)
- **PHP-LARAVEL-3M** — `Cannot read properties of undefined (reading
'sendMessage')` (`chrome-extension://…/injectedScript.bundle.js`)

These recur for every user who has the offending extension installed, so
resolving-and-waiting just re-pages us forever. This follows the
existing pattern of named noise predicates with vitest coverage
(`isChunkLoadErrorEvent`, `isSafariCashbackExtensionNoise`, …).

## How

The predicate inspects the **crashing (innermost) frame** of each
exception and drops the event only when that frame's URL uses a
browser-extension scheme (`chrome-extension://`, `moz-extension://`,
`safari-web-extension://`, `safari-extension://`,
`ms-browser-extension://`).

Matching only the crashing frame — not *any* frame — avoids
over-filtering legitimate app errors that merely pass through an
extension-wrapped handler.

### Not filtered on purpose

`AxiosError: Network Error` (PHP-LARAVEL-28 / -38) is left untouched: it
has no extension frames and a full backend outage produces the same
error, so we want it to keep alerting.

## Tests

`resources/js/lib/sentry.test.ts` — 4 new cases (drops the two real
extension shapes; keeps the network error and an app error where the
extension frame is not the crash site). `vitest run` green (12/12).
2026-06-20 12:47:05 +02:00
Nenad Vajagic 934e16c0fa
feat(currency): add RSD (Serbian Dinar) (#567)
## What
Add Serbian Dinar (RSD) as a supported currency.

## Compatibility
Conversion provider `@fawazahmed0/currency-api` covers RSD (standard ISO
4217). Conversion service lowercases codes and fetches `rsd.min.json` -
same path NZD uses. `exchange_rates` table stores rates as JSON, so any
3-letter code works. Validation rules and Inertia currency props
auto-derive from config via `CurrencyOptions`.

## Changes
- `config/currencies.php` - RSD entry (`allows_primary` +
`allows_account`)
- `lang/es.json` - Spanish translation
- `lang/fr.json` - French translation

## Tests
Haven't tested them locally, but should pass
2026-06-20 12:46:26 +02:00
Víctor Falcón b76a0de074
feat(transactions): default balance toggle on and apply it server-side (#566)
## What

In the add-transaction modal, the **Update account balance** checkbox
now defaults to **on**. An explicit opt-out is still remembered in
`localStorage`, so users who turned it off keep their preference.

Balance updates also move from the client to the backend, mirroring the
existing delete flow
(`ManualBalanceAdjuster::reverseDeletedTransaction`).

## How the balance is applied

When the toggle is on, `TransactionController::store` calls the new
`ManualBalanceAdjuster::applyCreatedTransaction`, which adjusts the
balance on the **transaction's own date**:

- A balance already on that date → increased by the transaction amount.
- No balance on that date but an earlier one exists → a new balance for
that date is created from the **closest earlier balance** plus the
amount.
- No prior balances at all (first transaction on the account) → the
balance is set to the transaction amount.

Connected accounts are skipped, since their balances come from bank
sync.

## Why

The previous client-side flow fetched all balances, computed from the
**latest balance overall** (not the transaction's date), and stored via
a separate request. This replaces those three round-trips with one
atomic server-side write and fixes the wrong base balance.

## Tests

- Feature tests for all three balance cases plus the not-requested and
connected-account guards (`tests/Feature/TransactionTest.php`).
- Frontend test asserting the toggle is checked by default in create
mode.

Full suite green locally: Pest (1690 passed), vitest (221 passed), Pint,
ESLint, Prettier.
2026-06-19 15:01:04 +00:00
Víctor Falcón 50dba4334a
style: pulse the AI categorize sparkle icon (#565)
## What

Replace the static `opacity-50` on the AI categorize sparkle icon with
`animate-pulse`, so the affordance gently pulses to draw attention.
Hover still fades to full opacity.

## Why

Makes the "let AI categorize your transactions" entry point more
discoverable in the transactions table.

## Testing

Visual-only Tailwind class change; no logic affected.
2026-06-19 14:29:54 +00:00
Víctor Falcón 29d13ceed1
feat(paywall): require a plan when the user has accepted AI (#564)
## What

Forces users who have accepted AI consent to choose a plan, the same way
connecting a bank already does.

## Why

AI suggestions are a paid-plan feature (`PlanFeature::AiSuggestions`),
and the onboarding notice tells the user *"AI suggestions are a Standard
Plan feature. You'll choose a plan at the end of the onboarding."* But
that was never enforced: `EnsureUserIsSubscribed` only gated on bank
connections, never on AI consent. A user who accepted AI without
connecting a bank saw the paywall once, got `paywall_seen_at` marked,
and then fell through to **free** access — making the notice a false
promise.

## Change

- `EnsureUserIsSubscribed`: a non-Pro user with an **active AI consent**
is now kept on the paywall on every request, exactly like a
bank-connected user (added `&& ! $user->hasActiveAiConsent()` to the
free-access branch).
- `SubscriptionController::index`: `canUseFreePlan` is now `false` when
the user has an active AI consent, so the paywall stops offering the
free option to these users (keeps page and middleware consistent).
- Revoking AI consent (`hasActiveAiConsent()` → false) restores
free-plan access — a clean escape hatch.

Onboarding itself is unaffected: the onboarding / AI-consent /
rule-suggestion routes live in the `auth,verified` group **without** the
`subscribed` middleware, so consenting mid-onboarding does not lock the
user out of finishing. The paywall only kicks in afterward, when
accessing the app — which is the intended behavior and matches the
notice.

## Tests

4 new tests in `SubscriptionTest` (forced to paywall even after seeing
it; `canUseFreePlan` false; subscribed users with consent still get
access; revoking consent restores free access). Full `SubscriptionTest`
green (38 passed). `pint` clean.

## ⚠️ Behavior change for existing users

This applies retroactively. From prod, **34 users currently have an
active AI consent**; any of them on the free plan today (no bank,
`paywall_seen_at` set) will be redirected to the paywall after deploy
and must subscribe or revoke consent. If we want to grandfather existing
consenters and only enforce this going forward, that needs an extra
condition (e.g. consent recorded after a cutoff date) — flag me and I'll
add it.
2026-06-19 14:18:49 +00:00
Víctor Falcón 57f8c93e28
feat(stats): weekly paywall stuck-cohort report to Discord (#563)
## 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.
2026-06-19 14:12:51 +00:00
Víctor Falcón ce6bfc9c56
feat(drip): email users stuck on the paywall a day after onboarding (#562)
## 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.
2026-06-19 14:11:12 +00:00
Víctor Falcón ae59c90f2c
AI auto-categorization: open to pro + consent, nudge free users (#561)
## What

Two related changes to the AI auto-categorization feature.

### 1. Open the gate to pro + consent (drop the new-signups-only cohort)

Eligibility for AI auto-categorization was: kill switch **+ pro plan +
active AI consent + a Pennant rollout flag** that only resolved for
users created after `ai_categorization.rollout_after`. That last cohort
gate limited the feature to a handful of recent signups (6 eligible
users in prod).

The gate is now just **kill switch + pro plan + active AI consent**, so
every consented pro user is eligible regardless of signup date.

- `allows()` and `allowsBackfill()` became identical and collapse into a
single `allows()`; `CategorizeBackfillCommand` calls it.
- The `AiCategorization` Pennant feature and the
`ai_categorization.rollout_after` config are now dead and removed. The
weekly cohort report already derives its release marker from the first
`ai_consents.accepted_at`, so nothing depends on the config.

> Note: the `AI_CATEGORIZATION_ROLLOUT_AFTER` env var must be removed
from the production environment — it is no longer read.

### 2. Nudge free users that AI could categorize their transactions

Free-plan users now see a subtle AI sparkle on uncategorized rows,
reusing the trailing icon slot already in `CategoryCell` (no layout
change). It only shows when subscriptions are enforced and the user is
not pro; clicking it routes to `/settings/billing`.

To keep it subtle, the sparkle is sampled to a share of rows via a
deterministic function of the transaction id (its last byte mapped onto
a 0-100 threshold), so the same rows decide the same way across reloads
instead of flickering or marking every row.

The share is **configurable** via `ai_categorization.upsell_sample_rate`
(env `AI_CATEGORIZATION_UPSELL_SAMPLE_RATE`, default 40), exposed to the
frontend as the `aiCategorizationUpsellRate` Inertia prop — no rebuild
needed to retune it.

## Tests

- PHP: `AiCategorizationGateTest` updated (rollout case dropped);
job/listener tests no longer activate the removed feature.
- JS: `ai-upsell-sample.test.ts` covers determinism, the 0/100 bounds,
the threshold boundary, and the per-rate split.
- Manually verified in-app (Playwright): nudge renders for a free,
consented, bankless user; rate matches config.

## Follow-ups (not in this PR)

- Remove the `AI_CATEGORIZATION_ROLLOUT_AFTER` prod env var.
- Backfilling existing pro+consent users' history still requires running
`ai:categorize-backfill {user}`.
- The nudge only reaches free users who pass the paywall (no bank
connection + paywall already seen); free users with a bank connection
never reach `/transactions`. Consider surfacing it on the paywall too.
2026-06-19 14:08:40 +00:00
Víctor Falcón 6cb8d11563
feat(connections): create a new account from the manage-accounts selector (#560)
## What

In the per-connection **Manage accounts** screen, the discovered-account
card now uses a single selector to choose where a newly detected bank
account syncs. The selector lists **"Create new account"** first,
followed by the compatible existing manual accounts — replacing the
previous separate "Create account" / "Link to existing" buttons.

The **"Add accounts"** header now stacks vertically on mobile
(title/description, then a full-width "Load accounts" button) and stays
a row on `sm+`.

## Why

Consolidating *create* and *link* into the same control matches how
users think about the action ("where should this account go?") and
removes the two side-by-side buttons that overflowed horizontally on
small screens.

## Notes

- **Backend unchanged**: `ConnectionAccountController@map` already
supported both `action: create` and `action: link`. This only re-routes
the same calls through the selector (`existingAccountId === null` →
create). Covered by existing tests in
`tests/Feature/OpenBanking/ConnectionAccountTest.php` (9 passing).
- **No new i18n keys**: reuses the existing `Create new account` and
`Select an account` translations.

## Screens

The "Manage accounts" link lives in the per-connection "…" menu and is
gated behind the `ManageBankAccounts` feature flag (admin-only during
rollout).
2026-06-19 12:49:03 +02:00
Víctor Falcón 46568700b2
fix(banking): skip inaccessible EnableBanking accounts instead of failing the connection (#559)
## Problem

Sentry issue
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
started escalating again — but with a **different** error than the 401
expired-session fixed in #557. Sentry groups both under the same
`getTransactions` culprit:

```
RequestException: HTTP request returned status code 400:
{"code":400,"message":"Account not found","detail":{"message":"Account not found","error_name":"AccountNotAccessibleException"}}
```

When EnableBanking returns `400 AccountNotAccessibleException` for
**one** account (closed, or no longer covered by the consent), the raw
`RequestException` was re-thrown. This:

1. **Aborted the whole connection's account loop** in
`EnableBankingSyncer` — accounts after the dead one never synced.
2. Marked the **entire connection** `Error` (`friendlyErrorMessage`
default), even though its other accounts were fine.
3. **Reported to Sentry** on every scheduled retry (escalating noise).

Confirmed in production (`agent:db --prod`): connection `019ea143-…` is
`active` with `valid_until` in the future (2026-09-05) and **6
accounts**; one (`08bbcdf9-…`) returns the 400, and the connection is
now stuck in `error` with the generic "try again later" message. 2 users
affected.

## Fix

A single account the bank no longer exposes must not break the rest of
the connection.

- New domain exception `InaccessibleBankAccountException` (implements
`ShouldntReport`, like `TransientBankingProviderException` /
`ExpiredBankingSessionException`).
- `EnableBankingProvider::getTransactions()` / `getBalances()` wrap a
`400 AccountNotAccessibleException` in it (keyed on `detail.error_name`)
instead of re-throwing the raw `RequestException`.
- `EnableBankingSyncer` catches it **per account**, logs a warning, and
`continue`s — the remaining accounts sync and the connection stays
`Active`.

Connections currently stuck in `error` from this self-heal on the next
scheduled sync (they're `Error` + under the retry cap + `valid_until`
future, so the scheduler re-dispatches them; the dead account is now
skipped).

Composes with #558: a user can permanently *stop syncing* such an
account from the manage-accounts screen. This PR only stops one dead
account from breaking automated syncs in the meantime.

## Tests

- `EnableBankingProviderTest`: `getTransactions` / `getBalances` wrap a
`400 AccountNotAccessibleException` as a non-reportable
`InaccessibleBankAccountException`.
- `SyncRetryAndLoggingTest`: with one inaccessible and one good account,
the good one still syncs, the connection stays `Active`, and nothing is
thrown.

All three failed before the fix. Full `tests/Feature/OpenBanking` suite
passes (276), pint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-19 08:59:05 +02:00