[CONSEJO — Doc 07] Contrarian: El error más costoso en SaaS B2B early-stage es over-engineering el backend antes de tener 10 clientes. Una API REST bien documentada con Postgres y Celery resuelve los primeros 200 clientes. Kafka, microservicios y event sourcing son problemas de mes 60, no de mes 1. First Principles: El motor estadístico es el corazón del producto, no la API. La API es el camino hacia el motor. Todo lo que complique el motor (latencia, dependencias externas, I/O bloqueante) daña el producto más que cualquier bug de CRUD. Expansionist: El schema de datos debe soportar multi-tenant y multi-país desde el día 1, aunque en MVP solo opere un tenant y un país. Agregar estas dimensiones después es una migración cara. Outsider: Los contadores y gerentes RRHH de PyMEs CR usan Alegra. Cualquier fricción en la integración Alegra mata la adopción más que cualquier bug del motor. La integración con el ERP del cliente es, funcionalmente, parte del producto. Executor: El motor estadístico debe ser un worker Celery completamente asíncrono. Nunca bloquear una request HTTP esperando que statsforecast termine. Una forecast run de 5,000 SKUs tarda minutos — el usuario no puede esperar en una pantalla de carga. Security Auditor: El acceso a datos de un tenant nunca puede filtrar datos de otro. Row-Level Security (RLS) en PostgreSQL como segunda línea de defensa detrás del ORM. Si el ORM filtra mal, el RLS corta. Dos capas. Chairman: La arquitectura backend es una decisión de largo plazo. Cambiar el schema de datos en producción con 50 clientes es tres veces más caro que hacerlo bien ahora. Invertir el tiempo en el modelo de datos correcto desde el día 1.
1. Stack técnico backend
| Componente | Tecnología | Versión | Propósito |
|---|---|---|---|
| Runtime | Python | 3.12 | Backend language |
| Framework | FastAPI | 0.115+ | API REST + async I/O |
| ORM | SQLAlchemy | 2.0 (async) | Acceso a base de datos |
| Validación | Pydantic | v2 | Schemas de request/response |
| Workers | Celery | 5.x | Procesamiento asíncrono del motor |
| Broker/cache | Redis | 7.x | Celery broker + cache de resultados |
| Base de datos | PostgreSQL | 16 | Persistencia principal |
| Migraciones | Alembic | latest | Migraciones de schema versionadas |
| Auth | python-jose | latest | JWT signing/verification |
| Resend SDK | latest | Emails transaccionales (magic link) | |
| HTTP client | httpx | latest | Llamadas a APIs externas (Alegra, ONVO Pay) |
| Testing | pytest + pytest-asyncio | latest | Tests unitarios e integración |
| Monitoreo | Sentry SDK | latest | Error tracking |
| Logging | structlog | latest | Logs estructurados (JSON) |
Deployment: Coolify sobre Hostinger KVM2 (2 vCPU, 8 GB RAM). Contenedores Docker gestionados por Coolify. PostgreSQL y Redis corren en el mismo VPS hasta ~50 clientes activos.
2. Arquitectura general
┌─────────────────────────────────────────────────────────────┐
│ CLIENTE (Next.js) │
└─────────────────────────────┬───────────────────────────────┘
│ HTTPS / JSON
▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Application │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ Auth Router │ │ Core Router │ │ Webhook Router │ │
│ │ /auth/* │ │ /api/v1/* │ │ /webhooks/* │ │
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
│ │ │
│ ┌───────────────────────────▼──────────────────────────┐ │
│ │ Services (Business Logic) │ │
│ │ InventoryService · OrderService · ForecastService │ │
│ │ TenantService · BillingService · IntegrationService │ │
│ └───────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌───────────────────────────▼──────────────────────────┐ │
│ │ SQLAlchemy async (Repository layer) │ │
│ └───────────────────────────┬──────────────────────────┘ │
└─────────────────────────────┬───────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────┐
│ PostgreSQL 16 │ │ Redis 7 │
│ Schema: sm_* │ │ Celery broker + cache │
└─────────────────────┘ └──────────┬──────────────────┘
│
▼
┌─────────────────────────────┐
│ Celery Workers │
│ ┌─────────────────────┐ │
│ │ forecast_worker │ │
│ │ (statsforecast) │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ email_worker │ │
│ │ (Resend) │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ integration_worker │ │
│ │ (Alegra sync) │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
3. API REST — contratos principales
3.1 Convenciones generales
- Base URL:
https://app.stockmind.io/api/v1/ - Autenticación:
Authorization: Bearer <jwt_token>en todos los endpoints protegidos - Formato de respuesta: JSON siempre. Errores con estructura
{"error": {"code": "...", "message": "..."}} - Paginación:
?page=1&page_size=50con respuesta{"items": [...], "total": N, "page": 1, "page_size": 50} - Ordenamiento:
?sort_by=field&sort_order=asc|desc - Filtros: query params tipados por endpoint
3.2 Autenticación
POST /auth/magic-link
Body: {"email": "string"}
Response: {"message": "Magic link sent"}
GET /auth/verify?token=<token>
Response: {
"access_token": "jwt_string",
"token_type": "bearer",
"expires_in": 2592000 // 30 días en segundos
}
POST /auth/logout
Headers: Authorization: Bearer <token>
Response: {"message": "Logged out"}
El JWT contiene: user_id, tenant_id, role, exp. El tenant_id es el campo crítico para el aislamiento de datos.
3.3 SKUs / Inventario
GET /inventory/skus
Params: page, page_size, sort_by, sort_order,
status (critical|low|ok|overstock|nodata),
abc_class (A|B|C),
search (string, fuzzy match en código y descripción)
Response: PaginatedResponse[SkuListItem]
GET /inventory/skus/{sku_id}
Response: SkuDetail (incluye forecast + last_order + explanation_html)
POST /inventory/skus
Body: SkuCreate
Response: SkuDetail
PUT /inventory/skus/{sku_id}
Body: SkuUpdate (campos parciales)
Response: SkuDetail
DELETE /inventory/skus/{sku_id}
Response: {"message": "SKU archived"} // soft delete, nunca hard delete
GET /inventory/summary
Response: {
"total_skus": int,
"by_status": {"critical": int, "low": int, "ok": int, "overstock": int, "nodata": int},
"by_abc": {"A": int, "B": int, "C": int},
"alerts": [AlertItem] // máximo 5 alertas más urgentes
}
POST /inventory/import/csv
Body: multipart/form-data (archivo CSV + tenant_id implícito del JWT)
Response: {"job_id": "uuid", "status": "queued", "estimated_minutes": int}
GET /inventory/import/{job_id}/status
Response: {"status": "queued|processing|completed|failed", "progress": 0.0-1.0, "errors": [...]}
3.4 Forecast
GET /forecast/skus/{sku_id}
Response: {
"sku_id": "uuid",
"method_used": "CrostonTSB",
"method_reason": "intermittent demand (ADI=2.4, CV²=0.38)",
"forecast": [{"date": "2026-06-01", "value": 45.2, "ci_lower": 28.1, "ci_upper": 62.3}],
"history": [{"date": "2026-04-01", "value": 38}],
"reorder_point": 200,
"safety_stock": 45,
"eoq": 350,
"explanation_html": "<p>El motor seleccionó...</p>",
"last_run_at": "2026-05-15T08:00:00Z",
"drift_status": "ok|alert|critical"
}
POST /forecast/run
Body: {"sku_ids": ["uuid", ...] | null} // null = todos los SKUs del tenant
Response: {"job_id": "uuid", "status": "queued"}
GET /forecast/run/{job_id}/status
Response: {"status": "queued|running|completed|failed", "progress": 0.0-1.0, "skus_processed": int}
3.5 Órdenes de Compra
GET /orders
Params: status (pending|approved|received|rejected), page, page_size
Response: PaginatedResponse[OrderListItem]
GET /orders/{order_id}
Response: OrderDetail
POST /orders/approve/{order_id}
Body: {"quantity_override": int | null} // null = usar cantidad sugerida
Response: OrderDetail (status="approved")
POST /orders/reject/{order_id}
Body: {"reason": "string"}
Response: OrderDetail (status="rejected")
POST /orders/receive/{order_id}
Body: {"quantity_received": int, "received_date": "date", "notes": "string"}
Response: OrderDetail (status="received")
POST /orders/bulk-approve
Body: {"order_ids": ["uuid", ...]}
Response: {"approved": int, "failed": [{"order_id": "uuid", "reason": "string"}]}
POST /orders
Body: OrderCreate (creación manual, sin sugerencia del motor)
Response: OrderDetail
3.6 Proveedores
GET /suppliers
Response: PaginatedResponse[SupplierListItem]
GET /suppliers/{supplier_id}
Response: SupplierDetail (incluye SKUs asociados + historial de lead time)
POST /suppliers
Body: SupplierCreate
Response: SupplierDetail
PUT /suppliers/{supplier_id}
Body: SupplierUpdate
Response: SupplierDetail
3.7 Integraciones
GET /integrations/status
Response: {"alegra": {"connected": bool, "last_sync": "datetime"}, ...}
POST /integrations/alegra/connect
Body: {"api_key": "string", "api_email": "string"}
Response: {"status": "connected", "company_name": "string"}
POST /integrations/alegra/sync
Response: {"job_id": "uuid"}
DELETE /integrations/alegra/disconnect
Response: {"message": "Disconnected"}
3.8 Facturación y suscripción
GET /billing/subscription
Response: {"plan": "starter|growth|pro|enterprise", "status": "active|trial|past_due", "next_payment": "date"}
POST /billing/checkout
Body: {"plan": "string", "payment_method": "onvo_pay"}
Response: {"checkout_url": "string"} // redirect a ONVO Pay
GET /billing/invoices
Response: PaginatedResponse[InvoiceListItem]
4. Workers Celery
4.1 forecast_worker — Motor estadístico
Tarea: tasks.forecast.run_forecast_pipeline
Trigger:
- Automático: diario a las 3am CR (cron Celery Beat)
- Manual: vía POST /forecast/run desde el frontend
Proceso:
1. Lee SKUs activos del tenant desde PostgreSQL
2. Llama al pipeline de 11 pasos (ver Doc 09 Motor Core)
3. Escribe resultados (forecast, policy parameters, explanation) en tabla sm_forecast_results
4. Genera sugerencias de OC (sm_order_suggestions) para SKUs en estado crítico/bajo
5. Publica notificaciones de alerta en Redis → API las lee en tiempo real vía polling
Configuración de colas:
CELERY_ROUTES = {
'tasks.forecast.run_forecast_pipeline': {'queue': 'forecast'},
'tasks.email.*': {'queue': 'email'},
'tasks.integration.*': {'queue': 'integration'},
}
CELERY_WORKER_CONCURRENCY = {
'forecast': 2, # CPU-bound, no más de 2 en KVM2
'email': 4, # I/O-bound, puede ser más
'integration': 2, # I/O-bound con rate limiting de Alegra
}
Timeout: forecast task timeout = 4h (para KVM2 con 100k SKUs). Si supera 90 min, emitir alerta interna.
4.2 email_worker — Emails transaccionales
Tareas:
- tasks.email.send_magic_link — enviado en <5s del request
- tasks.email.send_order_alert — alerta de OC urgente
- tasks.email.send_weekly_summary — resumen semanal (V1.1)
Provider: Resend API. Retry automático 3 veces con backoff exponencial (1s, 4s, 16s).
4.3 integration_worker — Sync con sistemas externos
Tareas:
- tasks.integration.sync_alegra — sincronización bidireccional con Alegra
- Pull: productos, movimientos de inventario desde Alegra → StockMind
- Push: ajustes de stock aprobados desde StockMind → Alegra (V1.1)
- Rate limit Alegra: 120 requests/min → worker respeta con asyncio.sleep
Retry policy: 5 intentos, backoff exponencial, DLQ (dead letter queue) en Redis para fallos persistentes.
5. Multi-tenancy y aislamiento de datos
5.1 Estrategia: schema shared, tenant_id en cada tabla
Todas las tablas de negocio incluyen tenant_id UUID NOT NULL. No hay schemas separados por tenant (demasiado overhead de gestión en fase early).
Capa 1 — ORM: todos los queries incluyen WHERE tenant_id = :tenant_id automáticamente vía un TenantFilterMixin en SQLAlchemy.
Capa 2 — PostgreSQL RLS:
ALTER TABLE sm_skus ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON sm_skus
USING (tenant_id = current_setting('app.tenant_id')::uuid);
El app.tenant_id se establece al inicio de cada transacción desde el JWT. Si el ORM falla en filtrar, el RLS lo bloquea.
Capa 3 — Tests: tests de integración que verifican explícitamente que tenant A no puede leer datos de tenant B.
5.2 Extracción del tenant_id
async def get_current_tenant(token: str = Depends(oauth2_scheme)) -> UUID:
payload = verify_jwt(token)
return UUID(payload["tenant_id"])
El tenant_id nunca viene del request body — siempre del JWT. Cualquier tenant_id en el body se ignora.
6. Integración con Alegra
Alegra es el ERP más usado en CR/LATAM PyME. La integración es crítica para la adopción.
6.1 Flujo de autenticación Alegra
Alegra usa API Key + Email (no OAuth2). El usuario ingresa sus credenciales en /configuracion/integraciones. Las credenciales se almacenan cifradas (AES-256 con llave gestionada por Coolify Secrets) en la tabla sm_integration_credentials.
6.2 Datos que se sincronizan
Desde Alegra → StockMind (pull, cada 6h): - Productos (id, nombre, código, unidad de medida, precio costo, precio venta) - Movimientos de inventario (entradas, salidas, ajustes con fecha y cantidad) - Proveedores (nombre, contacto)
Desde StockMind → Alegra (push, cuando se aprueba una OC en StockMind): - Ajuste de inventario (cuando se marca OC como "recibida") — V1.1
6.3 Manejo de conflictos
Si un producto existe en Alegra con el mismo código que en StockMind pero con diferente nombre → StockMind gana (el inventario en StockMind es la fuente de verdad para el motor). El usuario recibe notificación del conflicto.
7. Integración ONVO Pay (facturación)
ONVO Pay es la pasarela de pagos primaria para CR. Tilopay como backup.
7.1 Flujo de pago (suscripción mensual)
1. Usuario elige plan en /configuracion/facturacion
2. StockMind llama ONVO Pay API: crear checkout session
3. Redirect usuario a ONVO Pay hosted checkout
4. ONVO Pay procesa el pago (tarjeta CR, SINPE, etc.)
5. Webhook ONVO Pay → POST /webhooks/onvo-pay/payment-success
6. StockMind activa/extiende suscripción en sm_subscriptions
7. Email de confirmación al usuario (vía Resend)
7.2 Webhook de ONVO Pay
El endpoint /webhooks/onvo-pay/payment-success verifica la firma HMAC del webhook antes de procesar. Cualquier request sin firma válida retorna 401 sin procesar.
def verify_onvo_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
7.3 Renovación automática
ONVO Pay gestiona la renovación mensual vía tokens guardados. StockMind recibe el webhook de éxito/fallo y actualiza el estado de la suscripción. Si el pago falla → estado past_due, acceso reducido (solo lectura), email de alerta.
8. Configuración de infraestructura (Coolify + Hostinger KVM2)
8.1 Servicios en el VPS
| Servicio | Puerto interno | Expose externo | Notas |
|---|---|---|---|
| FastAPI (Uvicorn) | 8000 | No (Nginx proxy) | 4 workers Uvicorn |
| Celery forecast worker | — | No | 2 procesos |
| Celery email/integration worker | — | No | 4 procesos |
| Celery Beat (scheduler) | — | No | 1 proceso |
| Redis | 6379 | No | Solo acceso interno |
| PostgreSQL | 5432 | No | Solo acceso interno |
| Nginx (Coolify) | 80/443 | Sí | Reverse proxy + TLS |
8.2 Variables de entorno requeridas
# Database
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/stockmind
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT
JWT_SECRET_KEY=<256-bit random>
JWT_ALGORITHM=HS256
JWT_EXPIRE_DAYS=30
# Email
RESEND_API_KEY=<key>
FROM_EMAIL=noreply@stockmind.io
# Payments
ONVO_PAY_API_KEY=<key>
ONVO_PAY_WEBHOOK_SECRET=<secret>
TILOPAY_API_KEY=<key> # backup
# Integrations
ALEGRA_ENCRYPTION_KEY=<32-byte key for AES-256>
# Monitoring
SENTRY_DSN=<dsn>
# App
ENVIRONMENT=production|staging|development
ALLOWED_ORIGINS=https://app.stockmind.io
Todas las variables se gestionan en Coolify Secrets (nunca en el repo).
8.3 Escalabilidad en Hostinger
| Umbral | Acción |
|---|---|
| >50 clientes activos | Mover PostgreSQL a Hostinger Cloud DB o RDS (separar del VPS) |
| Forecast batch >90 min | Mover forecast worker a VPS dedicado (KVM4 o instancia separada) |
| >200 clientes | Upgrade VPS a KVM4 (4 vCPU, 16 GB) o evaluar multi-instancia |
| >500 clientes | Revisar arquitectura completa con ops engineer dedicado |
9. Logging y monitoreo
9.1 Logs estructurados (structlog)
Todos los logs en formato JSON para facilitar búsqueda en Coolify Logs:
{
"timestamp": "2026-05-15T10:00:00Z",
"level": "info",
"event": "forecast_completed",
"tenant_id": "uuid",
"skus_processed": 847,
"duration_seconds": 142.3,
"methods_selected": {"CrostonTSB": 312, "SES": 198, ...}
}
No loguear nunca: contraseñas, API keys, tokens JWT, datos personales de clientes, ni cantidades de inventario específicas de un cliente (riesgo Ley 8968).
9.2 Sentry error tracking
- Errores 5xx en FastAPI → Sentry automático
- Celery task failures → Sentry con contexto de la tarea
- Integración errores (Alegra, ONVO Pay) → Sentry con código de error del proveedor
- Alert threshold: >10 errores únicos en 1h → notificación a Douglas por email
9.3 Health checks
GET /health
Response: {"status": "ok", "db": "ok", "redis": "ok", "workers": {"forecast": "ok", "email": "ok"}}
GET /health/ready (liveness para Coolify)
Response: 200 OK si el servicio puede aceptar tráfico
10. Testing strategy
10.1 Cobertura objetivo
| Capa | Cobertura objetivo |
|---|---|
| Motor estadístico (pipeline completo) | ≥95% |
| Lógica de negocio (services) | ≥80% |
| API endpoints (integration tests) | ≥75% |
| Workers Celery | ≥70% |
| Utilidades y helpers | ≥90% |
10.2 Tipos de tests
Unit tests (pytest): cada función del motor de forma aislada. Fixtures con series temporales sintéticas conocidas (resultado esperado calculado a mano).
Integration tests (pytest + TestClient de FastAPI): cada endpoint con base de datos real (PostgreSQL en modo transacción, rollback después de cada test). Jamás mockear la base de datos para tests de lógica de negocio — riesgo documentado en wiki.
E2E tests (pytest + httpx): flujos completos (login → import → forecast run → approve order). Corren en staging antes de cada deploy a producción.
Property-based tests (Hypothesis): para el motor estadístico, verificar invariantes (ej: "el stock de seguridad nunca es negativo con inputs válidos", "el EOQ siempre es mayor al MOQ si el MOQ < demanda anual").
Ver también: Doc 06 (SRD Frontend — contratos de API que este backend expone) · Doc 08 (Modelo de Datos — schema SQL detallado) · Doc 09 (Motor Core — pipeline estadístico que este backend ejecuta) · Doc 10 (Seguridad — autenticación, cifrado, RLS) · wiki/04-stack-tecnico.md