Arquitectura de seguridad: autenticación JWT y prevención de vulnerabilidades
Cómo implementamos la seguridad en Four-Points PMS: autenticación JWT con cookies HttpOnly, prevención de XSS/CSRF/SQL injection, rate limiting y protección contra ataques comunes.
La seguridad no es un módulo más del sistema, es la base sobre la que se construyen todos los demás. Este artículo describe la arquitectura de seguridad implementada en Four-Points PMS: autenticación JWT, protección contra vulnerabilidades comunes, y las decisiones de diseño que garantizan que los datos del hotel estén protegidos.
→ Este artículo es técnico. Si buscas una visión general de los problemas que resuelve un sistema de gestión hotelera, empieza por 4 problemas operativos que frenan tu hotel.
Sistema de autenticación
Arquitectura general
Four-Points utiliza tokens JWT stateless almacenados en cookies HttpOnly. No hay sesiones en el servidor, no hay tabla de sesiones en la base de datos, y no se almacenan tokens en localStorage ni sessionStorage en el frontend. El backend es la única autoridad para establecer y limpiar cookies.
┌──────────────┐ POST /api/auth/login ┌──────────────┐
│ Frontend │ ──────────────────────────────→│ Backend │
│ Next.js 16 │ │ Express 5 │
│ │ ←──────────────────────────────│ │
│ │ Set-Cookie: access_token │ │
│ │ Set-Cookie: refresh_token │ │
└──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ MySQL │
│ (users, │
│ roles) │
└──────────────┘
Tokens JWT
Algoritmo: HS256 (explícitamente forzado en firma y verificación).
Clave secreta: SECRET_JWT_KEY, validada en el arranque del servidor:
- Debe existir como variable de entorno
- Longitud mínima de 32 caracteres (si no, el servidor rechaza arrancar)
Tipos de token y expiración:
| Token | Expiración | Campo type en payload |
|---|---|---|
| Access token | 15 minutos | 'access' |
| Refresh token | 7 días | 'refresh' |
Ambos se firman con la misma clave secreta. Se distinguen por el campo type en el payload.
Payload JWT:
{
id: string // UUID del usuario
username: string // nombre de usuario
role: string // nombre del rol (admin, recepcionista, etc.)
demo?: boolean // presente y true solo para la cuenta demo
type: 'access' | 'refresh'
iat?: number // emitido en (añadido por jwt.sign)
exp?: number // expira en (añadido por jwt.sign)
}
Configuración de cookies
const cookieOptions = {
httpOnly: true, // Siempre — XSS no puede robarlas
secure: process.env.NODE_ENV === 'production', // HTTPS solo en producción
sameSite: 'lax', // Protección CSRF
path: '/',
}
| Propiedad | Desarrollo | Producción |
|---|---|---|
httpOnly | true | true |
secure | false | true (HTTPS obligatorio) |
sameSite | 'lax' | 'lax' |
path | '/' | '/' |
| Cookie | maxAge | Propósito |
|---|---|---|
access_token | 15 minutos | Token de acceso a la API |
refresh_token | 7 días | Renovación automática del access token |
Flujo completo de autenticación
Login
POST /api/auth/login
Cadena de middleware: loginIpLimiter → loginLimiter → login handler
- Validar
usernameypassworddel body (ambos obligatorios) - Buscar el usuario en MySQL con
SELECT ... FROM users INNER JOIN roles WHERE username = ? - Si el usuario no existe, comparar la contraseña contra un hash ficticio (dummy hash) para prevenir ataques de timing que distingan “usuario no existe” de “contraseña incorrecta”
- Rechazar usuarios inactivos (
is_active = 0) - Rechazar la cuenta demo (no puede login con contraseña)
- Si falla: esperar hasta que hayan pasado 600ms desde el inicio de la petición, luego devolver
401 - Si tiene éxito: generar access token (15m) y refresh token (7d), establecer ambas cookies, devolver
{ success: true, user: {...} }(sin password)
Peticiones autenticadas
Cualquier ruta protegida
Cadena: authenticateToken → demoRestriction → demoWriteLimiter → handler
authenticateToken:
- Busca el token en
req.cookies.access_token - Si no está en cookies, busca en
Authorization: Bearer <token> - Si no hay token:
401 - Verifica firma, algoritmo (solo HS256) y expiración
- Verifica que
payload.type === 'access'(rechaza refresh tokens usados como access) - Establece
req.user = { id, username, role, isDemo } - Encadena
demoRestrictionydemoWriteLimiter
Renovación automática (refresh)
POST /api/auth/refresh-token
Cadena: refreshLimiter → refreshToken handler
El frontend detecta cualquier respuesta 401 (excepto en rutas de auth) y automáticamente:
- Llama a
POST /api/auth/refresh-token - Si hay peticiones concurrentes con 401, las encola (solo un refresh a la vez)
- Si el refresh tiene éxito, reintenta la petición original
- Si falla (3 intentos máximos), limpia cookies y redirige a
/login
El backend, al renovar:
- Verifica el refresh token
- Comprueba en MySQL que el usuario sigue existiendo y está activo
- Genera nuevos access y refresh tokens (sliding sessions)
- El rol se lee de MySQL en cada renovación — si un admin cambia el rol de un usuario, el cambio se aplica en el siguiente refresh
Logout
POST /api/auth/logout (sin autenticación requerida)
Limpia ambas cookies con las mismas opciones (httpOnly, secure, sameSite, path). El frontend redirige a /login.
Almacenamiento de contraseñas
Librería: bcrypt.
Rondas de sal: configurable vía SALT_ROUNDS (env), validado en el arranque: entero entre 10 y 15, por defecto 10.
Política de contraseñas:
- Longitud mínima: 12 caracteres (para contraseñas nuevas o cambiadas)
- Longitud máxima: 72 caracteres (límite de bcrypt)
- El login acepta contraseñas más cortas (compatibilidad con contraseñas pre-existentes a la política)
Prevención de ataques de timing en el login:
// Si el usuario no existe, comparar contra un hash ficticio
const DUMMY_HASH = '$2b$10$NZRVB2LhoSY6QTIjXL9PjeglXTf0F0uyXSM42Nh89IFBEhi8jU2U6'
const passwordToCompare = user?.password || DUMMY_HASH
const isPasswordValid = await bcrypt.compare(password, passwordToCompare)
// Ambas rutas (usuario no encontrado / contraseña incorrecta)
// esperan hasta 600ms antes de responder
await waitForFailureFloor(startedAt)
Esto impide que un atacante distinga entre “usuario no existe” y “contraseña incorrecta” midiendo el tiempo de respuesta.
Control de acceso basado en roles (RBAC)
El sistema exporta funciones de verificación de rol:
| Middleware | Roles permitidos | Uso |
|---|---|---|
isAdmin | admin | Gestión de usuarios, backoffice, config |
isOwnerOrAdmin | admin O dueño del recurso | Perfil propio |
canManageGroups | admin, group-admin | CRUD de grupos |
canManageCashier | admin, recepcionista, group-admin | Operaciones de caja |
canViewReports | admin | Reportes de caja |
canAccessMaintenance | admin, recepcionista, group-admin, mantenimiento | Módulo mantenimiento |
canAccessBackoffice | admin | Facturas, proveedores |
canResetChecklist | admin, recepcionista | Reset de checklists |
Restricciones de la cuenta demo
La cuenta demo (is_demo = 1) tiene un rol admin pero con restricciones en tres niveles:
demoRestriction(en cada petición autenticada): bloquea todas las subidas de archivos (multipart)denyDemo(por ruta): bloquea la ruta completamente (incluso lectura). Aplicado a: registro, cambio de perfil, cambio de contraseña, avatardenyDemoWrites(por grupo de rutas): permite lectura pero bloquea escritura. Aplicado a: usuarios, configuración, departamentos
Cada intento bloqueado se registra en demo_activity_log con: user_id, método, ruta, body_preview (campos sensibles redactados), IP y user agent.
Prevención de vulnerabilidades
XSS (Cross-Site Scripting)
Backend: API exclusivamente JSON
El backend nunca sirve HTML. Todas las respuestas son application/json. Esto elimina el XSS almacenado y reflejado en el lado del servidor: no hay punto de inyección de scripts en respuestas API.
Cabeceras Helmet (backend):
X-Content-Type-Options: nosniff // impide MIME sniffing
X-Frame-Options: SAMEORIGIN // impide clickjacking
X-XSS-Protection: 0 // recomendación moderna (desactivar)
Strict-Transport-Security // HSTS
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Origin-Agent-Cluster: ?1
CSP deshabilitado en el backend — al ser una API JSON pura, no sirve HTML ni assets, por lo que CSP no es necesario.
Frontend: Content Security Policy
El frontend (next.config.ts) aplica una CSP estática en todas las rutas:
default-src 'self'
script-src 'self' 'unsafe-inline' [+ 'unsafe-eval' en dev]
style-src 'self' 'unsafe-inline'
img-src 'self' blob: data: <apiOrigin> https://res.cloudinary.com https://images.unsplash.com
connect-src 'self' <apiOrigin>
frame-src 'self' blob:
worker-src 'self' blob:
object-src 'none'
base-uri 'self'
form-action 'self'
frame-ancestors 'none'
upgrade-insecure-requests (solo producción)
Nota: se usa 'unsafe-inline' en script-src porque Next.js requiere scripts inline. Esto es una limitación conocida de la CSP estática sin nonces (un nonce requeriría renderizado por petición). La CSP bloquea conexiones, frames y formularios hacia orígenes no listados.
Frontend: escaping de salida
React escapa automáticamente el contenido renderizado en JSX. No hay uso de dangerouslySetInnerHTML en el código base. Los comentarios de checklist y los cuerpos de texto se renderizan como texto plano por React.
No hay librerías de sanitización (DOMPurify, xss, sanitize-html) porque no son necesarias: el backend no sirve HTML, y el frontend usa el escaping nativo de React.
Archivos privados
Los archivos servidos a través del proxy del backend incluyen:
X-Content-Type-Options: nosniff // impide MIME sniffing
Cross-Origin-Resource-Policy: same-site
Cache-Control: private, max-age=300 // 5 min, solo cache privada
El nombre de archivo en Content-Disposition se sanitiza:
const safe = options.filename.replace(/[^\w.-]/g, '_')
CSRF (Cross-Site Request Forgery)
No se usan tokens CSRF. La prevención de CSRF se implementa con dos capas complementarias:
Capa 1: Validación de Origin en CORS
origin: (origin, callback) => {
if (!origin || origins.includes(origin)) {
callback(null, true)
return
}
callback(new Error('Origen no permitido')) // → 403
}
- Una petición cuyo
Originno está en la lista permitida recibe403antes de que se ejecute cualquier ruta - Esto incluye las “simple requests” que los navegadores envían sin preflight (form POST, multipart uploads, text/plain)
- Las peticiones sin
Originse permiten (server-to-server, curl, Postman — estas no llevan cookies de víctima)
Cobertura de tests: se verifica que peticiones JSON, form, multipart y text/plain desde orígenes no permitidos son rechazadas con 403.
Capa 2: cookies SameSite
sameSite: 'lax' // en ambas cookies de sesión
Un POST cross-site no enviará las cookies. Esto proporciona una segunda capa de defensa incluso si el CORS fallara.
Inyección SQL
Todas las consultas a la base de datos usan queries parametrizadas con mysql2:
await db.query('SELECT * FROM users WHERE id = ?', [userId])
MySQL con mysql2 no permite inyección SQL cuando se usan queries parametrizadas. Las consultas son estructuradas y los valores se pasan como parámetros tipados.
No hay interpolación de strings en SQL. Los nombres de columnas dinámicos se construyen desde listas predefinidas, nunca desde input del usuario.
Prevención de inyección en LIKE (repositories/shared/like.ts):
export function likeContains(query: string): string {
return `%${query.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
}
Escapa \, % y _ para que el texto del usuario se trate literalmente en patrones LIKE.
SSRF (Server-Side Request Forgery)
La función isOwnCloudinaryUrl() protege contra SSRF al descargar archivos de Cloudinary:
export function isOwnCloudinaryUrl(value: unknown, cloudName: string): boolean {
if (typeof value !== 'string' || !cloudName) return false
let url: URL
try { url = new URL(value) } catch { return false }
return (
url.protocol === 'https:' && // solo HTTPS
url.hostname === 'res.cloudinary.com' && // hostname exacto
url.port === '' && // sin puerto
url.username === '' && // sin userinfo
url.password === '' &&
url.pathname.startsWith(`/${cloudName}/`) // nuestro cloud name
)
}
fetchStoredFile() se niega a descargar de cualquier URL que no sea HTTPS en res.cloudinary.com dentro del cloud configurado. Además, maxRedirects: 0 — no se siguen redirecciones.
Path traversal en Cloudinary
La función isPublicIdInFolder() protege las operaciones de borrado en Cloudinary:
export function isPublicIdInFolder(publicId: unknown, folder: string): boolean {
return (
typeof publicId === 'string' &&
publicId.startsWith(`${folder}/`) && // debe estar dentro de la carpeta
!publicId.endsWith('/') && // sin trailing slash
!publicId.includes('//') && // sin dobles slashes
!publicId.split('/').includes('..') && // sin path traversal
![...publicId].some(c => c === '\\' || c.charCodeAt(0) < 32) // sin backslash ni control chars
)
}
Sin esta validación, un public_id manipulado por el cliente podría borrar cualquier archivo en el cloud de Cloudinary.
Seguridad en subida de archivos
Validación en tres capas (imágenes)
- MIME type en el
fileFilterde multer (tipo declarado) - Tamaño máximo:
fileSize: maxBytes + 1— multer deja de leer en el límite, así que un archivo oversized nunca se almacena completo en memoria. Devuelve 413 - Magic bytes después de la subida:
isImageFile(buffer)verifica los bytes reales del archivo
Firmas mágicas validadas:
| Formato | Bytes esperados |
|---|---|
| JPEG | FF D8 FF |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| GIF | GIF87a o GIF89a (primeros 6 bytes) |
| WebP | RIFF en 0-3 + WEBP en 8-11 |
Límites por tipo de archivo
| Ruta | Campo | Tamaño máx. | Verificación | Almacenamiento |
|---|---|---|---|---|
POST /api/auth/me/avatar | avatar | 2MB | MIME + magic bytes | Cloudinary |
POST /api/blacklist/upload | image | 5MB | MIME | Cloudinary |
POST /api/maintenance/:id/images | image | 5MB | MIME + magic bytes | Cloudinary |
POST /api/backoffice/invoices/:id/pdf | pdf | 10MB | MIME | Cloudinary |
POST /api/checklists/.../attachments | file | 5MB | MIME + magic bytes | Cloudinary |
Almacenamiento en memoria
Todos los uploads usan multer.memoryStorage() — los archivos nunca tocan el disco del servidor. Se procesan en memoria y se suben a Cloudinary directamente.
Sanitización de nombres de archivo
export function safePublicName(filename: string): string {
const name = filename
.replace(/\.[^/.]+$/, '') // eliminar extensión
.replace(/[^a-zA-Z0-9_-]/g, '_') // solo alfanumérico, guión, guión bajo
.substring(0, 50) // máximo 50 caracteres
return name || 'file'
}
Rate limiting
El sistema implementa múltiples limitadores de velocidad:
| Limitador | Ventana | Máximo | Clave | Aplicado a |
|---|---|---|---|---|
apiLimiter | 15 min | 300 req | IP | Todas las rutas /api |
loginLimiter | 15 min | 5 intentos | IP + username | POST /api/auth/login |
loginIpLimiter | 15 min | 20 fallos | IP | POST /api/auth/login (credential stuffing) |
refreshLimiter | 15 min | 30 | IP | POST /api/auth/refresh-token |
passwordChangeLimiter | 1 hora | 3 | User ID o IP | PATCH /api/auth/me/password |
profileUpdateLimiter | 15 min | 5 | User ID o IP | PATCH /api/auth/me/profile |
Robustez en la resolución de IP
La resolución de IP implementa una lógica robusta:
trust proxyconfigurado correctamente para el despliegue- Valida la forma de la cadena de proxies
- Si la cadena cambia: fail closed — usa el salto público más cercano (estricto, nunca más permisivo), registra un warning
- Normaliza IPv6-mapped IPv4 (
::ffff:1.2.3.4→1.2.3.4)
Seguridad en logs
Campos redactados (reemplazados por [REDACTED] en toda salida de log):
redact: {
paths: [
'*.password', // cualquier propiedad llamada "password"
'*.token', // cualquier propiedad llamada "token"
'*.refreshToken', // cualquier propiedad llamada "refreshToken"
'req.headers.authorization', // cabecera Authorization
'req.headers.cookie', // cabecera Cookie (contiene tokens)
],
censor: '[REDACTED]',
}
Sanitización del body en demo: los campos sensibles (password, currentPassword, newPassword, confirmPassword) se redactan antes de registrar el body en demo_activity_log.
Eventos de seguridad registrados (todos a nivel warn con prefijo [SECURITY]):
- Rate limit excedido (login, refresh, demo, password)
- Fallos de login (con razón:
user_not_found,inactive_user,demo_account,invalid_password) - Orígenes CORS bloqueados
- Cambios en la cadena de proxies
- Intentos bloqueados de la cuenta demo (con ruta, método, body preview, IP, user agent)
Manejo de errores
app.use((err, req, res, _next) => {
if (err.status && err.status < 500) {
res.status(err.status).json({ error: err.message })
return
}
logger.error({ err, path: req.path, method: req.method }, 'Unhandled error')
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Error interno del servidor'
: err.message,
})
})
- Errores 4xx: se envía el mensaje (son errores controlados y esperados)
- Errores 5xx en producción: mensaje genérico — sin stack trace, sin mensaje interno
- Errores 5xx en desarrollo: se envía el mensaje para debugging
- Error completo con stack: se registra en el servidor vía logger
Otras medidas de seguridad
Cache-Control
Todas las respuestas /api reciben Cache-Control: no-store. Los datos personales y financieros (blacklist, caja, facturas) no deben cachearse en navegadores ni proxies.
Tamaño máximo del body
app.use(express.json({ limit: '2mb' }))
X-Powered-By deshabilitado
app.disable('x-powered-by') // Express
Cambio de contraseña invalida sesiones
updatePassword limpia ambas cookies y requiere re-login.
Actualización de perfil re-emite tokens
updateProfile genera nuevos tokens con el username actualizado.
Soft delete de usuarios
Los usuarios nunca se borran físicamente; se marcan is_active = 0 y su username se sufija con _deleted_<timestamp>.
Autorización a nivel de objeto
- Mensajería: cada consulta de mensajes hace JOIN a través de
conversation_participants. Los usuarios solo ven mensajes de conversaciones en las que participan - Logbook: las operaciones de borrado/actualización están restringidas al autor (
WHERE author_id = ?) - Backoffice: cada mutación registra
created_by: req.user.idpara el audit trail
Archivos privados de Cloudinary
Las fotos de blacklist y los PDFs de facturas se suben como tipo authenticated en Cloudinary. Su secure_url lleva una firma que funciona para cualquiera para siempre, por lo que la URL nunca sale del servidor. La API sirve estos archivos a través de sus propios endpoints, que verifican la sesión y el rol antes de descargar de Cloudinary y hacer proxy de los bytes al cliente.
Solo los avatares permanecen públicos (tipo upload).
Resumen de cobertura de seguridad
Amenazas cubiertas
| Amenaza | Mecanismo de defensa | Capas |
|---|---|---|
| XSS | API JSON pura (sin HTML) + CSP en frontend + escaping de Astro | 3 |
| CSRF | Validación de Origin en CORS + cookies SameSite=Lax | 2 |
| Inyección SQL | Queries parametrizadas (Firestore) + whitelist de campos | 2 |
| SSRF | isOwnCloudinaryUrl() + maxRedirects: 0 | 2 |
| Path traversal | isPublicIdInFolder() + sanitización de nombres | 2 |
| Credential stuffing | loginIpLimiter (20 fallos/15min) + timing-safe login | 2 |
| Brute force login | loginLimiter (5 intentos/15min por IP+user) + 600ms floor | 2 |
| Session hijacking | Cookies HttpOnly + Secure + SameSite | 3 |
| Token theft (XSS) | Cookies HttpOnly (inaccesibles desde JS) | 1 |
| Clickjacking | X-Frame-Options: DENY + frame-ancestors: 'none' | 2 |
| MIME sniffing | X-Content-Type-Options: nosniff en API y archivos | 2 |
| Upload malicioso | MIME + magic bytes + tamaño + memoria (sin disco) | 4 |
| Replay de tokens | Access token de 15 min + refresh con verificación en BD | 2 |
| Escalada de privilegios | RBAC con middlewares + verificación de tipo de token | 2 |
| Enumeración de usuarios | Dummy hash + 600ms floor en login fallido | 2 |
| Fuga de secretos en logs | Redacción de password/token/authorization/cookie | 1 |
| Fuga de stack traces | Mensaje genérico en producción + logging solo en servidor | 2 |
| Caché de datos sensibles | Cache-Control: no-store en todas las respuestas API | 1 |
| Archivos expuestos | Proxy del backend + URLs firmadas nunca al cliente | 2 |
Configuración de seguridad del frontend (Next.js)
| Cabecera | Valor | Propósito |
|---|---|---|
Content-Security-Policy | CSP estática completa | Bloquea scripts/connections/frames no autorizados |
Cross-Origin-Opener-Policy | same-origin | Aísla el contexto de browsing |
X-Frame-Options | DENY | Anti-clickjacking |
X-Content-Type-Options | nosniff | Anti-MIME-sniffing |
Referrer-Policy | strict-origin-when-cross-origin | Control de fuga de referers |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Desactiva APIs del navegador |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Fuerza HTTPS |
Configuración de seguridad del backend
| Mecanismo | Configuración | Propósito |
|---|---|---|
| Helmet | CSP deshabilitado (API JSON) | Cabeceras de seguridad HTTP |
| CORS | Origen whitelist + credentials | CSRF + control de acceso |
| Rate limiting | Múltiples limitadores por endpoint | Anti-brute-force |
| JWT | HS256, access 15m + refresh 7d | Autenticación stateless |
| Cookies | HttpOnly + Secure + SameSite=Lax | Protección de sesión |
| Body parser | Límite 2MB | Anti-DoS por payload |
| Trust proxy | Configurado para el despliegue | IP correcta detrás de CDN |
| File uploads | Memoria + MIME + magic bytes + tamaño | Anti-upload malicioso |
| Error handler | Mensaje genérico en producción | Anti-fuga de información |
| Logging | Redacción de secretos | Anti-fuga en logs |
Notas y consideraciones
Decisiones de diseño documentadas
-
CSP deshabilitado en el backend: aceptable para una API JSON pura. Si en el futuro se añade algún endpoint HTML, CSP debe habilitarse.
-
Sin tokens CSRF: la combinación de Origin checking + SameSite=Lax es la defensa elegida, válida para una API JSON con autenticación por cookies.
-
unsafe-inlineen script-src del frontend: limitación conocida de la CSP estática sin nonces. Next.js requiere scripts inline. Un nonce requeriría renderizado por petición, incompatible con el modelo de Next.js App Router. -
Cross-Origin-Resource-Policy: same-site(nosame-origin) para archivos privados: el API y el frontend están en subdominios diferentes, por lo quesame-originrompería la carga de recursos. -
Sin versionado de API: todas las rutas están bajo
/api/sin prefijo de versión. Los cambios breaking se manejan con coordinación de deploy (frontend y backend se despliegan juntos).
Ausencias notables
- No hay 2FA (autenticación de dos factores). El sistema depende exclusivamente de contraseña + JWT
- No hay bloqueo de cuenta tras N intentos fallidos. Se previene con rate limiting por IP, pero una cuenta específica podría ser objetivo de un ataque distribuido
- No hay rotación forzada de contraseñas. Las contraseñas se cambian solo por iniciativa del usuario o un admin
- No hay auditoría de acceso (quién accedió a qué recurso y cuándo). Solo hay log de eventos de mutación (cambios de estado, creación, borrado)
Otros artículos técnicos
Arquitectura de checklists operativos: el procedimiento que vive en el sistema
Cómo diseñamos el módulo de checklists operativos: tres listas diarias, registro de ejecución, log de eventos, reinicio automático y procedimientos enlazados.
Generación automática de turnos con Google OR-Tools (CP-SAT)
Cómo utilizamos Google OR-Tools con CP-SAT para generar cuadrantes completos de turnos en segundos, respetando decenas de restricciones laborales simultáneas.
Arquitectura de backoffice: gestión de facturas de proveedor
Cómo diseñamos el módulo de backoffice: cuatro entidades, flujo de validación de facturas, editor de PDFs con sellos digitales, pagos automáticos y exportación.