openapi: 3.0.3
info:
  title: Tamkeen — Agent Management & Investor Logic (SP-05)
  version: 1.0.0
  description: |
    REST API contract for the Agent Management & Investor Logic domain.
    All endpoints under `/api/v1/agents*` are part of SP-05.
    Authentication: Laravel Sanctum bearer token. Authorization: Spatie permissions.
    Rate limiting: project-wide policy (NFR-003-6) — 120 req/min for reads, 60 req/min for writes, per admin user.
  contact:
    name: Tamkeen Engineering
servers:
  - url: https://api.tamkeen.local/api/v1
    description: Local development
  - url: https://api.tamkeen.com/api/v1
    description: Production
tags:
  - name: Agents
    description: Agent CRUD operations (FR-3.1..3.4)
  - name: Shares
    description: Share movement operations (FR-3.5..3.8)
security:
  - bearerAuth: []
paths:
  /agents:
    get:
      tags: [Agents]
      summary: List agents
      description: |
        Returns a cursor-paginated list of clients whose `client_type_flags` contains `"agent"`,
        sorted alphabetically by `name` ascending. Excludes `client_type_flags` and
        `reference_number` from the response (data minimization, SP-05-FR-030).
      operationId: listAgents
      parameters:
        - in: query
          name: search
          schema: { type: string }
          description: Case-insensitive partial match against `name` (ILIKE).
        - in: query
          name: cursor
          schema: { type: string }
          description: Opaque cursor for next page.
        - in: query
          name: per_page
          schema: { type: integer, default: 20, maximum: 100, minimum: 1 }
      responses:
        '200':
          description: Paginated agent list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentListResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Agents]
      summary: Create a new agent
      description: |
        Creates a new client record and adds `"agent"` to `client_type_flags` transactionally.
        If `shares_count > 0`, also creates an active share log row and adds `"investor"`
        to the flag set (BR-001-6, SP-05-FR-007). Requires `agents.create` permission.
      operationId: createAgent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StoreAgentRequest'
      responses:
        '201':
          description: Agent created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentCreatedResponse'
        '422': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /agents/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    get:
      tags: [Agents]
      summary: View agent details
      description: |
        Returns basic agent data plus six financial aggregates (`total_shares`,
        `total_installments_via_agent`, `total_collected_via_agent`,
        `total_remaining_via_agent`, `computed_profit`) and a `referred_customers`
        array. All aggregates computed live (not stored) per ALG 5.1..5.4.
        Requires `agents.view` permission.
      operationId: viewAgent
      responses:
        '200':
          description: Agent detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentDetailResponse'
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      tags: [Agents]
      summary: Update agent profile
      description: |
        Updates `name`, `phone`, `description` only. Share-related fields (including
        `shares_count`) in the body are silently ignored. Phone uniqueness is enforced
        with `unique:clients,phone,{id}` to allow the current phone to remain.
        Requires `agents.update` permission.
      operationId: updateAgent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAgentRequest'
      responses:
        '200':
          description: Agent updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentUpdatedResponse'
        '422': { $ref: '#/components/responses/ValidationError' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    # NO DELETE method by design (BR-001-1, SP-05-FR-011)

  /agents/{id}/shares:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    post:
      tags: [Shares]
      summary: Record a share movement
      description: |
        Creates a new `active` row in `agent_shares_logs` with `transaction_type`
        equal to `action` and `shares_count` equal to the request value.

        Guard order (SP-05-NFR-D-001a):
        1. Target agent exists (404)
        2. `action` ∈ {add, withdraw} and `shares_count ≥ 1` (422)
        3. If `action='withdraw'`, projected balance ≥ 0 (422 shares.insufficient_balance, BR-005-8)
        4. If `action='add'` and first add for this client, set `investor` flag (transactional)

        Requires `agents.manage_shares` permission.
      operationId: recordShare
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StoreAgentShareRequest'
      responses:
        '201':
          description: Share movement recorded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentShareLogResource'
        '422': { $ref: '#/components/responses/ValidationError' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /agents/{id}/shares/{shareLogId}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
      - in: path
        name: shareLogId
        required: true
        schema: { type: integer }
    patch:
      tags: [Shares]
      summary: Modify a share log row
      description: |
        Updates the SAME share log row in place: changes `shares_count` to the supplied
        value, changes `status` to `'modified'`, updates `updated_at`. No physical UPDATE
        on a different row is permitted.

        Guard sequence (with `lockForUpdate()` per SP-05-NFR-D-006):
        1. Target row exists (404)
        2. Target row IS the most recent active row (403 `errors/shares.not_latest`)
        3. Target row's `created_at` ≥ now() − 30 days (403 `errors/shares.lock_period_expired`)

        Requires `agents.manage_shares` permission.
      operationId: modifyShareLog
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAgentShareRequest'
      responses:
        '200':
          description: Share log row updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentShareLogResource'
        '422': { $ref: '#/components/responses/ValidationError' }
        '404': { $ref: '#/components/responses/NotFound' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Shares]
      summary: Logically delete a share log row
      description: |
        Updates the SAME share log row in place: changes `status` to `'deleted'`, updates
        `updated_at`. The row is never physically deleted (BR-005-7, SP-05-NFR-D-005).

        Guard sequence (with `lockForUpdate()` per SP-05-NFR-D-006):
        1. Target row exists (404)
        2. Target row IS the most recent active row (403 `errors/shares.not_latest`)
        3. Target row's `created_at` ≥ now() − 30 days (403 `errors/shares.lock_period_expired`)

        Requires `agents.manage_shares` permission.
      operationId: deleteShareLog
      responses:
        '200':
          description: Share log row marked deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  message: { type: string, example: 'success/agent.share_deleted' }
        '404': { $ref: '#/components/responses/NotFound' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /agents/{id}/shares-log:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    get:
      tags: [Shares]
      summary: View share movement history
      description: |
        Returns the full reverse-chronological history of all share log rows for the agent,
        including those in `modified` and `deleted` statuses. Cursor-paginated.
        Requires `agents.view_shares_log` permission.
      operationId: viewSharesLog
      parameters:
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: per_page
          schema: { type: integer, default: 20, maximum: 100, minimum: 1 }
      responses:
        '200':
          description: Share log history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SharesLogListResponse'
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Sanctum token

  schemas:
    # --- Requests ---
    StoreAgentRequest:
      type: object
      required: [name, phone]
      properties:
        name: { type: string, maxLength: 150 }
        phone: { type: string, maxLength: 20, description: 'Syrian format, e.g., 09XXXXXXXX' }
        description: { type: string, nullable: true }
        shares_count: { type: integer, minimum: 0, default: 0, description: 'Optional. If >0, also creates an active share log row.' }

    UpdateAgentRequest:
      type: object
      properties:
        name: { type: string, maxLength: 150 }
        phone: { type: string, maxLength: 20 }
        description: { type: string, nullable: true }
        # shares_count and other share-related fields are silently ignored

    StoreAgentShareRequest:
      type: object
      required: [action, shares_count]
      properties:
        action: { type: string, enum: [add, withdraw] }
        shares_count: { type: integer, minimum: 1 }

    UpdateAgentShareRequest:
      type: object
      required: [shares_count]
      properties:
        shares_count: { type: integer, minimum: 1 }

    # --- Resources ---
    AgentBase:
      type: object
      properties:
        id: { type: integer, example: 123 }
        name: { type: string, example: 'أحمد محمد العلي' }
        phone: { type: string, example: '0931234567' }
        description: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AgentListItem:
      allOf:
        - $ref: '#/components/schemas/AgentBase'
        - type: object
          properties:
            total_remaining_via_agent: { type: string, example: '5000.00', description: 'DECIMAL(10,2) as string' }
            total_collected_via_agent: { type: string, example: '3000.00' }

    AgentListResponse:
      type: object
      properties:
        success: { type: boolean }
        message: { type: string, nullable: true }
        data:
          type: object
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/AgentListItem' }
        meta:
          $ref: '#/components/schemas/PaginationMeta'

    AgentDetailResponse:
      type: object
      properties:
        success: { type: boolean }
        message: { type: string, nullable: true }
        data:
          allOf:
            - $ref: '#/components/schemas/AgentBase'
            - type: object
              properties:
                total_shares: { type: integer, example: 12, description: 'Sum of (+add, -withdraw) over active rows' }
                total_installments_via_agent: { type: string, example: '24000.00' }
                total_collected_via_agent: { type: string, example: '5000.00' }
                total_remaining_via_agent: { type: string, example: '19000.00' }
                computed_profit: { type: string, example: '450.00', description: '5% of (sum total_after_profit − purchase_amount) over active+completed contracts' }
                referred_customers:
                  type: array
                  items: { $ref: '#/components/schemas/ReferredCustomer' }
        meta: { type: object, nullable: true }

    ReferredCustomer:
      type: object
      properties:
        name: { type: string }
        total_installments: { type: string, example: '1200.00' }
        total_paid: { type: string, example: '400.00' }

    AgentCreatedResponse:
      type: object
      properties:
        success: { type: boolean }
        message: { type: string, example: 'success/agent.created' }
        data:
          allOf:
            - $ref: '#/components/schemas/AgentBase'
            - type: object
              properties:
                shares_count: { type: integer, example: 5, description: 'Echoes the input; present only if provided at creation' }

    AgentUpdatedResponse:
      type: object
      properties:
        success: { type: boolean }
        message: { type: string, example: 'success/agent.updated' }
        data: { $ref: '#/components/schemas/AgentBase' }

    AgentShareLogResource:
      type: object
      properties:
        id: { type: integer }
        client_id: { type: integer, description: 'May be exposed in this resource (not in agent base)' }
        transaction_type: { type: string, enum: [add, withdraw] }
        shares_count: { type: integer }
        status: { type: string, enum: [active, modified, deleted] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, nullable: true }

    SharesLogListResponse:
      type: object
      properties:
        success: { type: boolean }
        message: { type: string, nullable: true }
        data:
          type: object
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/AgentShareLogResource' }
        meta: { $ref: '#/components/schemas/PaginationMeta' }

    PaginationMeta:
      type: object
      properties:
        current_cursor: { type: string, nullable: true }
        has_more: { type: boolean }
        per_page: { type: integer }

    # --- Error responses ---
    ValidationError:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string, example: 'errors/general.validation' }
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }

    NotFound:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string, example: 'errors/agent.not_found' }
        errors: { type: object, nullable: true }

    Unauthorized:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string, example: 'auth.unauthorized' }

    Forbidden:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string, example: 'auth.forbidden' }

    TooManyRequests:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string, example: 'errors/general.too_many_requests' }
        errors: { type: object, nullable: true }

  responses:
    Unauthorized:
      description: Missing or invalid token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Unauthorized' }
    Forbidden:
      description: Authenticated but lacks permission, or business-rule violation
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Forbidden' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/NotFound' }
    ValidationError:
      description: Validation failure
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }
    TooManyRequests:
      description: Rate limit exceeded (project-wide NFR-003-6)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/TooManyRequests' }
