## MCP Phase 3 — OAuth 2.1 for Claude Desktop/web & ChatGPT connectors
Phase 1 shipped a read-only MCP server (#689); Phase 2 added write tools
+ the read/read_write token scope (#690). This phase adds **OAuth 2.1
(Authorization Code + PKCE)** so Anthropic's Claude Desktop/web custom
connectors and OpenAI's ChatGPT connectors can authenticate — those
clients sign in with OAuth rather than pasting a static bearer token, so
until now they only saw a "coming soon" note.
It reuses `laravel/mcp`'s built-in OAuth support (inert until Passport
is installed) wired to `laravel/passport ^13`. We do not hand-write the
authorization server, discovery endpoints, DCR endpoint, or the
`WWW-Authenticate` challenge — the package provides all of it.
### What's in it
- **`laravel/passport ^13`** + an `api` (passport) guard alongside the
existing session `web` guard; Passport migrations (UUID user columns),
config, and signing keys.
- **`Mcp::oauthRoutes()`** — RFC 8414/9728 discovery, RFC 7591 DCR
(`oauth/register`), and the `mcp:use` scope.
- **A second MCP endpoint `POST /mcp/oauth`** guarded by `auth:api`. The
existing Sanctum `/mcp` endpoint (Claude Code static PAT) is left 100%
unchanged.
- **On-brand OAuth consent screen** (Blade, light + dark, localized)
naming the connecting client and its redirect host, and stating plainly
what the connection can do (read/analyse + make changes, bank-connected
data excepted).
- **Settings UI**: the "Claude Desktop & ChatGPT" block now shows real
connect instructions (the `/mcp/oauth` URL to add as a custom connector,
no token needed) instead of "coming soon".
## Decision #1 — OAuth connections have read + write access
`laravel/mcp` advertises and uses a single `mcp:use` scope; it has no
read/write granularity, so there is no per-connection scope choice over
OAuth. **OAuth connections get full read + write access**, gated by the
user explicitly approving the connection on the Whisper Money consent
screen. (An earlier revision made them read-only; that restriction has
been lifted per request.)
`WriteTool` (`app/Mcp/Tools/WriteTool.php`) grants writes when the
request resolves through the `api` (Passport) guard **or** carries a
Sanctum `mcp:write` ability; a read-only Sanctum PAT is still rejected.
Bank-connected accounts and their transactions remain read-only for
every caller (only manual data can be created/edited/deleted; any
transaction can still be categorised/labelled). The consent screen and
settings copy state the read + write capability and the bank-connected
exception.
Possible follow-up: a consent-time read-only/read-write toggle, if
per-connection granularity is wanted (not offered by the standard MCP
OAuth flow's single scope).
## Other locked decisions
- **Route topology**: a separate `/mcp/oauth` endpoint rather than
multi-guarding `/mcp`. Keeps the Claude Code path unchanged (its
`abilities:mcp:read` gate would 403 an OAuth `mcp:use` token) and gives
each client type a clean documented URL. The package's nested discovery
`/.well-known/oauth-protected-resource/mcp/oauth` returns `resource =
url('/mcp/oauth')`.
- **Registration**: ship DCR (`oauth/register`). Redirect allowlist
tightened to `https://claude.ai` and `https://chatgpt.com` only — no
wildcard. CIMD is a possible later enhancement; both clients accept DCR.
## Deviation from the original plan — the User model is untouched
The plan proposed aliasing Passport's `HasApiTokens` trait alongside
Sanctum's (with `insteadof`/`as`) and implementing `OAuthenticatable`.
**Both are impossible here and, it turns out, unnecessary:**
- The two `HasApiTokens` traits declare an **incompatible `$accessToken`
property** (Sanctum untyped vs Passport `?ScopeAuthorizable`), which is
a hard PHP fatal that `insteadof` cannot resolve (it only resolves
methods).
- `OAuthenticatable::tokens(): HasMany` is incompatible with Sanctum's
canonical `tokens(): MorphMany`, and the Claude Code PAT suite depends
on Sanctum's `tokens()`. The interface is never enforced at runtime by
Passport (docblock-only).
- Passport's resource guard only calls `$user->withAccessToken()`, which
Sanctum already provides (untyped, so it accepts the Passport
`AccessToken`); and Passport's `AccessToken::can()` makes
`tokenCan('mcp:write')` behave correctly for OAuth tokens. So Sanctum
stays canonical and the Claude Code PAT path is genuinely unchanged.
## Signing keys (deploy note)
Passport signs OAuth tokens with a key pair. This PR provisions it
everywhere it's needed: CI (`passport:keys` before tests), the
production Docker entrypoint (generates into the persisted `storage/`
volume unless provided via `PASSPORT_PRIVATE_KEY`/`PASSPORT_PUBLIC_KEY`
env), `worktree.sh`, and a documented `.env.example` entry. **For a
multi-instance deployment, set `PASSPORT_*` env** so every instance
validates tokens with the same key.
## Tests (`tests/Feature/Mcp/McpOAuthTest.php`)
Discovery metadata (RFC 9728/8414), the mandatory **401 bootstrap**
challenge + `WWW-Authenticate` header, DCR (allowed + rejected redirect
URIs), the full **Authorization Code + PKCE** flow reaching a read tool,
and **write access over OAuth** (an OAuth connection calling
`create_label` succeeds and the row is created). The existing
`McpTokenTest` / `Mcp/*` suites (incl. the read-only Sanctum PAT
guardrail) and `LocalizationTest` still pass unchanged.
## QA
- **Protocol** (curl, over HTTPS): both discovery endpoints return the
exact required JSON; unauthenticated `POST /mcp/oauth` returns `401` +
`WWW-Authenticate: Bearer …
resource_metadata="…/.well-known/oauth-protected-resource/mcp/oauth"`;
DCR accepts `claude.ai`/`chatgpt.com` callbacks and rejects others with
`400 invalid_redirect_uri`.
- **Browser**: consent screen verified in light and dark mode (client
name, signed-in email, redirect host, read + write capability +
bank-connected read-only note, Cancel/Connect); updated settings page
verified. No JS errors.
- Full PKCE token exchange + a write tool call is covered by the green
Pest e2e test.
## Fast-follows (not in this PR)
- **"Connected apps" revoke UI** — `McpTokenController` manages only
Sanctum PATs today, so there's no in-app revoke for OAuth grants yet.
The consent copy says "disconnect from the connected app" for now; a
Passport-grant list + revoke is the top follow-up (more important now
that OAuth grants can write).
- CIMD registration; optional consent-time read-only/read-write toggle.
## Stacking
Was developed stacked on `mcp-write-tools` (#690), itself on #689.
**Both have since merged to `main`**, so this branch was rebased onto
`main` (`git rebase --onto origin/main mcp-write-tools`) and targets
`main` directly.
## What
Two artisan commands to retire the remaining legacy client-side
encrypted data in production (transactions with
`description_iv`/`notes_iv`, accounts with `name_iv`).
- **`encryption:notify-removal`** — queues a re-engagement/deletion
warning to every user who still has an encrypted transaction or account.
Reuses `SendUpdateEmailJob` (queue `emails`, dedup via `UserMailLog`,
50/day pacing). The email is framed as an **inactivity** cleanup (no
mention of encryption) and pitches recent additions (AI insights, bank
integrations, etc.).
- **`encryption:delete-accounts`** — soft-deletes and anonymizes
(`User::markAsDeleted()`) those users **only** if they have not signed
in within the grace window (`--days`, default 7, via `last_active_at`).
Anyone who came back is spared, and the demo account is always excluded.
Both support `--dry-run` and `--force`, and share the target query via
the `FindsUsersWithLegacyEncryption` concern so the warning and the
deletion always target the same set.
## Intended manual run
`notify-removal --dry-run` → run for real → wait 7 days →
`delete-accounts --dry-run` → run for real.
## Notes / scope
- Deletion is a **soft-delete + email anonymization** (accounts become
unusable); it does not hard-purge rows, and does not cancel Stripe
subscriptions or revoke bank connections (unlike `user:delete`). Legacy
inactive accounts are unlikely to have either; handle separately if
needed.
- Email copy is plain English (matches the existing `mail/updates/*`
convention), no new `__()` keys.
## Tests
`tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php` and
`DeleteEncryptedDataAccountsCommandTest.php` — 6 passing (targeting,
queue, dry-run, grace window, demo exclusion).
## Summary
Follow-up to #623 — addresses security findings **not covered** by the
maintainer's draft PR #627. The original scope was larger; out-of-scope
changes were reverted (see `revert: drop out-of-scope changes from
security round 2`) and are **no longer part of this PR** (details under
_Reverted / not included_).
## Changes
### Rate limiting
- `routes/api.php`: authenticated API group throttled at `300,1` (per
user). An SPA with offline sync + multi-widget dashboards fans out many
requests per interaction, so a tighter cap throttled legitimate bursts.
- `routes/web.php`: unauthenticated open-banking callback throttled at
`30,1` (per IP), enough headroom for browser retries and the iOS PWA →
Safari hand-off that can fire the callback twice.
- `use-decrypt-transactions.ts`: the legacy decrypt migration loops
without backoff (~3 requests per 100 encrypted transactions), so a large
account could hit the API throttle and abort the whole migration
silently. It now honours Laravel's `Retry-After` header on 429 and
retries the same request instead of bailing.
### State token persistence on failure
- `AuthorizationController::callback`: clears `state_token` on the
connection when EnableBanking session creation fails, preventing a stale
token from being replayed.
### Trust proxies hardening
- `bootstrap/app.php`: replaced `trustProxies(at: '*')` with an
env-based `TRUSTED_PROXIES` config. When unset, no proxy is trusted
(Laravel default) instead of trusting every IP.
- ⚠️ **Deploy requirement:** production runs behind a reverse proxy /
TLS terminator (`Dockerfile.production` + nginx serve HTTP on :80). With
`TRUSTED_PROXIES` unset, Laravel stops trusting
`X-Forwarded-For`/`X-Forwarded-Proto` → client IPs collapse to the proxy
IP (contaminating logs and per-IP throttling) and HTTPS detection
breaks. `TRUSTED_PROXIES` **must** be set in the production environment
before merge, and should be added to `.env.production.example`.
### XSS-safe Blade-to-JS injection
- `app.blade.php`: replaced `{{ }}` Blade syntax inside `<script>` with
`@json()` to prevent JS injection via variable content.
### TOCTOU defense-in-depth
- `Api/TransactionController::bulkUpdate`: added `->where('user_id',
$userId)` to the per-row UPDATE as belt-and-suspenders behind the
existing `abort(403)` ownership pre-check.
## Reverted / not included
The following were in the original description but were reverted and are
**not** in the current diff:
- `block-demo` middleware on additional route groups (already present
upstream on `routes/settings.php`).
- `->limit(500)` / `has_more` / `since` validation on
`TransactionSyncController`.
## Notes
- `vite.config.ts` appears in the diff only because of an older
merge-base; it is identical to `main` and nets to no change (will vanish
on rebase).
- No tests yet for the `state_token` clearing on `createSession` failure
or for the throttles — worth adding before merge (the callback throttle
is the most exposed surface).
---------
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
## What
Adds a lifecycle email sent **2 days after a user records AI consent**,
but only when that consent was given **outside of onboarding** — i.e.
more than 3 days after sign-up. This targets users who deliberately
opted into AI later (e.g. via the billing toggle / transactions banner
gated by the `AiConsentSettings` flag), not those who consented during
initial setup.
## How
- **`email:ai-consent-follow-up`** scheduled command (daily, 10:15
Europe/Madrid) selects active consents accepted exactly 2 days ago,
skips onboarding-era ones (`accepted_at <= created_at + 3 days`), and
queues a job per user.
- **`SendAiConsentFollowUpEmailJob`** sends the mailable and records a
`UserMailLog`. Dedup, locale and the queued-email plumbing all reuse the
existing drip system — **no migration**.
- Email is localised automatically via the user's `HasLocalePreference`,
like every other drip email.
## Design notes
- **Scheduled command, not a 2-day delayed job** — survives worker
restarts and respects consent revoked in the meantime.
- **Exact "2 days ago" window**, matching the existing paywall drip. If
the scheduler misses a day that cohort isn't retried; kept consistent
with the other drips on purpose.
- Copy is a first draft — open to wording tweaks.
## Tests
`tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php` — 9 tests
covering the happy path, onboarding exclusion (incl. the exact-3-days
boundary), timing window, revoked consent, the disabled-drip gate,
single-send + mail log, no-resend, and locale rendering. All green, Pint
clean.
## What
Sends an automated follow-up email the day after a user finishes
onboarding but stays stuck on the paywall, asking why they didn't
continue and inviting a direct reply.
## Why
Users who connect a bank account during onboarding but never pick a plan
are permanently redirected to the paywall by `EnsureUserIsSubscribed` —
they finish onboarding and then can't access the app. This reaches out
to understand the friction and recover them.
## Who gets the email (the genuinely stuck cohort)
- `onboarded_at` is calendar-yesterday (`whereDate('onboarded_at',
today()->subDay())`)
- Has an active banking connection (`bankingConnections()->exists()`)
- No Pro plan (`hasProPlan()` is false)
- Hasn't already received this email (idempotent via `UserMailLog`)
Users **without** a bank connection are excluded: they fall through to
free access after seeing the paywall once, so they aren't stuck.
## How
- New `email:paywall-follow-up` command scans the cohort daily and
queues the job — scheduled `dailyAt('10:00')` Europe/Madrid in
`routes/console.php`.
- New `DripEmailType::PaywallFollowUp` (`paywall_follow_up`) +
idempotent send job following the existing drip pattern.
- Short, human Markdown email with an explicit `replyTo`
(`hi@whisper.money`) so replies reach a real inbox.
- Spanish copy added to `lang/es.json`.
## Tests
10 new tests (correct cohort matched; wrong day / no bank / Pro plan
excluded; no double-send; no-op when drip disabled). `pint` clean.
## Note
There was no existing user-scanning drip command (current drips dispatch
with `delay()` on `Registered`). Since "stuck on paywall" state isn't
known at registration time, a daily scanning command is the right fit.
## Summary
Adds a **Notifications** section to the account settings page
(`/settings/account`, between Profile information and Update password)
where users can opt out of the daily "new transactions synced" email.
- New per-user preference `notify_on_bank_transactions_synced` on
`user_settings` (defaults to `true`, opt-out).
- `SendDailyBankTransactionsSyncedEmailJob` skips users who disabled it.
- Single generic `PATCH /settings/notifications` endpoint updates any
notification type via a key→column allowlist in
`NotificationPreferenceController::PREFERENCES`. Future notifications
only need a new entry there — no new route/controller.
- Email footer now links back to the settings section so users can
manage preferences.
## Testing
- `tests/Feature/Settings/NotificationPreferenceTest.php` — update,
unknown-key rejection, invalid value, auth, create-when-missing,
default-true, inertia prop.
- `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php` — email
not sent when disabled.
Swap Discord invite link `discord.gg/2WZmDW9QZ8` →
`discord.gg/m8hUhx6D9D` across all files (README, header, user menu,
connections page, welcome email, test).
## Sentry issue
- https://whisper-money.sentry.io/issues/PHP-LARAVEL-1S
## Root cause
- Browser/page translation mutated React-managed DOM on /onboarding by
injecting font wrappers. React later attempted to unmount a node that
translation had moved, causing NotFoundError removeChild.
## Fix
- Mark the Inertia root document as not translatable with
translate="no", notranslate class, and google notranslate meta tag.
- Add regression coverage for the root template markers.
## Verification
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## 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
## 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.
## Summary
- add month-end command to revoke old Enable Banking sessions for users
without a paid plan while keeping their linked accounts as manual
accounts
- extract banking disconnect flow into a reusable action so manual
disconnects and scheduled cleanup share same revoke and detach behavior
- add feature coverage for free-user cleanup, paid-user skips,
under-6-hour skips, non-Enable Banking skips, and revoke failure
fallback
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php
tests/Feature/OpenBanking/ConnectionControllerTest.php
## Summary
- gate waitlist signup behind email confirmation so only verified leads
receive a queue position and referral code
- add signed lead verification routes, a check-email page, and a
dedicated verification email for the waitlist flow
- update lead syncing and feature tests so only verified leads are
exported to Resend and referral movement happens after verification
## Testing
- php artisan test --compact tests/Feature/UserLeadTest.php
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
- php artisan test --compact tests/Feature/MailSenderTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- switch the iOS standalone PWA status bar style from
`black-translucent` to `default`
- keep installed iOS app content below the status bar and notch area
- extend the PWA template test to guard against regressing to
translucent overlap
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/PwaTest.php
## Summary
- update mail signatures to show Álvaro before Víctor across the Blade
mail templates
- add a focused mail test that guards the signature order so the old
ordering does not reappear
- verified with `php artisan test --compact
tests/Feature/MailSenderTest.php`
## Summary
- add a new `banks:check-logos` console command that validates all
non-null bank logo URLs weekly
- set broken/invalid bank logos to `null` and send an admin report email
to `ADMIN_EMAIL` when updates occur
- add weekly scheduling, admin mail config wiring, and feature tests for
valid/broken/head-fallback flows
## Testing
- vendor/bin/pint --dirty
- php artisan test tests/Feature/Console/CheckBankLogosCommandTest.php
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
- **Dynamic Stripe price resolution**: Replaces hardcoded
`stripe_price_id` env vars with lookup-key-based resolution
(`stripe_lookup_key`). A new `php artisan stripe:sync-prices` command
creates/updates Stripe prices from `config/subscriptions.php`
automatically.
- **Locale-aware currency formatting**: Replaces all
`getCurrencySymbol() + toFixed(2)` patterns with `formatCurrency()`
(backed by `Intl.NumberFormat`) across `welcome.tsx`, `paywall.tsx`,
`billing.tsx`, and `step-create-account.tsx`, so symbol position and
separators are correct for the user's locale (e.g. `3,90 €` in Spanish).
- **EUR defaults and updated plan prices**: Cashier currency defaulted
to EUR, plan prices updated to €7.80/month and €46.80/year, and
`pricing.currency` is now shared as an Inertia prop.
- **Promo/discount cleanup**: Removed all FOUNDER discount mentions and
Discord community links from the paywall, landing pricing section, and
invitation email.
## Summary
- Adds an inline email capture form on the landing page when
`HIDE_AUTH_BUTTONS=true`, replacing the previous CTA buttons
- Each signup gets a queue position (starting at #500), a unique
referral link, and a welcome email from `victor@whisper.money`
- Referring 10 people via the personal link moves the referrer 10
positions forward in the queue (floor: 1), triggering a notification
email
- Full Spanish localisation for all UI strings and email copy; `locale`
is stored on each lead so emails are sent in the lead's own language
- 14 feature tests covering all waitlist behaviour (positions,
referrals, emails, thank-you page)
## How to activate
Set `HIDE_AUTH_BUTTONS=true` in `.env`. The waitlist form and thank-you
page are completely dormant otherwise.
## 🚪 Why?
The verify email template included a personal introduction ("Hi! I'm
Victor, the founder of Whisper Money.") which is redundant and overly
informal for a transactional email. Removing it makes the email more
concise and professional.
## 🔑 What?
- Removed the personal introduction line from the verify email Blade
template
- Updated the corresponding Spanish translation key to match the new
string
## Why
### Problem
Two iOS-specific UX issues reported by users:
1. **App icon invisible on light wallpapers** — the home screen shortcut
icon was a black logo on a white background, making it nearly invisible
against light iOS wallpapers (appeared as a faint frosted outline).
2. **Country dropdown overflows viewport** — the country picker in the
Connect Account flow exceeded the mobile viewport height, making it
impossible to scroll through the full list on small screens.
### Root Cause
1. All icon PNGs were exported with a white background and a black/grey
logo. The PWA manifest `background_color` was also `#ffffff`. No dark
background was baked in.
2. `SelectContent` used a fixed `max-h-96` (384px) regardless of
available screen space. The `SelectPrimitive.Viewport` height was bound
to `--radix-select-trigger-height` (the trigger element's height)
instead of the actual available viewport space, preventing Radix's
internal scroll buttons from working correctly on mobile.
## What
### Changes
- Regenerated all icon PNGs (favicon, apple-touch-icon,
web-app-manifest, whispermoney_icon_x*) — white logo on `#1b1b18` dark
background via ImageMagick Screen composite
- Updated `site.webmanifest` `background_color` from `#ffffff` →
`#1b1b18`
- Fixed `SelectContent` max height: `max-h-96` →
`max-h-[min(24rem,var(--radix-select-content-available-height))]`
- Fixed `SelectPrimitive.Viewport` height:
`--radix-select-trigger-height` →
`--radix-select-content-available-height`
## Verification
### Tests
No automated tests cover static asset generation or CSS utility values —
these are visual/rendering fixes.
### Manual
- iOS: Remove and re-add the home screen shortcut from Safari to pick up
the new `apple-touch-icon.png`. Icon should show a white bird logo on a
dark background, visible on any wallpaper.
- Mobile (iOS/Android): Open Connect Account dialog and verify the
country dropdown fits within the viewport and is fully scrollable.
## 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
## Why
### Problem
When API tokens for Indexa Capital, Binance, or Bitpanda expire or are
revoked, syncs fail silently with 401/403 errors. Users have no way to
replace their credentials without disconnecting and recreating the
entire connection (losing account mappings and history).
## What
### Changes
- **Auth failure email notification**: on the final retry attempt (3rd
of 3), if a sync job fails with 401/403 for an API-key provider, an
email is sent to the user with a link to the connections settings page
- **Update credentials endpoint**: `PATCH
/settings/connections/{connection}/credentials` validates new
credentials against the provider API before saving, then triggers a sync
- **Update credentials dialog**: provider-specific form (API token for
Indexa/Bitpanda, API key + secret for Binance) shown via an "Update
Credentials" button in the error banner and dropdown menu
- **Mailable**: `BankingConnectionAuthFailedEmail` follows existing
patterns (queued, rate-limited, Markdown template)
- **Form request**: `UpdateConnectionCredentialsRequest` with dynamic
validation rules per provider and authorization check
### Files changed
| File | Change |
|------|--------|
| `app/Jobs/SyncBankingConnectionJob.php` | Send auth failed email on
final attempt for auth errors on API-key providers |
| `app/Mail/BankingConnectionAuthFailedEmail.php` | New queued mailable
|
| `resources/views/mail/banking-connection-auth-failed.blade.php` |
Email template |
| `app/Http/Controllers/OpenBanking/ConnectionController.php` |
`updateCredentials()` action with provider credential validation |
| `app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php`
| Dynamic validation per provider |
| `routes/settings.php` | PATCH route for credential updates |
| `resources/js/components/open-banking/update-credentials-dialog.tsx` |
Dialog with provider-specific fields |
| `resources/js/pages/settings/connections.tsx` | Update Credentials
button in error state and dropdown |
## Verification
### Tests
- **12 new tests** across 2 test files, all passing:
- `SyncBankingConnectionJobTest`: 5 tests covering email sent on final
retry (Indexa 401, Binance 403), not sent before final retry, not sent
for non-auth errors, not sent for EnableBanking
- `ConnectionControllerTest`: 7 tests covering valid credential update
for each provider, invalid credentials, EnableBanking rejection,
authorization, feature flag, required field validation
- Full OpenBanking test suite: **145 tests, 473 assertions** passing
## Summary
- Send a queued email notification to users after subsequent bank syncs
when new transactions are found
- Email includes a per-bank breakdown of imported transaction counts
with a link to the transactions page
- Skips notification on first sync and when zero new transactions are
created
## Test plan
- [x] Sends email when new transactions are synced on subsequent sync
- [x] Does not send email on first sync
- [x] Does not send email when zero new transactions
- [x] Aggregates multiple accounts under same bank
- [x] Lists different banks separately in email
- [x] Existing sync job tests still pass
## Summary
- Enables `MustVerifyEmail` on the `User` model so new users must verify
their email before accessing protected routes
- Unverified users are redirected to `/email/verify` when attempting to
access routes behind the `verified` middleware (subscribe, onboarding,
dashboard)
- A verification email is automatically sent on registration via Fortify
## Screenshot

## Video
https://github.com/whisper-money/whisper-money/raw/mail-verification/storage/videos/ac0945c527f014b9cd657f18c911f496.webm
## Test plan
- [x] New test: registration sends a `VerifyEmail` notification
- [x] New test: newly registered users have `email_verified_at` as null
- [x] New test: unverified users are redirected to `verification.notice`
from protected routes (subscribe, onboarding, dashboard)
- [x] New test: verified users are not redirected to verification notice
- [x] Existing email verification and notification tests still pass (31
auth tests, 52 settings tests)
- [x] Browser walkthrough: registration → redirected to `/email/verify`
page with "Resend verification email" button
## Summary
- Add service worker registration and `viewport-fit=cover` for proper
iOS standalone mode (no Safari chrome)
- Change manifest `start_url` to `/dashboard` so PWA users skip the
landing page
- Add tests for PWA configuration
## Test plan
- [x] Deploy and re-add PWA to iOS home screen — should open as
standalone (no address bar)
- [x] PWA launch should go to `/dashboard` instead of the landing page
- [x] Non-authenticated PWA users should be redirected to login
Closes#71
## Overview
This PR adds a flexible system for sending update emails to all Whisper
Money users. As the solo developer, you can now easily communicate
product updates, announcements, and news to your growing user base.
## Problem Solved
- **No easy way to send announcements**: Previously, there was no simple
way to broadcast important updates to all users
- **Manual process**: Would require writing custom scripts each time
- **No duplicate prevention**: Risk of accidentally sending the same
email multiple times
- **Version control**: Email content wasn't tracked in git
## Solution
A complete email system that lets you:
1. Write update emails as markdown templates (version controlled)
2. Send them with a single command
3. Automatic duplicate prevention (users only receive each update once)
4. Track who received what in the database
## How It Works
### 1. Create Email Template
Create a markdown file in `resources/views/mail/updates/`:
```blade
<x-mail::message>
# What's New in January 2026
Hi {{ $user->name }},
Your update content here...
<x-mail::button :url="'https://discord.gg/9UQWZECDDv'">
Join the Discord Community
</x-mail::button>
Víctor Falcón Ruíz
Founder & Solo Developer, Whisper Money
</x-mail::message>
```
### 2. Send to All Users
```bash
# Simple - one command
php artisan email:update first-update-jan-2026
# With options
php artisan email:update first-update-jan-2026 --subject="Personal Thank You" --exclude-demo
```
### 3. Automatic Tracking
- Users only receive each update once (tracked by identifier)
- Can send different updates to same users
- View history in `user_mail_logs` table
## First Email Included
The PR includes the first update email (`first-update-jan-2026`)
announcing:
- 240+ GitHub stars milestone
- First paying users
- Discord community invitation
- Canny roadmap links
- Personal message from Víctor as the solo developer
## Technical Details
### What's New
**Database:**
- Migration: Add `email_identifier` column to `user_mail_logs`
- Migration: Update unique constraint to `(user_id, email_type,
email_identifier)`
**Backend:**
- `DripEmailType` enum: Added `Update` case
- `UpdateEmail` mailable: Generic mailable accepting any view
- `SendUpdateEmailJob`: Handles sending + duplicate prevention
- `SendUpdateEmailCommand`: Artisan command with progress bar
- Updated all 6 drip email jobs to support new constraint
**Frontend:**
- Email template directory: `resources/views/mail/updates/`
- Comprehensive README with examples
**Tests:**
- Complete test suite: 11 tests covering all scenarios
- Tests duplicate prevention, filtering, queue behavior
### Command Options
```bash
php artisan email:update <view> [identifier] [options]
Arguments:
view Template name (e.g., "jan-2026-updates")
identifier Tracking ID (defaults to view name)
Options:
--subject= Custom email subject
--exclude-demo Skip demo account
--force Skip confirmation prompt
```
## Benefits
1. **Simple**: One command to send any update email
2. **Flexible**: Just create a new markdown view, no code changes needed
3. **Safe**: Confirmation prompt and duplicate prevention
4. **Fast**: Queued delivery, non-blocking
5. **Tracked**: Full audit trail in UserMailLog
6. **Consistent**: Follows existing drip email patterns
7. **Reusable**: Same command for all future update emails
8. **Version Controlled**: Email content is in git, reviewable
## Migration Path
To use in production:
```bash
# 1. Run migrations
php artisan migrate
# 2. Create your email template
vim resources/views/mail/updates/your-update.blade.php
# 3. Commit and deploy
git add resources/views/mail/updates/your-update.blade.php
git commit -m "Add update email"
git push
# 4. Send on production
php artisan email:update your-update
```
## Example Use Cases
- Product announcements
- New feature launches
- Community updates
- Milestone celebrations
- Important notifications
- Partnership announcements
## Files Changed
### Created
- `database/migrations/*_add_email_identifier_to_user_mail_logs.php`
- `database/migrations/*_update_user_mail_logs_unique_constraint.php`
- `app/Mail/UpdateEmail.php`
- `app/Jobs/SendUpdateEmailJob.php`
- `app/Console/Commands/SendUpdateEmailCommand.php`
- `resources/views/mail/updates/first-update-jan-2026.blade.php`
- `resources/views/mail/updates/README.md`
- `tests/Feature/Console/SendUpdateEmailCommandTest.php`
### Modified
- `app/Enums/DripEmailType.php` (added Update case)
- `app/Models/UserMailLog.php` (added email_identifier field)
- All 6 drip email jobs (added email_identifier support)
## Testing
All tests passing (11 tests, 27 assertions):
- ✅ Command dispatches jobs for all users
- ✅ Command excludes demo account when flag is set
- ✅ Command fails when view does not exist
- ✅ Command uses custom subject when provided
- ✅ Job is dispatched to emails queue
- ✅ Command handles empty user database gracefully
- ✅ Job skips users who already received the update
- ✅ Job creates mail log entry after sending
- ✅ Job sends email with correct view and user data
- ✅ Command requires confirmation by default
- ✅ Command skips confirmation with force flag
## Summary
- Add a subscription cancellation email that can be manually triggered
via artisan command
- Include CONTINUE50 coupon code (50% off current and future payments
for monthly/yearly subscriptions)
- Add option to disable all drip emails via `DRIP_EMAILS_ENABLED` env
var
## Usage
```bash
# Send to single user
php artisan email:subscription-cancelled user@example.com
# Send to multiple users
php artisan email:subscription-cancelled user1@example.com,user2@example.com
# Disable all drip emails
DRIP_EMAILS_ENABLED=false
```
## Summary
- Implement event-driven drip email campaign system for new user signups
- Add 5 targeted emails based on user journey (welcome, onboarding
reminder, promo code, import help, feedback)
- Configure Laravel Horizon with Redis queue for reliable job processing
- Track sent emails in `user_mail_logs` table to prevent duplicates
## Email Schedule
| Email | Timing | Condition |
|-------|--------|-----------|
| Welcome | 30 min after signup | All users |
| Onboarding Reminder | 1 day after signup | Not onboarded |
| Promo Code (FOUNDER) | 1 day after signup | Onboarded + has
transactions + not subscribed |
| Import Help | 1 day after signup | Onboarded + no transactions + not
subscribed |
| Feedback | 5 days after signup | Not subscribed |
## Architecture
```
User Registers → ScheduleDripEmailsListener
├─> SendWelcomeEmailJob (30 min delay)
├─> SendOnboardingReminderEmailJob (1 day delay)
├─> SendPromoCodeEmailJob (1 day delay)
├─> SendImportHelpEmailJob (1 day delay)
└─> SendFeedbackEmailJob (5 days delay)
```
Each job checks conditions at execution time and either sends the email
+ logs it, or silently completes.
## Test plan
- [x] All 23 drip email tests pass
- [x] Migration creates `user_mail_logs` table
- [x] Jobs dispatch with correct delays
- [x] Emails only sent when conditions are met
- [x] Duplicate emails prevented via UserMailLog check
- [ ] Verify Horizon processes jobs in production
## Changes
- Updated `site.webmanifest` with proper PWA configuration:
- Added all maskable icons (48, 72, 96, 128, 192, 384, 512)
- Set theme_color and background_color for native feel
- Added start_url, scope, id, description, and categories
- Enhanced iOS/Safari support in `app.blade.php`:
- Added `apple-mobile-web-app-capable` for fullscreen mode
- Added `apple-mobile-web-app-status-bar-style: black-translucent` for
integrated status bar
- Added `mobile-web-app-capable` for Android
The dynamic theme-color meta tags (`#ffffff` for light, `#383838` for
dark) match the app's background colors, ensuring the status bar blends
seamlessly with the app.