Commit Graph

15 Commits

Author SHA1 Message Date
Víctor Falcón 27919027fe
chore: harden Inertia boundary, CI type-check, and test isolation (#640)
## Summary

Wave 1 hardening: privacy/security quick wins on the Inertia boundary, a
CI safety net, and stricter test isolation. Four focused changes plus
two review fixes, each in its own commit. No dependency or
product-behavior changes.

## Changes (by commit)

1. **Hide sensitive User fields from serialization** — `User::$hidden`
only covered password / 2FA / remember_token, leaving Cashier billing
columns (`stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`) and
the legacy `encryption_salt` exposed in every serialized User, including
the Inertia-shared `auth.user` prop. None are read by the frontend and
Cashier keeps reading them server-side, so hiding them is invisible to
the UI and billing.
2. **Advisory frontend type-check in CI + duplicate `currency_code`
fix** — the CI linter job never ran `tsc`, so type errors accumulated
unseen. Adds a `Type Check Frontend` step running `bun run types`. It is
`continue-on-error: true` on purpose: the codebase already carries ~157
pre-existing `tsc --noEmit` errors, so gating on it now would turn CI
red on unrelated code. It surfaces type output today and should be
flipped to blocking once the backlog is cleared. Also removes a
duplicate `currency_code` member on the `User` TS interface (declared
twice, `CurrencyCode` and `string | null`); the TS language server flags
it, `tsc` masks it under `skipLibCheck`.
3. **Move residual-encryption cleanup out of Inertia `share()` into a
queued job** — `share()` is a shared-data provider and must be
read-only, but it ran a `DELETE`+`UPDATE` against the user on every
non-API web GET to purge the leftover encryption salt /
`EncryptedMessage` once a user had no encrypted data left. The existing
`encryption:*` commands do not cover this case (both target users who
*still* have encrypted data; this finalizes users who *finished*
decrypting). The work now goes to a new idempotent
`PurgeResidualEncryptionArtifactsJob` dispatched from `share()`,
preserving the eventual-cleanup semantics without writing during the
render.
4. **Block stray HTTP in the Feature test suite** — adds
`Http::preventStrayRequests()` in a Feature-scoped `beforeEach` so any
unfaked outbound request fails loudly instead of hitting the network. A
representative HTTP-touching subset (open banking,
exchange-rate/currency, AI categorization + AI/stats reports, analytics,
Discord, Stripe, bank logos) was run with the guard active; no test
relied on a real request, so no fakes had to be added.

### Review fixes (after the two-reviewer pass)

5. **Purge check uses `name_iv` source of truth, not the stale
`encrypted` flag** — the destructive purge decided "no encrypted
accounts" from `accounts.encrypted`, a flag the codebase already treats
as unreliable (see the
`align_accounts_encrypted_flag_with_plaintext_names` migration and
`FindsUsersWithLegacyEncryption`, which key off `name_iv`). An account
with an encrypted name but a stale `encrypted=false` flag could have its
key material destroyed. The job's guard now matches the canonical `*_iv`
predicate exactly; the loose flag gate in `share()` is kept only as a
cheap dispatch filter, and the job re-verifies with the safe predicate
before touching anything.
6. **Deduplicate purge dispatches with `ShouldBeUnique`** — `share()`
runs on every web GET, so an affected user re-enqueued the job on each
page load until a worker cleared the salt. The job is now
`ShouldBeUnique` keyed by user id, collapsing repeat dispatches into one
pending job.

## Test plan

- New/updated tests, all green:
- `InertiaSharedDataTest`: `auth.user` omits all sensitive fields; a web
GET no longer mutates the user inline and instead queues the cleanup
(and does not queue it when there is no salt).
- `PurgeResidualEncryptionArtifactsJobTest`: clears salt + message when
no `*_iv` data remains; keeps them when an encrypted transaction, an
encrypted account name, or a stale-flag-but-encrypted-name account
exists; no-op when salt already null.
- `StrayHttpRequestGuardTest`: an unfaked request throws
`StrayRequestException`; a matched fake still resolves.
- Green locally: `vendor/bin/pint --test`, `bun run lint` (0 errors),
`bun run format:check`, `bun run test` (254 frontend tests), targeted
backend
`--filter=InertiaSharedData|PurgeResidualEncryptionArtifactsJob|StrayHttpRequestGuard|Encryption`
(24 tests). Ran ~700 HTTP-touching Feature tests with the stray-request
guard active with no guard-induced failures.
- `bun run types` still reports the ~157 pre-existing errors (unchanged
set; this PR adds none) — that is exactly why the CI step is advisory
for now.

## Reviewer findings — addressed vs deferred

**Addressed**
- 🟠 Destructive purge keyed off the stale `accounts.encrypted` flag
instead of the `name_iv` source of truth → fixed in commit 5 (job now
mirrors `FindsUsersWithLegacyEncryption`; added a regression test for
the stale-flag case).
- 🟡 Per-request dispatch amplification with no dedup → fixed in commit 6
via `ShouldBeUnique`.

**Deferred (with rationale)**
- 🟠 "Three divergent copies of the legacy-encryption query" —
substantively resolved: the job now matches
`FindsUsersWithLegacyEncryption` exactly. The only remaining
`encrypted`-flag use is the `hasEncryptedAccounts` **UI prop** in
`share()`, which is a separate, pre-existing frontend concern; changing
it would alter which accounts the UI treats as encrypted and is out of
scope here. A full extraction into one shared scope would require
restructuring the trait (it builds a `User` query, not a per-model
boolean) and is not a Wave 1 quick win.
- 🟢 Redundant `->fresh()` / null guard in the job — kept deliberately:
it is the idempotency guard that makes the re-check read committed state
on the sync path (the second reviewer credited it as what makes repeat
dispatches safe).
- 🟢 `continue-on-error` shows the type-check step green — acknowledged;
flipping to blocking (or failing on an increase over a committed
baseline) is the follow-up once the ~157-error backlog is cleared.
- 🟢 (Product review) Theoretical one-request SSR/client
`hasEncryptionSetup` diff from async salt clearing — invisible in
practice (the lock button gates on `hasEncryptedAccounts ||
hasEncryptedTransactions`, false in both SSR and client), and `ssr.tsx`
is untouched. No action.

Product-bug reviewer verdict: no user-facing regressions. Hiding the 5
fields does not affect Cashier (raw attribute access),
`EncryptionController`, notifications, or any API/JSON path; the
`currency_code` dedup is runtime-identical; the cleanup timing change is
client-absorbed.
2026-07-04 18:57:58 +00:00
Víctor Falcón 220b1e11f1
refactor(banking): scalable per-provider sync via a syncer factory (#545)
## Why

Syncing was a growing `if/elseif` chain in
`SyncBankingConnectionJob::handle()` plus one private method per
provider. Every new provider meant editing the job, and the file mixed
provider-specific logic with cross-cutting orchestration. This does not
scale to dozens/hundreds of providers.

## What

Introduces a **factory + strategy** pattern:

- **`App\Contracts\BankingConnectionSyncer`** — interface: `sync()`,
`expires()`, `notifiesOnAuthFailure()`.
- **`AbstractBankingConnectionSyncer`** — defaults for the common case
(API-key provider: never expires, notifies on auth failure). A new
provider usually only implements `sync()`.
- **`BankingConnectionSyncerFactory`** — maps `connection.provider` to
its syncer (resolved from the container, so dependencies are injected).
- **Six syncers** — `IndexaCapitalSyncer`, `BinanceSyncer`,
`WiseSyncer`, `BitpandaSyncer`, `CoinbaseSyncer`, `EnableBankingSyncer`.
Each fully owns its provider behavior, including EnableBanking's daily
email and first-sync cutoff.

`SyncBankingConnectionJob` drops from ~520 to ~190 lines and now only
orchestrates: deleted-user/expiry/rate-limit checks, error handling,
retries, logging, and status updates. It asks the syncer `expires()` /
`notifiesOnAuthFailure()` instead of hardcoding provider lists.

### Adding a provider now

1. Create `XSyncer extends AbstractBankingConnectionSyncer` implementing
`sync()`.
2. Add one line to the factory map.

No changes to the job.

## Tests

- Existing job tests (`SyncBankingConnectionJobTest`,
`SyncRetryAndLoggingTest`) migrated through a shared `runSync()` helper
in `tests/Pest.php`; all original assertions preserved.
- New `BankingConnectionSyncerFactoryTest`: provider→syncer resolution,
unknown-provider exception, and the capability flags.
- Full suite green: **1604 passed**, 0 failures. PHPStan clean, Pint
applied.

## Docs

Adds `docs/adding-a-banking-provider.md` — a step-by-step developer
guide (usable by a human or an AI agent) covering the sync provider and
the broader end-to-end integration touchpoints.
2026-06-16 16:25:29 +02:00
Víctor Falcón 0b528b7902
feat: add Discord admin feed for daily stats and Stripe events (#458)
## What

Adds a private Discord admin feed with two flows, both posting through
one webhook (`DISCORD_WEBHOOK_URL`):

### 1. Daily stats — `stats:daily-report`
Scheduled **09:00 Europe/Madrid**. Posts an embed with:
- New users created **yesterday** (Madrid calendar day, converted to UTC
for the query)
- Total users
- Active/trialing counts + current & projected **MRR / ARR** per
currency

Reuses the #457 Stripe stats logic, extracted into
`SubscriptionStatsCollector` so the existing `stripe:subscription-stats`
command and this report share one source of truth.

### 2. Stripe events — `PostStripeEventToDiscord`
Queued listener on Cashier's `WebhookReceived`. Posts on:
- `customer.subscription.created` / `updated` / `deleted`
- `invoice.payment_succeeded` / `payment_failed`

## Setup
- `DISCORD_WEBHOOK_URL` — added to prod  (set locally to test)
- Stripe dashboard must send the 5 event types above to the existing
Cashier `/stripe/webhook` endpoint
- A queue worker must be running (listener is `ShouldQueue`)

## Tests
13 passing: Discord client (3), daily report (2), Stripe event listener
(4), plus the existing stats command (4) still green after the refactor.
2026-05-30 18:14:46 +02:00
Víctor Falcón c3dcbb48fd
Fix PHP-LARAVEL-1V exchange rate cache race (#383)
## Sentry
- Issue: PHP-LARAVEL-1V
- URL: https://whisper-money.sentry.io/issues/PHP-LARAVEL-1V

## Root cause
`ExchangeRateService::getRates()` skipped the database lookup after
`preloadRates()` marked a miss, then used `create()` to cache fetched
rates. If another request inserted the same `base_currency` + `date`
meanwhile, the unique index threw a duplicate-key exception.

## Fix
Use atomic Eloquent `upsert()` for exchange-rate cache writes so
concurrent requests converge on one row. Added regression coverage for
the preload-miss race path.

## Verification
- `php artisan test --compact tests/Feature/ExchangeRateServiceTest.php`
- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact --filter="getRates tolerates another
request storing rates after preload miss"`
2026-05-12 12:45:45 +02:00
Víctor Falcón 0815548ac9
ci: tier 1 + tier 2 pipeline speedups (#342)
Continues the work merged in #341 with two focused commits.

## Baseline vs result (real numbers from runs on this PR)

| Job | Before (last run on #341) | After | Notes |
|---|---|---|---|
| build-assets | 70s (gates 3 jobs) | 74s | unchanged |
| tests | 215s | 243s | now starts at t=0 (no gate) |
| performance-tests | 72s | 64s | now starts at t=0 (no gate) |
| browser-tests longest shard | 341s | 323s | -18s |
| linter | 61s | 63s | same |
| static-analysis | 29s | 23s | -6s |
| **Wall (critical path)** | **~7m** | **~6.6m** | **-25s on the
critical path** |

The critical path is still `build-assets → browser-tests-matrix`. Real
wall-time wins for that path require either smarter shard balancing or a
Playwright Docker container — both deliberately deferred (see below).

## Tier 1 — wall-time wins (commit 1)

- **Pest:** `withoutVite()` is now applied globally in the `Feature` and
`Performance` suites via `pest()->beforeEach(...)->in(...)`. Those
suites never assert on the JS bundle, so they no longer need a compiled
Vite manifest.
- **Drop `needs: build-assets`** from `tests` and `performance-tests`.
Both now start at t=0 instead of waiting ~70s for the artifact. Browser
tests still gate on it (real Playwright session needs the manifest).
- **Playwright:** install only `chromium` (no firefox/webkit) since
that's all pest-plugin-browser uses. Saves ~15s of system-dep install
per shard.
- **Drop `actions/setup-node`** from browser-tests-matrix;
`oven-sh/setup-bun` already provides node for `npx playwright`.

I also tried bumping browser shards 4 → 6 but reverted (commit 3):
pest-plugin-browser's `--shard=N/M` distributes very unevenly at M=6 in
this codebase — shard 6 ended up running the entire 106-test suite
(13m26s) while other shards ran 23 tests each. Reverted to 4 shards for
predictable balance. Better balancing needs a test-time-aware split
(follow-up).

## Tier 2 — caching + setup dedup (commit 2)

- New composite actions:
- `.github/actions/setup-php-deps`: PHP + composer cache + `vendor/`
cache. On a `vendor/` cache hit, `composer install` is skipped entirely.
- `.github/actions/setup-bun-deps`: Bun + bun cache + `node_modules/`
cache. On a hit, `bun install` is skipped entirely.
- All 6 jobs refactored to use the composite actions. Removes ~150 lines
of duplicated YAML.
- **Bug fix: Pint cache key** dropped `${{ github.sha }}` from the
primary key so it actually reuses across runs (was effectively 0%
reuse).

## Deliberately not included

- **Pest `--parallel`**: paratest's per-process DB suffix conflicts with
Testcontainers user grants (see revert in 82e9a77 on the parent branch).
Worth revisiting in a follow-up that either widens grants or overrides
`ParallelTesting::setUpProcess`.
- **Playwright Docker container** (`mcr.microsoft.com/playwright`):
would eliminate the 34s system-deps install entirely but couples PHP
setup to a custom container; risky for one PR.
- **Test-time-aware browser shard split**: only real way to actually
shrink the critical path further given pest-plugin-browser's current
sharding behavior.

## Verification

- Final CI run on this PR is green (one flaky `Create Category` browser
timeout that passed on rerun — pre-existing flake unrelated to these
changes).
- `php artisan test --filter=InertiaSharedDataTest` and
`--filter=BudgetPeriodDateTest` pass with `public/build` deleted,
confirming the global `withoutVite()` covers feature tests that
previously rendered Inertia pages.
- All YAML files parse cleanly.
- `vendor/bin/pint --dirty` passes.
2026-05-01 19:07:04 +02:00
Víctor Falcón 244344e953
feat(open-banking): remove feature flag gating (#297)
## Summary
- remove the Pennant-based `open-banking` flag and middleware gating so
open banking is always available for authenticated users
- simplify landing, onboarding, settings, and subscription flows to use
always-on open-banking behavior and remove stale frontend/shared flag
plumbing
- update open-banking tests and purge stored `open-banking` Pennant rows

## Testing
- `php artisan test --compact
tests/Feature/OpenBanking/InstitutionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BinanceControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BitpandaControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/ConnectionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact
tests/Feature/WelcomeBanksOrderingTest.php`
- `vendor/bin/pint --dirty --format agent`

## Notes
- `php artisan pennant:purge open-banking` was run to remove stale
stored values
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php`
still has one unrelated real-estate onboarding browser failure (`it
creates a real estate account during onboarding when feature is
enabled`)
2026-04-17 10:20:05 +02:00
Víctor Falcón 1880333b1c
chore: upgrade Laravel 12 to 13 (#242)
## Summary

- Upgrade `laravel/framework` from v12 to **v13.1.1** and update all 52
dependencies to their latest versions
- Bump `laravel/tinker` from v2 to **v3.0** (required for Laravel 13
compatibility)
- Address Laravel 13 breaking change: add `serializable_classes =>
false` to `config/cache.php`
- Fix cached `Collection` in `routes/web.php` — converted to plain array
via `->toArray()` for serialization safety

## Changes

| File | What changed |
|------|-------------|
| `composer.json` | Bumped `php ^8.3`, `laravel/framework ^13.0`,
`laravel/tinker ^3.0` |
| `composer.lock` | 52 packages updated, 1 removed
(`symfony/polyfill-php83`) |
| `config/cache.php` | Added `serializable_classes => false` |
| `routes/web.php` | Cached query result uses `->toArray()`, fallback
changed from `collect()` to `[]` |

## Testing

Full test suite passing: **919 tests, 3792 assertions, 0 failures**
2026-03-25 12:56:33 +00:00
Víctor Falcón 93f1f82ac5
test: add performance test suite with query count ceilings (#148)
## Why

### Problem

As new features are added, dashboard and settings pages have been
accumulating extra database queries — risking N+1 regressions and
degraded response times. There was no automated way to detect these
regressions before they reach production.

## What

### Changes

- **New `Performance` test suite** (`tests/Performance/`) with 25 tests
that enforce query count ceilings on every page and API endpoint
accessible from the sidebar
- `PageQueryCountTest.php` — 15 tests covering Dashboard, Accounts,
Transactions, Budgets, Cashflow, and all Settings pages, plus scaling
tests that prove query count stays constant as data volume grows
- `ApiQueryCountTest.php` — 10 tests covering all Dashboard and Cashflow
analytics API endpoints, plus scaling tests
- **Separate CI job** (`performance-tests`) that runs in parallel with
`tests` and `linter`, and gates both `build-image` and `deploy`
- **Excluded from the main `tests` job** to avoid running them twice
(`--exclude-testsuite=Performance`)
- Helper functions (`performanceSeedUser`, `countQueries`,
`assertMaxQueries`) added to `tests/Pest.php` for reuse
- `phpunit.xml` updated with the new `Performance` testsuite entry

### How it works

Each test seeds a user with realistic data (3 accounts, 30 transactions,
15 balances, 5 categories, 3 labels, 1 budget) and asserts the endpoint
executes **at most N queries**. Thresholds have a small buffer (~3
queries) above current counts — tight enough to catch N+1 regressions
(which add dozens of queries) but loose enough to not break on minor
legitimate changes. On failure, every executed SQL query is dumped for
easy debugging.

### Run locally

```bash
php artisan test --testsuite=Performance
```

## Verification

### Tests

All 25 performance tests pass. Existing Feature (633) and Unit (22)
suites unaffected.
2026-02-24 10:47:51 +01:00
Víctor Falcón a53e2be57b
Remove encryption from browser tests and demo user (#129)
## Summary

- Removed all `setupEncryptionKey()` / `visitWithEncryptionKey()` calls
from browser tests — encryption key setup in localStorage is no longer
needed since new users don't have encryption
- Removed `encryption_salt` from `UserFactory::onboarded()` state and
`OnboardingFlowTest` user creation
- Removed `name_iv` from `Account::factory()` calls in
`BankAccountsTest`
- Deleted `DemoEncryptionService` and its unit test — demo command now
stores plaintext account names and transaction descriptions
- Removed `demoEncryptionKey` Inertia shared prop and `encryption_key`
from demo config
- Removed encryption helper methods from `TestCase.php` and global
`setupEncryptionKey()` from `Pest.php`

## Test plan

- [x] Run `php artisan test --exclude-testsuite=Browser` — all
non-browser tests pass
- [x] Run `php artisan test --testsuite=Browser` — browser tests pass
without encryption key setup
- [x] Run `php artisan demo:reset` — demo account created with plaintext
data
- [x] Verify existing encryption migration tests still pass
(`EncryptionTest`, `DecryptTransactionsTest`,
`PlaintextTransactionsTest`)
2026-02-17 11:45:27 +01:00
Víctor Falcón 439ec86722
chore: Simplify IndexedDB sync by moving to Inertia shared props (#63)
This PR simplifies the IndexedDB synchronization mechanism by removing
individual sync controllers and services, and instead using Inertia.js
shared props to provide data globally across the application.

## Benefits

1. **Simplified Architecture**: Removed complex sync logic for
static/semi-static data (accounts, categories, banks, labels, automation
rules)
2. **Better Performance**: Data is now shared via Inertia props,
eliminating unnecessary API calls and IndexedDB operations
3. **Reduced Complexity**: Significantly reduced codebase size (~2000
lines removed)
4. **Better UX**: Data is immediately available on page load without
waiting for sync operations
5. **Maintainability**: Fewer moving parts means easier to maintain and
debug

## Migration Notes

- Transaction syncing still uses IndexedDB for offline support
- All other data (accounts, categories, banks, labels, automation rules)
is now fetched via Inertia shared props
- Components automatically receive updated data on navigation without
manual sync operations
2026-01-19 19:15:26 +01:00
Víctor Falcón 0d76913e5d
test: Fix and improve Browser test suite (#62) 2026-01-19 10:04:25 +01:00
Víctor Falcón 517d758a9d
Import balances assistant (#14)
<img width="1220" height="803" alt="image"
src="https://github.com/user-attachments/assets/ae0be642-6648-456a-8c43-cca69fd2e750"
/>
2025-12-05 16:39:56 +01:00
Víctor Falcón 571f0159e1 Add browser timeout to Pest tests 2025-12-03 17:42:18 +01:00
Víctor Falcón 5cfe3c81a1 Better input field UX 2025-11-26 11:24:37 +01:00
Víctor Falcón 2eaad3204c Install Pest 2025-11-07 12:01:58 +00:00