openapi: 3.1.0
info:
  title: FlexApp API
  description: |
    HTTP contract between the FlexApp mobile app (Kotlin Multiplatform)
    and the FlexApp backend.

    All endpoints are JSON-only. Error responses share a single envelope
    (`code` is machine-readable, `message` is end-user-friendly French).

    This spec is the source of truth — the Go handlers must match it.
  version: "0.1.0"
  contact:
    name: FlexApp team
    email: yaoeloge@gmail.com

servers:
  - url: https://api-flexapp.pluxce.com
    description: Production
  - url: http://localhost:8080
    description: Local development (docker compose up)

tags:
  - name: meta
    description: Health checks and instance info.
  - name: auth
    description: Phone-based signup and login flow.
  - name: me
    description: Endpoints scoped to the authenticated user (access_token required).

paths:
  /healthz:
    get:
      tags: [meta]
      summary: Liveness + database probe
      description: |
        Returns `200 OK` if the process is up and Postgres responds to
        a ping. Used by Dokploy / load balancers as the readiness probe.
      operationId: healthz
      responses:
        "200":
          description: Service is healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
        "503":
          description: Database unreachable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/me:
    get:
      tags: [me]
      summary: Current user's profile
      description: |
        Returns the profile of the caller (identified by the
        `access_token` JWT). Used by the mobile app to:

        - rehydrate the local Session after a cold start,
        - power the ProfileScreen header,
        - detect stale data when the device clock drifts.
      operationId: getMe
      security: [{ accessToken: [] }]
      responses:
        "200":
          description: Authenticated user payload.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeResponse" }
        "401":
          description: Missing or invalid access_token.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Token references a user that no longer exists.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/me/country:
    patch:
      tags: [me]
      summary: Update the user's country of residence
      description: |
        Sets `country_code` on the user's row. The mobile calls this
        from the Profile → Préférences → Pays sheet. Validation is
        purely against the supported-countries allow-list; we don't
        re-derive from the phone prefix because legitimate users may
        keep a foreign number while resident elsewhere (diaspora).

        Returns the full `MeResponse` so the client can rebuild its
        local session without an extra GET.
      operationId: updateCountry
      security: [{ accessToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchCountryRequest" }
      responses:
        "200":
          description: Country updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeResponse" }
        "400":
          description: Country not in the supported list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: invalid_country, message: "Pays non pris en charge." }
        "401":
          description: Missing/invalid access_token, or device revoked.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/me/events:
    get:
      tags: [me]
      summary: Server-Sent Events stream for real-time updates
      description: |
        Long-lived `text/event-stream` connection that pushes balance
        and transaction events as they happen — the mobile no longer
        has to poll `/me/account` or `/me/transactions` on every
        screen entry.

        Event types currently emitted (the `event:` field):
          - `balance.changed` — { balance: int64, currency: string }
          - `transaction.created` — full Transaction (same shape as
            the REST list endpoint)

        Heartbeat: a comment line (`: ping`) every 25 s so proxies
        don't close idle connections.

        Auth: `Authorization: Bearer <access_token>`. The Ktor mobile
        client can carry headers on SSE; if a browser ever needs to
        consume this we'll add an `?access_token=` query fallback.

        Reconnect: the mobile re-opens on disconnect (network drop,
        token refresh). A reconciliation pass via /me/account on
        reconnect catches any events dropped during the gap.
      operationId: streamEvents
      security: [{ accessToken: [] }]
      responses:
        "200":
          description: SSE stream opened.
          content:
            text/event-stream:
              schema: { type: string, example: "event: balance.changed\ndata: {\"balance\":12000,\"currency\":\"XOF\"}\n\n" }
        "401":
          description: Missing/invalid token.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/me/account:
    get:
      tags: [me]
      summary: The user's primary wallet
      description: |
        Returns the user's primary wallet (one per user, in the currency
        derived from their country: XOF for UEMOA, XAF for CEMAC).
        Multi-currency wallets are a later phase — for now the
        endpoint always returns a single account.

        Balance is an integer in the currency's smallest unit
        (see `scale`). 1 250 FCFA = 1250; $12.34 = 1234. Never use
        floats client-side.
      operationId: getAccount
      security: [{ accessToken: [] }]
      responses:
        "200":
          description: Wallet snapshot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "404":
          description: No wallet provisioned for this user (shouldn't happen post-signup).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/me/transactions:
    get:
      tags: [me]
      summary: The user's transaction history
      description: |
        Paginated list of every transaction the caller is involved in
        (initiator OR counterparty). Sorted newest first. Cursor-based
        pagination: pass `before` (the response's `next_before`) to
        fetch the next page.
      operationId: listTransactions
      security: [{ accessToken: [] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: before
          in: query
          description: RFC-3339 cursor — return rows with `created_at < before`.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Transactions page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListTransactionsResponse" }

  /v1/agent/cash-in:
    post:
      tags: [agent]
      summary: Agent credits cash into a user's wallet
      description: |
        Called by the FlexApp agent app after it scans the user's QR
        (which carries their `@tag`) and enters the cash amount the
        user is handing over. The backend moves `amount` from the
        agent's float account to the user's wallet inside a single
        ledger transaction.

        **Auth is stubbed**: header `X-Agent-Code` carries the agent's
        short code, trusted as-is. The full agent auth flow (phone +
        PIN + device, mirror of user auth) will swap the middleware
        without touching this handler.

        State: completed immediately. No external rail to wait on.

        Commission is not applied yet (Phase 5F) — `amount` is the
        net credited to the user, the agent receives `amount` cash.
      operationId: agentCashIn
      parameters:
        - in: header
          name: X-Agent-Code
          required: true
          schema: { type: string, example: "agentci01" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AgentCashInRequest" }
      responses:
        "200":
          description: Cash-in accepted and settled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AgentCashInResponse" }
        "400":
          description: Invalid payload (zero/negative amount, missing fields…).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Missing or unknown agent code.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                missingCode: { value: { code: missing_agent_code, message: "Authentification agent requise." } }
                notFound:    { value: { code: agent_not_found, message: "Code agent inconnu." } }
        "403":
          description: Agent exists but is suspended/closed.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Recipient tag matches no user.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: Recipient wallet or agent float not provisioned in this currency.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/transfers:
    post:
      tags: [ledger]
      summary: Send money to another FlexApp user by tag
      description: |
        Synchronous P2P transfer between two wallets on the FlexApp
        ledger. The recipient is resolved by their `@tag`. Currency
        is implicit: the sender's wallet's currency. Cross-currency
        transfers are rejected — there's no FX layer in this phase.

        Fees come from the live `fee_schedules` table for kind
        `p2p_transfer`. Sender is debited by `amount + fee`,
        recipient credited by `amount`, system fee account credited
        by `fee`. Double-entry sums to zero.

        **Idempotency**: required `idempotency_key` makes retries
        safe. Replaying the same `(sender, key)` returns the original
        result; no double-charge.
      operationId: createTransfer
      security: [{ accessToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransferRequest" }
      responses:
        "200":
          description: Transfer accepted and settled. Postings applied.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TransferResponse" }
        "400":
          description: Invalid input (zero amount, missing key, self-transfer…).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                invalidAmount:    { value: { code: invalid_amount, message: "Le montant doit être supérieur à zéro." } }
                missingKey:       { value: { code: missing_idempotency_key, message: "`idempotency_key` est requis." } }
                selfTransfer:     { value: { code: self_transfer, message: "Impossible de vous envoyer à vous-même." } }
                insufficient:     { value: { code: insufficient_funds, message: "Solde insuffisant." } }
        "404":
          description: Recipient tag doesn't match any user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: recipient_not_found, message: "Aucun utilisateur pour ce tag." }
        "409":
          description: Recipient inactive, or wallets in mismatched currencies.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/users/lookup:
    get:
      tags: [me]
      summary: Resolve a FlexApp tag to its public-display fields
      description: |
        Used by the mobile Send flow to live-preview the recipient
        as the user types. Returns only the bare display info — no
        phone, no country, no state — even though the route sits
        behind the access-token middleware. Keeps the directory
        leak surface tiny.

        Returns 404 `tag_not_found` for unknown OR not-yet-active
        accounts; both cases produce the same UX (no auto-complete
        hit) so we don't reveal whether a tag exists in some
        partial-onboarding state.
      operationId: lookupTag
      security: [{ accessToken: [] }]
      parameters:
        - in: query
          name: tag
          required: true
          schema: { type: string, example: "@baazz" }
      responses:
        "200":
          description: Tag resolved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserLookup" }
        "404":
          description: Tag doesn't match an active user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: tag_not_found, message: "Aucun utilisateur pour ce tag." }

  /v1/me/security:
    patch:
      tags: [me]
      summary: Update the user's security toggles
      description: |
        Currently exposes a single toggle: `single_device_mode`.

        When the user turns it ON, the backend immediately revokes
        every other active device for that user (keeping only the
        device making this request). When OFF, only future logins are
        affected — already-revoked devices stay revoked.

        Returns the full [MeResponse] so the mobile can reflect the
        updated state without a follow-up GET /me.
      operationId: updateSecurity
      security: [{ accessToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchSecurityRequest" }
      responses:
        "200":
          description: Security settings updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeResponse" }
        "400":
          description: No field to update.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: no_change, message: "Aucun champ à mettre à jour." }
        "401":
          description: Missing/invalid access_token, or device revoked.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/me/devices:
    get:
      tags: [me]
      summary: List the user's registered devices
      description: |
        Returns every device row belonging to the caller, active rows
        first (sorted by `last_seen_at` descending) then revoked rows.
        Powers the "Appareils connectés" card in the mobile Profile.

        Each row carries a `current: true` flag when it's the device
        making this request — the UI uses it to render a "Cet appareil"
        badge and hide the revoke button.
      operationId: listDevices
      security: [{ accessToken: [] }]
      responses:
        "200":
          description: Devices for the authenticated user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListDevicesResponse" }
        "401":
          description: Missing/invalid access_token, or the current device has been revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                revoked: { value: { code: device_revoked, message: "Cet appareil a été déconnecté." } }

  /v1/me/devices/{id}:
    delete:
      tags: [me]
      summary: Revoke a device by id
      description: |
        Flips the target device's `revoked_at` to now and stores the
        reason `user_revoked_from_profile`. The next request from that
        device gets a 401 `device_revoked` and the mobile triggers
        sign-out.

        The current device cannot revoke itself this way — the API
        returns 400 `cannot_revoke_current`. Use the regular sign-out
        flow for that.
      operationId: revokeDevice
      security: [{ accessToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204":
          description: Device revoked.
        "400":
          description: Tried to revoke the current device, or missing id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: cannot_revoke_current, message: "Utilisez \"Se déconnecter\" pour cet appareil." }
        "401":
          description: Missing/invalid access_token.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: No device with that id belongs to the caller.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: device_not_found, message: "Appareil introuvable." }

  /v1/auth/phone/verify:
    post:
      tags: [auth]
      summary: Verify the OTP a user received by SMS
      description: |
        Step 2 of the signup/login flow. The client submits the OTP
        digits the user typed. On success:

        - `purpose=signup` returns a short-lived **signup_token** that
          authorises `/identity/set`, `/tag/set` and `/pin/set`. The
          user's `state` advances from `phone_pending` to
          `identity_pending`.
        - `purpose=login` returns the long-lived **access_token**. The
          user is already `active`.

        The OTP is one-shot — consumed on first match. Wrong attempts
        increment a per-OTP counter; after 5 wrong tries the OTP is
        burned and a fresh `/phone/start` is required.
      operationId: verifyPhone
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerifyPhoneRequest" }
            examples:
              signup:
                value: { phone: "+225 0707076277", code: "617092", purpose: signup }
      responses:
        "200":
          description: OTP accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyPhoneResponse" }
        "400":
          description: Malformed body or invalid phone.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Wrong or expired OTP.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                wrong:    { value: { code: invalid_otp, message: "Code incorrect." } }
                expired:  { value: { code: otp_expired, message: "Code expiré. Demandez-en un nouveau." } }
        "404":
          description: Login attempted for an unknown phone.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: Too many wrong attempts on this OTP.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: too_many_attempts, message: "Trop de tentatives. Demandez un nouveau code." }

  /v1/auth/refresh:
    post:
      tags: [auth]
      summary: Mint a fresh access_token without going through OTP
      description: |
        Silent re-auth path. Used by the mobile when the cached
        access_token has expired but the user is still in front of the
        app — typically right after they unlock the LockScreen.

        Security comes from the triple `(phone, PIN, registered device)`:

         - the PIN must Argon2-verify against the user's stored hash,
         - the [device.uid] must match a non-revoked row in `devices`
           for that user. We do NOT register new device rows here, so
           a brand-new install can't refresh — it has to walk the full
           /phone/verify + /pin/verify dance.

        Returns the same shape as `/v1/auth/pin/verify` so the mobile
        can drop in the new token transparently.
      operationId: refresh
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefreshRequest" }
      responses:
        "200":
          description: New access_token issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyPinResponse" }
        "401":
          description: Wrong PIN, or device is not registered (or revoked).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                wrongPin:
                  value: { code: invalid_pin, message: "PIN incorrect." }
                deviceUnknown:
                  value: { code: device_not_registered, message: "Cet appareil n'est pas reconnu." }
        "404":
          description: No account for this phone.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/auth/logout:
    post:
      tags: [auth]
      summary: End the session on this device
      description: |
        Revokes the device row bound to the calling access_token
        (`revoke_reason = user_logged_out`). The next request from this
        install returns 401 `device_revoked` and the mobile transitions
        back to the auth flow.

        Distinct from `DELETE /v1/me/devices/{id}` — that one targets
        OTHER devices and refuses to act on the current one. Use this
        endpoint for the "Se déconnecter" button in the user's own
        profile, then clear local state on the device.

        Idempotent: an already-revoked device gracefully returns 204.
        Tokens minted before the devices table existed (one-time grace
        period) also return 204 — there's simply nothing to revoke.
      operationId: logout
      security: [{ accessToken: [] }]
      responses:
        "204": { description: Session ended on this device. }
        "401":
          description: Missing/invalid access_token, or the current device is already revoked.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/auth/pin/verify:
    post:
      tags: [auth]
      summary: Verify the user's PIN and mint the access_token
      description: |
        Second leg of the login flow. The mobile sends the PIN the user
        typed in the LockScreen-like prompt right after OTP. On success
        the server returns the long-lived **access_token** and the
        user's profile (so the mobile can rebuild Session without an
        extra `/me` call).
      operationId: verifyPin
      security: [{ loginToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerifyPinRequest" }
      responses:
        "200":
          description: PIN matches; access_token issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyPinResponse" }
        "401":
          description: Wrong PIN or invalid login_token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: invalid_pin, message: "PIN incorrect." }

  /v1/auth/identity/set:
    post:
      tags: [auth]
      summary: Set the user's first and last name
      description: |
        Step 3 of signup. Persists the user's display identity and
        advances `state` to `tag_pending`. Requires a valid
        **signup_token** in `Authorization: Bearer …`.
      operationId: setIdentity
      security: [{ signupToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetIdentityRequest" }
            examples:
              ci: { value: { first_name: "Eloge", last_name: "YAO" } }
      responses:
        "200":
          description: Identity saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetIdentityResponse" }
        "400":
          description: Invalid name (empty or too long).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Missing or invalid signup_token.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: User isn't in a state where identity can still be set.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/auth/tag/set:
    post:
      tags: [auth]
      summary: Set the user's FlexTag (handle)
      description: |
        Step 4 of signup. Persists the user's `@tag` and advances
        `state` to `pin_pending`. Tag rules: 3-20 chars, `[a-z0-9_-]`
        only, must not be reserved.
      operationId: setTag
      security: [{ signupToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetTagRequest" }
            examples:
              ci: { value: { tag: "eloge_y" } }
      responses:
        "200":
          description: Tag saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetTagResponse" }
        "400":
          description: Tag doesn't match the character or length rules.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Missing or invalid signup_token.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: Tag taken, reserved, or user not in tag_pending state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                taken:    { value: { code: tag_taken,    message: "Ce tag est déjà pris." } }
                reserved: { value: { code: tag_reserved, message: "Ce tag est réservé." } }

  /v1/auth/pin/set:
    post:
      tags: [auth]
      summary: Set the 4-6 digit PIN and finalise signup
      description: |
        Step 5 (final) of signup. Hashes the PIN with Argon2id, flips
        the user to `active`, and returns the long-lived
        **access_token** the mobile app stores and sends as
        `Authorization: Bearer …` on every protected call from now on.
      operationId: setPin
      security: [{ signupToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetPinRequest" }
            examples:
              fourDigit: { value: { pin: "1234" } }
      responses:
        "200":
          description: Account activated; access_token issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetPinResponse" }
        "400":
          description: PIN length or charset invalid.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Missing or invalid signup_token.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: User not in pin_pending state.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/auth/phone/start:
    post:
      tags: [auth]
      summary: Send an OTP to a phone number
      description: |
        Step 1 of the signup or login flow. The server generates an
        N-digit OTP (length controlled by `OTP_LENGTH`, default 6),
        stores its HMAC-SHA256, and dispatches it to the user via SMS.

        In development with `LOG_OTP=true` the code is also printed to
        the api logs so you can complete the flow without an SMS gateway.

        Rate-limited per phone via `OTP_RESEND_AFTER` (default 45s).
      operationId: startPhone
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StartPhoneRequest" }
            examples:
              signupCI:
                summary: Signup from Côte d'Ivoire
                value:
                  phone: "+225 0707076277"
                  purpose: signup
              loginCI:
                summary: Login from Côte d'Ivoire
                value:
                  phone: "+225 0707076277"
                  purpose: login
      responses:
        "200":
          description: OTP successfully dispatched.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartPhoneResponse" }
        "400":
          description: Malformed body or invalid phone.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                invalidPhone:
                  value: { code: invalid_phone, message: "Numéro de téléphone invalide." }
                invalidPurpose:
                  value: { code: invalid_purpose, message: '`purpose` must be "signup" or "login".' }
                invalidCountry:
                  value: { code: invalid_country, message: "Pays non pris en charge." }
                countryMismatch:
                  value: { code: country_mismatch, message: "Le pays sélectionné ne correspond pas au préfixe téléphonique." }
        "404":
          description: Login attempted for a phone that has no account yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: not_registered, message: "Aucun compte FlexApp pour ce numéro." }
        "409":
          description: Signup attempted for a phone already linked to an active account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: already_registered, message: "Ce numéro a déjà un compte FlexApp. Connectez-vous." }
        "429":
          description: Resend requested too soon after the previous one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: resend_too_soon, message: "Patientez avant de demander un nouveau code." }
        "500":
          description: Unexpected server error.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  securitySchemes:
    signupToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Short-lived JWT (`typ=signup`, ~10 min) returned by
        `/v1/auth/phone/verify` when `purpose=signup`. Used to authorise
        the identity/tag/pin endpoints during account provisioning.
    loginToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Short-lived JWT (`typ=login`, ~5 min) returned by
        `/v1/auth/phone/verify` when `purpose=login`. The client trades
        it for an [accessToken] by calling `/v1/auth/pin/verify` with
        the user's PIN. Cannot call any business endpoint directly.
    accessToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Long-lived JWT (`typ=access`, ~1 h) returned by `/v1/auth/pin/set`
        or by `/v1/auth/phone/verify` when `purpose=login`. Used by
        every protected business endpoint.

  schemas:
    Error:
      type: object
      description: Uniform error envelope returned by every non-2xx response.
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            Machine-readable, stable across releases. Use this in client
            switch statements to drive UX (show a specific dialog, etc.).
          example: invalid_phone
        message:
          type: string
          description: |
            End-user-friendly French copy. Safe to display as-is in the
            mobile app, e.g. inside a toast or an inline error.
          example: "Numéro de téléphone invalide."

    OtpPurpose:
      type: string
      enum: [auto, signup, login]
      description: |
        - `auto` (default) — let the server detect: existing active
          account → `login`, otherwise → `signup`. The resolved value
          comes back in [StartPhoneResponse.purpose].
        - `signup` — explicit, rejects phones already active.
        - `login`  — explicit, rejects phones without an account.

    StartPhoneRequest:
      type: object
      required: [phone]
      properties:
        phone:
          type: string
          description: |
            Phone number in international or local form. The server
            normalises and validates against UEMOA prefixes (CI/SN/BF/…)
            using libphonenumber rules.
          example: "+225 0707076277"
        purpose:
          allOf: [{ $ref: "#/components/schemas/OtpPurpose" }]
          default: auto
          description: |
            Optional. Defaults to `auto` (recommended) — the server
            picks signup or login and returns the resolved value.
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          description: |
            ISO-3166-1 alpha-2 of the user's country of residence,
            picked in the mobile PhoneStep country selector. Optional —
            when omitted the server derives it from the dial prefix.
            When supplied, must either match the derived value or be
            blank; mismatches return 400 `country_mismatch`.

            Whitelist (MVP): CI, SN, BJ, BF, ML, NE, TG, GW.
          example: "CI"

    StartPhoneResponse:
      type: object
      required: [code_length, expires_in_seconds, resend_in_seconds, purpose]
      properties:
        code_length:
          type: integer
          description: Number of digits the user must enter.
          example: 6
        expires_in_seconds:
          type: integer
          description: How long the OTP stays valid.
          example: 300
        resend_in_seconds:
          type: integer
          description: Seconds the client must wait before calling /start again.
          example: 45
        purpose:
          type: string
          enum: [signup, login]
          description: |
            Always resolved server-side. When the client sent `auto`
            (the recommended default), this tells the mobile which
            post-OTP UX to play.
          example: signup

    UserState:
      type: string
      enum: [phone_pending, identity_pending, tag_pending, pin_pending, active]
      description: |
        Where the user sits in the signup state machine. The mobile
        client should jump straight to the matching step when resuming
        an interrupted signup.

    VerifyPhoneRequest:
      type: object
      required: [phone, code, purpose]
      properties:
        phone:   { type: string, example: "+225 0707076277" }
        code:    { type: string, description: "OTP digits the user typed (length = StartPhoneResponse.code_length)." }
        purpose: { $ref: "#/components/schemas/OtpPurpose" }

    VerifyPhoneResponse:
      type: object
      required: [expires_in_seconds, state]
      properties:
        signup_token:
          type: string
          description: |
            Present iff `purpose=signup`. JWT, send as
            `Authorization: Bearer …` on identity/tag/pin endpoints.
        login_token:
          type: string
          description: |
            Present iff `purpose=login`. JWT, used ONLY to call
            `/v1/auth/pin/verify` which trades it for an access_token
            after the user re-enters their PIN.
        expires_in_seconds:
          type: integer
          example: 600
        state: { $ref: "#/components/schemas/UserState" }

    RefreshRequest:
      type: object
      required: [phone, pin, device]
      properties:
        phone:  { type: string, example: "+225 0707076277" }
        pin:    { type: string, minLength: 4, maxLength: 6, example: "1234" }
        device: { $ref: "#/components/schemas/DeviceInfo" }

    VerifyPinRequest:
      type: object
      required: [pin, device]
      properties:
        pin:
          type: string
          description: 4-6 digit numeric PIN (same the user set at signup).
          example: "1234"
          minLength: 4
          maxLength: 6
        device: { $ref: "#/components/schemas/DeviceInfo" }

    VerifyPinResponse:
      type: object
      required: [access_token, expires_in_seconds, tag, first_name, last_name, phone, country_code, single_device_mode]
      properties:
        access_token:
          type: string
          description: "JWT, send as `Authorization: Bearer …` for every protected call."
        expires_in_seconds: { type: integer, example: 3600 }
        tag:                { type: string, example: "@eloge_y" }
        first_name:         { type: string, example: "Eloge" }
        last_name:          { type: string, example: "YAO" }
        phone:              { type: string, example: "+2250707076277" }
        country_code:       { type: string, minLength: 2, maxLength: 2, example: "CI" }
        single_device_mode: { type: boolean, example: false }

    SetIdentityRequest:
      type: object
      required: [first_name, last_name]
      properties:
        first_name: { type: string, example: "Eloge", maxLength: 60 }
        last_name:  { type: string, example: "YAO",   maxLength: 60 }

    SetIdentityResponse:
      type: object
      required: [state]
      properties:
        state: { $ref: "#/components/schemas/UserState" }

    SetTagRequest:
      type: object
      required: [tag]
      properties:
        tag:
          type: string
          description: |
            FlexTag handle without the leading `@`. 3-20 chars,
            `[a-z0-9_-]` only. The server lowercases automatically.
          example: "eloge_y"
          minLength: 3
          maxLength: 20

    SetTagResponse:
      type: object
      required: [tag, state]
      properties:
        tag:
          type: string
          description: Final tag as stored, with the leading `@`.
          example: "@eloge_y"
        state: { $ref: "#/components/schemas/UserState" }

    SetPinRequest:
      type: object
      required: [pin, device]
      properties:
        pin:
          type: string
          description: 4-6 digit numeric PIN. Stored as Argon2id hash.
          example: "1234"
          minLength: 4
          maxLength: 6
        device: { $ref: "#/components/schemas/DeviceInfo" }

    DeviceInfo:
      type: object
      description: |
        Device identity bundle attached to /pin/set and /pin/verify.
        The `uid` is a stable per-install identifier the client
        persists in its local storage (SharedPreferences / NSUserDefaults).
        Survives app restarts but rotates on reinstall — we treat a
        reinstall as a new device, which is the desired behaviour for
        the single-device-at-a-time rule.
      required: [uid, platform]
      properties:
        uid:         { type: string, description: "Stable per-install UUID.", example: "1f0f0d3a-…" }
        platform:    { type: string, enum: [android, ios] }
        model:       { type: string, example: "Pixel 9", description: "Marketing name; best-effort." }
        app_version: { type: string, example: "0.1.4" }

    Device:
      type: object
      description: A registered device for the authenticated user.
      required: [id, platform, created_at, last_seen_at, revoked, current]
      properties:
        id:           { type: string, format: uuid, description: "Server-assigned. Use in DELETE /v1/me/devices/{id}." }
        platform:     { type: string, enum: [android, ios] }
        model:        { type: string, nullable: true, example: "Pixel 9" }
        app_version:  { type: string, nullable: true, example: "0.1.4" }
        created_at:   { type: string, format: date-time }
        last_seen_at: { type: string, format: date-time }
        revoked:      { type: boolean, description: "True when the device has been revoked. Revoked devices are kept for audit; revoked rows can't authenticate." }
        revoke_reason:
          type: string
          nullable: true
          enum: [superseded_by_other_device, user_revoked_from_profile]
          description: |
            Why this device was revoked. `superseded_by_other_device`
            is set automatically when the user signs in on another
            install; `user_revoked_from_profile` when they hit the
            "Déconnecter" button in Mes appareils.
        current:      { type: boolean, description: "True when this row corresponds to the device that made the request — UI shows a badge and hides the revoke button." }

    ListDevicesResponse:
      type: object
      required: [devices]
      properties:
        devices:
          type: array
          items: { $ref: "#/components/schemas/Device" }

    MeResponse:
      type: object
      required: [id, phone, tag, first_name, last_name, state, country_code, single_device_mode]
      properties:
        id:           { type: string, format: uuid }
        phone:        { type: string, example: "+2250707076277" }
        tag:          { type: string, example: "@eloge_y" }
        first_name:   { type: string, example: "Eloge" }
        last_name:    { type: string, example: "YAO" }
        state:        { $ref: "#/components/schemas/UserState" }
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          description: "ISO-3166-1 alpha-2. Drives currency, MM operator list, KYC tier."
          example: "CI"
        single_device_mode:
          type: boolean
          description: |
            When true, every new sign-in revokes the user's other
            active devices. Default false: multi-device is the friendly
            path, users opt in for stricter security from
            Profile → Sécurité.

    Account:
      type: object
      description: |
        A wallet that holds money. End-user wallets always have
        `state="active"` post-signup; `frozen` and `closed` are reserved
        for compliance / ops actions.
      required: [id, currency, scale, symbol, balance, state]
      properties:
        id:       { type: string, format: uuid }
        currency: { type: string, minLength: 3, maxLength: 3, example: "XOF" }
        scale:
          type: integer
          minimum: 0
          maximum: 4
          description: Number of decimal places — 0 for XOF/XAF, 2 for USD.
          example: 0
        symbol:   { type: string, example: "FCFA" }
        balance:
          type: integer
          format: int64
          description: Signed amount in the currency's smallest unit. Never a float.
          example: 284750
        state:    { type: string, enum: [active, frozen, closed] }

    Transaction:
      type: object
      description: |
        A logical money movement. The amount the user moved is `amount`;
        any fee taken on top is `fee`. `net_for_caller` is the signed
        delta from the caller's perspective (positive when money came
        in, negative when it went out).
      required: [id, kind, state, currency, amount, fee, net_for_caller, created_at]
      properties:
        id:    { type: string, format: uuid }
        kind:  { type: string, enum: [p2p_transfer, mm_topup, mm_payout, fee, adjustment, agent_cash_in, agent_cash_out] }
        state: { type: string, enum: [pending, completed, failed, reversed] }
        currency: { type: string, minLength: 3, maxLength: 3, example: "XOF" }
        amount: { type: integer, format: int64, minimum: 0, example: 5000 }
        fee:    { type: integer, format: int64, minimum: 0, example: 0 }
        net_for_caller:
          type: integer
          format: int64
          description: Signed delta from the caller's wallet — > 0 received, < 0 sent.
          example: -5000
        counterparty_label:
          type: string
          description: Tag of the other end of the transfer, when applicable.
          example: "@pro"
        counterparty_first_name:
          type: string
          description: Counterparty's first name (p2p only). Lets the mobile build "Envoi rapide" pills without an extra lookup.
          example: "Eloge"
        counterparty_last_name:
          type: string
          description: Counterparty's last name (p2p only).
          example: "YAO"
        created_at:   { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }

    UserLookup:
      type: object
      description: Minimal public display info for a tag, used by the Send-flow live preview.
      required: [tag, first_name, last_name]
      properties:
        tag:        { type: string, example: "@baazz" }
        first_name: { type: string, example: "Eloge" }
        last_name:  { type: string, example: "YAO" }

    ListTransactionsResponse:
      type: object
      required: [transactions]
      properties:
        transactions:
          type: array
          items: { $ref: "#/components/schemas/Transaction" }
        next_before:
          type: string
          format: date-time
          description: |
            Cursor for the next page. Empty when no more rows. Pass
            back as `?before=`.

    AgentCashInRequest:
      type: object
      required: [user_tag, amount, idempotency_key]
      properties:
        user_tag:        { type: string, example: "@baazz" }
        amount:          { type: integer, format: int64, minimum: 1, example: 5000 }
        idempotency_key: { type: string, example: "1f0f0d3a-12cb-4a16-a4d2-9f01c1a2d000" }

    AgentCashInResponse:
      type: object
      required: [transaction_id, amount, currency, agent_balance_after, user_balance_after, user_tag]
      properties:
        transaction_id:      { type: string, format: uuid }
        amount:              { type: integer, format: int64 }
        currency:            { type: string, example: "XOF" }
        agent_balance_after: { type: integer, format: int64, description: "Agent float after the move." }
        user_balance_after:  { type: integer, format: int64, description: "User wallet after the move." }
        user_tag:            { type: string, example: "@baazz" }

    TransferRequest:
      type: object
      required: [recipient_tag, amount, idempotency_key]
      properties:
        recipient_tag:
          type: string
          description: Recipient handle, with or without the leading "@".
          example: "@pro"
        amount:
          type: integer
          format: int64
          minimum: 1
          description: Gross amount to send, in the sender's currency smallest unit (before fee).
          example: 5000
        idempotency_key:
          type: string
          description: |
            Client-generated UUID. Same key replayed returns the same
            transaction — safe to retry on flaky networks.
          example: "1f0f0d3a-12cb-4a16-a4d2-9f01c1a2d000"
        note:
          type: string
          maxLength: 140
          description: Optional human note attached to the transaction metadata.

    TransferResponse:
      type: object
      required: [transaction, sender_balance_after]
      properties:
        transaction: { $ref: "#/components/schemas/Transaction" }
        sender_balance_after:
          type: integer
          format: int64
          description: Sender wallet balance after the transfer settled. Lets the mobile refresh without a GET /me/account.

    PatchSecurityRequest:
      type: object
      properties:
        single_device_mode:
          type: boolean
          description: |
            Set true to enforce single-device mode. The backend
            additionally revokes every other active device of the user
            on the ON transition. Set false to allow multiple
            simultaneous devices.

    PatchCountryRequest:
      type: object
      required: [country_code]
      properties:
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          example: "SN"

    SetPinResponse:
      type: object
      required: [access_token, expires_in_seconds, tag, first_name, last_name, country_code, single_device_mode]
      properties:
        access_token:
          type: string
          description: "JWT, send as `Authorization: Bearer …` for every protected call."
        expires_in_seconds:
          type: integer
          example: 3600
        tag:                { type: string, example: "@eloge_y" }
        first_name:         { type: string, example: "Eloge" }
        last_name:          { type: string, example: "YAO" }
        country_code:       { type: string, minLength: 2, maxLength: 2, example: "CI" }
        single_device_mode: { type: boolean, example: false }
