# Quickstart — SP-05 Agent Management & Investor Logic

**Branch**: `006-agent-investor` | **Date**: 2026-06-15
**Spec**: [spec.md](spec.md) · **Plan**: [plan.md](plan.md) · **Data Model**: [data-model.md](data-model.md)

A 10-minute onboarding for engineers implementing SP-05. Assumes you have:
- PHP 8.3+ with `bcmath` extension
- PostgreSQL 15+ with the Tamkeen schema already migrated (SP-02)
- Laravel 12+ with Sanctum + Spatie installed (SP-01)

---

## 1. Repository Setup (1 min)

```bash
git checkout 006-agent-investor
composer install
php artisan migrate
```

Verify pre-existing artifacts are available:
```bash
# Client model + immutability (SP-03)
php artisan tinker --execute="echo class_exists('App\Domains\Customer\Models\Client') ? 'OK' : 'MISSING';"

# HasReferenceNumber trait (SP-03)
php artisan tinker --execute="echo trait_exists('App\Shared\Traits\HasReferenceNumber') ? 'OK' : 'MISSING';"

# agent_shares_logs table (SP-02)
php artisan tinker --execute="echo \Illuminate\Support\Facades\Schema::hasTable('agent_shares_logs') ? 'OK' : 'MISSING';"

# Permissions seeded (SP-04)
php artisan tinker --execute="echo \Spatie\Permission\Models\Permission::where('name', 'agents.view')->exists() ? 'OK' : 'MISSING';"
```

If any output is `MISSING`, the prerequisite Specs are not implemented; return to that Spec first.

---

## 2. Domain Scaffolding (2 min)

Create the new domain directory structure:

```bash
mkdir -p app/Domains/Agent/{Http/Controllers,Http/Requests,Http/Resources,Services,Models/Scopes,Routes/v1}
```

Register the new `AgentServiceProvider` in `bootstrap/providers.php`:
```php
return [
    // ... existing providers ...
    App\Domains\Agent\AgentServiceProvider::class,
];
```

The `AgentServiceProvider` boots:
```php
public function boot(): void
{
    $this->loadRoutesFrom(__DIR__ . '/Routes/v1/api.php');
}
```

The `Routes/v1/api.php`:
```php
use App\Domains\Agent\Http\Controllers\AgentController;
use App\Domains\Agent\Http\Controllers\AgentSharesController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth:sanctum'])->prefix('agents')->group(function () {
    Route::get('/', [AgentController::class, 'index'])->middleware('permission:agents.view');
    Route::post('/', [AgentController::class, 'store'])->middleware('permission:agents.create');
    Route::get('{id}', [AgentController::class, 'show'])->whereNumber('id')->middleware('permission:agents.view');
    Route::put('{id}', [AgentController::class, 'update'])->whereNumber('id')->middleware('permission:agents.update');
    // NO DELETE — see BR-001-1, SP-05-FR-011

    Route::post('{id}/shares', [AgentSharesController::class, 'store'])->whereNumber('id')->middleware('permission:agents.manage_shares');
    Route::patch('{id}/shares/{shareLogId}', [AgentSharesController::class, 'update'])
        ->whereNumber('id')->whereNumber('shareLogId')
        ->middleware('permission:agents.manage_shares');
    Route::delete('{id}/shares/{shareLogId}', [AgentSharesController::class, 'destroy'])
        ->whereNumber('id')->whereNumber('shareLogId')
        ->middleware('permission:agents.manage_shares');
    Route::get('{id}/shares-log', [AgentSharesController::class, 'log'])->whereNumber('id')->middleware('permission:agents.view_shares_log');
});
```

> **Note on rate limiting**: The throttle middleware is applied project-wide (per NFR-003-6, Q5 clarification) by the `bootstrap/app.php` configuration, NOT in the per-domain route file. The throttle key is the admin user ID.

---

## 3. Models (3 min)

You do NOT need a new `Agent` model — agents are `Client` instances. You DO need a model for share log rows (the existing migration creates the table, but the Eloquent model must be in the Agent domain).

```bash
# app/Domains/Agent/Models/AgentSharesLog.php
```

```php
<?php
namespace App\Domains\Agent\Models;

use App\Domains\Agent\Models\Scopes\AgentSharesLogScopes;
use App\Domains\Customer\Models\Client;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class AgentSharesLog extends Model
{
    use AgentSharesLogScopes;

    protected $table = 'agent_shares_logs';
    public $timestamps = true;

    protected $fillable = [
        'client_id', 'shares_count', 'transaction_type', 'status',
    ];

    protected $casts = [
        'shares_count' => 'integer',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    public function client(): BelongsTo
    {
        return $this->belongsTo(Client::class, 'client_id');
    }
}
```

Scopes trait:
```php
// app/Domains/Agent/Models/Scopes/AgentSharesLogScopes.php
<?php
namespace App\Domains\Agent\Models\Scopes;

trait AgentSharesLogScopes
{
    public function scopeActive($q) { return $q->where('status', 'active'); }
    public function scopeForAgent($q, int $agentId) { return $q->where('client_id', $agentId); }
    public function scopeRecentFirst($q) {
        return $q->orderByDesc('created_at')->orderByDesc('id');
    }
    public function scopeMostRecentActive($q) {
        return $q->active()->recentFirst();
    }
}
```

---

## 4. FormRequests (5 min)

Five FormRequests, one per write endpoint + the list request. Each one delegates messages to `lang/ar/validation.php` and `lang/ar/errors/*.php`.

```php
// app/Domains/Agent/Http/Requests/StoreAgentRequest.php
<?php
namespace App\Domains\Agent\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreAgentRequest extends FormRequest
{
    public function authorize(): bool { return true; } // gated by middleware
    public function rules(): array {
        return [
            'name' => 'required|string|max:150',
            'phone' => 'required|string|max:20|unique:clients,phone',
            'description' => 'nullable|string',
            'shares_count' => 'nullable|integer|min:0',
        ];
    }
    public function messages(): array { return __('validation'); }
}
```

(Repeat pattern for `UpdateAgentRequest` — phone unique with `ignore($this->route('id'))`, fields optional; `StoreAgentShareRequest` — `action` enum + `shares_count` ≥ 1; `UpdateAgentShareRequest` — `shares_count` ≥ 1; `ListAgentsRequest` — `search`, `per_page` 1-100.)

---

## 5. Resources (3 min)

```php
// app/Domains/Agent/Http/Resources/BaseAgentResource.php
<?php
namespace App\Domains\Agent\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

abstract class BaseAgentResource extends JsonResource
{
    protected function baseFields(): array {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'phone' => $this->phone,
            'description' => $this->description,
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}
```

```php
// app/Domains/Agent/Http/Resources/AgentListResource.php
class AgentListResource extends BaseAgentResource {
    public function toArray($request): array {
        return array_merge($this->baseFields(), [
            'total_remaining_via_agent' => $this->whenLoaded('summary', fn() => $this->summary['total_remaining_via_agent'] ?? '0.00'),
            'total_collected_via_agent' => $this->whenLoaded('summary', fn() => $this->summary['total_collected_via_agent'] ?? '0.00'),
        ]);
    }
}
```

```php
// app/Domains/Agent/Http/Resources/AgentDetailResource.php
class AgentDetailResource extends BaseAgentResource {
    public function toArray($request): array {
        $summary = app(AgentSummaryService::class)->getSummary($this->id);
        return array_merge($this->baseFields(), [
            'total_shares' => $summary['total_shares'],
            'total_installments_via_agent' => $summary['total_installments_via_agent'],
            'total_collected_via_agent' => $summary['total_collected_via_agent'],
            'total_remaining_via_agent' => $summary['total_remaining_via_agent'],
            'computed_profit' => $summary['computed_profit'],
            'referred_customers' => $summary['referred_customers'],
        ]);
    }
}
```

`AgentShareLogResource` and `AgentShareCreatedResource` (one for the bare log row, one for the wrapped response with success message).

---

## 6. Services (10 min)

Three services, one per Use Case. This is where all business logic lives.

```php
// app/Domains/Agent/Services/AgentService.php
class AgentService
{
    public function __construct(private RefreshCustomerListingJob $refreshJob) {}

    public function list(ListAgentsRequest $req): array
    {
        $query = Client::query()
            ->whereJsonContains('client_type_flags', 'agent')
            ->when($req->search, fn($q) => $q->where('name', 'ILIKE', "%{$req->search}%"))
            ->orderBy('name', 'asc')
            ->orderBy('id', 'asc') // stability for cursor
            ->cursorPaginate($req->input('per_page', 20));
        // ... return wrapped response
    }

    public function create(StoreAgentRequest $req): Client
    {
        return DB::transaction(function () use ($req) {
            $client = Client::create([
                'name' => $req->name,
                'phone' => $req->phone,
                'description' => $req->description,
            ]);
            $client->markAsAgent();
            if ($req->shares_count > 0) {
                AgentSharesLog::create([
                    'client_id' => $client->id,
                    'shares_count' => $req->shares_count,
                    'transaction_type' => 'add',
                    'status' => 'active',
                ]);
                $client->markAsInvestor();
            }
            return $client;
        });
    }

    public function update(int $id, UpdateAgentRequest $req): Client
    {
        $client = Client::findOrFail($id);
        $client->fill($req->only(['name', 'phone', 'description']));
        $client->save();
        return $client;
    }
}
```

`AgentSharesService` — implements the share movement with all guards (see spec.md §3.5..3.7 + research.md R-001). `AgentSummaryService` — pure aggregation queries (see research.md R-004).

---

## 7. Translations (1 min)

Three new files:

```php
// lang/ar/errors/agent.php
return [
    'not_found' => 'الوكيل غير موجود.',
    'phone_unique' => 'رقم الهاتف مستخدم مسبقاً.',
    'immutable' => 'لا يمكن حذف هذا الوكيل.',
];

// lang/ar/errors/shares.php
return [
    'not_latest' => 'لا يمكن تعديل هذا السجل. يمكن تعديل آخر سجل نشط فقط.',
    'lock_period_expired' => 'لا يمكن تعديل هذا السجل. مرت أكثر من 30 يوماً على إنشائه.',
    'insufficient_balance' => 'لا يمكن سحب هذا العدد من الأسهم. الرصيد الحالي غير كافٍ.',
];

// lang/ar/success/agent.php
return [
    'created' => 'تم إنشاء الوكيل بنجاح.',
    'updated' => 'تم تحديث الوكيل بنجاح.',
    'share_added' => 'تم تسجيل إضافة الأسهم بنجاح.',
    'share_withdrawn' => 'تم تسجيل سحب الأسهم بنجاح.',
    'share_modified' => 'تم تعديل سجل الأسهم بنجاح.',
    'share_deleted' => 'تم حذف سجل الأسهم بنجاح.',
];
```

Mirror the structure in `lang/en/...` if the English locale is enabled.

---

## 8. Exception + Mapping (1 min)

```php
// app/Exceptions/Business/InsufficientShareBalanceException.php
namespace App\Exceptions\Business;
class InsufficientShareBalanceException extends \RuntimeException {
    public function __construct(?string $message = null) {
        parent::__construct($message ?? __('errors/shares.insufficient_balance'));
    }
}
```

Register in `app/Exceptions/ExceptionMappings.php`:
```php
\App\Exceptions\Business\InsufficientShareBalanceException::class
    => fn ($e) => unprocessableResponse(['shares_count' => $e->getMessage()]),
```

---

## 9. Tests (15 min, TDD)

Write tests FIRST per Constitution §III.

### Unit tests (no DB)
- `tests/Unit/Services/Agent/AgentSummaryServiceTest.php` — pure PHP, mock Client + query results, 100% coverage
- `tests/Unit/Models/AgentSharesLogScopesTest.php` — verifies scope SQL generation

### Feature tests (full HTTP, RefreshDatabase)
- `tests/Feature/Agent/CreateAgentTest.php` — happy + 422 phone + 422 missing + 422 negative + BR-005-8 (no transaction AC-021)
- `tests/Feature/Agent/ListAgentsTest.php` — sort, search, empty, cursor (AC-001)
- `tests/Feature/Agent/ViewAgentTest.php` — happy + 404 + zero-data (AC-004..006)
- `tests/Feature/Agent/UpdateAgentTest.php` — happy + 422 phone + 404 + ignore shares_count (AC-007)
- `tests/Feature/Agent/AddSharesTest.php` — happy + first-add flag + idempotent (AC-002, AC-008, AC-022)
- `tests/Feature/Agent/WithdrawSharesTest.php` — happy + boundary zero + rejection <0 (AC-009a, AC-009b)
- `tests/Feature/Agent/ModifyShareLogTest.php` — happy + 30-day expiry + not-latest + boundary + concurrency (AC-010, AC-015, AC-016, AC-039)
- `tests/Feature/Agent/DeleteShareLogTest.php` — happy + 30-day expiry + not-latest (AC-011, AC-017, AC-023, AC-025)
- `tests/Feature/Agent/ViewSharesLogTest.php` — happy + reverse chrono + all statuses (AC-012)
- `tests/Feature/Agent/AgentImmutabilityTest.php` — delete throws, no DELETE route (AC-020, AC-024)

---

## 10. Run & Verify (2 min)

```bash
# Lint / static analysis (project-defined)
./vendor/bin/pint

# Unit tests
php artisan test --testsuite=Unit

# Feature tests
php artisan test --testsuite=Feature

# Full suite
php artisan test

# Coverage
php artisan test --coverage --min=80
```

Manual smoke (via tinker):
```php
$admin = \App\Models\Admin::first();
$resp = $this->actingAs($admin, 'admin')
    ->postJson('/api/v1/agents', ['name' => 'Test', 'phone' => '0931234567', 'shares_count' => 5]);
echo $resp->status(); // 201
echo $resp->json('data.id');
```

---

## Common Pitfalls

1. **Forgetting `lockForUpdate()`** in PATCH/DELETE share log → race condition, AC-039 fails.
2. **Using `float` for `computed_profit`** → precision drift, AC fails. Always `bcmath`.
3. **Returning `reference_number` in a Resource** → data-minimization violation, AC-026 fails. The `baseFields()` extraction in `BaseAgentResource` deliberately omits it.
4. **Setting `investor` flag from a withdrawal** → permanent flag violated, AC-022 fails. The flag is set ONLY on first `add` (either at creation or at first `POST /shares`).
5. **Physical DELETE on `agent_shares_logs`** → BR-005-7 violation. Always update `status` to `'deleted'`.
6. **Forgetting `whereNumber('id')` on routes** → `id` could match `shares-log` segment, route shadowing.
7. **Putting throttle middleware in the per-domain route file** → violates NFR-003-6 (project-wide). Throttle belongs in `bootstrap/app.php`.
8. **Hardcoding Arabic or English strings in the controller/service** → AC-028 fails. Use `__()` everywhere.

---

## See Also

- **Spec**: [spec.md](spec.md) — 40 FRs, 22 NFRs, 39 ACs
- **Plan**: [plan.md](plan.md) — architecture overview, constitution check, source layout
- **Data Model**: [data-model.md](data-model.md) — entity-by-entity detail
- **Contracts**: [contracts/agents.openapi.yaml](contracts/agents.openapi.yaml) — OpenAPI 3.0
- **Research**: [research.md](research.md) — Phase 0 best-practice decisions
