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.
This commit is contained in:
Víctor Falcón 2026-06-02 12:24:39 +02:00 committed by GitHub
parent e5b493329a
commit e178f1b1bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 298 additions and 1 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\UpdateNotificationPreferencesRequest;
use Illuminate\Http\RedirectResponse;
class NotificationPreferenceController extends Controller
{
/**
* Map of public notification keys to their `user_settings` columns.
*
* Add future notification types here to expose them through this endpoint.
*
* @var array<string, string>
*/
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();
}
}

View File

@ -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(),
]);
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Settings;
use App\Http\Controllers\Settings\NotificationPreferenceController;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationPreferencesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$allowedKeys = implode(',', array_keys(NotificationPreferenceController::PREFERENCES));
return [
'notifications' => ['required', 'array:'.$allowedKeys],
'notifications.*' => ['required', 'boolean'],
];
}
}

View File

@ -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)

View File

@ -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()) {

View File

@ -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',
];
}

View File

@ -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,
];
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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",

View File

@ -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<SharedData>().props;
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(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({
<Separator />
<div className="space-y-6">
<HeadingSmall
title={__('Notifications')}
description={__(
'Manage the automatic notifications you receive',
)}
/>
<div className="flex items-start gap-3">
<Checkbox
id="notify-on-bank-transactions-synced"
checked={notifyBankTransactions}
onCheckedChange={(checked) =>
handleNotifyBankTransactionsChange(
checked === true,
)
}
className="mt-0.5"
/>
<div className="grid gap-1">
<Label htmlFor="notify-on-bank-transactions-synced">
{__('New transactions from connected banks')}
</Label>
<p className="text-sm text-muted-foreground">
{__(
'Receive an email when new transactions are imported from your connected banks. Sent at most once a day.',
)}
</p>
</div>
</div>
</div>
<Separator />
<div className="space-y-6">
<HeadingSmall
title={__('Update password')}

View File

@ -14,4 +14,8 @@
{{ __('Best,') }}<br>
{{ __('Álvaro & Víctor') }}<br>
{{ __('Founders of Whisper Money') }}
<x-slot:subcopy>
{{ __('Don\'t want these emails? Manage notifications in [account settings](:url).', ['url' => route('account.edit')]) }}
</x-slot:subcopy>
</x-mail::message>

View File

@ -163,7 +163,9 @@ img {
}
.subcopy p {
color: #a1a1aa;
font-size: 14px;
text-align: center;
}
/* Footer */

View File

@ -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');

View File

@ -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();

View File

@ -0,0 +1,87 @@
<?php
use App\Models\User;
use App\Models\UserSetting;
beforeEach(function () {
config(['landing.hide_auth_buttons' => 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));
});