Commit 0504e7d1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add phone-or-email login, Egyptian NID decoder, proration engine, and receptionist wizard overhaul

- AuthService: accepts email or phone for login (identifier-based lookup)
- Login UI: updated to text input with Arabic labels
- EgyptianNidDecoder: decodes 14-digit NID → birth date, gender, governorate
- ProrationResult DTO + ProrationService: mid-month enrollment fee proration (x/30 of remaining days based on configurable renewal day)
- EnrollmentSettingsSeeder: seeds enrollment.allow_proration and enrollment.renewal_day per academy
- NewRegistrationWizard: rewritten — player-first flow, NID auto-decode locks birth/gender/governorate, foreign player toggle, guardian name deduced from player name, phone-only guardian, super admin price override, proration display
- EnrollmentService: applies proration to invoice creation
- EnrollExistingWizard: adds proratedProgramFee computed property, review step shows original vs prorated fee
- Migration: adds governorate column to people table
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 20184e77
...@@ -12,9 +12,11 @@ class AuthService ...@@ -12,9 +12,11 @@ class AuthService
private const MAX_FAILED_ATTEMPTS = 5; private const MAX_FAILED_ATTEMPTS = 5;
private const LOCKOUT_MINUTES = 30; private const LOCKOUT_MINUTES = 30;
public function attempt(string $email, string $password, string $ip, ?string $userAgent = null): AuthResult public function attempt(string $identifier, string $password, string $ip, ?string $userAgent = null): AuthResult
{ {
$user = User::where('email', $email)->first(); $user = filter_var($identifier, FILTER_VALIDATE_EMAIL)
? User::where('email', $identifier)->first()
: User::where('phone', $identifier)->first();
if (!$user) { if (!$user) {
return new AuthResult(success: false, reason: 'invalid_credentials'); return new AuthResult(success: false, reason: 'invalid_credentials');
......
<?php
namespace App\Domain\Identity\Services;
use Carbon\Carbon;
class EgyptianNidDecoder
{
// Governorate codes → Arabic name
private const GOVERNORATES = [
'01' => 'القاهرة',
'02' => 'الإسكندرية',
'03' => 'بور سعيد',
'04' => 'السويس',
'11' => 'دمياط',
'12' => 'الدقهلية',
'13' => 'الشرقية',
'14' => 'القليوبية',
'15' => 'كفر الشيخ',
'16' => 'الغربية',
'17' => 'المنوفية',
'18' => 'البحيرة',
'19' => 'الإسماعيلية',
'21' => 'الجيزة',
'22' => 'بني سويف',
'23' => 'الفيوم',
'24' => 'المنيا',
'25' => 'أسيوط',
'26' => 'سوهاج',
'27' => 'قنا',
'28' => 'أسوان',
'29' => 'الأقصر',
'31' => 'البحر الأحمر',
'32' => 'الوادي الجديد',
'33' => 'مطروح',
'34' => 'شمال سيناء',
'35' => 'جنوب سيناء',
'88' => 'أجنبي مقيم',
];
public function decode(string $nid): array
{
$nid = preg_replace('/\D/', '', $nid);
if (strlen($nid) !== 14) {
return ['valid' => false];
}
$century = $nid[0];
if (!in_array($century, ['2', '3'])) {
return ['valid' => false];
}
$year = ($century === '2' ? '19' : '20') . substr($nid, 1, 2);
$month = substr($nid, 3, 2);
$day = substr($nid, 5, 2);
try {
$birthDate = Carbon::createFromDate((int)$year, (int)$month, (int)$day);
if ($birthDate->isFuture()) {
return ['valid' => false];
}
} catch (\Throwable) {
return ['valid' => false];
}
$govCode = substr($nid, 7, 2);
$governorate = self::GOVERNORATES[$govCode] ?? null;
// Last digit before check digit (position 12, 0-indexed) determines gender
// Odd = male, even = female
$genderDigit = (int) $nid[12];
$gender = ($genderDigit % 2 !== 0) ? 'male' : 'female';
return [
'valid' => true,
'birth_date' => $birthDate->toDateString(),
'gender' => $gender,
'governorate_ar' => $governorate,
'governorate_code' => $govCode,
];
}
}
<?php
namespace App\Domain\Shared\DTOs;
readonly class ProrationResult
{
public function __construct(
public bool $applied,
public int $originalAmount,
public int $proratedAmount,
public int $remainingDays,
public int $renewalDay,
public string $description,
) {}
}
<?php
namespace App\Domain\Shared\Services;
use App\Domain\Shared\DTOs\ProrationResult;
use Carbon\Carbon;
class ProrationService
{
public function __construct(
private readonly SettingsService $settings,
) {}
public function isEnabled(): bool
{
return (bool) $this->settings->get('enrollment.allow_proration', false);
}
public function renewalDay(): int
{
return (int) $this->settings->get('enrollment.renewal_day', 1);
}
/**
* Calculate prorated fee for a mid-month enrollment.
* If today is on or before the renewal day, no proration applies (full price).
*/
public function calculate(int $baseAmount, ?Carbon $enrollmentDate = null): ProrationResult
{
$today = $enrollmentDate ?? now();
$renewalDay = $this->renewalDay();
$currentDay = (int) $today->day;
// No proration if enrolling on or before the renewal day
if ($currentDay <= $renewalDay) {
return new ProrationResult(
applied: false,
originalAmount: $baseAmount,
proratedAmount: $baseAmount,
remainingDays: 30,
renewalDay: $renewalDay,
description: '',
);
}
// Days remaining until next renewal: renewal_day + 30 - today
$remainingDays = $renewalDay + 30 - $currentDay;
$remainingDays = max(1, $remainingDays);
$proratedAmount = (int) ceil($baseAmount * $remainingDays / 30);
$description = "متناسب: {$remainingDays} من 30 يوم";
return new ProrationResult(
applied: true,
originalAmount: $baseAmount,
proratedAmount: $proratedAmount,
remainingDays: $remainingDays,
renewalDay: $renewalDay,
description: $description,
);
}
}
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService; use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Services\SettingsService; use App\Domain\Shared\Services\SettingsService;
use App\Domain\Attendance\Services\AttendanceGenerationService; use App\Domain\Attendance\Services\AttendanceGenerationService;
use App\Domain\Training\Enums\RenewalPolicy; use App\Domain\Training\Enums\RenewalPolicy;
...@@ -28,6 +29,7 @@ public function __construct( ...@@ -28,6 +29,7 @@ public function __construct(
private readonly PricingService $pricingService, private readonly PricingService $pricingService,
private readonly InvoiceService $invoiceService, private readonly InvoiceService $invoiceService,
private readonly SettingsService $settings, private readonly SettingsService $settings,
private readonly ProrationService $prorationService,
) {} ) {}
public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment
...@@ -385,13 +387,22 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -385,13 +387,22 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
return; return;
} }
// Apply proration if enabled
$proration = $this->prorationService->calculate($priceResult->finalAmount);
$finalAmount = $proration->proratedAmount;
$lineDescription = "اشتراك: {$program->name_ar}";
if ($proration->applied) {
$lineDescription .= " ({$proration->description})";
}
$invoice = $this->invoiceService->create([ $invoice = $this->invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $group->academy_id, 'academy_id' => $enrollment->academy_id ?? $group->academy_id,
'branch_id' => $group->branch_id, 'branch_id' => $group->branch_id,
'billable_type' => $participant->getMorphClass(), 'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id, 'billable_id' => $participant->id,
'number' => $this->invoiceService->generateNumber($group->academy_id), 'number' => $this->invoiceService->generateNumber($group->academy_id),
'total_amount' => $priceResult->finalAmount, 'total_amount' => $finalAmount,
'subtotal_amount' => $priceResult->baseAmount, 'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount, 'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0, 'tax_amount' => 0,
...@@ -399,10 +410,10 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -399,10 +410,10 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name, 'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
], [ ], [
[ [
'description' => "اشتراك: {$program->name_ar}", 'description' => $lineDescription,
'quantity' => 1, 'quantity' => 1,
'unit_price' => $priceResult->baseAmount, 'unit_price' => $finalAmount,
'discount_amount' => $priceResult->totalDiscount, 'discount_amount' => 0,
'tax_amount' => 0, 'tax_amount' => 0,
], ],
], $actor); ], $actor);
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
#[Title('تسجيل الدخول')] #[Title('تسجيل الدخول')]
class Login extends Component class Login extends Component
{ {
public string $email = ''; public string $identifier = '';
public string $password = ''; public string $password = '';
public bool $remember = false; public bool $remember = false;
public ?string $errorMessage = null; public ?string $errorMessage = null;
...@@ -21,7 +21,7 @@ class Login extends Component ...@@ -21,7 +21,7 @@ class Login extends Component
public function rules(): array public function rules(): array
{ {
return [ return [
'email' => 'required|email', 'identifier' => 'required|string|min:3',
'password' => 'required|min:6', 'password' => 'required|min:6',
]; ];
} }
...@@ -29,8 +29,8 @@ public function rules(): array ...@@ -29,8 +29,8 @@ public function rules(): array
public function messages(): array public function messages(): array
{ {
return [ return [
'email.required' => 'البريد الإلكتروني مطلوب', 'identifier.required' => 'البريد الإلكتروني أو رقم الهاتف مطلوب',
'email.email' => 'صيغة البريد الإلكتروني غير صحيحة', 'identifier.min' => 'يجب أن يكون الإدخال 3 أحرف على الأقل',
'password.required' => 'كلمة المرور مطلوبة', 'password.required' => 'كلمة المرور مطلوبة',
'password.min' => 'كلمة المرور يجب أن تكون 6 أحرف على الأقل', 'password.min' => 'كلمة المرور يجب أن تكون 6 أحرف على الأقل',
]; ];
...@@ -42,7 +42,7 @@ public function login(AuthService $authService): void ...@@ -42,7 +42,7 @@ public function login(AuthService $authService): void
$this->errorMessage = null; $this->errorMessage = null;
$result = $authService->attempt( $result = $authService->attempt(
email: $this->email, identifier: $this->identifier,
password: $this->password, password: $this->password,
ip: request()->ip(), ip: request()->ip(),
userAgent: request()->userAgent(), userAgent: request()->userAgent(),
...@@ -54,7 +54,7 @@ public function login(AuthService $authService): void ...@@ -54,7 +54,7 @@ public function login(AuthService $authService): void
} elseif ($result->reason === 'inactive') { } elseif ($result->reason === 'inactive') {
$this->errorMessage = 'الحساب غير نشط. تواصل مع الإدارة.'; $this->errorMessage = 'الحساب غير نشط. تواصل مع الإدارة.';
} else { } else {
$this->errorMessage = 'البريد الإلكتروني أو كلمة المرور غير صحيحة'; $this->errorMessage = 'بيانات الدخول غير صحيحة';
} }
return; return;
} }
......
...@@ -4,7 +4,9 @@ ...@@ -4,7 +4,9 @@
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Models\BasePrice; use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Shared\DTOs\ProrationResult;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Activity; use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\Enrollment; use App\Domain\Training\Models\Enrollment;
...@@ -161,6 +163,24 @@ public function selectedProgramFee(): int ...@@ -161,6 +163,24 @@ public function selectedProgramFee(): int
return $price?->amount ?? 0; return $price?->amount ?? 0;
} }
#[Computed]
public function proratedProgramFee(): ProrationResult
{
$baseFee = $this->selectedProgramFee;
$service = app(ProrationService::class);
if (!$service->isEnabled() || $baseFee === 0) {
return new ProrationResult(
applied: false,
originalAmount: $baseFee,
proratedAmount: $baseFee,
remainingDays: 30,
renewalDay: $service->renewalDay(),
description: '',
);
}
return $service->calculate($baseFee);
}
public function render() public function render()
{ {
$searchResults = collect(); $searchResults = collect();
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('people', function (Blueprint $table) {
$table->string('governorate', 100)->nullable()->after('national_id');
});
}
public function down(): void
{
Schema::table('people', function (Blueprint $table) {
$table->dropColumn('governorate');
});
}
};
...@@ -54,6 +54,7 @@ public function run(): void ...@@ -54,6 +54,7 @@ public function run(): void
$this->call(RolesAndPermissionsSeeder::class); $this->call(RolesAndPermissionsSeeder::class);
$this->call(PermissionSeeder::class); $this->call(PermissionSeeder::class);
$this->call(PaymentNotificationTemplateSeeder::class); $this->call(PaymentNotificationTemplateSeeder::class);
$this->call(EnrollmentSettingsSeeder::class);
// Assign academy_owner role + super_admin // Assign academy_owner role + super_admin
$ownerRole = Role::where('academy_id', $academy->id) $ownerRole = Role::where('academy_id', $academy->id)
......
<?php
namespace Database\Seeders;
use App\Domain\Shared\Models\SystemSetting;
use App\Domain\Identity\Models\Organization;
use Illuminate\Database\Seeder;
class EnrollmentSettingsSeeder extends Seeder
{
public function run(): void
{
$academies = Organization::all();
$defaults = [
[
'group' => 'enrollment',
'key' => 'enrollment.allow_proration',
'value' => '0',
'type' => 'boolean',
'label_ar' => 'تفعيل الدفع الجزئي (تناسبي)',
'description_ar' => 'عند تفعيله، يدفع المشترك الذي يلتحق في منتصف الشهر نسبة الأيام المتبقية فقط',
],
[
'group' => 'enrollment',
'key' => 'enrollment.renewal_day',
'value' => '1',
'type' => 'integer',
'label_ar' => 'يوم التجديد الشهري',
'description_ar' => 'اليوم من كل شهر الذي يُعتبر موعد تجديد الاشتراك (1-28)',
],
];
foreach ($academies as $academy) {
foreach ($defaults as $setting) {
SystemSetting::firstOrCreate(
['academy_id' => $academy->id, 'key' => $setting['key']],
array_merge($setting, ['academy_id' => $academy->id]),
);
}
}
}
}
...@@ -22,14 +22,14 @@ ...@@ -22,14 +22,14 @@
@endif @endif
<form wire:submit="login"> <form wire:submit="login">
<!-- Email --> <!-- Identifier (email or phone) -->
<div class="mb-4"> <div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('البريد الإلكتروني') }}</label> <label for="identifier" class="block text-sm font-medium text-gray-700 mb-1">{{ __('البريد الإلكتروني أو رقم الهاتف') }}</label>
<input type="email" id="email" wire:model="email" <input type="text" id="identifier" wire:model="identifier"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent text-sm sm:text-base @error('email') border-red-500 @enderror" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent text-sm sm:text-base @error('identifier') border-red-500 @enderror"
style="--tw-ring-color: var(--brand-primary, #2563eb);" style="--tw-ring-color: var(--brand-primary, #2563eb);"
placeholder="admin@example.com" dir="ltr" required autofocus inputmode="email" autocomplete="email"> placeholder="{{ __('admin@example.com أو 01012345678') }}" dir="ltr" required autofocus autocomplete="username">
@error('email') @error('identifier')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p> <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror @enderror
</div> </div>
......
...@@ -272,9 +272,16 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-emerald-600 text-whi ...@@ -272,9 +272,16 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-emerald-600 text-whi
<span class="font-medium text-gray-800 ms-1">{{ $program->activity?->name_ar }}</span> <span class="font-medium text-gray-800 ms-1">{{ $program->activity?->name_ar }}</span>
</div> </div>
@if($this->selectedProgramFee > 0) @if($this->selectedProgramFee > 0)
<div> @php $proration = $this->proratedProgramFee; @endphp
<div class="col-span-2">
<span class="text-gray-500">{{ __('الرسوم') }}:</span> <span class="text-gray-500">{{ __('الرسوم') }}:</span>
<span class="font-bold text-green-700 ms-1" dir="ltr">{{ number_format($this->selectedProgramFee / 100, 2) }} {{ __('ج.م') }}</span> @if($proration->applied)
<span class="line-through text-gray-400 ms-1" dir="ltr">{{ number_format($proration->originalAmount / 100, 2) }} {{ __('ج.م') }}</span>
<span class="font-bold text-green-700 ms-2" dir="ltr">{{ number_format($proration->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
<span class="text-xs text-blue-600 ms-1">({{ $proration->description }})</span>
@else
<span class="font-bold text-green-700 ms-1" dir="ltr">{{ number_format($proration->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
@endif
</div> </div>
@endif @endif
@endif @endif
...@@ -284,6 +291,7 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-emerald-600 text-whi ...@@ -284,6 +291,7 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-emerald-600 text-whi
{{-- Payment Option --}} {{-- Payment Option --}}
@if($program && $this->selectedProgramFee > 0) @if($program && $this->selectedProgramFee > 0)
@php $proration = $this->proratedProgramFee; @endphp
<div class="border-t border-gray-200 pt-6"> <div class="border-t border-gray-200 pt-6">
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<label class="relative cursor-pointer" dir="ltr"> <label class="relative cursor-pointer" dir="ltr">
......
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