# Cycle 12: Approval Workflows

## Goal
Create a configurable approval system for sensitive operations (refunds, large discounts, participant status changes, stock adjustments). Build the `approval_requests` table, approval chain resolution, notification integration, and admin UI.

---

## Migration: `2024_01_01_000070_create_approval_requests_table.php`

```sql
CREATE TABLE approval_requests (
    id                  BIGSERIAL PRIMARY KEY,
    uuid                UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    academy_id          BIGINT NOT NULL REFERENCES organizations(id),
    request_type        VARCHAR(50) NOT NULL,
    requestable_type    VARCHAR(150) NOT NULL,
    requestable_id      BIGINT NOT NULL,
    requested_by        BIGINT NOT NULL REFERENCES users(id),
    assigned_to         BIGINT REFERENCES users(id),
    assigned_role       VARCHAR(50),
    status              VARCHAR(20) NOT NULL DEFAULT 'pending',
    priority            VARCHAR(10) NOT NULL DEFAULT 'normal',
    title               VARCHAR(200) NOT NULL,
    description         TEXT,
    context_data        JSONB NOT NULL DEFAULT '{}',
    resolution_notes    TEXT,
    resolved_by         BIGINT REFERENCES users(id),
    resolved_at         TIMESTAMP,
    expires_at          TIMESTAMP,
    created_at          TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMP NOT NULL DEFAULT NOW()
);

ALTER TABLE approval_requests ADD CONSTRAINT approval_requests_status_check
    CHECK (status IN ('pending', 'approved', 'rejected', 'expired', 'cancelled'));

ALTER TABLE approval_requests ADD CONSTRAINT approval_requests_priority_check
    CHECK (priority IN ('low', 'normal', 'high', 'urgent'));

CREATE INDEX approval_requests_academy_status ON approval_requests(academy_id, status);
CREATE INDEX approval_requests_assigned ON approval_requests(assigned_to, status) WHERE status = 'pending';
CREATE INDEX approval_requests_role ON approval_requests(assigned_role, status) WHERE status = 'pending';
CREATE INDEX approval_requests_requestable ON approval_requests(requestable_type, requestable_id);
```

---

## Migration: `2024_01_01_000071_create_approval_chains_table.php`

```sql
CREATE TABLE approval_chains (
    id              BIGSERIAL PRIMARY KEY,
    academy_id      BIGINT NOT NULL REFERENCES organizations(id),
    request_type    VARCHAR(50) NOT NULL,
    step_order      SMALLINT NOT NULL,
    approver_role   VARCHAR(50) NOT NULL,
    is_required     BOOLEAN NOT NULL DEFAULT TRUE,
    auto_approve_after_hours INTEGER,
    created_at      TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX approval_chains_unique ON approval_chains(academy_id, request_type, step_order);
```

---

## Migration: `2024_01_01_000072_create_approval_logs_table.php`

```sql
CREATE TABLE approval_logs (
    id                  BIGSERIAL PRIMARY KEY,
    approval_request_id BIGINT NOT NULL REFERENCES approval_requests(id) ON DELETE CASCADE,
    action              VARCHAR(20) NOT NULL,
    actor_id            BIGINT NOT NULL REFERENCES users(id),
    notes               TEXT,
    created_at          TIMESTAMP NOT NULL DEFAULT NOW()
);

ALTER TABLE approval_logs ADD CONSTRAINT approval_logs_action_check
    CHECK (action IN ('created', 'approved', 'rejected', 'escalated', 'reassigned', 'commented', 'cancelled'));
```

---

## Request Types (Configurable Per Academy)

| Type | Default Approver Role | Trigger |
|------|----------------------|---------|
| `refund` | `branch_manager` | RefundService when amount > threshold |
| `large_discount` | `academy_admin` | PricingService when discount > academy max |
| `participant_blacklist` | `academy_owner` | ParticipantService on blacklist transition |
| `stock_adjustment` | `branch_manager` | InventoryService on manual adjustment |
| `invoice_cancel` | `branch_manager` | InvoiceService when invoice has partial payment |
| `wallet_manual_credit` | `accountant` | WalletService on manual top-up above threshold |
| `session_cancel` | `head_trainer` | SessionService when < 24h notice |
| `enrollment_override` | `branch_manager` | EnrollmentService when group is full |

---

## Models

### `app/Domain/Shared/Models/ApprovalRequest.php`

```php
namespace App\Domain\Shared\Models;

use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;

class ApprovalRequest extends Model
{
    use BelongsToAcademy, HasUuid;

    protected $fillable = [
        'academy_id', 'request_type', 'requestable_type', 'requestable_id',
        'requested_by', 'assigned_to', 'assigned_role', 'status', 'priority',
        'title', 'description', 'context_data', 'resolution_notes',
        'resolved_by', 'resolved_at', 'expires_at',
    ];

    protected $casts = [
        'context_data' => 'array',
        'resolved_at' => 'datetime',
        'expires_at' => 'datetime',
    ];

    public function requestable(): MorphTo { return $this->morphTo(); }
    public function requester(): BelongsTo { return $this->belongsTo(User::class, 'requested_by'); }
    public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to'); }
    public function resolver(): BelongsTo { return $this->belongsTo(User::class, 'resolved_by'); }
    public function logs(): HasMany { return $this->hasMany(ApprovalLog::class); }

    public function isPending(): bool { return $this->status === 'pending'; }
    public function isExpired(): bool { return $this->expires_at && $this->expires_at->isPast(); }
}
```

### `app/Domain/Shared/Models/ApprovalChain.php`

```php
class ApprovalChain extends Model
{
    use BelongsToAcademy;
    public $timestamps = false;
    const UPDATED_AT = null;

    protected $fillable = [
        'academy_id', 'request_type', 'step_order',
        'approver_role', 'is_required', 'auto_approve_after_hours',
    ];
}
```

---

## ApprovalService: `app/Domain/Shared/Services/ApprovalService.php`

```php
namespace App\Domain\Shared\Services;

class ApprovalService
{
    public function request(
        string $requestType,
        Model $requestable,
        string $title,
        array $contextData,
        User $requester,
        string $priority = 'normal',
        ?int $expiresInHours = null
    ): ApprovalRequest {
        return DB::transaction(function () use ($requestType, $requestable, $title, $contextData, $requester, $priority, $expiresInHours) {
            $chain = $this->resolveChain($requestType, $requester);

            $request = ApprovalRequest::create([
                'academy_id' => $requester->academy_id,
                'request_type' => $requestType,
                'requestable_type' => $requestable->getMorphClass(),
                'requestable_id' => $requestable->id,
                'requested_by' => $requester->id,
                'assigned_to' => $chain['assigned_user_id'],
                'assigned_role' => $chain['assigned_role'],
                'status' => 'pending',
                'priority' => $priority,
                'title' => $title,
                'description' => $this->buildDescription($requestType, $contextData),
                'context_data' => $contextData,
                'expires_at' => $expiresInHours ? now()->addHours($expiresInHours) : null,
            ]);

            ApprovalLog::create([
                'approval_request_id' => $request->id,
                'action' => 'created',
                'actor_id' => $requester->id,
            ]);

            ApprovalRequested::dispatch($request);
            return $request;
        });
    }

    public function approve(ApprovalRequest $request, User $actor, ?string $notes = null): ApprovalRequest
    {
        $this->assertCanResolve($request, $actor);

        return DB::transaction(function () use ($request, $actor, $notes) {
            $request->update([
                'status' => 'approved',
                'resolved_by' => $actor->id,
                'resolved_at' => now(),
                'resolution_notes' => $notes,
            ]);

            ApprovalLog::create([
                'approval_request_id' => $request->id,
                'action' => 'approved',
                'actor_id' => $actor->id,
                'notes' => $notes,
            ]);

            ApprovalResolved::dispatch($request, 'approved');
            $this->executePostApproval($request);
            return $request;
        });
    }

    public function reject(ApprovalRequest $request, User $actor, string $reason): ApprovalRequest
    {
        $this->assertCanResolve($request, $actor);

        return DB::transaction(function () use ($request, $actor, $reason) {
            $request->update([
                'status' => 'rejected',
                'resolved_by' => $actor->id,
                'resolved_at' => now(),
                'resolution_notes' => $reason,
            ]);

            ApprovalLog::create([
                'approval_request_id' => $request->id,
                'action' => 'rejected',
                'actor_id' => $actor->id,
                'notes' => $reason,
            ]);

            ApprovalResolved::dispatch($request, 'rejected');
            return $request;
        });
    }

    // ─── Resolution Logic ──────────────────────────────────

    private function resolveChain(string $requestType, User $requester): array
    {
        // Look up academy-specific chain first
        $chain = ApprovalChain::where('academy_id', $requester->academy_id)
            ->where('request_type', $requestType)
            ->orderBy('step_order')
            ->first();

        if ($chain) {
            // Find a user with the required role in this academy
            $assignee = User::where('academy_id', $requester->academy_id)
                ->whereHas('roles', fn ($q) => $q->where('slug', $chain->approver_role))
                ->where('id', '!=', $requester->id) // Cannot approve own request
                ->first();

            return [
                'assigned_user_id' => $assignee?->id,
                'assigned_role' => $chain->approver_role,
            ];
        }

        // Default chain: escalate to branch_manager, then academy_admin
        return [
            'assigned_user_id' => null,
            'assigned_role' => 'branch_manager',
        ];
    }

    private function assertCanResolve(ApprovalRequest $request, User $actor): void
    {
        if (!$request->isPending()) {
            throw new DomainException('هذا الطلب تم البت فيه بالفعل');
        }

        // Check if actor has the assigned role OR is the direct assignee
        if ($request->assigned_to && $request->assigned_to === $actor->id) return;
        if ($request->assigned_role && $actor->hasRole($request->assigned_role)) return;

        // Super admin can always resolve
        if ($actor->hasRole('super_admin')) return;

        throw new DomainException('ليس لديك صلاحية البت في هذا الطلب');
    }

    private function executePostApproval(ApprovalRequest $request): void
    {
        // After approval, execute the pending action
        match ($request->request_type) {
            'refund' => app(RefundService::class)->executeApprovedRefund($request),
            'stock_adjustment' => app(InventoryService::class)->executeApprovedAdjustment($request),
            'participant_blacklist' => app(ParticipantService::class)->executeApprovedBlacklist($request),
            'enrollment_override' => app(EnrollmentService::class)->executeApprovedOverride($request),
            default => null, // Some types just need the approval flag, no auto-execution
        };
    }

    // ─── Helper: Requires Approval? ──────────────────────────

    public function requiresApproval(string $requestType, array $context): bool
    {
        // Check if this type is configured for the current academy
        $settings = SystemSetting::get("approvals.{$requestType}", [
            'enabled' => true,
            'threshold' => null,
        ]);

        if (!$settings['enabled']) return false;

        // Check threshold (e.g., refunds > 50000 piasters need approval)
        if ($settings['threshold'] && isset($context['amount'])) {
            return $context['amount'] > $settings['threshold'];
        }

        return true;
    }
}
```

---

## Integration with Existing Services

### RefundService Integration:
```php
public function initiateRefund(Payment $payment, int $amount, string $reason, User $actor): mixed
{
    if (app(ApprovalService::class)->requiresApproval('refund', ['amount' => $amount])) {
        return app(ApprovalService::class)->request(
            requestType: 'refund',
            requestable: $payment,
            title: "طلب استرداد - فاتورة #{$payment->invoice->invoice_number}",
            contextData: [
                'payment_id' => $payment->id,
                'refund_amount' => $amount,
                'reason' => $reason,
                'original_amount' => $payment->amount,
            ],
            requester: $actor,
            priority: $amount > 100000 ? 'high' : 'normal',
        );
    }

    // No approval needed — execute directly
    return $this->executeRefund($payment, $amount, $reason, $actor);
}

public function executeApprovedRefund(ApprovalRequest $request): void
{
    $payment = Payment::findOrFail($request->context_data['payment_id']);
    $this->executeRefund(
        $payment,
        $request->context_data['refund_amount'],
        $request->context_data['reason'],
        $request->resolver,
    );
}
```

---

## Livewire: Approval Queue

### `app/Livewire/Approvals/ApprovalQueue.php`

```php
class ApprovalQueue extends Component
{
    use WithPagination;

    #[Url] public string $status = 'pending';
    #[Url] public string $type = '';
    #[Url] public string $priority = '';

    public function render()
    {
        $this->authorize('approvals.list');

        $user = auth()->user();
        $query = ApprovalRequest::query()
            ->when($this->status, fn ($q) => $q->where('status', $this->status))
            ->when($this->type, fn ($q) => $q->where('request_type', $this->type))
            ->when($this->priority, fn ($q) => $q->where('priority', $this->priority));

        // Show only requests assigned to this user or their role
        if (!$user->hasRole('super_admin') && !$user->hasRole('academy_owner')) {
            $userRoles = $user->roles->pluck('slug')->toArray();
            $query->where(function ($q) use ($user, $userRoles) {
                $q->where('assigned_to', $user->id)
                  ->orWhereIn('assigned_role', $userRoles);
            });
        }

        $requests = $query->with(['requester', 'requestable'])
            ->orderByRaw("CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END")
            ->orderBy('created_at')
            ->paginate(15);

        return view('livewire.approvals.approval-queue', compact('requests'));
    }

    public function approve(int $requestId): void
    {
        $request = ApprovalRequest::findOrFail($requestId);
        try {
            app(ApprovalService::class)->approve($request, auth()->user());
            $this->dispatch('toast', type: 'success', message: __('تم الموافقة بنجاح'));
        } catch (DomainException $e) {
            $this->dispatch('toast', type: 'error', message: $e->getMessage());
        }
    }

    public function reject(int $requestId, string $reason): void
    {
        $request = ApprovalRequest::findOrFail($requestId);
        try {
            app(ApprovalService::class)->reject($request, auth()->user(), $reason);
            $this->dispatch('toast', type: 'success', message: __('تم الرفض'));
        } catch (DomainException $e) {
            $this->dispatch('toast', type: 'error', message: $e->getMessage());
        }
    }
}
```

---

## Scheduled Job: Expire Old Requests

### `app/Console/Commands/ExpireApprovalRequests.php`
```php
protected $signature = 'approvals:expire';

public function handle(): int
{
    $expired = ApprovalRequest::where('status', 'pending')
        ->whereNotNull('expires_at')
        ->where('expires_at', '<', now())
        ->update(['status' => 'expired']);

    $this->info("Expired {$expired} approval requests.");
    return 0;
}
```

Schedule: `->hourly()`

---

## Routes

```php
Route::middleware(['auth', 'permission:approvals.list'])->group(function () {
    Route::get('/approvals', ApprovalQueue::class)->name('approvals.index');
});
```

---

## Permissions to Seed

```
approvals.list, approvals.approve, approvals.reject, approvals.configure
```

---

## Sidebar Addition

Under "الإدارة" (Management) or as a topbar badge:
- طلبات الموافقة (Approvals) — with pending count badge

---

## Notification Integration

When `ApprovalRequested` is dispatched:
1. In-app notification to assigned_to (or all users with assigned_role)
2. If priority = 'urgent' or 'high': push email notification immediately
3. Include approve/reject deep links in notification

When `ApprovalResolved` is dispatched:
1. Notify the original requester (approved or rejected)
2. Include resolution notes

---

## Academy Settings for Approval Configuration

Stored in `system_settings` JSONB:
```json
{
    "approvals": {
        "refund": { "enabled": true, "threshold": 50000 },
        "large_discount": { "enabled": true, "threshold": 30 },
        "stock_adjustment": { "enabled": true, "threshold": null },
        "participant_blacklist": { "enabled": true, "threshold": null },
        "session_cancel": { "enabled": false, "threshold": null }
    }
}
```

---

## Verification

- [ ] Refund above threshold → creates pending approval request
- [ ] Refund below threshold → executes directly (no approval)
- [ ] Approver sees request in queue, approves → refund executes
- [ ] Approver rejects → requester notified, refund NOT executed
- [ ] Expired requests auto-transition to 'expired' status
- [ ] User cannot approve own requests
- [ ] Only users with correct role see relevant requests
- [ ] Urgent priority requests appear first in queue
- [ ] Academy owner can configure thresholds in settings
- [ ] Approval log records all actions immutably
