Commit Graph

1006 Commits

Author SHA1 Message Date
Víctor Falcón 3526e5fd12
Fix service worker registration rejection (#376)
## Sentry
- Fixes PHP-LARAVEL-1Q
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1Q

## Root cause
- Service worker registration promise rejected in production and had no
rejection handler.
- Browser reported unhandled promise rejection: Error: Rejected.

## Fix
- Add a catch handler to service worker registration so
unsupported/intercepted registration failures do not become unhandled
global errors.
- Add regression coverage asserting registration has rejection handling.

## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
2026-05-10 17:45:47 +01:00
Víctor Falcón c929c1f7a5
chore: Add sentry issue slash command (#375) 2026-05-10 17:43:39 +01:00
Víctor Falcón 69610c57b3
Recover from stale Vite chunks (#374)
## Summary
- reload once when a stale Vite dynamic import chunk fails
- suppress recoverable chunk load failures from Sentry
- add Vitest coverage for recovery and Sentry filtering

Fixes PHP-LARAVEL-1P

## Tests
- npm run test -- resources/js/lib/chunk-load-recovery.test.ts
resources/js/lib/sentry.test.ts
- npm run types *(fails: existing TypeScript errors outside this
change)*
2026-05-10 17:20:24 +02:00
Víctor Falcón 6335287765
fix(sentry): ignore postMessage clone noise (#373)
## Summary
- filter browser postMessage DataCloneError noise before Sentry send
- add focused classifier tests

## Tests
- npm test -- resources/js/lib/sentry.test.ts
- npm run types *(fails: existing repo-wide TypeScript errors, including
missing Wayfinder generated modules)*

Fixes PHP-LARAVEL-1M
2026-05-10 11:16:38 +01:00
Víctor Falcón 718cfa9e8f
Fix Sentry transaction and dashboard crashes (#372)
## Summary
- guard transaction list Inertia callbacks against non-paginated
transaction props
- exclude soft-deleted categories from dashboard category aggregates
- add dashboard category and cursor pagination tests

Fixes PHP-LARAVEL-1K
Fixes PHP-LARAVEL-1J

## Tests
- npm run test -- cursor-pagination.test.ts
- php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
--filter='top categories'
- vendor/bin/pint --dirty --format agent

Note: npm run types still fails on existing unrelated TypeScript errors.
2026-05-10 11:10:31 +01:00
Víctor Falcón f4ab4a1989
Fix Sentry release commit detection in image build (#371)
## Summary
- add --ignore-missing to Sentry set-commits so build-image does not
fail when previous release SHA is unavailable
- cover the workflow command in Sentry config test

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SentryConfigTest.php
2026-05-09 16:51:51 +01:00
Víctor Falcón 6132a0fa92
Init browser Sentry from env DSN (#370)
## Summary
- read browser Sentry DSN from `SENTRY_LARAVEL_DSN`
- expose `SENTRY_LARAVEL_DSN` through Vite env
- enable default PII and disable client Sentry when DSN missing
- removed temporary welcome test button

## Tests
- `npx eslint resources/js/pages/welcome.tsx resources/js/app.tsx
resources/js/types/vite-env.d.ts vite.config.ts`
- `php artisan test --compact tests/Feature/SentryConfigTest.php`
- `npm run build` (build completed; existing Sentry sourcemap upload
reports SSL error against Bugsink)
2026-05-09 12:07:11 +02:00
Víctor Falcón 97df0597f8
Prevent cached cashflow analytics responses (#368)
## Summary
- mark cashflow analytics JSON responses as no-store/private
- add coverage for cache-control header

## Why
Browser tests reuse the same cashflow API URLs across authenticated
users. Cached user-specific analytics can leak stale breakdown data
between sessions and hide the income category.

## Tests
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
2026-05-08 16:51:27 +02:00
Víctor Falcón 360a38a880
Update worktree script (#366)
## Summary\n- update worktree script\n\n## Tests\n- not run (script-only
change)
2026-05-07 20:43:58 +01:00
Víctor Falcón e36d6f3e16
Speed up PR CI browser path (#365)
## Summary
- rebalance Browser tests with explicit class-filter shards
- build PR browser assets inside shards, removing the build-assets
dependency gate
- run Browser shards with Pest parallelism ()

## Autoresearch metric
- baseline modeled PR critical path: 406.50s
- final modeled PR critical path: 234.27s
- modeled improvement: 172.23s (42.4%)

## Verification
- ran METRIC ci_total_s=234.270
METRIC actual_recent_pr_total_s=422.000
METRIC build_assets_s=0.000
METRIC tests_s=170.000
METRIC browser_matrix_s=232.270
METRIC linter_s=60.000
METRIC static_analysis_s=26.500
METRIC performance_tests_s=63.000
METRIC job_count=13.000
METRIC browser_shards=6.000 for each experiment
- coverage guard in autoresearch script checks Browser filters cover all
recent Browser classes exactly once

## Notes
- wall-clock faster; runner minutes likely higher due extra shards and
duplicate asset builds
- real CI should validate Browser parallelism flake risk
2026-05-07 20:40:13 +01:00
Víctor Falcón e3c2d2fd82
Fix duplicate category name validation (#364)
## Summary
- validate category names against the existing per-user unique index
- return validation errors for create/update duplicate races instead of
500s
- cover create, update, soft-delete, cross-user, and DB race cases

Fixes PHP-LARAVEL-1E
Fixes PHP-LARAVEL-1F

## Tests
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
- vendor/bin/pint --dirty --format agent
- git diff --check
2026-05-07 12:55:35 +01:00
Víctor Falcón 5784e25f0a chore: release v0.2.0 2026-05-07 11:56:58 +02:00
Víctor Falcón 164235f6d3
Fix label creation dropdown refresh (#363)
## Summary
- keep newly created labels available in the combobox without reload
- propagate created labels through transaction forms and bulk actions
- add browser coverage for creating a label from transaction label
dropdown

## Tests
- php artisan test --compact tests/Browser/TransactionsTest.php
--filter='newly created labels'
- vendor/bin/pint --dirty --format agent
- npm run build

Note: `npm run types` still fails on pre-existing TypeScript errors
outside this change.
2026-05-07 10:26:43 +01:00
Víctor Falcón caae0e8918
Preload exchange rates (#362)
## Summary
- preload dashboard exchange-rate ranges in one query
- memoize exchange-rate lookups per request
- cover Sentry N+1 regression

Fixes PHP-LARAVEL-1G

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/ExchangeRateServiceTest.php
- php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
- php artisan test --compact tests/Performance/ApiQueryCountTest.php
--filter='net worth evolution API'
- php artisan test --compact tests/Performance/PageQueryCountTest.php
--filter='dashboard'
2026-05-06 16:35:49 +01:00
Víctor Falcón 17d952435b
Hide stale onboarding plan warning (#360)
## Summary
- hide connected-account plan warning and price after the user chooses
connected setup once
- seed onboarding state from existing connected accounts
- add hook unit coverage and browser coverage for warning/price
visibility

## Tests
- bun run test -- resources/js/hooks/use-onboarding-state.test.tsx
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter="hides the connected plan warning"
- npm run types -- --pretty false 2>&1 | rg
"resources/js/(components/onboarding/step-create-account|hooks/use-onboarding-state|pages/onboarding/index)"
(no onboarding errors; repo has unrelated existing TS errors)
2026-05-06 15:10:32 +01:00
Víctor Falcón 0f1ce81b4e
Fix onboarding return after bank auth errors (#361)
## Summary
- Keep non-onboarded users on the accounts step after bank authorization
errors
- Add feature and browser coverage for the callback error path

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter='returns to the accounts step'
- php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php
--filter='callback with error'
2026-05-06 15:50:02 +02:00
Víctor Falcón 6cd465bb78
Fix bank connection FAQ (#359)
## Summary
- update landing FAQ to confirm secure Open Banking connections
- clarify bank connections are Pro-only and free users import via
CSV/Excel
- add regression coverage for FAQ copy and Spanish translation

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Unit/WelcomeFaqTest.php
- git diff --check
2026-05-06 11:28:26 +01: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
Víctor Falcón 1024122e57
chore: add MCP SDK dev dependency (#357)
## Summary
- Add @modelcontextprotocol/sdk as a dev dependency via Bun
- Update bun.lock

## Verification
- bun install --frozen-lockfile
2026-05-05 15:28:35 +01:00
Víctor Falcón 0fe7bf7fc6
Batch historical balance writes (#356)
## Summary
- Batch calculated historical account balances with one upsert
- Keep existing balance dates untouched
- Add regression coverage for one account_balances write query

Fixes PHP-LARAVEL-1C

## Tests
- php artisan test --compact
tests/Feature/OpenBanking/BalanceSyncServiceTest.php
- vendor/bin/pint --dirty --format agent
2026-05-05 15:26:28 +01:00
Víctor Falcón 22043ced29
fix(budgets): remove Custom period type to fix duplicate-key crash (#355)
## Why merge this

Production bug
**[PHP-LARAVEL-1B](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1B)**
— `/budgets/{budget}` throws a 500 (`UniqueConstraintViolationException`
on `budget_periods_budget_id_start_date_unique`) for the affected user.
Page is unusable for them until fixed.

## Root cause

The Custom period type let users create budgets with arbitrary
`period_duration`. Combined with `calculatePeriodDates()`'s day-of-month
snap (`startDate->day(period_start_day)`), short durations produced
periods where `end_date == start_date == period_start_day`. On the next
visit after that day, regeneration computed a new period whose start
snapped backward to the same day → unique key collision.

Concrete trace from the Sentry event:

- Budget: `Seguro de Salud`, `period_type=custom, period_duration=1,
period_start_day=1`, created `2026-05-05`.
- Initial period created at budget creation: `start=2026-05-01,
end=2026-05-01` (already 4 days stale because Custom snapped
`now()=05-05` back to day 1, then `addDays(1).subDay()` produced same
date).
- Today `05-05` → `getCurrentPeriod()` returns null → `generatePeriod()`
snaps back to `05-01` again → `INSERT (budget_id, 05-01)` → 1062.

Verified in prod DB: this is the **only** budget in the entire database
with `period_type='custom'` and the only period row with `end_date <=
start_date`.

## Fix

Custom is the only period type that exposes this snap-back collision
(Monthly/Weekly/Biweekly all use a self-consistent advance). It also has
no defensible UX — every legitimate use case is covered by
Monthly/Weekly/Biweekly. So we remove it end-to-end:

- `BudgetPeriodType::Custom` enum case dropped.
- `BudgetPeriodService`: Custom branches removed from
`calculatePeriodDates()` and `calculateNextPeriodStartDate()`.
- `Budget` model: `period_duration` removed from `$fillable` and
`casts()`.
- `StoreBudgetRequest` / `UpdateBudgetRequest`: `period_duration` rule
removed.
- `BudgetController`: `period_duration` no longer passed in
store/update.
- Create + edit budget dialogs: Custom option and `period_duration`
input removed.
- `ResetDemoAccountCommand`: Custom switch case removed.
- `BudgetFactory`: `custom()` state and `period_duration` default
removed.
- `types/budget.ts`: `'custom'` removed from `BUDGET_PERIOD_TYPES` and
`Budget.period_duration` field dropped.
- Migration `2026_05_05_132023_convert_custom_budgets_to_monthly`:
rewrites any existing `custom` rows to `monthly` with
`period_duration=null, period_start_day=1` so the dropped enum case
can't crash on hydration.

Period generation logic for Monthly/Weekly/Biweekly is **unchanged**.

## Manual prod cleanup (separate, after merge)

The single buggy budget in prod will be deleted manually:

```sql
DELETE FROM budget_periods WHERE id = '019df7fd-6073-731b-9c08-f3723515292b';
DELETE FROM budgets WHERE id = '019df7fd-6071-7219-b190-ece22ccdb63f';
```

## Tests

`tests/Feature/BudgetPeriodServiceTest.php` covers Monthly + Weekly
advance and Monthly first-period generation. Existing
`BudgetPeriodDateTest` unaffected.

## Risk

- `period_duration` column kept in DB (nullable) to avoid data loss; no
migration needed.
- Migration ensures any environment with `period_type='custom'` rows is
converted before the enum case is removed, preventing hydration errors.
- No customers actively rely on Custom: prod query confirmed only the
single broken budget uses it, and that one is being deleted.

Fixes PHP-LARAVEL-1B
2026-05-05 16:07:16 +02:00
Víctor Falcón 3fea230d58
chore: Don't run `demo:reset` command daily (#353)
Removes daily schedule for `demo:reset` command.
2026-05-05 09:59:54 +01:00
Víctor Falcón e387c038ca
perf(resend): default sync-leads to last 24h window (#354)
## Why

`resend:sync-leads` was syncing every verified lead on each run, taking
4+ minutes.

## Change

- New `--since=N` option (hours). Default `24`.
- `--since=0` syncs all leads (manual backfill).
- Scheduler unchanged: runs daily at 03:00 → 24h window covers it.

## Tests

- Default 24h window excludes older leads.
- `--since=0` syncs everything.
2026-05-05 09:57:25 +01:00
Víctor Falcón b1709b714e
fix(onboarding): guard window access in SSR (#351)
## Problem

Production SSR crashes repeatedly when rendering the Onboarding page:

```
ReferenceError: window is not defined
  at Onboarding (/app/bootstrap/ssr/assets/index-BEtdcHXd.js:2295)
```

The `useMemo` reading `?step=` from the URL accessed
`window.location.search` directly, which fails during Inertia SSR.

Observed ~7+ times in production logs between 18:35–21:37 UTC.

## Fix

Add `typeof window === 'undefined'` guard before reading the URL.
Returns `undefined` on the server so the step initializer falls back to
default behavior; client hydration re-runs and reads the URL.

## Related logs

- `Inertia\Ssr\SsrException` — Onboarding page
- Other SSR-touching files (`welcome.tsx`, `auth/login.tsx`) verified
safe (window inside `useEffect` or already guarded).
2026-05-05 08:45:44 +01:00
Víctor Falcón f800847591
feat(banking): back off scheduler when EnableBanking returns 429 (#352)
## Problem

Production logs show repeated EnableBanking 429s on the same
connections, every cron cycle:

```
[2026-05-04 18:00:55] EnableBanking API error status:429
  body: [HUB046] Allowed number of accesses exceeded for consent
[2026-05-04 21:47:12] EnableBanking API error status:429
  body: Daily PSU not present consultation limit has been exceeded
[2026-05-05 00:01:41] same connection, same error
[2026-05-05 06:01:41] same connection, same error
```

Root cause: `SyncBankingConnectionJob` returned early on 429s without
persisting any backoff state. The scheduler kept re-dispatching the same
connection on every run, hammering the provider and burning the daily
quota.

## Fix

Persist a per-connection backoff window so the scheduler stops
re-dispatching until the provider quota resets.

- New `rate_limited_until` column on `banking_connections`.
- On 429: derive the window from
  1. `Retry-After` header if present,
2. "Daily ..." message → next UTC midnight (matches PSU daily limit
semantics),
  3. default 1 hour (consent / generic).
- Job short-circuits with a `Skipped` sync log if the window is still
active.
- Successful sync clears the window.
- `SyncAllBankingConnectionsJob` + `SyncBankingConnections` command
filter out connections still inside their backoff.

## Tests

- Existing rate-limit test updated (now also asserts the backoff is
set).
- New: daily message → next UTC midnight.
- New: `Retry-After` header honoured (1800s).
- New: rate-limited connection skipped without calling provider.
- New: successful sync clears `rate_limited_until`.
- New: scheduler excludes connections whose backoff has not expired.

`php artisan test --compact
--filter="SyncBankingConnectionJobTest|SyncRetryAndLoggingTest|SyncBankingConnectionsCommandTest"`
→ 70 passed.
2026-05-05 09:39:32 +02:00
Víctor Falcón 21b5692174
fix: include production Dockerfile in deploy filter (#350)
## Summary
- include Dockerfile.* in CI changes filter
- ensure Dockerfile.production-only commits build and deploy

## Verification
- python3 assertion confirms Dockerfile.* filter exists and matches
Dockerfile.production
2026-05-04 15:36:56 +01:00
Víctor Falcón 23977f74f5
Fix Bun permissions for SSR worker (#349)
## Summary
- install Bun under /usr/local/bun instead of /root/.bun
- expose Bun via /usr/local/bin for www-data supervisor workers

## Test
- docker build -f Dockerfile.production --check .
2026-05-04 14:15:33 +01:00
Víctor Falcón 049486093a
Add Sentry user context (#348)
## Summary
- identify authenticated users on Sentry web requests with id and email
- add user and banking connection context to banking sync jobs
- cover Sentry context behavior with feature tests

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SentryUserMiddlewareTest.php
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
2026-05-04 13:26:50 +01:00
Víctor Falcón cd2462d451
Fix default category creation N+1 (#347)
## Summary
- replace default category firstOrCreate loop with one lookup plus bulk
insert
- add regression coverage for repeated category lookups

Fixes PHP-LARAVEL-19

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter='default categories'
2026-05-04 13:22:55 +01:00
Víctor Falcón 70f3897b55
fix: unblock onboarding after sync failure (#346)
## Summary
- mark failed banking sync jobs as error so onboarding can continue
- add EnableBanking HTTP timeouts to avoid worker hard timeouts
- add regression coverage for failed active sync jobs

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
--filter='failed sync job marks active connection as error'
- php artisan test --compact
tests/Feature/Onboarding/OnboardingSyncStatusTest.php --filter='returns
pending false when unsynced connection has an error status'
2026-05-04 12:52:21 +01:00
Víctor Falcón 592fb20059
Fix Stripe checkout promotion code conflict (#345)
## Summary
- avoid sending both allow_promotion_codes and discounts to Stripe
Checkout
- keep manual promo entry enabled only when no lead promotion code
exists
- add checkout regression test

Fixes PHP-LARAVEL-1A

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SubscriptionTest.php
--filter='checkout'
2026-05-04 12:31:04 +01:00
Víctor Falcón fe3b32395c
Fix missing historical currency rates (#344)
## Summary
- fall back to previous historical currency rate dates when exact API
release is missing
- keep latest rate lookups unchanged
- add regression coverage for missing historical release

Fixes PHP-LARAVEL-18

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CurrencyConversionServiceTest.php
2026-05-04 11:26:45 +01:00
Víctor Falcón f6c20576b5
fix(banking): clamp linkedDateFrom to today on EnableBanking sync (#343)
## Problem

`EnableBankingProvider::getTransactions` returned 422
`DATE_FROM_IN_FUTURE` when an account had a future-dated last
transaction (e.g. pending/scheduled). `linkedDateFrom` was set from
`lastTransaction->transaction_date` without bounding, producing
`dateFrom > dateTo`.

Sentry:
[PHP-LARAVEL-15](https://whisper-money.sentry.io/issues/PHP-LARAVEL-15)

## Fix

Clamp `linkedDateFrom` to `dateTo` (today) in
`SyncBankingConnectionJob::syncEnableBanking`.

## Tests

Added test covering future-dated last transaction case.

Fixes PHP-LARAVEL-15
2026-05-03 17:26:27 +00: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 b94a285aad
ci: speed up pipeline (#341)
## Why

Last CI run on a PR took **~16m 36s** wall (browser-tests dominated).
Total CPU ~34 min. Several easy wins identified.

## Baseline (run 25169327223)

| Job | Wall | Critical step |
|---|---|---|
| browser-tests | 16m 36s | Browser Tests 718s, Build 166s, Playwright
50s |
| tests | 9m 38s | Tests 334s, Build 173s |
| performance-tests | 4m 19s | Build 173s |
| linter | 2m 54s | Pint 109s |
| static-analysis | 36s | — |

## Changes (one commit each)

**A. Shard browser tests 4×** — `pest --shard=N/4` matrix. ~12 min → ~3
min critical path.

**B. Build assets once, share via artifact** — new `build-assets` job
uploads `public/build`; tests/browser-tests/performance-tests download
it. Saves ~340s of duplicated CPU.

**C. Disable xdebug in tests job** — `coverage: xdebug` was set but
`--coverage` never passed. Silent ~30-50% slowdown.

**D. Parallel Pest with Testcontainers** — `pest --parallel
--processes=4`, `TESTCONTAINERS=true` so each worker gets isolated
MySQL. Drops job-level mysql service.

**E. Cache composer + bun deps** — `actions/cache` for composer cache
dir and `~/.bun/install/cache` keyed on lockfiles. Saves 30-60s/job
warm.

**F. Cache Playwright browsers** — cache `~/.cache/ms-playwright` keyed
on bun.lock; on hit only runs `install-deps`. Saves ~50s.

**G. Pint parallel + cache** — `--parallel --cache-file=.pint.cache`
with persisted cache. ~109s → ~5-10s warm.

## Expected wall time after

Browser shards become critical path: **~6-7 min** (down from 16-17 min).

## Risks

- **D**: 4 paratest workers each spawn a MySQL container — watch
RAM/runner load. Drop to `--processes=2` if flaky.
- **A**: more concurrent runners. Adjust shard count if MySQL service
init becomes the new bottleneck per shard.
- **B**: `build-assets` is now a hard dep for
tests/browser-tests/performance-tests.

## Test plan

CI itself is the test. Watch this PR run, compare timings to baseline.
2026-04-30 14:52:33 +00:00
Víctor Falcón ab3d6e9fca
feat(leads): cohort-based launch invitations with per-user Stripe coupons (#333)
## Summary

Wires up the launch flow: each waitlist lead gets a per-cohort
invitation email, a personal single-use Stripe promo code matching their
reward, and a signed landing link that unlocks register/install.

## Cohorts (resolved at send-time, by queue rank ASC, ignoring
`position` null/0)

| Cohort | Rule | Reward | Stripe coupon |
|---|---|---|---|
| `founder` | ranks 1–10 | Free forever | `wm_founder_forever` (100%
off, forever) |
| `founder_referrer` | referred any current founder (overrides rank) |
Free forever | `wm_founder_forever` |
| `early_bird` | ranks 11–100 | 2 mo free monthly / 3 mo free yearly
first year | `wm_earlybird_monthly` (100% off, 2 mo, monthly only) +
`wm_earlybird_yearly` (25% off once, yearly only) |
| `waitlist` | ranks 101+ | same as early bird | same coupons |

## What's added

- **DB**: `cohort`, `promo_code_monthly`, `promo_code_yearly`,
`invitation_sent_at` on `user_leads`.
- **Services**: `LeadCohortResolver`, `LeadPromoCodeAllocator`.
- **Commands**:
- `php artisan stripe:ensure-launch-coupons` — idempotent Stripe coupon
setup (run once per env).
- `php artisan leads:send-invitations --limit=N [--cohort=…] [--dry-run]
[--force]` — wave-by-wave delivery, idempotent across runs
(`invitation_sent_at` gate). Lazily generates Stripe promo codes per
lead it touches.
- **Checkout**: `SubscriptionController::checkout` resolves the auth
user's `UserLead` by email and applies the matching promo code
(`monthly`/`yearly`) via Cashier's `withPromotionCode()`.
- **Invite link**:
`LandingAuthOverrideService::generateInvitationUrl($leadId, days: 30)` —
signed lead-bound URL that unlocks auth buttons (existing override
cookie) and stores `invited_lead_id` in session. The register view
prefills the email from the lead.
- **Emails**: 4 cohort markdown templates (`founder`,
`founder-referrer`, `early-bird`, `waitlist`) with per-cohort subject +
body. Locale set from `$lead->preferredLocale()`. 28 Spanish keys added.

## Tests (Pest)

- `LeadCohortResolverTest` — boundaries + founder-referrer override +
null/0 position skip.
- `SendUserLeadInvitationsTest` — batch ordering, idempotent across
runs, ignores null/0, persists cohort.
- `UserLeadInvitationTest` — per-cohort body content, signed lead-bound
signup URL, Spanish locale.
- `LandingAuthOverrideTest` — invitation URL unlocks + stores session
lead.

Full suite green (1262 passed).

## Launch checklist

1. Merge + deploy.
2. `php artisan stripe:ensure-launch-coupons` against staging Stripe →
verify in dashboard → run on prod.
3. Optional preflight: `php artisan leads:send-invitations --limit=10
--dry-run`.
4. Wave-by-wave: `php artisan leads:send-invitations --limit=50` per
day.
5. Confirm `HIDE_AUTH_BUTTONS=true` on prod so only signed invite links
unlock register/install.

## Notes / non-goals

- No `User → UserLead` foreign key — checkout matches by email per
request.
- Existing `FOUNDER` promo flow is untouched and still kicks in if a
user has no lead-specific code.
- `founder_referrer` cohort is empty in current prod data; logic is in
place for when founders start referring.
2026-04-30 15:10:28 +01:00
Víctor Falcón 8f42496a5f
fix(dashboard): avoid month overflow in real estate projection (#340)
## Problem

`DashboardAnalyticsTest > real estate balance evolution appends
projected mortgage balance from linked loan` fails on certain calendar
dates (e.g. when run on 2026-04-30) with:

```
Failed asserting that 17324874 is less than 17324874.
```

## Root cause

Carbon's `addMonths()` defaults to overflow mode, so adding months to a
day that doesn't exist in the target month rolls forward into the next
month:

- `2026-04-30 + 10 months` → `2027-02-30` → overflow → `2027-03-02`
- `2026-04-30 + 11 months` → `2027-03-30`

Both end up in the same `Y-m` (`2027-03`). The dashboard
balance-evolution endpoint and
`LoanAmortizationService::projectFromBalance` use `addMonths` to build
month-keyed projections and to label projected chart points. When two
iterations collapse onto the same `Y-m`:

1. `generateProjection` overwrites the earlier balance with the later
one.
2. The controller emits two consecutive projected points labeled with
that month and assigns the same `mortgage_balance` to both, breaking the
strictly-decreasing assertion.

## Fix

Switch every `addMonths()` call in the projection paths to
`addMonthsNoOverflow()` so each iteration lands on a distinct month.

- `app/Services/LoanAmortizationService.php` — `projectFromBalance`,
`projectFromOriginal`
- `app/Http/Controllers/Api/DashboardAnalyticsController.php` — real
estate projected loop

## Verification

```
php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
Tests: 34 passed (220 assertions)
```

Previously failing on 2026-04-30, now green.
2026-04-30 13:53:01 +00:00
Víctor Falcón 0f2300bf3e
feat(accounts): show projection on real estate chart (#338)
Real estate account chart now appends 12 months of projected data when
the property has a revaluation percentage and/or a linked loan:

- Market value projected forward via the configured revaluation rate
  (compounded monthly).
- Mortgage balance projected via LoanAmortizationService on the linked
  loan, so the mortgage decline is visible alongside the property
  revaluation in the same chart.

Backend: DashboardAnalyticsController appends projected points with
display-currency variants. Frontend: chart adds dashed projection lines
for value and mortgage_balance plus a Today reference line. Tooltip and
currency-mode swap propagate the new projected_mortgage_balance field.
2026-04-27 08:08:12 +01:00
Víctor Falcón d697857041
typo: Add missing space (#339) 2026-04-27 07:59:45 +01:00
Víctor Falcón 13f741aaed
fix(real-estate): compound annual revaluation monthly (#337)
Previous formula divided annual % by 12 linearly, which over-applied
the configured rate when compounded over 12 months (e.g. 12% annual
grew balances ~12.68%/yr).

Use (1 + p/100)^(1/12) - 1 so 12 monthly applications equal the
configured annual percentage exactly. Works for negatives too.
2026-04-27 07:35:51 +01:00
Víctor Falcón af7ba727d5
ci: drop tests/** from code paths filter (#336) 2026-04-26 10:48:25 +02:00
Víctor Falcón 405e6bfc29
ci: skip build and deploy when no code changes (#335)
## Why

Pushes to `main` that only modify workflows, README, or docs were
triggering production deploys.

## What

- Adds a `changes` job using `dorny/paths-filter@v3` to detect whether
the push touched application code.
- Gates `build-image` and `deploy` on `needs.changes.outputs.code ==
'true'`.

## Code paths considered

`app/`, `bootstrap/`, `config/`, `database/`, `public/`, `resources/`,
`routes/`, `tests/`, `storage/`, `artisan`, `composer.{json,lock}`,
`package*.json`, `bun.lock*`, `vite.config.*`, `tsconfig*.json`,
`eslint.config.*`, `.prettierrc*`, `phpstan.neon*`, `phpunit.xml*`,
`pint.json`, `Dockerfile`, `docker/**`, `.dockerignore`, `.env.example`.

Anything else (e.g. `.github/**`, `*.md`, `docs/**`) is treated as
non-deployable.
2026-04-26 09:15:06 +01:00
Víctor Falcón b2d73ef5c3
ci: add manual release workflow (#334)
Adds a manually-triggered release workflow using `release-it`.

## How it works

1. Go to **Actions → Release → Run workflow**
2. Pick `patch` / `minor` / `major`
3. Workflow:
   - checks out `main`, creates `release/run-<id>` branch
- runs `release-it --ci <increment>`: bumps `package.json`, updates
`CHANGELOG.md`, commits, tags `vX.Y.Z`, pushes branch + tag, creates the
GitHub release
   - opens a PR back to `main` with the version bump + changelog

No direct push to `main` required. Tag and release are published
immediately; the PR just lands the version bump.

## Changes

- `.github/workflows/release.yml` — new workflow
- `.release-it.json` — allow non-main branches, ensure branch push

## Caveats

- Squash-merging the release PR leaves the tag on a dangling commit (tag
still valid). Use merge or rebase to keep the tag reachable from `main`.
2026-04-26 08:44:26 +01:00
Víctor Falcón 00c412a837 chore: release v0.1.20 2026-04-24 19:23:07 +02:00
Víctor Falcón 0eca002856
fix(docker): ensure www-data owns storage after artisan commands (#329)
## Problem

Sentry reporting permission-denied errors on `/open-banking/callback`:

> UnexpectedValueException: The stream or file
"/app/storage/logs/laravel.log" could not be opened in append mode:
Failed to open stream: Permission denied

4 issues, 14 events (PHP-LARAVEL-Z, 10, 11, 12).

## Root cause

Entrypoint runs as root. After the initial `chown www-data`, artisan
commands (`config:cache`, `route:cache`, `view:cache`, `event:cache`,
`migrate`) execute and can create/touch `storage/logs/laravel.log` as
root. Later, php-fpm (www-data) tries to append and fails.

Queue workers + inertia-ssr also run as root via supervisor — same
failure mode from worker side.

## Fix

- `docker/entrypoint.sh`: re-chown `/app/storage` and
`/app/bootstrap/cache` to www-data **after** artisan cache warm-up, just
before `exec supervisord`.
- `docker/supervisor/supervisord.conf`: add `user=www-data` to
`queue-worker`, `queue-worker-emails`, `inertia-ssr`. Nginx and php-fpm
master keep root (need port 80 / pool forking).

## Verification

Deploy and confirm Sentry issues stop firing on next
`/open-banking/callback` hits.
2026-04-24 18:21:15 +01:00
Víctor Falcón 79075dbcdf
fix(i18n): translate Unknown Income/Expense and other missing ES strings (#331)
## Summary

Fixes untranslated strings surfaced in the Spanish UI (e.g. `Unknown
Income`, `Unknown Expense` on the cashflow breakdown).

## Root cause

`CashflowAnalyticsController` pushed literal English names into the API
response for uncategorized totals, bypassing `__()`. Additional `__()`
calls across the sync flow and validation had no matching keys in
`lang/es.json`. Several frontend toasts/errors were also hardcoded.

## Changes

**Controller (P0)**
- `app/Http/Controllers/Api/CashflowAnalyticsController.php` — wrap
`Unknown Income`/`Unknown Expense` with `__()` (lines 250, 323).

**Translations (P0 + P1 + P2)**
- `lang/es.json` — added 23 keys (1730 → 1753):
  - `Unknown Income`, `Unknown Expense`, `Unknown Bank`, `Unknown error`
- Sync/banking messages: `Invalid credentials…`, `Rate limit exceeded…`,
`Failed to sync with the provider…`, `The provider is experiencing
issues…`, `An unexpected error occurred during sync…`, `Credentials
updated. Sync started.`, `Action required: :provider connection needs
attention`, `:count new transactions synced on Whisper Money`
- Validation: `The selected property cannot be linked.`, `The selected
property is already linked to a loan.`, `The verification link is
invalid.`, `This field is required.`
- Misc: `Confirm your waitlist spot - Whisper Money`, `Log in to your
bank`
- Frontend toasts: `Failed to re-evaluate rules. Please try again.`,
`Failed to load import data`, `All transactions failed to import`,
`Failed to update transactions`, `Failed to update transactions with
labels`

**Frontend hardcoded strings wrapped in `__()` (P2)**
- `resources/js/components/transactions/import-transactions-drawer.tsx`
- `resources/js/components/accounts/import-balances-drawer.tsx`
- `resources/js/lib/sync-manager.ts` (added `@/utils/i18n` import)
- `resources/js/components/transactions/transaction-list.tsx`
- `resources/js/components/transactions/import-transactions-button.tsx`
- `resources/js/pages/transactions/index.tsx`

## Verification

- `vendor/bin/pint --dirty` → pass
- `php artisan test --filter=CashflowAnalytics` → 24 passed
- `php artisan test --filter=Sync` → 166 passed
- `npm run build` → OK
- Post-fix audit: 0 real missing `__()` keys remaining
2026-04-24 17:58:26 +01:00
Víctor Falcón 7028400050
chore: remove release workflow (#330)
Direct pushes to `main` are blocked by branch ruleset, causing the
release workflow to fail. Removing it for now.
2026-04-24 16:49:39 +00:00
Copilot 25e14fa4db
Use Bun in manual release workflow (#327)
Manual `Release` workflow failed before `release-it` ran because it
installed dependencies with `npm ci` while repository dependency state
is maintained with Bun. This change aligns the release job with the
repo’s actual package manager so manual releases can install
dependencies from the committed lockfile.

- **Problem**
- `release.yml` used `npm ci`, which requires `package.json` and
`package-lock.json` to be perfectly in sync.
- Repo changes had moved dependency resolution forward via Bun, causing
the manual release job to fail during install instead of reaching
release creation.

- **Workflow change**
  - Keep Node setup for `release-it`.
  - Add Bun setup in the release job.
  - Replace `npm ci` with `bun install --frozen-lockfile`.

- **Result**
- Manual release job now follows same package manager path as rest of
repo/workflows.
- Dependency installation uses committed Bun lock state, avoiding
`package-lock.json` drift as release blocker.

```yaml
- uses: actions/setup-node@v4
  with:
    node-version: '20'

- uses: oven-sh/setup-bun@v2

- name: Install dependencies
  run: bun install --frozen-lockfile
```

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: victor-falcon <238766+victor-falcon@users.noreply.github.com>
2026-04-24 18:24:45 +02:00
Víctor Falcón 267bdec405
ci: add manual release workflow (#326)
Adds a manually-triggered workflow to cut releases from the GitHub
Actions UI.

## What

New workflow: \`.github/workflows/release.yml\`

- Trigger: \`workflow_dispatch\` with a \`bump\` input (\`patch\` /
\`minor\` / \`major\`, default \`patch\`)
- Runs \`npx release-it <bump> --ci\` → reuses existing
\`.release-it.json\`:
  - angular conventional-changelog preset
  - updates \`CHANGELOG.md\`
  - commits \`chore: release vX.Y.Z\`
  - tags \`vX.Y.Z\`
  - creates GitHub release with grouped Features / Bug Fixes notes

## Auth

Uses the default \`GITHUB_TOKEN\` (no PAT). Requires:

- \`permissions: contents: write\` (push commit/tag, create release)
- Repo setting: Settings → Actions → General → Workflow permissions →
**Read and write permissions**

If \`main\` has branch protection blocking direct pushes, the bot token
must be allowed to bypass, otherwise the push step will fail and a PAT
will be needed instead.

## How to run

Actions tab → Release → Run workflow → pick bump type.
2026-04-24 14:39:26 +01:00
Víctor Falcón 5b1d059e02
feat(loans): backfill historical balances on loan creation (#322)
## Summary

Mirror the real-estate historical-balance pattern for loan accounts.
When a loan is created with a current balance, linearly interpolate
monthly `account_balances` rows between the loan start date
(`original_amount`) and today (current balance).

## Approach

Since we don't know how the user actually paid down the loan, use simple
linear regression between two known points:
- `(start_date, original_amount)` from `loan_details`
- `(today, current_balance)` from the latest `account_balances` row

Rows are placed on the start date, the 1st of each intermediate month,
and today.

## Changes

- **`LoanBalanceGeneratorService`** — linear interpolation + upsert on
`(account_id, balance_date)` so existing rows aren't duplicated.
- **`GenerateHistoricalLoanBalancesJob`** — `ShouldQueue`, 3 tries, 10s
backoff. Takes account, original amount, start date, current balance,
from, to.
- **`Settings/AccountController::store`** (loan branch) — after creating
`loanDetail`, if a starting balance was provided: generate the last 12
months synchronously, dispatch older window to the queue when
`start_date` predates it.
- **`LoanDetailController::update`** — when the detail is first created
for an existing account, use the most recent `AccountBalance` as the
current value and apply the same sync/async split.

## Tests

- Service: interpolation, single-balance edge, future start_date, upsert
dedupe, monthly 1st placement, from/to range.
- Job: implements `ShouldQueue`, respects from/to range.

```
Tests:    8 passed (62 assertions)
```

No dependency or directory-structure changes.
2026-04-24 13:09:34 +01:00