# API v1 de MiFicha

Base: `https://mificha.app/api/v1/` (o la URL de una instalación propia). JSON en UTF-8.

## Convenciones

- **Autenticación de la app**: `Authorization: Token <token>` (DRF). La web usa la sesión.
- **Empresa**: quien está en varias empresas manda `X-Empresa: <id>`; sin ella, la última elegida.
- **Idioma**: `Accept-Language: es` o `gl` (mensajes de error y textos).
- **Fechas**: ISO 8601 con zona (`2026-03-02T09:00:00+01:00`); fechas sin hora `2026-03-02`.
- **Duraciones**: en **segundos** (enteros): `trabajado`, `previsto`, `pausas`.
- **Horas de nómina**: cadenas decimales con 2 cifras (`"6.50"`).
- **Errores**: código HTTP 400/401/403/404/409/429 y
  `{"error": {"codigo": "ya_dentro", "mensaje": "Ya tienes una entrada registrada: ficha la salida."}}`.
  Los `codigo` de fichar son los de `fichajes.servicios.ErrorFichaje`.
- **Listas**: `{"resultados": [...], "siguiente": "<url>|null"}` con `?limite=` (máx. 200).

## Público

| Método | Ruta | Respuesta |
|---|---|---|
| GET | `config/` | `{"modo": "nube"\|"propia", "marca", "version_api": 1, "version_minima_app": "1.0.0", "idiomas": ["es","gl"], "proveedores_acceso": ["google","microsoft","apple","mifactu"], "registro_abierto": bool, "legal": {"privacidad": url, "condiciones": url}, "oidc": {"<proveedor>": {"client_id", "autorizar", "alcance"}}}` |

La app usa `config/` para validar la URL del servidor elegido (nube o instalación propia). `oidc` da, para Google,
Microsoft y MiFactu, el `client_id` **del servidor** y su dirección de autorización: la app autoriza con PKCE y
manda el código a `auth/oidc/<proveedor>/`, que el servidor canjea con su secreto (que nunca se publica). El **QR de configuración**
que muestra el panel contiene `mificha://servidor?url=<url>` (también vale la URL https sola).

## Cuenta

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| POST | `auth/login/` | `{email, password, dispositivo}` | `{token, usuario}` o `{dos_pasos: true, reto}` |
| POST | `auth/dos-pasos/` | `{reto, codigo}` | `{token, usuario}` |
| POST | `auth/logout/` | — | 204 (invalida el token) |
| POST | `auth/registro/` | `{email, password, nombre, empresa: {nombre, nif}}` (solo nube) | `{pendiente: true}` (código por correo) |
| POST | `auth/registro/confirmar/` | `{email, codigo}` | `{token, usuario}` |
| POST | `auth/recuperar/` | `{email}` | 204 (envía el enlace) |
| POST | `auth/invitacion/` | `{token, password?, nombre?}` | `{token, usuario}` (acepta la invitación; crea el usuario si no existe) |
| POST | `auth/oidc/<proveedor>/` | `{id_token}` (Apple/Google) o `{code, code_verifier, redirect_uri}` | `{token, usuario}` |
| POST | `auth/dos-pasos/perdido/` | `{reto}` | `{correo, horas}` («He perdido el móvil»: enlace al correo) |
| POST | `auth/oidc/nonce/` | — | `{nonce}` (un solo uso; obligatorio con el id_token de Apple, que lleva su SHA-256) |
| POST | `auth/oidc/crear/` | `{ticket, acepto, nombre?}` | `{token, usuario}` (201) |
| POST | `auth/oidc/vincular/` | `{ticket, password \| codigo}` | `{token, usuario, asegurada}` |
| POST | `auth/oidc/codigo/` | `{ticket}` | `{correo}` (reenvía el código para vincular) |
| GET | `yo/` | — | `usuario` |
| PATCH | `yo/` | `{nombre?, idioma?}` | `usuario` |
| POST | `yo/empresa/` | `{empresa}` | `usuario` (cambia la empresa actual) |
| DELETE | `yo/` | `{password}` | 204 (borra la cuenta si no es titular con trabajadores) |

`auth/oidc/<proveedor>/` puede responder también `{dos_pasos: true, reto}`, `{nueva: true, ticket, email, nombre}`
(cuenta por crear: `crear/`) o `{vincular: true, ticket, email, con_contrasena, codigo_enviado}` (ya hay cuenta con ese
correo: `vincular/`). Detalle en docs/cuentas.md.

`usuario`:
```json
{"id": 7, "email": "ana@ejemplo.com", "nombre": "Ana López", "idioma": "es",
 "empresa_actual": 3,
 "empresas": [{"id": 3, "nombre": "Talleres Rías", "rol": "trabajador", "rol_nombre": "Trabajador",
               "permisos": ["fichar"], "trabajador": {"id": 12, "nombre": "Ana López"} }]}
```

## Fichar (trabajador)

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| GET | `fichaje/` | — | `estado_fichaje` |
| POST | `fichaje/` | `{tipo?: "entrada"\|"salida"\|"pausa_inicio"\|"pausa_fin", latitud?, longitud?, precision?, nota?}` | `{marcaje, estado: estado_fichaje}` (201) |

`estado_fichaje`:
```json
{"estado": "fuera|dentro|pausa", "siguiente": "entrada|salida|pausa_fin",
 "ultimo": {"tipo": "entrada", "instante": "…"} ,
 "hoy": {"fecha": "2026-03-02", "trabajado": 7200, "pausas": 900, "previsto": 28800, "abierta": true,
         "tramos": [{"desde": "…", "hasta": null, "tipo": "trabajo"}]},
 "puede_fichar": true, "motivo": "",
 "empresa": {"fichaje_app": true, "geolocalizacion": "no|opcional|obligatoria", "modo_tipo": "automatico|botones",
             "qr": true, "qr_ventana_segundos": 15, "qr_tolerancia": 2, "trabajador_ve_ubicacion": true},
 "movil_registrado": true}
```

`marcaje`:
```json
{"id": 91, "tipo": "entrada", "tipo_nombre": "Entrada", "instante": "…", "origen": "app", "origen_nombre": "App del móvil",
 "diferido": false, "vigente": true, "terminal": "Entrada principal"|null, "latitud": 42.24|null, "longitud": -8.72|null,
 "dentro_geocerca": true|null, "correccion": 5|null}
```
(La ubicación solo aparece si `trabajador_ve_ubicacion` o si quien consulta tiene `ver_ubicacion`.)

## Mis registros

| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
| GET | `marcajes/` | `desde`, `hasta` (fechas), `trabajador` (solo con `ver_equipo`) | lista de `marcaje` |
| GET | `jornadas/` | `desde`, `hasta`, `trabajador` | lista de `jornada` |
| GET | `calendario/` | `anio`, `mes`, `trabajador` | `{"anio","mes","dias":[jornada…], "totales": {trabajado, previsto, dias_trabajados, incidencias}}` |

`jornada`:
```json
{"fecha": "2026-03-02", "estado": "trabajado|abierto|incidencia|falta|ausencia|festivo|libre",
 "trabajado": 28800, "pausas": 900, "previsto": 28800, "diferencia": 0,
 "primera_entrada": "…"|null, "ultima_salida": "…"|null, "tramos": [...],
 "incidencias": [{"codigo": "retraso", "texto": "Entrada 12 min tarde.", "gravedad": "aviso|error|info"}],
 "festivo": "", "ausencia": {"tipo": "vacaciones", "nombre": "Vacaciones"}|null}
```

## Móvil para el QR

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| GET | `credenciales/movil/` | — | `{"registrado": bool, "kid", "dispositivo", "creada"}` |
| POST | `credenciales/movil/` | `{clave_publica (Ed25519, 32 bytes, base64url sin =), dispositivo}` | `{"kid", "empresa_id", "ventana_segundos", "tolerancia"}` (201; revoca el móvil anterior) |
| DELETE | `credenciales/movil/` | — | 204 |

**Generar el QR en la app** (cada `ventana_segundos`):
```
ventana = floor(unix_utc / ventana_segundos)
firma   = Ed25519_sign(clave_privada, "MF1|" + kid + "|" + ventana + "|" + empresa_id)   (bytes ASCII)
qr      = "MF1." + kid + "." + ventana + "." + base64url_sin_relleno(firma)
```
La clave privada se genera en el móvil y se guarda en el Keychain/Keystore; nunca se envía.

## Correcciones

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| GET | `correcciones/` | `?estado=` | lista de `correccion` (las mías) |
| POST | `correcciones/` | `{tipo: "alta"\|"anulacion"\|"modificacion", marcaje?, nuevo_tipo?, nuevo_instante?, motivo}` | `correccion` (201) |
| POST | `correcciones/<id>/cancelar/` | — | `correccion` |
| POST | `correcciones/<id>/conformidad/` | `{conforme: bool, comentario}` | `correccion` |

`correccion`: `{id, tipo, tipo_nombre, estado, estado_nombre, marcaje: marcaje|null, nuevo_tipo, nuevo_instante, motivo,
pedida_por, pedida_desde: "trabajador"|"empresa", creada, resuelta_por, resuelta_el, comentario, conformidad,
conformidad_comentario, trabajador: {id, nombre}}`

## Resúmenes mensuales

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| GET | `resumenes/` | — | lista de `resumen` (los míos) |
| GET | `resumenes/<id>/` | — | `resumen` con `detalle` |
| POST | `resumenes/<id>/firmar/` | `{conforme: bool, comentario}` | `resumen` |
| GET | `resumenes/<id>/pdf/` | — | PDF |

`resumen`: `{id, anio, mes, version, estado, estado_nombre, dias_trabajados, trabajado, previsto, horas_ordinarias,
horas_complementarias, horas_extra, horas_extra_fm, horas_extra_compensadas, ausencias: [...], incidencias, huella,
cerrado_el, firmado_el, disconformidad, detalle?: [...]}`

## Ausencias

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| GET | `ausencias/` | `?anio=` | `{"resultados": [ausencia], "siguiente", "vacaciones": {"anio", "derecho", "tipo", "disfrutadas", "programadas", "pendientes", "solicitadas"}}` |
| POST | `ausencias/` | multipart o JSON: `{tipo, desde, hasta, horas?, motivo, justificante?}` | `ausencia` (201) |
| POST | `ausencias/<id>/anular/` | — | `ausencia` (solo solicitadas, o aprobadas futuras) |

Vacaciones (días): `derecho` proporcional a los días de alta del año (redondeado al medio día, puede ser `7.5`), `tipo` naturales|laborables, `disfrutadas` aprobadas hasta hoy, `programadas` aprobadas por venir, `pendientes` = derecho − disfrutadas − programadas, `solicitadas` pendientes de aprobar.

`ausencia`: `{id, tipo, tipo_nombre, desde, hasta, dias, horas, estado, estado_nombre, motivo, respuesta, justificante: bool,
trabajador: {id, nombre}}`

## Avisos

| GET | `avisos/` | — | `[{"tipo": "conformidad|firma|ausencia|correccion|olvido", "texto", "fecha", "enlace": {"pantalla", "id"}}]` |

## Equipo (encargados, RR. HH., titular; permiso `ver_equipo` / `aprobar`)

| Método | Ruta | Respuesta |
|---|---|---|
| GET | `equipo/hoy/` | `{"resumen": {"dentro", "pausa", "fuera", "ausentes", "sin_fichar"}, "trabajadores": [{"trabajador": {id, nombre, iniciales, centro}, "estado": "dentro\|pausa\|fuera\|ausente\|sin_fichar\|libre", "desde": "…", "trabajado": 0, "previsto": 0, "incidencias": [...]}]}` |
| GET | `equipo/trabajadores/` | `[{id, nombre, iniciales, nif, centro, puesto, activo}]` |
| GET | `equipo/correcciones/?estado=pendiente` | lista de `correccion` |
| POST | `equipo/correcciones/<id>/aprobar/` `{comentario}` | `correccion` |
| POST | `equipo/correcciones/<id>/rechazar/` `{comentario}` | `correccion` |
| GET | `equipo/ausencias/?estado=solicitada` | lista de `ausencia` |
| POST | `equipo/ausencias/<id>/aprobar/` `{respuesta}` | `ausencia` |
| POST | `equipo/ausencias/<id>/rechazar/` `{respuesta}` | `ausencia` |
| GET | `equipo/incidencias/?desde&hasta` | `[{"trabajador": {...}, "fecha", "incidencias": [...]}]` |

## Terminales (`Authorization: Terminal mft_…`) — ver PROTOCOLO_TERMINAL.md

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| POST | `terminal/emparejar/` | `{codigo, version, plataforma}` (sin autenticación) | `{"clave": "mft_…", "terminal": terminal}` |
| GET | `terminal/` | — | `{"terminal": terminal, "hora_servidor"}` |
| POST | `terminal/lecturas/` | `{"lecturas": [{"id_local", "texto", "leido_en"?, "tipo"?}], "reloj"?}` | `{"resultados": [respuesta…], "hora_servidor"}` |
| POST | `terminal/latido/` | `{version, reloj, cola}` | `{"hora_servidor", "modo_prueba", "modo_alta": "Ana López"\|null, "terminal": terminal}` |
| POST | `entrada/<token>/` | formato del fabricante (plantilla del panel) | `respuesta` |

`terminal`: `{id, nombre, adaptador, modo_tipo: "automatico"|"botones", acepta_pin, config, empresa: {id, nombre},
centro: {id, nombre}|null, zona_horaria}`

`respuesta` (de cada lectura): `{id_local, estado: "ok|rechazada|duplicada|prueba|alta", codigo, mensaje, senal:
"ok|error|aviso", trabajador, tipo, hora}`

## Integradores (`Authorization: Bearer mfk_live_…`) — integraciones/

`integracion/trabajadores/`, `integracion/marcajes/`, `integracion/jornadas/`, `integracion/resumenes/`, alta de
marcajes desde sistemas propios (`origen: api`) y avisos firmados (HMAC-SHA256, cabecera `X-MiFicha-Firma`):
`marcaje.creado`, `correccion.resuelta`, `resumen.cerrado`, `resumen.firmado`, `ausencia.resuelta`. Detalle en
[integraciones.md](integraciones.md).
