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:
parent
e5b493329a
commit
e178f1b1bd
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -163,7 +163,9 @@ img {
|
|||
}
|
||||
|
||||
.subcopy p {
|
||||
color: #a1a1aa;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
Loading…
Reference in New Issue