# Restaurant staff API

> Kitchen queue, floor plan, cashier, deliveries and reservations for paired staff devices.

Kitchen queue, floor plan, cashier, deliveries and reservations for paired staff devices.

| Field | Value |
| --- | --- |
| Base URL | `https://fina.do/api/staff/v1` |
| Authentication | `X-Staff-Device` (paired device) + `Authorization: Staff <token>` (PIN shift) |
| OpenAPI 3.1 | [/api/staff/v1/openapi.json](https://fina.do/api/staff/v1/openapi.json) |
| Built for | Kitchen displays, POS and waiter apps paired by the owner |

Two factors: the business owner pairs a device from the panel (`POST /devices/pair` exchanges the pairing code for the device secret sent in `X-Staff-Device`), then each employee opens a shift with their PIN (`POST /sessions` → `Authorization: Staff <token>`). Every shift route checks a role permission.

### Endpoints

Base URL: `https://fina.do/api/staff/v1`

| Method | Path | Summary | Auth |
| --- | --- | --- | --- |
| POST | `/devices/pair` | Canjea el código del panel y entrega el secreto del dispositivo | App key |
| POST | `/devices/current/unpair` | Desvincula la tablet con el PIN de alguien con device.manage: revoca su secreto y cierra sus turnos | Device |
| GET | `/roster` | Personal que puede entrar en este dispositivo. Cada persona trae `pin_length` (4, 5 o 6) para que el teclado pare ahí; null = PIN puesto antes de que guardáramos el largo | Device |
| POST | `/sessions` | Abre un turno con el PIN | Device |
| DELETE | `/sessions/current` | Cierra el turno | Device + Staff shift |
| GET | `/me` | Empleado, rol, permisos y vistas. `business.business_type` (bakery, restaurant…) para el vocabulario de la app | Device + Staff shift |
| GET | `/push-token` | Estado del token de este dispositivo: {registered, employee_id, is_me, platform, updated_at} | Device + Staff shift |
| PUT | `/push-token` | Registra el token FCM de este dispositivo y lo ata a la persona del turno; cuerpo {token, platform: ios\|android, locale, app_version?} | Device + Staff shift |
| DELETE | `/push-token` | Apaga los avisos de este dispositivo (no exige turno): {revoked} | Device |
| GET | `/kitchen` | Cola de cocina. Panadería: cada línea trae `message` («Mensaje en la torta») y `reference_image_url` (foto firmada, ≤ 10 min) y el pedido `scheduled_for` + `window_start_min`/`window_end_min` si es un encargo. Cada pedido trae `payment` (ver `OrderPayment`) | Device + Staff shift |
| POST | `/orders/{id}/advance` | Avanza pendiente → en cocina → listo | Device + Staff shift |
| POST | `/orders/{id}/serve` | Marca servido un pedido listo | Device + Staff shift |
| POST | `/orders/{id}/void` | Anula un pedido sin cobrar, con motivo | Device + Staff shift |
| POST | `/orders/{id}/charge` | Cobra el pedido y registra el ingreso; cuerpo opcional {payment_method: cash\|card\|transfer\|other, cash_received_minor} | Device + Staff shift |
| GET | `/cashier` | Pedidos pendientes de cobro (los de las últimas 36 h y los encargos programados desde ayer). Cada pedido trae `payment`: si `on_credit` es true (mayoreo a crédito) NO se cobra; `collect_minor` es lo que hay que cobrar | Device + Staff shift |
| GET | `/busy` | «Mucha demanda»: si está activo (a mano o por la cola), minutos extra y umbral automático | Device + Staff shift |
| POST | `/busy` | Enciende o apaga «Mucha demanda» y los minutos extra; el umbral automático requiere device.manage. Al encenderlo avisa a los pedidos en curso | Device + Staff shift |
| POST | `/orders/{id}/accept` | Acepta el pedido de un cliente (cocina o caja). Sin respuesta en 10 min se cancela solo | Device + Staff shift |
| POST | `/orders/{id}/reject` | Rechaza el pedido de un cliente con un motivo: se cancela y se devuelve lo pagado con Fina Pay | Device + Staff shift |
| GET | `/deliveries` | Pedidos a domicilio en curso: cliente, dirección, cobro y quién lo lleva. Cada pedido trae `payment` (`collect_minor` = lo que se cobra al entregar; 0 si `on_credit` o ya pagado → «A crédito · no cobrar»). `summary.to_collect_minor` y `cash_collected_minor` excluyen lo a crédito | Device + Staff shift |
| POST | `/deliveries/{id}/dispatch` | Salgo con el pedido: pasa a en camino | Device + Staff shift |
| POST | `/deliveries/{id}/deliver` | Pedido entregado | Device + Staff shift |
| GET | `/cashier/close` | Cierre de caja del día (?date=YYYY-MM-DD): totales por método, por cajero, efectivo esperado y anulados | Device + Staff shift |
| GET | `/floor` | Plano con el estado vivo de cada mesa | Device + Staff shift |
| GET | `/tables/{id}` | Cuenta de la mesa | Device + Staff shift |
| PATCH | `/tables/{id}` | Asigna la mesa a un mesero | Device + Staff shift |
| POST | `/tables/{id}/orders` | Toma un pedido para la mesa | Device + Staff shift |
| POST | `/tables/{id}/close` | Cobra todo lo pendiente de la mesa y la libera; cuerpo opcional {payment_method, cash_received_minor} | Device + Staff shift |
| GET | `/menu` | La carta (también con menu.availability). Cada plato trae `model_3d` (modelo 3D publicado de «Platos en AR») o null | Device + Staff shift |
| POST | `/menu/{dishId}/availability` | Agota o devuelve un plato (86), o una opción o extra suyo con value_id | Device + Staff shift |
| GET | `/calls` | Llamadas de mesa abiertas y atendidas | Device + Staff shift |
| POST | `/calls/{id}/ack` | Atiende una llamada | Device + Staff shift |
| POST | `/calls/{id}/resolve` | Resuelve una llamada | Device + Staff shift |
| GET | `/reservations` | Reservas del día (?date=YYYY-MM-DD) | Device + Staff shift |
| POST | `/reservations/{id}/status` | Confirma, sienta o cancela una reserva | Device + Staff shift |
| GET | `/dealer/leads` | Concesionario, «mis prospectos» (?status=open\|all): los asignados al vendedor ligado a este empleado; con dealer.leads.view_all, los de todo el equipo. Sin vendedor ligado devuelve linked:false y lista vacía | Device + Staff shift |
| GET | `/dealer/test-drives` | Concesionario, «mis pruebas de manejo» (?date=YYYY-MM-DD&days=1..7): las del vendedor ligado a este empleado; con dealer.test_drives.view_all, las de todo el equipo | Device + Staff shift |
| GET | `/farm/orders` | Granja, pedidos (?filter=pending\|today\|upcoming\|active\|done) con el contador de cada pestaña: {orders, counts}. Cada pedido trae `on_credit` y `payment` (`OrderPayment`) | Device + Staff shift |
| GET | `/farm/today` | Granja, «Hoy»: foto del día en una respuesta (?low_stock_threshold=1..1000, 5 por defecto): {date, generated_at, now_min, currency_code, counts{pending,today,active,delivered_today,cancelled_today}, sold_today, next_window\|null, alerts, stock\|null (solo con farm.stock.manage), couriers} | Device + Staff shift |
| GET | `/farm/orders/{id}` | Granja, detalle de un pedido: artículos, línea de tiempo (by/actor_name/note), avisos y nota del cliente | Device + Staff shift |
| POST | `/farm/orders/{id}/approve` | Granja, aprueba un pedido que espera aceptación (409 ORDER_ALREADY_APPROVED / ORDER_ALREADY_CANCELLED) | Device + Staff shift |
| POST | `/farm/orders/{id}/reject` | Granja, rechaza un pedido que espera aceptación; cuerpo opcional {reason}. Se devuelve lo pagado | Device + Staff shift |
| POST | `/farm/orders/{id}/status` | Granja, mueve el estado {status: preparing\|ready_for_pickup\|on_the_way\|delivered, courier_id?}; solo hacia adelante (409 INVALID_TRANSITION / ORDER_NOT_APPROVED) | Device + Staff shift |
| GET | `/farm/couriers` | Granja, repartidores activos para «¿Quién lo lleva?» | Device + Staff shift |
| GET | `/farm/packing` | Granja, lista de empaque del día (?date=YYYY-MM-DD) por franja: productos sumados, pedidos y marcas de empacado | Device + Staff shift |
| POST | `/farm/packing/marks` | Granja, marca empacada una línea {kind:"line", date, window_key, line_key} o un pedido {kind:"order", order_id} | Device + Staff shift |
| DELETE | `/farm/packing/marks/{id}` | Granja, quita una marca de empacado | Device + Staff shift |
| POST | `/farm/orders/{id}/cancel` | Granja, cancela un pedido {reason}: avisa al cliente y devuelve el stock | Device + Staff shift |
| POST | `/farm/orders/{id}/alerts` | Granja, avisa al cliente de un pedido {reason, message?} (push order.alert) | Device + Staff shift |
| POST | `/farm/orders/{id}/alerts/{alertId}/resolve` | Granja, marca resuelto un aviso de pedido | Device + Staff shift |
| GET | `/farm/notice` | Granja, aviso general vigente y cuántos clientes tienen la app: {notice\|null, app_customers} | Device + Staff shift |
| PUT | `/farm/notice` | Granja, publica o reemplaza el aviso general {level, title, message, until?, send_push} | Device + Staff shift |
| DELETE | `/farm/notice` | Granja, quita el aviso general | Device + Staff shift |
| GET | `/farm/products` | Granja, productos activos con stock, disponibilidad y variantes | Device + Staff shift |
| PATCH | `/farm/products/{id}` | Granja, agota, repone o ajusta el stock {available?, stock?, variant_id?} (disponible = stock mayor a 0) | Device + Staff shift |
| GET | `/appointments` | Citas, agenda del día (?date=YYYY-MM-DD&professional_id=<uuid>\|unassigned): con appointments.view las del profesional ligado a este empleado y las sin asignar; con appointments.view_all (que también abre la agenda), las de todo el equipo. Sin profesional ligado devuelve linked:false y lista vacía | Device + Staff shift |
| GET | `/appointments/pending` | Citas, solicitudes por aprobar desde hoy: {count, appointments} | Device + Staff shift |
| GET | `/appointments/{id}` | Citas, detalle con nota del cliente, nota interna, respuestas del formulario, precio y las acciones permitidas (actions). Fuera del alcance responde 404 | Device + Staff shift |
| POST | `/appointments/{id}/approve` | Citas, aprueba una solicitud (avisa al cliente). 409 APPOINTMENT_NOT_PENDING / APPOINTMENT_UNPAID | Device + Staff shift |
| POST | `/appointments/{id}/reject` | Citas, rechaza una solicitud {reason 3-200}: se cancela y se avisa al cliente. 409 APPOINTMENT_NOT_PENDING | Device + Staff shift |
| POST | `/appointments/{id}/complete` | Citas, marca completada una cita confirmada (desde 15 min antes de su inicio; no crea ingreso). 409 APPOINTMENT_NOT_STARTED / APPOINTMENT_STATE_CONFLICT | Device + Staff shift |
| POST | `/appointments/{id}/no-show` | Citas, marca «no asistió» una cita confirmada ya empezada (estado derivado no_show; no avisa al cliente). 409 APPOINTMENT_NOT_STARTED / APPOINTMENT_STATE_CONFLICT | Device + Staff shift |
| DELETE | `/appointments/{id}/no-show` | Citas, deshace «no asistió» el mismo día local del inicio o el siguiente. 409 APPOINTMENT_STATE_CONFLICT | Device + Staff shift |
| GET | `/appointments/{id}/slots` | Citas, huecos libres para reprogramar (?date=YYYY-MM-DD): {date, duration_min, professional_id, starts, reason: null\|closed\|service_unavailable} | Device + Staff shift |
| POST | `/appointments/{id}/reschedule` | Citas, reprograma a un hueco libre {date, start_min} (conserva duración y profesional; avisa al cliente). 409 SLOT_NOT_AVAILABLE / APPOINTMENT_STATE_CONFLICT | Device + Staff shift |
| POST | `/appointments/{id}/cancel` | Citas, cancela una cita {reason 3-200}: avisa al cliente y revierte el ingreso registrado si el negocio devuelve en cancelaciones. 409 APPOINTMENT_STATE_CONFLICT | Device + Staff shift |
| PUT | `/appointments/{id}/staff-note` | Citas, nota interna del equipo {note: ≤1000 \| null} (no la ve el cliente) | Device + Staff shift |
| GET | `/dealer/today` | Concesionario, «Hoy» del vendedor: {date, generated_at, linked, leads\|null, test_drives\|null}; exige algún permiso de ver (leads o pruebas), contadores del alcance, siguiente prueba y hasta 5 prospectos en «focus» | Device + Staff shift |
| GET | `/dealer/leads/{id}` | Concesionario, detalle de un prospecto del alcance: línea de tiempo con quién hizo cada cosa, seguimiento pendiente, etapas permitidas, motivos de pérdida y `actions` (404 fuera del alcance) | Device + Staff shift |
| POST | `/dealer/leads/{id}/stage` | Concesionario, mueve la etapa {stage: contacted\|test_drive\|negotiation\|financing\|lost, note?, lost_reason?, lost_note?} (nunca won). 409 LEAD_STAGE_CONFLICT; 400 INVALID_REQUEST con details.reason | Device + Staff shift |
| POST | `/dealer/leads/{id}/activities` | Concesionario, registra llamada, WhatsApp, nota, visita o correo {kind, body?, follow_up_at?}: {activity_id, first_contact} | Device + Staff shift |
| POST | `/dealer/leads/{id}/follow-ups/{activityId}/complete` | Concesionario, cierra un seguimiento pendiente del prospecto: {done} | Device + Staff shift |
| GET | `/dealer/test-drives/{id}` | Concesionario, detalle de una prueba de manejo del alcance con hitos y `actions` (404 fuera del alcance) | Device + Staff shift |
| POST | `/dealer/test-drives/{id}/confirm` | Concesionario, confirma una prueba solicitada y avisa al cliente. 409 TEST_DRIVE_STATE_CONFLICT | Device + Staff shift |
| POST | `/dealer/test-drives/{id}/check-in` | Concesionario, check-in {license_verified: true, odometer_out: 0..3000000}. 400 ODOMETER_INVALID / INVALID_REQUEST (LICENSE_NOT_VERIFIED); 409 TEST_DRIVE_STATE_CONFLICT | Device + Staff shift |
| POST | `/dealer/test-drives/{id}/close` | Concesionario, cierra la prueba {odometer_in, outcome: liked\|undecided\|not_interested, feedback?}. 400 ODOMETER_INVALID (ODOMETER_LOWER); 409 TEST_DRIVE_STATE_CONFLICT | Device + Staff shift |
| POST | `/dealer/test-drives/{id}/no-show` | Concesionario, marca «no asistió» una prueba cuya hora ya pasó y programa un seguimiento: {status, follow_up_at}. 409 TEST_DRIVE_STATE_CONFLICT | Device + Staff shift |
| POST | `/dealer/test-drives/{id}/cancel` | Concesionario, cancela la prueba {reason 3-200} y avisa al cliente. 409 TEST_DRIVE_STATE_CONFLICT | Device + Staff shift |
| GET | `/store/today` | Tienda, la foto del día: contadores (pending, to_pack, ready, shipped, delivered_today, cancelled_today), vendido hoy, el más antiguo por aprobar y stock bajo (null sin store.stock.manage). ?low_stock_threshold=1..1000 | Device + Staff shift |
| GET | `/store/orders` | Tienda, pedidos por pestaña (?filter=pending\|to_pack\|ready\|shipped\|done) con el contador de cada una | Device + Staff shift |
| GET | `/store/orders/{id}` | Tienda, detalle de un pedido: artículos con foto, talla/color y SKU, historial con quién hizo cada cambio, guía de envío y las acciones permitidas (actions) | Device + Staff shift |
| POST | `/store/orders/{id}/approve` | Tienda, aprueba un pedido por aprobar (registra el ingreso y descuenta el stock, igual que el panel). 409 ORDER_ALREADY_APPROVED / ORDER_ALREADY_CANCELLED / NO_INCOME_ACCOUNT | Device + Staff shift |
| POST | `/store/orders/{id}/reject` | Tienda, rechaza un pedido por aprobar {reason 3-200}: se cancela y se avisa al cliente. 409 ORDER_ALREADY_APPROVED / ORDER_ALREADY_CANCELLED / ORDER_PAID (pagado en línea: se rechaza desde el panel para devolver el dinero) | Device + Staff shift |
| POST | `/store/orders/{id}/status` | Tienda, mueve la entrega {status: preparing\|ready_for_pickup\|on_the_way\|delivered, carrier?, tracking_code?} (guía solo al enviar). 409 ORDER_NOT_APPROVED / INVALID_TRANSITION | Device + Staff shift |
| GET | `/store/products` | Tienda, productos activos con stock y variantes con sus atributos (?filter=all\|low\|out&q=&low_stock_threshold=) | Device + Staff shift |
| PATCH | `/store/products/{id}` | Tienda, agota, repone o ajusta el stock {available?, stock?, variant_id?} (disponible = stock mayor a 0) | Device + Staff shift |
| GET | `/realestate/visits` | Inmobiliaria, visitas por bandeja (?tab=new\|today\|upcoming\|past; sin tab, «new» si hay nuevas): {tab, scope, is_agent, counts, unassigned_count, visits}. Con realestate.visits.view las del agente (employee) y las sin asignar si es agente público; con realestate.visits.view_all, todas | Device + Staff shift |
| GET | `/realestate/visits/{id}` | Inmobiliaria, detalle de una visita: propiedad, cliente, mensaje, nota interna, sellos, hora sugerida (suggested) y acciones permitidas (actions). Fuera del alcance responde 404 | Device + Staff shift |
| POST | `/realestate/visits/{id}/schedule` | Inmobiliaria, confirma con hora o cambia la hora {date: YYYY-MM-DD, time_min: 0-1439 múltiplo de 15, no en el pasado, hasta hoy + 90}. 409 VISIT_STATE_CONFLICT | Device + Staff shift |
| POST | `/realestate/visits/{id}/done` | Inmobiliaria, marca realizada una visita confirmada (desde 30 min antes) con resultado opcional {outcome: interested\|not_interested\|wants_another_visit\|made_offer\|null}. 409 VISIT_NOT_STARTED / VISIT_STATE_CONFLICT | Device + Staff shift |
| POST | `/realestate/visits/{id}/no-show` | Inmobiliaria, marca «no asistió» una visita confirmada ya empezada (estado derivado no_show; no avisa al cliente). 409 VISIT_NOT_STARTED / VISIT_STATE_CONFLICT | Device + Staff shift |
| DELETE | `/realestate/visits/{id}/no-show` | Inmobiliaria, deshace «no asistió» el mismo día local de la visita o el siguiente. 409 VISIT_STATE_CONFLICT | Device + Staff shift |
| POST | `/realestate/visits/{id}/cancel` | Inmobiliaria, cancela una visita pendiente o confirmada {reason 3-200}. 409 VISIT_STATE_CONFLICT | Device + Staff shift |
| PUT | `/realestate/visits/{id}/note` | Inmobiliaria, nota interna del equipo {note: ≤1000 \| null} (no la ve el cliente) | Device + Staff shift |
| POST | `/realestate/visits/{id}/contact` | Inmobiliaria, registra un contacto con el cliente {channel: call\|whatsapp\|email}: {contacted_at, last_contact_at, contact_count}. 409 VISIT_STATE_CONFLICT | Device + Staff shift |
| POST | `/realestate/visits/{id}/claim` | Inmobiliaria, un agente público toma una visita sin asignar. 409 VISIT_ALREADY_ASSIGNED / AGENT_NOT_AVAILABLE | Device + Staff shift |
| POST | `/realestate/visits/{id}/assign` | Inmobiliaria, asigna o quita el agente {agent_id: uuid \| null}; exige además realestate.visits.view_all (403 FORBIDDEN). Avisa al agente nuevo. 409 AGENT_NOT_AVAILABLE / VISIT_STATE_CONFLICT | Device + Staff shift |
| GET | `/realestate/agents` | Inmobiliaria, agentes públicos activos para «Asignar agente»: {agents: [{id, name, photo_url, open_visits, is_me}]} | Device + Staff shift |
| GET | `/realestate/properties` | Inmobiliaria, propiedades en alcance (?status=available\|reserved\|all, «available» por defecto): {scope, counts{available,reserved,other}, properties} con visitas pendientes y próximas | Device + Staff shift |
| PATCH | `/realestate/properties/{id}` | Inmobiliaria, cambia una propiedad entre disponible y reservada {status: available\|reserved}. 409 PROPERTY_STATE_CONFLICT (desde alquilada, vendida o inactiva) | Device + Staff shift |
| GET | `/rentcar/today` | Rent Car, «Hoy»: {date, generated_at, counts{pending,pickups,returns,not_returned,active}, fleet{available,rented,in_shop,inactive}, pickups, returns}. 404 NOT_FOUND si el negocio no tiene el módulo | Device + Staff shift |
| GET | `/rentcar/reservations` | Rent Car, reservas de vehículos (?tab=pending\|pickups\|returns\|active\|upcoming; sin tab, la primera con reservas): {tab, counts, reservations} | Device + Staff shift |
| GET | `/rentcar/reservations/{id}` | Rent Car, detalle: reserva, nota, servicios, inspecciones (URLs firmadas, inspected_by_name), comparison salida/llegada, pickup_reference y las acciones permitidas (actions) | Device + Staff shift |
| POST | `/rentcar/reservations/{id}/approve` | Rent Car, aprueba una reserva pendiente (avisa al cliente). 409 RESERVATION_STATE_CONFLICT / RESERVATION_AWAITING_PAYMENT (se confirma sola al pagar) | Device + Staff shift |
| POST | `/rentcar/reservations/{id}/reject` | Rent Car, rechaza una reserva pendiente {reason 3-200}: se cancela, se cancela el cobro pendiente y se avisa al cliente. 409 RESERVATION_STATE_CONFLICT | Device + Staff shift |
| POST | `/rentcar/reservations/{id}/photos` | Rent Car, sube una foto de inspección (multipart, campo file; 10 MB): {id, url}. 413 FILE_TOO_LARGE / 415 UNSUPPORTED_IMAGE / 429 UPLOAD_LIMIT / 503 STORAGE_UNAVAILABLE | Device + Staff shift |
| DELETE | `/rentcar/reservations/{id}/photos/{photoId}` | Rent Car, borra una foto que aún no está en una inspección guardada | Device + Staff shift |
| POST | `/rentcar/reservations/{id}/checkout` | Rent Car, check-out {odometer_km, fuel_eighths 0-8, notes?, photos[1-24]{id, angle, damage_note?}}: guarda la inspección de salida con el nombre del empleado y pasa a en curso. 409 RESERVATION_NOT_STARTED / VEHICLE_IN_SHOP / RESERVATION_STATE_CONFLICT; 400 INSPECTION_NEEDS_PHOTOS / INVALID_PHOTO | Device + Staff shift |
| POST | `/rentcar/reservations/{id}/checkin` | Rent Car, check-in (mismo cuerpo): guarda la inspección de llegada, completa la reserva y devuelve {status, inspection, comparison}. También recibe una completada por el cron (hasta 3 días). 409 INSPECTION_REQUIRED; 400 ODOMETER_BELOW_PICKUP {pickup_km} / INSPECTION_NEEDS_PHOTOS / INVALID_PHOTO | Device + Staff shift |
| GET | `/rentcar/fleet` | Rent Car, flota (?state=all\|available\|rented\|in_shop): {counts, vehicles} con estado derivado, alquiler en curso, próxima reserva y reservas por venir | Device + Staff shift |
| POST | `/rentcar/fleet/{id}/shop` | Rent Car, manda un vehículo al taller {note?≤200}: deja de verse en la app de clientes; las reservas no cambian. {state, upcoming_count}. 409 VEHICLE_RENTED / VEHICLE_IN_SHOP | Device + Staff shift |
| DELETE | `/rentcar/fleet/{id}/shop` | Rent Car, saca un vehículo del taller: {state: available\|rented}. 409 VEHICLE_NOT_IN_SHOP | Device + Staff shift |
| GET | `/loyalty` | Fidelidad, el programa resumido: {program: {name, status, points_label_singular, points_label_plural, currency_code, enrollment_mode, starts_at, ends_at, rules{per_purchase, per_visit, per_amount, min_purchase_minor}, self_check_in}\|null} | Device + Staff shift |
| GET | `/loyalty/members` | Fidelidad, busca socios (?q= nombre o teléfono&cursor=): {members, next_cursor}. Cada socio trae nombre, teléfono enmascarado («•••• 1234»; nunca el correo), saldo, estado y última actividad | Device + Staff shift |
| POST | `/loyalty/members` | Fidelidad, inscribe un cliente {client_id} o crea el cliente {name, phone} y lo inscribe: {created, client_created, member}. 403 LOYALTY_MEMBER_INACTIVE si está bloqueado | Device + Staff shift |
| GET | `/loyalty/members/by-card/{token}` | Fidelidad, el socio de una tarjeta escaneada; {token} es el token o la URL completa del QR (https://…/f/<token>, codificada). 404 LOYALTY_NOT_FOUND | Device + Staff shift |
| GET | `/loyalty/members/{id}` | Fidelidad, detalle del socio: saldo, canjes pendientes (pending_redemptions) y últimos 20 movimientos (movements) | Device + Staff shift |
| POST | `/loyalty/members/{id}/earn` | Fidelidad, acredita {kind: visit\|purchase\|visit_purchase, amount_minor?, store_order_id?, invoice_id?}. Idempotency-Key OBLIGATORIO (400 IDEMPOTENCY_KEY_REQUIRED). Con store_order_id (aprobado) o invoice_id (pagada) el origen es ese documento, del MISMO negocio y del cliente del socio (el pedido de sala sin cliente se permite asociar), y el IMPORTE lo toma el servidor (amount_minor se ignora); no da puntos dos veces. Sin documento, staff_purchase con amount_minor acotado a 5000000 (400 INVALID_REQUEST si lo supera) y siempre sujeto al tope diario de puntos. {status: awarded\|candidate\|duplicate, points, balance}. 409 LOYALTY_PROGRAM_INACTIVE / ORDER_STATE_CONFLICT / ORDER_CLIENT_MISMATCH / INVOICE_STATE_CONFLICT / INVOICE_CLIENT_MISMATCH | Device + Staff shift |
| POST | `/loyalty/members/{id}/adjust` | Fidelidad, ajusta puntos a mano {delta ≠ 0, reason 3-200}. Idempotency-Key OBLIGATORIO. 409 LOYALTY_INSUFFICIENT_POINTS si dejaría el saldo bajo 0 | Device + Staff shift |
| GET | `/loyalty/redemptions` | Fidelidad, canjes del negocio (?status=pending\|used\|expired\|cancelled, pending por defecto), los 50 más recientes | Device + Staff shift |
| POST | `/loyalty/redemptions/validate` | Fidelidad, mira un código de canje SIN marcarlo {code}: {valid, error_code, redemption\|null} | Device + Staff shift |
| POST | `/loyalty/redemptions/{id}/use` | Fidelidad, entrega la recompensa (marca usado). 409 LOYALTY_CODE_ALREADY_USED / LOYALTY_CODE_CANCELLED, 410 LOYALTY_CODE_EXPIRED | Device + Staff shift |
| POST | `/loyalty/redemptions/{id}/cancel` | Fidelidad, cancela un canje y devuelve los puntos {reason 3-200?}. Un canje ya usado solo lo cancela quien tiene loyalty.adjust y con motivo (403 LOYALTY_NOT_AVAILABLE si no; el permiso base se acepta también con solo loyalty.adjust) | Device + Staff shift |
| GET | `/advisory/cases` | Asesoría, mis casos (?status=open\|all\|requested\|active\|on_hold\|completed\|cancelled\|declined, open por defecto &stage=<key>&q=&mine=1): {cases}. Sin advisory.view_all solo los que lidero o donde tengo una visita asignada; con view_all, los de todo el equipo (mine=1 los acota a los míos). También lo abre view_all | Device + Staff shift |
| POST | `/advisory/cases` | Asesoría, crea un caso activo {client_id \| client_name+client_phone, case_type_id, title, summary?, owner_employee_id?, priority?, stage_key?, target_date?, site_name?, site_address?, site_lat?, site_lng?}: {case, client_created}. Idempotency-Key OBLIGATORIO (reenviar devuelve el mismo caso) | Device + Staff shift |
| GET | `/advisory/case-types` | Asesoría, tipos de caso vigentes con sus etapas, para crear un caso: {case_types} | Device + Staff shift |
| GET | `/advisory/employees` | Asesoría, el equipo para «Asignar a…» (responsable o técnico de una visita): {employees} | Device + Staff shift |
| GET | `/advisory/cases/{id}` | Asesoría, detalle del caso: {case, activities (las 50 últimas, todas las visibilidades)}. El teléfono del cliente va enmascarado («•••• 1234») y sin correo salvo advisory.view_all; los contadores de mercado solo cuentan con advisory.market. También lo abre view_all o advisory.visits. 404 ADVISORY_NOT_FOUND si no es de mi alcance | Device + Staff shift |
| GET | `/advisory/cases/{id}/contact` | Asesoría, el teléfono real para llamar o escribir por WhatsApp {name, phone, tel, whatsapp}. Registra el acceso en la actividad del staff. También lo abre view_all o advisory.visits | Device + Staff shift |
| POST | `/advisory/cases/{id}/activities` | Asesoría, anota en la bitácora {kind: note\|call\|whatsapp\|email\|meeting\|task, body ≤4000, visibility: internal\|client, due_at? (obligatorio en task)}: {activity}. Idempotency-Key OBLIGATORIO. 409 ADVISORY_CASE_CLOSED si el caso está cerrado y no es una nota interna | Device + Staff shift |
| POST | `/advisory/cases/{id}/activities/{aid}/done` | Asesoría, marca hecha una tarea (idempotente): {activity} | Device + Staff shift |
| POST | `/advisory/cases/{id}/stage` | Asesoría, cambia de etapa {stage_key, tell_client?}: {case, stage_key}. 409 ADVISORY_INVALID_TRANSITION si el caso no está abierto o ya está en esa etapa | Device + Staff shift |
| POST | `/advisory/cases/{id}/status` | Asesoría, cambia el estado {action: accept\|decline\|hold\|resume\|complete\|cancel\|reopen, outcome? (complete), reason? (decline\|cancel), owner_employee_id?, stage_key? (accept)}: {case}. 409 ADVISORY_INVALID_TRANSITION / 422 ADVISORY_FIELDS_MISSING | Device + Staff shift |
| POST | `/advisory/cases/{id}/owner` | Asesoría, cambia o quita el responsable {employee_id: uuid \| null}: {case}. Exige además advisory.view_all (403 ADVISORY_FORBIDDEN) | Device + Staff shift |
| GET | `/advisory/cases/{id}/dossiers` | Asesoría, expedientes del caso con ítems (estado, motivo de rechazo, vencimiento) y archivos (sin rutas de almacenamiento): {dossiers}. También lo abre view_all | Device + Staff shift |
| POST | `/advisory/dossier-items/{id}/review` | Asesoría, revisa un documento {action: approve\|reject\|waive\|unwaive\|reopen, reason? (obligatorio en reject\|waive\|reopen), expires_on? (obligatorio al aprobar un ítem que vence)}: {item}. 409 ADVISORY_ITEM_LOCKED / ADVISORY_INVALID_TRANSITION | Device + Staff shift |
| POST | `/advisory/dossier-items/{id}/files` | Asesoría, adjunta una subida a un documento {upload_id} (de POST /advisory/uploads): {item}. 415 ADVISORY_FILE_TYPE si el ítem no acepta ese tipo | Device + Staff shift |
| GET | `/advisory/files/{id}/url` | Asesoría, URL firmada y corta de un archivo del expediente {url, expires_in}; el acceso se registra antes de firmar. Las fotos de visita también las abre advisory.visits. 404 ADVISORY_NOT_FOUND si no es de mi alcance | Device + Staff shift |
| POST | `/advisory/uploads` | Asesoría, sube un archivo (bytes en el cuerpo con Content-Type y X-File-Name; ?purpose=dossier\|visit_photo, dossier por defecto: visit_photo exige advisory.visits): 201 {upload_id, mime, size_bytes}. Mismo validador que el cliente: 413 ADVISORY_FILE_TOO_LARGE (15 MB), 415 ADVISORY_FILE_TYPE (jpeg/png/webp/heic/pdf, por contenido real), 429 ADVISORY_UPLOAD_LIMIT | Device + Staff shift |
| GET | `/advisory/visits` | Asesoría, la agenda de visitas (?from=YYYY-MM-DD&to=YYYY-MM-DD en hora RD, to inclusive; sin rango trae también las por agendar; &mine=1): {visits}. Sin advisory.view_all solo las mías. También lo abre view_all | Device + Staff shift |
| GET | `/advisory/visits/{id}` | Asesoría, la visita para su informe: {visit, case, employee, metrics (indicadores medibles con rango y último valor), photos}. El teléfono va enmascarado; el real por /cases/{id}/contact. También lo abren view_all y view | Device + Staff shift |
| POST | `/advisory/cases/{id}/visits` | Asesoría, crea una visita {scheduled_at?, duration_min?, employee_id?, site_address?, purpose?, request_note?}: con fecha nace agendada, sin ella por agendar; sin employee_id la toma quien la crea. Asignar a otra persona exige advisory.assign. Idempotency-Key OBLIGATORIO. También lo abre advisory.manage | Device + Staff shift |
| POST | `/advisory/visits/{id}/schedule` | Asesoría, agenda una visita pedida o reprograma una agendada {scheduled_at, duration_min?, employee_id?, site_address?, purpose?}. También lo abre advisory.manage | Device + Staff shift |
| POST | `/advisory/visits/{id}/start` | Asesoría, inicia la visita (agendada → en curso). 403 ADVISORY_FORBIDDEN si no es mía y no tengo view_all | Device + Staff shift |
| POST | `/advisory/visits/{id}/cancel` | Asesoría, cancela la visita {reason?}. También lo abre advisory.manage | Device + Staff shift |
| POST | `/advisory/visits/{id}/no-show` | Asesoría, marca «no estaba» una visita agendada | Device + Staff shift |
| PUT | `/advisory/visits/{id}/report` | Asesoría, guarda el borrador del informe {findings ≤8000, recommendations[{id, text, priority: high\|medium\|low, due_on?, status: open\|done\|dropped}], measurements[{case_metric_id, value, note?}], photos[{upload_id, caption?}] (de POST /advisory/uploads?purpose=visit_photo, hasta 40)}: {visit}. Se puede repetir mientras la visita no esté cancelada | Device + Staff shift |
| POST | `/advisory/visits/{id}/complete` | Asesoría, completa la visita {report?}; exige hallazgos o al menos una recomendación (422 ADVISORY_FIELDS_MISSING). Las mediciones pasan a ser registros del indicador | Device + Staff shift |
| POST | `/advisory/visits/{id}/publish` | Asesoría, publica el informe al cliente: {visit, version, republished, pdf, report_url}. Republicar sube la versión con el mismo enlace | Device + Staff shift |
| GET | `/advisory/cases/{id}/metrics` | Asesoría, indicadores del caso con su serie e historial: {metrics}. También lo abren view_all y advisory.visits | Device + Staff shift |
| POST | `/advisory/cases/{id}/metrics/{cmId}/records` | Asesoría, registra una medición {value, measured_on, note?} dentro del rango del indicador: 201 {record_id}. Idempotency-Key OBLIGATORIO. También lo abre advisory.visits. 404 si el indicador no es de ese caso | Device + Staff shift |
| GET | `/advisory/buyers` | Asesoría, compradores activos (?q=): {buyers}. Teléfono enmascarado y sin correo salvo advisory.view_all | Device + Staff shift |
| GET | `/advisory/cases/{id}/opportunities` | Asesoría, oportunidades del caso con su comprador y oferta: {opportunities} | Device + Staff shift |
| POST | `/advisory/opportunities/{id}/status` | Asesoría, mueve una oportunidad {status: draft\|shared\|negotiating\|agreed\|won\|lost\|withdrawn, price_minor?, currency_code?, volume?, closed_reason?, next_step?}: {opportunity}. Ganar exige precio y volumen (422 ADVISORY_FIELDS_MISSING) | Device + Staff shift |

Source: https://fina.do/developers#api-staff