## Problem
On mobile, starting an OAuth connection from ChatGPT (MCP connector)
opens the **installed Whisper Money PWA** and lands on the **dashboard**
instead of the OAuth consent screen — so the connection can never be
approved.
## Cause
The web app manifest declares `scope: "/"`, so the installed PWA claims
the entire origin, including `/oauth/authorize`. On Android the OAuth
link gets captured into the app, and the default launch behavior just
**focuses the already-open window (the dashboard) and drops the incoming
URL** — the consent page never loads.
Narrowing `scope` to exclude `/oauth` isn't viable: the app's routes are
flat (`/dashboard`, `/transactions`, `/accounts`, …) and share no
prefix, so a narrower scope would push half the app out to the browser.
## Fix
- `site.webmanifest`: add `launch_handler.client_mode: "focus-existing"`
so the launcher hands the app the captured target URL via the launch
queue.
- `app.tsx`: a `launchQueue` consumer that navigates to the target URL
when it's an `/oauth/` path. Every other launch is a no-op (keeps its
place).
Low blast radius — additive manifest field + a guard scoped to `/oauth/`
paths; existing (desktop) OAuth is unaffected.
## Verification
Cannot be tested off-device. After deploy, on the phone:
1. **Reinstall the PWA** (remove from home screen, re-add) so Android
re-fetches the manifest.
2. Retry the ChatGPT OAuth connect → should land on the "Connect ChatGPT
to Whisper Money" consent screen.
Depends on `launch_handler`/`launchQueue` (Chrome/Edge 102+, covers
nearly all Android).
## Why
Feedback from onboarding users: in the categorizer step people often
**don't realize they need to categorize at least 5 transactions** before
*Continue* unlocks, and can't tell why the button is disabled. Worse,
the old counter switched to **"N remaining"** after 5 (showing *every*
uncategorized transaction, e.g. "195 remaining"), which made several
users think they had to categorize **all** of them.
## What changed
### Categorizer clarity (`step-categorize-transactions.tsx`)
- **New full-width progress banner** above the transaction card,
replacing the cramped corner counter that was getting truncated on
mobile:
- explicit instruction: *"To continue, you need to categorize at least 5
transactions."* (reuses the existing intro string)
- a **5-segment progress bar** that fills as you go
- the live **X/5** count
- a reassurance line: *"You do not need to categorize all of them."*
- When the minimum is reached (or the list is exhausted), the banner
turns **green** — *"Done! You can continue now, or keep categorizing if
you want."* — and the **Continue** button gets a ring so it's obvious
the step is unlocked.
- **Removed the misleading "N remaining"** display.
### Onboarding layout & toasts
- **Mobile top spacing** (`onboarding-layout.tsx`): reduced the large
top padding on the step content on mobile so it sits closer to the
progress dots (unchanged on desktop).
- **Toasts during onboarding** (`app.tsx`): onboarding has no bottom
navigation bar, so toasts now render **bottom-center, flush to the
bottom** instead of being lifted 110px to clear the (absent) mobile tab
bar. Behavior elsewhere in the app is unchanged.
Two new strings added to `lang/es.json`.
## Notes
- The `canContinue` / `minimumRequired` gating logic is unchanged.
- Verified: `lint` clean, `es.json` valid, `LocalizationTest` passes,
full CI green.
- Recorded mobile + desktop walkthroughs locally.
## What
Adds an `isBrowserExtensionNoise` predicate to the frontend Sentry
`beforeSend` chain that drops errors originating from injected
browser-extension scripts.
## Why
Triaging Sentry surfaced recurring, unactionable issues caused entirely
by users' browser extensions, not our code:
- **PHP-LARAVEL-21** — `i: Failed to connect to MetaMask`
(`chrome-extension://…/inpage.js`)
- **PHP-LARAVEL-3M** — `Cannot read properties of undefined (reading
'sendMessage')` (`chrome-extension://…/injectedScript.bundle.js`)
These recur for every user who has the offending extension installed, so
resolving-and-waiting just re-pages us forever. This follows the
existing pattern of named noise predicates with vitest coverage
(`isChunkLoadErrorEvent`, `isSafariCashbackExtensionNoise`, …).
## How
The predicate inspects the **crashing (innermost) frame** of each
exception and drops the event only when that frame's URL uses a
browser-extension scheme (`chrome-extension://`, `moz-extension://`,
`safari-web-extension://`, `safari-extension://`,
`ms-browser-extension://`).
Matching only the crashing frame — not *any* frame — avoids
over-filtering legitimate app errors that merely pass through an
extension-wrapped handler.
### Not filtered on purpose
`AxiosError: Network Error` (PHP-LARAVEL-28 / -38) is left untouched: it
has no extension frames and a full backend outage produces the same
error, so we want it to keep alerting.
## Tests
`resources/js/lib/sentry.test.ts` — 4 new cases (drops the two real
extension shapes; keeps the network error and an app error where the
extension frame is not the crash site). `vitest run` green (12/12).
## What
Gate Sentry error reporting on the production environment so nothing is
sent from local, staging, or testing.
- **Backend** (`config/sentry.php`): DSN resolves to `null` unless
`APP_ENV=production`. A null DSN disables the Laravel SDK entirely.
- **Frontend** (`resources/js/app.tsx`): `enabled` now requires
`import.meta.env.MODE === 'production'` (plus a DSN).
## Summary
- keep Stripe past_due subscriptions active during retry window
- share payment issue state with Inertia and show persistent toast
- send toast action directly to Stripe Billing Portal
- allow canceled users to fall back to free plan, while paid-only bank
connection access remains blocked
- add Spanish translations for new payment issue messages
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SubscriptionTest.php
tests/Feature/InertiaSharedDataTest.php
- php artisan test --compact tests/Feature/LocalizationTest.php
- npm test -- subscription-payment-issue-toast
Note: npm run types still fails on pre-existing unrelated TypeScript
errors.
## 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)*
## Summary
- store browser-detected IANA timezones on users during registration and
share the value to the frontend
- silently backfill missing timezones for authenticated users without
overwriting existing saved values
- add focused feature coverage for registration, shared props, and the
timezone backfill endpoint
## Testing
- php artisan test --compact tests/Feature/Auth/RegistrationTest.php
tests/Feature/InertiaSharedDataTest.php
tests/Feature/Settings/TimezoneTest.php
- npm test -- resources/js/utils/currency.test.ts
## Summary
- Adds the missing `:8000` port to the frontend Sentry/Bugsink DSN in
`app.tsx`, so errors are sent to the correct endpoint at
`bugsink.whisper.money:8000`.
## Summary
- Add a new **Chart color scheme** dropdown in Settings > Appearance
allowing users to choose between 4 color palettes: **Colorful** (new
default), **Neutral** (previous zinc grayscale), **Blue**, and **Pink**
- Persist the setting in a new `user_settings` table with instant
client-side feedback via localStorage + cookie
- All charts across dashboard, categories, budgets, and cashflow update
instantly when switching schemes
- Reduced colorful palette intensity (shifted from 500/600 to 300/400
range) for lower contrast
## Summary
- Add `GET /api/transactions?encrypted=true` endpoint for paginated
listing of encrypted transactions
- Add `PATCH /api/transactions/bulk` endpoint for batch-updating
decrypted transaction data (max 50 per request, bypasses model events)
- Add `useDecryptTransactions` hook that mirrors the existing account
name decryption flow: fetches encrypted transactions page-by-page,
decrypts `description`/`notes` client-side via Web Crypto API, sends
plaintext back and clears IVs
- Wire the hook into `AppSidebarLayout` so decryption runs automatically
when the user unlocks their encryption key
- Once all transactions (and accounts) are decrypted, the encryption key
button in the header disappears automatically
## Test plan
- [x] 11 Pest tests covering encrypted/plaintext filtering, pagination,
user scoping, bulk update, authorization, validation, nullable notes,
guest access, and no-model-events behavior
- [x] Manual: create encrypted transactions, unlock encryption key,
verify transactions get decrypted and the key button disappears
## Summary
- Removes the `plaintext-transactions` Pennant feature flag — plaintext
is now the default for all users
- Removes encryption guards and `isPlaintext` conditionals from
transaction create/edit/import flows, keeping only the plaintext code
paths
- Makes `EncryptionKeyButton` conditional — only shown when user has
legacy encrypted accounts or transactions
- Removes encryption onboarding steps (`step-encryption-explained`,
`step-encryption-setup`) and related state/props
- Updates landing page to replace E2E encryption marketing with
privacy-first messaging ("Your Data, Your Rules" section)
- Updates privacy policy to replace E2E encryption claims with accurate
security language (encryption at rest, TLS in transit, no third-party
sharing)
- Cleans up tests to remove feature flag assertions and
`Feature::activate()` calls
**22 files changed, 145 insertions, 730 deletions**
## Test plan
- [x] Create a transaction without encryption key unlocked — should
succeed
- [x] EncryptionKeyButton should not appear in header for users with no
encrypted data
- [x] EncryptionKeyButton should appear for users with legacy encrypted
transactions/accounts
- [x] Landing page has no E2E encryption references, shows new privacy
section
- [x] Onboarding flow has no encryption setup steps
- [x] Privacy policy reflects accurate security language
- [x] Frontend builds successfully (`bun run build`)
- [x] All linting passes (`bun run lint`, `vendor/bin/pint --dirty`)
## Summary
This PR implements a global privacy mode feature that allows users to
hide all monetary amounts throughout the application.
## Changes
- **Created PrivacyModeContext**: New context provider with localStorage
persistence for privacy mode state
- **Created AmountDisplay component**: Centralized component for
displaying monetary amounts with blur effect when privacy mode is
enabled
- **Updated app logo logic**: Logo now shows birdhouse icon when privacy
mode is enabled or encryption key is not set
- **Added privacy mode toggle**: New toggle in user menu to
enable/disable privacy mode
- **Replaced inline amount formatting**: All monetary amounts now use
the AmountDisplay component
- **Updated trend indicators**: AmountTrendIndicator and
PercentageTrendIndicator now use AmountDisplay
- **Added animated transitions**: Smooth transitions when toggling
privacy mode
## Features
- Privacy mode state persists across sessions (localStorage)
- Amounts display as `$•••.••` with blur effect when privacy mode is
enabled
- Animated transitions (300ms) when toggling privacy mode
- App logo changes to birdhouse when privacy mode is on
- Toggle accessible from user menu at the top
## Testing
- Tested privacy mode toggle functionality
- Verified amounts are hidden/shown correctly across all pages
- Confirmed logo animation works properly
- Verified localStorage persistence
## Screenshots
Privacy mode can be toggled from the user menu, and all amounts
throughout the application will be hidden with a blur effect.
- Add user-session-storage utility to track and manage user IDs in localStorage
- Update SyncProvider to detect user changes and clear IndexedDB/localStorage when switching users
- Pass initial user from app.tsx to SyncProvider for proper initialization
- Reload page after clearing data to ensure clean state for new user