Commit c5c72141 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Full system implementation: domain modules, events, wizards, deployment

- All 14 domain modules with models, services, events, listeners
- Auto-enrollment with auto-group creation when capacity full
- SuperAdmin setup wizard (8-step first-login onboarding)
- Receptionist desk wizards (registration, enrollment, payment, POS)
- Complete permission seeder (138 permissions, 10 roles)
- Full sidebar navigation with permission gates
- Docker + supervisor (PHP-FPM, nginx, queue worker, scheduler)
- Pre-deploy checklist and FK verification scripts
- All 35 events registered with ShouldDispatchAfterCommit
- All listeners queued with ShouldQueue
- RTL Arabic-first UI with logical Tailwind properties
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 100e6ffe
---
description: Enforces migration-first development. Schema is source of truth.
globs: ["database/migrations/**", "app/Domain/**/Models/**", "app/Models/**"]
---
# Migration First — Schema Is Source of Truth
## Before Writing Any Model
1. The migration for this table MUST already exist
2. Model `$fillable` columns are COPIED from migration — not typed from memory
3. Model `$casts` match the migration column types exactly
4. Enum values match the CHECK constraint values character-for-character
## Migration Standards
- Table names: plural snake_case (`training_programs`, `invoice_items`)
- FK columns: `{singular_table}_id` (`academy_id`, `participant_id`)
- Booleans: `is_` or `has_` prefix, always have `->default()`
- Money: `bigInteger` (piasters, not pounds, not decimal)
- Status/type: `string(30)` with CHECK constraint via `DB::statement()`
- JSONB: `->default('{}')` or `->default('[]')` — never nullable
- Every tenant table has `$table->foreignId('academy_id')->constrained('organizations')`
- Every public entity has `$table->uuid('uuid')->unique()`
- Soft-deletable entities have `$table->softDeletes()`
- User-created entities have `$table->foreignId('created_by')->constrained('users')`
## CHECK Constraint Pattern
```php
DB::statement("ALTER TABLE {table} ADD CONSTRAINT {table}_{column}_check CHECK ({column} IN ('value1', 'value2'))");
```
## Column Name Reference (NEVER deviate)
- Tenant: `academy_id`
- Branch: `branch_id`
- Person: `person_id`
- Participant: `participant_id`
- Group: `training_group_id`
- Program: `training_program_id`
- Session: `training_session_id`
- Invoice: `invoice_id`
- Facility: `facility_id`
- Creator: `created_by`
- Approver: `approved_by`
- Product: `product_id`
- Warehouse: `warehouse_id`
## Index Rules
- `nullableMorphs()` already creates composite index — don't duplicate
- `foreignId()->constrained()` already creates single-column index
- Always index: `academy_id`, `status`, `created_at`
- Composite: leftmost column is the most selective filter
---
description: Enforces multi-tenancy rules. Every query scoped to academy.
globs: ["app/**/*.php", "database/migrations/**"]
---
# Multi-Tenancy — Every Query Scoped
## BelongsToAcademy Trait
Every domain model (except Organization itself) MUST use the `BelongsToAcademy` trait. This trait:
- Auto-sets `academy_id` on creating
- Adds global scope filtering by `app('current_academy')`
## Tables WITHOUT academy_id (exhaustive list)
- `organizations` (IS the tenant)
- `migrations`, `jobs`, `failed_jobs`, `cache`, `sessions`, `password_reset_tokens`
- `permissions` (shared definitions)
Everything else HAS `academy_id`. No exceptions.
## Cross-Tenant Safety
- Unique constraints MUST include `academy_id`: `unique(['academy_id', 'slug'])`
- FK references MUST be within same tenant (a payment can't reference another academy's invoice)
- Seeded data (accounts, roles) is PER academy — not shared
- SuperAdmin bypasses scope via `withoutGlobalScope('academy')`
- Regular users NEVER bypass scope
## When Creating Models
```php
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
class MyModel extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
}
```
---
description: All amounts stored as integers in piasters. Zero tolerance for floats.
globs: ["app/**/*.php", "database/migrations/**", "resources/views/**"]
---
# Money — Piasters Only
## Storage
- ALL money columns: `$table->bigInteger('column_name')` — NEVER decimal, float, or double
- Value represents piasters (1 EGP = 100 piasters)
- 150.00 EGP is stored as `15000`
- Negative amounts: ONLY for credits, refunds, adjustments
- Zero: valid (free trial, 100% discount)
## Display (Blade ONLY)
```php
// format_money() helper — the ONLY place conversion happens
function format_money(int $piasters): string {
return number_format($piasters / 100, 2) . ' ج.م';
}
```
## Input (from forms)
```php
// Convert user input (pounds) to storage (piasters)
$piasters = (int) round((float) $request->amount * 100);
```
## Arithmetic
- Always work with integers
- Division/splitting: round DOWN, assign remainder to last item
- NEVER do math on formatted strings
- Comparison: use integer comparison (`$balance >= $amount`), never float comparison
## Column Names for Money
These are ALL bigint piasters:
`total_amount`, `subtotal`, `discount_amount`, `tax_amount`, `paid_amount`, `balance_due`,
`unit_price`, `unit_cost`, `line_total`, `base_cost`, `selling_price`, `fee_amount`,
`opening_balance`, `closing_balance`, `wallet_balance`, `frozen_amount`,
`late_fee_amount`, `installment_amount`, `refunded_amount`, `variance`,
`total_purchases`, `total_sales`, `shipping_cost`, `rental_cost_per_hour`,
`component_cost`, `discrepancy_value`, `total_variance_value`
---
description: Service layer patterns. Events for side effects. No inline emails/notifications.
globs: ["app/Domain/**/Services/**", "app/Domain/**/Events/**", "app/Domain/**/Listeners/**"]
---
# Service Layer & Events
## Service Pattern (EVERY service)
```php
namespace App\Domain\{Module}\Services;
class {Entity}Service
{
public function create(array $data, User $actor): Model
{
return DB::transaction(function () use ($data, $actor) {
$entity = Entity::create([...]);
EntityCreated::dispatch($entity); // fires AFTER commit
return $entity;
});
}
}
```
## Service Rules
1. ALL writes inside `DB::transaction()`
2. Receive explicit params — NEVER use `auth()`, `request()`, `session()` inside services
3. Return the entity — caller decides response
4. Throw domain exceptions for business rule violations
5. Dispatch events — NEVER send email/SMS/notification directly
6. One service per domain entity
7. Services CAN call other services (constructor injection)
8. Services NEVER call controllers or Livewire components
## Event Rules
1. All domain events implement `ShouldDispatchAfterCommit`
2. Events carry: the entity + the actor (User)
3. Listeners implement `ShouldQueue` (async side effects)
4. One listener = ONE side effect
5. Listener failure does NOT roll back the original action
6. NEVER send email/notification from service or controller — use event + listener
## Exception Pattern
```php
namespace App\Domain\Shared\Exceptions;
class DomainException extends \RuntimeException {}
class InsufficientBalanceException extends DomainException {}
class InvalidStatusTransitionException extends DomainException {}
// ... etc
```
Controllers/Livewire catch these and show appropriate error messages.
## Dependency Direction
Services call DOWN the dependency chain, never up:
- POSService → InvoiceService → PaymentService → TransactionService
- EnrollmentService → PricingService, AttendanceGenerationService
- NEVER: PaymentService → POSService (use events instead)
---
description: Double-entry accounting, immutable transactions, invoice lifecycle.
globs: ["app/Domain/Financial/**", "database/migrations/**financial**", "database/migrations/**invoice**", "database/migrations/**payment**", "database/migrations/**wallet**", "database/migrations/**transaction**"]
---
# Financial Integrity
## Double-Entry — ALWAYS in Pairs
Every financial movement creates TWO transaction records (debit + credit).
Transaction amounts are ALWAYS positive. The type field (debit/credit) determines direction.
```php
// The ONLY way to write transactions
Transaction::create(['account_id' => $debit, 'type' => 'debit', 'amount' => $amount]);
Transaction::create(['account_id' => $credit, 'type' => 'credit', 'amount' => $amount]);
```
## Transactions Are IMMUTABLE
- No `updated_at` on transactions table
- No `deleted_at` — never soft-delete
- Corrections create NEW reversing entries — never modify existing
## Invoice State Machine (ONLY valid transitions)
```
draft → sent → partially_paid → paid
draft → cancelled
sent → cancelled (ONLY if paid_amount == 0)
sent → overdue (auto, when due_date passes)
overdue → partially_paid → paid
```
INVALID: `partially_paid → cancelled`, `paid → cancelled`
## Invoice Calculation Order
```
subtotal = sum(item.line_total)
discount_amount = applied discount
total_amount = subtotal - discount_amount + tax_amount
balance_due = total_amount - paid_amount
```
Total is FROZEN at creation. Never recalculate from items after invoice is sent.
## Payment Rules
- Amount MUST be > 0 AND <= invoice.balance_due
- Cannot pay cancelled or draft invoice
- After payment: update invoice.paid_amount, recalculate balance_due
- If balance_due == 0: invoice status = 'paid'
- Double-entry: Debit Cash/Card/Wallet, Credit Accounts Receivable
## Wallet Rules
- Balance NEVER goes negative (check BEFORE deduct)
- Always `lockForUpdate()` before modifying balance
- Every change = wallet_transactions record
- available_balance = balance - frozen_amount
## Refund Rules
- Refund amount <= payment.amount - payment.refunded_amount
- Creates reversing double-entry transactions
- Updates invoice.paid_amount and balance_due
- Refunds above threshold require approval
---
description: State machines for all entities. Only valid transitions allowed.
globs: ["app/Domain/**/Services/**", "app/Domain/**/Enums/**"]
---
# Status Transitions — Hard Enforcement
## Participant Status
Valid transitions (service MUST enforce):
```
registered → active, withdrawn
active → frozen, suspended, inactive, graduated, transferred, withdrawn, blacklisted
frozen → active, withdrawn, inactive
suspended → active, withdrawn, blacklisted
inactive → active, withdrawn
graduated → active
withdrawn → active
transferred → (terminal)
blacklisted → (terminal, super_admin override only)
```
NEVER: any status → registered, frozen → graduated, suspended → graduated
## Invoice Status
```
draft → sent, cancelled
sent → partially_paid, overdue, cancelled (only if paid_amount==0)
overdue → partially_paid, paid
partially_paid → paid (NEVER cancelled — has payments)
paid → refunded (via refund flow only)
```
## Training Group Status
```
forming → active, cancelled
active → full, on_hold, completed, cancelled
full → active (spot opens)
on_hold → active
```
## Enrollment Status
```
pending → active, cancelled, waitlisted
active → completed, cancelled, expired
waitlisted → pending (spot opens), cancelled
```
## Session Status
```
scheduled → in_progress, cancelled, rescheduled
in_progress → completed
```
## Implementation Pattern
```php
private const VALID_TRANSITIONS = [
'current_status' => ['allowed_target_1', 'allowed_target_2'],
];
public function changeStatus(Model $entity, string $newStatus, string $reason): void
{
$allowed = self::VALID_TRANSITIONS[$entity->status] ?? [];
if (!in_array($newStatus, $allowed)) {
throw new InvalidStatusTransitionException(
"Cannot transition from '{$entity->status}' to '{$newStatus}'"
);
}
// proceed...
}
```
Every service that manages a stateful entity MUST have this constant and validation.
---
description: Stock changes only through movements. Never modify levels directly.
globs: ["app/Domain/Inventory/**", "database/migrations/**inventory**", "database/migrations/**product**", "database/migrations/**warehouse**"]
---
# Inventory — Movements Are Law
## The Iron Rule
NEVER modify `inventory_levels.quantity_on_hand` directly. ALL changes go through `InventoryService::createMovement()` which:
1. Locks the level row (`lockForUpdate()`)
2. Records `quantity_before`
3. Adjusts level
4. Records `quantity_after`
5. Creates the movement record
## Movement Type → Direction (hardcoded, no exceptions)
| Type | Direction |
|------|-----------|
| `purchase_received` | `in` |
| `sale` | `out` |
| `transfer_out` | `out` |
| `transfer_in` | `in` |
| `return_to_stock` | `in` |
| `return_to_supplier` | `out` |
| `adjustment_up` | `in` |
| `adjustment_down` | `out` |
| `damage` | `out` |
| `loss` | `out` |
| `consumption` | `out` |
| `kit_assembly` | `out` |
| `kit_disassembly` | `in` |
| `initial_stock` | `in` |
| `count_adjustment_up` | `in` |
| `count_adjustment_down` | `out` |
If type doesn't match direction → throw exception.
## Stock Deduction Rules
- `track_inventory = false` products: NO movement created on sale
- `track_inventory = true` products: movement MUST be created on sale
- Kit sale: one movement PER component (type='kit_assembly')
- Stock can go to 0 but not negative (throw InsufficientStockException)
- Override: admin can force-sell with warning if physical stock exists but system shows 0
## Integrity
- `quantity_available = quantity_on_hand - quantity_reserved`
- Weekly reconciliation job: sum(in movements) - sum(out movements) should equal current level
- If mismatch: LOG WARNING, notify admin — do NOT auto-fix
---
description: Arabic-first RTL. Logical properties. Bilingual fields.
globs: ["resources/views/**", "resources/css/**", "resources/js/**"]
---
# RTL Arabic-First
## Default Direction
```html
<html dir="rtl" lang="ar">
```
Arabic is the DEFAULT. English/LTR is the exception.
## Tailwind — ALWAYS Logical Properties
| NEVER | ALWAYS | Why |
|-------|--------|-----|
| `ml-*` | `ms-*` | margin-start |
| `mr-*` | `me-*` | margin-end |
| `pl-*` | `ps-*` | padding-start |
| `pr-*` | `pe-*` | padding-end |
| `text-left` | `text-start` | |
| `text-right` | `text-end` | |
| `left-*` | `start-*` | |
| `right-*` | `end-*` | |
| `border-l-*` | `border-s-*` | |
| `border-r-*` | `border-e-*` | |
| `rounded-l-*` | `rounded-s-*` | |
| `rounded-r-*` | `rounded-e-*` | |
| `float-left` | `float-start` | |
| `float-right` | `float-end` | |
Exception: number inputs get `dir="ltr"` (numbers are always LTR).
## Bilingual Fields
Every user-facing entity has both `name` and `name_ar`:
- `name_ar` is REQUIRED (primary language)
- `name` is optional (English reference)
- Display: `app()->getLocale() === 'ar' ? $model->name_ar : $model->name`
## Strings
ALL user-facing strings wrapped in `__()`:
```php
session()->flash('success', __('تم الحفظ بنجاح'));
```
## Font
Primary: Cairo (Arabic) / Inter (Latin fallback)
```css
font-family: 'Cairo', 'Noto Sans Arabic', system-ui, sans-serif;
```
---
description: Livewire 3 + Alpine.js patterns. One Alpine scope per feature.
globs: ["app/Livewire/**", "resources/views/livewire/**", "resources/views/components/**"]
---
# Livewire & Alpine Rules
## Livewire Component Structure
### List components ALWAYS have:
- `WithPagination` trait
- `#[Url]` on search, status, sortBy, sortDir
- `updatedSearch()``$this->resetPage()`
- Permission check in render query: `PermissionService::applyScope()`
- Server-side pagination (NEVER load all records)
### Form components ALWAYS have:
- `$this->authorize()` in mount
- `rules()` method matching migration constraints exactly
- `messages()` with Arabic error messages
- `save()` wraps in try/catch for DomainExceptions
- Redirects with flash message on success
## Alpine.js — ONE Scope Per Feature
```html
<!-- CORRECT -->
<div x-data="{ activeTab: 'info', showModal: false }">
<!-- everything that needs these states lives inside -->
</div>
<!-- WRONG — nested x-data shadows parent -->
<div x-data="{ activeTab: 'info' }">
<div x-data="{ activeTab: 'sub' }"> <!-- SHADOWS! -->
```
## State Ownership
- Alpine owns: tabs, dropdowns, modals, tooltips, visual toggles
- Livewire owns: data, search, filters, CRUD, form submission
## Communication
```html
<!-- Alpine → Livewire -->
<button @click="$wire.delete(id)">
<!-- Livewire → Alpine -->
$this->dispatch('item-saved');
<!-- consumed: @item-saved.window="open = false" -->
```
## Loading States (MANDATORY)
Every submit button:
```html
<button wire:loading.attr="disabled" wire:target="save">
<span wire:loading.remove wire:target="save">حفظ</span>
<span wire:loading wire:target="save">جارٍ الحفظ...</span>
</button>
```
Every table:
```html
<div wire:loading.class="opacity-50 pointer-events-none">
```
## No Dead Links
- `href="#"` is FORBIDDEN
- If feature not built: don't render the link
- All links use `route('name')` — never hardcoded URLs
- Nav items wrapped in `@can('module.action')`
## Components — Required Interface
Every `<x-ui.input>` must accept: `name`, `label`, `error`, `required`, `disabled`
Every `<x-ui.button>` must accept: `type`, `variant`, `size`, `disabled`, `loading`
Every `<x-ui.modal>` must accept: `title`, `maxWidth`, `closeable`
## Debouncing
- Search: `wire:model.live.debounce.300ms`
- Expensive checks: `wire:model.blur`
- Regular form fields: `wire:model` (no live)
---
description: RBAC + scope-based permissions. Every route and action gated.
globs: ["app/Livewire/**", "routes/**", "resources/views/**"]
---
# Permissions — Gate Everything
## Permission Format
`{module}.{action}` — e.g., `participants.list`, `invoices.create`, `attendance.mark`
## Enforcement Points (ALL required)
1. **Route middleware**: `->middleware('permission:module.action')`
2. **Livewire mount**: `$this->authorize('module.action')`
3. **Service layer**: scope-check for specific records
4. **Blade**: `@can('module.action')` wraps buttons/links
5. **Query scope**: list queries filtered by user's scope
## Scope Resolution
When checking if user can access a SPECIFIC record:
```
'academy' → true (tenant scope already applied)
'branch' → record.branch_id === user.branch_id
'own' → record.created_by === user.id
'own_groups' → record's group is in user's assigned groups
'own_children' → record's participant is in guardian's children
```
## UI Rules
- No permission = element NOT rendered (not greyed out)
- Navigation items: only show what user can access
- Action buttons (create/edit/delete): gated individually
- Export buttons: require `{module}.export`
- Financial columns: hidden from non-financial roles
## Role Hierarchy
Level determines who can assign whom:
```
super_admin(100) > academy_owner(90) > academy_admin(80) > branch_manager(70)
> head_trainer(60) > trainer(50) > receptionist(40) > accountant(35)
> data_entry(30) > parent(5)
```
User can ONLY assign roles with level LOWER than their own.
---
description: Attendance auto-generation, grace periods, auto-absent, threshold enforcement.
globs: ["app/Domain/Attendance/**", "app/Domain/Training/**"]
---
# Attendance Engine
## Auto-Generation
When a training session is created:
1. Get all ACTIVE enrollments for the group → create `expected` records (subject_type='participant')
2. Get all ACTIVE assignments targeting this session/group → create `expected` records (subject_type matches role)
3. Do NOT generate for: frozen participants, cancelled enrollments
When enrollment activated:
- Generate `expected` for all FUTURE sessions of that group
When enrollment cancelled:
- DELETE all `expected` records for future sessions (keep past records intact)
## Status Values
```
expected, present, late, excused, absent, no_show, left_early, partial, cancelled, exempt
```
## Grace Period
- `actual_check_in <= expected_start + grace_period``present`
- `actual_check_in > expected_start + grace_period``late`, calculate `late_minutes`
- Grace periods: configurable per academy via system_settings
- Participants: default 15 min
- Trainers: default 10 min
## Auto-Absent (hourly job)
Records where:
- `status = 'expected'`
- Session `end_time` + 2 hours has passed
- No manual marking done
→ Set `status = 'absent'`, flag for admin review
## Attendance Rate Formula
```
rate = (present + late + partial) / (total - cancelled - exempt) × 100
```
- `excused` does NOT count (neither positive nor negative)
- `absent` + `no_show` count AGAINST
## Threshold Enforcement
- Consecutive absences (default 5) → auto-suspend participant
- Attendance % below minimum (default 75%) → notify guardian + admin
- Both thresholds configurable per academy in system_settings
## Assignment Override
Session-level assignment OVERRIDES group-level for that session:
- If session has its own trainer assignment → use that
- If not → fall back to group assignment
- The overridden trainer is NOT expected at that session
---
description: Pricing engine resolution. No fixed prices. Snapshot principle.
globs: ["app/Domain/Pricing/**", "app/Domain/Financial/Services/**"]
---
# Pricing Engine
## Core Principle
There are NO fixed prices. Every price is calculated at sale/enrollment time.
## Resolution Order (10 steps, no shortcuts)
1. Find base price (fail if none exists)
2. Gather applicable rules (active, in date range, matching branch/target)
3. Evaluate conditions against participant context
4. Filter non-stackable conflicts (highest priority wins)
5. Apply rules in priority order (ASC)
6. Apply rule-level caps (max_discount_percent per rule)
7. Apply promotion/coupon (if any)
8. Apply global maximum discount (academy setting, default 50%)
9. Floor check (never negative)
10. Return PriceResult
## Rule Types (exact 13)
`age`, `membership_duration`, `family_size`, `sibling_order`, `classification`,
`enrollment_timing`, `enrollment_volume`, `seasonal`, `gender`, `branch`,
`day_time`, `loyalty`, `custom`
## Adjustment Types
- `percentage_discount`: subtract X% from current price
- `fixed_discount`: subtract X piasters
- `fixed_price`: override to exact amount
- `percentage_increase`: add X%
## Snapshot Principle
Once a price is calculated and placed on an invoice:
- It is FROZEN forever on that invoice
- Changing rules/base prices does NOT affect existing invoices
- Next renewal/purchase recalculates with current rules
## No Base Price = HARD FAIL
If PricingService cannot find an active base_price for the item + branch + date:
- Throw exception
- Block the sale/enrollment
- Show error: "لا يوجد سعر محدد"
- NEVER default to 0 or guess
## Stackable vs Non-Stackable
- `stackable = true`: rule applies alongside others (cumulative discounts)
- `stackable = false`: if multiple non-stackable rules match, ONLY highest priority applies
- Stackable and non-stackable rules CAN coexist (stackable applies on top of the winning non-stackable)
---
description: POS transaction flow. 10 steps. All-or-nothing.
globs: ["app/Domain/POS/**", "app/Livewire/POS/**", "resources/views/livewire/pos/**"]
---
# POS — The 10-Step Flow
ALL steps happen within ONE `DB::transaction`. If any step fails, NOTHING is committed.
## Steps
1. **Identify participant** (optional for walk-in product sales, required for enrollment)
2. **Add items** — product (check stock), kit (check component availability), program (check prerequisites)
3. **Calculate prices** — each item through pricing engine individually
4. **Apply coupon** (validate all 8 checks)
5. **Calculate totals** — subtotal, discount, tax, total
6. **Select payment method** — cash, card, wallet, split
7. **Validate payment** — wallet balance check with lockForUpdate
8. **Commit transaction** (single DB::transaction):
- pos_transactions + items
- invoice + items
- payment(s) + double-entry transactions
- wallet deduction (if wallet)
- inventory movements (if tracked products/kits)
- enrollment record (if program)
- session totals update
9. **Generate receipt** — number format: `RCP-{BRANCH}-{YYYYMMDD}-{SEQ}`
10. **Dispatch events** — POSTransactionCompleted, EnrollmentCreated (if applicable)
## Guards (BLOCK sale if)
- No POS session open → "يجب فتح وردية أولاً"
- Participant suspended → block new purchases (allow paying outstanding balance only)
- Duplicate enrollment → "مسجل بالفعل في هذا البرنامج"
- Split payment doesn't cover total → "المبلغ المدفوع أقل من الإجمالي"
- Cart empty → disable submit
## Product Out of Stock
- Show warning with remaining quantity
- Allow admin override (sell what's physically there)
- Movement still created (stock goes to 0 or negative with flag)
## POS Session
- One open session per user per branch at a time
- Must be open before any sale
- Links to cash_session for financial reconciliation
- Tracks: transactions_count, total_sales
---
description: Vertical slice order. One feature end-to-end before the next.
globs: ["**"]
---
# Build Order — Vertical Slices
## The Rule
ONE vertical slice at a time: migration → model → service → controller/Livewire → view → test in browser.
NEVER batch 20 migrations, then 20 models, then 20 services.
## Module Build Order (dependencies dictate sequence)
### Phase 1: Foundation (no domain dependencies)
1. Organizations + Branches migration/model
2. Users migration/model + Auth (login, middleware)
3. Roles + Permissions migration/model/seeder
4. People migration/model
5. Layout (app.blade.php, sidebar, topbar) — bare minimum working UI
### Phase 2: Core Entities
6. Participants (migration → model → service → list/create/show views)
7. Guardians + pivot (migration → model → service → UI in participant form)
8. Activities (migration → model → simple CRUD)
9. Financial Accounts (migration → model → seeder)
### Phase 3: Programs & Training
10. Training Programs (migration → model → service → CRUD)
11. Training Groups (migration → model → service → CRUD)
12. Group Schedules + Session Generation
13. Enrollments (migration → model → service → UI in participant + program views)
### Phase 4: Financial
14. Invoices (migration → model → service → list/create/show)
15. Payments (migration → model → service → record payment UI)
16. Transactions (double-entry, created by PaymentService)
17. Wallets (migration → model → service → UI in participant profile)
18. Payment Plans + Installments
19. Cash Sessions + Daily Closing
### Phase 5: Scheduling & Space
20. Facilities (migration → model → CRUD)
21. Space Layouts + Segments
22. Space Reservations + Collision Detection
23. Assignments (migration → model → service → UI)
### Phase 6: Attendance
24. Attendance Records (migration → model)
25. Attendance Generation Service
26. Take Attendance UI (trainer view)
27. Auto-absent job + threshold enforcement
### Phase 7: Pricing & POS
28. Base Prices (migration → model → UI in program/product settings)
29. Pricing Rules (migration → model → service → rule builder UI)
30. Promotions/Coupons
31. POS Session + Transaction + Full POS UI
### Phase 8: Inventory
32. Products + Categories (migration → model → CRUD)
33. Warehouses + Inventory Levels
34. Inventory Movements
35. Purchase Orders
36. Kits
37. Stock Counts
### Phase 9: Notifications & CMS
38. Notification Templates + rendering
39. Email channel (poste.io)
40. SMS interface (log gateway)
41. Notification preferences
42. Audit Log (trait + observer)
### Phase 10: Reporting & Polish
43. Dashboard widgets
44. Exportable reports
45. Evaluations
46. System Settings UI
47. SuperAdmin panel
### Phase 11: Deployment
48. Dockerfile + nginx + supervisor
49. captain-definition
50. Environment variables
51. Deploy to CapRover
## Before Starting ANY Migration
1. Read the docs/rules file for that module
2. Check migration order in `docs/rules/11-integration-contract.md`
3. Ensure all prerequisite tables exist
4. Grep existing migrations for column names you're referencing
---
description: Cross-module integration verification. Check every dependency before committing.
globs: ["app/Domain/**", "database/migrations/**"]
---
# Integration Checks — Before Every Commit
## When Writing a Migration
- [ ] Column names match the exact names in `docs/rules/11-integration-contract.md`
- [ ] FK references point to tables that ALREADY EXIST (check migration order)
- [ ] CHECK constraint values exactly match the enum you'll write
- [ ] Composite unique constraints include `academy_id`
- [ ] No duplicate indexes (morphs and constrained() already create them)
## When Writing a Model
- [ ] `$fillable` lists EVERY column from migration (except id, timestamps, auto-fields)
- [ ] `$casts` match column types: bigint→'integer', jsonb→'array', date→'date', bool→'boolean'
- [ ] Enum casts use the correct enum class with matching values
- [ ] Relationships match FK direction (belongsTo where FK lives, hasMany on the other side)
- [ ] Traits applied: BelongsToAcademy, HasUuid, SoftDeletes, Auditable (as applicable)
## When Writing a Service
- [ ] Wrapped in DB::transaction
- [ ] Does NOT use auth(), request(), session()
- [ ] Dispatches events (does not send notifications directly)
- [ ] Validates business rules (state machine transitions, balance checks)
- [ ] Calls other services via constructor injection (not facades, not static)
- [ ] Updates ALL denormalized fields that this operation affects
## When Writing a Livewire Component
- [ ] mount() has $this->authorize() call
- [ ] rules() validation matches migration constraints character-for-character
- [ ] messages() has Arabic translations for every rule
- [ ] Query applies PermissionService::applyScope()
- [ ] try/catch around service calls with proper error handling
- [ ] Flash messages in Arabic
- [ ] Redirects to named routes
## When Writing a View
- [ ] No `href="#"` anywhere
- [ ] All links use `route()` helper
- [ ] All strings in `__()`
- [ ] All Tailwind uses logical properties (ms/me/ps/pe not ml/mr/pl/pr)
- [ ] Buttons/links wrapped in `@can()` for permission
- [ ] Loading states on all submit buttons
- [ ] Empty states for all lists
## Cross-Module Impact Checklist
When modifying enrollment:
- [ ] Group.current_count updated
- [ ] Attendance records generated/removed
- [ ] Invoice created (if fee > 0)
- [ ] Waitlist checked (if spot opened from cancellation)
- [ ] Participant status updated (registered → active on first enrollment)
When recording payment:
- [ ] Invoice.paid_amount updated
- [ ] Invoice.balance_due recalculated
- [ ] Invoice.status transitions if fully paid
- [ ] Double-entry transactions created
- [ ] Cash session updated (if cash)
- [ ] Wallet deducted (if wallet)
- [ ] Enrollment activated (if pending enrollment invoice now paid)
When cancelling session:
- [ ] Attendance records set to 'cancelled'
- [ ] Space reservation freed
- [ ] Affected people notified
- [ ] Session status set correctly
When changing participant status:
- [ ] Enrollments handled (frozen = pause, suspended = block new)
- [ ] Attendance expectations adjusted
- [ ] Financial impact handled (freeze stops billing)
- [ ] Notifications sent to guardian
---
description: Enum values MUST match CHECK constraints exactly. Single source of truth.
globs: ["app/Domain/**/Enums/**", "database/migrations/**"]
---
# Enums & CHECK Constraints — Character-for-Character Match
## The Rule
The PHP enum case values and the PostgreSQL CHECK constraint values must be IDENTICAL.
When writing an enum, COPY from the migration. When writing a migration, DEFINE the values first.
## Enum Pattern
```php
namespace App\Domain\{Module}\Enums;
enum StatusName: string
{
case Draft = 'draft';
case Active = 'active';
// Values are ALWAYS snake_case strings
}
```
## CHECK Pattern in Migration
```php
DB::statement("ALTER TABLE table_name ADD CONSTRAINT table_name_column_check
CHECK (column IN ('draft', 'active'))");
```
## Naming Convention
- Constraint: `{table}_{column}_check`
- Enum class: PascalCase singular noun (`InvoiceStatus`, `PaymentMethod`)
- Enum values: snake_case (`'partially_paid'`, `'in_progress'`)
- Enum cases: PascalCase (`PartiallyPaid`, `InProgress`)
## Complete Enum Registry
### InvoiceStatus
`'draft', 'sent', 'partially_paid', 'paid', 'overdue', 'cancelled', 'refunded'`
### PaymentStatus
`'confirmed', 'pending', 'failed', 'refunded', 'partially_refunded'`
### PaymentMethod
`'cash', 'card', 'bank_transfer', 'wallet', 'cheque', 'other'`
### TransactionType
`'debit', 'credit'`
### AccountType
`'asset', 'liability', 'equity', 'revenue', 'expense'`
### ParticipantStatus
`'registered', 'active', 'frozen', 'suspended', 'inactive', 'graduated', 'transferred', 'withdrawn', 'blacklisted'`
### EnrollmentStatus
`'pending', 'active', 'completed', 'cancelled', 'expired', 'waitlisted'`
### GroupStatus
`'forming', 'active', 'full', 'on_hold', 'completed', 'cancelled'`
### SessionStatus
`'scheduled', 'in_progress', 'completed', 'cancelled', 'rescheduled'`
### AttendanceStatus
`'expected', 'present', 'late', 'excused', 'absent', 'no_show', 'left_early', 'partial', 'cancelled', 'exempt'`
### Gender
`'male', 'female'`
### Classification
`'regular', 'vip', 'scholarship', 'staff_child', 'trial'`
### FacilityStatus
`'active', 'maintenance', 'closed', 'reserved', 'unavailable'`
### LayoutType
`'grid', 'lanes', 'zones', 'custom'`
### ReservationStatus
`'confirmed', 'tentative', 'cancelled'`
### AssignmentScope
`'full', 'partial', 'supervising', 'assisting', 'observing'`
### AssignmentStatus
`'active', 'suspended', 'completed', 'cancelled'`
### MovementDirection
`'in', 'out'`
### PurchaseOrderStatus
`'draft', 'submitted', 'confirmed', 'partially_received', 'received', 'cancelled'`
### WalletStatus
`'active', 'frozen', 'closed'`
### UserStatus
`'active', 'inactive', 'suspended', 'pending'`
### PricingAdjustmentType
`'percentage_discount', 'fixed_discount', 'fixed_price', 'percentage_increase'`
### PricingRuleType
`'age', 'membership_duration', 'family_size', 'sibling_order', 'classification', 'enrollment_timing', 'enrollment_volume', 'seasonal', 'gender', 'branch', 'day_time', 'loyalty', 'custom'`
## Validation Rules (in FormRequests)
```php
'status' => 'required|in:' . implode(',', array_column(StatusEnum::cases(), 'value'))
```
This ensures form validation accepts EXACTLY what the DB accepts.
---
description: Audit logs are immutable. Notifications via events only. Templates for all messages.
globs: ["app/Domain/Shared/**", "app/Domain/Notification/**", "app/Domain/CMS/**"]
---
# Audit & Notifications
## Audit Log — IMMUTABLE
- No `updated_at`, no `deleted_at` on audit_logs table
- Created via `Auditable` trait (model observer on created/updated/deleted)
- Stores: who, what, when, old_values, new_values, ip_address
- EXCLUDED from logging: password, remember_token, api_key
- Financial audit logs: retained FOREVER
- Non-financial: retained 1 year (configurable)
- Nobody can edit or delete audit logs (no UI for it, no API endpoint)
## Notification Flow
```
Event → Listener → NotificationService → Channel Handler → Log
```
### Rules
1. Notifications are triggered by EVENTS — never called directly from services/controllers
2. Every notification is LOGGED (notification_logs table) — success or failure
3. In-app notifications CANNOT be disabled by user
4. Email/SMS CAN be disabled per user per notification type (preferences)
5. High-priority notifications ignore digest mode (always immediate)
6. SMS rate limit: 100/hour per academy
7. Max 1 email per recipient per event (no duplicates)
## Template Rendering
1. Templates stored in `notification_templates` table
2. Resolution: academy-specific first, then platform default
3. ALL template variables MUST be provided (throw on missing — never render raw `{{brackets}}`)
4. Variable replacement: simple str_replace (no Blade in templates)
5. SMS templates: respect 70-char Arabic limit per segment
## Notification Preferences Resolution
1. Check `notification_preferences` for user + event type
2. If no record exists → ALL channels enabled (default)
3. In-app: always on regardless of preference
4. If `digest_mode = true` AND priority is 'normal' → queue for daily digest at configured time
## Phone Number Formatting (for SMS)
- Strip spaces and dashes
- Ensure starts with `+20` (Egypt country code)
- `01012345678``+201012345678`
- Validate: must be 12 digits after +20
---
description: Space reservation collision detection. Temporal layouts. No double-booking.
globs: ["app/Domain/Facility/**", "app/Domain/Scheduling/**"]
---
# Space & Collision Detection
## Temporal Layouts
A facility can have DIFFERENT layouts at different times:
- Morning: 3×2 grid (small areas for kids)
- Evening: 2×1 grid (large areas for adults)
Layout identified by: `facility_id + day_of_week + start_time + end_time`
## Layout Overlap Rule
No two layouts for the same facility can overlap in time on the same day.
`is_recurring = true` with specific `effective_day_of_week` vs `is_recurring = false` with specific `effective_date`:
- Specific date OVERRIDES recurring for that date
## Collision Detection Algorithm
```php
SpaceCollisionService::check($facilityId, $date, $startTime, $endTime, $segmentIds, $excludeId = null)
1. Find CONFIRMED reservations for facility on date
2. Filter overlapping time: WHERE start_time < $endTime AND end_time > $startTime
3. Exclude self (if editing): WHERE id != $excludeId
4. Decode segments JSON from each overlapping reservation
5. Intersect: requested segments occupied segments
6. Empty intersection no collision (OK)
7. Non-empty return conflict details
```
## Reservation Rules
- `confirmed` reservations block others (hard)
- `tentative` reservations warn but don't block
- `cancelled` reservations are invisible to collision detection
- Cannot reserve unavailable segments (`is_available = false`)
- Cannot reserve outside facility operating hours
- Cannot reserve when facility status != 'active'
## Session → Reservation Link
When session is created:
1. Check if group has assigned space (via group_schedule)
2. If yes: auto-create reservation for the session's time + segments
3. If collision: FAIL session creation, report conflict
When session is cancelled:
- Linked reservation.status → 'cancelled'
- Segments immediately available for others
## Layout Types → Segment Auto-Generation
- `grid(rows=3, columns=2)` → 6 segments: R1C1, R1C2, R2C1, R2C2, R3C1, R3C2
- `lanes(lane_count=6)` → 6 segments: Lane 1, Lane 2, ..., Lane 6
- `zones(definitions=[...])` → one segment per zone definition object
- `custom(definitions=[...])` → same as zones, no geometric constraints
# Git
.git
.gitignore
.gitattributes
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Node (built in Docker)
node_modules/
# Vendor (built in Docker)
vendor/
# Environment
.env
.env.local
.env.*.local
# Logs & cache
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
!storage/logs/.gitkeep
!storage/framework/cache/data/.gitkeep
!storage/framework/sessions/.gitkeep
!storage/framework/views/.gitkeep
# Testing
tests/
phpunit.xml
.phpunit.result.cache
# Documentation
docs/
*.md
!README.md
# Build artifacts
public/build/
public/hot
# Claude
.claude/
CLAUDE.md
# Misc
"Beanding Guide.txt"
"system info.txt"
elcaptain-sportsonly-db.md
APP_NAME=Laravel
APP_ENV=local
APP_NAME="El Captain"
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_DEBUG=false
APP_URL=https://el-captain.caprover.al-arcade.com
APP_LOCALE=en
APP_LOCALE=ar
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_FAKER_LOCALE=ar_EG
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_LEVEL=error
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=pgsql
DB_HOST=srv-captain--postgres-db
DB_PORT=5432
DB_DATABASE=elcaptainsportsonly
DB_USERNAME=elcaptain
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
......@@ -38,28 +35,33 @@ FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
MAIL_MAILER=smtp
MAIL_HOST=srv-captain--poste-io
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="noreply@al-arcade.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_VERIFY_PEER=false
TRUSTED_PROXIES=*
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Queue
QUEUE_RETRY_AFTER=90
QUEUE_MAX_TRIES=3
QUEUE_TIMEOUT=60
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# SMS (placeholder — log driver until provider configured)
SMS_DRIVER=log
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# System Limits
MAX_DISCOUNT_PERCENT=50
SMS_RATE_LIMIT_PER_HOUR=100
ATTENDANCE_GRACE_MINUTES_PARTICIPANT=15
ATTENDANCE_GRACE_MINUTES_TRAINER=10
CONSECUTIVE_ABSENCE_THRESHOLD=5
ATTENDANCE_MIN_RATE_PERCENT=75
VITE_APP_NAME="${APP_NAME}"
# ============================================================
# El Captain Sports Management — Production Dockerfile
# Multi-stage: build assets → production PHP image
# ============================================================
# Stage 1: Build frontend assets
FROM node:20-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund
COPY vite.config.js ./
COPY resources/ resources/
COPY public/ public/
RUN npm run build
# Stage 2: Composer dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--no-scripts \
--no-autoloader \
--prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev
# Stage 3: Production image
FROM php:8.3-fpm-alpine
# System dependencies
RUN apk add --no-cache \
nginx \
supervisor \
postgresql-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
zip \
libzip-dev \
icu-dev \
oniguruma-dev \
curl \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_pgsql \
pgsql \
gd \
zip \
intl \
mbstring \
opcache \
pcntl \
&& rm -rf /var/cache/apk/*
# PHP configuration
COPY docker/php/php.ini /usr/local/etc/php/conf.d/99-app.ini
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/10-opcache.ini
COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf
# Nginx configuration
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
RUN rm -f /etc/nginx/http.d/default.conf.bak
# Supervisor configuration
COPY docker/supervisor/supervisord.conf /etc/supervisord.conf
# Application setup
WORKDIR /var/www/html
# Copy application code
COPY --from=vendor /app/vendor ./vendor
COPY . .
# Copy built assets from node stage
COPY --from=assets /app/public/build ./public/build
# Create required directories
RUN mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Copy entrypoint
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
<?php
namespace App\Console\Commands;
use App\Domain\Audit\Models\AuditLog;
use Illuminate\Console\Command;
class CleanupAuditLogs extends Command
{
protected $signature = 'audit:cleanup {--days=365 : Days to retain non-financial logs}';
protected $description = 'Remove non-financial audit logs older than retention period';
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$deleted = AuditLog::where('is_financial', false)
->where('created_at', '<', $cutoff)
->delete();
$this->info("Deleted {$deleted} expired non-financial audit log(s).");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class EnforceAttendanceThresholds extends Command
{
protected $signature = 'attendance:enforce-thresholds';
protected $description = 'Check attendance thresholds and auto-suspend if exceeded';
private const DEFAULT_MAX_CONSECUTIVE_ABSENCES = 5;
private const DEFAULT_MIN_ATTENDANCE_PERCENT = 75;
public function handle(): int
{
$this->checkConsecutiveAbsences();
$this->checkAttendancePercentage();
return self::SUCCESS;
}
private function checkConsecutiveAbsences(): void
{
$maxAbsences = self::DEFAULT_MAX_CONSECUTIVE_ABSENCES;
$participants = Participant::where('status', 'active')->get();
foreach ($participants as $participant) {
$recentRecords = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participant->id)
->whereHas('session', fn ($q) => $q->orderByDesc('session_date'))
->orderByDesc('created_at')
->limit($maxAbsences)
->pluck('status');
if ($recentRecords->count() < $maxAbsences) {
continue;
}
$allAbsent = $recentRecords->every(fn ($s) =>
in_array($s instanceof AttendanceStatus ? $s->value : $s, ['absent', 'no_show'])
);
if ($allAbsent) {
$participant->update([
'status' => 'suspended',
'status_reason' => __('تعليق تلقائي: :count غيابات متتالية', ['count' => $maxAbsences]),
'status_changed_at' => now(),
]);
Log::warning("Participant #{$participant->id} auto-suspended: {$maxAbsences} consecutive absences");
$this->warn(__('تم تعليق المشترك #:id (:name)', [
'id' => $participant->id,
'name' => $participant->person?->name_ar ?? '-',
]));
}
}
}
private function checkAttendancePercentage(): void
{
$minPercent = self::DEFAULT_MIN_ATTENDANCE_PERCENT;
$participants = Participant::where('status', 'active')->get();
foreach ($participants as $participant) {
$total = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participant->id)
->count();
if ($total < 10) {
continue; // Not enough data
}
$excluded = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participant->id)
->whereIn('status', [AttendanceStatus::Cancelled, AttendanceStatus::Exempt])
->count();
$denominator = $total - $excluded;
if ($denominator <= 0) {
continue;
}
$positive = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participant->id)
->whereIn('status', [AttendanceStatus::Present, AttendanceStatus::Late, AttendanceStatus::Partial])
->count();
$rate = round(($positive / $denominator) * 100, 1);
if ($rate < $minPercent) {
Log::info("Participant #{$participant->id} attendance below threshold: {$rate}% < {$minPercent}%");
$this->info(__('نسبة حضور منخفضة: المشترك #:id بنسبة :rate%', [
'id' => $participant->id,
'rate' => $rate,
]));
}
}
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Training\Models\TrainingSession;
use Illuminate\Console\Command;
class MarkAutoAbsent extends Command
{
protected $signature = 'attendance:mark-absent';
protected $description = 'Mark expected attendance records as absent after session end + 2 hours';
public function handle(): int
{
$cutoff = now()->subHours(2);
// Find completed sessions that ended more than 2 hours ago
$expiredSessionIds = TrainingSession::where('status', 'completed')
->whereRaw("(session_date || ' ' || end_time)::timestamp < ?", [$cutoff])
->pluck('id');
// Also check in_progress sessions past their end time + 2h
$inProgressExpired = TrainingSession::where('status', 'in_progress')
->whereRaw("(session_date || ' ' || end_time)::timestamp < ?", [$cutoff])
->pluck('id');
$allExpiredIds = $expiredSessionIds->merge($inProgressExpired)->unique();
if ($allExpiredIds->isEmpty()) {
$this->info(__('لا توجد جلسات منتهية.'));
return self::SUCCESS;
}
$updated = AttendanceRecord::whereIn('training_session_id', $allExpiredIds)
->where('status', AttendanceStatus::Expected)
->update([
'status' => AttendanceStatus::Absent->value,
'is_flagged' => true,
'marked_at' => now(),
'metadata' => json_encode(['auto_marked' => true, 'marked_reason' => 'auto_absent_after_2h']),
]);
$this->info(__('تم تسجيل :count سجل كغائب.', ['count' => $updated]));
return self::SUCCESS;
}
}
<?php
namespace App\Domain\Attendance\Enums;
enum AttendanceStatus: string
{
case Expected = 'expected';
case Present = 'present';
case Late = 'late';
case Excused = 'excused';
case Absent = 'absent';
case NoShow = 'no_show';
case LeftEarly = 'left_early';
case Partial = 'partial';
case Cancelled = 'cancelled';
case Exempt = 'exempt';
public function label(): string
{
return match ($this) {
self::Expected => 'متوقع',
self::Present => 'حاضر',
self::Late => 'متأخر',
self::Excused => 'معذور',
self::Absent => 'غائب',
self::NoShow => 'لم يحضر',
self::LeftEarly => 'غادر مبكراً',
self::Partial => 'حضور جزئي',
self::Cancelled => 'ملغى',
self::Exempt => 'معفى',
};
}
public function isPositive(): bool
{
return in_array($this, [self::Present, self::Late, self::Partial]);
}
public function isNegative(): bool
{
return in_array($this, [self::Absent, self::NoShow]);
}
public function isExcluded(): bool
{
return in_array($this, [self::Cancelled, self::Exempt]);
}
public function color(): string
{
return match ($this) {
self::Expected => 'gray',
self::Present => 'green',
self::Late => 'amber',
self::Excused => 'blue',
self::Absent => 'red',
self::NoShow => 'red',
self::LeftEarly => 'orange',
self::Partial => 'yellow',
self::Cancelled => 'gray',
self::Exempt => 'purple',
};
}
}
<?php
namespace App\Domain\Attendance\Events;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AttendanceMarked implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public AttendanceRecord $record,
public User $actor,
) {}
}
<?php
namespace App\Domain\Attendance\Events;
use App\Domain\Participant\Models\Participant;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AttendanceThresholdBreached implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Participant $participant,
public float $rate,
public float $threshold,
) {}
}
<?php
namespace App\Domain\Attendance\Events;
use App\Domain\Attendance\Models\AttendanceRecord;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ParticipantAbsent implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public AttendanceRecord $record,
) {}
}
<?php
namespace App\Domain\Attendance\Listeners;
use App\Domain\Attendance\Events\ParticipantAbsent;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class NotifyGuardianOfAbsence implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(ParticipantAbsent $event): void
{
try {
$participant = $event->record->subject;
if (!$participant || $event->record->subject_type !== 'participant') {
return;
}
$guardians = $participant->guardians ?? collect();
$primaryGuardian = $guardians->first();
if (!$primaryGuardian) {
return;
}
$this->notificationService->sendSimple(
type: 'participant_absent',
recipientId: $primaryGuardian->id,
recipientType: 'guardian',
data: [
'participant_name' => $participant->person->full_name_ar ?? '',
'session_date' => $event->record->session?->session_date?->format('Y-m-d'),
'group_name' => $event->record->session?->group?->name_ar ?? '',
],
);
} catch (\Throwable $e) {
Log::error('NotifyGuardianOfAbsence failed: ' . $e->getMessage(), [
'record_id' => $event->record->id,
]);
}
}
public function failed(ParticipantAbsent $event, \Throwable $exception): void
{
Log::critical('NotifyGuardianOfAbsence PERMANENTLY FAILED', [
'record_id' => $event->record->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Attendance\Listeners;
use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class NotifyThresholdBreach implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(AttendanceThresholdBreached $event): void
{
try {
$this->notificationService->sendSimple(
type: 'attendance_threshold_breached',
recipientId: $event->participant->id,
recipientType: 'participant',
data: [
'participant_name' => $event->participant->person->full_name_ar ?? '',
'rate' => round($event->rate, 1),
'threshold' => $event->threshold,
],
priority: 'high',
);
} catch (\Throwable $e) {
Log::error('NotifyThresholdBreach failed: ' . $e->getMessage(), [
'participant_id' => $event->participant->id,
]);
}
}
public function failed(AttendanceThresholdBreached $event, \Throwable $exception): void
{
Log::critical('NotifyThresholdBreach PERMANENTLY FAILED', [
'participant_id' => $event->participant->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Attendance\Listeners;
use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Participant\Services\ParticipantService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class SuspendOnThreshold implements ShouldQueue
{
public function __construct(
private ParticipantService $participantService,
) {}
public function handle(AttendanceThresholdBreached $event): void
{
try {
$participant = $event->participant;
if ($participant->status === 'suspended' || $participant->status === 'blacklisted') {
return;
}
$statusValue = $participant->status instanceof \BackedEnum
? $participant->status->value
: (string) $participant->status;
if (!in_array($statusValue, ['active'])) {
return;
}
$this->participantService->changeStatus(
$participant,
'suspended',
"تجاوز حد الغياب المسموح - نسبة الحضور: {$event->rate}%",
);
} catch (\Throwable $e) {
Log::error('SuspendOnThreshold failed: ' . $e->getMessage(), [
'participant_id' => $event->participant->id,
]);
}
}
public function failed(AttendanceThresholdBreached $event, \Throwable $exception): void
{
Log::critical('SuspendOnThreshold PERMANENTLY FAILED', [
'participant_id' => $event->participant->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Attendance\Models;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class AttendanceRecord extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = [
'academy_id',
'training_session_id',
'subject_type',
'subject_id',
'status',
'check_in_at',
'check_out_at',
'late_minutes',
'duration_minutes',
'notes',
'status_reason',
'marked_by',
'marked_at',
'is_auto_generated',
'is_flagged',
'metadata',
];
protected function casts(): array
{
return [
'status' => AttendanceStatus::class,
'check_in_at' => 'datetime',
'check_out_at' => 'datetime',
'marked_at' => 'datetime',
'late_minutes' => 'integer',
'duration_minutes' => 'integer',
'is_auto_generated' => 'boolean',
'is_flagged' => 'boolean',
'metadata' => 'array',
];
}
public function session(): BelongsTo
{
return $this->belongsTo(TrainingSession::class, 'training_session_id');
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function marker(): BelongsTo
{
return $this->belongsTo(User::class, 'marked_by');
}
public function scopeForSession($query, int $sessionId)
{
return $query->where('training_session_id', $sessionId);
}
public function scopeExpected($query)
{
return $query->where('status', AttendanceStatus::Expected);
}
public function scopeMarked($query)
{
return $query->where('status', '!=', AttendanceStatus::Expected);
}
public function scopePositive($query)
{
return $query->whereIn('status', [
AttendanceStatus::Present,
AttendanceStatus::Late,
AttendanceStatus::Partial,
]);
}
public function scopeNegative($query)
{
return $query->whereIn('status', [
AttendanceStatus::Absent,
AttendanceStatus::NoShow,
]);
}
}
<?php
namespace App\Domain\Attendance\Services;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use Illuminate\Support\Facades\DB;
class AttendanceGenerationService
{
public function generateForSession(TrainingSession $session): int
{
return DB::transaction(function () use ($session) {
$count = 0;
// 1. Generate for active participants (via enrollments)
$enrollments = Enrollment::where('training_group_id', $session->training_group_id)
->where('status', 'active')
->with('participant')
->get();
foreach ($enrollments as $enrollment) {
$participant = $enrollment->participant;
// Skip frozen participants
if (!$participant || in_array($participant->status->value ?? $participant->status, ['frozen', 'suspended'])) {
continue;
}
$created = $this->createIfNotExists(
$session,
\App\Domain\Participant\Models\Participant::class,
$participant->id
);
if ($created) {
$count++;
}
}
// 2. Generate for assigned staff (session-level overrides group-level)
$assignments = $this->resolveAssignments($session);
foreach ($assignments as $assignment) {
$created = $this->createIfNotExists(
$session,
\App\Models\User::class,
$assignment->user_id
);
if ($created) {
$count++;
}
}
return $count;
});
}
public function generateForEnrollment(Enrollment $enrollment): int
{
$count = 0;
$participant = $enrollment->participant;
if (!$participant) {
return 0;
}
// Generate for all FUTURE sessions of the group
$futureSessions = TrainingSession::where('training_group_id', $enrollment->training_group_id)
->where('session_date', '>=', now()->toDateString())
->whereIn('status', ['scheduled', 'in_progress'])
->get();
foreach ($futureSessions as $session) {
$created = $this->createIfNotExists(
$session,
\App\Domain\Participant\Models\Participant::class,
$participant->id
);
if ($created) {
$count++;
}
}
return $count;
}
public function removeForCancelledEnrollment(Enrollment $enrollment): int
{
// DELETE expected records for FUTURE sessions only
return AttendanceRecord::where('subject_type', \App\Domain\Participant\Models\Participant::class)
->where('subject_id', $enrollment->participant_id)
->where('status', AttendanceStatus::Expected)
->whereHas('session', function ($q) use ($enrollment) {
$q->where('training_group_id', $enrollment->training_group_id)
->where('session_date', '>=', now()->toDateString());
})
->delete();
}
public function generateUpcoming(TrainingGroup $group, int $daysAhead = 7): int
{
$count = 0;
$futureSessions = TrainingSession::where('training_group_id', $group->id)
->where('session_date', '>=', now()->toDateString())
->where('session_date', '<=', now()->addDays($daysAhead)->toDateString())
->whereIn('status', ['scheduled'])
->get();
foreach ($futureSessions as $session) {
$count += $this->generateForSession($session);
}
return $count;
}
private function resolveAssignments(TrainingSession $session): \Illuminate\Support\Collection
{
// Session-level assignments override group-level
$sessionAssignments = Assignment::where('assignable_type', TrainingSession::class)
->where('assignable_id', $session->id)
->where('status', 'active')
->get();
if ($sessionAssignments->isNotEmpty()) {
return $sessionAssignments;
}
// Fall back to group-level
return Assignment::where('assignable_type', TrainingGroup::class)
->where('assignable_id', $session->training_group_id)
->where('status', 'active')
->get();
}
private function createIfNotExists(TrainingSession $session, string $subjectType, int $subjectId): bool
{
$exists = AttendanceRecord::where('training_session_id', $session->id)
->where('subject_type', $subjectType)
->where('subject_id', $subjectId)
->exists();
if ($exists) {
return false;
}
AttendanceRecord::create([
'academy_id' => $session->academy_id,
'training_session_id' => $session->id,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'status' => AttendanceStatus::Expected->value,
'is_auto_generated' => true,
]);
return true;
}
}
<?php
namespace App\Domain\Attendance\Services;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Attendance\Events\ParticipantAbsent;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class AttendanceMarkingService
{
private const DEFAULT_PARTICIPANT_GRACE_MINUTES = 15;
private const DEFAULT_TRAINER_GRACE_MINUTES = 10;
public function markPresent(AttendanceRecord $record, User $marker, ?Carbon $checkInTime = null): AttendanceRecord
{
return DB::transaction(function () use ($record, $marker, $checkInTime) {
$checkInTime ??= now();
$session = $record->session;
$graceMinutes = $this->getGraceMinutes($record);
$sessionStart = Carbon::parse($session->session_date->format('Y-m-d') . ' ' . $session->start_time);
$graceDeadline = $sessionStart->copy()->addMinutes($graceMinutes);
if ($checkInTime->lte($graceDeadline)) {
$status = AttendanceStatus::Present;
$lateMinutes = null;
} else {
$status = AttendanceStatus::Late;
$lateMinutes = (int) $checkInTime->diffInMinutes($sessionStart);
}
$record->update([
'status' => $status,
'check_in_at' => $checkInTime,
'late_minutes' => $lateMinutes,
'marked_by' => $marker->id,
'marked_at' => now(),
'is_auto_generated' => false,
]);
AttendanceMarked::dispatch($record->fresh(), $marker);
$this->checkThresholds($record);
return $record->fresh();
});
}
public function markStatus(AttendanceRecord $record, AttendanceStatus $status, User $marker, ?string $reason = null): AttendanceRecord
{
return DB::transaction(function () use ($record, $status, $marker, $reason) {
$data = [
'status' => $status,
'marked_by' => $marker->id,
'marked_at' => now(),
'is_auto_generated' => false,
];
if ($reason) {
$data['status_reason'] = $reason;
}
if ($status === AttendanceStatus::Present || $status === AttendanceStatus::Late) {
$data['check_in_at'] = $record->check_in_at ?? now();
}
$record->update($data);
$freshRecord = $record->fresh();
AttendanceMarked::dispatch($freshRecord, $marker);
if (in_array($status, [AttendanceStatus::Absent, AttendanceStatus::NoShow])) {
ParticipantAbsent::dispatch($freshRecord);
}
$this->checkThresholds($record);
return $freshRecord;
});
}
public function markCheckOut(AttendanceRecord $record, User $marker, ?Carbon $checkOutTime = null): AttendanceRecord
{
return DB::transaction(function () use ($record, $marker, $checkOutTime) {
$checkOutTime ??= now();
$durationMinutes = null;
if ($record->check_in_at) {
$durationMinutes = (int) $record->check_in_at->diffInMinutes($checkOutTime);
}
// If left early (before session end), mark accordingly
$session = $record->session;
$sessionEnd = Carbon::parse($session->session_date->format('Y-m-d') . ' ' . $session->end_time);
$status = $record->status;
if ($checkOutTime->lt($sessionEnd->subMinutes(10))) {
$status = AttendanceStatus::LeftEarly;
}
$record->update([
'check_out_at' => $checkOutTime,
'duration_minutes' => $durationMinutes,
'status' => $status,
'marked_by' => $marker->id,
'marked_at' => now(),
]);
return $record->fresh();
});
}
public function bulkMark(int $sessionId, array $marks, User $marker): int
{
$count = 0;
DB::transaction(function () use ($sessionId, $marks, $marker, &$count) {
foreach ($marks as $mark) {
$record = AttendanceRecord::where('training_session_id', $sessionId)
->where('id', $mark['record_id'])
->first();
if (!$record) {
continue;
}
$status = AttendanceStatus::from($mark['status']);
$this->markStatus($record, $status, $marker, $mark['reason'] ?? null);
$count++;
}
});
return $count;
}
public function calculateRate(int $participantId, ?int $groupId = null): float
{
$query = AttendanceRecord::where('subject_type', \App\Domain\Participant\Models\Participant::class)
->where('subject_id', $participantId);
if ($groupId) {
$query->whereHas('session', fn ($q) => $q->where('training_group_id', $groupId));
}
$total = (clone $query)->count();
$excluded = (clone $query)->whereIn('status', [
AttendanceStatus::Cancelled,
AttendanceStatus::Exempt,
])->count();
$denominator = $total - $excluded;
if ($denominator <= 0) {
return 100.0;
}
$positive = (clone $query)->whereIn('status', [
AttendanceStatus::Present,
AttendanceStatus::Late,
AttendanceStatus::Partial,
])->count();
return round(($positive / $denominator) * 100, 1);
}
private function getGraceMinutes(AttendanceRecord $record): int
{
// Trainers (users) get less grace
if ($record->subject_type === \App\Models\User::class) {
return self::DEFAULT_TRAINER_GRACE_MINUTES;
}
return self::DEFAULT_PARTICIPANT_GRACE_MINUTES;
}
private function checkThresholds(AttendanceRecord $record): void
{
if ($record->subject_type !== Participant::class) {
return;
}
$participant = Participant::find($record->subject_id);
if (!$participant) {
return;
}
$rate = $this->calculateRate($participant->id);
$threshold = 75.0; // Default, would be loaded from academy settings
if ($rate < $threshold) {
AttendanceThresholdBreached::dispatch($participant, $rate, $threshold);
}
}
}
<?php
namespace App\Domain\Audit\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class AuditLog extends Model
{
use BelongsToAcademy;
/**
* Immutable — no updated_at column.
*/
const UPDATED_AT = null;
protected $table = 'audit_logs';
protected $fillable = [
'academy_id',
'user_id',
'auditable_type',
'auditable_id',
'action',
'old_values',
'new_values',
'ip_address',
'user_agent',
'url',
'is_financial',
];
protected function casts(): array
{
return [
'old_values' => 'array',
'new_values' => 'array',
'is_financial' => 'boolean',
'created_at' => 'datetime',
];
}
// ------------------------------------------------------------------
// Relationships
// ------------------------------------------------------------------
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function auditable(): MorphTo
{
return $this->morphTo();
}
// ------------------------------------------------------------------
// Scopes
// ------------------------------------------------------------------
public function scopeForModel(Builder $query, string $type, int $id): Builder
{
return $query->where('auditable_type', $type)->where('auditable_id', $id);
}
public function scopeByUser(Builder $query, int $userId): Builder
{
return $query->where('user_id', $userId);
}
public function scopeFinancial(Builder $query): Builder
{
return $query->where('is_financial', true);
}
public function scopeRecent(Builder $query, int $days = 30): Builder
{
return $query->where('created_at', '>=', now()->subDays($days));
}
public function scopeForAction(Builder $query, string $action): Builder
{
return $query->where('action', $action);
}
}
<?php
namespace App\Domain\Facility\Enums;
enum FacilityStatus: string
{
case Active = 'active';
case Maintenance = 'maintenance';
case Closed = 'closed';
case Reserved = 'reserved';
case Unavailable = 'unavailable';
public function label(): string
{
return match ($this) {
self::Active => 'نشط',
self::Maintenance => 'صيانة',
self::Closed => 'مغلق',
self::Reserved => 'محجوز',
self::Unavailable => 'غير متاح',
};
}
}
<?php
namespace App\Domain\Facility\Enums;
enum FacilityType: string
{
case Field = 'field';
case Court = 'court';
case Pool = 'pool';
case Gym = 'gym';
case Track = 'track';
case Hall = 'hall';
case Room = 'room';
case Outdoor = 'outdoor';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Field => 'ملعب',
self::Court => 'كورت',
self::Pool => 'حمام سباحة',
self::Gym => 'صالة رياضية',
self::Track => 'مضمار',
self::Hall => 'قاعة',
self::Room => 'غرفة',
self::Outdoor => 'مساحة خارجية',
self::Other => 'أخرى',
};
}
}
<?php
namespace App\Domain\Facility\Enums;
enum LayoutType: string
{
case Grid = 'grid';
case Lanes = 'lanes';
case Zones = 'zones';
case Custom = 'custom';
public function label(): string
{
return match ($this) {
self::Grid => 'شبكة',
self::Lanes => 'حارات',
self::Zones => 'مناطق',
self::Custom => 'مخصص',
};
}
}
<?php
namespace App\Domain\Facility\Enums;
enum ReservationStatus: string
{
case Confirmed = 'confirmed';
case Tentative = 'tentative';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Confirmed => 'مؤكد',
self::Tentative => 'مبدئي',
self::Cancelled => 'ملغى',
};
}
}
<?php
namespace App\Domain\Facility\Events;
use App\Domain\Facility\Models\Facility;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FacilityStatusChanged implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Facility $facility,
public string $oldStatus,
public string $newStatus,
public User $actor,
) {}
}
<?php
namespace App\Domain\Facility\Events;
use App\Domain\Facility\Models\SpaceReservation;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SpaceReservationCreated implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public SpaceReservation $reservation,
public User $actor,
) {}
}
<?php
namespace App\Domain\Facility\Models;
use App\Domain\Facility\Enums\FacilityStatus;
use App\Domain\Facility\Enums\FacilityType;
use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Facility extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes, Auditable;
protected $fillable = [
'academy_id',
'branch_id',
'name',
'name_ar',
'code',
'type',
'status',
'description',
'description_ar',
'capacity',
'surface_type',
'area_sqm',
'length_m',
'width_m',
'is_indoor',
'has_lighting',
'has_ac',
'operating_start',
'operating_end',
'rental_cost_per_hour',
'address',
'latitude',
'longitude',
'amenities',
'metadata',
'photo_path',
'sort_order',
'created_by',
];
protected function casts(): array
{
return [
'type' => FacilityType::class,
'status' => FacilityStatus::class,
'capacity' => 'integer',
'area_sqm' => 'decimal:2',
'length_m' => 'decimal:2',
'width_m' => 'decimal:2',
'is_indoor' => 'boolean',
'has_lighting' => 'boolean',
'has_ac' => 'boolean',
'rental_cost_per_hour' => 'integer',
'amenities' => 'array',
'metadata' => 'array',
'sort_order' => 'integer',
];
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function layouts(): HasMany
{
return $this->hasMany(SpaceLayout::class);
}
public function reservations(): HasMany
{
return $this->hasMany(SpaceReservation::class);
}
public function scopeActive($query)
{
return $query->where('status', FacilityStatus::Active);
}
}
<?php
namespace App\Domain\Facility\Models;
use App\Domain\Facility\Enums\LayoutType;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SpaceLayout extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = [
'academy_id',
'facility_id',
'name',
'name_ar',
'layout_type',
'layout_config',
'is_recurring',
'effective_day_of_week',
'effective_date',
'start_time',
'end_time',
'is_active',
'sort_order',
'metadata',
'created_by',
];
protected function casts(): array
{
return [
'layout_type' => LayoutType::class,
'layout_config' => 'array',
'is_recurring' => 'boolean',
'effective_day_of_week' => 'integer',
'effective_date' => 'date',
'is_active' => 'boolean',
'sort_order' => 'integer',
'metadata' => 'array',
];
}
public function facility(): BelongsTo
{
return $this->belongsTo(Facility::class);
}
public function segments(): HasMany
{
return $this->hasMany(SpaceSegment::class)->orderBy('sort_order');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForDay($query, int $dayOfWeek)
{
return $query->where(function ($q) use ($dayOfWeek) {
$q->where('is_recurring', true)
->where('effective_day_of_week', $dayOfWeek);
});
}
public function scopeForDate($query, string $date)
{
return $query->where('is_recurring', false)
->where('effective_date', $date);
}
}
<?php
namespace App\Domain\Facility\Models;
use App\Domain\Facility\Enums\ReservationStatus;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class SpaceReservation extends Model
{
use BelongsToAcademy, HasUuid, Auditable;
protected $fillable = [
'academy_id',
'facility_id',
'space_layout_id',
'status',
'reservation_date',
'start_time',
'end_time',
'segment_ids',
'reservable_type',
'reservable_id',
'title',
'notes',
'is_recurring',
'recurrence_pattern',
'recurrence_end_date',
'created_by',
'cancelled_by',
'cancelled_at',
'metadata',
];
protected function casts(): array
{
return [
'status' => ReservationStatus::class,
'reservation_date' => 'date',
'segment_ids' => 'array',
'is_recurring' => 'boolean',
'recurrence_end_date' => 'date',
'cancelled_at' => 'datetime',
'metadata' => 'array',
];
}
public function facility(): BelongsTo
{
return $this->belongsTo(Facility::class);
}
public function layout(): BelongsTo
{
return $this->belongsTo(SpaceLayout::class, 'space_layout_id');
}
public function reservable(): MorphTo
{
return $this->morphTo();
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function canceller(): BelongsTo
{
return $this->belongsTo(User::class, 'cancelled_by');
}
public function isConfirmed(): bool
{
return $this->status === ReservationStatus::Confirmed;
}
public function isCancelled(): bool
{
return $this->status === ReservationStatus::Cancelled;
}
public function scopeConfirmed($query)
{
return $query->where('status', ReservationStatus::Confirmed);
}
public function scopeForDate($query, string $date)
{
return $query->where('reservation_date', $date);
}
public function scopeOverlapping($query, string $startTime, string $endTime)
{
return $query->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime);
}
}
<?php
namespace App\Domain\Facility\Models;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SpaceSegment extends Model
{
use HasUuid;
protected $fillable = [
'space_layout_id',
'code',
'name',
'name_ar',
'row_index',
'col_index',
'lane_number',
'area_sqm',
'is_available',
'capacity',
'sort_order',
'metadata',
];
protected function casts(): array
{
return [
'row_index' => 'integer',
'col_index' => 'integer',
'lane_number' => 'integer',
'area_sqm' => 'decimal:2',
'is_available' => 'boolean',
'capacity' => 'integer',
'sort_order' => 'integer',
'metadata' => 'array',
];
}
public function layout(): BelongsTo
{
return $this->belongsTo(SpaceLayout::class, 'space_layout_id');
}
public function scopeAvailable($query)
{
return $query->where('is_available', true);
}
}
<?php
namespace App\Domain\Facility\Services;
use App\Domain\Facility\Enums\FacilityStatus;
use App\Domain\Facility\Events\FacilityStatusChanged;
use App\Domain\Facility\Models\Facility;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class FacilityService
{
private const VALID_TRANSITIONS = [
'active' => ['maintenance', 'closed', 'reserved', 'unavailable'],
'maintenance' => ['active', 'closed'],
'closed' => ['active', 'maintenance'],
'reserved' => ['active'],
'unavailable' => ['active'],
];
public function create(array $data, User $creator): Facility
{
return DB::transaction(function () use ($data, $creator) {
return Facility::create([
...$data,
'created_by' => $creator->id,
]);
});
}
public function update(Facility $facility, array $data): Facility
{
return DB::transaction(function () use ($facility, $data) {
$facility->update($data);
return $facility->fresh();
});
}
public function changeStatus(Facility $facility, string $newStatus, ?User $actor = null): Facility
{
return DB::transaction(function () use ($facility, $newStatus, $actor) {
$currentStatus = $facility->status->value ?? $facility->status;
$allowed = self::VALID_TRANSITIONS[$currentStatus] ?? [];
if (!in_array($newStatus, $allowed)) {
throw new DomainException(
"لا يمكن تغيير الحالة من '{$facility->status->label()}' إلى '" . FacilityStatus::from($newStatus)->label() . "'"
);
}
$facility->update(['status' => $newStatus]);
$fresh = $facility->fresh();
if ($actor) {
FacilityStatusChanged::dispatch($fresh, $currentStatus, $newStatus, $actor);
}
return $fresh;
});
}
public function delete(Facility $facility): void
{
DB::transaction(function () use ($facility) {
$facility->delete();
});
}
}
<?php
namespace App\Domain\Facility\Services;
use App\Domain\Facility\Enums\ReservationStatus;
use App\Domain\Facility\Models\Facility;
use App\Domain\Facility\Models\SpaceReservation;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class ReservationService
{
public function __construct(
private SpaceCollisionService $collisionService,
) {}
/**
* Create a new space reservation with collision detection.
*/
public function reserve(array $data, User $creator, ?Model $reservable = null): SpaceReservation
{
return DB::transaction(function () use ($data, $creator, $reservable) {
$facility = Facility::findOrFail($data['facility_id']);
// Validate facility is active and within operating hours
$this->collisionService->validateFacilityCanBeReserved(
$facility, $data['start_time'], $data['end_time']
);
// Validate segments are available
$segmentIds = $data['segment_ids'] ?? [];
if (!empty($segmentIds)) {
$this->collisionService->validateSegmentsAvailable($segmentIds);
}
// Check for collisions (hard block for confirmed)
$status = $data['status'] ?? ReservationStatus::Confirmed->value;
if ($status === ReservationStatus::Confirmed->value) {
$conflicts = $this->collisionService->check(
$data['facility_id'],
$data['reservation_date'],
$data['start_time'],
$data['end_time'],
$segmentIds
);
if (!empty($conflicts)) {
$conflictMsg = collect($conflicts)->map(fn ($c) =>
($c['title'] ?? 'حجز') . ' (' . $c['time'] . ')'
)->implode(', ');
throw new DomainException('تعارض مع حجوزات مؤكدة: ' . $conflictMsg);
}
}
return SpaceReservation::create([
...$data,
'academy_id' => $creator->academy_id ?? $facility->academy_id,
'reservable_type' => $reservable ? $reservable->getMorphClass() : null,
'reservable_id' => $reservable?->getKey(),
'created_by' => $creator->id,
]);
});
}
/**
* Cancel a reservation and free the space.
*/
public function cancel(SpaceReservation $reservation, User $actor, ?string $reason = null): SpaceReservation
{
return DB::transaction(function () use ($reservation, $actor, $reason) {
if ($reservation->status === ReservationStatus::Cancelled) {
throw new DomainException('الحجز ملغى بالفعل');
}
$reservation->update([
'status' => ReservationStatus::Cancelled->value,
'cancelled_by' => $actor->id,
'cancelled_at' => now(),
'notes' => $reason
? ($reservation->notes ? $reservation->notes . "\n" . $reason : $reason)
: $reservation->notes,
]);
return $reservation->fresh();
});
}
/**
* Confirm a tentative reservation (re-checks collisions).
*/
public function confirm(SpaceReservation $reservation): SpaceReservation
{
return DB::transaction(function () use ($reservation) {
if ($reservation->status !== ReservationStatus::Tentative) {
throw new DomainException('يمكن تأكيد الحجوزات المبدئية فقط');
}
// Re-check collisions before confirming
$conflicts = $this->collisionService->check(
$reservation->facility_id,
$reservation->reservation_date->format('Y-m-d'),
$reservation->start_time,
$reservation->end_time,
$reservation->segment_ids ?? [],
$reservation->id
);
if (!empty($conflicts)) {
throw new DomainException('لا يمكن التأكيد — تعارض مع حجوزات أخرى');
}
$reservation->update(['status' => ReservationStatus::Confirmed->value]);
return $reservation->fresh();
});
}
/**
* Cancel reservation linked to a training session.
* Called when a session is cancelled.
*/
public function cancelForSession(Model $session, User $actor): void
{
$reservations = SpaceReservation::where('reservable_type', $session->getMorphClass())
->where('reservable_id', $session->getKey())
->where('status', '!=', ReservationStatus::Cancelled->value)
->get();
foreach ($reservations as $reservation) {
$this->cancel($reservation, $actor, 'ملغى بسبب إلغاء الحصة');
}
}
/**
* Auto-reserve facility space for a newly created training session.
*/
public function autoReserveForSession(\App\Domain\Training\Models\TrainingSession $session, \App\Domain\Training\Models\TrainingSchedule $schedule): void
{
if (!$schedule->facility_id || !$schedule->segments) {
return;
}
try {
$this->reserve([
'academy_id' => $session->academy_id,
'facility_id' => $schedule->facility_id,
'reservation_date' => $session->session_date->toDateString(),
'start_time' => $session->start_time,
'end_time' => $session->end_time,
'segments' => $schedule->segments,
'status' => 'confirmed',
'notes' => "حجز تلقائي للحصة #{$session->session_number}",
], \App\Models\User::find($session->trainer_id) ?? \App\Models\User::first(), $session);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning("Auto-reservation failed for session {$session->id}: " . $e->getMessage());
}
}
}
<?php
namespace App\Domain\Facility\Services;
use App\Domain\Facility\Enums\FacilityStatus;
use App\Domain\Facility\Enums\ReservationStatus;
use App\Domain\Facility\Models\Facility;
use App\Domain\Facility\Models\SpaceReservation;
use App\Domain\Facility\Models\SpaceSegment;
use App\Domain\Shared\Exceptions\DomainException;
class SpaceCollisionService
{
/**
* Check for confirmed reservation collisions.
*
* Returns array of conflicts (empty = no collision).
*/
public function check(
int $facilityId,
string $date,
string $startTime,
string $endTime,
array $segmentIds,
?int $excludeId = null
): array {
// 1. Find confirmed reservations for facility on date that overlap in time
$query = SpaceReservation::where('facility_id', $facilityId)
->where('reservation_date', $date)
->where('status', ReservationStatus::Confirmed)
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime);
// 3. Exclude self (if editing)
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
$overlapping = $query->get();
if ($overlapping->isEmpty()) {
return [];
}
// 4-5. Decode segments JSON and intersect with requested
$conflicts = [];
foreach ($overlapping as $reservation) {
$occupiedSegments = $reservation->segment_ids ?? [];
$intersection = array_intersect($segmentIds, $occupiedSegments);
// 6-7. Non-empty intersection = conflict
if (!empty($intersection)) {
$conflicts[] = [
'reservation_id' => $reservation->id,
'title' => $reservation->title,
'time' => $reservation->start_time . ' - ' . $reservation->end_time,
'conflicting_segments' => array_values($intersection),
];
}
}
return $conflicts;
}
/**
* Get tentative reservation warnings (non-blocking).
*/
public function getTentativeWarnings(
int $facilityId,
string $date,
string $startTime,
string $endTime,
array $segmentIds
): array {
$query = SpaceReservation::where('facility_id', $facilityId)
->where('reservation_date', $date)
->where('status', ReservationStatus::Tentative)
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime);
$overlapping = $query->get();
$warnings = [];
foreach ($overlapping as $reservation) {
$occupiedSegments = $reservation->segment_ids ?? [];
$intersection = array_intersect($segmentIds, $occupiedSegments);
if (!empty($intersection)) {
$warnings[] = [
'reservation_id' => $reservation->id,
'title' => $reservation->title,
'time' => $reservation->start_time . ' - ' . $reservation->end_time,
'conflicting_segments' => array_values($intersection),
];
}
}
return $warnings;
}
/**
* Validate that all requested segments are marked as available.
*/
public function validateSegmentsAvailable(array $segmentIds): void
{
$unavailable = SpaceSegment::whereIn('id', $segmentIds)
->where('is_available', false)
->pluck('name_ar');
if ($unavailable->isNotEmpty()) {
throw new DomainException('الأجزاء التالية غير متاحة: ' . $unavailable->implode(', '));
}
}
/**
* Validate that the facility is active and the time is within operating hours.
*/
public function validateFacilityCanBeReserved(Facility $facility, string $startTime, string $endTime): void
{
if ($facility->status !== FacilityStatus::Active) {
throw new DomainException('المنشأة غير نشطة حالياً: ' . $facility->status->label());
}
if ($facility->operating_start && $startTime < $facility->operating_start) {
throw new DomainException('وقت البداية قبل ساعات العمل (' . $facility->operating_start . ')');
}
if ($facility->operating_end && $endTime > $facility->operating_end) {
throw new DomainException('وقت النهاية بعد ساعات العمل (' . $facility->operating_end . ')');
}
}
}
<?php
namespace App\Domain\Facility\Services;
use App\Domain\Facility\Models\SpaceLayout;
use App\Domain\Facility\Models\SpaceSegment;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class SpaceLayoutService
{
/**
* Create a new space layout with auto-generated segments.
*/
public function create(array $data, User $creator): SpaceLayout
{
return DB::transaction(function () use ($data, $creator) {
$this->validateNoOverlap($data);
$layout = SpaceLayout::create([
...$data,
'created_by' => $creator->id,
]);
$this->generateSegments($layout);
return $layout->load('segments');
});
}
/**
* Update a layout. Regenerates segments if type/config changed.
*/
public function update(SpaceLayout $layout, array $data): SpaceLayout
{
return DB::transaction(function () use ($layout, $data) {
$this->validateNoOverlap($data, $layout->id);
$oldType = $layout->layout_type->value;
$oldConfig = $layout->layout_config;
$layout->update($data);
$newType = $data['layout_type'] ?? $oldType;
$newConfig = $data['layout_config'] ?? $oldConfig;
if ($newType !== $oldType || $newConfig !== $oldConfig) {
$layout->segments()->delete();
$this->generateSegments($layout->fresh());
}
return $layout->fresh(['segments']);
});
}
/**
* Delete a layout and its segments.
*/
public function delete(SpaceLayout $layout): void
{
DB::transaction(function () use ($layout) {
$layout->segments()->delete();
$layout->delete();
});
}
/**
* Resolve which layout applies for a given facility, date, and time.
* Specific-date layouts override recurring ones.
*/
public function resolveLayout(int $facilityId, string $date, string $time): ?SpaceLayout
{
$dayOfWeek = (int) date('w', strtotime($date));
// Specific date override takes priority
$specificLayout = SpaceLayout::where('facility_id', $facilityId)
->where('is_recurring', false)
->where('effective_date', $date)
->where('start_time', '<=', $time)
->where('end_time', '>', $time)
->where('is_active', true)
->first();
if ($specificLayout) {
return $specificLayout;
}
// Fall back to recurring layout for this day of week
return SpaceLayout::where('facility_id', $facilityId)
->where('is_recurring', true)
->where('effective_day_of_week', $dayOfWeek)
->where('start_time', '<=', $time)
->where('end_time', '>', $time)
->where('is_active', true)
->first();
}
/**
* Get all available segments for a facility at a given date/time.
*/
public function getAvailableSegments(int $facilityId, string $date, string $time): array
{
$layout = $this->resolveLayout($facilityId, $date, $time);
if (! $layout) {
return [];
}
return $layout->segments()
->where('is_available', true)
->orderBy('sort_order')
->get()
->toArray();
}
/**
* Validate that no overlapping layout exists for the same facility/time slot.
*/
private function validateNoOverlap(array $data, ?int $excludeId = null): void
{
$query = SpaceLayout::where('facility_id', $data['facility_id'])
->where('is_active', true)
->where('start_time', '<', $data['end_time'])
->where('end_time', '>', $data['start_time']);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
if ($data['is_recurring'] ?? true) {
$query->where('is_recurring', true)
->where('effective_day_of_week', $data['effective_day_of_week']);
} else {
$query->where('is_recurring', false)
->where('effective_date', $data['effective_date']);
}
if ($query->exists()) {
throw new DomainException('يوجد تخطيط متداخل في نفس الفترة الزمنية');
}
}
/**
* Auto-generate segments based on layout type and config.
*/
private function generateSegments(SpaceLayout $layout): void
{
$type = $layout->layout_type->value;
$config = $layout->layout_config;
$segments = match ($type) {
'grid' => $this->generateGridSegments($config),
'lanes' => $this->generateLaneSegments($config),
'zones' => $this->generateZoneSegments($config),
'custom' => $this->generateCustomSegments($config),
default => [],
};
foreach ($segments as $index => $segment) {
SpaceSegment::create([
'space_layout_id' => $layout->id,
'sort_order' => $index,
...$segment,
]);
}
}
/**
* Grid: rows x columns → R1C1, R1C2, R2C1, etc.
*/
private function generateGridSegments(array $config): array
{
$rows = $config['rows'] ?? 2;
$cols = $config['columns'] ?? 2;
$segments = [];
for ($r = 1; $r <= $rows; $r++) {
for ($c = 1; $c <= $cols; $c++) {
$code = "R{$r}C{$c}";
$segments[] = [
'code' => $code,
'name' => "Row {$r} Col {$c}",
'name_ar' => "صف {$r} عمود {$c}",
'row_index' => $r,
'col_index' => $c,
];
}
}
return $segments;
}
/**
* Lanes: lane_count → Lane 1, Lane 2, etc.
*/
private function generateLaneSegments(array $config): array
{
$count = $config['lane_count'] ?? 4;
$segments = [];
for ($i = 1; $i <= $count; $i++) {
$segments[] = [
'code' => "L{$i}",
'name' => "Lane {$i}",
'name_ar' => "حارة {$i}",
'lane_number' => $i,
];
}
return $segments;
}
/**
* Zones: definitions array → one segment per zone definition.
*/
private function generateZoneSegments(array $config): array
{
$definitions = $config['definitions'] ?? [];
$segments = [];
foreach ($definitions as $index => $def) {
$segments[] = [
'code' => $def['code'] ?? 'Z' . ($index + 1),
'name' => $def['name'] ?? 'Zone ' . ($index + 1),
'name_ar' => $def['name_ar'] ?? 'منطقة ' . ($index + 1),
'area_sqm' => $def['area_sqm'] ?? null,
'capacity' => $def['capacity'] ?? null,
];
}
return $segments;
}
/**
* Custom: same as zones (no geometric constraints).
*/
private function generateCustomSegments(array $config): array
{
return $this->generateZoneSegments($config);
}
}
<?php
namespace App\Domain\Financial\Enums;
enum CashSessionStatus: string
{
case Open = 'open';
case Closed = 'closed';
case Suspended = 'suspended';
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\CashSession;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CashSessionClosed implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public CashSession $cashSession,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Installment;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InstallmentOverdue implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Installment $installment,
) {}
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Invoice;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InvoiceCancelled implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Invoice $invoice,
public User $actor,
) {}
}
......@@ -3,12 +3,17 @@
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Invoice;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InvoiceCreated
class InvoiceCreated implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(public Invoice $invoice) {}
public function __construct(
public Invoice $invoice,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Invoice;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InvoiceOverdue implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Invoice $invoice,
) {}
}
......@@ -3,12 +3,17 @@
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Invoice;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InvoicePaid
class InvoicePaid implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(public Invoice $invoice) {}
public function __construct(
public Invoice $invoice,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Invoice;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InvoiceSent implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Invoice $invoice,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\PaymentPlan;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PaymentPlanDefaulted implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public PaymentPlan $paymentPlan,
) {}
}
......@@ -3,12 +3,17 @@
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Payment;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PaymentReceived
class PaymentReceived implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(public Payment $payment) {}
public function __construct(
public Payment $payment,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Wallet;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WalletDeposited implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Wallet $wallet,
public int $amount,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Events;
use App\Domain\Financial\Models\Wallet;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WalletFrozen implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Wallet $wallet,
public User $actor,
) {}
}
<?php
namespace App\Domain\Financial\Listeners;
use App\Domain\Financial\Events\InvoicePaid;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class ActivateEnrollmentOnPayment implements ShouldQueue
{
public function handle(InvoicePaid $event): void
{
try {
$invoice = $event->invoice;
$pendingEnrollments = Enrollment::where('invoice_id', $invoice->id)
->where('status', 'pending')
->get();
foreach ($pendingEnrollments as $enrollment) {
$enrollment->update([
'status' => 'active',
'activated_at' => now(),
]);
}
} catch (\Throwable $e) {
Log::error('ActivateEnrollmentOnPayment failed: ' . $e->getMessage(), [
'invoice_id' => $event->invoice->id,
]);
}
}
public function failed(InvoicePaid $event, \Throwable $exception): void
{
Log::critical('ActivateEnrollmentOnPayment PERMANENTLY FAILED', [
'invoice_id' => $event->invoice->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Financial\Listeners;
use App\Domain\Financial\Events\InstallmentOverdue;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class NotifyInstallmentDue implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(InstallmentOverdue $event): void
{
try {
$plan = $event->installment->paymentPlan;
$this->notificationService->sendSimple(
type: 'installment_overdue',
recipientId: $plan->participant_id,
recipientType: 'participant',
data: [
'installment_sequence' => $event->installment->sequence,
'amount' => $event->installment->amount,
'due_date' => $event->installment->due_date->format('Y-m-d'),
],
);
} catch (\Throwable $e) {
Log::error('NotifyInstallmentDue failed: ' . $e->getMessage(), [
'installment_id' => $event->installment->id,
]);
}
}
public function failed(InstallmentOverdue $event, \Throwable $exception): void
{
Log::critical('NotifyInstallmentDue PERMANENTLY FAILED', [
'installment_id' => $event->installment->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Financial\Listeners;
use App\Domain\Financial\Events\InvoiceCreated;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class SendInvoiceNotification implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(InvoiceCreated $event): void
{
try {
$this->notificationService->sendSimple(
type: 'invoice_created',
recipientId: $event->invoice->billable_id,
recipientType: $event->invoice->billable_type,
data: [
'invoice_number' => $event->invoice->invoice_number,
'total_amount' => $event->invoice->total_amount,
'due_date' => $event->invoice->due_date?->format('Y-m-d'),
],
);
} catch (\Throwable $e) {
Log::error('SendInvoiceNotification failed: ' . $e->getMessage(), [
'invoice_id' => $event->invoice->id,
]);
}
}
public function failed(InvoiceCreated $event, \Throwable $exception): void
{
Log::critical('SendInvoiceNotification PERMANENTLY FAILED', [
'invoice_id' => $event->invoice->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Financial\Listeners;
use App\Domain\Financial\Events\InvoiceOverdue;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class SendOverdueReminder implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(InvoiceOverdue $event): void
{
try {
$this->notificationService->sendSimple(
type: 'invoice_overdue',
recipientId: $event->invoice->billable_id,
recipientType: $event->invoice->billable_type,
data: [
'invoice_number' => $event->invoice->invoice_number,
'total_amount' => $event->invoice->total_amount,
'balance_due' => $event->invoice->due_amount,
'due_date' => $event->invoice->due_date?->format('Y-m-d'),
'days_overdue' => $event->invoice->due_date?->diffInDays(now()),
],
);
} catch (\Throwable $e) {
Log::error('SendOverdueReminder failed: ' . $e->getMessage(), [
'invoice_id' => $event->invoice->id,
]);
}
}
public function failed(InvoiceOverdue $event, \Throwable $exception): void
{
Log::critical('SendOverdueReminder PERMANENTLY FAILED', [
'invoice_id' => $event->invoice->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Financial\Listeners;
use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class SendPaymentConfirmation implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(PaymentReceived $event): void
{
try {
$this->notificationService->sendSimple(
type: 'payment_received',
recipientId: $event->payment->payer_id,
recipientType: $event->payment->payer_type,
data: [
'payment_reference' => $event->payment->reference,
'amount' => $event->payment->amount,
'method' => $event->payment->method,
'invoice_number' => $event->payment->invoice?->invoice_number,
],
);
} catch (\Throwable $e) {
Log::error('SendPaymentConfirmation failed: ' . $e->getMessage(), [
'payment_id' => $event->payment->id,
]);
}
}
public function failed(PaymentReceived $event, \Throwable $exception): void
{
Log::critical('SendPaymentConfirmation PERMANENTLY FAILED', [
'payment_id' => $event->payment->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Financial\Listeners;
use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Financial\Models\CashSession;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class UpdateCashSessionTotals implements ShouldQueue
{
public function handle(PaymentReceived $event): void
{
try {
$payment = $event->payment;
if ($payment->method !== 'cash') {
return;
}
$cashSession = CashSession::where('user_id', $event->actor->id)
->where('status', 'open')
->first();
if (!$cashSession) {
return;
}
$cashSession->increment('transactions_count');
$cashSession->increment('total_cash_in', $payment->amount);
} catch (\Throwable $e) {
Log::error('UpdateCashSessionTotals failed: ' . $e->getMessage(), [
'payment_id' => $event->payment->id,
]);
}
}
public function failed(PaymentReceived $event, \Throwable $exception): void
{
Log::critical('UpdateCashSessionTotals PERMANENTLY FAILED', [
'payment_id' => $event->payment->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Financial\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CashSession extends Model
{
use BelongsToAcademy, HasUuid, Auditable;
protected $fillable = [
'academy_id', 'branch_id', 'user_id',
'opening_balance', 'closing_balance', 'expected_balance', 'variance',
'total_cash_in', 'total_cash_out', 'transactions_count',
'status', 'opened_at', 'closed_at',
'opening_notes', 'closing_notes', 'closed_by',
'denominations', 'metadata',
];
protected $casts = [
'opening_balance' => 'integer',
'closing_balance' => 'integer',
'expected_balance' => 'integer',
'variance' => 'integer',
'total_cash_in' => 'integer',
'total_cash_out' => 'integer',
'transactions_count' => 'integer',
'opened_at' => 'datetime',
'closed_at' => 'datetime',
'denominations' => 'array',
'metadata' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function branch(): BelongsTo
{
return $this->belongsTo(\App\Domain\Identity\Models\Branch::class);
}
public function closedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'closed_by');
}
public function isOpen(): bool
{
return $this->status === 'open';
}
public function scopeOpen($query)
{
return $query->where('status', 'open');
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
public function isFinancialAudit(): bool
{
return true;
}
}
......@@ -2,11 +2,14 @@
namespace App\Domain\Financial\Models;
use App\Domain\Shared\Traits\Auditable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Installment extends Model
{
use Auditable;
protected $fillable = [
'payment_plan_id',
'payment_id',
......@@ -41,4 +44,9 @@ public function isOverdue(): bool
{
return $this->status === 'pending' && $this->due_date->isPast();
}
public function isFinancialAudit(): bool
{
return true;
}
}
......@@ -3,6 +3,7 @@
namespace App\Domain\Financial\Models;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
......@@ -15,7 +16,7 @@
class Invoice extends Model
{
use HasUuid, BelongsToAcademy, SoftDeletes;
use HasUuid, BelongsToAcademy, SoftDeletes, Auditable;
protected $fillable = [
'academy_id',
......@@ -104,4 +105,9 @@ public function recalculateTotals(): void
$this->due_amount = $this->total_amount - $this->paid_amount;
$this->save();
}
public function isFinancialAudit(): bool
{
return true;
}
}
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
......@@ -15,7 +16,7 @@
class Payment extends Model
{
use HasUuid, BelongsToAcademy, SoftDeletes;
use HasUuid, BelongsToAcademy, SoftDeletes, Auditable;
protected $fillable = [
'academy_id',
......@@ -86,4 +87,9 @@ public function isConfirmed(): bool
{
return $this->status === PaymentStatus::Confirmed;
}
public function isFinancialAudit(): bool
{
return true;
}
}
......@@ -2,6 +2,7 @@
namespace App\Domain\Financial\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
......@@ -10,7 +11,7 @@
class PaymentPlan extends Model
{
use HasUuid, BelongsToAcademy;
use HasUuid, BelongsToAcademy, Auditable;
protected $fillable = [
'academy_id',
......@@ -50,4 +51,9 @@ public function isComplete(): bool
{
return $this->paid_installments >= $this->total_installments;
}
public function isFinancialAudit(): bool
{
return true;
}
}
......@@ -3,6 +3,7 @@
namespace App\Domain\Financial\Models;
use App\Domain\Financial\Enums\TransactionType;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
......@@ -12,7 +13,9 @@
class Transaction extends Model
{
use HasUuid, BelongsToAcademy;
use HasUuid, BelongsToAcademy, Auditable;
const UPDATED_AT = null;
protected $fillable = [
'academy_id',
......@@ -70,4 +73,9 @@ public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function isFinancialAudit(): bool
{
return true;
}
}
......@@ -2,6 +2,7 @@
namespace App\Domain\Financial\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
......@@ -10,7 +11,7 @@
class Wallet extends Model
{
use HasUuid, BelongsToAcademy;
use HasUuid, BelongsToAcademy, Auditable;
protected $fillable = [
'academy_id',
......@@ -43,4 +44,9 @@ public function formattedBalance(): string
{
return number_format($this->balance / 100, 2) . ' ' . $this->currency;
}
public function isFinancialAudit(): bool
{
return true;
}
}
......@@ -2,6 +2,7 @@
namespace App\Domain\Financial\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
......@@ -10,7 +11,7 @@
class WalletTransaction extends Model
{
use HasUuid;
use HasUuid, Auditable;
protected $fillable = [
'wallet_id',
......@@ -50,4 +51,9 @@ public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function isFinancialAudit(): bool
{
return true;
}
}
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Events\CashSessionClosed;
use App\Domain\Financial\Models\CashSession;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CashSessionService
{
public function open(User $user, int $branchId, int $openingBalance, ?string $notes = null): CashSession
{
return DB::transaction(function () use ($user, $branchId, $openingBalance, $notes) {
// Guard: user must not have another open session
$existingOpen = CashSession::where('user_id', $user->id)
->where('status', 'open')
->exists();
if ($existingOpen) {
throw new DomainException('يوجد وردية مفتوحة بالفعل. يجب إغلاقها أولاً');
}
return CashSession::create([
'academy_id' => $user->academy_id,
'branch_id' => $branchId,
'user_id' => $user->id,
'opening_balance' => $openingBalance,
'status' => 'open',
'opened_at' => now(),
'opening_notes' => $notes,
]);
});
}
public function close(CashSession $session, int $closingBalance, ?string $notes = null, ?array $denominations = null, ?User $closedBy = null): CashSession
{
return DB::transaction(function () use ($session, $closingBalance, $notes, $denominations, $closedBy) {
if ($session->status !== 'open') {
throw new DomainException('هذه الوردية ليست مفتوحة');
}
$expectedBalance = $session->opening_balance + $session->total_cash_in - $session->total_cash_out;
$variance = $closingBalance - $expectedBalance;
$session->update([
'closing_balance' => $closingBalance,
'expected_balance' => $expectedBalance,
'variance' => $variance,
'status' => 'closed',
'closed_at' => now(),
'closing_notes' => $notes,
'closed_by' => $closedBy?->id ?? $session->user_id,
'denominations' => $denominations,
]);
$freshSession = $session->fresh();
if ($closedBy) {
CashSessionClosed::dispatch($freshSession, $closedBy);
}
return $freshSession;
});
}
public function suspend(CashSession $session, string $reason, User $actor): CashSession
{
return DB::transaction(function () use ($session, $reason, $actor) {
if ($session->status !== 'open') {
throw new DomainException('هذه الوردية ليست مفتوحة');
}
$session->update([
'status' => 'suspended',
'closing_notes' => $reason,
'closed_by' => $actor->id,
]);
return $session->fresh();
});
}
public function recordCashIn(CashSession $session, int $amount): void
{
if (!$session->isOpen()) {
throw new DomainException('لا يمكن تسجيل عملية على وردية مغلقة');
}
$session->increment('total_cash_in', $amount);
$session->increment('transactions_count');
}
public function recordCashOut(CashSession $session, int $amount): void
{
if (!$session->isOpen()) {
throw new DomainException('لا يمكن تسجيل عملية على وردية مغلقة');
}
$session->increment('total_cash_out', $amount);
$session->increment('transactions_count');
}
public function getOpenSession(User $user): ?CashSession
{
return CashSession::where('user_id', $user->id)
->where('status', 'open')
->first();
}
}
......@@ -3,6 +3,7 @@
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Events\InvoiceCancelled;
use App\Domain\Financial\Events\InvoiceCreated;
use App\Domain\Financial\Events\InvoicePaid;
use App\Domain\Financial\Models\Invoice;
......@@ -32,7 +33,7 @@ public function create(array $data, array $items, User $creator): Invoice
$invoice->recalculateTotals();
InvoiceCreated::dispatch($invoice);
InvoiceCreated::dispatch($invoice, $creator);
return $invoice->fresh(['items']);
});
......@@ -55,16 +56,26 @@ public function addItem(Invoice $invoice, array $itemData): InvoiceItem
public function cancel(Invoice $invoice, User $user): Invoice
{
return DB::transaction(function () use ($invoice, $user) {
if ($invoice->paid_amount > 0) {
throw new \App\Domain\Shared\Exceptions\DomainException('لا يمكن إلغاء فاتورة بها مدفوعات');
}
if (!in_array($invoice->status, [InvoiceStatus::Draft, InvoiceStatus::Sent, InvoiceStatus::Overdue])) {
throw new \App\Domain\Shared\Exceptions\DomainException('لا يمكن إلغاء فاتورة بحالة: ' . $invoice->status->label());
}
$invoice->update([
'status' => InvoiceStatus::Cancelled,
'cancelled_at' => now(),
]);
InvoiceCancelled::dispatch($invoice, $user);
return $invoice;
});
}
public function markAsPaid(Invoice $invoice): void
public function markAsPaid(Invoice $invoice, User $actor): void
{
$invoice->update([
'status' => InvoiceStatus::Paid,
......@@ -72,10 +83,10 @@ public function markAsPaid(Invoice $invoice): void
'due_amount' => 0,
]);
InvoicePaid::dispatch($invoice);
InvoicePaid::dispatch($invoice, $actor);
}
public function updatePaidAmount(Invoice $invoice, int $amount): void
public function updatePaidAmount(Invoice $invoice, int $amount, User $actor): void
{
$invoice->paid_amount += $amount;
$invoice->due_amount = $invoice->total_amount - $invoice->paid_amount;
......@@ -92,7 +103,7 @@ public function updatePaidAmount(Invoice $invoice, int $amount): void
$invoice->save();
if ($invoice->status === InvoiceStatus::Paid) {
InvoicePaid::dispatch($invoice);
InvoicePaid::dispatch($invoice, $actor);
}
}
......
......@@ -30,13 +30,14 @@ public function recordPayment(array $data, User $creator): Payment
if ($payment->invoice_id) {
$this->invoiceService->updatePaidAmount(
$payment->invoice,
$payment->amount
$payment->amount,
$creator
);
}
$this->createTransaction($payment);
PaymentReceived::dispatch($payment);
PaymentReceived::dispatch($payment, $creator);
return $payment;
});
......@@ -65,7 +66,8 @@ public function refund(Payment $payment, int $amount, User $creator): Payment
if ($payment->invoice_id) {
$this->invoiceService->updatePaidAmount(
$payment->invoice,
-$amount
-$amount,
$creator
);
}
......
......@@ -2,6 +2,7 @@
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Events\WalletDeposited;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Financial\Models\WalletTransaction;
use App\Models\User;
......@@ -36,7 +37,7 @@ public function deposit(Wallet $wallet, int $amount, string $description, ?Model
$wallet->increment('balance', $amount);
return WalletTransaction::create([
$transaction = WalletTransaction::create([
'wallet_id' => $wallet->id,
'type' => 'deposit',
'direction' => 'credit',
......@@ -48,6 +49,12 @@ public function deposit(Wallet $wallet, int $amount, string $description, ?Model
'description' => $description,
'created_by' => $creator?->id,
]);
if ($creator) {
WalletDeposited::dispatch($wallet, $amount, $creator);
}
return $transaction;
});
}
......
<?php
namespace App\Domain\Identity\DTOs;
use App\Models\User;
readonly class AuthResult
{
public function __construct(
public bool $success,
public ?User $user = null,
public ?string $reason = null,
public ?string $status = null,
public ?int $minutesRemaining = null,
public ?int $attemptsRemaining = null,
) {}
}
<?php
namespace App\Domain\Identity\DTOs;
use Carbon\Carbon;
readonly class NationalIdData
{
public function __construct(
public Carbon $birthDate,
public string $gender,
public string $governorate,
public string $governorateCode,
) {}
}
<?php
namespace App\Domain\Identity\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Branch extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id', 'name', 'name_ar', 'code',
'address', 'city', 'governorate', 'phone', 'email',
'latitude', 'longitude', 'is_main', 'is_active',
'manager_id', 'operating_hours',
];
protected $casts = [
'is_main' => 'boolean',
'is_active' => 'boolean',
'latitude' => 'decimal:7',
'longitude' => 'decimal:7',
'operating_hours' => 'array',
];
public function manager(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'manager_id');
}
public function users(): HasMany
{
return $this->hasMany(\App\Models\User::class);
}
}
<?php
namespace App\Domain\Identity\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Guardian extends Model
{
use BelongsToAcademy, HasUuid, Auditable;
protected $fillable = [
'academy_id', 'person_id', 'user_id',
'relationship_type', 'occupation', 'workplace',
'can_pickup', 'is_emergency_contact', 'is_financial_responsible',
'notes',
];
protected $casts = [
'can_pickup' => 'boolean',
'is_emergency_contact' => 'boolean',
'is_financial_responsible' => 'boolean',
];
public function person(): BelongsTo
{
return $this->belongsTo(Person::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
public function participants(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(\App\Domain\Participant\Models\Participant::class, 'guardian_participant')
->withPivot('relationship_type', 'is_primary', 'is_emergency_contact', 'can_pickup', 'receives_notifications', 'can_authorize_payment', 'created_at');
}
public function getNameArAttribute(): string
{
return $this->person?->name_ar ?? '';
}
public function getPhoneAttribute(): string
{
return $this->person?->phone ?? '';
}
}
<?php
namespace App\Domain\Identity\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LoginHistory extends Model
{
use BelongsToAcademy;
public $timestamps = false;
protected $table = 'login_history';
protected $fillable = [
'user_id', 'academy_id', 'ip_address', 'user_agent',
'status', 'failed_reason',
];
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $casts = [
'created_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
}
<?php
namespace App\Domain\Identity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Permission extends Model
{
public $timestamps = false;
protected $fillable = [
'name', 'module', 'action', 'description', 'description_ar',
];
// created_at only (no updated_at)
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'permission_role')
->withPivot('scope', 'created_at');
}
}
<?php
namespace App\Domain\Identity\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Person extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes, Auditable;
protected $table = 'people';
protected $fillable = [
'academy_id', 'user_id', 'name', 'name_ar',
'email', 'phone', 'phone_secondary',
'national_id', 'date_of_birth', 'gender', 'nationality',
'address', 'city', 'governorate',
'photo_path', 'blood_type', 'medical_notes',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'classification', 'source', 'notes', 'metadata', 'created_by',
];
protected $casts = [
'date_of_birth' => 'date',
'metadata' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
public function guardian(): HasOne
{
return $this->hasOne(Guardian::class);
}
public function participant(): HasOne
{
return $this->hasOne(\App\Domain\Participant\Models\Participant::class);
}
public function getAgeAttribute(): ?int
{
return $this->date_of_birth?->age;
}
public function getFullNameAttribute(): string
{
return app()->getLocale() === 'ar'
? ($this->name_ar ?: $this->name)
: ($this->name ?: $this->name_ar);
}
}
<?php
namespace App\Domain\Identity\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Role extends Model
{
use BelongsToAcademy, Auditable;
protected $fillable = [
'academy_id', 'name', 'name_ar', 'slug', 'level',
'description', 'is_system',
];
protected $casts = [
'level' => 'integer',
'is_system' => 'boolean',
];
public function permissions(): BelongsToMany
{
return $this->belongsToMany(Permission::class, 'permission_role')
->withPivot('scope', 'created_at');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(\App\Models\User::class, 'role_user')
->withPivot('branch_id', 'assigned_by', 'created_at');
}
}
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\DTOs\AuthResult;
use App\Domain\Identity\Models\LoginHistory;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
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
{
$user = User::where('email', $email)->first();
if (!$user) {
return new AuthResult(success: false, reason: 'invalid_credentials');
}
// Check if account is locked
if ($user->isLockedOut()) {
$this->recordLogin($user, $ip, $userAgent, 'locked', 'Account locked');
$minutesRemaining = now()->diffInMinutes($user->locked_until);
return new AuthResult(success: false, reason: 'locked', minutesRemaining: $minutesRemaining);
}
// Check account status
if ($user->status !== 'active') {
$this->recordLogin($user, $ip, $userAgent, 'blocked', "Account status: {$user->status}");
return new AuthResult(success: false, reason: 'inactive', status: $user->status);
}
// Verify password
if (!Hash::check($password, $user->password)) {
$user->increment('failed_login_attempts');
// Lock if threshold reached
if ($user->failed_login_attempts >= self::MAX_FAILED_ATTEMPTS) {
$user->update(['locked_until' => now()->addMinutes(self::LOCKOUT_MINUTES)]);
$this->recordLogin($user, $ip, $userAgent, 'locked', 'Max attempts exceeded');
return new AuthResult(success: false, reason: 'locked', minutesRemaining: self::LOCKOUT_MINUTES);
}
$this->recordLogin($user, $ip, $userAgent, 'failed', 'Invalid password');
$attemptsRemaining = self::MAX_FAILED_ATTEMPTS - $user->failed_login_attempts;
return new AuthResult(success: false, reason: 'invalid_credentials', attemptsRemaining: $attemptsRemaining);
}
// SUCCESS
$user->update([
'failed_login_attempts' => 0,
'locked_until' => null,
'last_login_at' => now(),
'last_login_ip' => $ip,
'login_count' => $user->login_count + 1,
]);
$this->recordLogin($user, $ip, $userAgent, 'success');
return new AuthResult(success: true, user: $user);
}
private function recordLogin(User $user, string $ip, ?string $userAgent, string $status, ?string $reason = null): void
{
LoginHistory::create([
'user_id' => $user->id,
'academy_id' => $user->academy_id,
'ip_address' => $ip,
'user_agent' => $userAgent,
'status' => $status,
'failed_reason' => $reason,
]);
}
}
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class BranchService
{
public function create(array $data, User $actor): Branch
{
return DB::transaction(function () use ($data, $actor) {
$branch = Branch::create($data);
// If this is marked as main, unmark others
if ($branch->is_main) {
Branch::where('academy_id', $branch->academy_id)
->where('id', '!=', $branch->id)
->update(['is_main' => false]);
}
return $branch;
});
}
public function update(Branch $branch, array $data, User $actor): Branch
{
return DB::transaction(function () use ($branch, $data) {
$branch->update($data);
if (isset($data['is_main']) && $data['is_main']) {
Branch::where('academy_id', $branch->academy_id)
->where('id', '!=', $branch->id)
->update(['is_main' => false]);
}
return $branch->fresh();
});
}
public function delete(Branch $branch): void
{
// TODO: in later phases, check for active participants, groups, sessions
if ($branch->is_main) {
throw new DomainException(
__('لا يمكن حذف الفرع الرئيسي')
);
}
$branch->delete();
}
}
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\DTOs\NationalIdData;
use Carbon\Carbon;
class NationalIdService
{
public function parse(string $nationalId): ?NationalIdData
{
if (!$this->isValid($nationalId)) {
return null;
}
$century = (int) $nationalId[0];
$year = (int) substr($nationalId, 1, 2);
$month = (int) substr($nationalId, 3, 2);
$day = (int) substr($nationalId, 5, 2);
$governorateCode = substr($nationalId, 7, 2);
$genderDigit = (int) $nationalId[12];
$fullYear = ($century === 2 ? 1900 : 2000) + $year;
$birthDate = Carbon::createFromDate($fullYear, $month, $day);
$gender = $genderDigit % 2 === 0 ? 'female' : 'male';
$governorate = self::GOVERNORATES[$governorateCode] ?? 'Unknown';
return new NationalIdData(
birthDate: $birthDate,
gender: $gender,
governorate: $governorate,
governorateCode: $governorateCode,
);
}
public function isValid(string $nationalId): bool
{
// Must be exactly 14 digits
if (!preg_match('/^\d{14}$/', $nationalId)) {
return false;
}
// Century must be 2 or 3
$century = (int) $nationalId[0];
if ($century !== 2 && $century !== 3) {
return false;
}
// Month 01-12
$month = (int) substr($nationalId, 3, 2);
if ($month < 1 || $month > 12) {
return false;
}
// Day 01-31
$day = (int) substr($nationalId, 5, 2);
if ($day < 1 || $day > 31) {
return false;
}
return true;
}
private const GOVERNORATES = [
'01' => 'Cairo', '02' => 'Alexandria', '03' => 'Port Said',
'04' => 'Suez', '11' => 'Damietta', '12' => 'Dakahlia',
'13' => 'Sharqia', '14' => 'Qalyubia', '15' => 'Kafr El Sheikh',
'16' => 'Gharbia', '17' => 'Monufia', '18' => 'Beheira',
'19' => 'Ismailia', '21' => 'Giza', '22' => 'Beni Suef',
'23' => 'Fayoum', '24' => 'Minya', '25' => 'Asyut',
'26' => 'Sohag', '27' => 'Qena', '28' => 'Aswan',
'29' => 'Luxor', '31' => 'Red Sea', '32' => 'New Valley',
'33' => 'Matrouh', '34' => 'North Sinai', '35' => 'South Sinai',
'88' => 'Foreign',
];
}
This diff is collapsed.
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\DTOs\NationalIdData;
use App\Domain\Identity\Models\Person;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class PersonService
{
public function __construct(
private readonly NationalIdService $nationalIdService,
) {}
public function create(array $data, User $actor): Person
{
return DB::transaction(function () use ($data, $actor) {
// Parse national ID if provided
if (!empty($data['national_id'])) {
$parsed = $this->nationalIdService->parse($data['national_id']);
if ($parsed) {
$data['date_of_birth'] = $data['date_of_birth'] ?? $parsed->birthDate->toDateString();
$data['gender'] = $data['gender'] ?? $parsed->gender;
$data['governorate'] = $data['governorate'] ?? $parsed->governorate;
}
}
$data['created_by'] = $actor->id;
return Person::create($data);
});
}
public function update(Person $person, array $data, User $actor): Person
{
return DB::transaction(function () use ($person, $data) {
// Re-parse national ID if changed
if (!empty($data['national_id']) && $data['national_id'] !== $person->national_id) {
$parsed = $this->nationalIdService->parse($data['national_id']);
if ($parsed) {
$data['date_of_birth'] = $data['date_of_birth'] ?? $parsed->birthDate->toDateString();
$data['gender'] = $data['gender'] ?? $parsed->gender;
$data['governorate'] = $data['governorate'] ?? $parsed->governorate;
}
}
$person->update($data);
return $person->fresh();
});
}
public function linkUser(Person $person, User $user): Person
{
$person->update(['user_id' => $user->id]);
$user->update(['person_id' => $person->id]);
return $person->fresh();
}
}
This diff is collapsed.
<?php
namespace App\Domain\Inventory\Enums;
enum MovementDirection: string
{
case In = 'in';
case Out = 'out';
public function label(): string
{
return match ($this) {
self::In => 'وارد',
self::Out => 'صادر',
};
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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