openapi: 3.0.3 info: title: Playa Reservas API version: 1.0.4 description: | API REST de Playa Reservas — Sistema multi-tenant SaaS para reservas de playa + POS hostelería + portal cliente + restaurante + lealtad + multi-canal. **Spec scope** (revisada 2026-05-14): este documento es de referencia (NO se genera automáticamente desde el código). Cubre los endpoints más relevantes para integradores y auditoría. Para los ~50 handlers REST completos ver `@php/handlers/` (uno por archivo + README operativo). ## Autenticación - **Header**: `X-Auth-Token: ` obtenido vía `POST /api.php?resource=auth&action=login`. - **TTL**: 30 días (sliding window) — el backend extiende `session_expires_at` en cada call si quedan <(`ttl - 1h`) de vida útil. - **2FA TOTP**: tras password OK, si `totp_enabled=1` el backend devuelve `{totp_required: true, totp_pending_token}`; el cliente debe enviar el código TOTP via `POST resource=auth&action=verify_totp`. - **Portal cliente**: endpoints `/portal*` aceptan `X-Portal-Token` separado (`portal_users.session_token`). ## Multi-tenant - **Resolver**: `X-Tenant-Id` header > subdomain > `default`. - El subdomain (`mibeach.dominio.com`) se mapea a `tenants.subdomain` → `tenants.id` (`@php/db.php::currentTenant()`). - Todos los handlers filtran por `tenant_id` (audit S-05, S-06, F2b). - Tokens emitidos en tenant A son **inválidos** en tenant B (revalidación cross-tenant en `authUser()` / `authPortalUser()`). ## Rate limiting - **Login**: 10 intentos fallidos por IP en 15 min → bloqueo IP. 5 intentos fallidos por usuario en 15 min → bloqueo username. Tabla `login_attempts`. - **Signup**: 3 intentos por IP/hora. Tabla `signup_attempts`. - **API general**: sin throttle global — se asume balanceo en edge (Cloudflare). ## Errores estandarizados Todas las respuestas siguen `{ok: boolean, data?: object, error?: string}`. Códigos comunes: - `400` validación inválida - `401` token ausente/expirado - `403` permisos insuficientes o feature flag OFF - `404` recurso no encontrado - `409` conflicto (e.g. UNIQUE violation, race condition) - `429` rate limited - `500` error interno (capturado en Sentry si está configurado) ## CORS / CSP - **CORS**: configurable en `php/config.php` → `security.allowed_origins`. - **CSP**: emite `Content-Security-Policy` por perfil (API/HTML/PORTAL) vía `@php/csp-helper.php`. Modo nonce-based opcional — ver `@docs/CSP_Activation.md`. ## Versionado Tras v1.0.0 (mayo 2026, corte de v3.x → SaaS público) seguimos [SemVer](https://semver.org/lang/es/). Las releases v1.x.y mantienen backward-compat dentro del major. Para changelog ver `CHANGELOG.md`. contact: name: Playa Reservas url: https://github.com/gfelix87/codes servers: - url: https://{subdomain}.tudominio.com/php description: Producción multi-tenant variables: subdomain: default: app description: Subdomain del tenant (mapeado a tenants.subdomain) - url: https://tudominio.com/php description: Producción mono-tenant (default) - url: http://localhost/php description: Desarrollo local tags: - name: Auth description: Login, logout, TOTP, recuperación de password - name: Tenants description: Resolver multi-tenant + signup self-service - name: Reservations description: Reservas de playa (staff + portal) - name: Portal description: Portal cliente público (registro, login, reservas guest) - name: Clients description: CRM (clientes, etiquetas, historial) - name: Zones description: Configuración zonas (playa / restaurante / híbrido) - name: POS description: Punto de venta hostelería (orders, products, cash session) - name: Restaurant description: Restaurante (rooms, tables, bookings) - name: Loyalty description: Lealtad (puntos, tiers, coupons, wallet, referrals) - name: Billing description: Stripe Subscriptions para el tenant SaaS - name: Verifactu description: Facturación electrónica España (AEAT / TicketBAI) - name: Push description: Notificaciones push VAPID - name: Realtime description: Server-Sent Events (SSE) - name: Channels description: Integraciones OTAs (Booking, Hotusa, ...) - name: Admin description: Audit log, users, config, demos, updates check - name: Reports description: Reportes ejecutivos + POS analytics - name: System description: Healthcheck, iCal feeds, weather security: - bearerAuth: [] components: parameters: TenantId: name: X-Tenant-Id in: header required: false schema: { type: string } description: Override del resolver subdomain (header preferente) securitySchemes: bearerAuth: type: apiKey in: header name: X-Auth-Token description: Token de sesión obtenido tras login portalAuth: type: apiKey in: header name: X-Portal-Token description: Token de sesión del portal cliente schemas: ApiResponse: type: object required: [ok] properties: ok: { type: boolean } data: { type: object, nullable: true } error: { type: string, nullable: true } Reservation: type: object properties: id: { type: string, example: res_1k2m3 } zone_id: { type: string } seat_id: { type: string } seat_name: { type: string } line_id: { type: string } line_name: { type: string } person_name: { type: string } phone: { type: string, nullable: true } notes: { type: string, nullable: true } start_date: { type: string, format: date } start_time: { type: string, example: "10:00" } end_time: { type: string, example: "14:00" } duration: { type: number, example: 4 } price: { type: number, format: float } payment_method: { type: string, enum: [cash, card, transfer, pending] } discount: { type: number, format: float } currency: { type: string, example: "EUR" } status: { type: string, enum: [active, completed, cancelled] } portal_user_id: { type: string, nullable: true, description: "ID del usuario del portal cliente. Si está informado, api.php dispara push notifications al cliente en creación, cancelación y cambios de estado (v3.33.8)." } created_by: { type: string } created_by_name: { type: string } created_at: { type: string, format: date-time } Client: type: object properties: id: { type: string } name: { type: string } phone: { type: string } notes: { type: string } tags: { type: array, items: { type: string } } visit_count: { type: integer } total_spent: { type: number, format: float } last_visit_at:{ type: string, format: date-time } User: type: object properties: id: { type: string } username: { type: string } role: { type: string, enum: [basic, admin, superadmin] } email: { type: string, format: email, nullable: true } display_name: { type: string, nullable: true } active: { type: boolean } AuditEntry: type: object properties: id: { type: integer } action: { type: string } entity_type: { type: string } entity_id: { type: string } user_name: { type: string } note: { type: string } context: { type: object } created_at: { type: string, format: date-time } Tenant: type: object properties: id: { type: string, example: "ten-abc123" } name: { type: string } subdomain: { type: string } plan: { type: string, enum: [trial, starter, pro, enterprise] } status: { type: string, enum: [trial, active, past_due, suspended, cancelled] } trial_ends_at: { type: string, format: date-time, nullable: true } stripe_customer_id: { type: string, nullable: true } stripe_subscription_id: { type: string, nullable: true } branding_json: { type: object, nullable: true } created_at: { type: string, format: date-time } PortalUser: type: object properties: id: { type: string } email: { type: string, format: email, nullable: true } phone: { type: string, nullable: true } display_name: { type: string } provider: { type: string, enum: [email, whatsapp, google] } google_sub: { type: string, nullable: true } created_at: { type: string, format: date-time } PosOrder: type: object properties: id: { type: string, example: "ord_1k2m3n" } zone_id: { type: string } table_id: { type: string, nullable: true } seat_id: { type: string, nullable: true } status: { type: string, enum: [pending, accepted, preparing, ready, served, paid, cancelled, refunded] } items: { type: array, items: { $ref: '#/components/schemas/PosOrderItem' } } subtotal: { type: number, format: float } tax_total: { type: number, format: float } discount_total: { type: number, format: float } tip: { type: number, format: float } total: { type: number, format: float } payment_method: { type: string, enum: [cash, card, bizum, transfer, mixed, pending] } invoice_number: { type: string, nullable: true, description: "Formato YYYY-NNNN-zona" } verifactu_hash: { type: string, nullable: true } source: { type: string, enum: [staff, portal, guest, kiosk, channel] } created_at: { type: string, format: date-time } paid_at: { type: string, format: date-time, nullable: true } PosOrderItem: type: object properties: product_id: { type: string } name: { type: string } qty: { type: number } unit_price: { type: number, format: float } modifiers: { type: array, items: { type: object } } course: { type: string, enum: [appetizer, starter, main, dessert, drink], nullable: true } notes: { type: string, nullable: true } line_total: { type: number, format: float } PosProduct: type: object properties: id: { type: string } category_id: { type: string } zone_id: { type: string, nullable: true } name: { type: string } name_i18n: { type: object, additionalProperties: { type: string }, nullable: true } sku: { type: string, nullable: true } price: { type: number, format: float } tax_rate: { type: number, format: float, description: "% IVA (0, 4, 10, 21)" } active: { type: boolean } sold_out: { type: boolean } stock: { type: integer, nullable: true } image_url: { type: string, nullable: true } CashSession: type: object properties: id: { type: string } zone_id: { type: string } z_number: { type: integer } opened_at: { type: string, format: date-time } closed_at: { type: string, format: date-time, nullable: true } opening_cash: { type: number, format: float } closing_cash: { type: number, format: float, nullable: true } expected_cash: { type: number, format: float, nullable: true } variance: { type: number, format: float, nullable: true } operator_id: { type: string } status: { type: string, enum: [open, closed] } SignupRequest: type: object required: [business_name, subdomain, admin_email, admin_password] properties: business_name: { type: string } subdomain: { type: string, description: "3-32 chars [a-z0-9-], reserved list excluded" } admin_email: { type: string, format: email } admin_password: { type: string, description: "min 8, upper+lower+digit (OWASP policy)" } plan: { type: string, enum: [trial, starter, pro], default: trial } zone_kind: { type: string, enum: [playa, restaurante], default: playa } accept_terms: { type: boolean } BillingCheckoutSession: type: object properties: checkout_url: { type: string, format: uri } session_id: { type: string } expires_at: { type: string, format: date-time } VerifactuRecord: type: object properties: id: { type: integer } zone_id: { type: string } source_type: { type: string, enum: [pos_order] } source_id: { type: string } chain_index: { type: integer } prev_hash: { type: string, nullable: true } record_hash: { type: string } invoice_number: { type: string } amount: { type: number, format: float } status: { type: string, enum: [pending, sent, accepted, rejected, manual_hold, mock] } xml_payload: { type: string, nullable: true } aeat_csv: { type: string, nullable: true, description: "Código de Verificación de Aeat" } qr_url: { type: string, nullable: true } retries: { type: integer } last_error: { type: string, nullable: true } created_at: { type: string, format: date-time } UpdateManifest: type: object properties: version: { type: string, example: "1.0.5" } download_url: { type: string, format: uri, nullable: true } notes: { type: string, nullable: true } released_at: { type: string, format: date-time, nullable: true } UpdateCheckResponse: type: object properties: status: { type: string, enum: [ok, no_remote_configured, fetch_failed, bad_manifest] } current: { type: string, description: "Versión local (package.json)" } latest: { type: string, nullable: true } update_available: { type: boolean } notes: { type: string, nullable: true } download_url: { type: string, nullable: true } released_at: { type: string, nullable: true } manifest_url: { type: string, nullable: true } checked_at: { type: integer, description: "Unix timestamp" } LoyaltyPoints: type: object properties: client_id: { type: string } zone_id: { type: string } balance: { type: integer } lifetime: { type: integer } tier_code: { type: string, nullable: true } updated_at: { type: string, format: date-time } Channel: type: object properties: id: { type: string } zone_id: { type: string } provider: { type: string, enum: [booking, hotusa, expedia, generic_ota] } active: { type: boolean } credentials_meta: { type: object, description: "Booleans 'has_api_key', etc. — NUNCA expone secretos" } created_at: { type: string, format: date-time } WeatherCondition: type: object properties: date: { type: string, format: date } condition: { type: string, example: "rain" } temp_min: { type: number, format: float } temp_max: { type: number, format: float } wind_kmh: { type: number, format: float } rain_mm: { type: number, format: float } adverse: { type: boolean, description: "Cruza umbrales configurados" } paths: /api.php?resource=login: post: summary: Iniciar sesión security: [] requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: { type: string } password: { type: string } responses: '200': description: Login OK, devuelve session_token content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: token: { type: string } user: { $ref: '#/components/schemas/User' } '401': { description: Credenciales inválidas } '429': { description: Bloqueado por demasiados intentos } /api.php?resource=reservations: get: summary: Lista reservas parameters: - name: zone_id in: query schema: { type: string } - name: from in: query schema: { type: string, format: date } - name: to in: query schema: { type: string, format: date } - name: status in: query schema: { type: string, enum: [active, completed, cancelled] } responses: '200': description: Lista de reservas content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: array items: { $ref: '#/components/schemas/Reservation' } post: summary: Crea una reserva requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/Reservation' } responses: '201': { description: Creada } '409': { description: Conflicto (puesto ocupado) } /api.php?resource=reservations&id={id}: put: summary: Actualiza una reserva (campos parciales) description: | Actualiza uno o varios campos de la reserva. Si `status` cambia a `cancelled` y la reserva tiene `portal_user_id`, se envía push al cliente portal (v3.33.8). Si `status` cambia a `completed`, se envía push de agradecimiento. parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object properties: person_name: { type: string } phone: { type: string } notes: { type: string } start_date: { type: string, format: date } start_time: { type: string } end_time: { type: string } duration: { type: number } price: { type: number } payment_method: { type: string } discount: { type: number } discount_reason: { type: string } status: { type: string, enum: [active, completed, cancelled] } responses: '200': { description: Actualizada } '400': { description: Nada que actualizar } delete: summary: Cancela una reserva (soft-delete) description: | Marca la reserva como `cancelled`. Si tiene `portal_user_id`, envía push al cliente portal con mensaje "Reserva cancelada por el establecimiento" (v3.33.8). parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: { '200': { description: Cancelada } } /api.php?resource=portal: post: summary: Crea una reserva desde el portal cliente (sin autenticación de staff) description: | Crea reserva con `source=portal` y `payment_method=pending`. Si incluye `portal_user_id`, lo persiste en la reserva y envía push de confirmación al cliente registrado. Siempre envía push al role `admin` notificando la nueva reserva (v3.33.8). Requiere `portal_enabled=1` en `app_config`. security: [] requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/Reservation' - properties: portal_user_id: { type: string, nullable: true } responses: '200': { description: Reserva creada } '403': { description: Portal desactivado } '409': { description: Puesto ya reservado } /api.php?resource=portal&id={id}: delete: summary: Cancela una reserva desde el portal cliente (sin autenticación de staff) description: | Solo puede cancelar reservas con `source=portal` y `status=active`. Aplica las reglas de cancelación de la zona (hoursNotice, penalty). Envía push al role `admin` notificando la cancelación (v3.33.8). security: [] parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: Cancelada content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: cancelled: { type: boolean } penalty: { type: integer, description: Penalización en % aplicada } '403': { description: Cancelación no permitida por las reglas de la zona } '404': { description: Reserva no encontrada } /api.php?resource=clients: get: summary: Lista clientes (CRM) parameters: - { name: q, in: query, schema: { type: string }, description: Búsqueda por nombre o teléfono } responses: '200': description: Lista content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: array items: { $ref: '#/components/schemas/Client' } /healthcheck.php: get: summary: Healthcheck (BBDD + escritura logs + disco) security: [] parameters: - { name: simple, in: query, schema: { type: integer, enum: [0, 1] }, description: "1=respuesta texto OK/FAIL para monitores baratos" } responses: '200': { description: Healthy } '503': { description: Degraded } /ical.php: get: summary: Feed iCal con las reservas security: [] parameters: - { name: token, in: query, required: true, schema: { type: string }, description: session_token del usuario } - { name: zone, in: query, schema: { type: string } } responses: '200': description: Calendar feed (.ics) content: text/calendar: schema: { type: string } /sse.php: get: summary: Server-Sent Events para updates en tiempo real security: [] parameters: - { name: token, in: query, required: true, schema: { type: string } } - { name: zone, in: query, schema: { type: string } } responses: '200': description: Stream SSE content: text/event-stream: schema: { type: string } /push.php?action=status: get: summary: Estado del sistema Push (claves VAPID, suscriptores, errores) responses: '200': { description: Push status } '409': { description: Push no configurado } /push.php?action=send: post: summary: Envía una notificación push (broadcast o por rol/zona/usuario) requestBody: required: true content: application/json: schema: type: object required: [title, body] properties: title: { type: string } body: { type: string } url: { type: string } tag: { type: string } target: { type: string, enum: [broadcast, role, zone, user, portal_user], default: broadcast } role: { type: string, description: Requerido si target=role } zone_id: { type: string, description: Requerido si target=zone } target_id: { type: string, description: Requerido si target=user o portal_user } responses: '200': { description: Notificaciones enviadas } '409': { description: Push no configurado } /admin-tools.php?action=audit_list: get: summary: Lista entradas del audit log (solo superadmin) parameters: - { name: from, in: query, schema: { type: string, format: date } } - { name: to, in: query, schema: { type: string, format: date } } - { name: action_filter, in: query, schema: { type: string } } - { name: user, in: query, schema: { type: string } } - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } } responses: '200': description: Lista content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: rows: { type: array, items: { $ref: '#/components/schemas/AuditEntry' } } count: { type: integer } /admin-tools.php?action=rotate_cron_secret: post: tags: [Admin] summary: Rota el cron_secret (superadmin) responses: '200': description: Nuevo secreto generado (mostrado UNA vez) content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: new_secret: { type: string } rotated_at: { type: string, format: date-time } cron_example: { type: string } # ============================================================================ # Endpoints añadidos desde v3.33 (auditados en v1.0.4) — reference-level # ============================================================================ /api.php?resource=auth&action=login: post: tags: [Auth] summary: Login (v2 con TOTP + rate limit) security: [] requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: { type: string } password: { type: string } totp: { type: string, nullable: true, description: "Solo si totp_pending_token activo" } totp_pending_token: { type: string, nullable: true } responses: '200': description: Login OK o TOTP pendiente content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: token: { type: string } user: { $ref: '#/components/schemas/User' } totp_required: { type: boolean } totp_pending_token: { type: string } '401': { description: Credenciales inválidas } '429': { description: Rate limited (10 IP/15m o 5 user/15m) } /api.php?resource=auth&action=logout: post: tags: [Auth] summary: Invalida la sesión actual responses: '200': { description: Logout OK } /api.php?resource=auth&action=verify_totp: post: tags: [Auth] summary: Verifica código TOTP tras login con totp_required security: [] requestBody: required: true content: application/json: schema: type: object required: [totp_pending_token, totp] properties: totp_pending_token: { type: string } totp: { type: string, description: "6 dígitos o recovery code" } responses: '200': { description: TOTP OK, token emitido } '401': { description: Código inválido } /api.php?resource=tenant_info: get: tags: [Tenants] summary: Información pública del tenant resuelto (sin auth) security: [] parameters: - $ref: '#/components/parameters/TenantId' responses: '200': description: Tenant info (logo, colores, nombre) content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: id: { type: string } name: { type: string } subdomain: { type: string } branding_json: { type: object } /api.php?resource=signup: post: tags: [Tenants] summary: Self-service signup (crear tenant + admin + trial 14d) security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SignupRequest' } responses: '201': description: Tenant creado content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: tenant: { $ref: '#/components/schemas/Tenant' } admin_token: { type: string } tenant_url: { type: string, format: uri } '400': { description: Validación inválida (subdomain reservado, password débil) } '409': { description: Subdomain ya existe } '429': { description: Rate limited (3 signups/IP/h) } /api.php?resource=billing&action=create_checkout: post: tags: [Billing] summary: Crea Stripe Checkout Session para upgrade plan requestBody: required: true content: application/json: schema: type: object required: [plan] properties: plan: { type: string, enum: [starter, pro, enterprise] } return_url: { type: string, format: uri } responses: '200': description: URL de checkout content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: { $ref: '#/components/schemas/BillingCheckoutSession' } '409': { description: Stripe no configurado en el tenant } /api.php?resource=billing&action=portal: post: tags: [Billing] summary: Crea Stripe Billing Portal Session (gestión sub del tenant) responses: '200': description: URL del portal Stripe content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: portal_url: { type: string, format: uri } /api.php?resource=billing&action=status: get: tags: [Billing] summary: Estado actual del plan + sub Stripe del tenant responses: '200': description: Estado plan content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: { $ref: '#/components/schemas/Tenant' } /api.php?resource=verifactu: get: tags: [Verifactu] summary: Lista records Verifactu del tenant (con paginación) parameters: - { name: zone_id, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string, enum: [pending, sent, accepted, rejected, manual_hold] } } - { name: from, in: query, schema: { type: string, format: date } } - { name: to, in: query, schema: { type: string, format: date } } - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 500 } } - { name: offset, in: query, schema: { type: integer, default: 0 } } responses: '200': description: Lista records content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: rows: { type: array, items: { $ref: '#/components/schemas/VerifactuRecord' } } total: { type: integer } /api.php?resource=verifactu&action=verify_chain: get: tags: [Verifactu] summary: Verifica integridad de cadena hash SHA-256 parameters: - { name: zone_id, in: query, required: true, schema: { type: string } } responses: '200': description: Resultado verificación content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: valid: { type: boolean } total: { type: integer } first_break: { type: integer, nullable: true } /api.php?resource=verifactu&action=retry: post: tags: [Verifactu] summary: Re-encolar records en manual_hold para reintento AEAT requestBody: content: application/json: schema: type: object properties: record_id: { type: integer, description: "Si omitido, retry todos los manual_hold" } responses: '200': { description: Retry agendado } /api.php?resource=verifactu_config: get: tags: [Verifactu] summary: Config Verifactu del tenant (NIF, modo, endpoint AEAT) responses: '200': description: Config content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: nif: { type: string } mode: { type: string, enum: [mock, sif, verifactu] } region: { type: string, enum: [aeat, tbai_bizkaia, tbai_gipuzkoa, tbai_araba, tbai_navarra] } environment: { type: string, enum: [preprod, prod] } has_cert: { type: boolean } cert_expires: { type: string, format: date-time, nullable: true } /api.php?resource=updates_check: get: tags: [Admin] summary: Comprueba si hay actualización de Playa Reservas disponible description: | Compara `package.json:version` con un manifest remoto configurable. NO auto-actualiza. Solo informa al admin (`v3.57.32` + `v3.57.35`). El backend cachea el resultado por 6h para no bombardear el manifest URL. responses: '200': description: Estado update check content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: { $ref: '#/components/schemas/UpdateCheckResponse' } post: tags: [Admin] summary: Persiste URL del manifest remoto + fuerza re-fetch requestBody: required: true content: application/json: schema: type: object properties: manifest_url: { type: string, format: uri, nullable: true } responses: '200': { description: URL guardada } /api.php?resource=pos_orders: get: tags: [POS] summary: Lista pedidos POS de la zona parameters: - { name: zone_id, in: query, required: true, schema: { type: string } } - { name: status, in: query, schema: { type: string } } - { name: from, in: query, schema: { type: string, format: date } } - { name: to, in: query, schema: { type: string, format: date } } responses: '200': description: Lista content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: array items: { $ref: '#/components/schemas/PosOrder' } post: tags: [POS] summary: Crea pedido POS (staff o portal con dual-auth) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PosOrder' } responses: '201': { description: Pedido creado, push enviado a kitchen/portal } '409': { description: Stock insuficiente o conflicto items } /api.php?resource=pos_orders&id={id}&action=add_items: post: tags: [POS] summary: Añade items a pedido existente (decrement atómico stock) parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [items] properties: items: { type: array, items: { $ref: '#/components/schemas/PosOrderItem' } } responses: '200': { description: Items añadidos } '409': { description: Stock insuficiente } /api.php?resource=pos_orders&id={id}&action=pay: post: tags: [POS] summary: Cobra pedido (cash/card/bizum/mixed) + hook Verifactu parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [payment_method, amount] properties: payment_method: { type: string, enum: [cash, card, bizum, transfer, mixed] } amount: { type: number, format: float } tip: { type: number, format: float, default: 0 } splits: { type: array, description: "Si payment_method=mixed", items: { type: object } } responses: '200': { description: Pedido cobrado, factura Verifactu emitida } '400': { description: Importe no cuadra con total } /api.php?resource=pos_products: get: tags: [POS] summary: Lista productos POS (con i18n + modificadores) parameters: - { name: zone_id, in: query, required: true, schema: { type: string } } - { name: category_id,in: query, schema: { type: string } } - { name: active, in: query, schema: { type: boolean } } responses: '200': description: Lista productos content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: array items: { $ref: '#/components/schemas/PosProduct' } post: tags: [POS] summary: Crea producto POS requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PosProduct' } responses: '201': { description: Creado } /api.php?resource=pos_cash_sessions: get: tags: [POS] summary: Lista cash sessions (cierre Z) de la zona parameters: - { name: zone_id, in: query, required: true, schema: { type: string } } responses: '200': description: Lista content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: array items: { $ref: '#/components/schemas/CashSession' } post: tags: [POS] summary: Abre cash session requestBody: required: true content: application/json: schema: type: object required: [zone_id, opening_cash] properties: zone_id: { type: string } opening_cash: { type: number, format: float } responses: '201': { description: Cash session abierta } '409': { description: Ya hay session abierta en esa zona } /api.php?resource=pos_cash_sessions&id={id}&action=close: post: tags: [POS] summary: Cierra cash session (cierre Z) con arqueo parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [closing_cash] properties: closing_cash: { type: number, format: float } notes: { type: string, nullable: true } responses: '200': { description: Session cerrada con variance calculada } /api.php?resource=pos_reports&action=daily: get: tags: [Reports] summary: Reporte diario POS (cierre Z per-zona, exactamente 1 zona) parameters: - { name: zone_id, in: query, required: true, schema: { type: string } } - { name: date, in: query, required: true, schema: { type: string, format: date } } responses: '200': { description: Daily report (ventas, items, métodos) } '400': { description: Multi-zone no permitido en daily } /api.php?resource=pos_reports&action=range_kpis: get: tags: [Reports] summary: KPIs rango de fechas POS (multi-zone via zones=A,B,C) parameters: - { name: zones, in: query, schema: { type: string, description: "CSV de zone_ids (max 50)" } } - { name: zone, in: query, schema: { type: string, description: "Legacy single zone (auto-promoted)" } } - { name: from, in: query, required: true, schema: { type: string, format: date } } - { name: to, in: query, required: true, schema: { type: string, format: date } } responses: '200': description: KPIs agregados content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: zones: { type: array, items: { type: string } } revenue_total: { type: number, format: float } orders_total: { type: integer } avg_ticket: { type: number, format: float } revenue_by_day: { type: array, items: { type: object } } /api.php?resource=reports&action=executive_pl: get: tags: [Reports] summary: Reporte ejecutivo P&L + YoY (admin+payroll cross-zone) parameters: - { name: zone_ids, in: query, schema: { type: string, description: "CSV. Omitido = todas las zonas del tenant" } } - { name: from, in: query, required: true, schema: { type: string, format: date } } - { name: to, in: query, required: true, schema: { type: string, format: date } } responses: '200': { description: P&L + comparativa YoY } /api.php?resource=pos_guest&action=order: post: tags: [POS] summary: Crea pedido desde QR-mesa (sin auth — table+zone validados) security: [] requestBody: required: true content: application/json: schema: type: object required: [zone_id, table_id, items] properties: zone_id: { type: string } table_id: { type: string } items: { type: array, items: { $ref: '#/components/schemas/PosOrderItem' } } notes: { type: string, nullable: true } portal_user_id: { type: string, nullable: true, description: "Si el cliente está logueado en portal" } responses: '201': { description: Pedido creado, push a kitchen + POS } /api.php?resource=pos_guest&action=pay: post: tags: [POS] summary: Cobra pedido QR-mesa con Stripe Checkout (tip max 50%/50€) security: [] requestBody: required: true content: application/json: schema: type: object required: [order_id, payment_method] properties: order_id: { type: string } payment_method: { type: string, enum: [card, bizum] } tip: { type: number, format: float, default: 0 } responses: '200': { description: Stripe checkout URL devuelta } /api.php?resource=portal_users&action=register: post: tags: [Portal] summary: Registro de cliente en portal (email/WhatsApp/Google) security: [] requestBody: required: true content: application/json: schema: type: object required: [provider, display_name] properties: provider: { type: string, enum: [email, whatsapp, google] } email: { type: string, format: email, nullable: true } phone: { type: string, nullable: true } password: { type: string, nullable: true, description: "Solo email provider" } google_id_token: { type: string, nullable: true, description: "Solo google provider" } display_name: { type: string } accept_terms: { type: boolean } responses: '201': description: Portal user creado + session_token content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: user: { $ref: '#/components/schemas/PortalUser' } session_token: { type: string } /api.php?resource=portal_users&action=login: post: tags: [Portal] summary: Login portal (email/password o Google ID token) security: [] requestBody: required: true content: application/json: schema: type: object required: [provider] properties: provider: { type: string, enum: [email, google] } email: { type: string, format: email, nullable: true } password: { type: string, nullable: true } google_id_token: { type: string, nullable: true } responses: '200': { description: Login OK } '401': { description: Credenciales inválidas } /api.php?resource=portal_users&action=self_update: post: tags: [Portal] summary: Cliente actualiza su propio perfil (RGPD Art.16) security: - portalAuth: [] requestBody: required: true content: application/json: schema: type: object properties: display_name: { type: string } email: { type: string, format: email } phone: { type: string } password: { type: string, description: "Mínimo 8 chars, política OWASP" } responses: '200': { description: Perfil actualizado } /api.php?resource=portal_users&action=delete_account: post: tags: [Portal] summary: Cliente solicita borrado/anonimización (RGPD Art.17) security: - portalAuth: [] responses: '200': { description: Account anonimizada (soft-delete con retención legal) } /api.php?resource=loyalty: get: tags: [Loyalty] summary: Saldo puntos del cliente parameters: - { name: client_id, in: query, required: true, schema: { type: string } } - { name: zone_id, in: query, required: true, schema: { type: string } } responses: '200': description: Saldo + tier content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: { $ref: '#/components/schemas/LoyaltyPoints' } /api.php?resource=loyalty_coupons&action=redeem: post: tags: [Loyalty] summary: Canjea cupón (validation: scope, tier, fecha, uso único) requestBody: required: true content: application/json: schema: type: object required: [code, order_id] properties: code: { type: string } order_id: { type: string } responses: '200': { description: Cupón aplicado } '404': { description: Cupón no válido o expirado } /api.php?resource=external_channels: get: tags: [Channels] summary: Lista canales OTAs del tenant (credenciales OCULTAS) responses: '200': description: Lista (credentials_meta booleans, NO secretos) content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: array items: { $ref: '#/components/schemas/Channel' } post: tags: [Channels] summary: Crea canal OTA (credentials cifradas server-side con XSalsa20-Poly1305) requestBody: required: true content: application/json: schema: type: object required: [zone_id, provider, credentials] properties: zone_id: { type: string } provider: { type: string, enum: [booking, hotusa, expedia, generic_ota] } credentials: { type: object, description: "api_key/secret/health_url cifrados antes de persistir" } responses: '201': { description: Canal creado } /api.php?resource=external_channels&id={id}&action=test: post: tags: [Channels] summary: Ping al provider con credenciales descifradas on-demand parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': { description: Provider responde } '502': { description: Provider unreachable } /api.php?resource=weather: get: tags: [System] summary: Forecast 5 días + condición actual (proxy OpenWeatherMap cacheado) parameters: - { name: zone_id, in: query, required: true, schema: { type: string } } responses: '200': description: Weather data con badge "adverse" si cruza umbrales content: application/json: schema: allOf: - $ref: '#/components/schemas/ApiResponse' - properties: data: type: object properties: current: { $ref: '#/components/schemas/WeatherCondition' } forecast: { type: array, items: { $ref: '#/components/schemas/WeatherCondition' } } /api.php?resource=weather_settings: get: tags: [System] summary: Config weather del tenant (API key OWM, umbrales, ciudad) responses: '200': { description: Config } put: tags: [System] summary: Actualiza config weather (admin only) requestBody: required: true content: application/json: schema: type: object properties: api_key: { type: string, description: "Cifrado server-side antes de persistir" } city: { type: string, nullable: true } lat: { type: number, format: float, nullable: true } lng: { type: number, format: float, nullable: true } threshold_rain_mm: { type: number, format: float, default: 3 } threshold_wind_kmh:{ type: number, format: float, default: 35 } threshold_temp_min:{ type: number, format: float, default: 15 } responses: '200': { description: Config actualizada } /php/billing-webhook.php: post: tags: [Billing] summary: Webhook Stripe (eventos suscripción tenant SaaS) security: [] description: | Endpoint de Stripe webhook que actualiza `tenants.status` y `tenants.stripe_subscription_id` según eventos: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`. Auth via Stripe Signature (`Stripe-Signature` header) — NO requiere token JWT. Logs ambiguous tenant en Sentry. requestBody: required: true content: application/json: schema: { type: object } responses: '200': { description: Evento procesado } '400': { description: Signature inválida }