Cotizaciones
API de Cotizaciones
Endpoints para crear, listar y administrar cotizaciones profesionales con partidas, seguimiento de estado, generación de PDF e historial de versiones.
Listar Cotizaciones
GET /api/quotes
Permiso: quotes:read | Los colaboradores solo ven sus propias cotizaciones; los propietarios y administradores ven todas.
Obtén una lista paginada de cotizaciones del tenant actual.
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| search | string | No | Busca por número de cotización, nombre del contacto o nombre de la empresa (sin distinguir mayúsculas) |
| status | string | No | Filtra por estado: GENERATED, SENT, WON, LOST, ACCEPTED, REJECTED |
| page | number | No | Número de página (por defecto: 1) |
Response 200 OK:
{
"data": [
{
"id": "clxyz...",
"quoteNumber": 42,
"status": "SENT",
"subtotal": "15000.00",
"tax": "2400.00",
"shippingCost": "500.00",
"total": "17900.00",
"notes": "Entrega en 5 días hábiles",
"expiresAt": "2026-04-15T00:00:00.000Z",
"createdAt": "2026-03-31T10:00:00.000Z",
"client": {
"contactName": "María López",
"companyName": "Distribuidora del Norte S.A."
},
"user": {
"name": "Carlos Méndez"
},
"_count": {
"items": 3
}
}
],
"total": 87,
"page": 1,
"totalPages": 5
}Errores:
| Status | Error | Cuándo |
|---|---|---|
| 401 | "No autorizado" | Sesión ausente o inválida |
| 403 | "Sin permisos" | El rol no tiene quotes:read |
Ejemplo con cURL:
curl -X GET "https://cotizera.com/api/quotes?page=1&status=SENT&search=María" \
-H "Cookie: next-auth.session-token=YOUR_TOKEN"Crear Cotización
POST /api/quotes
Permiso: quotes:create
Crea una nueva cotización con partidas. Calcula automáticamente subtotales, impuesto (según la configuración del tenant o el 16% por defecto) y total. Asigna un número de cotización secuencial por tenant.
Request Body:
{
"clientId": "clxyz...",
"items": [
{
"productId": "clxyz...",
"productName": "Laptop HP ProBook 450",
"model": "450-G10",
"unitPrice": 18500,
"quantity": 2,
"discountPct": 5
}
],
"notes": "Incluye instalación y configuración",
"shippingCost": 350
}| Field | Type | Required | Description |
|---|---|---|---|
| clientId | string | Yes | ID del cliente (debe pertenecer al mismo tenant y estar activo) |
| items | array | Yes | Al menos una partida |
| items[].productId | string | No | Referencia al catálogo de productos |
| items[].productName | string | Yes | Nombre del producto a mostrar |
| items[].model | string | No | Modelo o SKU |
| items[].unitPrice | number | Yes | Precio unitario (>= 0) |
| items[].quantity | integer | Yes | Cantidad (>= 1) |
| items[].discountPct | number | No | Porcentaje de descuento por partida (0–100, por defecto: 0) |
| notes | string | No | Notas de texto libre (máximo 300 caracteres) |
| shippingCost | number | No | Costo de envío (>= 0, por defecto: 0) |
Response 201 Created:
{
"id": "clxyz...",
"quoteNumber": 43,
"status": "GENERATED",
"subtotal": 35150,
"tax": 5624,
"shippingCost": 350,
"total": 41124,
"notes": "Incluye instalación y configuración",
"expiresAt": "2026-04-15T00:00:00.000Z",
"publicToken": "abc123...",
"createdAt": "2026-03-31T10:00:00.000Z",
"items": [...],
"client": { "id": "...", "contactName": "María López", ... }
}Errores:
| Status | Error | Cuándo |
|---|---|---|
| 400 | Mensaje de validación Zod | Entrada inválida (por ejemplo, array de items vacío) |
| 401 | "No autorizado" | Sesión ausente o inválida |
| 403 | "Sin permisos" | El rol no tiene quotes:create |
| 404 | "Cliente no encontrado" | El cliente no existe, pertenece a otro tenant o está inactivo |
Efectos secundarios:
- Dispara el evento webhook
quote.created - Activa el paso de onboarding
first_quote - Registra una entrada de auditoría con acción
create
Obtener una Cotización
GET /api/quotes/:id
Permiso: quotes:read | Los colaboradores solo pueden acceder a sus propias cotizaciones.
Obtén una cotización individual con todas sus partidas, datos del cliente e información del creador.
Response 200 OK:
{
"id": "clxyz...",
"quoteNumber": 42,
"status": "SENT",
"subtotal": "15000.00",
"tax": "2400.00",
"total": "17900.00",
"items": [
{
"id": "...",
"productName": "Monitor Dell 27\"",
"model": "U2723QE",
"unitPrice": "7500.00",
"quantity": 2,
"discountPct": "0.00",
"subtotal": "15000.00",
"product": { "id": "...", "name": "Monitor Dell 27\"", "modelSku": "U2723QE" }
}
],
"client": { "id": "...", "contactName": "María López", "companyName": "...", "email": "...", "phone": "..." },
"user": { "name": "Carlos Méndez", "email": "carlos@example.com" }
}Errores:
| Status | Error | Cuándo |
|---|---|---|
| 401 | "No autorizado" | Sesión ausente o inválida |
| 403 | "Sin permisos" | El rol no tiene quotes:read |
| 404 | "Cotización no encontrada" | La cotización no existe o el acceso fue denegado |
Actualizar Estado de Cotización
PATCH /api/quotes/:id/status
Permiso: quotes:read_all (solo propietario/administrador)
Actualiza el estado de una cotización. Sigue una máquina de estados estricta:
| Desde | Transiciones permitidas |
|---|---|
GENERATED |
-> SENT |
SENT |
-> WON, LOST |
WON |
(terminal) |
LOST |
(terminal) |
ACCEPTED |
(terminal) |
REJECTED |
(terminal) |
Request Body:
{
"status": "SENT"
}| Field | Type | Required | Description |
|---|---|---|---|
| status | QuoteStatus | Yes | GENERATED, SENT, WON, LOST, ACCEPTED, REJECTED |
Response 200 OK: Devuelve el objeto de cotización actualizado.
Errores:
| Status | Error | Cuándo |
|---|---|---|
| 400 | "No se puede cambiar de GENERATED a WON" | Transición de estado inválida |
| 404 | "Cotización no encontrada" | Cotización no encontrada en el tenant |
Efectos secundarios:
- Dispara el evento webhook
quote.status_changed - Registra una entrada de auditoría con acción
status_change
Duplicar Cotización
POST /api/quotes/:id/duplicate
Permiso: quotes:create | Plan: PRO
Crea una copia de una cotización existente con un nuevo número, nueva fecha de expiración e impuesto recalculado.
Request Body: No se requiere.
Response 201 Created: Devuelve el nuevo objeto de cotización con partidas y cliente.
Errores:
| Status | Error | Cuándo |
|---|---|---|
| 403 | "Función disponible en el plan Pro" | El tenant está en el plan FREE |
| 404 | "Cotización no encontrada" | La cotización original no fue encontrada |
Compartir Cotización por Correo
POST /api/quotes/:id/share
Permiso: quotes:read
Genera un PDF (si no está en caché), lo sube a S3 y lo envía por correo al cliente. Cambia automáticamente el estado de GENERATED a SENT.
Request Body: No se requiere.
Response 200 OK:
{
"success": true
}Errores:
| Status | Error | Cuándo |
|---|---|---|
| 404 | "Cotización no encontrada" | Cotización no encontrada o acceso denegado |
| 500 | "Error al enviar el correo" | Falló el envío del correo |
Generar / Transmitir PDF
GET /api/quotes/:id/pdf
Permiso: quotes:read
Transmite el PDF directamente como respuesta binaria. Lo genera al momento con los datos actuales, incluyendo firma digital si existe. Los tenants PRO obtienen marca personalizada (colores, logo, sin marca de agua, datos bancarios).
Response 200 OK:
- Content-Type:
application/pdf - Content-Disposition:
inline; filename="COT-0042.pdf"
POST /api/quotes/:id/pdf
Permiso: quotes:read
Genera el PDF, lo sube a S3 y devuelve una URL de descarga prefirmada. Si ya fue subido, devuelve una URL prefirmada nueva sin regenerar el archivo.
Response 200 OK:
{
"pdfUrl": "https://s3.amazonaws.com/..."
}Revisar Cotización (Nueva Versión)
POST /api/quotes/:id/revise
Permiso: quotes:create | Plan: PRO (requiere la función quote_versioning)
Crea una nueva versión de la cotización. Guarda una instantánea del estado actual en QuoteVersion, luego reemplaza las partidas y totales con los nuevos datos. Incrementa el campo version.
Request Body: Mismo esquema que Crear Cotización (createQuoteSchema).
{
"clientId": "clxyz...",
"items": [...],
"notes": "Versión actualizada con descuento",
"shippingCost": 0
}Response 200 OK: Devuelve la cotización actualizada con las nuevas partidas y cliente.
Errores:
| Status | Error | Cuándo |
|---|---|---|
| 403 | "Función no disponible en el plan actual" | El plan no soporta versionamiento |
| 404 | "Cotización no encontrada" | Cotización no encontrada en el tenant |
Efectos secundarios:
- Crea una instantánea
QuoteVersiondel estado anterior - Dispara el evento webhook
quote.revised - Registra una entrada de auditoría con acción
quote.revised
Obtener Historial de Versiones
GET /api/quotes/:id/versions
Permiso: quotes:read
Obtén todas las versiones anteriores de una cotización, ordenadas por número de versión de forma descendente.
Response 200 OK:
[
{
"id": "clxyz...",
"version": 1,
"snapshot": {
"clientId": "...",
"items": [...],
"notes": "...",
"subtotal": 15000,
"tax": 2400,
"total": 17400
},
"createdBy": "Carlos Méndez",
"createdAt": "2026-03-30T10:00:00.000Z"
}
]Obtener Datos de Firma
GET /api/quotes/:id/signature
Permiso: quotes:read
Obtén los datos de la firma digital de una cotización, si existe.
Response 200 OK:
{
"signature": {
"type": "draw",
"data": "data:image/png;base64,...",
"signerName": "María López",
"signerEmail": "maria@distribuidora.mx",
"signedAt": "2026-03-31T14:30:00.000Z"
}
}Si no existe firma, signature será null.