openapi: 3.0.3

info:
  title: ThunJai Logistics Pool - Open API
  version: 1.0.0
  description: >
    Open API for the **ThunJai Logistics Pool** - a neutral middleware that lets multiple
    logistics companies connect over one API and share data: a common Lao address master
    (province -> district -> village), a central tracking number reused by every company,
    order creation with address-ID validation, a rule-based carrier-recommendation engine,
    order cancellation, and a status hook for carriers to push delivery updates back into the pool.


    ### Authentication

    All endpoints except `POST /auth/requestToken` require a Bearer token:

    `Authorization: Bearer <access_token>`


    Obtain the token from `POST /auth/requestToken` using your API credentials
    (`user` = sender user, `pwd` = API key) and a request signature:


    `sign = HMAC_SHA256( api_key_secret , "user=<user>&pwd=<pwd>" )`  (hex digest)


    The query string is built with the keys in the order `user`, `pwd`
    (i.e. exactly `user=<user>&pwd=<pwd>`). Access and refresh tokens are valid for 6 hours;
    use `POST /auth/refreshToken` to obtain a new pair.


    ### Response envelope

    Every response uses a common envelope:
    `{ "data": <payload|null>, "data_total": <number>, "success": <bool>, "error": <string|null> }`.


    **Business failures** (e.g. invalid address IDs, unknown tracking number, already-cancelled order)
    return **HTTP 200** with `success: false` and an `error` message. Missing parameters return
    **400**, authentication failures **401**, rate-limit **429**, and unexpected server errors **500**.

servers:
  - url: https://stg-pool-api.thunjaiexpress.com
    description: Staging

tags:
  - name: Auth
    description: Token issuance & refresh
  - name: Address
    description: Shared Lao address master (province / district / village)
  - name: Order
    description: Pool order create, detail & cancel
  - name: Logistics
    description: Carrier recommendation
  - name: Status Hook
    description: Carrier delivery-status reporting
  - name: Wallet
    description: Carrier wallet credit - top-up notification & balance
  - name: Customer
    description: Customer lookup by phone (across sender & receiver of past orders)

security:
  - bearerAuth: []

paths:

  /auth/requestToken:
    post:
      tags: [Auth]
      summary: Request an access/refresh token pair
      description: >
        Public endpoint (rate-limited to 30 requests / 15 min per IP).
        Authenticates the caller with `user` + `pwd` and a HMAC signature.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RequestTokenBody'
      responses:
        '200':
          description: >
            Tokens issued (`success: true`), or invalid signature (`success: false`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenPairEnvelope'
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /auth/refreshToken:
    post:
      tags: [Auth]
      summary: Exchange a refresh token for a new token pair
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token:
                  type: string
                  description: The refresh token from a prior requestToken/refreshToken call.
      responses:
        '200':
          description: New token pair, or a failure message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenPairEnvelope'
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /address/provinces:
    get:
      tags: [Address]
      summary: List all active provinces (แขวง)
      responses:
        '200':
          description: Province list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Province' }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /address/districts:
    get:
      tags: [Address]
      summary: List districts (เมือง) of a province
      parameters:
        - name: province_id
          in: query
          required: true
          schema: { type: integer }
          example: 1
      responses:
        '200':
          description: District list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/District' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /address/villages:
    get:
      tags: [Address]
      summary: List villages (บ້ານ) of a district
      parameters:
        - name: district_id
          in: query
          required: true
          schema: { type: integer }
          example: 10
      responses:
        '200':
          description: Village list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Village' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /address/all:
    get:
      tags: [Address]
      summary: Full address tree (province -> district -> village) in one payload
      responses:
        '200':
          description: Nested address data.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ProvinceNested' }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /logistics/recommend:
    post:
      tags: [Logistics]
      summary: Recommend carriers for a shipment
      description: >
        Evaluates admin-configurable rules (weight threshold, pickup type, volume) and filters out
        carriers that do not serve the recipient province/district. Returns the matching carriers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecommendRequest'
      responses:
        '200':
          description: Recommended carriers (may be an empty list).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Logistics' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /order:
    get:
      tags: [Order]
      summary: Get the full detail of one order by id or tracking number
      description: >
        Returns every field of one order owned by the calling account — sender & recipient
        contact, address IDs resolved to their Lao display names, COD, status and audit
        timestamps — plus the order's `products` list and its `status_history` (the delivery
        timeline reported via the status hook, oldest first). Scoped to the authenticated
        carrier: an order that does not exist, or belongs to another carrier, returns
        `success: false` with `Order not found.` (the two cases are indistinguishable).


        Look the order up by either `id` or `tracking_number` — supply one of them. If both
        are given, `id` takes precedence.
      parameters:
        - name: id
          in: query
          required: false
          description: The order id (returned by `POST /order`). Provide this or `tracking_number`.
          schema: { type: integer }
          example: 101
        - name: tracking_number
          in: query
          required: false
          description: The central tracking number (prefix `TJP`). Provide this or `id`.
          schema: { type: string }
          example: TJP26061400000001
      responses:
        '200':
          description: >
            Order detail (`success: true`), or not-found / not-yours (`success: false`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/OrderDetail' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Order]
      summary: Create a pool order (issues a central tracking number)
      description: >
        Validates sender & recipient address IDs (province/district required, village optional but
        validated when supplied). On success a unique central `tracking_number` (prefix `TJP`) is
        generated atomically.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderCreateRequest'
      responses:
        '200':
          description: >
            Order created (`success: true`), or an address-validation failure (`success: false`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/OrderCreateResult' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'
    delete:
      tags: [Order]
      summary: Cancel a pool order
      description: Only the owning account may cancel, and only while the order is still `ACTIVE`.
      parameters:
        - name: tracking_number
          in: query
          required: true
          schema: { type: string }
          example: TJP26061400000001
      responses:
        '200':
          description: >
            Cancelled (`success: true`), or not-found / already-cancelled (`success: false`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/OrderCancelResult' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /hook/status:
    post:
      tags: [Status Hook]
      summary: Report a delivery status into the pool
      description: >
        Any authenticated carrier may report status for a tracking number. The record references the
        pool order id (the tracking number's id) plus the reporting carrier.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StatusHookRequest'
      responses:
        '200':
          description: >
            Status stored (`success: true`), or unknown tracking number (`success: false`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/StatusHookResult' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /wallet/topup:
    post:
      tags: [Wallet]
      summary: Submit a wallet top-up notification
      description: >
        Notify ThunJai that you have transferred money to top up your wallet. The notification
        is created as `PENDING` and credits your balance only after an admin approves it.
        Scoped to the authenticated carrier.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WalletTopupRequest'
      responses:
        '200':
          description: >
            Top-up notification recorded (`success: true`, status `PENDING`), or a validation
            failure such as an invalid amount (`success: false`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/WalletTopupResult' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /wallet/balance:
    get:
      tags: [Wallet]
      summary: Get the current wallet balance
      description: >
        Returns the authenticated carrier's current wallet balance. A wallet is created
        automatically (balance 0) on first access.
      responses:
        '200':
          description: Current balance.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/WalletBalanceResult' }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /customer:
    get:
      tags: [Customer]
      summary: Look up customers by phone number
      description: >
        Searches the caller's customers — contacts seen on their past orders, across both
        the sender and receiver sides — for a phone number, returning the most-recent known
        name/address for each distinct phone. Address IDs are resolved to their Lao display
        names (`province_name` / `district_name` / `district_sub_name`). Handy for order-form
        auto-fill / type-ahead. The number is matched as a "contains" pattern, so partial
        numbers work (minimum 3 characters). Results are scoped to the authenticated carrier;
        you never see other carriers' customers.
      parameters:
        - name: tel
          in: query
          required: true
          description: Phone number or fragment (min 3 characters).
          schema: { type: string }
          example: "8562055"
        - name: limit
          in: query
          required: false
          description: Max rows to return (default 20, capped at 100).
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: Matching customers (latest details per person).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Customer' }
        '400':
          $ref: '#/components/responses/ParameterRequired'
        '401':
          $ref: '#/components/responses/Unauthorized'

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Access token from `POST /auth/requestToken`.

  responses:
    ParameterRequired:
      description: Required parameter missing.
    Unauthorized:
      description: Missing or invalid access token.
    TooManyRequests:
      description: Rate limit exceeded (auth endpoints).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }

  schemas:

    Envelope:
      type: object
      description: Common response wrapper used by every endpoint.
      properties:
        data:
          nullable: true
          description: The response payload (object, array, or null).
        data_total:
          type: integer
          example: 1
        success:
          type: boolean
          example: true
        error:
          type: string
          nullable: true
          example: null

    RequestTokenBody:
      type: object
      required: [user, pwd, sign]
      properties:
        user:
          type: string
          description: Sender user name.
          example: testco
        pwd:
          type: string
          description: API key.
          example: testapikey
        sign:
          type: string
          description: HMAC_SHA256(api_key_secret, "user=<user>&pwd=<pwd>") as a hex digest.
          example: 9f1c2a...e7

    TokenPair:
      type: object
      properties:
        access_token:
          type: string
        refresh_token:
          type: string

    TokenPairEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data:
              $ref: '#/components/schemas/TokenPair'

    Province:
      type: object
      properties:
        id: { type: integer, example: 1 }
        code: { type: string, example: VTE }
        name_in_lao: { type: string, example: ນະຄອນຫຼວງວຽງຈັນ }
        name_in_english: { type: string, example: Vientiane }
        name_in_thai: { type: string, example: เวียงจันทน์ }
        zone: { type: string, nullable: true }

    District:
      type: object
      properties:
        id: { type: integer, example: 10 }
        province_id: { type: integer, example: 1 }
        code: { type: string, example: CHAN }
        name_in_lao: { type: string }
        name_in_english: { type: string, example: Chanthabuly }
        name_in_thai: { type: string }

    Customer:
      type: object
      description: A person found on past orders, reconstructed from order contact fields.
      properties:
        phone: { type: string, example: "8562055512345" }
        name: { type: string, example: "Somchai" }
        province_id: { type: integer, nullable: true, example: 1 }
        district_id: { type: integer, nullable: true, example: 10 }
        district_sub_id: { type: integer, nullable: true, example: 100 }
        province_name: { type: string, nullable: true, description: Lao display name of province_id., example: ນະຄອນຫຼວງວຽງຈັນ }
        district_name: { type: string, nullable: true, description: Lao display name of district_id., example: ຈັນທະບູລີ }
        district_sub_name: { type: string, nullable: true, description: Lao display name of district_sub_id. }
        address_detail: { type: string, nullable: true, example: "Ban Phonsavanh, House 12" }
        last_order_time: { type: string, format: date-time, nullable: true, description: Time of this person's most recent order. }
        order_count: { type: integer, example: 3, description: How many orders this person appears on. }

    Village:
      type: object
      properties:
        id: { type: integer, example: 100 }
        province_id: { type: integer, example: 1 }
        district_id: { type: integer, example: 10 }
        code: { type: string, example: V100 }
        name_in_lao: { type: string }
        name_in_english: { type: string, example: Village One }
        name_in_thai: { type: string }
        post_code: { type: string, example: '01000' }

    ProvinceNested:
      allOf:
        - $ref: '#/components/schemas/Province'
        - type: object
          properties:
            district:
              type: array
              items:
                allOf:
                  - $ref: '#/components/schemas/District'
                  - type: object
                    properties:
                      district_sub:
                        type: array
                        items: { $ref: '#/components/schemas/Village' }

    Logistics:
      type: object
      properties:
        id: { type: integer, example: 1 }
        code: { type: string, example: A }
        name: { type: string, example: Company A }
        logo: { type: string, example: https://logo/a.png }

    RecommendRequest:
      type: object
      required: [to_province_id, to_district_id]
      properties:
        weight: { type: number, description: Weight per item (kg)., example: 20 }
        width: { type: number, description: Width (cm)., example: 100 }
        length: { type: number, description: Length (cm)., example: 100 }
        height: { type: number, description: Height (cm)., example: 100 }
        pickup_type:
          type: string
          enum: [HOME, BRANCH]
          description: HOME = deliver to door, BRANCH = pickup at branch.
          example: HOME
        to_province_id: { type: integer, example: 1 }
        to_district_id: { type: integer, example: 10 }
        cod:
          type: boolean
          description: Whether the shipment is cash-on-delivery (accepted for future rules).
          example: true

    OrderCreateRequest:
      type: object
      required:
        - from_name
        - from_phone
        - from_province_id
        - from_district_id
        - to_name
        - to_phone
        - to_province_id
        - to_district_id
      properties:
        reference_number:
          type: string
          description: The carrier's own tracking number (stored as a reference).
          example: REF-0001
        reference_number_2:
          type: string
          nullable: true
          description: Optional second carrier reference number.
          example: REF-0001-B
        from_name: { type: string, example: Sender One }
        from_phone: { type: string, example: '2055500001' }
        from_province_id: { type: integer, example: 1 }
        from_district_id: { type: integer, example: 10 }
        from_district_sub_id: { type: integer, nullable: true, example: 100 }
        from_address_detail: { type: string, example: 123 Main St }
        to_name: { type: string, example: Recipient One }
        to_phone: { type: string, example: '2055500002' }
        to_province_id: { type: integer, example: 1 }
        to_district_id: { type: integer, example: 11 }
        to_district_sub_id: { type: integer, nullable: true }
        to_address_detail: { type: string, example: 456 Market Rd }
        cod_amount: { type: number, example: 50000 }
        products:
          type: array
          nullable: true
          description: >
            Optional list of products/items carried by this order. Each item requires
            product_name; all other fields are optional.
          items:
            $ref: '#/components/schemas/OrderProduct'

    OrderProduct:
      type: object
      required:
        - product_name
      properties:
        product_name: { type: string, example: T-Shirt }
        product_code:  { type: string, nullable: true, example: SKU-001 }
        quantity:      { type: number, example: 2, default: 1 }
        price:         { type: number, nullable: true, description: Unit price (optional)., example: 15000 }
        weight:        { type: number, nullable: true, description: Weight in kg (optional)., example: 0.5 }
        width:         { type: number, nullable: true, description: Width in cm (optional)., example: 20 }
        length:        { type: number, nullable: true, description: Length in cm (optional)., example: 30 }
        height:        { type: number, nullable: true, description: Height in cm (optional)., example: 10 }
        note:          { type: string, nullable: true, example: fragile }

    OrderCreateResult:
      type: object
      properties:
        id: { type: integer, example: 101 }
        tracking_number: { type: string, example: TJP26061400000001 }
        reference_number: { type: string, nullable: true, example: REF-0001 }
        reference_number_2: { type: string, nullable: true, example: REF-0001-B }
        status: { type: string, example: ACTIVE }
        product_count: { type: integer, example: 2, description: Number of products saved with the order. }

    OrderCancelResult:
      type: object
      properties:
        id: { type: integer, example: 101 }
        tracking_number: { type: string, example: TJP26061400000001 }
        status: { type: string, example: CANCELLED }

    OrderDetail:
      type: object
      description: Full detail of one order, including products and the delivery status timeline.
      properties:
        id: { type: integer, example: 101 }
        tracking_number: { type: string, example: TJP26061400000001 }
        reference_number: { type: string, nullable: true, example: REF-0001 }
        reference_number_2: { type: string, nullable: true, example: REF-0001-B }
        sender_account_id: { type: integer, example: 12 }
        sender_owner: { type: string, nullable: true }
        status: { type: string, example: ACTIVE, description: ACTIVE or CANCELLED. }
        cod_amount: { type: number, example: 50000 }
        from_name: { type: string, example: Sender One }
        from_phone: { type: string, example: '2055500001' }
        from_province_id: { type: integer, nullable: true, example: 1 }
        from_district_id: { type: integer, nullable: true, example: 10 }
        from_district_sub_id: { type: integer, nullable: true, example: 100 }
        from_address_detail: { type: string, nullable: true, example: 123 Main St }
        from_province_name: { type: string, nullable: true, description: Lao display name of from_province_id. }
        from_district_name: { type: string, nullable: true, description: Lao display name of from_district_id. }
        from_district_sub_name: { type: string, nullable: true, description: Lao display name of from_district_sub_id. }
        to_name: { type: string, example: Recipient One }
        to_phone: { type: string, example: '2055500002' }
        to_province_id: { type: integer, nullable: true, example: 1 }
        to_district_id: { type: integer, nullable: true, example: 11 }
        to_district_sub_id: { type: integer, nullable: true }
        to_address_detail: { type: string, nullable: true, example: 456 Market Rd }
        to_province_name: { type: string, nullable: true, description: Lao display name of to_province_id. }
        to_district_name: { type: string, nullable: true, description: Lao display name of to_district_id. }
        to_district_sub_name: { type: string, nullable: true, description: Lao display name of to_district_sub_id. }
        add_time: { type: string, format: date-time, nullable: true }
        add_by: { type: string, nullable: true }
        edit_time: { type: string, format: date-time, nullable: true }
        edit_by: { type: string, nullable: true }
        cancel_time: { type: string, format: date-time, nullable: true }
        cancel_by: { type: string, nullable: true }
        products:
          type: array
          description: Products carried by the order (empty array when none).
          items: { $ref: '#/components/schemas/OrderDetailProduct' }
        status_history:
          type: array
          description: Delivery status events reported via the status hook, oldest first.
          items: { $ref: '#/components/schemas/OrderStatusEvent' }

    OrderDetailProduct:
      type: object
      properties:
        id: { type: integer, example: 5001 }
        order_id: { type: integer, example: 101 }
        product_name: { type: string, example: T-Shirt }
        product_code: { type: string, nullable: true, example: SKU-001 }
        quantity: { type: number, example: 2 }
        price: { type: number, nullable: true, example: 15000 }
        weight: { type: number, nullable: true, example: 0.5 }
        width: { type: number, nullable: true, example: 20 }
        length: { type: number, nullable: true, example: 30 }
        height: { type: number, nullable: true, example: 10 }
        note: { type: string, nullable: true, example: fragile }

    OrderStatusEvent:
      type: object
      properties:
        id: { type: integer, example: 9001 }
        order_id: { type: integer, example: 101 }
        logistics_id: { type: integer, nullable: true, description: Carrier (db_sender.sender_id) that reported the event., example: 3 }
        status_time: { type: string, format: date-time, nullable: true, example: '2026-06-14 09:30:00' }
        status_name: { type: string, example: IN_TRANSIT }
        status_detail: { type: string, nullable: true, example: Departed origin hub }
        address_text: { type: string, nullable: true, example: Vientiane Hub }
        add_time: { type: string, format: date-time, nullable: true }

    StatusHookRequest:
      type: object
      required: [tracking_number, status_name]
      properties:
        tracking_number: { type: string, example: TJP26061400000001 }
        status_name:
          type: string
          example: IN_TRANSIT
          description: >
            Delivery status code reported by the carrier. Recommended values:
            `PICKED` (parcel picked up from sender),
            `IN_TRANSIT` (in transit between hubs),
            `AT_DESTINATION_BRANCH` (arrived at the destination branch),
            `ON_DELIVERY` (out for last-mile delivery),
            `DELIVERED` (delivered to recipient),
            `CANCELLED` (delivery cancelled),
            `RETURNED` (returned to sender).
          enum:
            - PICKED
            - IN_TRANSIT
            - AT_DESTINATION_BRANCH
            - ON_DELIVERY
            - DELIVERED
            - CANCELLED
            - RETURNED
        status_detail: { type: string, example: Departed origin hub }
        status_time:
          type: string
          description: Optional event time (ISO 8601 or 'YYYY-MM-DD HH:mm:ss'); defaults to server now.
          example: '2026-06-14 09:30:00'
        address_text: { type: string, example: Vientiane Hub }

    StatusHookResult:
      type: object
      properties:
        id: { type: integer, example: 5001 }
        order_id: { type: integer, example: 101 }
        status_name:
          type: string
          example: IN_TRANSIT
          description: The delivery status code that was stored (echoes the request).
          enum:
            - PICKED
            - IN_TRANSIT
            - AT_DESTINATION_BRANCH
            - ON_DELIVERY
            - DELIVERED
            - CANCELLED
            - RETURNED

    WalletTopupRequest:
      type: object
      required: [amount]
      properties:
        amount:        { type: number, description: Transferred amount (> 0)., example: 500000 }
        bank_name:     { type: string, nullable: true, example: BCEL }
        bank_ref:      { type: string, nullable: true, description: Transfer reference / slip number., example: TXN-20260618-001 }
        slip_image:    { type: string, nullable: true, description: URL of the uploaded transfer slip., example: 'https://.../slip.jpg' }
        transfer_time: { type: string, nullable: true, description: When the transfer was made (ISO 8601 or 'YYYY-MM-DD HH:mm:ss')., example: '2026-06-18 10:15:00' }
        note:          { type: string, nullable: true, example: Top-up for June }

    WalletTopupResult:
      type: object
      properties:
        id: { type: integer, example: 7001 }
        sender_account_id: { type: integer, example: 12 }
        amount: { type: number, example: 500000 }
        status: { type: string, example: PENDING, description: Always PENDING until an admin approves it. }
        add_time: { type: string, example: '2026-06-18 10:16:32' }

    WalletBalanceResult:
      type: object
      properties:
        sender_account_id: { type: integer, example: 12 }
        balance: { type: number, example: 1250000 }
        currency: { type: string, example: LAK }
