Commit Graph

4 Commits

Author SHA1 Message Date
Víctor Falcón 1bd2d80b14
fix(budgets): keep per-transaction email notifications opt-in (#735)
Budget email notifications shipped opt-in, then #733 turned all three on
by default. The per-transaction notice is the wrong one to force on: it
emails on **every single transaction** assigned to a budget, and a
catch-all budget matches all of them. This puts it back to opt-in and
leaves the limit alerts (close to limit, over limit) on by default.

## Why a second migration instead of editing the first one

`2026_07_24_100000_enable_budget_notifications_by_default` **has already
run in production** (batch 76). Laravel never re-runs an applied
migration, so editing it in place would have been inert: 382 budgets and
174 settings rows would keep the flag on, and the `user_settings` column
default would stay `true`.

That default matters beyond new signups: every settings controller
writes its own column through `setting()->updateOrCreate(...)`, so a
user who merely changed their chart colour scheme would get a settings
row with `budget_notify_on_new_transaction = 1` filled in by MySQL —
silently opted in, with the checkbox rendering as checked.

So the original migration is restored to its merged form and a new one
resets the flag: column default back to `false`, plus a one-off reset of
existing `user_settings` and `budgets` rows. It uses the query builder,
so `updated_at` is untouched — same as the force-enable did. 381 of the
382 affected budgets had not been touched by their owner since the
deploy, so the reset undoes a force-enable rather than a user's choice.

## Changes

- New migration resetting
`user_settings.budget_notify_on_new_transaction` (default + rows) and
`budgets.notify_on_new_transaction`.
- `BudgetController::store` and
`NotificationPreferenceController::index` fall back to `false` for the
per-transaction flag when a user has no settings row.
- `UserSettingFactory` matches the new default.

Users who want the per-transaction notice can still turn it on per
budget, or as their default for new budgets, in Settings →
Notifications.

## QA

Ran the migration against a real database and checked the end state:
`budget_notify_on_new_transaction` default `0` with all 151 settings
rows and 333 budgets reset, both limit flags default `1` and left on.

Then drove real transactions through the listener and queue on a live
budget (limit €300) and read the resulting mail off Mailhog:

| Event | Spent / limit | Email |
| --- | --- | --- |
| New transaction, default settings | €50 / €300 | none |
| Reaches 90% | €270 / €300 | «Presupuesto de Ocio» está cerca de su
límite |
| Exceeds the limit | €320 / €300 | «Presupuesto de Ocio» ha superado su
límite |
| New transaction, after opting in | €10 / €5000 | Nueva transacción en
«Presupuesto de Ocio» |

Assignment still happens in every case — only the email is suppressed.
265 tests green across the Budget/Setting/Notification suites, including
new assertions that the rendered defaults have the per-transaction flag
off and that a settings row created without the budget columns does not
opt the user in.
2026-07-25 12:36:13 +02:00
Víctor Falcón 045466f70d
feat(budgets): enable email notifications by default and coalesce alerts per transaction (#733)
## What

Follow-up to #731 (per-budget email notifications). Two changes:

1. **Enable budget email notifications by default.** They shipped opt-in
with every toggle defaulting to `false`, so no one received them. They
are now on by default.
2. **Coalesce budget alerts into one email per transaction.** A single
transaction that crossed a budget's limit could send two emails for the
same budget (the "new transaction" notice *and* the over/close-limit
alert). Now at most one email is sent per budget per assignment.

## Enable by default

- `user_settings.budget_notify_on_*` column defaults flipped to `true`,
and **all existing `user_settings` and `budgets` rows are backfilled to
`true`** so current users and budgets start notifying — not just new
ones.
- `BudgetController::store` and
`NotificationPreferenceController::index` fall back to `true` when a
user has no settings row yet.
- `UserSettingFactory` reflects the new default.
- The `budgets` columns keep their own `false` DB default on purpose:
new budgets are seeded from the user's `user_settings` defaults at
creation, so only the one-off backfill is needed there.

## Coalescing

`BudgetNotificationService::processPeriod` now sends **at most one email
per budget per transaction assignment**, picking the most severe
applicable event with priority **over-limit > close-to-limit >
new-transaction**, and passes the triggering transaction through to
whichever fires. So a transaction that pushes a budget over its limit
produces a single over-limit email that still names the transaction and
shows how far over the budget is.

Preserved from #731: the atomic per-period `close_to_limit_notified` /
`over_limit_notified` claim (one email per crossing, safe under
concurrent workers), the reset-and-re-notify path when spending drops
back below the threshold, and the no-emails-on-historical-backfill
guarantee. Coalescing is **per budget** — a transaction matching two
different budgets still sends one email to each.

## Trade-offs (deliberate)

- **`notify_on_new_transaction` is on by default**, which means one
email per transaction assigned to any budget (no batching yet — flagged
in #731). A catch-all budget matches every expense, so this can be
high-volume; the whole-user-base backfill also produces a send spike on
first sync after deploy. Batching the new-transaction type into a daily
digest (mirroring the bank-sync digest) is the natural follow-up if
volume proves noisy.
- The migration backfill is intentionally unconditional and one-way for
data: `down()` restores the column defaults but not the backfilled row
values.

## Every email links to the settings page

Each budget email already carries a "Manage notifications in
notification settings" link (`route('notifications.index')`) so users
can turn any of them off. A render-test assertion now locks this in.

## Tests

- Coalescing: a single limit-crossing transaction sends exactly one
over-limit email that references the triggering transaction.
- On-by-default: a new budget created without custom preferences enables
all three types.
- All existing #731 sending/preference tests still pass (dedup,
re-notify after drop, no-limit budgets, historical backfill sends
nothing, enabling while already over).

QA (backend): rendered the coalesced over-limit email and confirmed it
names the transaction, shows "Over by", and links to the settings page;
verified the migration backfill flips pre-existing `false` rows to
`true` against a real DB.
2026-07-24 15:33:14 +02:00
Víctor Falcón 0351dbfb38
feat(budgets): add per-budget email notifications (#731)
## 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).
2026-07-24 12:52:03 +02:00
Víctor Falcón e178f1b1bd
feat(settings): let users disable bank transactions email (#472)
## 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.
2026-06-02 12:24:39 +02:00