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
private const MAX_FAILED_ATTEMPTS = 5;
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) {
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 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Services\SettingsService;
use App\Domain\Attendance\Services\AttendanceGenerationService;
use App\Domain\Training\Enums\RenewalPolicy;
......@@ -28,6 +29,7 @@ public function __construct(
private readonly PricingService $pricingService,
private readonly InvoiceService $invoiceService,
private readonly SettingsService $settings,
private readonly ProrationService $prorationService,
) {}
public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment
......@@ -385,13 +387,22 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
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([
'academy_id' => $enrollment->academy_id ?? $group->academy_id,
'branch_id' => $group->branch_id,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'number' => $this->invoiceService->generateNumber($group->academy_id),
'total_amount' => $priceResult->finalAmount,
'total_amount' => $finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
......@@ -399,10 +410,10 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
], [
[
'description' => "اشتراك: {$program->name_ar}",
'description' => $lineDescription,
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'unit_price' => $finalAmount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], $actor);
......
......@@ -13,7 +13,7 @@
#[Title('تسجيل الدخول')]
class Login extends Component
{
public string $email = '';
public string $identifier = '';
public string $password = '';
public bool $remember = false;
public ?string $errorMessage = null;
......@@ -21,7 +21,7 @@ class Login extends Component
public function rules(): array
{
return [
'email' => 'required|email',
'identifier' => 'required|string|min:3',
'password' => 'required|min:6',
];
}
......@@ -29,8 +29,8 @@ public function rules(): array
public function messages(): array
{
return [
'email.required' => 'البريد الإلكتروني مطلوب',
'email.email' => 'صيغة البريد الإلكتروني غير صحيحة',
'identifier.required' => 'البريد الإلكتروني أو رقم الهاتف مطلوب',
'identifier.min' => 'يجب أن يكون الإدخال 3 أحرف على الأقل',
'password.required' => 'كلمة المرور مطلوبة',
'password.min' => 'كلمة المرور يجب أن تكون 6 أحرف على الأقل',
];
......@@ -42,7 +42,7 @@ public function login(AuthService $authService): void
$this->errorMessage = null;
$result = $authService->attempt(
email: $this->email,
identifier: $this->identifier,
password: $this->password,
ip: request()->ip(),
userAgent: request()->userAgent(),
......@@ -54,7 +54,7 @@ public function login(AuthService $authService): void
} elseif ($result->reason === 'inactive') {
$this->errorMessage = 'الحساب غير نشط. تواصل مع الإدارة.';
} else {
$this->errorMessage = 'البريد الإلكتروني أو كلمة المرور غير صحيحة';
$this->errorMessage = 'بيانات الدخول غير صحيحة';
}
return;
}
......
......@@ -4,7 +4,9 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Shared\DTOs\ProrationResult;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\Enrollment;
......@@ -161,6 +163,24 @@ public function selectedProgramFee(): int
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()
{
$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
$this->call(RolesAndPermissionsSeeder::class);
$this->call(PermissionSeeder::class);
$this->call(PaymentNotificationTemplateSeeder::class);
$this->call(EnrollmentSettingsSeeder::class);
// Assign academy_owner role + super_admin
$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 @@
@endif
<form wire:submit="login">
<!-- Email -->
<!-- Identifier (email or phone) -->
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('البريد الإلكتروني') }}</label>
<input type="email" id="email" wire:model="email"
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"
<label for="identifier" class="block text-sm font-medium text-gray-700 mb-1">{{ __('البريد الإلكتروني أو رقم الهاتف') }}</label>
<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('identifier') border-red-500 @enderror"
style="--tw-ring-color: var(--brand-primary, #2563eb);"
placeholder="admin@example.com" dir="ltr" required autofocus inputmode="email" autocomplete="email">
@error('email')
placeholder="{{ __('admin@example.com أو 01012345678') }}" dir="ltr" required autofocus autocomplete="username">
@error('identifier')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
......
......@@ -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>
</div>
@if($this->selectedProgramFee > 0)
<div>
@php $proration = $this->proratedProgramFee; @endphp
<div class="col-span-2">
<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>
@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
{{-- Payment Option --}}
@if($program && $this->selectedProgramFee > 0)
@php $proration = $this->proratedProgramFee; @endphp
<div class="border-t border-gray-200 pt-6">
<div class="flex items-center gap-4 mb-4">
<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