{
  "openapi": "3.1.0",
  "info": {
    "title": "Ficha — API para juegos",
    "version": "1.0.0",
    "summary": "Cobra y paga en fichas desde tu juego.",
    "description": "Ficha es una billetera común para los juegos de un grupo de desarrolladores amigos. **Una ficha = S/ 1.**\n\n## Convenciones\n\n- **Montos siempre en céntimos enteros**: `150` = S/ 1.50. Nunca decimales.\n- Todas las respuestas son JSON. Los errores tienen siempre la forma `{ \"error\": { \"codigo\": \"...\", \"mensaje\": \"...\", ...extra } }`: compara `codigo` (estable) en tu programa y enseña `mensaje` (en español) si quieres. Algunos errores traen datos extra (`disponible_hoy`, `saldo`, `falta`, `movimiento_id`…).\n- **Secreto**: las rutas de cobros, pagos y consultas usan `Authorization: Bearer fsk_...`. El secreto va SOLO en el servidor de tu juego, nunca en el navegador ni en un ejecutable que repartas: con él se puede vaciar tu banca.\n- Las rutas de vinculación por código no llevan secreto y tienen CORS abierto, para llamarlas desde el propio juego.\n\n## Cobros y pagos\n\n- `cobro`: jugador → banca de tu juego. `pago`: banca → jugador. La banca no puede quedar en negativo: no se crean fichas de la nada.\n- **`referencia` es obligatoria e idempotente**: un id único tuyo por operación (`[A-Za-z0-9_.:-]`, hasta 100). Si reintentas con la misma referencia y los mismos datos, recibes la operación original (HTTP 200, `repetida: true`) y no se mueve dinero otra vez. Si la reutilizas con otro tipo, jugador o monto, recibes `409 referencia_repetida`. Reintenta siempre con la misma referencia ante errores de red o 5xx.\n- **Límite diario**: cada jugador pone a cada juego un límite de cobro (por defecto S/ 20, máximo S/ 1000). Cuenta lo cobrado en las **últimas 24 horas** (ventana móvil, no día calendario). Los pagos no tienen límite.\n- Si el jugador desconecta tu juego ya no le puedes cobrar (`autorizacion_revocada`), pero sí pagarle lo que le debías.\n\n## Vincular a un jugador\n\nAntes de cobrar, el jugador tiene que autorizar a tu juego. Obtienes su `id` numérico, que es lo que usas después.\n\n**Por código** (Unity, Godot, consola, bots…): `POST /api/v1/vinculaciones` devuelve un `codigo` corto («BCDF-GH23») que enseñas en pantalla junto con `url`; el jugador lo escribe en esa página. Consulta `GET /api/v1/vinculaciones/{codigo_dispositivo}` cada `intervalo` segundos hasta `aprobada`, `rechazada` o `vencida` (10 minutos). Importante: si el cliente hace esta consulta, tu servidor no debe creerse sin más el `id` que le mande el cliente; haz la consulta desde el servidor (o pásale el `codigo_dispositivo` y que la repita él).\n\n**Por redirección** (juegos web): manda al jugador a `{URL}/autorizar?juego=<slug>&redireccion=<URL registrada>&estado=<valor anti-CSRF>&limite=<céntimos>`. Al aprobar vuelve a `redireccion?codigo=fac_...&estado=...` (si rechaza, a `redireccion?error=rechazado&estado=...`); comprueba `estado` y canjea el código desde tu servidor con `POST /api/v1/token` (un solo uso, 5 minutos). La redirección tiene que estar registrada en el juego (https, o http://localhost / http://127.0.0.1 para probar) y coincidir exactamente.\n\n## SDKs\n\nJavaScript, Python, C#, Godot y Lua en el repositorio (`sdk/`). Todos reintentan solo ante errores de red o 5xx, reutilizando la misma referencia."
  },
  "servers": [
    {
      "url": "https://ficha.bezenti.com",
      "description": "Producción (dinero real)"
    },
    {
      "url": "https://ficha-pruebas.bezenti.com",
      "description": "Pruebas (fichas de mentira, sin recargas ni retiros)"
    }
  ],
  "tags": [
    {
      "name": "Vinculación",
      "description": "Conectar la cuenta de un jugador con tu juego."
    },
    {
      "name": "Operaciones",
      "description": "Cobros y pagos en céntimos. Requieren el secreto."
    },
    {
      "name": "Consultas",
      "description": "Jugadores, banca e historial. Requieren el secreto."
    },
    {
      "name": "General"
    }
  ],
  "paths": {
    "/api/v1": {
      "get": {
        "tags": [
          "General"
        ],
        "operationId": "indice",
        "summary": "Versión de la API y dónde está la documentación",
        "responses": {
          "200": {
            "description": "Datos de la API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Indice"
                },
                "example": {
                  "nombre": "Ficha",
                  "version": 1,
                  "documentacion": "/docs",
                  "openapi": "/openapi.json"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vinculaciones": {
      "post": {
        "tags": [
          "Vinculación"
        ],
        "operationId": "vincular",
        "summary": "Empezar una vinculación por código",
        "description": "Para juegos que no son web. No lleva secreto: se puede llamar desde el propio juego (CORS abierto). `limite_diario` es una sugerencia; el jugador elige el límite final al aprobar.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudVinculacion"
              },
              "example": {
                "juego": "reflejos",
                "limite_diario": 2000
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Vinculación creada. Enseña `codigo` y `url`; guarda `codigo_dispositivo` para consultar.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "Siempre `*`: se puede llamar desde el navegador o el dispositivo del jugador.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Vinculacion"
                },
                "example": {
                  "codigo": "BCDF-GH23",
                  "codigo_dispositivo": "fdc_m_zbz2hlgXpCKKg47CGdKalU-XGn6QJc",
                  "url": "https://ficha.bezenti.com/vincular",
                  "url_directa": "https://ficha.bezenti.com/vincular?codigo=BCDF-GH23",
                  "expira_en": 600,
                  "intervalo": 3
                }
              }
            }
          },
          "400": {
            "description": "Falta `juego` o `limite_diario` no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "juego_invalido": {
                    "value": {
                      "error": {
                        "codigo": "juego_invalido",
                        "mensaje": "Manda { \"juego\": \"<id público del juego>\" }."
                      }
                    }
                  },
                  "limite_invalido": {
                    "value": {
                      "error": {
                        "codigo": "limite_invalido",
                        "mensaje": "limite_diario debe ser un entero de céntimos."
                      }
                    }
                  },
                  "fuera_de_rango": {
                    "value": {
                      "error": {
                        "codigo": "limite_invalido",
                        "mensaje": "El límite diario debe estar entre S/ 0 y S/ 1,000.00."
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "El juego está suspendido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "juego_suspendido": {
                    "value": {
                      "error": {
                        "codigo": "juego_suspendido",
                        "mensaje": "Este juego está suspendido."
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No existe un juego con ese id público.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "juego_invalido": {
                    "value": {
                      "error": {
                        "codigo": "juego_invalido",
                        "mensaje": "No hay ningún juego «mi-juego»."
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          }
        }
      },
      "options": {
        "tags": [
          "Vinculación"
        ],
        "operationId": "vincularOpciones",
        "summary": "Preflight CORS",
        "security": [],
        "responses": {
          "204": {
            "description": "Cabeceras CORS."
          }
        }
      }
    },
    "/api/v1/vinculaciones/{codigo_dispositivo}": {
      "get": {
        "tags": [
          "Vinculación"
        ],
        "operationId": "estadoVinculacion",
        "summary": "Consultar una vinculación por código",
        "description": "Consúltala cada `intervalo` segundos mientras enseñas el código. Al aprobarse trae el jugador; su `id` es lo que usas en cobros y pagos. Una vinculación aprobada sigue devolviendo `aprobada`.\n\n**Si el código lo pidió el cliente del juego**, no te fíes de un id de jugador que te mande el cliente (cualquiera puede mandar otro id). Haz que el cliente le pase el `codigo_dispositivo` a tu servidor y que tu servidor consulte esta ruta él mismo, comprobando que `estado` es `aprobada` y que `juego` es tu slug. El `codigo_dispositivo` es imposible de adivinar, así que solo lo tiene quien pidió el código.",
        "security": [],
        "parameters": [
          {
            "name": "codigo_dispositivo",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^fdc_"
            },
            "description": "El `codigo_dispositivo` que devolvió `POST /api/v1/vinculaciones`."
          }
        ],
        "responses": {
          "200": {
            "description": "Estado actual.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "Siempre `*`: se puede llamar desde el navegador o el dispositivo del jugador.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EstadoVinculacion"
                },
                "examples": {
                  "pendiente": {
                    "value": {
                      "estado": "pendiente",
                      "juego": "reflejos"
                    }
                  },
                  "aprobada": {
                    "value": {
                      "estado": "aprobada",
                      "jugador": {
                        "id": 12,
                        "usuario": "ana",
                        "nombre": "Ana"
                      },
                      "juego": "reflejos"
                    }
                  },
                  "rechazada": {
                    "value": {
                      "estado": "rechazada",
                      "juego": "reflejos"
                    }
                  },
                  "vencida": {
                    "value": {
                      "estado": "vencida",
                      "juego": "reflejos"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No existe ese código de dispositivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "no_encontrado": {
                    "value": {
                      "error": {
                        "codigo": "no_encontrado",
                        "mensaje": "Ese código de dispositivo no existe."
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          }
        }
      },
      "options": {
        "tags": [
          "Vinculación"
        ],
        "operationId": "estadoVinculacionOpciones",
        "summary": "Preflight CORS",
        "security": [],
        "parameters": [
          {
            "name": "codigo_dispositivo",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Cabeceras CORS."
          }
        }
      }
    },
    "/api/v1/token": {
      "post": {
        "tags": [
          "Vinculación"
        ],
        "operationId": "canjearCodigo",
        "summary": "Canjear el código de la vinculación web",
        "description": "El jugador vuelve de `/autorizar` a tu `redireccion` con `?codigo=fac_...&estado=...`. Tu servidor lo canjea aquí, con la MISMA `redireccion` usada en `/autorizar`. Cada código vale una sola vez y durante 5 minutos. No es idempotente: si la respuesta se pierde, vuelve a pedir autorización al jugador.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudToken"
              },
              "example": {
                "codigo": "fac_4mJ3x0yq8Qn2aW6kV1cZbP9sT7rLhE5u",
                "redireccion": "https://mijuego.com/callback"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Quién es el jugador.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RespuestaToken"
                },
                "example": {
                  "jugador": {
                    "id": 12,
                    "usuario": "ana",
                    "nombre": "Ana"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Código inválido, usado, vencido, de otro juego o con otra redirección; o cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "codigo_invalido": {
                    "value": {
                      "error": {
                        "codigo": "codigo_invalido",
                        "mensaje": "Ese código ya se usó."
                      }
                    }
                  },
                  "json_invalido": {
                    "value": {
                      "error": {
                        "codigo": "json_invalido",
                        "mensaje": "El cuerpo debe ser un objeto JSON."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Falta el secreto o no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "sin_secreto": {
                    "value": {
                      "error": {
                        "codigo": "sin_secreto",
                        "mensaje": "Falta la cabecera Authorization: Bearer <secreto del juego>."
                      }
                    }
                  },
                  "secreto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "secreto_invalido",
                        "mensaje": "Ese secreto no corresponde a ningún juego (¿lo regeneraste?)."
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          },
          "403": {
            "description": "El juego está suspendido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "juego_suspendido": {
                    "value": {
                      "error": {
                        "codigo": "juego_suspendido",
                        "mensaje": "Este juego está suspendido por el cajero."
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jugadores/{id}": {
      "get": {
        "tags": [
          "Consultas"
        ],
        "operationId": "jugador",
        "summary": "Saldo del jugador y lo que le puedes cobrar hoy",
        "description": "Solo para jugadores que vincularon su cuenta con tu juego. Si la desconectaron, `vinculado` es false, `saldo` null y `disponible_hoy` 0.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Datos del jugador.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EstadoJugador"
                },
                "example": {
                  "id": 12,
                  "usuario": "ana",
                  "nombre": "Ana",
                  "saldo": 4850,
                  "vinculado": true,
                  "limite_diario": 2000,
                  "disponible_hoy": 1850
                }
              }
            }
          },
          "401": {
            "description": "Falta el secreto o no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "sin_secreto": {
                    "value": {
                      "error": {
                        "codigo": "sin_secreto",
                        "mensaje": "Falta la cabecera Authorization: Bearer <secreto del juego>."
                      }
                    }
                  },
                  "secreto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "secreto_invalido",
                        "mensaje": "Ese secreto no corresponde a ningún juego (¿lo regeneraste?)."
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          },
          "403": {
            "description": "El jugador no existe o nunca vinculó tu juego.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "jugador_no_vinculado": {
                    "value": {
                      "error": {
                        "codigo": "jugador_no_vinculado",
                        "mensaje": "Ese jugador no ha vinculado su cuenta con tu juego."
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/cobros": {
      "post": {
        "tags": [
          "Operaciones"
        ],
        "operationId": "cobrar",
        "summary": "Cobrar a un jugador (jugador → banca)",
        "description": "Pasa `monto` céntimos del jugador a la banca de tu juego. Respeta el límite diario del jugador (últimas 24 h). Un reintento con la misma referencia devuelve la operación original aunque después el jugador haya llegado al límite o desconectado el juego.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudOperacion"
              },
              "example": {
                "jugador": 12,
                "monto": 150,
                "concepto": "Entrada a la partida",
                "referencia": "partida-8812"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Operación hecha ahora.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Operacion"
                },
                "example": {
                  "id": 412,
                  "tipo": "cobro",
                  "jugador": 12,
                  "monto": 150,
                  "concepto": "Entrada a la partida",
                  "referencia": "partida-8812",
                  "saldo_jugador": 4850,
                  "banca": 12150,
                  "creado": "2026-11-04T15:20:11.000Z",
                  "repetida": false
                }
              }
            }
          },
          "200": {
            "description": "Ya existía una operación idéntica con esa referencia: se devuelve tal cual, con `repetida: true`, sin mover dinero otra vez.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Operacion"
                },
                "example": {
                  "id": 412,
                  "tipo": "cobro",
                  "jugador": 12,
                  "monto": 150,
                  "concepto": "Entrada a la partida",
                  "referencia": "partida-8812",
                  "saldo_jugador": 4850,
                  "banca": 12150,
                  "creado": "2026-11-04T15:20:11.000Z",
                  "repetida": true
                }
              }
            }
          },
          "400": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "json_invalido": {
                    "value": {
                      "error": {
                        "codigo": "json_invalido",
                        "mensaje": "El cuerpo debe ser un objeto JSON."
                      }
                    }
                  },
                  "jugador_invalido": {
                    "value": {
                      "error": {
                        "codigo": "jugador_invalido",
                        "mensaje": "jugador debe ser el id numérico del jugador."
                      }
                    }
                  },
                  "monto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "monto_invalido",
                        "mensaje": "monto debe ser un entero de céntimos mayor que 0 (150 = S/ 1.50)."
                      }
                    }
                  },
                  "concepto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "concepto_invalido",
                        "mensaje": "Falta concepto: qué se cobra o paga."
                      }
                    }
                  },
                  "referencia_invalida": {
                    "value": {
                      "error": {
                        "codigo": "referencia_invalida",
                        "mensaje": "referencia es obligatoria: un id único tuyo para esta operación (letras, números, . : _ -; hasta 100). Si reintentas con la misma, no se cobra dos veces."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Falta el secreto o no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "sin_secreto": {
                    "value": {
                      "error": {
                        "codigo": "sin_secreto",
                        "mensaje": "Falta la cabecera Authorization: Bearer <secreto del juego>."
                      }
                    }
                  },
                  "secreto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "secreto_invalido",
                        "mensaje": "Ese secreto no corresponde a ningún juego (¿lo regeneraste?)."
                      }
                    }
                  }
                }
              }
            }
          },
          "402": {
            "description": "El jugador no tiene saldo suficiente. `saldo` es lo que tiene y `falta` lo que le falta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "saldo_insuficiente": {
                    "value": {
                      "error": {
                        "codigo": "saldo_insuficiente",
                        "mensaje": "Saldo insuficiente: tienes S/ 1.00 y necesitas S/ 1.50.",
                        "saldo": 100,
                        "falta": 50
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "No se le puede cobrar a este jugador ahora, o el juego está suspendido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "limite_excedido": {
                    "value": {
                      "error": {
                        "codigo": "limite_excedido",
                        "mensaje": "Pasa el límite diario que el jugador le puso a tu juego (S/ 20.00; le quedan S/ 0.50).",
                        "limite_diario": 2000,
                        "disponible_hoy": 50
                      }
                    }
                  },
                  "jugador_no_vinculado": {
                    "value": {
                      "error": {
                        "codigo": "jugador_no_vinculado",
                        "mensaje": "Ese jugador no ha vinculado su cuenta con tu juego."
                      }
                    }
                  },
                  "autorizacion_revocada": {
                    "value": {
                      "error": {
                        "codigo": "autorizacion_revocada",
                        "mensaje": "El jugador desconectó tu juego de su cuenta."
                      }
                    }
                  },
                  "juego_suspendido": {
                    "value": {
                      "error": {
                        "codigo": "juego_suspendido",
                        "mensaje": "Este juego está suspendido por el cajero."
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "La referencia ya se usó para una operación distinta (otro tipo, jugador o monto). `movimiento_id` es la operación original.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "referencia_repetida": {
                    "value": {
                      "error": {
                        "codigo": "referencia_repetida",
                        "mensaje": "La referencia «partida-8812» ya se usó para otra operación.",
                        "movimiento_id": 412
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          }
        }
      }
    },
    "/api/v1/pagos": {
      "post": {
        "tags": [
          "Operaciones"
        ],
        "operationId": "pagar",
        "summary": "Pagar a un jugador (banca → jugador)",
        "description": "Pasa `monto` céntimos de la banca de tu juego al jugador. Falla con `banca_insuficiente` si la banca no alcanza. Funciona aunque el jugador haya desconectado el juego. Misma idempotencia que `/cobros`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudOperacion"
              },
              "example": {
                "jugador": 12,
                "monto": 300,
                "concepto": "Premio: primer lugar",
                "referencia": "premio-8812"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Operación hecha ahora.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Operacion"
                },
                "example": {
                  "id": 413,
                  "tipo": "pago",
                  "jugador": 12,
                  "monto": 300,
                  "concepto": "Premio: primer lugar",
                  "referencia": "premio-8812",
                  "saldo_jugador": 5150,
                  "banca": 11850,
                  "creado": "2026-11-04T15:20:11.000Z",
                  "repetida": false
                }
              }
            }
          },
          "200": {
            "description": "Ya existía una operación idéntica con esa referencia: se devuelve tal cual, con `repetida: true`, sin mover dinero otra vez.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Operacion"
                },
                "example": {
                  "id": 413,
                  "tipo": "pago",
                  "jugador": 12,
                  "monto": 300,
                  "concepto": "Premio: primer lugar",
                  "referencia": "premio-8812",
                  "saldo_jugador": 5150,
                  "banca": 11850,
                  "creado": "2026-11-04T15:20:11.000Z",
                  "repetida": true
                }
              }
            }
          },
          "400": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "json_invalido": {
                    "value": {
                      "error": {
                        "codigo": "json_invalido",
                        "mensaje": "El cuerpo debe ser un objeto JSON."
                      }
                    }
                  },
                  "jugador_invalido": {
                    "value": {
                      "error": {
                        "codigo": "jugador_invalido",
                        "mensaje": "jugador debe ser el id numérico del jugador."
                      }
                    }
                  },
                  "monto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "monto_invalido",
                        "mensaje": "monto debe ser un entero de céntimos mayor que 0 (150 = S/ 1.50)."
                      }
                    }
                  },
                  "concepto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "concepto_invalido",
                        "mensaje": "Falta concepto: qué se cobra o paga."
                      }
                    }
                  },
                  "referencia_invalida": {
                    "value": {
                      "error": {
                        "codigo": "referencia_invalida",
                        "mensaje": "referencia es obligatoria: un id único tuyo para esta operación (letras, números, . : _ -; hasta 100). Si reintentas con la misma, no se cobra dos veces."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Falta el secreto o no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "sin_secreto": {
                    "value": {
                      "error": {
                        "codigo": "sin_secreto",
                        "mensaje": "Falta la cabecera Authorization: Bearer <secreto del juego>."
                      }
                    }
                  },
                  "secreto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "secreto_invalido",
                        "mensaje": "Ese secreto no corresponde a ningún juego (¿lo regeneraste?)."
                      }
                    }
                  }
                }
              }
            }
          },
          "402": {
            "description": "La banca del juego no alcanza. `saldo` es lo que tiene la banca y `falta` lo que le falta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "banca_insuficiente": {
                    "value": {
                      "error": {
                        "codigo": "banca_insuficiente",
                        "mensaje": "La banca del juego no alcanza: tiene S/ 2.00 y necesita S/ 3.00.",
                        "saldo": 200,
                        "falta": 100
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "El jugador nunca vinculó tu juego, o el juego está suspendido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "jugador_no_vinculado": {
                    "value": {
                      "error": {
                        "codigo": "jugador_no_vinculado",
                        "mensaje": "Ese jugador no ha vinculado su cuenta con tu juego."
                      }
                    }
                  },
                  "juego_suspendido": {
                    "value": {
                      "error": {
                        "codigo": "juego_suspendido",
                        "mensaje": "Este juego está suspendido por el cajero."
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "La referencia ya se usó para una operación distinta (otro tipo, jugador o monto). `movimiento_id` es la operación original.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "referencia_repetida": {
                    "value": {
                      "error": {
                        "codigo": "referencia_repetida",
                        "mensaje": "La referencia «partida-8812» ya se usó para otra operación.",
                        "movimiento_id": 412
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          }
        }
      }
    },
    "/api/v1/operaciones/{referencia}": {
      "get": {
        "tags": [
          "Operaciones"
        ],
        "operationId": "operacion",
        "summary": "Buscar un cobro o pago por su referencia",
        "description": "Para saber si una operación se hizo cuando no llegó la respuesta. También vale reintentar con la misma referencia.",
        "parameters": [
          {
            "name": "referencia",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_.:\\-]{1,100}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La operación (siempre con `repetida: false`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Operacion"
                },
                "example": {
                  "id": 412,
                  "tipo": "cobro",
                  "jugador": 12,
                  "monto": 150,
                  "concepto": "Entrada a la partida",
                  "referencia": "partida-8812",
                  "saldo_jugador": 4850,
                  "banca": 12150,
                  "creado": "2026-11-04T15:20:11.000Z",
                  "repetida": false
                }
              }
            }
          },
          "401": {
            "description": "Falta el secreto o no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "sin_secreto": {
                    "value": {
                      "error": {
                        "codigo": "sin_secreto",
                        "mensaje": "Falta la cabecera Authorization: Bearer <secreto del juego>."
                      }
                    }
                  },
                  "secreto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "secreto_invalido",
                        "mensaje": "Ese secreto no corresponde a ningún juego (¿lo regeneraste?)."
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No hay ningún cobro ni pago de tu juego con esa referencia.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "no_encontrado": {
                    "value": {
                      "error": {
                        "codigo": "no_encontrado",
                        "mensaje": "No hay ninguna operación con esa referencia."
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          }
        }
      }
    },
    "/api/v1/banca": {
      "get": {
        "tags": [
          "Consultas"
        ],
        "operationId": "banca",
        "summary": "Saldo de la banca de tu juego",
        "description": "Lo que puede pagar tu juego ahora, y lo cobrado/pagado en las últimas 24 h y en total.",
        "responses": {
          "200": {
            "description": "Estado de la banca.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Banca"
                },
                "example": {
                  "juego": {
                    "slug": "reflejos",
                    "nombre": "Reflejos",
                    "estado": "activo"
                  },
                  "saldo": 12150,
                  "ultimas_24h": {
                    "cobrado": 4500,
                    "cobros": 30,
                    "pagado": 2400,
                    "pagos": 12
                  },
                  "total": {
                    "cobrado": 98000,
                    "pagado": 61200
                  }
                }
              }
            }
          },
          "401": {
            "description": "Falta el secreto o no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "sin_secreto": {
                    "value": {
                      "error": {
                        "codigo": "sin_secreto",
                        "mensaje": "Falta la cabecera Authorization: Bearer <secreto del juego>."
                      }
                    }
                  },
                  "secreto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "secreto_invalido",
                        "mensaje": "Ese secreto no corresponde a ningún juego (¿lo regeneraste?)."
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          }
        }
      }
    },
    "/api/v1/movimientos": {
      "get": {
        "tags": [
          "Consultas"
        ],
        "operationId": "movimientos",
        "summary": "Historial de la banca",
        "description": "Del más nuevo al más antiguo. Incluye cobros, pagos, fondeos y retiros del dueño y ajustes del admin. Para la página siguiente pasa en `antes` el valor de `siguiente`.",
        "parameters": [
          {
            "name": "limite",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            },
            "description": "Cuántos devolver (se ajusta a 1–200)."
          },
          {
            "name": "antes",
            "in": "query",
            "schema": {
              "type": "integer"
            },
            "description": "Devuelve solo movimientos con id menor que este."
          }
        ],
        "responses": {
          "200": {
            "description": "Una página del historial.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaginaMovimientos"
                },
                "example": {
                  "movimientos": [
                    {
                      "id": 413,
                      "tipo": "pago",
                      "monto": -300,
                      "concepto": "Premio: primer lugar",
                      "referencia": "premio-8812",
                      "jugador": 12,
                      "contraparte": "@ana",
                      "banca": 11850,
                      "creado": "2026-11-04T15:21:40.000Z"
                    },
                    {
                      "id": 412,
                      "tipo": "cobro",
                      "monto": 150,
                      "concepto": "Entrada a la partida",
                      "referencia": "partida-8812",
                      "jugador": 12,
                      "contraparte": "@ana",
                      "banca": 12150,
                      "creado": "2026-11-04T15:20:11.000Z"
                    }
                  ],
                  "siguiente": 412
                }
              }
            }
          },
          "401": {
            "description": "Falta el secreto o no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "sin_secreto": {
                    "value": {
                      "error": {
                        "codigo": "sin_secreto",
                        "mensaje": "Falta la cabecera Authorization: Bearer <secreto del juego>."
                      }
                    }
                  },
                  "secreto_invalido": {
                    "value": {
                      "error": {
                        "codigo": "secreto_invalido",
                        "mensaje": "Ese secreto no corresponde a ningún juego (¿lo regeneraste?)."
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          }
        }
      }
    }
  },
  "security": [
    {
      "secreto": []
    }
  ],
  "components": {
    "securitySchemes": {
      "secreto": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "fsk_...",
        "description": "El secreto de tu juego (empieza por `fsk_`). Se ve una sola vez al crear o regenerar el juego. Solo en tu servidor."
      }
    },
    "responses": {
      "ErrorInterno": {
        "description": "Algo falló en Ficha. Reintenta con la misma referencia.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "codigo": "error_interno",
                "mensaje": "Algo falló en Ficha. Reintenta con la misma referencia."
              }
            }
          }
        }
      }
    },
    "schemas": {
      "Centimos": {
        "type": "integer",
        "description": "Monto en céntimos enteros (150 = S/ 1.50).",
        "examples": [
          150
        ]
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "codigo",
              "mensaje"
            ],
            "properties": {
              "codigo": {
                "type": "string",
                "description": "Código estable para comparar en tu programa. Puede haber códigos nuevos en el futuro: trata los desconocidos como error genérico.",
                "examples": [
                  "sin_secreto",
                  "secreto_invalido",
                  "json_invalido",
                  "solicitud_invalida",
                  "juego_invalido",
                  "limite_invalido",
                  "juego_suspendido",
                  "no_encontrado",
                  "codigo_invalido",
                  "jugador_invalido",
                  "jugador_no_vinculado",
                  "monto_invalido",
                  "concepto_invalido",
                  "referencia_invalida",
                  "referencia_repetida",
                  "autorizacion_revocada",
                  "limite_excedido",
                  "saldo_insuficiente",
                  "banca_insuficiente",
                  "error_interno"
                ]
              },
              "mensaje": {
                "type": "string",
                "description": "Explicación en español, se puede enseñar al jugador o al desarrollador."
              },
              "limite_diario": {
                "$ref": "#/components/schemas/Centimos",
                "description": "En `limite_excedido`."
              },
              "disponible_hoy": {
                "$ref": "#/components/schemas/Centimos",
                "description": "En `limite_excedido`: lo que aún se puede cobrar."
              },
              "saldo": {
                "$ref": "#/components/schemas/Centimos",
                "description": "En `saldo_insuficiente` (del jugador) y `banca_insuficiente` (de la banca)."
              },
              "falta": {
                "$ref": "#/components/schemas/Centimos",
                "description": "En `saldo_insuficiente` y `banca_insuficiente`."
              },
              "movimiento_id": {
                "type": "integer",
                "description": "En `referencia_repetida`: la operación que ya usó esa referencia."
              }
            },
            "additionalProperties": true
          }
        }
      },
      "Indice": {
        "type": "object",
        "properties": {
          "nombre": {
            "type": "string"
          },
          "version": {
            "type": "integer"
          },
          "documentacion": {
            "type": "string"
          },
          "openapi": {
            "type": "string"
          }
        }
      },
      "Jugador": {
        "type": "object",
        "required": [
          "id",
          "usuario",
          "nombre"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "description": "Lo que usas como `jugador` en cobros y pagos."
          },
          "usuario": {
            "type": "string",
            "description": "Nombre de usuario en Ficha (sin @)."
          },
          "nombre": {
            "type": "string"
          }
        }
      },
      "EstadoJugador": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Jugador"
          },
          {
            "type": "object",
            "required": [
              "saldo",
              "vinculado",
              "limite_diario",
              "disponible_hoy"
            ],
            "properties": {
              "saldo": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Saldo en céntimos; null si desconectó tu juego."
              },
              "vinculado": {
                "type": "boolean",
                "description": "false si el jugador desconectó tu juego."
              },
              "limite_diario": {
                "$ref": "#/components/schemas/Centimos"
              },
              "disponible_hoy": {
                "$ref": "#/components/schemas/Centimos",
                "description": "Lo que aún le puedes cobrar (últimas 24 h)."
              }
            }
          }
        ]
      },
      "SolicitudVinculacion": {
        "type": "object",
        "required": [
          "juego"
        ],
        "properties": {
          "juego": {
            "type": "string",
            "description": "Id público (slug) de tu juego."
          },
          "limite_diario": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100000,
            "default": 2000,
            "description": "Límite diario sugerido, en céntimos."
          }
        }
      },
      "Vinculacion": {
        "type": "object",
        "required": [
          "codigo",
          "codigo_dispositivo",
          "url",
          "url_directa",
          "expira_en",
          "intervalo"
        ],
        "properties": {
          "codigo": {
            "type": "string",
            "pattern": "^[B-DF-HJ-NP-TV-XZ2-9]{4}-[B-DF-HJ-NP-TV-XZ2-9]{4}$",
            "description": "Código corto para enseñar en pantalla."
          },
          "codigo_dispositivo": {
            "type": "string",
            "description": "Para consultar el estado. No lo enseñes."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Página donde el jugador escribe el código."
          },
          "url_directa": {
            "type": "string",
            "format": "uri",
            "description": "La misma página con el código ya puesto (para un QR o un enlace)."
          },
          "expira_en": {
            "type": "integer",
            "description": "Segundos hasta que vence."
          },
          "intervalo": {
            "type": "integer",
            "description": "Segundos entre consultas."
          }
        }
      },
      "EstadoVinculacion": {
        "type": "object",
        "required": [
          "estado",
          "juego"
        ],
        "properties": {
          "estado": {
            "type": "string",
            "enum": [
              "pendiente",
              "aprobada",
              "rechazada",
              "vencida"
            ]
          },
          "jugador": {
            "$ref": "#/components/schemas/Jugador",
            "description": "Solo cuando `estado` es `aprobada`."
          },
          "juego": {
            "type": "string",
            "description": "Id público (slug) del juego que pidió el código. Tu servidor debe comprobar que es el suyo."
          }
        }
      },
      "SolicitudToken": {
        "type": "object",
        "required": [
          "codigo",
          "redireccion"
        ],
        "properties": {
          "codigo": {
            "type": "string",
            "description": "El `codigo` (fac_...) que llegó a tu redirección."
          },
          "redireccion": {
            "type": "string",
            "format": "uri",
            "description": "La misma URL usada en /autorizar."
          }
        }
      },
      "RespuestaToken": {
        "type": "object",
        "required": [
          "jugador"
        ],
        "properties": {
          "jugador": {
            "$ref": "#/components/schemas/Jugador"
          }
        }
      },
      "SolicitudOperacion": {
        "type": "object",
        "required": [
          "jugador",
          "monto",
          "concepto",
          "referencia"
        ],
        "properties": {
          "jugador": {
            "type": "integer",
            "description": "Id del jugador."
          },
          "monto": {
            "type": "integer",
            "minimum": 1,
            "description": "Céntimos enteros mayores que 0."
          },
          "concepto": {
            "type": "string",
            "minLength": 1,
            "maxLength": 140,
            "description": "Qué se cobra o paga; lo ve el jugador. Se recorta a 140."
          },
          "referencia": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_.:\\-]{1,100}$",
            "description": "Id único tuyo para esta operación (clave de idempotencia). Un UUID vale."
          }
        }
      },
      "Operacion": {
        "type": "object",
        "required": [
          "id",
          "tipo",
          "jugador",
          "monto",
          "concepto",
          "referencia",
          "saldo_jugador",
          "banca",
          "creado",
          "repetida"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "tipo": {
            "type": "string",
            "enum": [
              "cobro",
              "pago"
            ]
          },
          "jugador": {
            "type": "integer"
          },
          "monto": {
            "$ref": "#/components/schemas/Centimos",
            "description": "Siempre positivo."
          },
          "concepto": {
            "type": "string"
          },
          "referencia": {
            "type": "string"
          },
          "saldo_jugador": {
            "$ref": "#/components/schemas/Centimos",
            "description": "Saldo del jugador justo después de esta operación."
          },
          "banca": {
            "$ref": "#/components/schemas/Centimos",
            "description": "Saldo de la banca justo después de esta operación."
          },
          "creado": {
            "type": "string",
            "format": "date-time"
          },
          "repetida": {
            "type": "boolean",
            "description": "true si ya existía y no se movió dinero otra vez."
          }
        }
      },
      "Banca": {
        "type": "object",
        "required": [
          "juego",
          "saldo",
          "ultimas_24h",
          "total"
        ],
        "properties": {
          "juego": {
            "type": "object",
            "properties": {
              "slug": {
                "type": "string"
              },
              "nombre": {
                "type": "string"
              },
              "estado": {
                "type": "string",
                "enum": [
                  "activo",
                  "suspendido"
                ]
              }
            }
          },
          "saldo": {
            "$ref": "#/components/schemas/Centimos",
            "description": "Lo que puede pagar tu juego ahora."
          },
          "ultimas_24h": {
            "type": "object",
            "properties": {
              "cobrado": {
                "$ref": "#/components/schemas/Centimos"
              },
              "cobros": {
                "type": "integer"
              },
              "pagado": {
                "$ref": "#/components/schemas/Centimos"
              },
              "pagos": {
                "type": "integer"
              }
            }
          },
          "total": {
            "type": "object",
            "properties": {
              "cobrado": {
                "$ref": "#/components/schemas/Centimos"
              },
              "pagado": {
                "$ref": "#/components/schemas/Centimos"
              }
            }
          }
        }
      },
      "Movimiento": {
        "type": "object",
        "required": [
          "id",
          "tipo",
          "monto",
          "concepto",
          "referencia",
          "jugador",
          "contraparte",
          "banca",
          "creado"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "tipo": {
            "type": "string",
            "enum": [
              "cobro",
              "pago",
              "fondeo",
              "retiro_banca",
              "ajuste"
            ]
          },
          "monto": {
            "type": "integer",
            "description": "Céntimos con signo vistos desde la banca: positivo entra, negativo sale."
          },
          "concepto": {
            "type": "string"
          },
          "referencia": {
            "type": [
              "string",
              "null"
            ],
            "description": "Solo en cobros y pagos."
          },
          "jugador": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Id del jugador en cobros y pagos; null en el resto."
          },
          "contraparte": {
            "type": "string",
            "description": "@usuario, o «Ajuste del admin»."
          },
          "banca": {
            "$ref": "#/components/schemas/Centimos",
            "description": "Saldo de la banca después del movimiento."
          },
          "creado": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PaginaMovimientos": {
        "type": "object",
        "required": [
          "movimientos",
          "siguiente"
        ],
        "properties": {
          "movimientos": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Movimiento"
            }
          },
          "siguiente": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Pásalo como `antes` para la página siguiente; null si no hay más."
          }
        }
      }
    }
  }
}
