From e178f1b1bdb57a778ae2124e651078d1904f71c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 2 Jun 2026 12:24:39 +0200 Subject: [PATCH] feat(settings): let users disable bank transactions email (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../NotificationPreferenceController.php | 37 ++++++++ .../Settings/ProfileController.php | 2 + .../UpdateNotificationPreferencesRequest.php | 33 +++++++ ...endDailyBankTransactionsSyncedEmailJob.php | 4 + app/Models/User.php | 5 ++ app/Models/UserSetting.php | 3 + database/factories/UserSettingFactory.php | 1 + ...sactions_synced_to_user_settings_table.php | 28 ++++++ lang/es.json | 5 ++ resources/js/pages/settings/account.tsx | 52 ++++++++++- .../mail/bank-transactions-synced.blade.php | 4 + .../views/vendor/mail/html/themes/default.css | 2 + routes/settings.php | 4 + .../SyncBankingConnectionJobTest.php | 32 +++++++ .../Settings/NotificationPreferenceTest.php | 87 +++++++++++++++++++ 15 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Settings/NotificationPreferenceController.php create mode 100644 app/Http/Requests/Settings/UpdateNotificationPreferencesRequest.php create mode 100644 database/migrations/2026_06_02_094823_add_notify_on_bank_transactions_synced_to_user_settings_table.php create mode 100644 tests/Feature/Settings/NotificationPreferenceTest.php diff --git a/app/Http/Controllers/Settings/NotificationPreferenceController.php b/app/Http/Controllers/Settings/NotificationPreferenceController.php new file mode 100644 index 00000000..1aaa609f --- /dev/null +++ b/app/Http/Controllers/Settings/NotificationPreferenceController.php @@ -0,0 +1,37 @@ + + */ + public const PREFERENCES = [ + 'bank_transactions_synced' => 'notify_on_bank_transactions_synced', + ]; + + public function update(UpdateNotificationPreferencesRequest $request): RedirectResponse + { + $attributes = collect($request->validated('notifications')) + ->mapWithKeys(fn ($enabled, string $key): array => [ + self::PREFERENCES[$key] => filter_var($enabled, FILTER_VALIDATE_BOOLEAN), + ]) + ->all(); + + $request->user()->setting()->updateOrCreate( + ['user_id' => $request->user()->id], + $attributes, + ); + + return back(); + } +} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 16e57857..9126c5e0 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -24,6 +24,7 @@ class ProfileController extends Controller 'status' => $request->session()->get('status'), 'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(), 'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), + 'notifyOnBankTransactionsSynced' => $request->user()->wantsBankTransactionsSyncedEmail(), ]); } @@ -37,6 +38,7 @@ class ProfileController extends Controller 'status' => $request->session()->get('status'), 'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(), 'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), + 'notifyOnBankTransactionsSynced' => $request->user()->wantsBankTransactionsSyncedEmail(), ]); } diff --git a/app/Http/Requests/Settings/UpdateNotificationPreferencesRequest.php b/app/Http/Requests/Settings/UpdateNotificationPreferencesRequest.php new file mode 100644 index 00000000..b26f2f74 --- /dev/null +++ b/app/Http/Requests/Settings/UpdateNotificationPreferencesRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + $allowedKeys = implode(',', array_keys(NotificationPreferenceController::PREFERENCES)); + + return [ + 'notifications' => ['required', 'array:'.$allowedKeys], + 'notifications.*' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php b/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php index 6d783b2a..4f630868 100644 --- a/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php +++ b/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php @@ -41,6 +41,10 @@ class SendDailyBankTransactionsSyncedEmailJob implements ShouldBeUnique, ShouldQ return; } + if (! $this->user->wantsBankTransactionsSyncedEmail()) { + return; + } + $localReportDate = $this->localReportDate(); $lastSentMailLog = UserMailLog::query() ->where('user_id', $this->user->id) diff --git a/app/Models/User.php b/app/Models/User.php index 64a14057..43faf88b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -237,6 +237,11 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma return ! $this->isDeleted(); } + public function wantsBankTransactionsSyncedEmail(): bool + { + return $this->setting->notify_on_bank_transactions_synced ?? true; + } + public function routeNotificationForMail(?Notification $notification = null): ?string { if (! $this->canReceiveEmails()) { diff --git a/app/Models/UserSetting.php b/app/Models/UserSetting.php index cba1b5e5..0e24777c 100644 --- a/app/Models/UserSetting.php +++ b/app/Models/UserSetting.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property ChartColorScheme $chart_color_scheme * @property bool $include_loans_in_net_worth_chart * @property bool $include_real_estate_in_net_worth_chart + * @property bool $notify_on_bank_transactions_synced */ class UserSetting extends Model { @@ -24,6 +25,7 @@ class UserSetting extends Model 'chart_color_scheme', 'include_loans_in_net_worth_chart', 'include_real_estate_in_net_worth_chart', + 'notify_on_bank_transactions_synced', ]; protected function casts(): array @@ -32,6 +34,7 @@ class UserSetting extends Model 'chart_color_scheme' => ChartColorScheme::class, 'include_loans_in_net_worth_chart' => 'boolean', 'include_real_estate_in_net_worth_chart' => 'boolean', + 'notify_on_bank_transactions_synced' => 'boolean', ]; } diff --git a/database/factories/UserSettingFactory.php b/database/factories/UserSettingFactory.php index d8ec2d6b..f274db78 100644 --- a/database/factories/UserSettingFactory.php +++ b/database/factories/UserSettingFactory.php @@ -24,6 +24,7 @@ class UserSettingFactory extends Factory 'chart_color_scheme' => ChartColorScheme::Colorful, 'include_loans_in_net_worth_chart' => true, 'include_real_estate_in_net_worth_chart' => true, + 'notify_on_bank_transactions_synced' => true, ]; } diff --git a/database/migrations/2026_06_02_094823_add_notify_on_bank_transactions_synced_to_user_settings_table.php b/database/migrations/2026_06_02_094823_add_notify_on_bank_transactions_synced_to_user_settings_table.php new file mode 100644 index 00000000..1e324f8e --- /dev/null +++ b/database/migrations/2026_06_02_094823_add_notify_on_bank_transactions_synced_to_user_settings_table.php @@ -0,0 +1,28 @@ +boolean('notify_on_bank_transactions_synced')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_settings', function (Blueprint $table) { + $table->dropColumn('notify_on_bank_transactions_synced'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index df18dfbf..aea2f74b 100644 --- a/lang/es.json +++ b/lang/es.json @@ -519,6 +519,7 @@ "Do not show in Sankey": "No mostrar en Sankey", "Do not show in cashflow chart": "No mostrar en el gráfico de flujo de caja", "Don't have an account?": "¿No tienes una cuenta?", + "Don't want these emails? You can manage your notifications in your [account settings](:url).": "¿No quieres recibir estos correos? Puedes gestionar tus notificaciones en los [ajustes de tu cuenta](:url).", "Download as CSV or Excel format": "Descargar en formato CSV o Excel", "Drop your CSV or Excel file here, or click to browse": "Arrastra tu archivo CSV o Excel aquí, o haz clic para buscar", "Drop your file here, or click to browse": "Arrastra tu archivo aquí, o haz clic para explorar", @@ -879,6 +880,7 @@ "Manage Plan": "Gestionar Plan", "Manage Subscription": "Gestionar Suscripción", "Manage rules that categorize transactions and add labels automatically": "Gestiona reglas que categorizan transacciones y agregan etiquetas automáticamente", + "Manage the automatic notifications you receive": "Gestiona las notificaciones automáticas que recibes", "Manage your bank accounts": "Gestiona tus cuentas bancarias", "Manage your connected bank accounts for automatic transaction syncing.": "Gestiona tus cuentas bancarias conectadas para sincronización automática de transacciones.", "Manage your profile and account settings": "Gestiona tu perfil y configuración de cuenta", @@ -946,6 +948,7 @@ "Never": "Nunca", "New": "Nuevo", "New password": "Nueva contraseña", + "New transactions from connected banks": "Nuevas transacciones de bancos conectados", "New transactions synced, :name!": "¡Nuevas transacciones sincronizadas, :name!", "Next": "Siguiente", "Next month": "Mes siguiente", @@ -997,6 +1000,7 @@ "None (Remove)": "Ninguno (Eliminar)", "Not sure how to import transactions?": "¿No sabes cómo importar transacciones?", "Notes": "Notas", + "Notifications": "Notificaciones", "Nothing in these Terms excludes or limits our liability for death or personal injury caused by negligence, fraud, or any liability that cannot be excluded or limited under applicable law.": "Nada en estos Términos excluye o limita nuestra responsabilidad por muerte o lesiones personales causadas por negligencia, fraude, o cualquier responsabilidad que no pueda ser excluida o limitada bajo la ley aplicable.", "Nothing in these Terms excludes or limits our\\n liability for death or personal injury caused by\\n negligence, fraud, or any liability that cannot\\n be excluded or limited under applicable law.": "Nada en estos Términos excluye o limita nuestra responsabilidad por muerte o lesiones personales causadas por negligencia, fraude o cualquier responsabilidad que no pueda ser excluida o limitada según la ley aplicable.", "Notify us immediately of any unauthorized access or security breach": "Notifícanos inmediatamente de cualquier acceso no autorizado o violación de seguridad", @@ -1119,6 +1123,7 @@ "Rate limit exceeded. Please wait a few minutes and try again.": "Límite de solicitudes superado. Espera unos minutos e inténtalo de nuevo.", "Re-evaluate rules": "Reevaluar reglas", "Reactivate Your Subscription": "Reactiva Tu Suscripción", + "Receive an email when new transactions are imported from your connected banks. Sent at most once a day.": "Recibe un correo cuando se importen nuevas transacciones de tus bancos conectados. Se envía como máximo una vez al día.", "Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?", "Real Estate": "Bienes Raíces", "Reconnect": "Reconectar", diff --git a/resources/js/pages/settings/account.tsx b/resources/js/pages/settings/account.tsx index cbfa03bf..29a262d6 100644 --- a/resources/js/pages/settings/account.tsx +++ b/resources/js/pages/settings/account.tsx @@ -6,6 +6,7 @@ import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes'; import TwoFactorSetupModal from '@/components/two-factor-setup-modal'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { @@ -26,7 +27,7 @@ import { type BreadcrumbItem, type SharedData } from '@/types'; import { LANGUAGE_OPTIONS } from '@/types/language'; import { __ } from '@/utils/i18n'; import { Transition } from '@headlessui/react'; -import { Form, Head, Link, usePage } from '@inertiajs/react'; +import { Form, Head, Link, router, usePage } from '@inertiajs/react'; import { ShieldBan, ShieldCheck } from 'lucide-react'; import { useRef, useState } from 'react'; @@ -42,15 +43,30 @@ export default function Account({ status, requiresConfirmation = false, twoFactorEnabled = false, + notifyOnBankTransactionsSynced = true, }: { mustVerifyEmail: boolean; status?: string; requiresConfirmation?: boolean; twoFactorEnabled?: boolean; + notifyOnBankTransactionsSynced?: boolean; }) { const { auth, currencies } = usePage().props; const passwordInput = useRef(null); const currentPasswordInput = useRef(null); + const [notifyBankTransactions, setNotifyBankTransactions] = useState( + notifyOnBankTransactionsSynced, + ); + + const handleNotifyBankTransactionsChange = (checked: boolean) => { + setNotifyBankTransactions(checked); + + router.patch( + '/settings/notifications', + { notifications: { bank_transactions_synced: checked } }, + { preserveScroll: true, preserveState: true }, + ); + }; const { qrCodeSvg, @@ -257,6 +273,40 @@ export default function Account({ +
+ + +
+ + handleNotifyBankTransactionsChange( + checked === true, + ) + } + className="mt-0.5" + /> +
+ +

+ {__( + 'Receive an email when new transactions are imported from your connected banks. Sent at most once a day.', + )} +

+
+
+
+ + +
{{ __('Álvaro & Víctor') }}
{{ __('Founders of Whisper Money') }} + + +{{ __('Don\'t want these emails? Manage notifications in [account settings](:url).', ['url' => route('account.edit')]) }} + diff --git a/resources/views/vendor/mail/html/themes/default.css b/resources/views/vendor/mail/html/themes/default.css index 04e56d2d..878c99e2 100644 --- a/resources/views/vendor/mail/html/themes/default.css +++ b/resources/views/vendor/mail/html/themes/default.css @@ -163,7 +163,9 @@ img { } .subcopy p { + color: #a1a1aa; font-size: 14px; + text-align: center; } /* Footer */ diff --git a/routes/settings.php b/routes/settings.php index 541115e2..16c78045 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Settings\ChartColorSchemeController; use App\Http\Controllers\Settings\LabelController; use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController; use App\Http\Controllers\Settings\NetWorthChartRealEstatePreferenceController; +use App\Http\Controllers\Settings\NotificationPreferenceController; use App\Http\Controllers\Settings\PasswordController; use App\Http\Controllers\Settings\ProfileController; use App\Http\Controllers\Settings\TimezoneController; @@ -40,6 +41,9 @@ Route::middleware('auth')->group(function () { Route::patch('settings/accounts/{account}', [AccountController::class, 'update'])->name('accounts.update'); Route::delete('settings/accounts/{account}', [AccountController::class, 'destroy'])->name('accounts.destroy'); + Route::patch('settings/notifications', [NotificationPreferenceController::class, 'update']) + ->name('notifications.update'); + Route::get('settings/banks', [BankController::class, 'index'])->name('banks.index'); Route::post('settings/banks', [BankController::class, 'store'])->name('banks.store'); diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php index 074d631a..97792d46 100644 --- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php +++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php @@ -485,6 +485,38 @@ test('daily bank sync email job sends pending transactions once per day', functi Mail::assertQueued(BankTransactionsSyncedEmail::class, 1); }); +test('daily bank sync email job does not send when user disabled the notification', function () { + Mail::fake(); + + test()->travelTo(Carbon::parse('2026-04-15 09:00:00')); + + $user = User::factory()->onboarded()->create(); + $user->setting()->create(['notify_on_bank_transactions_synced' => false]); + $bank = Bank::factory()->create(['name' => 'Opted Out Bank']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'bank_id' => $bank->id, + ]); + + Transaction::factory()->count(2)->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + $job = new SendDailyBankTransactionsSyncedEmailJob($user, now()->toDateString()); + $job->handle(); + + Mail::assertNothingQueued(); + expect(UserMailLog::query()->count())->toBe(0); +}); + test('daily bank sync email job releases during quiet hours in user timezone', function () { Mail::fake(); diff --git a/tests/Feature/Settings/NotificationPreferenceTest.php b/tests/Feature/Settings/NotificationPreferenceTest.php new file mode 100644 index 00000000..e6f4e1e0 --- /dev/null +++ b/tests/Feature/Settings/NotificationPreferenceTest.php @@ -0,0 +1,87 @@ + false]); +}); + +test('notification preferences can be updated', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch(route('notifications.update'), [ + 'notifications' => ['bank_transactions_synced' => false], + ]); + + $response->assertSessionHasNoErrors()->assertRedirect(); + + expect($user->fresh()->setting->notify_on_bank_transactions_synced)->toBeFalse(); +}); + +test('notification preferences reject unknown notification keys', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch(route('notifications.update'), [ + 'notifications' => ['unknown_notification' => true], + ]); + + $response->assertSessionHasErrors('notifications'); +}); + +test('notification preferences reject invalid values', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch(route('notifications.update'), [ + 'notifications' => ['bank_transactions_synced' => 'sometimes'], + ]); + + $response->assertSessionHasErrors('notifications.bank_transactions_synced'); +}); + +test('notification preferences require authentication', function () { + $response = $this->patch(route('notifications.update'), [ + 'notifications' => ['bank_transactions_synced' => false], + ]); + + $response->assertRedirect(route('register')); +}); + +test('notification preferences create setting when none exists', function () { + $user = User::factory()->create(); + + expect(UserSetting::where('user_id', $user->id)->exists())->toBeFalse(); + + $this->actingAs($user) + ->patch(route('notifications.update'), [ + 'notifications' => ['bank_transactions_synced' => false], + ]); + + expect(UserSetting::where('user_id', $user->id)->exists())->toBeTrue(); + expect($user->fresh()->setting->notify_on_bank_transactions_synced)->toBeFalse(); +}); + +test('bank transactions notification defaults to true when no setting exists', function () { + $user = User::factory()->create(); + + expect($user->setting)->toBeNull(); + expect($user->wantsBankTransactionsSyncedEmail())->toBeTrue(); +}); + +test('bank transactions notification preference is shared with the account page', function () { + $user = User::factory()->create(); + UserSetting::factory()->for($user)->create([ + 'notify_on_bank_transactions_synced' => false, + ]); + + $response = $this->actingAs($user)->get(route('account.edit')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page->where('notifyOnBankTransactionsSynced', false)); +});