Commit Graph

4 Commits

Author SHA1 Message Date
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
Víctor Falcón c36df98d32
fix(banking): handle EnableBanking expired sessions as reconnect, not error (#557)
## Problem

Sentry issue
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
— `RequestException: HTTP 401 {"error":"EXPIRED_SESSION"}` in
`EnableBankingProvider::getTransactions` — was escalating (26 events, 2
users).

When an EnableBanking **session** expires *before* its 90-day consent
window (`valid_until`), the sync hit a `401 EXPIRED_SESSION` that was
not classified as transient or ASPSP, so it was re-thrown as a raw
`RequestException`. Consequences:

1. **Sentry noise** — reported as an error on every scheduled retry
until the failure cap.
2. **Silent breakage for the user** — EnableBanking's syncer returns
`notifiesOnAuthFailure() === false`, so the 401 went through the generic
permanent-error path: connection set to `Error` (not `Expired`) with
**no reconnect email**.

Confirmed in production: **10 of 17** EnableBanking connections in
`error` status are actually expired sessions (`valid_until` in the
future + "Authentication failed" message), all with no notification
sent.

## Fix

Treat a `401 EXPIRED_SESSION` as the expected lifecycle event it is:

- New domain exception `ExpiredBankingSessionException` (implements
`ShouldntReport`, like `TransientBankingProviderException`).
- `EnableBankingProvider::getTransactions()` / `getBalances()` wrap the
`401 EXPIRED_SESSION` in it instead of re-throwing the raw
`RequestException`.
- `SyncBankingConnectionJob` catches it and routes through the existing
expiry handling (extracted to `markExpired()`): marks the connection
`Expired`, sends `BankingConnectionExpiredEmail`, logs a skipped
attempt, returns **without throwing**.
- The provider's HTTP error logger downgrades the expected expiry from
`error` → `warning`.

`Expired` connections are not re-dispatched by
`SyncAllBankingConnectionsJob`, so retries stop and the user is prompted
to reconnect.

## Tests

- `EnableBankingProviderTest`: `getTransactions` / `getBalances` wrap a
`401 EXPIRED_SESSION` as a non-reportable
`ExpiredBankingSessionException`.
- `SyncRetryAndLoggingTest`: an expired session marks the connection
`Expired`, queues the reconnect email, and does not throw.

All three failed before the fix. Full `tests/Feature/OpenBanking` suite
passes (264 tests).

## Follow-up (not in this PR)

The 10 connections already stuck in `Error` from this bug are past the
retry cap and won't self-heal. A one-off command to reclassify them to
`Expired` and send the reconnect email would recover those users — worth
a separate, reviewed change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-18 13:48:13 +00:00
Víctor Falcón 64b78e3680
fix(banking): handle balance-fetch timeouts and silence handled retries (#450)
Fixes two linked production banking-sync issues.

## PHP-LARAVEL-W — `cURL error 28: timed out` in
`EnableBankingProvider::getBalances` (8 events, 4 users, High)

`getBalances` called `$response->throw()` raw, so a connection timeout
(or ASPSP error) escaped as an **unhandled**
`ConnectionException`/`RequestException` and crashed the sync.
`getTransactions` already wraps these in
`TransientBankingProviderException` (which `implements ShouldntReport`
and is handled as a transient, retryable error in
`SyncBankingConnectionJob`).

→ `getBalances` now follows the exact same pattern. Genuine validation
errors (non-ASPSP 4xx) stay reportable.

## PHP-LARAVEL-2D — `SyncBankingConnectionJob has been attempted too
many times` (High, regressed)

The hanging balance call above pushed the job past its 120s `timeout`,
the worker was killed mid-job, and the retry tripped a
`MaxAttemptsExceededException`. That exception is thrown by the queue
worker (not catchable in `handle()`), and the job's `failed()` handler
**already** records the terminal `Error` state on the connection — so
the Sentry report is redundant operational noise.

→ Fixing W removes the main cause of the timeout. Additionally,
`MaxAttemptsExceededException` is no longer reported **for this job
only** (scoped via `dontReportWhen` on `$e->job?->resolveName()`); other
jobs still report it.

## Tests
- `getBalances` wraps connection failures and ASPSP errors as
non-reportable transient errors; keeps non-ASPSP client errors
reportable.
- `MaxAttemptsExceededException` is not reported for
`SyncBankingConnectionJob`, but still reported for other jobs.

Fixes PHP-LARAVEL-W, PHP-LARAVEL-2D.
2026-05-29 14:58:38 +02:00
Víctor Falcón 917a9a655f
Handle transient EnableBanking sync failures (#358)
## Problem

Sentry showed two related production issues in the same sync path:

- `PHP-LARAVEL-13`: EnableBanking returned HTTP `400` from `GET
/accounts/{accountId}/transactions` with body `ASPSP_ERROR` / `Error
interacting with ASPSP`. This is an upstream bank/provider failure, but
the app threw an unhandled `RequestException`.
- `PHP-LARAVEL-14`: the same transactions endpoint timed out after 20s,
throwing an unhandled `ConnectionException`.

Both originated from `EnableBankingProvider::getTransactions()` and
bubbled through `SyncBankingConnectionJob`, creating Sentry issues for
expected transient provider/bank outages.

## New behavior

- Wrap EnableBanking `400 ASPSP_ERROR` responses in
`TransientBankingProviderException`.
- Wrap EnableBanking transaction connection failures / timeouts in the
same transient exception.
- Mark that exception as `ShouldntReport`, so these expected upstream
failures stop creating Sentry issues.
- Keep queue retry behavior intact. The job still retries and only marks
the connection as `Error` after normal retry exhaustion.
- Log transient sync failures as warnings instead of errors.
- Show users a retry-later message when retries are exhausted: the bank
provider is temporarily unavailable.
- Leave other `400` responses reportable. Validation / app-side request
bugs still throw `RequestException`.
- Leave auth failures and rate-limit handling unchanged.

Fixes PHP-LARAVEL-13
Fixes PHP-LARAVEL-14

## Testing

- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact
tests/Feature/OpenBanking/EnableBankingProviderTest.php
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php`
2026-05-06 09:24:05 +02:00