Commit 8d392c04 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(portal): notification preferences, language, and transfer reconciliation

Three P1 items, and a latent bug each of them depended on.

SetLocale was registered nowhere. The middleware has existed since early on
and no middleware group ever included it, so `app()->getLocale()` returned the
config default on every request and the bilingual half of an Arabic-first
product was dead code. Worse, that default was 'en' — so every page announced
`lang="en"` while being marked `dir="rtl"`, telling a screen reader two
contradictory things about the same text. The default is now 'ar', the
middleware runs, and the portal's direction follows the locale instead of being
hardcoded.

notification_preferences had per-event, per-channel columns and no interface
anywhere: it was written to by the deleted API and by nothing else, so every
member received everything on every channel with no way to say otherwise. The
preferences screen defaults an unset choice to ON — the member has not asked
for less, and silently defaulting to off means a missed instalment nobody was
told about.

The device list is on the same screen, because push is the one channel whose
recipients a member cannot otherwise see: an old phone, a browser at work, a
device someone else now owns, all receiving silently until the token rotates.

Transfer reconciliation is the control that makes proof approval honest. A
screenshot is not evidence; matching the day's total against the academy's own
statement is. The report is per branch per day with deliberately blank
statement and signature columns, because a report that cannot be signed is not
a control. It also surfaces ageing proofs — a member told "we will check" who
heard nothing — and transfers recorded with no proof behind them, which are
legitimate but which a reconciler needs to expect.

One column asserts a database constraint rather than a number: an approved
proof with no payment is made unrepresentable by
payment_proofs_approved_payment_check, so a non-zero count there means the
constraint is gone, and the screen says exactly that. A constraint nobody ever
looks at is one you find out about the hard way.

notification_preferences.academy_id added to the model's fillable — S1 added
the column and the model was still writing rows that belonged to no tenant.

Verified on the restored tenant: 12 portal screens and 6 staff screens render
200; suite 76 pass on SQLite.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent e5ce6f7f
......@@ -10,6 +10,9 @@ class NotificationPreference extends Model
protected $table = 'notification_preferences';
protected $fillable = [
// academy_id since S1's tenancy repair: this is a tenant table and was
// the only one of four still writing rows that belonged to no academy.
'academy_id',
'user_id',
'event_type',
'channel_email',
......
<?php
namespace App\Livewire\Financial;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\PaymentProof;
use App\Domain\Shared\Context\BranchContext;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* What the system says arrived by transfer, per branch per day, against what
* the academy's own statement says.
*
* A screenshot is not evidence — reconciliation is. Approving a proof records
* that a member of staff believed a transfer happened; this is the screen that
* catches the day when the belief and the bank disagree, which is the only way
* an approval process for money is honest.
*
* Three things it surfaces that nothing else does:
*
* - **Ageing.** A proof sitting unreviewed for days is a member who was told
* "we will check" and heard nothing.
* - **Unmatched approvals.** A proof approved but never posted — impossible
* given the CHECK constraint, which is exactly why an empty column here is
* a meaningful assertion rather than a hope.
* - **Transfers with no proof at all.** A transfer payment recorded at the
* till or in the office, with nothing to match against. Not wrong, but the
* reconciler needs to know it will not find one.
*/
#[Layout('layouts.app')]
#[Title('مطابقة التحويلات')]
class TransferReconciliation extends Component
{
#[Url(as: 'from')]
public string $from = '';
#[Url(as: 'to')]
public string $to = '';
public function mount(): void
{
$this->authorize('reports.financial');
$this->from = $this->from ?: now()->startOfMonth()->toDateString();
$this->to = $this->to ?: now()->toDateString();
}
public function render()
{
$branchId = app(BranchContext::class)->branchId();
// One row per branch per day, so a reconciler can sit with a statement
// and tick along.
$daily = Payment::query()
->whereIn('method', ['instapay', 'bank_transfer'])
->where('direction', 'inbound')
->whereBetween('payment_date', [$this->from, $this->to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->join('branches', 'branches.id', '=', 'payments.branch_id')
->groupBy('payments.payment_date', 'payments.branch_id', 'branches.name_ar', 'branches.name')
->orderByDesc('payments.payment_date')
->select([
'payments.payment_date',
'payments.branch_id',
DB::raw('COALESCE(branches.name_ar, branches.name) as branch_name'),
DB::raw('COUNT(*) as transfers'),
DB::raw('SUM(payments.amount) as total'),
])
->get();
$proofs = PaymentProof::query()
->whereBetween('created_at', [$this->from . ' 00:00:00', $this->to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
return view('livewire.financial.transfer-reconciliation', [
'daily' => $daily,
'grandTotal' => (int) $daily->sum('total'),
// Ageing: how long the oldest unreviewed proof has been waiting.
'ageing' => (clone $proofs)->whereIn('status', ['pending', 'under_review'])
->orderBy('created_at')
->with(['participant.person', 'invoice'])
->get(),
'rejected' => (clone $proofs)->where('status', 'rejected')->count(),
'approved' => (clone $proofs)->where('status', 'approved')->count(),
// Must always be empty: payment_proofs_approved_payment_check makes
// an approved proof without a payment unrepresentable. Shown anyway,
// because a constraint you never look at is a constraint you find
// out about the hard way.
'unmatched' => (clone $proofs)->where('status', 'approved')->whereNull('payment_id')->count(),
// Transfers recorded with no proof behind them — office and till
// transfers. Not wrong; the reconciler needs to expect them.
'withoutProof' => Payment::query()
->whereIn('method', ['instapay', 'bank_transfer'])
->where('direction', 'inbound')
->whereBetween('payment_date', [$this->from, $this->to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereNotExists(function ($q) {
$q->select(DB::raw(1))
->from('payment_proofs')
->whereColumn('payment_proofs.payment_id', 'payments.id');
})
->count(),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Notification\Models\NotificationPreference;
use App\Domain\Shared\Models\DeviceToken;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Which notifications reach the member, on which channel — and the devices
* currently entitled to receive them.
*
* `notification_preferences` has existed with per-event, per-channel columns
* and no interface anywhere: the table was written to by the deleted API and by
* nothing else, so in practice every member received everything on every
* channel with no way to say otherwise.
*
* The device list is here too, because push is the one channel a member cannot
* see the recipients of. An old phone, a browser at work, a device someone else
* now owns — all silently keep receiving until the token rotates.
*/
#[Layout('layouts.portal')]
#[Title('التفضيلات')]
class PortalPreferences extends Component
{
use PortalScreen;
/** @var array<string, array<string, bool>> event => channel => enabled */
public array $preferences = [];
public string $locale = 'ar';
/**
* The events a member can actually choose about.
*
* Deliberately not every event_type the system emits: a member cannot
* usefully opt out of "your payment was received", and offering the choice
* implies a control that should not exist.
*/
public const EVENTS = [
'session_reminder' => 'تذكير بالحصص',
'attendance_marked' => 'تسجيل الحضور',
'invoice_issued' => 'فاتورة جديدة',
'installment_due' => 'قسط مستحق',
'evaluation_shared' => 'تقييم جديد',
'document_expiring' => 'مستند على وشك الانتهاء',
'announcement' => 'إعلانات الأكاديمية',
'waitlist_offer' => 'توفّر مقعد',
];
public const CHANNELS = [
'channel_push' => 'إشعار على الهاتف',
'channel_in_app' => 'داخل التطبيق',
'channel_email' => 'بريد إلكتروني',
'channel_sms' => 'رسالة نصية',
];
public function mount(): void
{
$this->authorizePortal();
$this->locale = session('locale', app()->getLocale());
$existing = NotificationPreference::where('user_id', auth()->id())
->get()
->keyBy('event_type');
foreach (array_keys(self::EVENTS) as $event) {
$row = $existing[$event] ?? null;
foreach (array_keys(self::CHANNELS) as $channel) {
// Absent means "not yet chosen", which is opted in — the member
// has not asked for less, and silently defaulting to off would
// mean a missed instalment nobody was told about.
$this->preferences[$event][$channel] = $row ? (bool) $row->{$channel} : true;
}
}
}
public function save(): void
{
$user = auth()->user();
foreach ($this->preferences as $event => $channels) {
if (! array_key_exists($event, self::EVENTS)) {
continue;
}
NotificationPreference::updateOrCreate(
['user_id' => $user->id, 'event_type' => $event],
[
// academy_id exists on this table since S1's tenancy repair;
// without it the row belonged to no tenant.
'academy_id' => $user->academy_id,
'channel_push' => (bool) ($channels['channel_push'] ?? true),
'channel_in_app' => (bool) ($channels['channel_in_app'] ?? true),
'channel_email' => (bool) ($channels['channel_email'] ?? true),
'channel_sms' => (bool) ($channels['channel_sms'] ?? true),
]
);
}
session()->flash('success', __('تم حفظ التفضيلات'));
}
public function setLocale(string $locale): void
{
if (! in_array($locale, ['ar', 'en'], true)) {
return;
}
// SetLocale reads this on the next request. It was registered nowhere
// until now, so this session key had no effect on anything.
session(['locale' => $locale]);
$this->redirect(route('portal.preferences'), navigate: false);
}
public function forgetDevice(int $deviceId): void
{
DeviceToken::where('id', $deviceId)
->where('user_id', auth()->id())
->delete();
session()->flash('success', __('تم إلغاء تسجيل الجهاز'));
}
public function render()
{
return view('livewire.portal.portal-preferences', [
'events' => self::EVENTS,
'channels' => self::CHANNELS,
'devices' => DeviceToken::where('user_id', auth()->id())
->orderByDesc('last_used_at')
->get(),
]);
}
}
......@@ -28,6 +28,12 @@
// ResolveBranchContext needs to know which branches exist, and
// RequireBranchSelection needs a resolved context to judge.
$middleware->web(append: [
// SetLocale has existed since early on and was registered nowhere,
// so `app()->getLocale()` returned the config default on every
// request and the bilingual half of an Arabic-first product was
// dead code. It must run before anything renders a `lang` or `dir`
// attribute.
\App\Http\Middleware\SetLocale::class,
\App\Http\Middleware\SetCurrentAcademy::class,
\App\Http\Middleware\ResolveBranchContext::class,
\App\Http\Middleware\RequireBranchSelection::class,
......
......@@ -78,9 +78,12 @@
|
*/
'locale' => env('APP_LOCALE', 'en'),
// Arabic-first. The default was 'en', so every request that did not set a
// locale explicitly reported English — which is what a screen reader was
// told while the document was marked dir="rtl".
'locale' => env('APP_LOCALE', 'ar'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'ar'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
......
@php
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
// Direction follows the locale rather than being hardcoded: a document
// marked dir="rtl" while announcing lang="en" tells a screen reader two
// contradictory things about the same text.
$locale = app()->getLocale();
$dir = in_array($locale, ['ar', 'he', 'fa', 'ur'], true) ? 'rtl' : 'ltr';
$portal = app(\App\Domain\Shared\Context\PortalContext::class);
$profiles = $portal->switchableProfiles();
$activeProfile = $portal->activeParticipant();
@endphp
<!DOCTYPE html>
<html dir="rtl" lang="{{ app()->getLocale() }}" class="h-full"
<html dir="{{ $dir }}" lang="{{ $locale }}" class="h-full"
data-theme-mode="{{ $brand->themeMode }}"
data-theme="{{ $brand->themeMode === 'dark' ? 'dark' : 'light' }}">
<head>
......
<div class="space-y-5">
<header>
<h1 class="text-xl font-bold text-gray-900">{{ __('مطابقة التحويلات') }}</h1>
<p class="mt-0.5 text-sm text-gray-500">
{{ __('ما يقول النظام إنه وصل بالتحويل، لكل فرع ولكل يوم — للمطابقة مع كشف حساب الأكاديمية.') }}
</p>
</header>
<div class="flex flex-wrap items-end gap-3 rounded-xl border border-gray-200 bg-white p-4">
<div>
<label for="from" class="block text-xs font-semibold text-gray-700">{{ __('من') }}</label>
<input id="from" type="date" dir="ltr" wire:model.live="from" class="mt-1 rounded-lg border-gray-300 text-sm">
</div>
<div>
<label for="to" class="block text-xs font-semibold text-gray-700">{{ __('إلى') }}</label>
<input id="to" type="date" dir="ltr" wire:model.live="to" class="mt-1 rounded-lg border-gray-300 text-sm">
</div>
</div>
<div class="grid gap-3 sm:grid-cols-4">
@foreach([
[__('إجمالي التحويلات'), format_money($grandTotal), 'text-gray-900'],
[__('إثباتات معتمدة'), $approved, 'text-emerald-700'],
[__('إثباتات مرفوضة'), $rejected, 'text-gray-600'],
[__('تحويلات بلا إثبات'), $withoutProof, 'text-amber-700'],
] as [$label, $value, $tone])
<div class="rounded-xl border border-gray-200 bg-white p-4">
<p class="text-xs text-gray-500">{{ $label }}</p>
<p class="mt-1 text-xl font-extrabold {{ $tone }}" dir="ltr">{{ $value }}</p>
</div>
@endforeach
</div>
@if($unmatched > 0)
{{-- A CHECK constraint makes this state unrepresentable. If it is ever
non-zero the constraint is gone, which is a far bigger problem than
the number itself. --}}
<div class="rounded-xl border-2 border-red-300 bg-red-50 p-4">
<p class="font-bold text-red-800">
{{ __('تحذير: ') }}{{ $unmatched }} {{ __('إثبات معتمد بلا دفعة مسجَّلة') }}
</p>
<p class="mt-1 text-xs text-red-700">
{{ __('هذه الحالة يمنعها قيد في قاعدة البيانات. ظهورها يعني أن القيد لم يعد موجوداً — راجع الترحيلات فوراً.') }}
</p>
</div>
@endif
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<h2 class="border-b border-gray-100 px-4 py-3 text-sm font-bold text-gray-900">
{{ __('يومياً حسب الفرع') }}
</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-xs text-gray-500">
<tr>
<th class="px-4 py-3 text-start font-medium">{{ __('التاريخ') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الفرع') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('عدد التحويلات') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الإجمالي') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('كشف الحساب') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('توقيع المُطابِق') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($daily as $row)
<tr>
<td class="px-4 py-3 font-mono text-xs" dir="ltr">{{ $row->payment_date }}</td>
<td class="px-4 py-3">{{ $row->branch_name }}</td>
<td class="px-4 py-3 font-mono" dir="ltr">{{ $row->transfers }}</td>
<td class="px-4 py-3 font-mono font-semibold" dir="ltr">{{ format_money((int) $row->total) }}</td>
{{-- Deliberately blank and printable: the reconciler
fills these against the bank and signs. A report
that cannot be signed is not a control. --}}
<td class="px-4 py-3"><span class="inline-block h-5 w-28 border-b border-dashed border-gray-300"></span></td>
<td class="px-4 py-3"><span class="inline-block h-5 w-28 border-b border-dashed border-gray-300"></span></td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-10 text-center text-gray-500">{{ __('لا توجد تحويلات في هذه الفترة') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
@if($ageing->isNotEmpty())
<section class="overflow-hidden rounded-xl border border-amber-200 bg-white">
<h2 class="border-b border-amber-100 bg-amber-50 px-4 py-3 text-sm font-bold text-amber-900">
{{ __('إثباتات تنتظر المراجعة') }} ({{ $ageing->count() }})
</h2>
<ul class="divide-y divide-gray-100">
@foreach($ageing as $proof)
<li class="flex flex-wrap items-center justify-between gap-2 px-4 py-3 text-sm">
<span class="font-medium text-gray-900">{{ $proof->participant?->person?->name_ar ?? '—' }}</span>
<span class="font-mono text-xs text-gray-500" dir="ltr">{{ $proof->invoice?->number }}</span>
<span class="font-mono" dir="ltr">{{ format_money((int) $proof->amount_claimed) }}</span>
<span class="text-xs {{ $proof->created_at?->lt(now()->subDays(2)) ? 'font-bold text-red-700' : 'text-gray-500' }}">
{{ __('منذ') }} {{ $proof->created_at?->diffForHumans(null, true) }}
</span>
</li>
@endforeach
</ul>
</section>
@endif
</div>
......@@ -143,6 +143,7 @@ class="block rounded-xl px-4 py-3 text-center text-sm font-bold"
['portal.dues', 'الأقساط والعروض'],
['portal.documents', 'المستندات'],
['portal.requests.create', 'تقديم طلب'],
['portal.preferences', 'التفضيلات واللغة'],
['portal.privacy', 'الخصوصية والبيانات'],
] as [$route, $label])
<li>
......
<div class="space-y-4">
<x-portal.card :title="__('اللغة')">
<div class="flex gap-2">
@foreach(['ar' => 'العربية', 'en' => 'English'] as $code => $label)
<button type="button" wire:click="setLocale('{{ $code }}')"
class="flex-1 rounded-xl border px-4 py-2.5 text-sm font-semibold"
style="{{ $locale === $code
? 'background: var(--brand-500); color: var(--brand-fg); border-color: var(--brand-500);'
: 'border-color: var(--portal-border); color: var(--portal-muted);' }}"
@if($locale === $code) aria-current="true" @endif>
{{ $label }}
</button>
@endforeach
</div>
</x-portal.card>
<x-portal.card :title="__('الإشعارات')">
<p class="mb-3 text-[11px] leading-relaxed" style="color: var(--portal-muted);">
{{ __('اختر ما يصلك وعلى أي قناة. لا تشمل هذه الإعدادات الرسائل الضرورية مثل تأكيد الدفع.') }}
</p>
<div class="space-y-4">
@foreach($events as $event => $eventLabel)
<fieldset>
<legend class="text-sm font-semibold">{{ __($eventLabel) }}</legend>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-2">
@foreach($channels as $channel => $channelLabel)
<label class="flex items-center gap-1.5 text-[11px]">
<input type="checkbox"
wire:model="preferences.{{ $event }}.{{ $channel }}"
class="h-4 w-4 rounded"
style="accent-color: var(--brand-500);">
<span style="color: var(--portal-muted);">{{ __($channelLabel) }}</span>
</label>
@endforeach
</div>
</fieldset>
@endforeach
</div>
<button type="button" wire:click="save"
class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('حفظ') }}
</button>
</x-portal.card>
<x-portal.card :title="__('الأجهزة المسجَّلة')">
{{-- Push is the one channel whose recipients a member cannot otherwise
see: an old phone or a browser at work keeps receiving silently. --}}
@forelse($devices as $device)
<div class="flex items-center justify-between gap-3 border-b py-2.5 last:border-0"
style="border-color: var(--portal-border);">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">
{{ $device->device_name ?: ($device->platform === 'web' ? __('متصفح') : __('هاتف')) }}
</p>
<p class="truncate text-[11px]" style="color: var(--portal-muted);">
{{ $device->platform }}
@if($device->last_used_at) · {{ $device->last_used_at->diffForHumans() }} @endif
</p>
</div>
<button type="button" wire:click="forgetDevice({{ $device->id }})"
class="shrink-0 text-xs font-semibold" style="color: var(--brand-danger);">
{{ __('إلغاء') }}
</button>
</div>
@empty
<p class="py-2 text-xs" style="color: var(--portal-muted);">
{{ __('لا توجد أجهزة مسجَّلة لاستقبال الإشعارات') }}
</p>
@endforelse
</x-portal.card>
</div>
......@@ -611,6 +611,13 @@
Route::get('/contacts', \App\Livewire\Website\ContactSubmissionList::class)->name('contacts');
});
// ─── Transfer reconciliation ────────────────────────────────
// A screenshot is not evidence; reconciliation is. This is where the
// system's transfer total meets the academy's own bank statement.
Route::get('/reports/transfer-reconciliation', \App\Livewire\Financial\TransferReconciliation::class)
->middleware('permission:reports.financial')
->name('reports.transfer-reconciliation');
// ─── Member requests ────────────────────────────────────────
// One queue for freezes, transfers, cancellations, renewals and excuses.
// They are one interaction, and two queues is how one of them ends up
......@@ -754,6 +761,7 @@
Route::get('/pass', \App\Livewire\Portal\PortalPass::class)->name('pass');
Route::get('/dues', \App\Livewire\Portal\PortalDues::class)->name('dues');
Route::get('/privacy', \App\Livewire\Portal\PortalPrivacy::class)->name('privacy');
Route::get('/preferences', \App\Livewire\Portal\PortalPreferences::class)->name('preferences');
Route::get('/documents', \App\Livewire\Portal\PortalDocuments::class)
->middleware('permission:portal.documents')
......
......@@ -33,6 +33,8 @@ public function test_the_new_staff_screens_render_for_an_owner(): void
'portal-invitations.index',
'users.duplicates',
'attendance.scan',
'service-requests.index',
'reports.transfer-reconciliation',
] as $name) {
$response = $this->actingAs($owner)->get(route($name));
......
......@@ -47,6 +47,7 @@ public function test_every_portal_screen_renders_for_a_real_member(): void
'portal.documents' => null,
'portal.privacy' => null,
'portal.requests.create' => null,
'portal.preferences' => null,
];
foreach ($routes as $name => $needle) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment