## Why
#750 made a shared account count only your share of every transaction —
on the
dashboard and on the cashflow screen. Budgets were left out and shipped
as a
known gap: `budget_transactions.amount` is a snapshot written when a
transaction
is assigned, so a 50% joint account still spent **100%** of every
expense against
its budget. The same category could read €400 on the dashboard and €800
in
Budgets.
This closes that gap.
## What
A €100 expense on an account you own 50% of now counts €50 towards your
budgets.
Because every budget reader funnels through
`BudgetPeriod::spentAmount()` — a sum
of those snapshots — weighing the snapshot covers the budget cards, the
detail
page, the spending chart, carry-over, the limit alert emails and the MCP
tools in
one move.
- **Assignment writes the owner's share.** Both paths (the
per-transaction
listener and the historical backfill) go through one `recordSnapshot()`.
- **Changing an account's share rewrites its history.** A single SQL
`UPDATE`
re-weighs every budget row of that account, in every period, past ones
included.
- **A migration re-weighs the rows written before this**, so existing
shared
accounts are correct on deploy instead of on the next edit.
## How
The share is computed at the same two choke points #750 established:
- **PHP** — `Transaction::ownerShareOf()` (extracted from
`ConvertsTransactionCurrency`, which was doing the same null dance
inline)
feeds `BudgetTransactionService::recordSnapshot()`.
- **SQL** — `BudgetTransactionService::reweighAccountSnapshots()` reuses
`Transaction::OWNED_AMOUNT_SQL`, so the rounding matches what PHP would
have
written. A test pins both to the same answer on an uneven share.
The re-weigh hangs off `Account::booted()` rather than
`AccountController`, so a
seeder, an artisan command or a future MCP write tool cannot silently
skip it.
It also clears the period's `close_to_limit_notified` /
`over_limit_notified`
flags, the same way a refund that drops a budget back under its limit
does —
otherwise a budget that fell out of "over limit" would stay claimed and
never
alert on the next real crossing.
The owning account is eager loaded **withTrashed** everywhere the
snapshot is
written, because `OWNED_AMOUNT_SQL` joins `accounts` without the
soft-delete
scope; without it a transaction whose account was deleted would snapshot
at 100%
in PHP and at the real share in SQL.
## Deliberate boundaries
- **Transaction rows still show the real bank amount** — #750's rule.
The budget
detail page is the one screen where a weighted total sits directly above
its
own itemised list, so it now says so in a line under the chart. The
alert email
had the same mismatch inside one message and now quotes your share.
- **`carried_over_amount` is not re-derived.** It is a second snapshot
taken when
a period closes. `remainingAmount()` deliberately ignores it and the UI
only
types the field; it surfaces solely through MCP. Left alone rather than
adding
a second re-derivation path.
- The migration's `down()` restores the full transaction amount, which
is what
those rows held — it cannot know a share an account no longer has.
## Testing
`tests/Feature/SharedAccountOwnershipTest.php` gains the budget cases:
assignment,
the historical backfill, the re-weigh through the settings screen,
PHP/SQL
rounding parity on 33% of 3333, and the alert flags being cleared.
`tests/Feature/WeighBudgetTransactionsMigrationTest.php` covers the
backfill,
including that it is idempotent and that it keeps refunds negative (the
fix from
`2026_02_24_193117`). Fixing that test needed the `BudgetTransaction`
factory,
which had been pointing at a `BudgetPeriodAllocation` model that no
longer exists.
Manual QA on real local data — budget "Miami Flight", account "Daily"
set to 50%:
| | Before | At 50% | Back at 100% |
|---|---|---|---|
| Miami Flight spent | €3,229.40 | **€1,955.79** | €3,229.40 |
| Yearly Padel spent | €1,514.91 | €785.49 | €1,514.91 |
€1,955.79 is €3,229.40 − €1,273.61, exactly half of the €2,547.24 that
account
had in the budget. The round trip lands back on the original figure to
the cent,
so the re-weigh is idempotent on real data too.
## Demo
<!-- PLACEHOLDER: drag the QA video here -->
https://github.com/user-attachments/assets/1fa5b98a-2a11-4cad-b927-496b434d3295
## What
Once a month, email a CSV with the email address of every non-deleted
user to the owners.
- New `email:user-emails-report` command builds the CSV and sends
`UserEmailsReportEmail` with it attached as `text/csv`.
- Scheduled `monthlyOn(1, '09:05')` in `Europe/Madrid`, next to the
other `email:*` jobs.
- Recipients come from a comma-separated `REPORT_RECIPIENTS` env var.
The command fails loudly (exit 1, nothing sent) when it is unset, rather
than silently skipping.
The `SoftDeletes` global scope on `User` already excludes deleted users,
so no extra `whereNull` is needed.
## ⚠️ Required before this ships
Set `REPORT_RECIPIENTS` in the production environment, or the scheduled
command will fail every month:
```
REPORT_RECIPIENTS=first@example.com,second@example.com
```
Values are trimmed and empty entries dropped, so trailing commas and
spaces are safe.
## QA
No UI surface, so this was QA'd the way it is actually used: running the
command against the real local database (2520 users, 85 soft-deleted)
with mail captured by Mailhog.
| Check | Result |
| --- | --- |
| Command output | `Sent 2435 user email(s) as
user-emails-2026-08-12.csv.` (2520 − 85) |
| Message | 1 email, both recipients on a single `To` |
| Subject | `Monthly user emails export: 2435 users` |
| Attachment | one `text/csv` part,
`filename=user-emails-2026-08-12.csv`, 58 KB |
| CSV contents | `email` header + 2435 rows |
| Soft-deleted leakage | 0 overlap with the 85 soft-deleted addresses |
| Set equality | 0 rows in the CSV missing from the active set, 0 active
users missing from the CSV |
| Body | renders correctly in both the text and HTML parts |
| Schedule | `schedule:list` → `5 7 1 * *` (07:05 UTC = 09:05 CEST),
next due Sept 1 |
| Missing `REPORT_RECIPIENTS` | errors, exit 1, nothing sent |
| Recipient parsing | `" one@example.com , ,two@example.com,"` → two
clean recipients |
Tests: 2136 pass. `pint`, `phpstan`, `jscpd`, `crap`, `prettier` and
`eslint` all clean.
## Review notes
Two findings from review were raised rather than coded, since they are
product calls:
- **The export is not filtered by verification or consent.** It contains
every active address, including ~5.8% unverified ones. Literal "all
users", but those would bounce if the list is imported into a mail tool.
There is no marketing-consent flag anywhere in the schema, so nothing is
being ignored — just don't assume the list is filtered.
- **Privacy posture.** This puts the full user-email list into two
mailboxes every month, indefinitely, with no retention control. Worth a
conscious decision for an app positioned on not sharing user data.
`demo@whisper.money` is intentionally **not** excluded: the request was
every non-deleted user, and the existing exclusion precedent protects
the demo account from deletion, which is a different motive.
CSV formula injection (`=`, `+`, `-`, `@` local parts evaluating on
import into Sheets) was considered and skipped: zero such addresses
exist today and the only sensitive payload is the list itself, which the
recipients already own.
## What
Adds opt-in **email notifications per budget** for three events, plus a
dedicated **Notifications** settings page.
Each budget can independently enable:
- **New transaction** — a transaction was assigned to the budget
- **Close to limit** — spending crossed 90% of the limit
- **Over limit** — spending reached/passed the limit
A **Default (new budgets)** row lets you set the toggles that newly
created budgets inherit.
The existing *bank-transactions-synced* email toggle was **moved** from
the account page into this new Notifications page, so all email
preferences live in one place.
## UI
`Settings → Notifications`: one "Email notifications" section containing
the bank-sync toggle and a "Budgets" table (rows = Default + each
budget, columns = the three events).
<img width="1265" height="638" alt="budget-notifications-settings"
src="https://github.com/user-attachments/assets/b8386410-98dd-4a5e-9dee-6e5bf7effa31"
/>
## How it works
- Notifications fire from the **live transaction-assignment path**
(`BudgetTransactionService::assignTransaction`, already a queued
listener), never from historical backfill when a budget is created.
- Only the budget's **current period** is considered (the emails
describe the live "available before the limit" state).
- **New transaction** fires for a genuinely newly-assigned transaction;
the email includes the transaction.
- **Close/over** use two atomic per-period flags
(`close_to_limit_notified` / `over_limit_notified`) flipped with a
compare-and-set `UPDATE`, so exactly one email is sent per crossing even
under concurrent queue workers. The flags reset once spending drops back
below the close threshold, so a later crossing can notify again.
- Budgets with no limit (`allocated_amount <= 0`) never send close/over
emails; the "new transaction" email for such a budget omits the
limit/available rows.
- Every email shows the budget's current status (period, spent, limit,
available or over-by). Subcopy links back to the Notifications page.
- New budgets inherit the user's default toggles at creation time.
## Data model
- `budgets`: `notify_on_new_transaction`, `notify_on_close_to_limit`,
`notify_on_over_limit` (default `false`)
- `user_settings`: `budget_notify_on_*` defaults for new budgets
(default `false`)
- `budget_periods`: `close_to_limit_notified`, `over_limit_notified`
(dedup flags)
## Deliberate decisions
- **Threshold is a fixed 90%** for "close to limit" (not configurable) —
kept simple; easy to make per-budget later if requested.
- **"New transaction" sends one email per assigned transaction.** It is
opt-in and defaults to off. A bank sync importing many matching
transactions into an opted-in budget will therefore send several emails;
if this proves noisy we can batch per sync run (mirroring the daily
bank-sync digest). Flagged here rather than pre-building batching.
- **Limit basis is `allocated_amount`** (matches the budget
cards/spending chart), carry-over excluded.
## Tests
- Preference endpoints: page renders budgets + defaults, per-budget
toggle update, cross-user update forbidden, user-settings defaults
update, new budget inherits defaults.
- Sending: new-transaction opt-in/out, over-limit send, close-limit at
90%, no-resend dedup, re-notify after dropping below and crossing again,
no-limit budget skips close/over, historical backfill sends nothing,
notify after enabling the preference while already over. Plus a render
test covering all three email variants.
## Notes
- CI-enforced `es.json` keys for the new page and emails are included.
- All new PHP follows existing mail/queue conventions (`ShouldQueue`
mailable, `emails` queue, `RateLimited` middleware).
## What & why
The `HIDE_AUTH_BUTTONS` flag and its signed-link auth-override existed
only to gate registration/login behind a waitlist during the launch
period. That period is long over, so this removes the flag and the whole
waitlist apparatus. Registration and login are now always open, still
governed by `REGISTRATION_ENABLED` (Fortify's registration feature),
which is untouched.
## Removed
- **`HIDE_AUTH_BUTTONS`** config/env and
**`LandingAuthOverrideService`** — the signed-link override, override
cookie, and `?force=` bypass — plus its
`GenerateLandingAuthLinkCommand`.
- **Waitlist lead-capture + invitation apparatus**: `UserLeadController`
+ `waitlist.*`/`user-leads.*` routes, `StoreUserLeadRequest`, the
landing `WaitlistForm`, `waitlist/*` pages, waitlist/invitation mails,
invitation & re-invitation commands, the lead verification notification,
`LeadCohort`/`LeadCohortResolver`/`LeadPromoCodeAllocator`, and the
lead→Resend segment sync (scheduled tasks removed too).
- Frontend `hideAuthButtons`/`forcedRegistration` threading and the
`?force=` query on auth links (header, welcome, register, login).
- All tests for the removed features; `landing.hide_auth_buttons` config
stubs stripped from surviving tests.
## Kept on purpose (data preservation)
The `user_leads` **table, its migrations, and a lean `UserLead` model
are retained** — no destructive migration. Pre-launch contacts are not
lost, and new signups whose email matches a preserved lead still receive
that lead's promo code at Stripe checkout
(`SubscriptionController::resolveLeadPromotionCodeId`, unchanged).
## Testing
- Full non-Browser Pest suite green locally (**1948 passed**), plus
Pint, Prettier, ESLint.
- Browser QA (Playwright) on the running app: landing no longer shows
the waitlist form and renders the register/login CTAs; `/register`
renders the form (previously returned null when hidden); `/login` shows
the "Sign up" link pointing to `/register` with no `?force`. 16/16
functional checks passed; only pre-existing external-resource 404s in
console (gravatar `d=404`, `via.placeholder.com` seed images).
## Demo
https://github.com/user-attachments/assets/18a70f6d-b82f-4d92-b9de-da2610dc017a
## Why
The drip email family was near copy-paste. Each of the eight mailables
repeated the same constructor, `$tries`/`$backoff`, `drip_from`
envelope, `markdown` + `userName` content, and `RateLimited` middleware
— differing only in subject and template (plus a reply-to on one, an
extra view var on another). Each of the eight jobs repeated the
`canReceiveEmails` / `hasReceivedEmail` guards, the `Mail::send` call,
and the `UserMailLog::create` block — differing only in the email type,
the mailable, and a handful of per-email eligibility checks.
## What
Two abstract bases, so each subclass declares only what varies:
- **`DripMail`** — owns the shared mailable shape. Subclasses declare
`dripSubject()` and `template()`; optional `contentData()` (PromoCode's
`FOUNDER` var) and `repliesToSender()` (PaywallFollowUp) hooks. The
hooks are named `dripSubject()`/`template()` deliberately to avoid
colliding with `Illuminate\Mail\Mailable`'s existing public
`subject()`/`markdown()` methods.
- **`SendDripEmailJob`** — owns the shared `handle()` flow (shared
guards → send → `UserMailLog`). Subclasses declare `emailType()` and
`buildMail()`; an optional `shouldSend()` hook carries the per-email
eligibility rules (pro-plan, onboarding, transactions, bank connections,
AI consent).
Each mailable drops from ~68 to ~13 lines and each job from ~48 to ~18.
Net **−459 lines**, with the send/log logic and envelope construction
now in one place.
## Behavior
No functional change. Subjects, `from`/`reply-to`, queue name, rate
limiting, templates, view data, and every per-job eligibility guard are
preserved. Notably, passing `replyTo: []` for the seven non-reply mails
is identical to the previous omission — `Envelope`'s `replyTo` defaults
to `[]`. Verified: `pest --exclude-testsuite=Browser` **1872 passing, 0
failing**; `pint --test`, `phpstan` (level 5), `format`, `lint` clean.
## Review-driven follow-up commits
Two review agents (architecture/duplication/coverage, and
behavior/regressions) ran against the first commit. The behavior agent
verified all eight jobs' guard logic invert correctly (order +
negations) and that `replyTo: []` ≡ omitted — no regressions. The
coverage agent found the two hook-driven behaviors were untested;
applied, each as its own commit:
- Extended the drip-sender parity test to all eight mailables and
asserted `PaywallFollowUpEmail`'s reply-to (and that a default drip mail
sets none).
- Asserted `PromoCodeEmail` injects the `FOUNDER` promo code into its
view data.
## Deliberately out of scope
- Non-drip mailables (`Waitlist*`, `Banking*`, `UpdateEmail`, …) share
the same `RateLimited('emails')->releaseAfter(1)` middleware and could
later share a queued-mail base too — separate concern.
- **Pre-existing** (not introduced here, noted by review): the job sends
the mail before writing `UserMailLog`, so a failure between the two can
re-enqueue a duplicate; there is no lock/unique constraint on `(user_id,
email_type)`. Worth a separate hardening pass.
## 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
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
- Adds `leads:retry-failed-jobs` artisan command to selectively retry
failed `emails`-queue jobs after a Resend rate-limit incident
- Retries jobs for verified leads and `VerifyUserLeadEmailNotification`
for unverified leads; forgets jobs for deleted leads (DDoS cleanup) or
unverified leads receiving waitlist emails
- Adds `deleteWhenMissingModels = true` to all lead mail/notification
classes so future jobs for deleted leads are silently discarded instead
of failing
## Usage
```bash
# Preview (no changes)
php artisan leads:retry-failed-jobs --dry-run
# Execute
php artisan leads:retry-failed-jobs
```
## Test plan
- [x] `leads:retry-failed-jobs` forgets jobs for deleted leads
- [x] `leads:retry-failed-jobs` retries jobs for verified leads
- [x] `leads:retry-failed-jobs` forgets waitlist jobs for unverified
leads
- [x] `leads:retry-failed-jobs` retries
`VerifyUserLeadEmailNotification` for unverified leads
- [x] `leads:retry-failed-jobs` handles mixed job types correctly
- [x] `--dry-run` does not modify `failed_jobs`
## Summary
- explicitly set the transaction sync mailable sender to the configured
default transactional address
- add a regression test so the transaction sync email cannot fall back
to an unintended sender
## Testing
- php artisan test --compact tests/Feature/MailSenderTest.php
## Summary
- route drip mailables through `Álvaro and Víctor <hi@whisper.money>`
and send the rest from `Whisper Money <no-reply@whisper.money>`
- remove legacy per-mailable `Victor` sender overrides so non-drip mail
falls back to the default sender consistently
- add focused sender coverage for drip, non-drip, and verification mail
paths
## Testing
- `php artisan test --compact tests/Feature/MailSenderTest.php
tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.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
- 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
### 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
## 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
- When a transaction ID is provided during sync, check if it already
exists before creating
- If it exists, return the existing transaction with 200 status instead
of failing with duplicate key error
- Prevents duplicate transactions when sync retries occur due to network
issues
## Test plan
- [x] Attempt to create a transaction with a specific ID
- [x] Attempt to create another transaction with the same ID
- [x] Verify the second request returns the existing transaction instead
of an error
## 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