Commit Graph

3 Commits

Author SHA1 Message Date
Víctor Falcón 2c7fe5d64a
fix(wise): send the pagination cursor under the name Wise reads (#788)
> Sentry's MCP token is still expired, so this came from the production
DB and `failed_jobs` again — the follow-up I flagged in #782.

## The bug

A user connected Wise on 2026-07-29 and **has never received a completed
sync in 14 days**. Their wallet holds 110 transactions but **zero rows
in `account_balances`**, so it contributes nothing to their net worth,
and the connection shows a red Error badge with no notification ever
sent.

## Root cause: one word

`WiseClient::getActivities` sent the pagination cursor as `cursor`. Wise
*returns* it as `cursor` but only *reads* it as `nextCursor` — the docs
say it outright ("Pass this value as the `nextCursor` query parameter").
We sent the wrong name, Wise ignored it, and **every request returned
page one again**. The walk could never terminate.

The production data says the same thing without the docs:

| created_at | rows | transaction_date range |
|---|---|---|
| 2026-07-29 06:44:49 | 87 | 2025-12-23 → 2026-07-20 |
| 2026-07-29 06:44:50 | 10 | 2025-12-19 → 2025-12-23 |
| every run since | 1/day | that day only |

97 rows in two consecutive seconds — one `size=100` page after
`CARD_CHECK`/non-EUR filtering — and in ~50 runs over 14 days **never a
row older than 2025-12-19**. Page two has never been fetched.

The timeouts were a symptom, not the cause: the loop hammered one
endpoint for 120s straight, four times a day, until Wise started
answering with cURL-28s and a 500. 65 of the 66
`TimeoutExceededException` job failures in `failed_jobs` over 14 days
are this one connection, which also burns three 120s attempts plus three
worker kills per cycle on the single `default` worker, delaying everyone
else's jobs.

**I had this wrong.** My first pass diagnosed "a year of history is too
much to paginate" and capped the walk at 60s. That would have converted
an infinite loop into a permanent 100-activity ceiling on every Wise
account — masking the bug while looking like a fix. The product review
caught it; I verified it against both the Wise docs and the import
timestamps before rewriting.

## The commits

1. **`nextCursor`.** The root cause. The test keys its fake off
`nextCursor`, so the old name looks like what it was — a one-page
history that never ends. With the wrong name the test does not terminate
(verified under an alarm); with the right one it pages twice and stops.
2. **A time budget, as a safety net rather than the fix.** With
pagination working a normal wallet finishes in two requests, but a long
history or a slow provider would still get the job killed, and
`last_synced_at` is only written on success — which is exactly the
never-converges state. The deadline is the *caller's*, passed in: Wise
creates one account per currency per profile, so a budget per wallet
multiplies straight past the job's 120s (a three-wallet connection
reproduced the original bug verbatim — there is a test). Null lets
`banking:sync --sync`, which runs in-process without the worker timeout,
walk as far as it likes. `WiseClient` now owns 15s/5s timeouts instead
of inheriting the framework's 30s, so "budget plus one in-flight
request" is a bound this code can actually state. Matches the two
sibling clients.
3. **Balance first, and not load-bearing.** The balance ran after the
walk, which never returned — hence zero balance rows. Ordering it first
is only half the fix: `getBorderlessAccount` throws on a 5xx and nothing
caught it, so done naively it just swaps which half the user loses. It
is wrapped, counted into the returned metadata like
`EnableBankingSyncer` does, and skipped wallets are reported too.

## Verification

`tests/Feature/OpenBanking`: 354 tests, 344 pass, and the **same 10
failures as clean main** (Inertia page-render tests hitting the SSR
`/render` endpoint with no local server — baseline confirmed). 4 new
tests, each verified to fail with only its own change reverted:
per-wallet budget → the multi-wallet test; no try/catch → the balance
test; wrong cursor name → non-termination. `pint`, `crap` (0 methods
over 10 — `importPage` is extracted because the deadline pushed `sync`
to 11) and `dry` all green.

## Not done, deliberately

- **Wise has no historical-balance backfill**, unlike
Coinbase/IBKR/EnableBanking — `WiseBalanceSyncService` only ever writes
*today's* balance, and `BalanceLookup::getBalanceAt` returns 0 with no
earlier row. So this wallet will read €0 across the whole 12-month
sparkline and step to its real value the day this ships, next to 110
transactions going back to December. Pre-existing and true of any new
Wise connection, but this fix is what makes it visible. Its own PR.
- **N wallets on one profile each walk the identical list** — the
activities endpoint is per profile, and `parseActivity` filters by
currency afterwards. Fetch once per profile and fan out; real cost and
rate-limit win, bigger change.
- **Backfilling older history.** The next sync starts from the
connection-level `last_synced_at`, so pages left behind on a budget stop
are not revisited. `EnableBankingSyncer::resolveDateFrom` already has
the cheap pattern (derive the window from the imported rows, no schema
change) — for Wise's newest→oldest walk that means setting `until` to
the oldest imported row. Noted as the upgrade path in the code rather
than "persist a cursor", which needs a migration.
- **14 days broken and silent.** No notification exists for a connection
stuck in Error or never-synced, and since #757 correctly stopped
counting transient failures there is no escalation either. Worth an
alert on days-since-last-success; called out as a follow-up in #782 too.

## Auto-merge

Enabled. The root cause is a one-word parameter name confirmed against
the vendor docs and independently against production data; the other two
commits are additive safety with tests that each fail without them. No
migration, no data writes, no schema change, and the affected code path
serves one production connection that is currently completely broken —
the downside of being wrong is bounded by that, and the upside is a user
who gets their account back.
2026-08-12 11:30:37 +00:00
Víctor Falcón 81c32b887b
fix(banking): stop Wise outages from silently parking a bank connection (#757)
Fixes
[PHP-LARAVEL-58](https://whisper-money.sentry.io/issues/PHP-LARAVEL-58).

## What production looks like

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

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

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

## Three commits

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

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

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

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

## Trade-off, stated plainly

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

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

## Tests

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

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

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

- `WiseClient` sets no timeouts; `EnableBankingProvider` uses
`timeout(20)->connectTimeout(5)`, IB uses 5/15. Two of the three prod
failures were 30s timeouts, so bounding them is worth a look — but
shortening the window on a 12-month first sync needs its own thinking.
- `EnableBankingProvider` logs its 5xx at `error` while raising the same
transient exception. Wise is the consistent one here; EB should follow.
- The `ConnectionException` + 5xx catch skeleton now exists three times.
Rule of three is reached — a shared trait would also let
Binance/Coinbase/Bitpanda/IndexaCapital adopt it.
- Both credential-validation call sites catch `\Throwable`, so
connecting Wise during an outage still tells the user "Invalid API
token". Now cheaply fixable by catching the transient type first.
2026-08-10 16:40:09 +02:00
Toni Grunwald 1c5a76a3a4
feat: add Wise open banking integration with balance sync (#525)
## 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>
2026-06-16 13:23:25 +00:00