whisper-money/tests/Feature
Víctor Falcón 6219b6b7af
fix(banking): recognise a spent daily allowance, and stop it swallowing manual syncs (#795)
> Sentry's MCP token is still expired, so this came from the production
DB again.

## Two small fixes, and a much bigger finding I am *not* fixing here

I went after the rate-limit problem I flagged as the next big thing. The
headline number is real — **490 rate-limit failures in 14 days**, and
**14 of 18 Trade Republic connections have never once completed a sync**
— but my framing of it was wrong, and the change I first wrote would
have made those users slightly worse off. What survives is two narrow,
verified fixes. The actual cure is scoped at the bottom.

## 1. Recognise a spent daily allowance from what the banks actually say

`resolveRateLimitBackoffUntil` treated a 429 as a daily-allowance error
only when the message contained the literal word `daily`. Enable
Banking's banks say it several other ways, all of which were getting the
one-hour burst fallback:

| wording | events / connections (45d) |
|---|---|
| `[HUB046] Allowed number of accesses exceeded for consent.` | 234 / 88
|
| `Access exceeded` | 94 / 1 |
| `The access on the account has been exceeding the consented
multiplicity per day.` | 39 / 3 |
| `CLO03941 - Operación no disponible. Has superado el número máximo de
accesos.` (+ Catalan twin) | 5 / 2 |

The old one-hour window gave a free natural experiment: the later
same-day runs *did* happen, so I can check whether they recovered.
Across every wording above, **zero** later same-day attempts succeeded.
`Too many requests` is deliberately left on the short path — 1.7% of its
later same-day attempts do recover, so it is not a clean daily reset.

Matched on prose because there is nothing better: `detail.error_name` is
`RateLimitException` for a spent allowance and a plain burst alike.
Marked with a `ponytail:` note, including that a future attempt at a
structured field has to start by logging the full body, since
`error_message` keeps only the first 120 bytes.

**Honest scope**: for the largest widened cohort (HUB046) this saves
almost nothing, because those 429s land on the 18:00 run and the next
scheduled run is already after midnight. The saving is real for banks
that exhaust their allowance earlier — Eurocaja Rural burns its last two
runs every day, Postepay's wording says "per day" outright.

## 2. Stop the backoff from silently swallowing a manual sync

`ConnectionController::sync` cleared the status, the error message and
the failure counter, then dispatched — but left `rate_limited_until`,
which the job checks first. So "Sync started. Transactions will be
updated shortly." was followed by nothing at all. Fix (1) makes that
materially worse, turning a ≤1h swallow into the whole evening for the
banks that exhaust their allowance at 18:00.

The backoff is there to keep the *scheduler* off a provider that asked
us to stop. A person asking for their own connection is a different
request, and PSD2 budgets access with the user present separately from
unattended access. The other "try again" paths already clear everything
else on the connection; this one row was the omission.

## What I dropped, and why

I first also raised the unexplained-429 fallback from 1h to 7h, so it
would outlast the six-hourly schedule instead of expiring five hours
before it — the mechanism really is inert today, and I have the
receipts: of the ten connections currently holding a
`rate_limited_until`, nine are exactly 60 minutes past the run that set
them, because Enable Banking never sends `Retry-After`.

Both reviews showed it is worthless where it would apply, from different
angles, and they were right:

- Trade Republic is 81% of all rate limits, and its **first** run after
UTC midnight 429s as often as its fourth — 79% vs 73% over 21 days. Six
idle hours already don't help, so no sub-day window can.
- The cohort is bimodal: 8 connections with **zero** successes in 21
days, 3 at ~97%. That is a per-consent condition, not something our
cadence controls.
- Meanwhile those connections import transactions on nearly every run. A
7h window settles into 2 runs/day, so it would only have doubled their
worst-case transaction staleness in exchange for nothing.

## The actual cure, deliberately deferred

Both reviews rank two levers above anything in this PR, and they are
what fix the €0-balance users. **This PR does not claim to.**

**(a) Don't discard a run because the balance call was rate-limited.**
This is the whole user-visible bug. Transactions import fine; the 429
lands on `getBalances`, which `EnableBankingSyncer::syncBalances`
rethrows purely so the job can set the backoff — and that discards the
run, so `last_synced_at` is never set. Consequences: the connections
page shows an **indefinite spinner** ("Syncing transactions and
balances…") and polls the server **every 5 seconds** for as long as it
is open, `bank_transactions_email_cutoff_at` is never set so the daily
email never works, and ~11 users see a €0 balance feeding net worth.
Swallowing the 429 into metadata and applying the backoff on the success
path costs zero extra provider calls and fixes all of it. Its own PR
because threading the backoff out of the syncer needs a design, and last
time I let a partial run report success both reviews were right to stop
me.

**(b) Don't spend a provider access to rewrite a balance row we already
have.** `BalanceSyncService` keys the row on `balance_date`, so four
runs a day overwrite one value: ~1,740 balance calls/day producing ~305
new rows, ~82% redundant. This is the fix for the *non*-Trade-Republic
cohort, and the evidence is clean — **every one of the 86 non-TR
rate-limit events in 14 days happened on a connection that already had a
balance row for that day**, and HUB046 fires on the 4th run of the day
and essentially never on the first three (148 events at 18:00 vs 0 at
00:00 and 06:00). Needs a migration: the guard cannot key on
`balance_date` (some banks report yesterday's `reference_date`, so it
would never fire) nor on `updated_at` (an unchanged `updateOrCreate`
leaves the model clean), so it wants an explicit last-read timestamp.

Also noted: the rate-limit message never reaches the user at all —
`applyRateLimitBackoff` leaves the status Active while the connections
page only renders `error_message` for Error connections — and the copy
still says "wait a few minutes".

## Verification

`tests/Feature/OpenBanking`: 365 tests, 355 pass, the **same 10 failures
as clean main** (Inertia page-render tests hitting the SSR `/render`
endpoint). The daily-allowance test became a 7-case dataset covering
every wording production sends, and it fails on all the new ones against
the old matcher; the manual-sync test fails without its one-line fix.
Net −7 lines of test code, because two tests I had written folded into
the existing one. `pint`, `crap` and `dry` green.

## Auto-merge

Enabled. Both changes are small and evidence-backed: the matcher only
moves wordings that provably never recover within the day onto a window
that already existed and is already tested, and the second is a single
column added to an update that already clears three of its siblings.
2026-08-13 11:25:50 +00:00
..
Ai feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
Api feat(transactions): serve import dedup and account ledger from the backend (#631) 2026-07-03 16:49:59 +02:00
Auth fix(auth): make build deterministic when REGISTRATION_ENABLED=false (#720) 2026-07-22 07:04:20 +00:00
Console feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
Events fix: stop double-dispatching transaction listeners (N+1 insert into jobs) (#620) 2026-07-02 13:26:45 +00:00
Jobs feat(drip): email users stuck on the paywall a day after onboarding (#562) 2026-06-19 14:11:12 +00:00
Listeners feat(budgets): track multiple categories and labels per budget (#466) 2026-06-01 12:32:23 +02:00
Mcp feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
Onboarding fix(onboarding): don't trap users on the syncing step when a bank sync fails (#745) 2026-08-09 18:40:49 +02:00
OpenBanking fix(banking): recognise a spent daily allowance, and stop it swallowing manual syncs (#795) 2026-08-13 11:25:50 +00:00
Services refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
Settings fix(budgets): keep per-transaction email notifications opt-in (#735) 2026-07-25 12:36:13 +02:00
Spaces feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
Sync refactor: Simplify transaction endpoints architecture (#76) 2026-01-25 16:15:17 +01:00
AccountBalanceControllerTest.php fix(balances): allow saving a zero balance (#664) 2026-07-10 15:02:38 +02:00
AccountControllerTest.php feat(accounts): count shared accounts at the owner's percentage (#750) 2026-08-11 13:26:54 +00:00
AccountImportConfigTest.php feat(import): persist per-account import configuration on the backend (#698) 2026-07-18 16:10:40 +02:00
AccountUserCurrencyServiceTest.php fix(open-banking): stop storing the XXX no-currency placeholder on accounts (#602) 2026-06-27 16:01:21 +00:00
AiConsentSettingsTest.php refactor(ai): remove AiConsentSettings feature flag (#619) 2026-07-01 09:47:55 +02:00
AiConsentTest.php feat(ai): dismissable AI consent banner that stops after the first decision (#617) 2026-07-01 07:26:36 +00:00
AlignAccountsEncryptedFlagMigrationTest.php refactor(encryption): strip client-side transaction encryption (#514) 2026-06-20 16:13:26 +00:00
ApplyRealEstateRevaluationTest.php fix(real-estate): compound annual revaluation monthly (#337) 2026-04-27 07:35:51 +01:00
AuthenticatedLayoutSafeAreaTest.php fix(layout): keep bottom padding while floating nav is visible (#537) 2026-06-15 18:23:26 +02:00
AutomationRuleApplicationTest.php fix(budgets): re-derive budget membership when labels are attached without a model event (#787) 2026-08-12 12:48:47 +02:00
AutomationRuleEvaluationTest.php feat(transactions): add counterparty fields (#440) 2026-05-27 16:20:55 +02:00
AutomationRuleTest.php fix(ai): stop a learned rule title from 500-ing the user's category change (#741) 2026-08-09 15:39:14 +00:00
BackfillAccountIbansCommandTest.php chore: upgrade Laravel 12 to 13 (#242) 2026-03-25 12:56:33 +00:00
BackfillXxxAccountCurrenciesTest.php fix(open-banking): stop storing the XXX no-currency placeholder on accounts (#602) 2026-06-27 16:01:21 +00:00
BalanceLookupTest.php feat: investment benefits — show gains/losses on investment accounts (#140) 2026-02-23 13:59:10 +01:00
BudgetHistoricalAssignmentTest.php feat(budgets): track multiple categories and labels per budget (#466) 2026-06-01 12:32:23 +02:00
BudgetNotificationTest.php feat(budgets): enable email notifications by default and coalesce alerts per transaction (#733) 2026-07-24 15:33:14 +02:00
BudgetPeriodDateTest.php Remove budgets feature flag (#108) 2026-02-12 09:58:01 +01:00
BudgetPeriodServiceTest.php fix(budgets): make period generation idempotent (#533) 2026-06-15 12:44:44 +02:00
BudgetTest.php feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
BudgetTransactionServiceTest.php feat: parent/child category tree (#474) 2026-06-03 19:30:12 +02:00
BulkUpdateTransactionsTest.php refactor(transactions): build the bulk selection once in bulkUpdate (#774) 2026-08-11 13:14:19 +00:00
CancelFreeEnableBankingConnectionsCommandTest.php Cancel Enable Banking connections for free users (#289) 2026-04-15 16:23:03 +02:00
CashflowAnalyticsTest.php fix(cashflow): bound trend window to prevent request timeout (#534) 2026-06-15 12:47:27 +02:00
CashflowPageTest.php refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
CatchAllBudgetTest.php fix(budgets): keep labeled expenses out of the catch-all budget (#781) 2026-08-11 15:45:19 +00:00
CategoryMonthlyBreakdownTest.php feat(analysis): per-category 12-month spending drawer (#519) 2026-06-11 09:52:53 +02:00
CrapCommandTest.php ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
CurrencyConversionServiceTest.php fix(currency): make rate fetching resilient to slow CDN (#502) 2026-06-08 09:10:38 +02:00
DashboardAnalyticsTest.php fix(accounts): show credit cards as positive and exclude them from net worth (#673) 2026-07-13 07:27:52 +00:00
DashboardTest.php fix(auth): make build deterministic when REGISTRATION_ENABLED=false (#720) 2026-07-22 07:04:20 +00:00
DecryptTransactionsTest.php refactor(encryption): strip client-side transaction encryption (#514) 2026-06-20 16:13:26 +00:00
DeleteUserCommandTest.php Support soft-deleted users with reusable emails (#316) 2026-04-22 11:41:41 +01:00
DemoAccountRestrictionsTest.php fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
DisconnectBankingConnectionsCommandTest.php feat(banking): add command to disconnect connections by id (#497) 2026-06-06 11:16:01 +02:00
DiscordReportEmbedLimitsTest.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
DiscordWebhookTest.php feat: add Discord admin feed for daily stats and Stripe events (#458) 2026-05-30 18:14:46 +02:00
EncryptionTest.php refactor(encryption): strip client-side transaction encryption (#514) 2026-06-20 16:13:26 +00:00
ExampleTest.php Install Pest 2025-11-07 12:01:58 +00:00
ExchangeRateServiceTest.php Fix PHP-LARAVEL-1V exchange rate cache race (#383) 2026-05-12 12:45:45 +02:00
GenerateStripePromotionCodesCommandTest.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
IdorVulnerabilityTest.php refactor: Simplify transaction endpoints architecture (#76) 2026-01-25 16:15:17 +01:00
ImportDataTest.php fix: Apply automation rule labels on transaction creation and import (#79) 2026-01-27 11:11:29 +01:00
InertiaSharedDataTest.php feat(mcp): read-only MCP server for Pro accounts (#689) 2026-07-17 16:54:15 +02:00
IntegrationRequestTest.php feat(integration-requests): add done status and fix review command crash on orphaned author (#601) 2026-06-27 14:42:09 +00:00
LabelBudgetReassignmentTest.php fix(budgets): re-derive budget membership when labels are attached without a model event (#787) 2026-08-12 12:48:47 +02:00
LabelTest.php refactor(api): standardize serialization via model $hidden (#492) 2026-06-05 13:57:34 +02:00
LoanTest.php refactor(accounts): split store/update in the account controller (#776) 2026-08-11 13:58:47 +00:00
LocalizationTest.php feat(currencies): add the Danish Krone (DKK) (#754) 2026-08-10 12:08:56 +02:00
LoggingConfigTest.php fix(logging): keep laravel.log writable across container UIDs (#451) 2026-05-29 15:10:50 +02:00
MailSenderTest.php feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
NewTransactionsMarkerTest.php feat(transactions): make new-transaction marker cross-device (#611) 2026-06-29 19:11:37 +02:00
OpenAiAppsChallengeTest.php feat(mcp): serve the ChatGPT app directory domain challenge (#749) 2026-08-10 07:46:49 +00:00
PersistUpsellSourceFromStripeTest.php feat: reuse the upgrade modal at more upsell points and attribute revenue (#699) 2026-07-18 12:53:20 +00:00
PlaintextTransactionsTest.php Remove plaintext-transactions feature flag & E2E references (#116) 2026-02-13 11:10:21 +01:00
PlanFeatureTest.php feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
PopoverSafeAreaTest.php fix: keep iOS popovers below the notch (#282) 2026-04-13 15:19:56 +01:00
PostStripeEventToDiscordTest.php fix(discord): show old → new plan on plan change notification (#637) 2026-07-04 15:49:09 +00:00
PriceExperimentTest.php fix(subscriptions): assign the price arm before registration, not after (#792) 2026-08-13 08:14:37 +00:00
PurgeResidualEncryptionArtifactsJobTest.php chore: harden Inertia boundary, CI type-check, and test isolation (#640) 2026-07-04 18:57:58 +00:00
PwaTest.php Harden browser storage and PostHog recording (#402) 2026-05-14 15:57:48 +02:00
QueueConfigTest.php fix(queue): raise retry_after above the longest job timeout (PHP-LARAVEL-2D) (#645) 2026-07-05 09:31:23 +00:00
ReEvaluateTransactionRulesTest.php fix(security): scope job-status endpoints to owner + feature-area fixes (#627) 2026-07-03 14:49:32 +02:00
RealEstateAvailabilityTest.php refactor(real-estate): remove Pennant gating (#308) 2026-04-20 13:31:49 +01:00
RealEstateTest.php fix(balances): stop historical-balance generator OOM on ancient purchase/loan dates (PHP-LARAVEL-49) (#661) 2026-07-08 21:34:43 +00:00
ResendSyncCommandTest.php feat: Sync new users to Resend contacts (#85) 2026-01-28 21:25:58 +01:00
RoadmapTest.php feat(roadmap): add a public roadmap page mirrored from UserJot (#778) 2026-08-11 14:14:59 +00:00
RouteNotificationForMailTest.php refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
RuleEngineParityTest.php refactor: consolidate duplicated financial calculations (#643) 2026-07-04 22:26:44 +02:00
SavedFilterTest.php feat(transactions): add a monthly trend view to the analysis drawer (#736) 2026-07-26 17:03:52 +02:00
SendAiCohortReportCommandTest.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendAiConsentFollowUpEmailsCommandTest.php test(drip): deflake AI consent onboarding-grace boundary test (#625) 2026-07-03 07:06:20 +00:00
SendDailyStatsReportCommandTest.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendStuckCohortReportCommandTest.php feat(stats): add --no-discord to the remaining report commands (#607) 2026-06-29 15:32:31 +02:00
SendSubscriptionFunnelReportCommandTest.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
SentryConfigTest.php ci: remove production deploy job (#574) 2026-06-20 18:17:14 +00:00
SentryUserMiddlewareTest.php Add Sentry user context (#348) 2026-05-04 13:26:50 +01:00
SetLocaleTest.php feat(i18n): add French translation support (#532) 2026-06-15 19:15:43 +02:00
SharedAccountOwnershipTest.php feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
SitemapTest.php User Onboarding Flow (#23) 2025-12-12 13:06:08 +01:00
StrayHttpRequestGuardTest.php chore: harden Inertia boundary, CI type-check, and test isolation (#640) 2026-07-04 18:57:58 +00:00
StripeSubscriptionStatsCommandTest.php feat: add Discord admin feed for daily stats and Stripe events (#458) 2026-05-30 18:14:46 +02:00
SubscriptionTest.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
SuggestionPersistenceTest.php feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
SyncBankingConnectionsCommandTest.php chore: Remove account-mapping feature flag (#252) 2026-04-01 12:09:22 +02:00
SyncStripePricesCommandTest.php feat(subscriptions): add an A/B price experiment (€3.99 control vs €8.99 high) (#700) 2026-08-12 13:36:49 +02:00
TrackLastActiveAtTest.php feat(users): track last login and last active timestamps (#516) 2026-06-10 11:01:30 +02:00
TransactionAnalysisTest.php feat(transactions): add a monthly trend view to the analysis drawer (#736) 2026-07-26 17:03:52 +02:00
TransactionFilterTest.php fix(transactions): prevent crash when sorting by nullable column (#501) 2026-06-06 17:23:21 +02:00
TransactionSideClassificationTest.php refactor: consolidate duplicated financial calculations (#643) 2026-07-04 22:26:44 +02:00
TransactionTest.php refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
WeighBudgetTransactionsMigrationTest.php feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
WelcomeBanksOrderingTest.php feat(open-banking): remove feature flag gating (#297) 2026-04-17 10:20:05 +02:00