openapi: 3.1.0
info:
  title: Switag API
  version: "1.0.0"
  summary: HTTP contract for the Switag mobile wallet.
  description: |
    # Switag API

    **Switag** est un portefeuille mobile pour l'Afrique de l'Ouest
    (UEMOA, premier marché : Côte d'Ivoire). Ce document décrit le
    contrat HTTP entre l'application mobile Kotlin Multiplatform
    (Android + iOS) et le backend Go.

    ## Principes

    - **JSON only** — aucune route ne renvoie d'HTML ni de form-urlencoded.
    - **Erreurs uniformes** — toutes les réponses non-2xx partagent l'enveloppe
      `ErrorBody` (`code` machine, `message` localisé via `Accept-Language`).
    - **Argent en entier** — les montants sont des entiers dans l'unité
      minimale de la devise (XOF a `scale=0`, donc 1 250 FCFA = `1250`).
      Jamais de flottants côté wire.
    - **Téléphone E.164** — toujours stocké sans le `+`, toujours renvoyé
      avec. Le mobile peut envoyer la forme locale, le backend normalise.
    - **Idempotency keys** — obligatoires sur toute écriture argentée
      (transferts, contributions, cash-in, refund, crédits admin). UUID v4
      généré côté client ; un même `(initiator, key)` rejoué renvoie le
      résultat original, jamais de double-débit.
    - **Langues** — l'API renvoie les messages d'erreur en **français par
      défaut**, ou en **anglais** si le client envoie
      `Accept-Language: en`. Les codes d'erreur (`code`) restent
      identiques en toute langue ; seul `message` est traduit.

    ## Architecture

    - **Auth** — flow OTP par SMS (Firebase Phone Auth en option), puis
      JWT court-vivant `signup_token` / `login_token` pour le provisioning,
      puis `access_token` long-vivant pour les routes métier.
    - **Ledger** — Postgres en double-entrée. Chaque mouvement crée une
      `transaction` + des `postings` qui somment à zéro.
    - **Realtime** — `GET /v1/me/events` est un flux SSE
      (`text/event-stream`) avec ring buffer + replay via `Last-Event-ID`.
    - **Multi-device** — chaque appareil est enregistré, peut être révoqué
      depuis le profil, et reçoit ses notifications via FCM.

    ## Authentification — récap

    | Token         | TTL     | Usage                                           |
    |---------------|---------|-------------------------------------------------|
    | `signup_token`| ~10 min | identity/set, tag/set, pin/set                  |
    | `login_token` | ~5 min  | pin/verify uniquement                           |
    | `access_token`| ~1 h    | toutes les routes métier (`/v1/me`, transferts…)|

    L'`access_token` est lié à une `device_uid` ; révoquer l'appareil
    invalide le token immédiatement (401 `device_revoked`).
  contact:
    name: Switag · Switag
    email: yaoeloge@gmail.com
  license:
    name: Proprietary
    identifier: LicenseRef-Switag-Proprietary

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

tags:
  - name: Auth
    description: |
      Onboarding (signup) et connexion (login) par téléphone + OTP,
      plus échange Firebase et lifecycle des tokens (refresh, logout).
  - name: Me
    description: |
      Profil de l'utilisateur courant, sécurité, portefeuille, historique
      de transactions et flux SSE temps réel.
  - name: Users
    description: Lookup public d'un compte par son `@tag`.
  - name: Transfers
    description: Transferts P2P entre utilisateurs Switag et remboursements.
  - name: Agent
    description: Recharge physique (cash-in) effectuée par un agent agréé.
  - name: Tontines
    description: |
      Épargne rotative collective. Trois sous-types : **rotative**
      (chacun son tour), **enchères** (qui prend le pot ce cycle paie
      le plus), **accumulation** (cagnotte fermée à terme).
  - name: Cagnottes
    description: |
      Collectes ponctuelles type cagnotte de mariage, santé, projet —
      avec tag public partageable (`#mon-projet`).
  - name: Invitations
    description: Invitations entrantes vers une tontine.
  - name: Devices
    description: |
      Multi-device management — liste des appareils, révocation à
      distance, et registration du token FCM pour le push.
  - name: Admin
    description: |
      Endpoints d'administration / dev. Mountés uniquement quand
      `SWITAG_ADMIN_TOKEN` est défini ; à ne JAMAIS activer en
      production publique.
  - name: Health
    description: Liveness + readiness probes pour le load balancer.
  - name: Switag Business
    description: |
      **API B2B / marchands** (`/v1/biz/*`). Permet à un site e-commerce,
      une institution ou tout marchand d'**encaisser** (charges + checkout
      hébergé) et d'être **notifié** (webhooks `charge.succeeded` signés).
      Deux types d'appelant : le **tableau de bord** (session marchand
      `MerchantSession`) et le **serveur du marchand** (clé API
      `X-Api-Key` + `X-Api-Secret`). Activé quand `MERCHANT_JWT_SECRET`
      est défini.

paths:
  # ─────────────────────────────────────────────────────────────────
  # Health
  # ─────────────────────────────────────────────────────────────────
  /healthz:
    get:
      tags: [Health]
      summary: Liveness + ping Postgres
      description: |
        Renvoie `200 OK` si le process est UP et que Postgres répond au
        ping. Utilisé par Dokploy / load balancer comme readiness probe.
      operationId: healthz
      responses:
        "200":
          description: Service en bonne santé.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
        "503":
          description: Base de données injoignable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Auth
  # ─────────────────────────────────────────────────────────────────
  /v1/auth/phone/start:
    post:
      tags: [Auth]
      summary: Envoyer un OTP par SMS
      description: |
        Étape 1 du flow signup / login. Le serveur génère un OTP à N
        chiffres (longueur `OTP_LENGTH`, défaut 6), stocke son HMAC,
        et le dispatche par SMS.

        En dev avec `LOG_OTP=true`, le code est aussi loggé dans les
        logs api pour permettre de tester sans gateway SMS.

        Rate-limité par téléphone (`OTP_RESEND_AFTER`, défaut 45 s).
      operationId: startPhone
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StartPhoneRequest" }
            examples:
              signupCI:
                summary: Signup depuis la Côte d'Ivoire
                value: { phone: "+225 0707076277", purpose: signup, country_code: CI }
              loginCI:
                summary: Login depuis la Côte d'Ivoire
                value: { phone: "+225 0707076277", purpose: login }
      responses:
        "200":
          description: OTP envoyé.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartPhoneResponse" }
        "400":
          description: Body malformé ou téléphone invalide.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                invalidPhone:    { value: { code: invalid_phone, message: "Numéro de téléphone invalide." } }
                invalidPurpose:  { value: { code: invalid_purpose, message: '`purpose` must be "auto", "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 demandé sur un téléphone sans compte.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: not_registered, message: "Aucun compte Switag pour ce numéro." }
        "409":
          description: Signup demandé sur un téléphone déjà actif.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: already_registered, message: "Ce numéro a déjà un compte Switag. Connectez-vous." }
        "429":
          description: Resend demandé trop tôt après le précédent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: resend_too_soon, message: "Patientez avant de demander un nouveau code." }

  /v1/auth/phone/verify:
    post:
      tags: [Auth]
      summary: Valider l'OTP reçu par SMS
      description: |
        Étape 2 du flow. En cas de succès :
          - `purpose=signup` → renvoie un `signup_token` court-vivant
            qui autorise `/identity/set`, `/tag/set`, `/pin/set`.
          - `purpose=login`  → renvoie un `login_token` court-vivant
            qui ne sert qu'à appeler `/pin/verify`.

        OTP one-shot — consommé au premier match. 5 mauvaises tentatives
        brûlent l'OTP, il faut relancer `/phone/start`.
      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 accepté.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyPhoneResponse" }
        "400":
          description: Body malformé.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "401":
          description: OTP faux ou expiré.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                wrong:   { value: { code: invalid_otp, message: "Code incorrect." } }
                expired: { value: { code: otp_expired, message: "Code expiré. Demandez-en un nouveau." } }
        "404":
          description: Login pour un téléphone inconnu.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "429":
          description: Trop de mauvaises tentatives sur cet OTP.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: too_many_attempts, message: "Trop de tentatives. Demandez un nouveau code." }

  /v1/auth/firebase/exchange:
    post:
      tags: [Auth]
      summary: Échanger un Firebase ID token contre un signup/login token
      description: |
        Alternative au flow `phone/start` + `phone/verify` quand le SMS
        a été délivré et vérifié par **Firebase Auth Phone Sign-In**.
        Single call, même shape de réponse que `/phone/verify` — le
        mobile branche sur `signup_token` vs `login_token` de la même
        manière.

        Le token Firebase doit provenir du provider `phone` ; tout autre
        provider est rejeté avec `wrong_provider`.
      operationId: exchangeFirebase
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExchangeFirebaseRequest" }
      responses:
        "200":
          description: Token échangé.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyPhoneResponse" }
        "400":
          description: Body invalide, téléphone ou pays mal formé, mauvais provider.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                missingToken:  { value: { code: missing_token, message: "`firebase_id_token` is required." } }
                wrongProvider: { value: { code: wrong_provider, message: "Le token Firebase doit provenir d'une connexion par téléphone." } }
        "401":
          description: Token Firebase invalide / expiré / mauvaise signature.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: invalid_token, message: "Token Firebase invalide ou expiré." }

  /v1/auth/identity/set:
    post:
      tags: [Auth]
      summary: Étape 3 — saisir prénom + nom
      description: |
        Persiste l'identité de l'utilisateur et avance `state` à
        `tag_pending`. Nécessite un `signup_token` valide.
      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: Identité enregistrée.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateResponse" }
        "400":
          description: Nom invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "401":
          description: signup_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Mauvais état (étape déjà franchie).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/auth/tag/set:
    post:
      tags: [Auth]
      summary: Étape 4 — choisir le FlexTag (handle)
      description: |
        Persiste le `@tag` et avance `state` à `pin_pending`.
        Règles : 3-20 caractères `[a-z0-9_-]`, non réservé.
      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 enregistré.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetTagResponse" }
        "400":
          description: Tag mal formé.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "401":
          description: signup_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Tag pris, réservé, ou mauvais état utilisateur.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              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: Étape 5 — définir le PIN et finaliser le signup
      description: |
        Hash le PIN (Argon2id), enregistre l'appareil, passe l'utilisateur
        à `active` et renvoie le `access_token` long-vivant.
      operationId: setPin
      security: [{ SignupToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetPinRequest" }
            examples:
              ci:
                value:
                  pin: "1234"
                  device:
                    uid: "8f1c2b30-a8e0-4f2a-9d1d-1c93b3df2e10"
                    platform: "android"
                    model: "Pixel 7"
                    app_version: "1.0.0"
      responses:
        "200":
          description: Compte activé, access_token émis.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetPinResponse" }
        "400":
          description: PIN ou device invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "401":
          description: signup_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Mauvais état (compte pas en pin_pending).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/auth/pin/verify:
    post:
      tags: [Auth]
      summary: Vérifier le PIN et émettre l'access_token (login)
      description: |
        Deuxième moitié du flow login. Le mobile envoie le PIN saisi
        dans le LockScreen. En succès, renvoie le `access_token` et le
        profil pour reconstruire la Session sans GET supplémentaire.
      operationId: verifyPin
      security: [{ LoginToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerifyPinRequest" }
      responses:
        "200":
          description: PIN correct, access_token émis.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyPinResponse" }
        "401":
          description: PIN incorrect ou login_token invalide.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: invalid_pin, message: "PIN incorrect." }

  /v1/auth/refresh:
    post:
      tags: [Auth]
      summary: Re-authentifier silencieusement (phone + PIN + device)
      description: |
        Sert quand l'`access_token` a expiré mais que l'utilisateur est
        encore actif sur l'app. Trois facteurs vérifiés :

          - le PIN doit Argon2-matcher le hash stocké,
          - le `device.uid` doit correspondre à un appareil non révoqué
            pour cet utilisateur,
          - le téléphone identifie le compte.

        On n'enregistre PAS de nouvel appareil ici — une nouvelle install
        doit passer par le flow complet OTP + PIN.
      operationId: refresh
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefreshRequest" }
      responses:
        "200":
          description: Nouvel access_token émis.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyPinResponse" }
        "401":
          description: PIN incorrect ou appareil non reconnu.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                wrongPin:      { value: { code: invalid_pin, message: "PIN incorrect." } }
                deviceUnknown: { value: { code: device_not_registered, message: "Cet appareil n'est pas reconnu." } }
        "404":
          description: Aucun compte pour ce téléphone.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/auth/logout:
    post:
      tags: [Auth]
      summary: Terminer la session sur l'appareil courant
      description: |
        Révoque la device row liée à l'`access_token` appelant. Idempotent :
        un appareil déjà révoqué renvoie 204. Distinct de
        `DELETE /v1/me/devices/{id}` qui cible un AUTRE appareil et
        refuse l'appareil courant.
      operationId: logout
      security: [{ BearerAuth: [] }]
      responses:
        "204": { description: Session terminée. }
        "401":
          description: access_token manquant ou invalide / appareil déjà révoqué.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Me
  # ─────────────────────────────────────────────────────────────────
  /v1/me:
    get:
      tags: [Me]
      summary: Profil de l'utilisateur courant
      operationId: getMe
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Profil.
          content: { application/json: { schema: { $ref: "#/components/schemas/MeResponse" } } }
        "401":
          description: access_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: L'utilisateur référencé n'existe plus.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/me/country:
    patch:
      tags: [Me]
      summary: Changer le pays de résidence
      description: |
        Set `country_code` sur la row utilisateur. Validation contre la
        whitelist (`CI, SN, BJ, BF, ML, NE, TG, GW`). On ne re-dérive
        PAS depuis le préfixe téléphonique — la diaspora garde
        légitimement un numéro étranger.
      operationId: updateCountry
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchCountryRequest" }
      responses:
        "200":
          description: Pays mis à jour.
          content: { application/json: { schema: { $ref: "#/components/schemas/MeResponse" } } }
        "400":
          description: Pays hors whitelist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: invalid_country, message: "Pays non pris en charge." }
        "401":
          description: access_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/me/security:
    patch:
      tags: [Me]
      summary: Mettre à jour les réglages de sécurité
      description: |
        Aujourd'hui un seul toggle : `single_device_mode`. Quand activé,
        révoque immédiatement tous les autres appareils actifs (en gardant
        l'appareil appelant).
      operationId: updateSecurity
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchSecurityRequest" }
      responses:
        "200":
          description: Sécurité mise à jour.
          content: { application/json: { schema: { $ref: "#/components/schemas/MeResponse" } } }
        "400":
          description: Aucun champ à mettre à jour.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: no_change, message: "Aucun champ à mettre à jour." }
        "401":
          description: access_token manquant / appareil révoqué.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/me/account:
    get:
      tags: [Me]
      summary: Portefeuille principal de l'utilisateur
      description: |
        Renvoie l'unique wallet de l'utilisateur (devise dérivée du pays :
        XOF pour UEMOA, XAF pour CEMAC). Multi-currency wallets prévus
        en phase ultérieure.

        `balance` est un entier dans l'unité minimale (cf. `scale`).
        Ne jamais utiliser de flottants côté client.
      operationId: getAccount
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Snapshot du portefeuille.
          content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } }
        "404":
          description: Portefeuille non provisionné (ne devrait pas arriver post-signup).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/me/transactions:
    get:
      tags: [Me]
      summary: Historique des transactions
      description: |
        Liste paginée (cursor-based) de toutes les transactions où
        l'appelant est impliqué (initiateur OU contrepartie). Tri
        descendant par `created_at`. Cursor `next_before` à repasser
        pour la page suivante.
      operationId: listTransactions
      security: [{ BearerAuth: [] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: before
          in: query
          description: RFC-3339 — renvoie les rows avec `created_at < before`.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Page de transactions.
          content: { application/json: { schema: { $ref: "#/components/schemas/ListTransactionsResponse" } } }

  /v1/me/events:
    get:
      tags: [Me]
      summary: Flux SSE temps réel (balance, transactions, tontines, cagnottes)
      description: |
        Connexion `text/event-stream` longue qui pousse les événements
        au fil de l'eau — le mobile n'a plus à poller.

        **Types d'événements** (champ `event:`) :
          - `balance.changed` — `{ balance: int64, currency: string }`
          - `transaction.created` — Transaction (même shape que la liste REST)
          - `tontine.*` — `contribution_received`, `contribution_sent`,
            `payout_received`, `round_completed`, `invitation_received/accepted/declined/cancelled`,
            `member_joined`, `member_contributed`, `reminder`
          - `cagnotte.*` — `contribution_received`, `contribution_sent`,
            `goal_reached`, `closed`
          - `account.welcome` — émis une fois lors du premier push token

        **Heartbeat** : ligne commentaire `: ping` toutes les 5 s pour
        traverser les proxies qui coupent les connexions idle (~10 s
        observé sur Dokploy).

        **Auth** : `Authorization: Bearer <access_token>` (Ktor sait
        envoyer des headers sur SSE ; pour un client browser pur on
        ajoutera `?access_token=` ultérieurement).

        **Replay** : envoyer le header `Last-Event-ID: <id>` au reconnect
        — le serveur replay tout ce qui a été émis depuis (ring buffer
        per-user). Empty / inconnu = fresh start.
      operationId: streamEvents
      security: [{ BearerAuth: [] }]
      parameters:
        - in: header
          name: Last-Event-ID
          required: false
          description: Dernier id reçu — replay du backlog.
          schema: { type: string }
      responses:
        "200":
          description: Flux SSE ouvert.
          content:
            text/event-stream:
              schema:
                type: string
                example: |
                  id: 42
                  event: balance.changed
                  data: {"balance":12000,"currency":"XOF"}
        "401":
          description: access_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Devices
  # ─────────────────────────────────────────────────────────────────
  /v1/me/devices:
    get:
      tags: [Devices]
      summary: Lister les appareils enregistrés
      description: |
        Renvoie tous les appareils de l'utilisateur, actifs d'abord
        (triés par `last_seen_at` DESC) puis révoqués. Powers la card
        "Appareils connectés" du Profile mobile. Chaque row a un flag
        `current: true` pour l'appareil appelant.
      operationId: listDevices
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Appareils de l'utilisateur.
          content: { application/json: { schema: { $ref: "#/components/schemas/ListDevicesResponse" } } }
        "401":
          description: access_token manquant ou appareil courant révoqué.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                revoked: { value: { code: device_revoked, message: "Cet appareil a été déconnecté." } }

  /v1/me/devices/{id}:
    delete:
      tags: [Devices]
      summary: Révoquer un appareil
      description: |
        Flip `revoked_at = now()` avec reason `user_revoked_from_profile`.
        La prochaine requête de cet appareil renverra 401 `device_revoked`.

        Refuse de révoquer l'appareil COURANT (utiliser `/v1/auth/logout`).
      operationId: revokeDevice
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Appareil révoqué. }
        "400":
          description: Tentative de révocation de l'appareil courant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: cannot_revoke_current, message: "Utilisez \"Se déconnecter\" pour cet appareil." }
        "401":
          description: access_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Aucun appareil avec cet id pour cet utilisateur.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: device_not_found, message: "Appareil introuvable." }

  /v1/me/devices/current/push-token:
    put:
      tags: [Devices]
      summary: Enregistrer / rafraîchir le token FCM
      description: |
        Stocke le token FCM pour l'appareil courant (l'id vient du JWT,
        jamais du body — impossible de l'attribuer à un autre appareil).
        La première fois qu'un token est enregistré pour un compte,
        déclenche un push de bienvenue (`account.welcome`).
      operationId: setPushToken
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PushTokenRequest" }
      responses:
        "204": { description: Token enregistré. }
        "400":
          description: Token manquant ou trop long (>4096).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "401":
          description: Appareil révoqué entre la mint du JWT et l'appel.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
    delete:
      tags: [Devices]
      summary: Effacer le token FCM (sign-out propre)
      description: Idempotent. Si l'appareil n'a déjà plus de token, renvoie 204.
      operationId: clearPushToken
      security: [{ BearerAuth: [] }]
      responses:
        "204": { description: Token effacé. }

  # ─────────────────────────────────────────────────────────────────
  # Users
  # ─────────────────────────────────────────────────────────────────
  /v1/users/lookup:
    get:
      tags: [Users]
      summary: Résoudre un FlexTag vers son affichage public
      description: |
        Utilisé par le flow Send du mobile pour pré-visualiser le
        destinataire à la frappe. Renvoie strictement le minimum
        d'affichage — pas de téléphone, pas de pays. 404 sur les tags
        inconnus OU non-actifs (UX identique pour ne pas leak l'état
        partiel d'onboarding).
      operationId: lookupTag
      security: [{ BearerAuth: [] }]
      parameters:
        - in: query
          name: tag
          required: true
          schema: { type: string, example: "@baazz" }
      responses:
        "200":
          description: Tag résolu.
          content: { application/json: { schema: { $ref: "#/components/schemas/UserLookup" } } }
        "404":
          description: Tag inconnu ou utilisateur non actif.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: tag_not_found, message: "Aucun utilisateur pour ce tag." }

  # ─────────────────────────────────────────────────────────────────
  # Transfers
  # ─────────────────────────────────────────────────────────────────
  /v1/transfers:
    post:
      tags: [Transfers]
      summary: Envoyer de l'argent à un autre utilisateur par tag
      description: |
        Transfert P2P synchrone entre deux wallets. Devise implicite =
        devise du wallet expéditeur. Pas de FX — cross-currency rejeté.

        Les frais viennent de la table `fee_schedules` (kind
        `p2p_transfer`). Expéditeur débité de `amount + fee`,
        destinataire crédité de `amount`, compte système crédité de
        `fee`. Double-entrée somme à zéro.

        **Idempotency** : `idempotency_key` obligatoire. Rejouer la même
        clé renvoie le résultat original.
      operationId: createTransfer
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransferRequest" }
      responses:
        "200":
          description: Transfert accepté et settlé.
          content: { application/json: { schema: { $ref: "#/components/schemas/TransferResponse" } } }
        "400":
          description: Input invalide.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              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: Tag destinataire introuvable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: recipient_not_found, message: "Aucun utilisateur pour ce tag." }
        "409":
          description: Destinataire inactif ou wallets de devises différentes.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/transfers/{id}/refund:
    post:
      tags: [Transfers]
      summary: Rembourser un transfert P2P (expéditeur, fenêtre 1h)
      operationId: refundTransfer
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefundRequest" }
      responses:
        "200":
          description: Remboursement effectué.
          content: { application/json: { schema: { $ref: "#/components/schemas/RefundResponse" } } }
        "400":
          description: Body invalide (idempotency_key manquant).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Transaction introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Fenêtre dépassée, déjà remboursé, ou non remboursable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Agent
  # ─────────────────────────────────────────────────────────────────
  /v1/agent/cash-in:
    post:
      tags: [Agent]
      summary: Recharge cash dans le wallet utilisateur par un agent
      description: |
        Appelé par l'app agent après scan du QR utilisateur. Le backend
        déplace `amount` du float agent vers le wallet utilisateur en
        une seule transaction ledger.

        **Auth stubbed** : header `X-Agent-Code` accepté tel quel. Le
        flow agent complet (phone + PIN + device) remplacera le
        middleware sans toucher au handler.

        Commission pas encore appliquée (Phase 5F) — `amount` est le net
        crédité.
      operationId: agentCashIn
      security: [{ AgentCode: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AgentCashInRequest" }
      responses:
        "200":
          description: Cash-in accepté et settlé.
          content: { application/json: { schema: { $ref: "#/components/schemas/AgentCashInResponse" } } }
        "400":
          description: Payload invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "401":
          description: Code agent manquant ou inconnu.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                missingCode: { value: { code: missing_agent_code, message: "Authentification agent requise." } }
                notFound:    { value: { code: agent_not_found, message: "Code agent inconnu." } }
        "403":
          description: Agent suspendu.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tag destinataire introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Wallet destinataire ou float agent non provisionnés.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Tontines
  # ─────────────────────────────────────────────────────────────────
  /v1/tontines/templates/{subkind}:
    get:
      tags: [Tontines]
      summary: Template de formulaire pour la création d'une tontine
      description: |
        Renvoie le template (champs additionnels + règles candidates) à
        afficher dans le wizard de création, en fonction du sous-type :
        `rotative`, `encheres`, `accumulation`.
      operationId: getCreationTemplate
      security: [{ BearerAuth: [] }]
      parameters:
        - name: subkind
          in: path
          required: true
          schema: { type: string, enum: [rotative, encheres, accumulation] }
      responses:
        "200":
          description: Template.
          content: { application/json: { schema: { $ref: "#/components/schemas/TontineCreationTemplate" } } }
        "400":
          description: Subkind manquant.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines:
    get:
      tags: [Tontines]
      summary: Lister les tontines de l'utilisateur
      operationId: listTontines
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Tontines dont l'utilisateur est membre actif.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tontines:
                    type: array
                    items: { $ref: "#/components/schemas/TontineSummary" }
        "401":
          description: access_token manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
    post:
      tags: [Tontines]
      summary: Créer une tontine
      operationId: createTontine
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTontineRequest" }
      responses:
        "201":
          description: Tontine créée.
          content: { application/json: { schema: { $ref: "#/components/schemas/TontineSummary" } } }
        "400":
          description: Body invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Un membre invité (par tag) est introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "422":
          description: Aucun wallet pour l'utilisateur ou création échouée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}:
    get:
      tags: [Tontines]
      summary: Détail d'une tontine (members + round + stats)
      operationId: getTontine
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Détail.
          content: { application/json: { schema: { $ref: "#/components/schemas/TontineSummary" } } }
        "404":
          description: Tontine introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/contribute:
    post:
      tags: [Tontines]
      summary: Cotiser au cycle en cours
      description: |
        Si `amount` est omis, paye le solde restant dû pour le cycle.
        Si la cotisation complète le round, `round_complete=true`.
      operationId: contributeToTontine
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContributeToTontineRequest" }
      responses:
        "200":
          description: Cotisation enregistrée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ContributeToTontineResponse" } } }
        "400":
          description: idempotency_key manquant.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "403":
          description: Pas membre de cette tontine.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tontine introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Déjà cotisé, pas de cycle ouvert, ou tontine fermée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "422":
          description: Paiement partiel désactivé pour cette tontine.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/bid:
    post:
      tags: [Tontines]
      summary: Placer une enchère (tontines de type `encheres`)
      description: |
        Uniquement pour les tontines `subkind=encheres`. L'enchère doit
        être strictement supérieure à la précédente, et au moins égale
        au montant du cycle.
      operationId: placeBid
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlaceBidRequest" }
      responses:
        "200":
          description: Enchère acceptée.
          content: { application/json: { schema: { $ref: "#/components/schemas/PlaceBidResponse" } } }
        "400":
          description: Montant invalide ou pas une tontine d'enchères.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "403":
          description: Pas membre.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tontine introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Tontine fermée, fenêtre d'enchère close, ou enchère pas supérieure.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "422":
          description: Enchère trop basse ou pas assez de membres actifs.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/contributions:
    get:
      tags: [Tontines]
      summary: Lister les contributions d'une tontine
      operationId: listTontineContributions
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 100 } }
      responses:
        "200":
          description: Liste de contributions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/TontineContribution" }

  /v1/tontines/{id}/remind:
    post:
      tags: [Tontines]
      summary: Envoyer un rappel aux membres non-cotisants (admin)
      operationId: remindTontine
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Rappels envoyés.
          content:
            application/json:
              schema:
                type: object
                properties: { reminded: { type: boolean } }
        "403":
          description: Pas admin de cette tontine.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/members:
    post:
      tags: [Tontines]
      summary: Inviter un utilisateur (admin only)
      description: |
        Crée une invitation en attente pour le membre identifié par
        `tag`. Le destinataire la verra dans `GET /v1/invitations` et
        peut l'accepter ou décliner.
      operationId: inviteMember
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InviteToTontineRequest" }
      responses:
        "201":
          description: Invitation créée.
          content:
            application/json:
              schema:
                type: object
                properties:
                  member: { $ref: "#/components/schemas/MemberResponse" }
        "403":
          description: Pas admin.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tontine introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Déjà membre ou déjà invité.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                alreadyMember:  { value: { code: already_member,  message: "Cet utilisateur est déjà membre." } }
                alreadyInvited: { value: { code: already_invited, message: "Cet utilisateur a déjà une invitation en attente." } }

  /v1/tontines/{id}/members/{userId}:
    delete:
      tags: [Tontines]
      summary: Retirer un membre (admin only)
      operationId: removeTontineMember
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id,     in: path, required: true, schema: { type: string, format: uuid } }
        - { name: userId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Membre retiré.
          content:
            application/json:
              schema:
                type: object
                properties: { removed: { type: boolean } }
        "403":
          description: Pas admin.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tontine ou membre introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/archive:
    post:
      tags: [Tontines]
      summary: Archiver une tontine (admin only)
      operationId: archiveTontine
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Tontine archivée.
          content:
            application/json:
              schema:
                type: object
                properties: { archived: { type: boolean } }
        "400":
          description: Pas une tontine.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "403":
          description: Pas admin.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Déjà archivée ou clôturée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/advance-round:
    post:
      tags: [Tontines]
      summary: Forcer l'avancée du cycle (admin only)
      description: |
        Clôt le round en cours et ouvre le suivant. Utile pour rattraper
        un retard ou skipper un membre. Réservé à l'admin.
      operationId: advanceRound
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Cycle avancé — renvoie le summary mis à jour.
          content: { application/json: { schema: { $ref: "#/components/schemas/TontineSummary" } } }
        "403":
          description: Pas admin.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tontine introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Pas de round ouvert ou tontine fermée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/rules:
    put:
      tags: [Tontines]
      summary: Mettre à jour le règlement (admin only)
      operationId: updateTontineRules
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rules]
              properties:
                rules:
                  type: array
                  description: Tableau de clauses (JSON brut — voir TontineRuleClause).
                  items: { $ref: "#/components/schemas/TontineRuleClause" }
      responses:
        "200":
          description: Règles mises à jour.
          content:
            application/json:
              schema:
                type: object
                properties: { updated: { type: boolean } }
        "403":
          description: Pas admin.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tontine introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Tontine non active.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/beneficiaries-per-round:
    put:
      tags: [Tontines]
      summary: Régler le nombre de bénéficiaires par round (admin only)
      description: |
        Pour les tontines rotatives à plusieurs bénéficiaires par round
        (ex: 2 personnes touchent à chaque cycle). 1 par défaut.
      operationId: updateBeneficiariesPerRound
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [beneficiaries_per_round]
              properties:
                beneficiaries_per_round: { type: integer, minimum: 1, example: 2 }
      responses:
        "200":
          description: Paramètre mis à jour.
          content:
            application/json:
              schema:
                type: object
                properties: { updated: { type: boolean } }
        "403":
          description: Pas admin.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Tontine introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Tontine non active.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Invitations (tontines)
  # ─────────────────────────────────────────────────────────────────
  /v1/invitations:
    get:
      tags: [Invitations]
      summary: Lister les invitations en attente pour l'utilisateur
      operationId: listInvitations
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Invitations en attente.
          content:
            application/json:
              schema:
                type: object
                properties:
                  invitations:
                    type: array
                    items: { $ref: "#/components/schemas/PotInvitation" }

  /v1/tontines/{id}/invitations/accept:
    post:
      tags: [Invitations]
      summary: Accepter une invitation à rejoindre une tontine
      description: |
        L'id de path est l'id de la TONTINE (pas d'invitation id —
        l'unicité `(pot_id, user_id, pending)` suffit côté backend).
      operationId: acceptInvitation
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Invitation acceptée.
          content:
            application/json:
              schema:
                type: object
                properties: { accepted: { type: boolean } }
        "404":
          description: Invitation introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/tontines/{id}/invitations/decline:
    post:
      tags: [Invitations]
      summary: Décliner une invitation à rejoindre une tontine
      operationId: declineInvitation
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Invitation déclinée.
          content:
            application/json:
              schema:
                type: object
                properties: { declined: { type: boolean } }
        "404":
          description: Invitation introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Cagnottes
  # ─────────────────────────────────────────────────────────────────
  /v1/cagnottes:
    get:
      tags: [Cagnottes]
      summary: Lister les cagnottes de l'utilisateur
      operationId: listCagnottes
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Liste.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cagnottes:
                    type: array
                    items: { $ref: "#/components/schemas/CagnotteSummary" }
    post:
      tags: [Cagnottes]
      summary: Créer une cagnotte
      operationId: createCagnotte
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCagnotteRequest" }
      responses:
        "201":
          description: Cagnotte créée.
          content: { application/json: { schema: { $ref: "#/components/schemas/CagnotteSummary" } } }
        "400":
          description: Body invalide ou deadline mal formatée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Tag déjà pris.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: tag_taken, message: "Ce tag de cagnotte est déjà utilisé." }
        "422":
          description: Aucun wallet pour l'utilisateur.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/cagnottes/{id}:
    get:
      tags: [Cagnottes]
      summary: Détail d'une cagnotte
      operationId: getCagnotte
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Détail.
          content: { application/json: { schema: { $ref: "#/components/schemas/CagnotteSummary" } } }
        "404":
          description: Cagnotte introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/cagnottes/by-tag/{tag}:
    get:
      tags: [Cagnottes]
      summary: Trouver une cagnotte par son tag public
      description: |
        Le `{tag}` du path est sans `#`. Le backend préfixe automatiquement.
      operationId: getCagnotteByTag
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: tag, in: path, required: true, schema: { type: string, example: "didib-medical" } }
      responses:
        "200":
          description: Cagnotte trouvée.
          content: { application/json: { schema: { $ref: "#/components/schemas/CagnotteSummary" } } }
        "404":
          description: Cagnotte introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/cagnottes/{id}/contribute:
    post:
      tags: [Cagnottes]
      summary: Contribuer à une cagnotte
      operationId: contributeToCagnotte
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContributeToCagnotteRequest" }
      responses:
        "200":
          description: Contribution enregistrée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ContributeToCagnotteResponse" } } }
        "400":
          description: amount ou idempotency_key invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Cagnotte introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Cagnotte clôturée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/cagnottes/{id}/contributions:
    get:
      tags: [Cagnottes]
      summary: Lister les contributions d'une cagnotte
      operationId: listCagnotteContributions
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Liste de contributions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CagnotteContribution" }
        "404":
          description: Cagnotte introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/cagnottes/{id}/close:
    post:
      tags: [Cagnottes]
      summary: Clôturer et reverser au créateur
      operationId: closeCagnotte
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Cagnotte clôturée.
          content:
            application/json:
              schema:
                type: object
                properties: { closed: { type: boolean } }
        "403":
          description: Pas le créateur.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "404":
          description: Cagnotte introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Déjà clôturée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Admin (dev/staging only)
  # ─────────────────────────────────────────────────────────────────
  /v1/admin/credit:
    post:
      tags: [Admin]
      summary: Créditer le wallet d'un utilisateur (fixture / test)
      description: |
        **Réservé ops / dev.** Mounté UNIQUEMENT si la variable
        d'environnement `SWITAG_ADMIN_TOKEN` est définie. Auth par
        header `X-Admin-Token` en comparaison constant-time.

        Use case : seeder des soldes pour QA, démos, fixtures front,
        sans monter les rails Mobile Money. Crée un `transaction`
        `kind=adjustment` et publie `balance.changed` + `transaction.created`
        sur le flux SSE du destinataire.
      operationId: adminCredit
      security: [{ AdminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminCreditRequest" }
      responses:
        "200":
          description: Crédit appliqué.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdminCreditResponse" } } }
        "400":
          description: Amount invalide ou idempotency_key manquant.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "401":
          description: X-Admin-Token manquant ou faux.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { code: admin_forbidden, message: "Token administrateur invalide." }
        "404":
          description: Tag utilisateur inconnu.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Utilisateur inactif ou wallet non provisionné.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  # ─────────────────────────────────────────────────────────────────
  # Switag Business (B2B / marchands) — /v1/biz/*
  # ─────────────────────────────────────────────────────────────────
  /v1/biz/auth/signup:
    post:
      tags: [Switag Business]
      summary: Créer un compte marchand (self-service)
      description: |
        Crée un marchand (mode TEST jusqu'à validation KYB), son compte de
        reversement et l'identité propriétaire. Renvoie un JWT de session
        marchand pour le tableau de bord.
      operationId: bizSignup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, business_name]
              properties:
                email:         { type: string, format: email }
                password:      { type: string, minLength: 8 }
                full_name:     { type: string, example: "Eloge YAO" }
                business_name: { type: string, example: "Boutique Eloge" }
                country:       { type: string, minLength: 2, maxLength: 2, example: "CI" }
      responses:
        "200":
          description: Compte créé.
          content: { application/json: { schema: { $ref: "#/components/schemas/BizAuthResponse" } } }
        "409":
          description: Email déjà utilisé.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/auth/login:
    post:
      tags: [Switag Business]
      summary: Connexion marchand
      operationId: bizLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:    { type: string, format: email }
                password: { type: string }
      responses:
        "200":
          description: Connecté.
          content: { application/json: { schema: { $ref: "#/components/schemas/BizAuthResponse" } } }
        "401":
          description: Identifiants invalides.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/me:
    get:
      tags: [Switag Business]
      summary: Profil marchand + solde à reverser
      operationId: bizMe
      security: [{ MerchantSession: [] }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { type: object } } }

  /v1/biz/keys:
    get:
      tags: [Switag Business]
      summary: Lister les clés API
      operationId: bizListKeys
      security: [{ MerchantSession: [] }]
      responses:
        "200":
          description: Liste (sans les secrets).
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/BizApiKey" } } } } } }
    post:
      tags: [Switag Business]
      summary: Créer une clé API
      description: Le **secret** n'est renvoyé qu'à cette création — à conserver immédiatement.
      operationId: bizCreateKey
      security: [{ MerchantSession: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                environment: { type: string, enum: [test, live], default: test }
                label:       { type: string }
      responses:
        "201":
          description: Clé créée (avec le secret en clair).
          content: { application/json: { schema: { $ref: "#/components/schemas/BizCreatedKey" } } }

  /v1/biz/keys/{id}/revoke:
    post:
      tags: [Switag Business]
      summary: Révoquer une clé API
      operationId: bizRevokeKey
      security: [{ MerchantSession: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Révoquée. }

  /v1/biz/ping:
    get:
      tags: [Switag Business]
      summary: Vérifier l'authentification serveur (clé API)
      description: |
        Renvoie le marchand + l'environnement résolus à partir des en-têtes
        `X-Api-Key` + `X-Api-Secret`. Le plus simple « mon intégration
        marche-t-elle ? ».
      operationId: bizPing
      security: [{ ApiKeyId: [], ApiKeySecret: [] }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, merchant_id: { type: string }, environment: { type: string } } } } }
        "401":
          description: Clé/secret manquant ou invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/payment-links:
    post:
      tags: [Switag Business]
      summary: Créer un paiement depuis le tableau de bord (session)
      description: |
        Crée une charge / lien de paiement sans clé API (auth session marchand).
        `environment` au choix (défaut `test`) ; `live` exige un compte validé.
        Mêmes champs que la création par clé API (amount, reference, description,
        customer_tag, redirect_url, webhook_url).
      operationId: bizCreatePaymentLink
      security: [{ MerchantSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                environment:  { type: string, enum: [test, live], default: test }
                amount:       { type: integer, format: int64, minimum: 1, example: 5000 }
                reference:    { type: string }
                description:  { type: string }
                customer_tag: { type: string, example: "@eloge_y" }
                redirect_url: { type: string, format: uri }
      responses:
        "201":
          description: Charge créée.
          content: { application/json: { schema: { $ref: "#/components/schemas/BizCreatedCharge" } } }
        "400":
          description: Montant/devise/URL/tag invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "403":
          description: "`live_not_enabled` — compte non validé pour le mode réel."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/charges:
    get:
      tags: [Switag Business]
      summary: Lister les charges (tableau de bord)
      operationId: bizListCharges
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      parameters:
        - { name: env, in: query, schema: { type: string, enum: [test, live] }, description: "Filtre par environnement." }
        - { name: state, in: query, schema: { type: string, enum: [pending, paid, expired, cancelled, refunded] }, description: "Filtre par statut (l'expiration paresseuse est appliquée)." }
        - { name: q, in: query, schema: { type: string }, description: "Recherche sur la référence ou le token." }
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/BizCharge" } } } } } }
    post:
      tags: [Switag Business]
      summary: Créer une charge (encaissement par API)
      description: |
        Crée une demande de paiement. Renvoie un `token`, l'URL de la page de
        paiement hébergée (`checkout_url`) et un deep link mobile. Devise =
        celle du compte de reversement par défaut.
      operationId: bizCreateCharge
      security: [{ ApiKeyId: [], ApiKeySecret: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:      { type: integer, format: int64, minimum: 1, example: 5000 }
                currency:    { type: string, example: "XOF" }
                reference:   { type: string, example: "ORDER-123" }
                description: { type: string, example: "Commande #123" }
                webhook_url: { type: string, format: uri, description: "Override du webhook par défaut pour cette charge." }
                redirect_url: { type: string, format: uri, description: "URL de retour où renvoyer le client après un paiement réussi (style success_url). On y ajoute ?switag_charge=<token>&status=paid. Optionnel — vide = écran de succès Switag." }
                customer_tag: { type: string, example: "@eloge_y", description: "Cible un client précis : seul ce @tag peut payer ; la page pré-remplit/verrouille le champ. 400 invalid_customer_tag si le tag n'existe pas. Optionnel." }
      responses:
        "201":
          description: Charge créée.
          content: { application/json: { schema: { $ref: "#/components/schemas/BizCreatedCharge" } } }
        "400":
          description: Montant invalide ou devise incohérente.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/charges/{token}/resend-webhook:
    post:
      tags: [Switag Business]
      summary: Renvoyer le webhook charge.succeeded (clé API)
      operationId: bizResendWebhook
      security: [{ ApiKeyId: [], ApiKeySecret: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Renvoyé.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, token: { type: string } } } } }
        "404": { description: Charge introuvable. }
        "409": { description: Charge non payée. }
        "422": { description: Aucune URL de webhook. }
        "502": { description: Échec de livraison. }

  /v1/biz/charges/{token}/refund:
    post:
      tags: [Switag Business]
      summary: Rembourser une charge (total, non reversée)
      description: Rembourse intégralement une charge payée et non encore reversée → `refunded`.
      operationId: bizRefundCharge
      security: [{ ApiKeyId: [], ApiKeySecret: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Remboursée.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, token: { type: string }, state: { type: string, example: "refunded" }, refund_transaction_id: { type: string }, amount: { type: integer, format: int64 }, currency: { type: string } } } } }
        "404":
          description: Charge introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Non remboursable (non payée / déjà remboursée / déjà reversée).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/charges/{token}/cancel:
    post:
      tags: [Switag Business]
      summary: Annuler une charge (clé API)
      description: Annule une charge encore `pending` → `cancelled`. 409 si déjà payée/expirée/annulée.
      operationId: bizCancelCharge
      security: [{ ApiKeyId: [], ApiKeySecret: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Annulée.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, token: { type: string }, state: { type: string, example: "cancelled" } } } } }
        "404":
          description: Charge introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Charge non annulable (plus pending).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/checkout/{token}:
    get:
      tags: [Switag Business]
      summary: Vue publique d'une charge (page de paiement)
      description: Aucune authentification — le token EST la capacité.
      operationId: bizCheckout
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/BizCheckout" } } }
        "404":
          description: Charge introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/checkout/{token}/status:
    get:
      tags: [Switag Business]
      summary: Statut d'une charge (poll de la page)
      operationId: bizCheckoutStatus
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { type: object, properties: { state: { type: string, enum: [pending, paid, expired, cancelled, refunded] } } } } }

  /v1/biz/charges/{token}/app-pay:
    post:
      tags: [Switag Business]
      summary: Payer une charge depuis l'app (après scan du QR)
      description: |
        L'utilisateur Switag (authentifié par son access token) règle une
        charge qu'il a **scannée**. Le QR encode `switag://charge/<token>?w=<window>&s=<sig>` ;
        l'app extrait `token`, `w`, `s` et les renvoie ici. La signature est
        re-vérifiée (anti-rejeu d'un QR périmé), puis le paiement est réglé.
      operationId: bizAppPay
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [window, signature]
              properties:
                window:    { type: integer, format: int64, description: "Paramètre w du QR." }
                signature: { type: string, description: "Paramètre s du QR." }
      responses:
        "200":
          description: Paiement réglé.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, transaction_id: { type: string }, amount: { type: integer }, currency: { type: string } } } } }
        "401":
          description: QR périmé/invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "409":
          description: Charge déjà réglée ou expirée.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/checkout/{token}/web/start:
    post:
      tags: [Switag Business]
      summary: Fallback web — envoyer l'OTP au payeur
      description: Résout le `@tag`, envoie un OTP par SMS au numéro du compte.
      operationId: bizWebStart
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [tag], properties: { tag: { type: string, example: "@eloge_y" } } } } }
      responses:
        "200": { description: Code envoyé. }
        "404":
          description: Tag ou charge introuvable.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/checkout/{token}/web/pay:
    post:
      tags: [Switag Business]
      summary: Fallback web — payer (OTP + PIN)
      description: |
        Vérifie l'OTP puis le PIN du compte (sans appareil), puis règle la
        charge. Erreurs alignées sur les transferts : `invalid_otp`,
        `invalid_pin`, `pin_locked`, `challenge_required`, `tx_blocked`,
        `edd_required`, `kyc_cap_exceeded`, `payment_failed`.
      operationId: bizWebPay
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tag, otp, pin]
              properties:
                tag: { type: string, example: "@eloge_y" }
                otp: { type: string, example: "12345" }
                pin: { type: string, example: "1234" }
      responses:
        "200":
          description: Paiement réussi.
          content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, transaction_id: { type: string }, amount: { type: integer }, currency: { type: string } } } } }
        "401":
          description: OTP/PIN invalide.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }
        "422":
          description: Paiement impossible (solde, etc.).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/charges/{token}:
    get:
      tags: [Switag Business]
      summary: Détail d'une charge (vue marchand)
      operationId: bizChargeDetail
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  charge: { $ref: "#/components/schemas/BizChargeDetail" }
                  checkout_url: { type: string, format: uri }
        "404":
          description: Charge introuvable (ou n'appartenant pas au marchand).
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/overview:
    get:
      tags: [Switag Business]
      summary: Synthèse tableau de bord (agrégats + série 14 j)
      operationId: bizOverview
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/BizOverview" } } }

  # ─── Abonnements / mandats récurrents ────────────────────────────
  /v1/biz/mandates:
    get:
      tags: [Switag Business]
      summary: Lister les abonnements
      operationId: bizListMandates
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/BizMandate" } } } } } } }
    post:
      tags: [Switag Business]
      summary: Créer un mandat (clé API)
      operationId: bizCreateMandate
      security: [{ ApiKeyId: [], ApiKeySecret: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:       { type: integer, format: int64, minimum: 1 }
                interval:     { type: string, enum: [weekly, monthly, yearly], default: monthly }
                currency:     { type: string, example: "XOF" }
                reference:    { type: string }
                description:  { type: string }
                customer_tag: { type: string }
                webhook_url:  { type: string, format: uri }
                max_cycles:   { type: integer }
      responses:
        "201": { description: Mandat créé, content: { application/json: { schema: { $ref: "#/components/schemas/BizCreatedMandate" } } } }

  /v1/biz/mandate-links:
    post:
      tags: [Switag Business]
      summary: Créer un mandat depuis le dashboard (session)
      operationId: bizCreateMandateLink
      security: [{ MerchantSession: [] }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [amount], properties: { environment: { type: string, enum: [test, live], default: test }, amount: { type: integer, format: int64 }, interval: { type: string, enum: [weekly, monthly, yearly] }, reference: { type: string }, description: { type: string }, customer_tag: { type: string }, max_cycles: { type: integer } } } } }
      responses:
        "201": { description: Mandat créé, content: { application/json: { schema: { $ref: "#/components/schemas/BizCreatedMandate" } } } }

  /v1/biz/mandates/{token}:
    get:
      tags: [Switag Business]
      summary: Détail d'un mandat + cycles
      operationId: bizMandateDetail
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { mandate: { $ref: "#/components/schemas/BizMandate" }, payments: { type: array, items: { type: object } } } } } } }
        "404": { description: Introuvable. }

  /v1/biz/mandates/{token}/change:
    post:
      tags: [Switag Business]
      summary: Changer l'offre (montant/fréquence)
      operationId: bizChangeMandate
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { amount: { type: integer, format: int64 }, interval: { type: string, enum: [weekly, monthly, yearly] } } } } } }
      responses: { "200": { description: Modifié. }, "404": { description: Introuvable. }, "409": { description: Non modifiable. } }

  /v1/biz/mandates/{token}/pause:
    post:
      tags: [Switag Business]
      summary: Suspendre un mandat
      operationId: bizPauseMandate
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Suspendu. }, "409": { description: Non actif. } }

  /v1/biz/mandates/{token}/resume:
    post:
      tags: [Switag Business]
      summary: Reprendre un mandat
      operationId: bizResumeMandate
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Repris. }, "409": { description: Non en pause. } }

  /v1/biz/mandates/{token}/cancel:
    post:
      tags: [Switag Business]
      summary: Annuler un mandat
      operationId: bizCancelMandate
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Annulé. }, "404": { description: Introuvable. }, "409": { description: Non annulable. } }

  /v1/biz/subscriptions/{token}:
    get:
      tags: [Switag Business]
      summary: Vue publique d'un mandat (page d'autorisation)
      operationId: bizSubscriptionView
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: OK }, "404": { description: Introuvable. } }

  /v1/biz/subscriptions/{token}/authorize/start:
    post:
      tags: [Switag Business]
      summary: Autorisation web — envoyer l'OTP
      operationId: bizSubAuthorizeStart
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [tag], properties: { tag: { type: string } } } } } }
      responses: { "200": { description: Code envoyé. } }

  /v1/biz/subscriptions/{token}/authorize/confirm:
    post:
      tags: [Switag Business]
      summary: Autorisation web — OTP + PIN, débit cycle 1, activation
      operationId: bizSubAuthorizeConfirm
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [tag, otp, pin], properties: { tag: { type: string }, otp: { type: string }, pin: { type: string } } } } } }
      responses: { "200": { description: Activé. }, "401": { description: OTP/PIN invalide. } }

  /v1/biz/subscriptions/{token}/app-authorize:
    post:
      tags: [Switag Business]
      summary: Autorisation in-app (access token)
      operationId: bizSubAppAuthorize
      security: [{ BearerAuth: [] }]
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Activé. }, "403": { description: wrong_customer. }, "409": { description: Non autorisable. } }

  /v1/biz/me/subscriptions:
    get:
      tags: [Switag Business]
      summary: Mes abonnements (client, access token)
      operationId: bizMeSubscriptions
      security: [{ BearerAuth: [] }]
      responses: { "200": { description: OK } }

  /v1/biz/subscriptions/{token}/customer-cancel:
    post:
      tags: [Switag Business]
      summary: Annuler mon abonnement (client, access token)
      operationId: bizCustomerCancelMandate
      security: [{ BearerAuth: [] }]
      parameters: [{ name: token, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Annulé. }, "404": { description: Introuvable. } }

  /v1/biz/settlements:
    get:
      tags: [Switag Business]
      summary: Lister les reversements (T+N)
      operationId: bizListSettlements
      security: [{ MerchantSession: [] }, { ApiKeyId: [], ApiKeySecret: [] }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: "#/components/schemas/BizSettlement" } } } } } }

  /v1/biz/webhook:
    get:
      tags: [Switag Business]
      summary: Configuration webhook (URL + secret)
      operationId: bizGetWebhook
      security: [{ MerchantSession: [] }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { type: object, properties: { url: { type: string }, secret: { type: string } } } } }
    put:
      tags: [Switag Business]
      summary: Définir l'URL du webhook (HTTPS)
      operationId: bizSetWebhook
      security: [{ MerchantSession: [] }]
      requestBody:
        content: { application/json: { schema: { type: object, properties: { url: { type: string, format: uri } } } } }
      responses:
        "200": { description: Enregistré. }
        "400":
          description: URL non-HTTPS.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } }

  /v1/biz/webhook/rotate-secret:
    post:
      tags: [Switag Business]
      summary: Régénérer le secret de signature
      operationId: bizRotateWebhookSecret
      security: [{ MerchantSession: [] }]
      responses:
        "200":
          description: Nouveau secret.
          content: { application/json: { schema: { type: object, properties: { secret: { type: string } } } } }

components:
  # ─────────────────────────────────────────────────────────────────
  # Security schemes
  # ─────────────────────────────────────────────────────────────────
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT long-vivant `typ=access` (~1 h) émis par `/v1/auth/pin/set`
        ou `/v1/auth/pin/verify`. Lié à une `device_uid` — la révocation
        de l'appareil invalide le token (401 `device_revoked`).
    SignupToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT court-vivant `typ=signup` (~10 min) renvoyé par
        `/v1/auth/phone/verify` (purpose=signup) ou `/v1/auth/firebase/exchange`.
        Sert UNIQUEMENT aux endpoints identity/tag/pin du provisioning.
    LoginToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT court-vivant `typ=login` (~5 min) renvoyé par
        `/v1/auth/phone/verify` (purpose=login). Sert UNIQUEMENT à
        appeler `/v1/auth/pin/verify`.
    AgentCode:
      type: apiKey
      in: header
      name: X-Agent-Code
      description: |
        Code court d'un agent agréé. **Auth stubbed** pour le moment —
        sera remplacée par le flow agent complet (phone+PIN+device).
    AdminToken:
      type: apiKey
      in: header
      name: X-Admin-Token
      description: |
        Secret partagé (env `SWITAG_ADMIN_TOKEN`). Comparaison
        constant-time. Routes admin non mountées si la variable est vide.
    MerchantSession:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        **Switag Business** — JWT de session marchand (~12 h) renvoyé par
        `/v1/biz/auth/login` ou `/v1/biz/auth/signup`. Sert au tableau de
        bord marchand (clés, charges, reversements, webhooks).
    ApiKeyId:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        **Switag Business** — identifiant public de clé API (`pk_live_…` /
        `pk_test_…`). Accompagné de `X-Api-Secret` pour authentifier un
        serveur tiers (encaissement par API).
    ApiKeySecret:
      type: apiKey
      in: header
      name: X-Api-Secret
      description: |
        **Switag Business** — secret de la clé API (`sk_live_…` / `sk_test_…`),
        montré une seule fois à la création. Transmis sur TLS, vérifié en
        temps constant. À combiner avec `X-Api-Key`.

  # ─────────────────────────────────────────────────────────────────
  # Schemas
  # ─────────────────────────────────────────────────────────────────
  schemas:
    # ─── Error envelope ─────────────────────────────────────────────
    ErrorBody:
      type: object
      description: Enveloppe d'erreur uniforme renvoyée par toute réponse non-2xx.
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            Machine-readable, stable entre releases. À utiliser dans les
            switch côté client pour piloter l'UX (dialog spécifique, etc).
          example: invalid_phone
        message:
          type: string
          description: |
            Texte destiné à l'utilisateur final, safe à afficher tel quel
            (toast, inline error). **Localisé** selon le header
            `Accept-Language` de la requête : français par défaut,
            anglais si le client envoie `Accept-Language: en`. Le `code`
            ci-dessus reste identique quelle que soit la langue.
          example: "Numéro de téléphone invalide."

    # Backward-compat alias — anciens specs réfèrent `Error`.
    Error:
      $ref: "#/components/schemas/ErrorBody"

    # ─── Auth flow ─────────────────────────────────────────────────
    OtpPurpose:
      type: string
      enum: [auto, signup, login]
      description: |
        - `auto` (défaut) : laisse le serveur détecter (compte actif ?
          → login, sinon → signup). Le purpose résolu revient dans
          `StartPhoneResponse.purpose`.
        - `signup` / `login` : explicite.

    UserState:
      type: string
      enum: [phone_pending, identity_pending, tag_pending, pin_pending, active]
      description: |
        État dans la machine d'onboarding. Le mobile saute directement à
        l'étape correspondante quand il reprend un signup interrompu.

    StartPhoneRequest:
      type: object
      required: [phone]
      properties:
        phone:
          type: string
          description: |
            Forme internationale ou locale. Le serveur normalise et
            valide via libphonenumber.
          example: "+225 0707076277"
        purpose:
          allOf: [{ $ref: "#/components/schemas/OtpPurpose" }]
          default: auto
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          description: |
            ISO-3166-1 alpha-2. Optionnel — dérivé du préfixe quand omis.
            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, example: 6 }
        expires_in_seconds: { type: integer, example: 300 }
        resend_in_seconds: { type: integer, example: 45 }
        purpose:
          type: string
          enum: [signup, login]
          description: Résolu côté serveur (utile quand le client envoie `auto`).
          example: signup

    VerifyPhoneRequest:
      type: object
      required: [phone, code, purpose]
      properties:
        phone:   { type: string, example: "+225 0707076277" }
        code:    { type: string, example: "617092" }
        purpose: { $ref: "#/components/schemas/OtpPurpose" }

    VerifyPhoneResponse:
      type: object
      required: [expires_in_seconds, state]
      properties:
        signup_token:
          type: string
          description: Présent ssi `purpose=signup`.
        login_token:
          type: string
          description: Présent ssi `purpose=login`.
        expires_in_seconds: { type: integer, example: 600 }
        state: { $ref: "#/components/schemas/UserState" }

    ExchangeFirebaseRequest:
      type: object
      required: [firebase_id_token]
      properties:
        firebase_id_token:
          type: string
          description: ID token Firebase fraîchement issu du Phone Sign-In.
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          example: "CI"

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

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

    SetTagRequest:
      type: object
      required: [tag]
      properties:
        tag:
          type: string
          minLength: 3
          maxLength: 20
          pattern: "^[a-z0-9_-]+$"
          example: "eloge_y"

    SetTagResponse:
      type: object
      required: [tag, state]
      properties:
        tag:   { type: string, example: "@eloge_y" }
        state: { $ref: "#/components/schemas/UserState" }

    DeviceInfo:
      type: object
      required: [uid, platform]
      description: |
        Identité de l'appareil envoyée au signup et au login. Le `uid`
        est un identifiant stable généré côté mobile (UUID v4 persisté).
      properties:
        uid:         { type: string, format: uuid }
        platform:    { type: string, enum: [android, ios] }
        model:       { type: string, example: "Pixel 7" }
        app_version: { type: string, example: "1.0.0" }

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

    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, à envoyer comme `Authorization: Bearer …`." }
        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 }

    VerifyPinRequest:
      type: object
      required: [pin, device]
      properties:
        pin:    { type: string, minLength: 4, maxLength: 6, example: "1234" }
        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 }
        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 }

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

    # ─── Me ─────────────────────────────────────────────────────────
    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 }
        last_name:          { type: string }
        state:              { $ref: "#/components/schemas/UserState" }
        country_code:       { type: string, minLength: 2, maxLength: 2 }
        single_device_mode: { type: boolean }

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

    PatchSecurityRequest:
      type: object
      properties:
        single_device_mode:
          type: boolean
          description: Présent uniquement si l'utilisateur change ce flag.

    Account:
      type: object
      required: [id, currency, scale, symbol, balance, state]
      properties:
        id:       { type: string, format: uuid }
        currency: { type: string, example: "XOF" }
        scale:    { type: integer, example: 0, description: "Nombre de décimales (0 pour XOF/XAF)." }
        symbol:   { type: string, example: "FCFA" }
        balance:  { type: integer, format: int64, description: "Entier dans l'unité minimale.", example: 12500 }
        state:    { type: string, enum: [active, frozen, closed] }

    UserLookup:
      type: object
      required: [tag, first_name, last_name]
      properties:
        tag:        { type: string, example: "@eloge_y" }
        first_name: { type: string, example: "Eloge" }
        last_name:  { type: string, example: "YAO" }

    Transaction:
      type: object
      required: [id, kind, state, currency, amount, fee, net_for_caller, created_at, has_refund]
      properties:
        id:                       { type: string, format: uuid }
        kind:                     { type: string, enum: [p2p_transfer, cash_in, refund, adjustment, contribution, payout, fee] }
        state:                    { type: string, enum: [pending, completed, failed] }
        currency:                 { type: string, example: "XOF" }
        amount:                   { type: integer, format: int64 }
        fee:                      { type: integer, format: int64 }
        net_for_caller:           { type: integer, format: int64, description: "Signé : >0 = reçu, <0 = envoyé." }
        counterparty_label:       { type: string, example: "@fatou" }
        counterparty_first_name:  { type: string }
        counterparty_last_name:   { type: string }
        created_at:               { type: string, format: date-time }
        completed_at:             { type: string, format: date-time, nullable: true }
        has_refund:               { type: boolean }
        balance_after:            { type: integer, format: int64, nullable: true }

    ListTransactionsResponse:
      type: object
      required: [transactions]
      properties:
        transactions:
          type: array
          items: { $ref: "#/components/schemas/Transaction" }
        next_before:
          type: string
          format: date-time
          description: Cursor à repasser comme `?before=` pour la page suivante. Absent si fin de liste.

    # ─── Devices ────────────────────────────────────────────────────
    Device:
      type: object
      required: [id, platform, created_at, last_seen_at, revoked, current]
      properties:
        id:            { type: string, format: uuid }
        platform:      { type: string, enum: [android, ios] }
        model:         { type: string }
        app_version:   { type: string }
        created_at:    { type: string, format: date-time }
        last_seen_at:  { type: string, format: date-time }
        revoked:       { type: boolean }
        revoke_reason: { type: string, example: "user_revoked_from_profile" }
        current:       { type: boolean, description: "True si c'est l'appareil de la requête courante." }

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

    PushTokenRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          maxLength: 4096
          description: Token FCM courant pour cet appareil.

    # ─── Transfers ─────────────────────────────────────────────────
    TransferRequest:
      type: object
      required: [recipient_tag, amount, idempotency_key]
      properties:
        recipient_tag:   { type: string, example: "@fatou" }
        amount:          { type: integer, format: int64, minimum: 1, example: 5000 }
        idempotency_key: { type: string, format: uuid, description: "UUID v4 généré côté client." }
        note:            { type: string, maxLength: 280, example: "Merci !" }

    TransferResponse:
      type: object
      required: [transaction, sender_balance_after]
      properties:
        transaction:           { $ref: "#/components/schemas/Transaction" }
        sender_balance_after:  { type: integer, format: int64 }

    RefundRequest:
      type: object
      required: [idempotency_key]
      properties:
        idempotency_key: { type: string, format: uuid }

    RefundResponse:
      type: object
      required: [transaction, balance_after]
      properties:
        transaction:   { $ref: "#/components/schemas/Transaction" }
        balance_after: { type: integer, format: int64 }

    # ─── Agent ─────────────────────────────────────────────────────
    AgentCashInRequest:
      type: object
      required: [user_tag, amount, idempotency_key]
      properties:
        user_tag:        { type: string, example: "@eloge_y" }
        amount:          { type: integer, format: int64, minimum: 1, example: 10000 }
        idempotency_key: { type: string, format: uuid }

    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 }
        user_balance_after:  { type: integer, format: int64 }
        user_tag:            { type: string }

    # ─── Tontines ──────────────────────────────────────────────────
    TontineSubKind:
      type: string
      enum: [rotative, encheres, accumulation]
      description: |
        - `rotative` — chacun son tour reçoit le pot.
        - `encheres` — celui qui prend le pot ce cycle paie une prime
          (système d'enchères).
        - `accumulation` — cagnotte fermée à terme, redistribuée.

    TontineRuleClause:
      type: object
      required: [id, enabled, title, description]
      properties:
        id:          { type: string, example: "late_fee" }
        enabled:     { type: boolean }
        title:       { type: string, example: "Pénalité de retard" }
        description: { type: string, example: "Pénalité après le délai de grâce." }
        params:
          type: object
          additionalProperties: { type: string }
          example: { grace_hours: "24", fee_amount: "500" }

    TontineCreationTemplate:
      type: object
      description: Template renvoyé par GET /v1/tontines/templates/{subkind}.
      properties:
        subkind:        { $ref: "#/components/schemas/TontineSubKind" }
        rule_templates:
          type: array
          items: { $ref: "#/components/schemas/TontineRuleClause" }
        extra_fields:
          type: array
          description: Champs additionnels à afficher dans le wizard selon le subkind.
          items:
            type: object
            properties:
              name:  { type: string }
              type:  { type: string, enum: [string, integer, boolean, duration_hours] }
              label: { type: string }

    CreateTontineRequest:
      type: object
      required: [name, amount_per_cycle, cycle_days, subkind]
      properties:
        name:                    { type: string, example: "Épargne des mamans" }
        subkind:                 { $ref: "#/components/schemas/TontineSubKind" }
        amount_per_cycle:        { type: integer, format: int64, minimum: 1, example: 5000 }
        cycle_days:              { type: integer, enum: [7, 14, 30] }
        purpose:                 { type: string, example: "Achat de pagne" }
        beneficiaries_per_round: { type: integer, minimum: 1, default: 1 }
        members:
          type: array
          description: Tags à inviter à la création (l'admin est ajouté automatiquement).
          items: { type: string }
          example: ["@fatou", "@amadou"]
        rules:
          type: array
          items: { $ref: "#/components/schemas/TontineRuleClause" }

    Round:
      type: object
      required: [id, round_num, beneficiary_tag, due_at, state]
      properties:
        id:               { type: string, format: uuid }
        round_num:        { type: integer }
        beneficiary_tag:  { type: string, example: "@fatou" }
        beneficiary_tags:
          type: array
          description: Pour les tontines à plusieurs bénéficiaires par round.
          items: { type: string }
        due_at:           { type: string, format: date-time }
        state:            { type: string, enum: [open, completed, skipped] }
        created_at:       { type: string, format: date-time }
        bid_deadline_at:
          type: string
          format: date-time
          description: Présent pour les tontines `encheres` — fin de la fenêtre d'enchères.
        bids:
          description: Pour les tontines `encheres` — payload des enchères (JSON brut).
          oneOf:
            - type: array
              items: { type: object }
            - type: object

    MemberResponse:
      type: object
      required: [user_id, tag, first_name, last_name, role, state]
      properties:
        user_id:                 { type: string, format: uuid }
        tag:                     { type: string, example: "@fatou" }
        first_name:              { type: string }
        last_name:               { type: string }
        role:                    { type: string, enum: [admin, member] }
        state:                   { type: string, enum: [active, pending] }
        turn_index:              { type: integer, nullable: true }
        contributed_this_round:  { type: integer, format: int64 }

    TontineSummary:
      type: object
      required: [id, name, subkind, currency, member_count, members, amount_per_cycle, cycle_days, state]
      properties:
        id:                          { type: string, format: uuid }
        name:                        { type: string }
        subkind:                     { $ref: "#/components/schemas/TontineSubKind" }
        currency:                    { type: string, example: "XOF" }
        member_count:                { type: integer, description: Membres actifs uniquement. }
        members:
          type: array
          items: { $ref: "#/components/schemas/MemberResponse" }
        amount_per_cycle:            { type: integer, format: int64 }
        cycle_days:                  { type: integer }
        purpose:                     { type: string }
        collected:                   { type: integer, format: int64, description: "Cumul total depuis le début." }
        goal:                        { type: integer, format: int64, description: "total_rounds × N × cotisation." }
        total_rounds:                { type: integer }
        completed_rounds:            { type: integer }
        cycle_collected:             { type: integer, format: int64, description: "Cotisations du round en cours." }
        cycle_goal:                  { type: integer, format: int64, description: "N × cotisation (par round)." }
        current_round:               { allOf: [{ $ref: "#/components/schemas/Round" }], nullable: true }
        my_paid_this_cycle:          { type: boolean }
        my_contribution_this_cycle:  { type: integer, format: int64 }
        my_role:                     { type: string, enum: [admin, member] }
        pending_invitations_count:   { type: integer }
        state:                       { type: string, enum: [active, completed, cancelled] }
        rules:
          type: array
          nullable: true
          items: { $ref: "#/components/schemas/TontineRuleClause" }
        created_at:                  { type: string, format: date-time }

    ContributeToTontineRequest:
      type: object
      required: [idempotency_key]
      properties:
        amount:
          type: integer
          format: int64
          description: Optionnel — omettre pour payer le solde restant dû.
        idempotency_key: { type: string, format: uuid }

    ContributeToTontineResponse:
      type: object
      required: [contributed, balance_after, round_complete]
      properties:
        contributed:    { type: boolean }
        balance_after:  { type: integer, format: int64, description: "Solde du wallet du pot après contribution." }
        round_complete: { type: boolean, description: "True si cette contribution a clos le round." }

    PlaceBidRequest:
      type: object
      required: [amount]
      properties:
        amount: { type: integer, format: int64, minimum: 1, example: 7500 }

    PlaceBidResponse:
      type: object
      properties:
        bid_placed:    { type: boolean }
        amount:        { type: integer, format: int64 }
        balance_after: { type: integer, format: int64 }

    TontineContribution:
      type: object
      properties:
        id:                       { type: string, format: uuid }
        amount:                   { type: integer, format: int64 }
        contributor_tag:          { type: string }
        contributor_first_name:   { type: string }
        contributor_last_name:    { type: string }
        message:                  { type: string, nullable: true }
        round_num:                { type: integer, nullable: true }
        beneficiary_tag:          { type: string }
        created_at:               { type: string, format: date-time }

    InviteToTontineRequest:
      type: object
      required: [tag]
      properties:
        tag: { type: string, example: "@fatou" }

    PotInvitation:
      type: object
      required: [pot_id, pot_name, pot_kind]
      properties:
        pot_id:    { type: string, format: uuid }
        pot_name:  { type: string }
        pot_kind:  { type: string, enum: [tontine, cagnotte] }
        purpose:   { type: string }
        rules:
          type: array
          nullable: true
          items: { $ref: "#/components/schemas/TontineRuleClause" }

    # ─── Cagnottes ─────────────────────────────────────────────────
    CreateCagnotteRequest:
      type: object
      required: [name, tag]
      properties:
        name:        { type: string, example: "Soutien à Didib" }
        tag:
          type: string
          description: Tag PUBLIC partageable (sans `#`, le backend l'ajoute).
          example: "didib-medical"
        category:    { type: string, example: "Santé" }
        goal:        { type: integer, format: int64, nullable: true, example: 100000 }
        deadline_at: { type: string, format: date-time, nullable: true }

    CagnotteSummary:
      type: object
      required: [id, tag, name, currency, raised, state, created_at]
      properties:
        id:             { type: string, format: uuid }
        tag:            { type: string, example: "#didib-medical" }
        name:           { type: string }
        organizer_tag:  { type: string, example: "@eloge_y" }
        organizer_name: { type: string }
        category:       { type: string }
        currency:       { type: string, example: "XOF" }
        raised:         { type: integer, format: int64 }
        goal:           { type: integer, format: int64, nullable: true }
        deadline_at:    { type: string, format: date-time, nullable: true }
        contributions:
          type: array
          items: { $ref: "#/components/schemas/CagnotteContribution" }
        state:          { type: string, enum: [active, completed, cancelled] }
        created_at:     { type: string, format: date-time }

    CagnotteContribution:
      type: object
      properties:
        id:                       { type: string, format: uuid }
        amount:                   { type: integer, format: int64 }
        contributor_tag:          { type: string }
        contributor_first_name:   { type: string }
        contributor_last_name:    { type: string }
        message:                  { type: string, nullable: true }
        created_at:               { type: string, format: date-time }

    ContributeToCagnotteRequest:
      type: object
      required: [amount, idempotency_key]
      properties:
        amount:          { type: integer, format: int64, minimum: 1 }
        idempotency_key: { type: string, format: uuid }
        message:         { type: string, maxLength: 280 }

    ContributeToCagnotteResponse:
      type: object
      required: [contributed, raised, currency]
      properties:
        contributed: { type: boolean }
        raised:      { type: integer, format: int64 }
        currency:    { type: string }

    # ─── Admin ─────────────────────────────────────────────────────
    AdminCreditRequest:
      type: object
      required: [user_tag, amount, idempotency_key]
      properties:
        user_tag:        { type: string, example: "@eloge_y" }
        amount:          { type: integer, format: int64, minimum: 1, example: 50000 }
        idempotency_key: { type: string, format: uuid }
        note:            { type: string, example: "QA fixture" }

    AdminCreditResponse:
      type: object
      required: [transaction_id, user_tag, amount, currency, user_balance_after]
      properties:
        transaction_id:      { type: string, format: uuid }
        user_tag:            { type: string, example: "@eloge_y" }
        amount:              { type: integer, format: int64 }
        currency:            { type: string, example: "XOF" }
        user_balance_after:  { type: integer, format: int64 }

    AcceptInvitationResponse:
      type: object
      properties:
        accepted: { type: boolean }

    # ─── Switag Business (B2B) ────────────────────────────────────
    BizAuthResponse:
      type: object
      required: [token, expires_in, merchant_id, email]
      properties:
        token:       { type: string, description: "JWT de session marchand (Authorization: Bearer)." }
        expires_in:  { type: integer, example: 43200 }
        merchant_id: { type: string, format: uuid }
        email:       { type: string }

    BizApiKey:
      type: object
      properties:
        id:           { type: string, format: uuid }
        public_id:    { type: string, example: "pk_live_3f9a…" }
        environment:  { type: string, enum: [test, live] }
        secret_hint:  { type: string, example: "sk_live_3f9a…" }
        scopes:       { type: array, items: { type: string } }
        label:        { type: string }
        state:        { type: string, enum: [active, revoked] }
        created_at:   { type: string, format: date-time }
        last_used_at: { type: string, format: date-time, nullable: true }

    BizCreatedKey:
      type: object
      description: Renvoyé UNE seule fois — le `secret` n'est plus jamais affiché.
      required: [id, public_id, secret, environment]
      properties:
        id:          { type: string, format: uuid }
        public_id:   { type: string, example: "pk_live_3f9a…" }
        secret:      { type: string, example: "sk_live_…" }
        environment: { type: string, enum: [test, live] }
        scopes:      { type: array, items: { type: string } }
        label:       { type: string }

    BizCharge:
      type: object
      properties:
        id:          { type: string, format: uuid }
        token:       { type: string }
        amount:      { type: integer, format: int64 }
        currency:    { type: string, example: "XOF" }
        state:       { type: string, enum: [pending, paid, expired, cancelled, refunded] }
        reference:   { type: string }
        environment: { type: string, enum: [test, live] }
        created_at:  { type: string, format: date-time }

    BizCreatedCharge:
      type: object
      required: [id, token, amount, currency, state, checkout_url]
      properties:
        id:           { type: string, format: uuid }
        token:        { type: string }
        amount:       { type: integer, format: int64 }
        currency:     { type: string, example: "XOF" }
        state:        { type: string, example: "pending" }
        reference:    { type: string }
        checkout_url: { type: string, format: uri, example: "https://pay.switag.com/c/AbC123…" }
        redirect_url: { type: string, format: uri, description: "URL de retour après paiement (telle qu'envoyée), vide si non définie." }
        customer_tag: { type: string, description: "Client ciblé (sans '@'), vide si charge ouverte." }
        expires_at:   { type: string, format: date-time }

    BizCheckout:
      type: object
      description: Vue publique d'une charge (page de paiement hébergée).
      properties:
        token:         { type: string }
        state:         { type: string, enum: [pending, paid, expired, cancelled, refunded] }
        amount:        { type: integer, format: int64 }
        currency:      { type: string, example: "XOF" }
        reference:     { type: string }
        description:   { type: string }
        merchant_name: { type: string, example: "Boutique Eloge" }
        redirect_url:  { type: string, format: uri, description: "URL de retour du marchand après paiement (vide si non définie)." }
        customer_tag:  { type: string, description: "Client ciblé (sans '@') ; la page pré-remplit/verrouille le champ. Vide = ouvert." }
        expires_at:    { type: string, format: date-time }

    BizChargeDetail:
      type: object
      description: Vue marchand d'une charge (livraison webhook, reversement, horodatages).
      properties:
        id:                   { type: string, format: uuid }
        token:                { type: string }
        environment:          { type: string, enum: [test, live] }
        amount:               { type: integer, format: int64 }
        currency:             { type: string, example: "XOF" }
        reference:            { type: string }
        description:          { type: string }
        state:                { type: string, enum: [pending, paid, expired, cancelled, refunded] }
        customer_tag:         { type: string }
        redirect_url:         { type: string }
        webhook_url:          { type: string }
        webhook_delivered_at: { type: string, format: date-time, nullable: true }
        webhook_attempts:     { type: integer }
        transaction_id:       { type: string, nullable: true }
        settlement_id:        { type: string, nullable: true }
        expires_at:           { type: string, format: date-time }
        paid_at:              { type: string, format: date-time, nullable: true }
        created_at:           { type: string, format: date-time }

    BizMandate:
      type: object
      description: Mandat d'abonnement récurrent.
      properties:
        id:           { type: string, format: uuid }
        token:        { type: string }
        amount:       { type: integer, format: int64 }
        currency:     { type: string, example: "XOF" }
        interval:     { type: string, enum: [weekly, monthly, yearly] }
        state:        { type: string, enum: [pending_auth, active, paused, cancelled, completed] }
        reference:    { type: string }
        customer_tag: { type: string }
        cycles_done:  { type: integer }
        environment:  { type: string, enum: [test, live] }
        next_run_at:  { type: string, format: date-time, nullable: true }
        created_at:   { type: string, format: date-time }

    BizCreatedMandate:
      type: object
      required: [id, token, amount, currency, interval, state, authorization_url]
      properties:
        id:                { type: string, format: uuid }
        token:             { type: string }
        amount:            { type: integer, format: int64 }
        currency:          { type: string, example: "XOF" }
        interval:          { type: string, enum: [weekly, monthly, yearly] }
        state:             { type: string, example: "pending_auth" }
        reference:         { type: string }
        customer_tag:      { type: string }
        authorization_url: { type: string, format: uri }
        created_at:        { type: string, format: date-time }

    BizOverview:
      type: object
      description: Agrégats des charges du marchand pour le tableau de bord.
      properties:
        currency:         { type: string, example: "XOF" }
        gross_volume:     { type: integer, format: int64, description: "Total encaissé (charges payées)." }
        volume_7d:        { type: integer, format: int64, description: "Encaissé sur les 7 derniers jours." }
        paid_count:       { type: integer, description: "Nombre de charges payées." }
        pending_count:    { type: integer, description: "Charges encore payables." }
        unsettled_amount: { type: integer, format: int64, description: "Payé non encore reversé." }
        series:
          type: array
          description: Volume payé par jour, 14 derniers jours.
          items:
            type: object
            properties:
              date:   { type: string, example: "2026-05-31" }
              amount: { type: integer, format: int64 }

    BizSettlement:
      type: object
      properties:
        id:                { type: string, format: uuid }
        settlement_date:   { type: string, format: date }
        period_start:      { type: string, format: date }
        period_end:        { type: string, format: date }
        gross_amount:      { type: integer, format: int64 }
        net_amount:        { type: integer, format: int64 }
        currency:          { type: string, example: "XOF" }
        transaction_count: { type: integer }
        state:             { type: string, enum: [pending, approved, paid, failed, disputed, reversed] }
        paid_at:           { type: string, format: date-time, nullable: true }

