## 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
- When a user cancels the bank authorization flow (e.g. clicks cancel on
the bank's page), the pending `BankingConnection` is now soft-deleted so
it doesn't remain stuck in `pending` status
- Flash messages (`success`/`error`) are now shared with the frontend
via Inertia shared data
- The connections settings page shows a toast notification with the
error message on redirect
## Test plan
- [ ] Start a bank connection flow, cancel on the bank's authorization
page, and verify:
- A toast error appears on the connections page
- The pending connection is removed from the list
- [x] Complete a successful bank connection and verify the success toast
appears
- [x] Run `php artisan test
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
## Summary
- Replaces the simple confirmation dialog for bulk transaction deletion
with a type-to-confirm modal requiring users to type `delete X
transactions` (where X is the count) before the delete button enables
- Removes the admin-only restriction on bulk delete — now available to
all users
- Single-transaction delete keeps the existing simple AlertDialog
## Summary
- Remove the `budgets` Pennant feature flag — budgets is now enabled for
all users
- Delete `EnsureBudgetsFeature` middleware and its route guard
- Remove `budgets` from shared Inertia features and the `Features`
TypeScript interface
- Remove all `Feature::for($user)->activate('budgets')` calls from tests
- Delete `BudgetFeatureFlagTest` and feature-disabled test cases from
`BudgetsFeatureNavigationTest`
## Summary
- Adds **EnableBanking** as the first open banking provider, allowing
users to connect real bank accounts and automatically sync transactions
and balances
- Uses a **`BankingProviderInterface`** contract so future providers
(Plaid, GoCardless, etc.) can be added by implementing the same
interface
- Feature-flagged behind the **`open-banking`** Pennant flag (default:
off)
- Connected accounts are **unencrypted** and transactions have `source =
'enablebanking'`
### What's included
**Backend:**
- `BankingProviderInterface` contract + `EnableBankingProvider`
implementation (JWT RS256 auth)
- `BankingConnection` model with full lifecycle (pending →
awaiting_mapping → active → expired/revoked/error)
- `TransactionSyncService` — pagination, deduplication by
`external_transaction_id`, amount/date mapping
- `BalanceSyncService` — preferred balance type selection (CLBD → ITAV
fallback)
- Authorization flow: start auth → bank redirect → callback → session
creation → account mapping → sync
- `SyncBankingConnectionJob` (unique per connection, 3 retries) +
scheduled every 6 hours
- `banking:sync` artisan command
- 5 migrations: `banking_connections` table, account fields, transaction
`external_transaction_id`, `pending_accounts_data`, `linked_at`
**Frontend:**
- Manual vs Connected account choice in the create account dialog
- Multi-step bank connection dialog (country → bank selection →
confirmation → redirect)
- Account mapping page — map discovered bank accounts to existing
accounts, create new ones, or skip
- Settings/Connections page with status badges, sync/disconnect actions
- "Connected" badge on linked accounts in settings
**Tests:**
- 49 tests covering feature flags, controllers, account mapping,
transaction sync, balance sync, deduplication, and pagination
### Feature Flags
This PR introduces **two Pennant feature flags**:
1. **`open-banking`** — Gates the entire open banking feature
(institutions endpoint, authorization flow, connections page). When
disabled, all open banking routes return 404.
2. **`account-mapping`** — Controls whether users see an intermediate
account mapping step after connecting a bank. When **enabled**, users
are redirected to a mapping page where they can choose to create new
accounts, link to existing ones, or skip each discovered bank account.
When **disabled**, all discovered accounts are automatically created
(original behavior). Linked accounts only sync transactions from their
last transaction date and only update the current balance from the
provider (no historical balance calculation or daily balance tracking).
Enable per-user:
```bash
php artisan feature:enable open-banking user@example.com
php artisan feature:enable account-mapping user@example.com
```
Enable for all users:
```bash
php artisan feature:enable open-banking all
php artisan feature:enable account-mapping all
```
## Test plan
- [x] Enable feature flag: `php artisan feature:enable open-banking`
- [x] Verify "Connected" option appears in create account dialog
- [x] Start authorization flow and verify redirect to bank
- [x] With `account-mapping` **disabled**: verify callback creates
accounts directly and dispatches sync
- [x] With `account-mapping` **enabled**: verify callback redirects to
mapping page
- [x] Test mapping page: create new, link to existing, and skip actions
- [x] Verify linked accounts sync only from last transaction date and
only update current balance
- [x] Verify connections page shows "Setup Required" badge for
awaiting_mapping status
- [x] Run `php artisan banking:sync` and verify transactions sync
- [x] Verify connections page shows status, sync, and disconnect actions
- [x] Run full test suite: `php artisan test --compact`
## Summary
- When CSV dates are ambiguous (e.g. `05/03/2024` parses as both
DD-MM-YYYY and MM-DD-YYYY), the auto-detection heuristic now uses the
user's locale preference as a tie-breaker instead of relying on array
order
- Adds `getLocaleDateFormat()` helper that maps locale strings to their
expected date format (US → MM-DD-YYYY, most others → DD-MM-YYYY, CJK →
YYYY-MM-DD)
- Passes locale from Inertia shared props to both transaction and
balance import flows
Closes#100
## Test plan
- [x] Added 15 Vitest tests for `getLocaleDateFormat` and
`autoDetectDateFormat` with locale tie-breaking
- [ ] Manual test: Import CSV with ambiguous dates (day ≤ 12) using a
non-US locale, verify DD-MM-YYYY is preferred
- [ ] Manual test: Import CSV with unambiguous dates (day > 12), verify
locale does NOT override correct detection
## Summary
- Adds `plaintext-transactions` Pennant feature flag (defaults to
`false` / encryption ON)
- When active, new transactions are stored as plaintext (no client-side
encryption)
- Existing encrypted transactions continue to work — detection is based
on `description_iv` being NULL (plaintext) vs present (encrypted)
- Migration makes `description_iv` nullable on the transactions table
## Changes
**Backend:**
- Feature flag definition in `AppServiceProvider`, shared via
`HandleInertiaRequests`
- `StoreTransactionRequest` / `UpdateTransactionRequest` conditionally
require `description_iv`
- `TransactionFactory` gains a `plaintext()` state
**Frontend:**
- Edit dialog and import drawer skip encryption when flag is active
- `EncryptedTransactionDescription` renders plaintext directly when IV
is null
- All decryption loops handle both encrypted and plaintext transactions
- Rule re-evaluation stores notes as plaintext when flag is active
## Test plan
- [x] Run `php artisan migrate` to apply the migration
- [x] Activate flag: `php artisan feature:enable plaintext-transactions
all`
- [ ] Create a transaction — verify description is stored as plaintext
in DB (`description_iv` is NULL)
- [ ] Deactivate flag: `php artisan feature:disable
plaintext-transactions all`
- [ ] Create a transaction — verify encryption is still required (422
without IV)
- [x] Verify old encrypted transactions still display correctly
- [ ] Run `php artisan test --compact
tests/Feature/PlaintextTransactionsTest.php`
## Summary
- Store account names in plaintext instead of encrypting them
client-side. Encryption remains only for transaction descriptions and
notes.
- Existing encrypted account names are silently migrated when the user
unlocks their encryption key, via a new `useDecryptAccountNames` hook
that calls API endpoints to update each account.
- New `AccountName` component handles rendering both encrypted (legacy)
and plaintext accounts across the app.
## Test plan
- [x] Create a new account — name should be stored in plaintext
(`encrypted = false`, `name_iv = null`)
- [x] Unlock encryption key with existing encrypted accounts — they
should auto-migrate silently
- [x] After migration, verify accounts show `encrypted = false` in DB
and names are readable plaintext
- [x] Edit a migrated account — should work without encryption
- [x] Verify all account name displays work across dashboard, account
details, transactions, settings
## Summary
- Replaces Valet/Traefik with Docker-based infrastructure: Caddy (HTTPS
reverse proxy), MySQL, Redis, Mailhog
- PHP runs natively on the host via `composer run dev` — Caddy proxies
to `host.docker.internal:8000`
- PHP container available under `--profile full` for self-hosting via
`setup.sh`
- Vite dev server serves over HTTPS using mkcert certs to avoid
mixed-content issues
- `.env.example` defaults updated for host-based dev workflow (127.0.0.1
+ forwarded ports)
- Deletes obsolete `traefik/` directory
## Test plan
- [x] `docker compose up -d` starts caddy, mysql, redis, mailhog (NOT
php)
- [x] `composer run dev` serves PHP on :8000, Vite on :5173 with HTTPS
- [x] `https://whisper.money.local` loads correctly via Caddy
- [x] Vite HMR works (edit a React component, see hot reload)
- [x] `php artisan test --compact` passes
- [x] `docker compose --profile full up -d` also starts the PHP
container
## Summary
- Fixed undefined `formatDate` reference that crashed the component when
existing transactions loaded, preventing the collapsible from ever
appearing
- Imported `Transaction` type from the correct module
(`@/types/transaction` instead of `@/services/transaction-sync`)
- Removed unnecessary `parseInt` on the already-numeric `amount` field
## Summary
- Detects Android and iOS visitors on the landing page hero section
- Replaces the **Get Started** and **Check Demo** buttons with a single
**Install App** button on mobile
- **Android**: Captures the `beforeinstallprompt` event and triggers the
native PWA installation prompt when tapped
- **iOS**: Opens a dialog with step-by-step instructions (tap Share icon
→ Add to Home Screen)
- Desktop visitors see the original buttons unchanged
## New files
- `resources/js/hooks/use-pwa-install.ts` — Hook that detects platform
(Android/iOS), captures the `beforeinstallprompt` event, and exposes
`promptInstall()`
- `resources/js/components/landing/install-app-button.tsx` — Install
button component with iOS instructions dialog
## Screenshots
| Android & iOS | iOS Instructions |
|--------|--------|
| <img width="1082" height="2402" alt="localhost_8000_(Pixel 7)"
src="https://github.com/user-attachments/assets/bd42c24a-8a82-4f42-9f5f-c13bd5505836"
/> | <img width="1170" height="2532" alt="localhost_8000_(iPhone 12
Pro)"
src="https://github.com/user-attachments/assets/c4ec136f-46f1-44a5-a96a-d7afd41c6015"
/> |
## Summary
- Changed `SyncUserToResendListener` to listen to the `Verified` event
instead of `Registered`, so contacts are only created in Resend after
the user verifies their email address
- Updated corresponding tests to use the `Verified` event
## Test plan
- [x] `SyncUserToResendListenerTest` passes with `Verified` event
- [x] All auth and listener tests 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
- Fixes a race condition where the automerge workflow merged PR #94
despite `browser-tests` failing
- Adds SHA verification to ensure the CI run matches the PR's latest
commit (prevents stale merges from earlier pushes)
- Adds a failed-checks guard that inspects all PR check statuses before
merging
## What happened
PR #94 had two CI runs: an earlier one that passed and a later one where
`browser-tests` failed. The automerge workflow triggered on the first
successful run and merged the PR before the second run completed,
because it only checked `workflow_run.conclusion == 'success'` without
verifying it was for the latest commit.
## Test plan
- [x] Push a PR with the Automerge label, verify it merges when all
checks pass
- [x] Push a follow-up commit that breaks browser-tests — verify the
earlier success doesn't trigger a merge
- [x] Verify PRs without the Automerge label are unaffected
## Summary
- Prevents the landing page from briefly flashing before redirecting to
`/dashboard` when accessing from an installed PWA
- Detects standalone mode synchronously via a `useState` initializer so
the spinner renders on the very first paint
- Shows a centered loading spinner instead of the full landing page
while the redirect happens
## Test plan
- [x] Open the app as an installed PWA and verify a spinner shows
instead of the landing page before redirecting to dashboard
- [x] Open the landing page in a regular browser and verify the full
landing page renders normally with no spinner
## Summary
- Replaced the broken `fade-bottom` header with a floating **pill menu**
on mobile (matching the dashboard bottom nav style)
- Fixed landing screenshot images using `w-full` instead of `h-full
w-auto` for proper aspect ratio on mobile
- Reduced skew/rotation transforms on mobile for better readability
- Removed unused `fade-bottom` CSS class
## Changes
### Header (`header.tsx`)
- **Mobile** (`< sm`): Floating pill with `rounded-full`, glassmorphism
(`bg-background/70 backdrop-blur-xl`), positioned at `top-4` with
margin. Compact logo + auth buttons with `size="sm"` and `rounded-full`.
- **Desktop** (`sm+`): Clean fixed header with `bg-background/5
backdrop-blur-lg` (unchanged from original). All nav items (Github,
Discord, auth) preserved.
### Landing images (`welcome.tsx`)
- Changed images from `h-full w-auto` to `w-full` for proper responsive
aspect ratio
- Added `max-sm:` variants to reduce rotation/skew on mobile (`-16deg/8`
instead of `-24deg/12`)
### CSS (`app.css`)
- Removed unused `.fade-bottom` class
## Screenshots
### Mobile (390x844) — Pill Header
<img
src="https://raw.githubusercontent.com/whisper-money/whisper-money/fix/welcome-page-mobile-ios/.github/pr-screenshots/pr-mobile-hero.png"
width="300" />
### Mobile — Images Section
<img
src="https://raw.githubusercontent.com/whisper-money/whisper-money/fix/welcome-page-mobile-ios/.github/pr-screenshots/pr-mobile-images.png"
width="300" />
### Desktop (1440x900)
<img
src="https://raw.githubusercontent.com/whisper-money/whisper-money/fix/welcome-page-mobile-ios/.github/pr-screenshots/pr-desktop.png"
width="800" />
## Test plan
- [ ] Verify pill header displays correctly on iOS Safari (physical
device or simulator)
- [ ] Verify header buttons (Log in, Register) work on mobile
- [ ] Verify desktop header is unchanged with all buttons (Github,
Discord, Log in, Register)
- [ ] Verify landing images display with correct aspect ratio on mobile
- [ ] Verify dark mode works for both mobile pill header and desktop
header
## Summary
- Load the previous budget period (with transactions) in
`BudgetController::show()` and pass it to the frontend
- Overlay previous period's cumulative spending as a dashed line on the
budget spending chart
- Use day-of-period alignment (Day 1, Day 2...) when comparing so
periods of different lengths align
- Tooltip shows both "Spent" (current) and "Last period" values when
hovering
- Chart now shows the full period timeline (not just up to today)
- When no previous period exists, chart behavior is unchanged
## Screenshots
### Light mode

### Dark mode

## Test plan
- [x] Tests pass: `php artisan test --compact
tests/Feature/BudgetTest.php` (9 tests)
- [x] Pint formatting passes
- [x] ESLint + Prettier pass
- [ ] Manual: view a budget with a prior period — dashed line appears
- [ ] Manual: view a budget with no prior period — chart unchanged
## Summary
- Detect standalone/PWA display mode (`display-mode: standalone` and iOS
`navigator.standalone`) on the welcome page
- Redirect to `/dashboard` immediately so the landing page is never
shown inside the installed app
- Unauthenticated PWA users go: open app → `/dashboard` → `/login` (via
auth middleware)
## Test plan
- [x] Install the PWA and open it — should go straight to
login/dashboard, never the landing page
- [x] Visit the website in a regular browser tab — landing page still
works normally
## Summary
- Budget periods use `date` columns (`start_date`, `end_date`) but
queries compared them against `now()` which includes a time component
(e.g. `2026-01-31 15:30:00`). MySQL converts the date to midnight for
comparison, so `end_date >= now()` evaluates to `'2026-01-31 00:00:00'
>= '2026-01-31 15:30:00'` → **false** — causing "No active period" on
the last day of every period.
- Replaced `now()` with `today()` (date-only) in all budget period date
comparisons across `Budget::getCurrentPeriod()`,
`BudgetController::index()`, and `GenerateBudgetPeriods` command.
## Test plan
- [x] Added 4 Pest tests covering the edge case (last day at various
times, first day at end of day, index/show endpoints)
- [x] All existing budget tests pass
## 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
## Summary
- Fixes the automerge workflow that was merging PRs immediately without
waiting for CI
- Replaces `gh pr merge --auto` (requires branch protection) with a
`workflow_run` trigger that fires after CI completes
- When CI passes on a PR with the "Automerge" label, the PR is
squash-merged automatically
## Test plan
- [x] Merge this PR manually to get the workflow on `main`
- [x] Create a test PR, add the "Automerge" label, and verify it only
merges after CI passes
## Summary
- Replaced the default initials-based avatar fallback with
[Facehash](https://facehash.dev/) — deterministic, unique avatar faces
generated from the user's name
- Same name always produces the same face, no API calls needed
- Fully rounded with `rounded-full` styling
## Preview

Each user gets a unique, consistent face based on their name — no more
generic initials.
## Changes
- Installed `facehash` package
- Updated `resources/js/components/user-info.tsx` to use `<Facehash>`
instead of `<Avatar>` with initials fallback
## Test plan
- [x] Verify avatar renders correctly in the sidebar footer (desktop)
- [x] Verify avatar renders correctly in the mobile header
- [x] Verify avatar renders correctly in the user dropdown menu
- [x] Confirm the same user always gets the same face
- [x] Check dark mode appearance
## Summary
- Replace `subMonths()` with `subMonthsNoOverflow()` across controllers,
commands, and tests to prevent date overflow on months with 31 days
- On Jan 31, `subMonths(2)` overflows: Nov 31 doesn't exist → Carbon
rolls forward to Dec 1, producing wrong date ranges and off-by-one month
counts
## Root cause
Carbon's `subMonths()` allows day overflow. When the target month has
fewer days than the source date, it rolls into the next month. For
example:
- `2026-01-31 subMonths(2)` → Nov 31 → **2025-12-01** (expected Nov 30)
- `2026-01-31 subMonths(11)` → Feb 31 → **2025-03-03** (expected Feb 28)
`subMonthsNoOverflow()` clamps to the last valid day of the target month
instead.
## Files changed
- `app/Http/Controllers/Api/CashflowAnalyticsController.php` — cashflow
trend start date calculation
- `app/Console/Commands/ResetDemoAccountCommand.php` — balance history
date generation (was causing duplicate `balance_date` entries)
- `tests/Feature/AccountControllerTest.php` — balance evolution `from`
param
- `tests/Feature/CashflowAnalyticsTest.php` — transaction date
generation in loop
- `tests/Feature/DashboardAnalyticsTest.php` — net worth evolution
`from` param
## Test plan
- [x] `account balance evolution returns data` — expects 3 months, was
getting 2
- [x] `cashflow trend returns monthly data` — expects 3 months, was
getting 2
- [x] `cashflow trend defaults to 12 months` — expects 12 months, was
getting 11
- [x] `net worth evolution returns monthly data points` — expects 3
months, was getting 2
- [x] `demo:reset creates demo user` — was throwing
UniqueConstraintViolation on duplicate balance dates
## Summary
- Adds a new GitHub Actions workflow that enables auto-merge when the
"Automerge" label is added to a PR
- Uses `gh pr merge --auto --squash` to squash-merge once all required
CI checks pass
## Test plan
- [ ] Add the "Automerge" label to this PR to verify the workflow
triggers
- [ ] Confirm the PR is auto-merged after CI passes
## Summary
- Add `SyncUserToResendListener` to automatically sync users to Resend
contacts on registration
- Add `ResendService` for Resend API interactions
- Add `resend:sync` command to bulk sync existing users
## Changes
- **New**: `app/Listeners/SyncUserToResendListener.php` -
Auto-discovered queued listener
- **New**: `app/Services/ResendService.php` - Service for Resend
contacts API
- **New**: `app/Console/Commands/ResendSyncCommand.php` - Bulk sync
command
- **New**: Tests for listener and command
## Usage
New users are automatically synced on registration. For existing users:
```bash
php artisan resend:sync
```
## Test plan
- [x] New user registration triggers contact sync
- [x] `resend:sync` command syncs all existing users
- [x] Graceful handling when API key is not configured
- [x] Duplicate contacts are handled by Resend (no errors)