diff --git a/scripts/deploy_pacifictrans_apk.py b/scripts/deploy_pacifictrans_apk.py
index d1fa389..3cf4d4c 100644
--- a/scripts/deploy_pacifictrans_apk.py
+++ b/scripts/deploy_pacifictrans_apk.py
@@ -71,17 +71,16 @@ def main():
print("[4/4] Enviando notificación de Pacifictrans Go a WhatsApp (+57 316 745 5877)...", flush=True)
text_msg = (
- "🚗 *Pacifictrans Go: Seguridad de Rutas, Dashboard Admin Exclusivo & Grupos de WhatsApp Separados*\n\n"
- "¡Hola Carlos! Se ha desplegado una nueva versión con la reestructuración completa de seguridad y canales de WhatsApp:\n\n"
+ "🚗 *Pacifictrans Go: Tarifas Base Configurables, Sistema de Bonos de Descuento & Reserva Multi-Cupos*\n\n"
+ "¡Hola Carlos! Se ha actualizado la plataforma con las nuevas capacidades operativas y comerciales:\n\n"
"📱 *App Oficial:* `Pacifictrans Go`\n"
"📦 *Archivo APK:* `PacifictransGo.apk` (37.07 MB)\n"
"🆔 *Package ID:* `co.pacifictrans.go`\n\n"
- "⚡ *Novedades y Mejoras Implementadas:*\n"
- "• 🛡️ *Seguridad de Rutas por Permisos de Rol:* Protección de vistas y endpoints API (`ADMIN`, `CONDUCTOR`, `PASAJERO`). Si un usuario no autorizado o visitante intenta ingresar a la consola de administración (`/#admin`) o conductor (`/#conductor`), el sistema activa un Escudo de Seguridad interactivo con acceso restringido.\n"
- "• 🖥️ *Dashboard Exclusivo para Administradores:* La página principal quedó 100% limpia para clientes (sólo reserva de cupo, calculadora de fletes, muro de reseñas y descarga de app). La administración ahora opera en su propia Central de Mando Ejecutiva con Radar GPS en vivo, Telemetría, Control Bre-B y Tarifas.\n"
- "• 👥 *Separación de Grupos de WhatsApp por App:* Las notificaciones operativas de Pacific Trans GO se envían automáticamente al grupo *'Pacific Trans Notificaciones'* y las de Dripzone al grupo *'Dripzone Notificaciones'*. En caso de que no exista el grupo o el bot no esté agregado, hace fallback inmediato a tu número personal (+57 316 745 5877).\n"
- "• 🔍 *Auto-Detección de Grupos Evolution:* Botón en el panel administrativo que consulta Evolution API en vivo (`/api/admin/whatsapp/groups`) y resuelve los JIDs oficiales `@g.us`.\n"
- "• 💰 *Bandeja de Transferencias Bre-B:* Control de solicitudes de pago con ciclo de compensación inmediata 24/7 y generación de recibos digitales.\n\n"
+ "⚡ *Novedades Implementadas:*\n"
+ "• ⚙️ *Definición de Tarifas Base en Panel Admin:* Control en vivo persistido en MongoDB para la tarifa fija de pasajeros ($35.000 COP), radio de pooling, penalidad por cancelación, tarifa base de encomiendas ($25.000 COP), costo por km ($1.200 COP/km), factores de peso/volumen (sobre, mediano, grande), seguro y comisión de plataforma (15%).\n"
+ "• 🎟️ *Sistema de Bonos & Cupones de Descuento:* Creación y administración centralizada de promociones en el Administrador. Permite descuentos en porcentaje (%) o valor fijo en pesos ($ COP), configurando si aplican para VIAJES, ENCOMIENDAS o AMBOS, subtotal mínimo y límite de canjes. Incluye cupones semilla predefinidos (ej. PACIFICO10, BONOVIAJE5K, ENVIOYA, PROMO20).\n"
+ "• 💳 *Validación y Descuento Reactivo en Pasarela:* Los clientes pueden ingresar su cupón tanto en la reserva de viajes como en la cotización de fletes. El sistema valida el monto mínimo y calcula el descuento en tiempo real antes de generar la orden en Bold Smart Checkout.\n"
+ "• 👥 *Reserva Multi-Cupo (1 a 4 Asientos):* Posibilidad de reservar varios cupos a la vez ingresando los datos de cada pasajero (Nombre completo, Documento C.C., Teléfono/WhatsApp, dirección de recogida domiciliaria independiente y notas de equipaje).\n\n"
f"🔗 *Enlace de Descarga Directa:*\n👉 {APK_URL}"
)
diff --git a/transporte-cali-buenaventura/backend/public/css/style.css b/transporte-cali-buenaventura/backend/public/css/style.css
index 064bcdb..ff1427f 100644
--- a/transporte-cali-buenaventura/backend/public/css/style.css
+++ b/transporte-cali-buenaventura/backend/public/css/style.css
@@ -2009,7 +2009,423 @@ h1, h2, h3, h4, .brand-title {
filter: brightness(1.2);
}
+/* ==========================================================================
+ MULTI-SEATS & DYNAMIC PASSENGER FORM STYLES
+ ========================================================================== */
+.badge-seats-counter {
+ background: rgba(6, 182, 212, 0.15);
+ border: 1px solid var(--accent-cyan);
+ color: var(--accent-cyan);
+ font-size: 0.78rem;
+ padding: 3px 10px;
+ border-radius: 9999px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+.seat-qty-selector {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.6rem;
+ margin-top: 0.4rem;
+}
+@media (max-width: 640px) {
+ .seat-qty-selector {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+.btn-qty-seat {
+ background: rgba(15, 23, 42, 0.7);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius-md);
+ padding: 0.75rem 0.5rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.2rem;
+ cursor: pointer;
+ transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
+ color: var(--text-main);
+ outline: none;
+}
+
+.btn-qty-seat:hover {
+ background: rgba(30, 41, 59, 0.85);
+ border-color: rgba(6, 182, 212, 0.4);
+ transform: translateY(-2px);
+}
+
+.btn-qty-seat.active {
+ background: linear-gradient(135deg, rgba(6, 182, 212, 0.25) 0%, rgba(14, 165, 233, 0.15) 100%);
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 15px rgba(6, 182, 212, 0.3);
+ transform: translateY(-2px);
+}
+
+.seat-qty-number {
+ font-size: 1.35rem;
+ font-weight: 800;
+ font-family: 'Outfit', sans-serif;
+ color: #fff;
+ line-height: 1;
+}
+
+.btn-qty-seat.active .seat-qty-number {
+ color: var(--accent-cyan);
+}
+
+.seat-qty-desc {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ font-weight: 500;
+ text-align: center;
+}
+
+.btn-qty-seat.active .seat-qty-desc {
+ color: #e2e8f0;
+ font-weight: 600;
+}
+
+/* Tarjetas individuales de Pasajeros */
+.passenger-card {
+ background: rgba(15, 23, 42, 0.65);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius-md);
+ padding: 1.15rem;
+ position: relative;
+ transition: all 0.25s ease;
+ backdrop-filter: blur(10px);
+}
+
+.passenger-card:hover {
+ border-color: rgba(6, 182, 212, 0.3);
+ box-shadow: 0 6px 20px -5px rgba(0, 0, 0, 0.4);
+}
+
+.passenger-card.titular {
+ border-left: 3px solid var(--accent-cyan);
+}
+
+.passenger-card.companion {
+ border-left: 3px solid #a855f7;
+}
+
+.passenger-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.85rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.passenger-card-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: #fff;
+}
+
+.passenger-card-badge {
+ font-size: 0.7rem;
+ padding: 2px 8px;
+ border-radius: 9999px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.passenger-card-badge.titular {
+ background: rgba(6, 182, 212, 0.18);
+ color: var(--accent-cyan);
+ border: 1px solid rgba(6, 182, 212, 0.4);
+}
+
+.passenger-card-badge.companion {
+ background: rgba(168, 85, 247, 0.18);
+ color: #c084fc;
+ border: 1px solid rgba(168, 85, 247, 0.4);
+}
+
+.passenger-grid-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+@media (max-width: 580px) {
+ .passenger-grid-2 {
+ grid-template-columns: 1fr;
+ }
+}
+
+.same-address-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.82rem;
+ color: #cbd5e1;
+ background: rgba(2, 6, 23, 0.4);
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-sm);
+ margin-bottom: 0.75rem;
+ cursor: pointer;
+ border: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.same-address-toggle input[type="checkbox"] {
+ accent-color: var(--accent-cyan);
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+}
+
+/* Selector de Métodos de Pago */
+.payment-method-selector {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.6rem;
+ margin-top: 0.4rem;
+}
+
+@media (max-width: 640px) {
+ .payment-method-selector {
+ grid-template-columns: 1fr;
+ }
+}
+
+.payment-method-card {
+ background: rgba(15, 23, 42, 0.7);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius-md);
+ padding: 0.8rem;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.6rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.payment-method-card:hover {
+ background: rgba(30, 41, 59, 0.8);
+ border-color: rgba(6, 182, 212, 0.3);
+}
+
+.payment-method-card.active {
+ background: rgba(6, 182, 212, 0.1);
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 12px rgba(6, 182, 212, 0.2);
+}
+
+.payment-method-card input[type="radio"] {
+ accent-color: var(--accent-cyan);
+ margin-top: 3px;
+ cursor: pointer;
+}
+
+.payment-card-body {
+ display: flex;
+ flex-direction: column;
+}
+
+.payment-title {
+ font-size: 0.88rem;
+ font-weight: 700;
+ color: #fff;
+ line-height: 1.2;
+}
+
+.payment-sub {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ margin-top: 2px;
+}/* ==========================================================================
+ SISTEMA DE BONOS Y CUPONES DE DESCUENTO (VIAJES Y ENCOMIENDAS)
+ ========================================================================== */
+.coupon-input-group {
+ background: rgba(15, 23, 42, 0.65);
+ border: 1px solid var(--border-glass);
+ border-radius: var(--radius-md);
+ padding: 0.9rem;
+ transition: all 0.2s ease;
+}
+
+.coupon-input-group:focus-within {
+ border-color: rgba(6, 182, 212, 0.5);
+ box-shadow: 0 0 14px rgba(6, 182, 212, 0.15);
+}
+
+.coupon-feedback-pill {
+ font-size: 0.8rem;
+ padding: 0.45rem 0.75rem;
+ border-radius: var(--radius-sm);
+ margin-top: 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ animation: fadeIn 0.2s ease;
+}
+
+.coupon-feedback-success {
+ background: rgba(16, 185, 129, 0.15);
+ color: #34d399;
+ border: 1px solid rgba(16, 185, 129, 0.35);
+}
+
+.coupon-feedback-error {
+ background: rgba(239, 68, 68, 0.15);
+ color: #f87171;
+ border: 1px solid rgba(239, 68, 68, 0.35);
+}
+
+.summary-discount-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.4rem 0;
+ font-size: 0.88rem;
+ color: #34d399;
+ font-weight: 700;
+ border-bottom: 1px dashed rgba(52, 211, 153, 0.25);
+ margin-bottom: 0.5rem;
+}
+
+.coupon-code-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ background: rgba(14, 165, 233, 0.18);
+ color: #38bdf8;
+ border: 1px solid rgba(14, 165, 233, 0.4);
+ padding: 0.2rem 0.6rem;
+ border-radius: 6px;
+ font-family: monospace;
+ font-weight: 700;
+ font-size: 0.85rem;
+ letter-spacing: 0.05em;
+}
+
+.coupon-type-badge {
+ display: inline-block;
+ padding: 0.18rem 0.55rem;
+ border-radius: 12px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.coupon-type-porcentaje {
+ background: rgba(168, 85, 247, 0.2);
+ color: #c084fc;
+ border: 1px solid rgba(168, 85, 247, 0.4);
+}
+
+.coupon-type-fijo {
+ background: rgba(245, 158, 11, 0.2);
+ color: #fbbf24;
+ border: 1px solid rgba(245, 158, 11, 0.4);
+}
+
+.coupon-service-pill {
+ display: inline-block;
+ padding: 0.15rem 0.5rem;
+ border-radius: 10px;
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+
+.coupon-service-todos {
+ background: rgba(56, 189, 248, 0.15);
+ color: #7dd3fc;
+}
+
+.coupon-service-viajes {
+ background: rgba(34, 197, 94, 0.15);
+ color: #86efac;
+}
+
+.coupon-service-encomiendas {
+ background: rgba(251, 146, 60, 0.15);
+ color: #fdba74;
+}
+
+.coupons-table-responsive {
+ width: 100%;
+ overflow-x: auto;
+ border-radius: 12px;
+ border: 1px solid var(--border-glass);
+}
+
+.coupons-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.86rem;
+ text-align: left;
+}
+
+.coupons-table th {
+ background: rgba(15, 23, 42, 0.9);
+ color: #94a3b8;
+ padding: 0.85rem 1rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 0.72rem;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid var(--border-glass);
+}
+
+.coupons-table td {
+ padding: 0.85rem 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ color: #cbd5e1;
+ vertical-align: middle;
+}
+
+.coupons-table tr:hover {
+ background: rgba(30, 41, 59, 0.45);
+}
+
+.btn-coupon-toggle {
+ padding: 0.3rem 0.65rem;
+ border-radius: 8px;
+ font-size: 0.75rem;
+ font-weight: 700;
+ cursor: pointer;
+ border: none;
+ transition: all 0.2s;
+}
+
+.btn-coupon-toggle.active {
+ background: rgba(16, 185, 129, 0.2);
+ color: #34d399;
+ border: 1px solid rgba(16, 185, 129, 0.4);
+}
+
+.btn-coupon-toggle.inactive {
+ background: rgba(148, 163, 184, 0.15);
+ color: #94a3b8;
+ border: 1px solid rgba(148, 163, 184, 0.3);
+}
+
+.btn-coupon-delete {
+ background: rgba(239, 68, 68, 0.15);
+ color: #f87171;
+ border: 1px solid rgba(239, 68, 68, 0.35);
+ padding: 0.3rem 0.6rem;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.75rem;
+ transition: all 0.2s;
+}
+
+.btn-coupon-delete:hover {
+ background: rgba(239, 68, 68, 0.3);
+ color: #fff;
+}
diff --git a/transporte-cali-buenaventura/backend/public/index.html b/transporte-cali-buenaventura/backend/public/index.html
index cc155da..b5275db 100644
--- a/transporte-cali-buenaventura/backend/public/index.html
+++ b/transporte-cali-buenaventura/backend/public/index.html
@@ -221,7 +221,7 @@
- Dirección de Recogida en Cali (Domicilio)
+ Dirección Principal de Recogida en Cali (Domicilio)
@@ -235,20 +235,86 @@
-
-
-
-
+
+
-
🚗💨
-
-
¡Vehículo con 4/4 Cupos Completados!
-
- El viaje ha cambiado su estado a POOL_COMPLETED. Se ha enviado una alerta inmediata al Administrador para seleccionar el conductor de acuerdo a la cercanía de su residencia en Cali respecto a la primera recogida.
+ 🚗💨
+
+
¡Reserva de Cupos Confirmada!
+
+ Tu reserva ha sido procesada exitosamente en el sistema de pooling inteligente de Pacifictrans Go.
-
- 📲 Una vez asignado, se disparará automáticamente el mensaje de WhatsApp a los 4 pasajeros con los datos del conductor y horario exacto de recogida.
+
+
+
@@ -358,6 +440,23 @@
Descripción del Contenido
+
+
+
@@ -380,6 +479,16 @@
$1.500 COP
+
+ Subtotal Flete:
+ $197.100 COP
+
+
+
+ 🎟️ Descuento Cupón:
+ - $0 COP
+
+
@@ -395,7 +504,7 @@
$197.100 COP
-
+
Generar Guía y Enlace de Pago Bold
@@ -600,7 +709,10 @@
💰 2. Control de Pagos, Comisiones & Bre-B
0
- ⚙️ 3. Tarifas & Canales de WhatsApp
+ ⚙️ 3. Tarifas Base & Canales
+
+
+ 🎟️ 4. Bonos & Cupones de Descuento
@@ -617,11 +729,14 @@
📡 1. Radar Satelital & Despacho
- ⚙️ 2. Tarifas & Canales de Atención
+ ⚙️ 2. Tarifas Base & Canales
💰 3. Control de Pagos, Comisiones & Bre-B 0
+
+ 🎟️ 4. Bonos & Cupones de Descuento
+
@@ -907,6 +1022,163 @@
+
+
+
+
+
+
+
+ 🎟️
+ Sistema Centralizado de Bonos & Cupones de Descuento
+
+
+ Crea y administra promociones para el corredor Cali - Buenaventura. Define descuentos en Porcentaje (%) o Monto Fijo ($ COP) aplicables a viajes de pasajeros, encomiendas o ambos.
+
+
+
+
+ 🔄 Actualizar Cupones
+
+
+
+
+
+
+
+
Total Cupones Creados
+
0
+
En base de datos MongoDB
+
+
+
Cupones Activos
+
0
+
Listos para canje inmediato
+
+
+
Total de Canjes
+
0
+
Usados en reservas o envíos
+
+
+
Modalidades de Bono
+
% y $ COP
+
Porcentaje y Monto Fijo
+
+
+
+
+
+
+
+ ➕ Crear Nuevo Bono / Cupón de Descuento
+
+
+ Completa los datos para habilitar una nueva promoción en la pasarela y en las reservas directas.
+
+
+
+
+
+
+
+
+
+ 🎟️ Catálogo de Cupones en MongoDB
+
+ Cargando...
+
+
+
+
+
+
+ Código
+ Descuento
+ Aplica Para
+ Canjes / Límite
+ Estado
+ Acción
+
+
+
+
+
+ Cargando catálogo de cupones...
+
+
+
+
+
+
+
+
diff --git a/transporte-cali-buenaventura/backend/public/js/app.js b/transporte-cali-buenaventura/backend/public/js/app.js
index 2b88de3..9219178 100644
--- a/transporte-cali-buenaventura/backend/public/js/app.js
+++ b/transporte-cali-buenaventura/backend/public/js/app.js
@@ -1,4 +1,4 @@
-// Pacifictrans Go - Interactive Web Platform Logic
+22// Pacifictrans Go - Interactive Web Platform Logic
let currentTariffs = {
tarifaPasajeroFija: 35000,
precioBasePaquete: 25000,
@@ -34,8 +34,10 @@ document.addEventListener('DOMContentLoaded', () => {
initLeafletRadarMap();
initReviewsAndStats();
initTariffsManager();
+ initCouponsManager();
initSettlementsAndBreBSystem();
fetchTariffs();
+ fetchAdminCoupons();
fetchStatsAndReviews();
});
@@ -100,7 +102,7 @@ function initTabs() {
const tabContents = document.querySelectorAll('.tab-content');
// Switcher de Vistas Principales SPA con Escudo de Seguridad
- window.showView = function(viewName) {
+ window.showView = function (viewName) {
const viewPublic = document.getElementById('view-public-portal');
const viewAdmin = document.getElementById('view-admin-dashboard');
const viewDriver = document.getElementById('view-driver-portal');
@@ -156,7 +158,7 @@ function initTabs() {
};
// Selector elegante de servicios públicos: Pasajeros vs Encomiendas
- window.activatePublicService = function(targetTabId) {
+ window.activatePublicService = function (targetTabId) {
const btnPasajeros = document.getElementById('btn-service-pasajeros');
const btnFletes = document.getElementById('btn-service-fletes');
const tabPasajeros = document.getElementById('tab-pasajeros');
@@ -272,7 +274,7 @@ function initTabs() {
}
// Compatibilidad genérica para pestañas
- window.activateTab = function(targetId) {
+ window.activateTab = function (targetId) {
tabBtns.forEach(b => {
if (b.getAttribute('data-tab') === targetId) {
b.classList.add('active');
@@ -304,39 +306,47 @@ function initTabs() {
});
// Sub-tabs de la sección administrativa
- const subRadarBtn = document.getElementById('btn-admin-subtab-radar');
- const subTarifasBtn = document.getElementById('btn-admin-subtab-tarifas');
- const subLiquidacionesBtn = document.getElementById('btn-admin-subtab-liquidaciones');
const viewRadar = document.getElementById('admin-view-radar');
const viewTarifas = document.getElementById('admin-view-tarifas');
const viewLiquidaciones = document.getElementById('admin-view-liquidaciones');
+ const viewCupones = document.getElementById('admin-view-cupones');
- function setAdminSubTab(activeBtn, activeView) {
- [subRadarBtn, subTarifasBtn, subLiquidacionesBtn].forEach(b => b?.classList.remove('active'));
- [viewRadar, viewTarifas, viewLiquidaciones].forEach(v => { if (v) v.style.display = 'none'; });
- if (activeBtn) activeBtn.classList.add('active');
- if (activeView) activeView.style.display = 'block';
- }
+ function setAdminSubTab(targetViewId) {
+ const allViews = [
+ { id: 'radar', el: viewRadar },
+ { id: 'tarifas', el: viewTarifas },
+ { id: 'liquidaciones', el: viewLiquidaciones },
+ { id: 'cupones', el: viewCupones }
+ ];
- if (subRadarBtn && viewRadar) {
- subRadarBtn.addEventListener('click', () => {
- setAdminSubTab(subRadarBtn, viewRadar);
- if (leafletMap) setTimeout(() => leafletMap.invalidateSize(), 200);
+ allViews.forEach(v => {
+ if (v.el) v.style.display = (v.id === targetViewId) ? 'block' : 'none';
});
+
+ document.querySelectorAll('.admin-pill-btn').forEach(btn => {
+ const id = btn.id || '';
+ if (id.includes(targetViewId)) {
+ btn.classList.add('active');
+ } else {
+ btn.classList.remove('active');
+ }
+ });
+
+ if (targetViewId === 'radar' && leafletMap) {
+ setTimeout(() => leafletMap.invalidateSize(), 200);
+ }
+ if (targetViewId === 'liquidaciones' && window.fetchSettlementsOverview) {
+ window.fetchSettlementsOverview();
+ }
+ if (targetViewId === 'cupones' && window.fetchAdminCoupons) {
+ window.fetchAdminCoupons();
+ }
}
- if (subTarifasBtn && viewTarifas) {
- subTarifasBtn.addEventListener('click', () => {
- setAdminSubTab(subTarifasBtn, viewTarifas);
- });
- }
-
- if (subLiquidacionesBtn && viewLiquidaciones) {
- subLiquidacionesBtn.addEventListener('click', () => {
- setAdminSubTab(subLiquidacionesBtn, viewLiquidaciones);
- if (window.fetchSettlementsOverview) window.fetchSettlementsOverview();
- });
- }
+ document.querySelectorAll('#btn-admin-subtab-radar').forEach(b => b.addEventListener('click', () => setAdminSubTab('radar')));
+ document.querySelectorAll('#btn-admin-subtab-tarifas').forEach(b => b.addEventListener('click', () => setAdminSubTab('tarifas')));
+ document.querySelectorAll('#btn-admin-subtab-liquidaciones').forEach(b => b.addEventListener('click', () => setAdminSubTab('liquidaciones')));
+ document.querySelectorAll('#btn-admin-subtab-cupones').forEach(b => b.addEventListener('click', () => setAdminSubTab('cupones')));
// Sub-tabs de la sección del conductor
const subDriverWalletBtn = document.getElementById('btn-driver-subtab-billetera');
@@ -728,6 +738,11 @@ function updateUserUI(user) {
if (inputUserPhone && !inputUserPhone.value) inputUserPhone.value = user.telefono;
if (inputSenderPhone && !inputSenderPhone.value) inputSenderPhone.value = user.telefono;
+ const p1Name = document.getElementById('passenger-1-name');
+ const p1Phone = document.getElementById('passenger-1-phone');
+ if (p1Name && !p1Name.value) p1Name.value = user.nombre;
+ if (p1Phone && !p1Phone.value) p1Phone.value = user.telefono;
+
// Actualizar vista actual si está en zona protegida
const hash = (window.location.hash || '').toLowerCase();
if (hash === '#admin') window.showView('admin');
@@ -877,7 +892,7 @@ function renderSuggestedDriversList() {
`).join('');
}
-window.asignarConductorDemo = function(conductor, barrio) {
+window.asignarConductorDemo = function (conductor, barrio) {
const resultDiv = document.getElementById('schedule-dispatch-result');
if (resultDiv) {
resultDiv.style.display = 'block';
@@ -1116,18 +1131,12 @@ function syncTariffsToUI() {
if (footerLinkEl) footerLinkEl.href = `https://wa.me/${cleanPhone}?text=Hola%20Pacifictrans%20Go,%20deseo%20informaci%C3%B3n%20sobre%20un%20servicio`;
// 3. Precios en módulo de reservas de pasajeros
- const formattedPasajero = `$${currentTariffs.tarifaPasajeroFija.toLocaleString('es-CO')} COP`;
- const totalPriceEl = document.querySelector('.total-price');
- if (totalPriceEl) totalPriceEl.textContent = formattedPasajero;
-
- const btnBook = document.getElementById('btn-book-pool');
- if (btnBook && !btnBook.disabled) {
- btnBook.innerHTML = `
-
-
-
- Confirmar y Pagar Cupo (${formattedPasajero})
- `;
+ if (window.updatePassengerBookingSummary) {
+ window.updatePassengerBookingSummary();
+ } else {
+ const formattedPasajero = `$${currentTariffs.tarifaPasajeroFija.toLocaleString('es-CO')} COP`;
+ const totalPriceEl = document.querySelector('.total-price');
+ if (totalPriceEl) totalPriceEl.textContent = formattedPasajero;
}
// 4. Recalcular módulo de mensajería y fletes
@@ -1275,11 +1284,290 @@ function initPassengerCalculator() {
const originSelect = document.getElementById('pool-origin');
const addressGroup = document.getElementById('cali-address-group');
const buenaStopGroup = document.getElementById('buena-stop-group');
+ const caliAddressInput = document.getElementById('cali-address');
+ const buenaStopSelect = document.getElementById('buena-stop');
const bookBtn = document.getElementById('btn-book-pool');
const poolDots = document.querySelectorAll('.seat-dot');
+ const passengersContainer = document.getElementById('passengers-cards-container');
+ const badgeCupos = document.getElementById('badge-cupos-selected');
+ const seatButtons = document.querySelectorAll('.btn-qty-seat');
+ const paymentCards = document.querySelectorAll('.payment-method-card');
+ let selectedSeats = 1;
+ let selectedPaymentMethod = 'BOLD';
let currentSeatsFilled = 3;
+ // Cache para persistir los datos ingresados al alternar entre 1..4 cupos
+ const passengersData = [
+ { nombre: '', doc: '', phone: '', sameAddress: true, address: '', notes: '' },
+ { nombre: '', doc: '', phone: '', sameAddress: true, address: '', notes: '' },
+ { nombre: '', doc: '', phone: '', sameAddress: true, address: '', notes: '' },
+ { nombre: '', doc: '', phone: '', sameAddress: true, address: '', notes: '' }
+ ];
+
+ function saveCurrentFormData() {
+ for (let i = 1; i <= selectedSeats; i++) {
+ const nameInput = document.getElementById(`passenger-${i}-name`);
+ const docInput = document.getElementById(`passenger-${i}-doc`);
+ const phoneInput = document.getElementById(`passenger-${i}-phone`);
+ const notesInput = document.getElementById(`passenger-${i}-notes`);
+ const sameAddrCheck = document.getElementById(`passenger-${i}-same-addr`);
+ const addrInput = document.getElementById(`passenger-${i}-addr`);
+
+ if (nameInput) passengersData[i - 1].nombre = nameInput.value;
+ if (docInput) passengersData[i - 1].doc = docInput.value;
+ if (phoneInput) passengersData[i - 1].phone = phoneInput.value;
+ if (notesInput) passengersData[i - 1].notes = notesInput.value;
+ if (sameAddrCheck) passengersData[i - 1].sameAddress = sameAddrCheck.checked;
+ if (addrInput) passengersData[i - 1].address = addrInput.value;
+ }
+ }
+
+ function renderPassengerCards(count) {
+ if (!passengersContainer) return;
+
+ // Si hay usuario logueado y el pasajero 1 no tiene nombre, prellenar
+ if (currentUser && !passengersData[0].nombre) {
+ passengersData[0].nombre = currentUser.nombre || '';
+ passengersData[0].phone = currentUser.telefono || '';
+ }
+
+ let html = '';
+ for (let i = 1; i <= count; i++) {
+ const isTitular = (i === 1);
+ const data = passengersData[i - 1];
+
+ html += `
+
+
+
+
+
+
+
+ ${!isTitular ? `
+
+ ` : ''}
+
+ `;
+ }
+
+ passengersContainer.innerHTML = html;
+
+ // Conectar eventos a los checkboxes de misma dirección
+ for (let i = 2; i <= count; i++) {
+ const check = document.getElementById(`passenger-${i}-same-addr`);
+ const addrGroup = document.getElementById(`passenger-${i}-addr-group`);
+ if (check && addrGroup) {
+ check.addEventListener('change', () => {
+ addrGroup.style.display = check.checked ? 'none' : 'block';
+ passengersData[i - 1].sameAddress = check.checked;
+ });
+ }
+ }
+ }
+
+ let appliedCoupon = null;
+ const poolCouponInput = document.getElementById('pool-coupon-code');
+ const btnApplyPoolCoupon = document.getElementById('btn-apply-pool-coupon');
+ const poolCouponFeedback = document.getElementById('pool-coupon-feedback');
+
+ if (btnApplyPoolCoupon && poolCouponInput) {
+ btnApplyPoolCoupon.addEventListener('click', async () => {
+ const code = poolCouponInput.value.trim().toUpperCase();
+ if (!code) {
+ if (poolCouponFeedback) {
+ poolCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-error';
+ poolCouponFeedback.textContent = '⚠️ Ingresa el código del bono o cupón.';
+ poolCouponFeedback.style.display = 'flex';
+ }
+ return;
+ }
+
+ btnApplyPoolCoupon.disabled = true;
+ btnApplyPoolCoupon.textContent = '...';
+
+ try {
+ const subtotal = selectedSeats * (currentTariffs.tarifaPasajeroFija || 35000);
+ const res = await fetch('/api/coupons/validate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ codigo: code, serviceType: 'VIAJES', subtotal })
+ });
+ const data = await res.json();
+
+ if (data.valid) {
+ appliedCoupon = data;
+ if (poolCouponFeedback) {
+ poolCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-success';
+ poolCouponFeedback.textContent = data.message;
+ poolCouponFeedback.style.display = 'flex';
+ }
+ } else {
+ appliedCoupon = null;
+ if (poolCouponFeedback) {
+ poolCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-error';
+ poolCouponFeedback.textContent = data.message || 'Cupón inválido';
+ poolCouponFeedback.style.display = 'flex';
+ }
+ }
+ updateSummary();
+ } catch (err) {
+ console.error('Coupon validation error:', err);
+ if (poolCouponFeedback) {
+ poolCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-error';
+ poolCouponFeedback.textContent = 'Error al validar cupón con el servidor.';
+ poolCouponFeedback.style.display = 'flex';
+ }
+ } finally {
+ btnApplyPoolCoupon.disabled = false;
+ btnApplyPoolCoupon.textContent = 'Aplicar';
+ }
+ });
+ }
+
+ function updateSummary() {
+ const unitPrice = currentTariffs.tarifaPasajeroFija || 35000;
+ const subtotal = selectedSeats * unitPrice;
+ let discountAmount = 0;
+
+ if (appliedCoupon && appliedCoupon.valid) {
+ if (appliedCoupon.coupon.tipoDescuento === 'PORCENTAJE') {
+ discountAmount = Math.round(subtotal * (appliedCoupon.coupon.valorDescuento / 100));
+ } else {
+ discountAmount = Math.min(appliedCoupon.coupon.valorDescuento, subtotal);
+ }
+ discountAmount = Math.min(discountAmount, subtotal);
+ }
+
+ const totalAmount = Math.max(0, subtotal - discountAmount);
+ const formattedUnit = `$${unitPrice.toLocaleString('es-CO')} COP`;
+ const formattedSubtotal = `$${subtotal.toLocaleString('es-CO')} COP`;
+ const formattedTotal = `$${totalAmount.toLocaleString('es-CO')} COP`;
+
+ const summarySubtotalRow = document.getElementById('summary-subtotal-row');
+ const summarySubtotalPrice = document.getElementById('summary-subtotal-price');
+ const summaryDiscountRow = document.getElementById('summary-discount-row');
+ const summaryDiscountAmount = document.getElementById('summary-discount-amount');
+ const summaryPriceLabel = document.getElementById('summary-price-label');
+ const summaryTotalPrice = document.getElementById('summary-total-price');
+ const summaryCapacity = document.getElementById('summary-capacity-text');
+ const summarySeatsPill = document.getElementById('summary-seats-pill');
+ const btnBookText = document.getElementById('btn-book-text');
+ const badgeCuposSelected = document.getElementById('badge-cupos-selected');
+
+ if (badgeCuposSelected) {
+ badgeCuposSelected.textContent = `${selectedSeats} ${selectedSeats === 1 ? 'Cupo Seleccionado' : 'Cupos Seleccionados'}`;
+ }
+
+ if (summarySeatsPill) {
+ summarySeatsPill.textContent = `${selectedSeats} ${selectedSeats === 1 ? 'Pasajero (1 cupo)' : `Pasajeros (${selectedSeats} cupos)`}`;
+ }
+
+ if (summaryCapacity) {
+ summaryCapacity.textContent = selectedSeats === 4
+ ? 'Vehículo Completo Exclusivo (4/4 Cupos)'
+ : `Máximo 4 Pasajeros (${selectedSeats} ${selectedSeats === 1 ? 'cupo solicitado' : 'cupos solicitados'})`;
+ }
+
+ if (discountAmount > 0) {
+ if (summarySubtotalRow) {
+ summarySubtotalRow.style.display = 'flex';
+ if (summarySubtotalPrice) summarySubtotalPrice.textContent = formattedSubtotal;
+ }
+ if (summaryDiscountRow) {
+ summaryDiscountRow.style.display = 'flex';
+ const typeText = appliedCoupon.coupon.tipoDescuento === 'PORCENTAJE' ? `${appliedCoupon.coupon.valorDescuento}% OFF` : 'Bono Fijo';
+ if (summaryDiscountAmount) summaryDiscountAmount.textContent = `- $${discountAmount.toLocaleString('es-CO')} COP (${typeText})`;
+ }
+ if (summaryPriceLabel) {
+ summaryPriceLabel.textContent = `Total con Descuento (${selectedSeats} ${selectedSeats === 1 ? 'Cupo' : 'Cupos'}):`;
+ }
+ } else {
+ if (summarySubtotalRow) summarySubtotalRow.style.display = 'none';
+ if (summaryDiscountRow) summaryDiscountRow.style.display = 'none';
+ if (summaryPriceLabel) {
+ summaryPriceLabel.textContent = `Total a Pagar (${selectedSeats} ${selectedSeats === 1 ? 'Cupo' : 'Cupos'}):`;
+ }
+ }
+
+ if (summaryTotalPrice) {
+ summaryTotalPrice.textContent = formattedTotal;
+ }
+
+ if (btnBookText) {
+ const methodText = selectedPaymentMethod === 'BOLD' ? 'Pagar con Bold' : (selectedPaymentMethod === 'NEQUI' ? 'Pagar con Nequi' : 'Reservar');
+ btnBookText.textContent = `${methodText} ${selectedSeats} ${selectedSeats === 1 ? 'Cupo' : 'Cupos'} (${formattedTotal})`;
+ }
+
+ // Actualizar previsualización en dots del vehículo
+ updateSeatVisuals(selectedSeats);
+ }
+
+ window.updatePassengerBookingSummary = updateSummary;
+
+ // Escuchadores de botones de selección de cupos
+ seatButtons.forEach((btn) => {
+ btn.addEventListener('click', () => {
+ saveCurrentFormData();
+ seatButtons.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ selectedSeats = parseInt(btn.getAttribute('data-seats') || '1', 10);
+ renderPassengerCards(selectedSeats);
+ updateSummary();
+ });
+ });
+
+ // Selector de método de pago
+ paymentCards.forEach((card) => {
+ card.addEventListener('click', () => {
+ paymentCards.forEach(c => c.classList.remove('active'));
+ card.classList.add('active');
+ const radio = card.querySelector('input[type="radio"]');
+ if (radio) {
+ radio.checked = true;
+ selectedPaymentMethod = radio.value;
+ }
+ updateSummary();
+ });
+ });
+
function updateOriginDestination() {
if (!originSelect) return;
const isCali = originSelect.value === 'CALI';
@@ -1292,39 +1580,190 @@ function initPassengerCalculator() {
updateOriginDestination();
}
+ // Render inicial
+ renderPassengerCards(selectedSeats);
+ updateSummary();
+
+ // Envío del Formulario y Reserva en MongoDB con Pooling
if (bookBtn) {
bookBtn.addEventListener('click', async (e) => {
e.preventDefault();
- // Verificación de seguridad: si no ha validado identidad con OTP, pedir autenticación
+ saveCurrentFormData();
+
+ // 1. Validar dirección principal de Cali
+ const caliAddress = caliAddressInput ? caliAddressInput.value.trim() : '';
+ const isCaliOrigin = originSelect ? originSelect.value === 'CALI' : true;
+ if (isCaliOrigin && !caliAddress) {
+ alert('📍 Por favor ingresa la dirección de recogida en Cali (domicilio puerta a puerta).');
+ caliAddressInput?.focus();
+ return;
+ }
+
+ // 2. Validar que cada pasajero tenga nombre, documento y teléfono
+ const validatedPassengers = [];
+ for (let i = 1; i <= selectedSeats; i++) {
+ const d = passengersData[i - 1];
+ if (!d.nombre || d.nombre.trim().length < 3) {
+ alert(`⚠️ Por favor escribe el nombre completo del Pasajero #${i}.`);
+ document.getElementById(`passenger-${i}-name`)?.focus();
+ return;
+ }
+ if (!d.doc || d.doc.trim().length < 5) {
+ alert(`⚠️ Por favor ingresa el documento de identidad del Pasajero #${i} (requerido para la póliza contractual SOAT).`);
+ document.getElementById(`passenger-${i}-doc`)?.focus();
+ return;
+ }
+ if (!d.phone || d.phone.trim().replace(/\D/g, '').length < 7) {
+ alert(`⚠️ Por favor ingresa un número de teléfono o WhatsApp válido para el Pasajero #${i}.`);
+ document.getElementById(`passenger-${i}-phone`)?.focus();
+ return;
+ }
+
+ const pAddress = (!d.sameAddress && d.address && d.address.trim())
+ ? d.address.trim()
+ : (caliAddress || 'Punto Oficial Buenaventura');
+
+ validatedPassengers.push({
+ nombre: d.nombre.trim(),
+ documentoIdentidad: d.doc.trim(),
+ telefono: d.phone.trim(),
+ direccionRecogida: pAddress,
+ coordenadas: [-76.5320, 3.4215],
+ notas: d.notes ? d.notes.trim() : '',
+ esTitular: (i === 1)
+ });
+ }
+
+ // 3. Verificación de seguridad OTP
if (!currentUser) {
+ const p1Phone = validatedPassengers[0].telefono.replace(/\D/g, '');
+ const authInput = document.getElementById('auth-identifier');
+ if (authInput && p1Phone) {
+ authInput.value = p1Phone;
+ }
alert('🛡️ Por seguridad de los conductores y de los demás pasajeros, debes verificar tu identidad con OTP antes de confirmar tu reserva.');
document.getElementById('modal-auth-dripzone')?.classList.add('active');
return;
}
+ // 4. Procesar reserva en el Backend
bookBtn.disabled = true;
+ const originalBtnHtml = bookBtn.innerHTML;
bookBtn.innerHTML = `
- Procesando Reserva...
+ Agrupando ${selectedSeats} Cupo(s)...
`;
- setTimeout(() => {
- currentSeatsFilled = 4;
- updateSeatVisuals(currentSeatsFilled);
+ try {
+ const payload = {
+ usuarioId: currentUser ? currentUser._id : undefined,
+ sentido: isCaliOrigin ? 'CALI_A_BUENAVENTURA' : 'BUENAVENTURA_A_CALI',
+ direccionTexto: caliAddress || 'Terminal Buenaventura',
+ coordenadas: [-76.5320, 3.4215],
+ puntoFijoBuenaventura: {
+ nombre: buenaStopSelect ? buenaStopSelect.options[buenaStopSelect.selectedIndex].text : 'Terminal de Transporte de Buenaventura',
+ coordenadas: [-77.0250, 3.8820]
+ },
+ metodoPago: selectedPaymentMethod,
+ cuposSolicitados: selectedSeats,
+ pasajeros: validatedPassengers,
+ codigoCupon: appliedCoupon?.coupon?.codigo || undefined,
+ email: currentUser?.email || ''
+ };
- const alertBox = document.getElementById('pool-dispatch-alert');
- if (alertBox) {
- alertBox.style.display = 'block';
- alertBox.scrollIntoView({ behavior: 'smooth' });
+ const token = localStorage.getItem('pt_token') || '';
+ const res = await fetch('/api/trips/pool', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await res.json();
+
+ if (res.ok && data.success) {
+ const totalOccupied = data.totalCuposOcupados || data.pasajerosAgrupados || 4;
+ updateSeatVisuals(totalOccupied);
+
+ // Mostrar alerta detallada
+ const alertBox = document.getElementById('pool-dispatch-alert');
+ const alertTitle = document.getElementById('alert-title');
+ const alertMsg = document.getElementById('alert-message');
+ const alertBreakdown = document.getElementById('alert-passengers-breakdown');
+ const alertBoldArea = document.getElementById('alert-bold-payment-area');
+
+ if (alertTitle) {
+ alertTitle.textContent = totalOccupied === 4
+ ? '🔥 ¡Vehículo Completo (4/4 Cupos) Listo para Despacho!'
+ : `✅ ¡${selectedSeats} ${selectedSeats === 1 ? 'Cupo Reservado' : 'Cupos Reservados'} Exitosamente! (${totalOccupied}/4 Ocupados)`;
+ }
+
+ if (alertMsg) {
+ alertMsg.innerHTML = `
+ Viaje #${data.tripId?.slice(-6) || ''} en ruta ${payload.sentido === 'CALI_A_BUENAVENTURA' ? 'Cali ➔ Buenaventura' : 'Buenaventura ➔ Cali'}.
+ ${data.message}
+ `;
+ }
+
+ if (alertBreakdown) {
+ let breakdownHtml = `
+
👥 Pasajeros Registrados en esta Reserva:
`;
+ validatedPassengers.forEach((p, idx) => {
+ breakdownHtml += `
+
+ ${idx + 1}. ${p.nombre} (C.C. ${p.documentoIdentidad}) • Tel: +${p.telefono} • Recogida: ${p.direccionRecogida} ${p.notas ? `• 🧳 ${p.notas}` : ''}
+
+ `;
+ });
+ breakdownHtml += `
`;
+ alertBreakdown.innerHTML = breakdownHtml;
+ }
+
+ // Si es pago Bold, renderizar botón y QR
+ if (alertBoldArea) {
+ if (data.boldPayment && data.boldPayment.checkoutUrl) {
+ alertBoldArea.style.display = 'block';
+ alertBoldArea.innerHTML = `
+
+ `;
+ } else {
+ alertBoldArea.style.display = 'none';
+ }
+ }
+
+ if (alertBox) {
+ alertBox.style.display = 'block';
+ alertBox.scrollIntoView({ behavior: 'smooth' });
+ }
+
+ const formattedTotal = `$${(selectedSeats * (currentTariffs.tarifaPasajeroFija || 35000)).toLocaleString('es-CO')} COP`;
+ bookBtn.innerHTML = `✅ ${selectedSeats} Cupo(s) Confirmado(s) (${formattedTotal})`;
+ } else {
+ alert('❌ No se pudo completar la reserva: ' + (data.message || data.error || 'Intenta de nuevo'));
+ bookBtn.innerHTML = originalBtnHtml;
+ bookBtn.disabled = false;
}
-
+ } catch (err) {
+ console.error('Error booking pool:', err);
+ alert('⚠️ Error de conexión con el servidor de pooling. Verifica tu red.');
+ bookBtn.innerHTML = originalBtnHtml;
bookBtn.disabled = false;
- const formatted = `$${currentTariffs.tarifaPasajeroFija.toLocaleString('es-CO')} COP`;
- bookBtn.innerHTML = `✅ Cupo Reservado Exitosamente (${formatted})`;
- }, 1200);
+ }
});
}
@@ -1338,7 +1777,7 @@ function initPassengerCalculator() {
}
});
const seatCounter = document.getElementById('seat-counter-text');
- if (seatCounter) seatCounter.textContent = `${count}/4 Pasajeros`;
+ if (seatCounter) seatCounter.textContent = `${count}/4 Pasajeros Confirmados`;
}
updateSeatVisuals(currentSeatsFilled);
@@ -1354,10 +1793,19 @@ function initFreightCalculator() {
const baseCostEl = document.getElementById('freight-base-cost');
const distCostEl = document.getElementById('freight-dist-cost');
const insuranceCostEl = document.getElementById('freight-insurance-cost');
+ const freightSubtotalRow = document.getElementById('freight-subtotal-row');
+ const freightSubtotalPrice = document.getElementById('freight-subtotal-price');
+ const freightDiscountRow = document.getElementById('freight-discount-row');
+ const freightDiscountAmount = document.getElementById('freight-discount-amount');
+ const freightCouponInput = document.getElementById('freight-coupon-code');
+ const btnApplyFreightCoupon = document.getElementById('btn-apply-freight-coupon');
+ const freightCouponFeedback = document.getElementById('freight-coupon-feedback');
+ const btnBookFreight = document.getElementById('btn-book-freight');
const KM_DIST = 115; // Corredor Cali - Buenaventura
+ let appliedFreightCoupon = null;
- window.recalculateFreight = function() {
+ window.recalculateFreight = function () {
const rawFactor = parseFloat(packageTypeSelect?.value || '1.0');
let factor = rawFactor;
if (rawFactor === 1.0 && currentTariffs.factorPaquetes?.sobre) {
@@ -1375,17 +1823,172 @@ function initFreightCalculator() {
const distancePart = KM_DIST * costPerKm;
const subtotal = (basePrice + distancePart) * factor;
const seguro = Math.round(declaredValue * 0.01) + (currentTariffs.seguroBasePaquete || 0);
- const total = Math.round(subtotal + seguro);
+ const subtotalBruto = Math.round(subtotal + seguro);
+
+ let discountAmount = 0;
+ if (appliedFreightCoupon && appliedFreightCoupon.valid) {
+ if (appliedFreightCoupon.coupon.tipoDescuento === 'PORCENTAJE') {
+ discountAmount = Math.round(subtotalBruto * (appliedFreightCoupon.coupon.valorDescuento / 100));
+ } else {
+ discountAmount = Math.min(appliedFreightCoupon.coupon.valorDescuento, subtotalBruto);
+ }
+ discountAmount = Math.min(discountAmount, subtotalBruto);
+ }
+
+ const total = Math.max(0, subtotalBruto - discountAmount);
if (baseCostEl) baseCostEl.textContent = `$${basePrice.toLocaleString('es-CO')} COP`;
if (distCostEl) distCostEl.textContent = `$${Math.round(distancePart * factor).toLocaleString('es-CO')} COP`;
if (insuranceCostEl) insuranceCostEl.textContent = `$${seguro.toLocaleString('es-CO')} COP`;
+
+ if (discountAmount > 0) {
+ if (freightSubtotalRow) {
+ freightSubtotalRow.style.display = 'flex';
+ if (freightSubtotalPrice) freightSubtotalPrice.textContent = `$${subtotalBruto.toLocaleString('es-CO')} COP`;
+ }
+ if (freightDiscountRow) {
+ freightDiscountRow.style.display = 'flex';
+ const typeText = appliedFreightCoupon.coupon.tipoDescuento === 'PORCENTAJE' ? `${appliedFreightCoupon.coupon.valorDescuento}% OFF` : 'Bono Fijo';
+ if (freightDiscountAmount) freightDiscountAmount.textContent = `- $${discountAmount.toLocaleString('es-CO')} COP (${typeText})`;
+ }
+ } else {
+ if (freightSubtotalRow) freightSubtotalRow.style.display = 'none';
+ if (freightDiscountRow) freightDiscountRow.style.display = 'none';
+ }
+
if (totalPriceEl) totalPriceEl.textContent = `$${total.toLocaleString('es-CO')} COP`;
};
if (packageTypeSelect) packageTypeSelect.addEventListener('change', window.recalculateFreight);
if (declaredValueInput) declaredValueInput.addEventListener('input', window.recalculateFreight);
- window.recalculateFreight();
+
+ if (btnApplyFreightCoupon && freightCouponInput) {
+ btnApplyFreightCoupon.addEventListener('click', async () => {
+ const code = freightCouponInput.value.trim().toUpperCase();
+ if (!code) {
+ if (freightCouponFeedback) {
+ freightCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-error';
+ freightCouponFeedback.textContent = '⚠️ Ingresa el código del bono o cupón de envío.';
+ freightCouponFeedback.style.display = 'flex';
+ }
+ return;
+ }
+
+ btnApplyFreightCoupon.disabled = true;
+ btnApplyFreightCoupon.textContent = '...';
+
+ try {
+ const rawFactor = parseFloat(packageTypeSelect?.value || '1.0');
+ const declaredValue = parseFloat(declaredValueInput?.value || '0');
+ const basePrice = currentTariffs.precioBasePaquete || 25000;
+ const costPerKm = currentTariffs.precioKmPaquete || 1200;
+ const distancePart = KM_DIST * costPerKm;
+ const subtotal = (basePrice + distancePart) * rawFactor;
+ const seguro = Math.round(declaredValue * 0.01) + (currentTariffs.seguroBasePaquete || 0);
+ const subtotalBruto = Math.round(subtotal + seguro);
+
+ const res = await fetch('/api/coupons/validate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ codigo: code, serviceType: 'ENCOMIENDAS', subtotal: subtotalBruto })
+ });
+ const data = await res.json();
+
+ if (data.valid) {
+ appliedFreightCoupon = data;
+ if (freightCouponFeedback) {
+ freightCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-success';
+ freightCouponFeedback.textContent = data.message;
+ freightCouponFeedback.style.display = 'flex';
+ }
+ } else {
+ appliedFreightCoupon = null;
+ if (freightCouponFeedback) {
+ freightCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-error';
+ freightCouponFeedback.textContent = data.message || 'Cupón no válido para encomiendas';
+ freightCouponFeedback.style.display = 'flex';
+ }
+ }
+ window.recalculateFreight();
+ } catch (err) {
+ console.error('Coupon freight error:', err);
+ if (freightCouponFeedback) {
+ freightCouponFeedback.className = 'coupon-feedback-pill coupon-feedback-error';
+ freightCouponFeedback.textContent = 'Error al verificar cupón.';
+ freightCouponFeedback.style.display = 'flex';
+ }
+ } finally {
+ btnApplyFreightCoupon.disabled = false;
+ btnApplyFreightCoupon.textContent = 'Aplicar';
+ }
+ });
+ }
+
+ // Despacho / Creación de Encomienda con Bold y Cupón
+ if (btnBookFreight) {
+ btnBookFreight.addEventListener('click', async (e) => {
+ e.preventDefault();
+ const senderPhone = document.getElementById('sender-phone')?.value.trim();
+ const receiverPhone = document.getElementById('receiver-phone')?.value.trim();
+ const packageDesc = document.getElementById('package-desc')?.value.trim();
+
+ if (!senderPhone || senderPhone.replace(/\D/g, '').length < 7) {
+ alert('⚠️ Por favor ingresa el teléfono/WhatsApp del remitente.');
+ document.getElementById('sender-phone')?.focus();
+ return;
+ }
+ if (!receiverPhone || receiverPhone.replace(/\D/g, '').length < 7) {
+ alert('⚠️ Por favor ingresa el teléfono/WhatsApp del destinatario.');
+ document.getElementById('receiver-phone')?.focus();
+ return;
+ }
+ if (!packageDesc) {
+ alert('⚠️ Por favor describe el contenido de la encomienda.');
+ document.getElementById('package-desc')?.focus();
+ return;
+ }
+
+ btnBookFreight.disabled = true;
+ const originalText = btnBookFreight.innerHTML;
+ btnBookFreight.innerHTML = '⏳ Generando Enlace de Pago Bold...';
+
+ try {
+ const payload = {
+ tipoPaquete: packageTypeSelect ? packageTypeSelect.options[packageTypeSelect.selectedIndex].text : 'Paquete Pequeño',
+ factor: parseFloat(packageTypeSelect?.value || '1.2'),
+ valorDeclarado: parseFloat(declaredValueInput?.value || '100000'),
+ telefonoRemitente: senderPhone,
+ telefonoDestinatario: receiverPhone,
+ descripcionContenido: packageDesc,
+ codigoCupon: appliedFreightCoupon?.coupon?.codigo || undefined
+ };
+
+ const res = await fetch('/api/trips/package', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await res.json();
+ if (res.ok && data.success) {
+ if (data.boldPaymentLink) {
+ window.open(data.boldPaymentLink, '_blank');
+ alert(`✅ ¡Guía #${data.guiaSeguimiento || 'PT'} generada!\n\nTarifa Liquidada: $${(data.tarifaFinal || data.tarifaCalculada).toLocaleString('es-CO')} COP\n${data.descuentoCupon ? '🎟️ Descuento aplicado: -$' + data.descuentoCupon.descuento.toLocaleString('es-CO') + ' COP\n' : ''}\nSe ha abierto la pasarela Bold Smart Checkout para completar el pago.`);
+ } else {
+ alert(`✅ Guía generada correctamente: #${data.guiaSeguimiento}`);
+ }
+ } else {
+ alert('Error al generar envío: ' + (data.error || 'Verifica los datos'));
+ }
+ } catch (err) {
+ console.error('Error package booking:', err);
+ alert('Error al conectar con el servidor.');
+ } finally {
+ btnBookFreight.disabled = false;
+ btnBookFreight.innerHTML = originalText;
+ }
+ });
+ }
}
/* ==========================================================================
@@ -1517,7 +2120,7 @@ function initSettlementsAndBreBSystem() {
const btnShareBrebWa = document.getElementById('btn-share-breb-whatsapp');
// Helper para abrir el comprobante oficial de Bre-B
- window.openBreBReceiptModal = function(receipt) {
+ window.openBreBReceiptModal = function (receipt) {
if (!brebModal) return;
const amountEl = document.getElementById('modal-receipt-amount');
const datetimeEl = document.getElementById('modal-receipt-datetime');
@@ -1576,7 +2179,7 @@ function initSettlementsAndBreBSystem() {
/* --------------------------------------------------------------------------
A. PANEL ADMINISTRADOR: OVERVIEW & CONTROL DE PAGOS DE VIAJE
-------------------------------------------------------------------------- */
- window.fetchSettlementsOverview = async function() {
+ window.fetchSettlementsOverview = async function () {
try {
const res = await fetch('/api/settlements/overview');
const data = await res.json();
@@ -1823,12 +2426,15 @@ function initSettlementsAndBreBSystem() {
html += `
-
+
${idx + 1}. ${p.nombre}
(+${p.telefono})
+ ${p.documentoIdentidad ? `C.C. ${p.documentoIdentidad} ` : ''}
+ ${p.esTitular ? 'Titular ' : ''}
📍 Recogida: ${p.direccionRecogida}
+ ${p.notas ? `🧳 Equipaje: ${p.notas} ` : ''}
@@ -2118,7 +2724,7 @@ function initSettlementsAndBreBSystem() {
/* --------------------------------------------------------------------------
B. PORTAL DEL CONDUCTOR: BILLETERA DIGITAL & RETIROS BRE-B
-------------------------------------------------------------------------- */
- window.fetchDriverWallet = async function() {
+ window.fetchDriverWallet = async function () {
const driverId = currentUser?._id || defaultDriverId;
try {
const res = await fetch(`/api/settlements/driver/${driverId}/wallet`);
@@ -2366,7 +2972,7 @@ function initSettlementsAndBreBSystem() {
}
// 2. Administrador: Bandeja de Solicitudes de Transferencia Bre-B
- window.fetchAdminPayoutRequests = async function() {
+ window.fetchAdminPayoutRequests = async function () {
const listEl = document.getElementById('admin-payout-requests-list');
const badgeEl = document.getElementById('admin-payouts-badge');
const filterSelect = document.getElementById('filter-payout-requests-status');
@@ -2584,7 +3190,7 @@ function initSettlementsAndBreBSystem() {
};
// 3. Conductor: Seguimiento de "Mis Solicitudes de Transferencia"
- window.fetchDriverPayoutRequests = async function() {
+ window.fetchDriverPayoutRequests = async function () {
const listEl = document.getElementById('driver-my-payout-requests-list');
if (!listEl) return;
@@ -2692,4 +3298,261 @@ function initSettlementsAndBreBSystem() {
window.fetchDriverPayoutRequests();
}
+/* ==========================================================================
+ 14. GESTIÓN ADMINISTRATIVA DE BONOS Y CUPONES DE DESCUENTO
+ ========================================================================== */
+function initCouponsManager() {
+ const form = document.getElementById('form-create-coupon');
+ const typeSelect = document.getElementById('coupon-type-select');
+ const labelValue = document.getElementById('label-coupon-value');
+ const inputValue = document.getElementById('coupon-value-input');
+ const feedbackEl = document.getElementById('coupon-form-feedback');
+ const btnRefresh = document.getElementById('btn-refresh-coupons');
+
+ // Ajustar etiqueta dinámica según tipo de descuento
+ if (typeSelect && labelValue && inputValue) {
+ typeSelect.addEventListener('change', () => {
+ if (typeSelect.value === 'PORCENTAJE') {
+ labelValue.textContent = 'Valor (% Descuento) *';
+ inputValue.placeholder = 'Ej: 15';
+ inputValue.max = '100';
+ } else {
+ labelValue.textContent = 'Monto Fijo de Descuento ($ COP) *';
+ inputValue.placeholder = 'Ej: 5000';
+ inputValue.removeAttribute('max');
+ }
+ });
+ }
+
+ // Crear cupón
+ if (form) {
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const codeInput = document.getElementById('coupon-code-input');
+ const descInput = document.getElementById('coupon-desc-input');
+ const serviceSelect = document.getElementById('coupon-service-select');
+ const minSubtotalInput = document.getElementById('coupon-min-subtotal');
+ const limitInput = document.getElementById('coupon-usage-limit');
+ const expDateInput = document.getElementById('coupon-exp-date');
+ const activeCheck = document.getElementById('coupon-active-check');
+ const submitBtn = document.getElementById('btn-submit-coupon');
+
+ const code = (codeInput?.value || '').trim().toUpperCase();
+ const valor = parseFloat(inputValue?.value || '0');
+
+ if (!code) {
+ alert('⚠️ Por favor ingresa el código del cupón.');
+ codeInput?.focus();
+ return;
+ }
+
+ if (isNaN(valor) || valor <= 0) {
+ alert('⚠️ El valor del descuento debe ser mayor a cero.');
+ inputValue?.focus();
+ return;
+ }
+
+ if (typeSelect?.value === 'PORCENTAJE' && valor > 100) {
+ alert('⚠️ El porcentaje de descuento no puede ser mayor a 100%.');
+ inputValue?.focus();
+ return;
+ }
+
+ submitBtn.disabled = true;
+ submitBtn.textContent = '⏳ Creando cupón en MongoDB...';
+
+ const payload = {
+ codigo: code,
+ descripcion: descInput?.value.trim() || '',
+ tipoDescuento: typeSelect?.value || 'PORCENTAJE',
+ valorDescuento: valor,
+ aplicaPara: serviceSelect?.value || 'TODOS',
+ montoMinimoSubtotal: parseFloat(minSubtotalInput?.value || '0') || 0,
+ limiteUsosTotales: limitInput?.value ? parseInt(limitInput.value, 10) : null,
+ fechaExpiracion: expDateInput?.value ? expDateInput.value : null,
+ estaActivo: activeCheck ? activeCheck.checked : true
+ };
+
+ try {
+ const token = localStorage.getItem('pt_token') || '';
+ const res = await fetch('/api/coupons', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await res.json();
+ if (res.ok && data.success) {
+ if (feedbackEl) {
+ feedbackEl.className = 'coupon-feedback-pill coupon-feedback-success';
+ feedbackEl.textContent = `✅ Cupón "${data.coupon.codigo}" creado exitosamente.`;
+ feedbackEl.style.display = 'flex';
+ }
+ form.reset();
+ if (activeCheck) activeCheck.checked = true;
+ fetchAdminCoupons();
+ } else {
+ if (feedbackEl) {
+ feedbackEl.className = 'coupon-feedback-pill coupon-feedback-error';
+ feedbackEl.textContent = `❌ ${data.error || 'Error al crear cupón.'}`;
+ feedbackEl.style.display = 'flex';
+ }
+ }
+ } catch (err) {
+ console.error('Error creating coupon:', err);
+ if (feedbackEl) {
+ feedbackEl.className = 'coupon-feedback-pill coupon-feedback-error';
+ feedbackEl.textContent = 'Error de conexión con el servidor.';
+ feedbackEl.style.display = 'flex';
+ }
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = '➕ Crear y Publicar Cupón de Descuento';
+ }
+ });
+ }
+
+ if (btnRefresh) {
+ btnRefresh.addEventListener('click', fetchAdminCoupons);
+ }
+}
+
+async function fetchAdminCoupons() {
+ const tableBody = document.getElementById('coupons-table-body');
+ const counterLabel = document.getElementById('coupons-counter-label');
+ const kpiTotal = document.getElementById('kpi-total-coupons');
+ const kpiActive = document.getElementById('kpi-active-coupons');
+ const kpiRedeemed = document.getElementById('kpi-redeemed-coupons');
+
+ try {
+ const res = await fetch('/api/coupons');
+ const data = await res.json();
+
+ if (res.ok && data.success) {
+ const coupons = data.coupons || [];
+
+ if (counterLabel) counterLabel.textContent = `${coupons.length} Registrados`;
+ if (kpiTotal) kpiTotal.textContent = coupons.length;
+
+ const activeCount = coupons.filter(c => c.estaActivo).length;
+ if (kpiActive) kpiActive.textContent = activeCount;
+
+ const totalRedeemed = coupons.reduce((sum, c) => sum + (c.canjesActuales || 0), 0);
+ if (kpiRedeemed) kpiRedeemed.textContent = totalRedeemed;
+
+ if (!tableBody) return;
+
+ if (coupons.length === 0) {
+ tableBody.innerHTML = `
+
+
+ No hay cupones creados aún. Usa el formulario de la izquierda para crear el primero.
+
+
+ `;
+ return;
+ }
+
+ tableBody.innerHTML = coupons.map(c => {
+ const isPorc = c.tipoDescuento === 'PORCENTAJE';
+ const descFmt = isPorc ? `${c.valorDescuento}% OFF` : `$${c.valorDescuento.toLocaleString('es-CO')} COP OFF`;
+ const typeBadge = `
${descFmt} `;
+
+ let serviceIcon = '🌟';
+ let serviceClass = 'coupon-service-todos';
+ let serviceName = 'Todos';
+ if (c.aplicaPara === 'VIAJES') {
+ serviceIcon = '🚗';
+ serviceClass = 'coupon-service-viajes';
+ serviceName = 'Viajes';
+ } else if (c.aplicaPara === 'ENCOMIENDAS') {
+ serviceIcon = '📦';
+ serviceClass = 'coupon-service-encomiendas';
+ serviceName = 'Encomiendas';
+ }
+ const serviceBadge = `
${serviceIcon} ${serviceName} `;
+
+ const usageText = c.limiteUsosTotales ? `${c.canjesActuales || 0} / ${c.limiteUsosTotales}` : `${c.canjesActuales || 0} (Sin límite)`;
+
+ const statusBtn = `
+
+ ${c.estaActivo ? '🟢 Activo' : '⚪ Inactivo'}
+
+ `;
+
+ const deleteBtn = `
+
+ 🗑️
+
+ `;
+
+ return `
+
+
+ ${c.codigo}
+ ${c.descripcion ? `${c.descripcion}
` : ''}
+
+ ${typeBadge}
+ ${serviceBadge}
+
+ ${usageText}
+ ${c.montoMinimoSubtotal > 0 ? `Min: $${c.montoMinimoSubtotal.toLocaleString('es-CO')}
` : ''}
+
+ ${statusBtn}
+ ${deleteBtn}
+
+ `;
+ }).join('');
+ }
+ } catch (err) {
+ console.error('Error fetching admin coupons:', err);
+ }
+}
+
+window.fetchAdminCoupons = fetchAdminCoupons;
+
+window.toggleCouponStatus = async function (couponId, nextState) {
+ try {
+ const token = localStorage.getItem('pt_token') || '';
+ const res = await fetch('/api/coupons/toggle', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({ id: couponId, estaActivo: nextState })
+ });
+ if (res.ok) {
+ fetchAdminCoupons();
+ }
+ } catch (err) {
+ console.error('Error toggling coupon status:', err);
+ }
+};
+
+window.deleteAdminCoupon = async function (couponId, code) {
+ if (!confirm(`¿Estás seguro de eliminar el cupón "${code}"? Esta acción no se puede deshacer.`)) {
+ return;
+ }
+ try {
+ const token = localStorage.getItem('pt_token') || '';
+ const res = await fetch('/api/coupons/delete', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({ id: couponId })
+ });
+ if (res.ok) {
+ fetchAdminCoupons();
+ }
+ } catch (err) {
+ console.error('Error deleting coupon:', err);
+ }
+};
+
diff --git a/transporte-cali-buenaventura/backend/src/index.ts b/transporte-cali-buenaventura/backend/src/index.ts
index 119f026..2f5fdea 100644
--- a/transporte-cali-buenaventura/backend/src/index.ts
+++ b/transporte-cali-buenaventura/backend/src/index.ts
@@ -15,6 +15,7 @@ import { authService } from './services/auth.service';
import { statsReviewsService } from './services/stats-reviews.service';
import { settlementService } from './services/settlement.service';
import { whatsAppNotificationService } from './services/whatsapp-notification.service';
+import { couponService } from './services/coupon.service';
import { Trip } from './models/Trip';
const PORT = process.env.PORT || 4000;
@@ -316,6 +317,48 @@ const server = http.createServer(async (req, res) => {
return sendJson(200, { success: true, message: 'Tarifas actualizadas correctamente en MongoDB', config: updated });
}
+ // 13.2.1. Gestión de Bonos y Cupones de Descuento (Viajes y Encomiendas)
+ if (pathname === '/api/coupons' && req.method === 'GET') {
+ const coupons = await couponService.getAllCoupons();
+ return sendJson(200, { success: true, count: coupons.length, coupons });
+ }
+
+ if (pathname === '/api/coupons' && req.method === 'POST') {
+ const body = await getJsonBody();
+ try {
+ const coupon = await couponService.createCoupon(body);
+ return sendJson(201, { success: true, coupon });
+ } catch (err: any) {
+ return sendJson(400, { success: false, error: err.message || 'Error al crear cupón' });
+ }
+ }
+
+ if ((pathname === '/api/coupons/toggle' || (pathname.startsWith('/api/coupons/') && pathname.endsWith('/toggle'))) && (req.method === 'POST' || req.method === 'PATCH')) {
+ const body = await getJsonBody();
+ const id = body.id || body.couponId || pathname.split('/')[3];
+ if (!id) return sendJson(400, { success: false, error: 'ID de cupón requerido' });
+ const coupon = await couponService.toggleCouponStatus(id, body.estaActivo);
+ return sendJson(200, { success: true, coupon });
+ }
+
+ if ((pathname === '/api/coupons/delete' && req.method === 'POST') || (pathname.startsWith('/api/coupons/') && req.method === 'DELETE')) {
+ const body = await getJsonBody();
+ const id = pathname.split('/')[3] || body.id || body.couponId;
+ if (!id) return sendJson(400, { success: false, error: 'ID de cupón requerido' });
+ const success = await couponService.deleteCoupon(id);
+ return sendJson(200, { success });
+ }
+
+ if (pathname === '/api/coupons/validate' && req.method === 'POST') {
+ const body = await getJsonBody();
+ const validation = await couponService.validateCoupon(
+ body.codigo || body.code,
+ body.serviceType || body.tipoServicio || 'VIAJES',
+ parseFloat(body.subtotal || 0)
+ );
+ return sendJson(200, validation);
+ }
+
// 13.3. Estadísticas de Viajes y Sistema de Calificaciones/Comentarios
if (pathname === '/api/stats-reviews' && req.method === 'GET') {
const data = await statsReviewsService.getStatsAndReviews();
@@ -499,6 +542,7 @@ async function bootstrap() {
try {
await connectDatabase();
console.log('✅ [MongoDB] Conectado exitosamente en segundo plano.');
+ await couponService.ensureSeedCoupons();
} catch (err: any) {
console.warn(`⚠️ [MongoDB Warning] Conexión no disponible (${err.message || 'ENOTFOUND'}). Reintentando en 10s...`);
setTimeout(tryConnect, 10000);
diff --git a/transporte-cali-buenaventura/backend/src/models/Coupon.ts b/transporte-cali-buenaventura/backend/src/models/Coupon.ts
new file mode 100644
index 0000000..f44e6f7
--- /dev/null
+++ b/transporte-cali-buenaventura/backend/src/models/Coupon.ts
@@ -0,0 +1,83 @@
+import mongoose, { Schema, Document, Model } from 'mongoose';
+
+export type CouponDiscountType = 'PORCENTAJE' | 'MONTO_FIJO';
+export type CouponApplicableService = 'TODOS' | 'VIAJES' | 'ENCOMIENDAS';
+
+export interface ICoupon extends Document {
+ codigo: string;
+ descripcion: string;
+ tipoDescuento: CouponDiscountType;
+ valorDescuento: number;
+ aplicaPara: CouponApplicableService;
+ montoMinimoSubtotal: number;
+ limiteUsosTotales?: number | null;
+ canjesActuales: number;
+ fechaExpiracion?: Date | null;
+ estaActivo: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const CouponSchema = new Schema
(
+ {
+ codigo: {
+ type: String,
+ required: true,
+ unique: true,
+ uppercase: true,
+ trim: true,
+ index: true,
+ },
+ descripcion: {
+ type: String,
+ default: '',
+ trim: true,
+ },
+ tipoDescuento: {
+ type: String,
+ enum: ['PORCENTAJE', 'MONTO_FIJO'],
+ required: true,
+ default: 'PORCENTAJE',
+ },
+ valorDescuento: {
+ type: Number,
+ required: true,
+ min: 1,
+ },
+ aplicaPara: {
+ type: String,
+ enum: ['TODOS', 'VIAJES', 'ENCOMIENDAS'],
+ required: true,
+ default: 'TODOS',
+ },
+ montoMinimoSubtotal: {
+ type: Number,
+ default: 0,
+ min: 0,
+ },
+ limiteUsosTotales: {
+ type: Number,
+ default: null,
+ },
+ canjesActuales: {
+ type: Number,
+ default: 0,
+ min: 0,
+ },
+ fechaExpiracion: {
+ type: Date,
+ default: null,
+ },
+ estaActivo: {
+ type: Boolean,
+ default: true,
+ index: true,
+ },
+ },
+ {
+ timestamps: true,
+ }
+);
+
+export const Coupon: Model =
+ mongoose.models.Coupon || mongoose.model('Coupon', CouponSchema);
diff --git a/transporte-cali-buenaventura/backend/src/models/Trip.ts b/transporte-cali-buenaventura/backend/src/models/Trip.ts
index b43c155..2ba5e95 100644
--- a/transporte-cali-buenaventura/backend/src/models/Trip.ts
+++ b/transporte-cali-buenaventura/backend/src/models/Trip.ts
@@ -35,9 +35,10 @@ export interface ITripLiquidacion {
}
export interface ITripPassenger {
- usuarioId: Types.ObjectId;
+ usuarioId?: Types.ObjectId;
nombre: string;
telefono: string;
+ documentoIdentidad?: string;
direccionRecogida: string;
coordenadas: IGeoPoint;
estadoPago: PassengerPaymentStatus;
@@ -45,6 +46,8 @@ export interface ITripPassenger {
secuenciaOrden?: number;
horaEstimadaRecogida?: string;
recogido?: boolean;
+ notas?: string;
+ esTitular?: boolean;
}
export interface IPackageInfo {
@@ -128,6 +131,14 @@ export interface ITrip extends Document {
// Control Financiero, Liquidación de Saldo y Despacho Bre-B
liquidacion?: ITripLiquidacion;
+ // Descuento aplicado por cupón promocional
+ descuentoCupon?: {
+ codigo: string;
+ tipo: 'PORCENTAJE' | 'MONTO_FIJO';
+ valor: number;
+ montoDescontado: number;
+ };
+
createdAt: Date;
updatedAt: Date;
}
@@ -153,7 +164,7 @@ const TripPassengerSchema = new Schema(
usuarioId: {
type: Schema.Types.ObjectId,
ref: 'User',
- required: true,
+ required: false,
},
nombre: {
type: String,
@@ -163,6 +174,10 @@ const TripPassengerSchema = new Schema(
type: String,
required: true,
},
+ documentoIdentidad: {
+ type: String,
+ default: '',
+ },
direccionRecogida: {
type: String,
required: true,
@@ -189,6 +204,14 @@ const TripPassengerSchema = new Schema(
type: Boolean,
default: false,
},
+ notas: {
+ type: String,
+ default: '',
+ },
+ esTitular: {
+ type: Boolean,
+ default: false,
+ },
},
{ _id: true }
);
@@ -270,6 +293,12 @@ const TripSchema = new Schema(
required: true,
default: 0,
},
+ descuentoCupon: {
+ codigo: { type: String, uppercase: true },
+ tipo: { type: String, enum: ['PORCENTAJE', 'MONTO_FIJO'] },
+ valor: Number,
+ montoDescontado: { type: Number, default: 0 },
+ },
comisionAdmin: {
type: Number,
required: true,
diff --git a/transporte-cali-buenaventura/backend/src/services/coupon.service.ts b/transporte-cali-buenaventura/backend/src/services/coupon.service.ts
new file mode 100644
index 0000000..e156de9
--- /dev/null
+++ b/transporte-cali-buenaventura/backend/src/services/coupon.service.ts
@@ -0,0 +1,299 @@
+import { Coupon, ICoupon, CouponDiscountType, CouponApplicableService } from '../models/Coupon';
+
+export interface CreateCouponDTO {
+ codigo: string;
+ descripcion?: string;
+ tipoDescuento: CouponDiscountType;
+ valorDescuento: number;
+ aplicaPara: CouponApplicableService;
+ montoMinimoSubtotal?: number;
+ limiteUsosTotales?: number | null;
+ fechaExpiracion?: Date | string | null;
+ estaActivo?: boolean;
+}
+
+export interface CouponValidationResult {
+ valid: boolean;
+ coupon?: {
+ id: string;
+ codigo: string;
+ descripcion: string;
+ tipoDescuento: CouponDiscountType;
+ valorDescuento: number;
+ aplicaPara: CouponApplicableService;
+ };
+ subtotalOriginal: number;
+ descuentoCalculado: number;
+ totalFinal: number;
+ message: string;
+}
+
+export class CouponService {
+ private static instance: CouponService;
+
+ private constructor() {}
+
+ public static getInstance(): CouponService {
+ if (!CouponService.instance) {
+ CouponService.instance = new CouponService();
+ }
+ return CouponService.instance;
+ }
+
+ /**
+ * Inicializa cupones predeterminados si la colección está vacía
+ */
+ public async ensureSeedCoupons(): Promise {
+ try {
+ const count = await Coupon.countDocuments();
+ if (count === 0) {
+ await Coupon.create([
+ {
+ codigo: 'PACIFICO10',
+ descripcion: '10% de descuento en viajes de pasajeros y encomiendas',
+ tipoDescuento: 'PORCENTAJE',
+ valorDescuento: 10,
+ aplicaPara: 'TODOS',
+ montoMinimoSubtotal: 20000,
+ estaActivo: true,
+ },
+ {
+ codigo: 'BONOVIAJE5K',
+ descripcion: 'Bono fijo de $5.000 COP de descuento en reserva de cupos de viaje',
+ tipoDescuento: 'MONTO_FIJO',
+ valorDescuento: 5000,
+ aplicaPara: 'VIAJES',
+ montoMinimoSubtotal: 35000,
+ estaActivo: true,
+ },
+ {
+ codigo: 'ENVIOYA',
+ descripcion: 'Bono de $4.000 COP de descuento en fletes de mensajería y paquetería',
+ tipoDescuento: 'MONTO_FIJO',
+ valorDescuento: 4000,
+ aplicaPara: 'ENCOMIENDAS',
+ montoMinimoSubtotal: 25000,
+ estaActivo: true,
+ },
+ {
+ codigo: 'PROMO20',
+ descripcion: 'Super Bono del 20% de descuento especial',
+ tipoDescuento: 'PORCENTAJE',
+ valorDescuento: 20,
+ aplicaPara: 'TODOS',
+ montoMinimoSubtotal: 50000,
+ estaActivo: true,
+ },
+ ]);
+ console.log('🎟️ [Coupons Seeded] Cupones iniciales creados exitosamente en MongoDB.');
+ }
+ } catch (err) {
+ console.warn('⚠️ [Coupons Seed Warning]:', err);
+ }
+ }
+
+ /**
+ * Obtiene todos los cupones para el panel de administración
+ */
+ public async getAllCoupons(): Promise {
+ await this.ensureSeedCoupons();
+ return Coupon.find().sort({ createdAt: -1 });
+ }
+
+ /**
+ * Crea un nuevo cupón desde el panel administrativo
+ */
+ public async createCoupon(data: CreateCouponDTO): Promise {
+ const cleanCode = (data.codigo || '').trim().toUpperCase();
+ if (!cleanCode) {
+ throw new Error('El código del cupón es obligatorio.');
+ }
+
+ const existing = await Coupon.findOne({ codigo: cleanCode });
+ if (existing) {
+ throw new Error(`El cupón con código "${cleanCode}" ya existe.`);
+ }
+
+ const valor = parseFloat(String(data.valorDescuento));
+ if (isNaN(valor) || valor <= 0) {
+ throw new Error('El valor del descuento debe ser mayor a cero.');
+ }
+
+ if (data.tipoDescuento === 'PORCENTAJE' && valor > 100) {
+ throw new Error('El porcentaje de descuento no puede ser mayor al 100%.');
+ }
+
+ let expDate: Date | null = null;
+ if (data.fechaExpiracion) {
+ expDate = new Date(data.fechaExpiracion);
+ }
+
+ const coupon = await Coupon.create({
+ codigo: cleanCode,
+ descripcion: (data.descripcion || '').trim(),
+ tipoDescuento: data.tipoDescuento || 'PORCENTAJE',
+ valorDescuento: valor,
+ aplicaPara: data.aplicaPara || 'TODOS',
+ montoMinimoSubtotal: data.montoMinimoSubtotal ? Math.max(0, parseFloat(String(data.montoMinimoSubtotal))) : 0,
+ limiteUsosTotales: data.limiteUsosTotales ? parseInt(String(data.limiteUsosTotales), 10) : null,
+ fechaExpiracion: expDate,
+ estaActivo: data.estaActivo !== undefined ? data.estaActivo : true,
+ });
+
+ console.log(`🎟️ [Coupon Created] Cupón creado: ${coupon.codigo} (${coupon.tipoDescuento}: ${coupon.valorDescuento})`);
+ return coupon;
+ }
+
+ /**
+ * Valida un cupón de descuento para un servicio y monto determinados
+ */
+ public async validateCoupon(
+ code: string,
+ serviceType: 'VIAJES' | 'ENCOMIENDAS',
+ subtotal: number
+ ): Promise {
+ await this.ensureSeedCoupons();
+
+ const cleanCode = (code || '').trim().toUpperCase();
+ if (!cleanCode) {
+ return {
+ valid: false,
+ subtotalOriginal: subtotal,
+ descuentoCalculado: 0,
+ totalFinal: subtotal,
+ message: 'Por favor ingresa un código de cupón.',
+ };
+ }
+
+ const coupon = await Coupon.findOne({ codigo: cleanCode });
+ if (!coupon) {
+ return {
+ valid: false,
+ subtotalOriginal: subtotal,
+ descuentoCalculado: 0,
+ totalFinal: subtotal,
+ message: `El cupón "${cleanCode}" no existe o no es válido.`,
+ };
+ }
+
+ if (!coupon.estaActivo) {
+ return {
+ valid: false,
+ subtotalOriginal: subtotal,
+ descuentoCalculado: 0,
+ totalFinal: subtotal,
+ message: `El cupón "${cleanCode}" se encuentra actualmente inactivo.`,
+ };
+ }
+
+ if (coupon.fechaExpiracion && new Date() > new Date(coupon.fechaExpiracion)) {
+ return {
+ valid: false,
+ subtotalOriginal: subtotal,
+ descuentoCalculado: 0,
+ totalFinal: subtotal,
+ message: `El cupón "${cleanCode}" ha expirado el ${coupon.fechaExpiracion.toLocaleDateString('es-CO')}.`,
+ };
+ }
+
+ if (coupon.limiteUsosTotales && coupon.canjesActuales >= coupon.limiteUsosTotales) {
+ return {
+ valid: false,
+ subtotalOriginal: subtotal,
+ descuentoCalculado: 0,
+ totalFinal: subtotal,
+ message: `El cupón "${cleanCode}" ha alcanzado su límite máximo de canjes disponibles.`,
+ };
+ }
+
+ if (coupon.aplicaPara !== 'TODOS' && coupon.aplicaPara !== serviceType) {
+ const servicioPermitido = coupon.aplicaPara === 'VIAJES' ? 'viajes de pasajeros' : 'encomiendas y fletes';
+ return {
+ valid: false,
+ subtotalOriginal: subtotal,
+ descuentoCalculado: 0,
+ totalFinal: subtotal,
+ message: `Este cupón solo es válido para ${servicioPermitido}.`,
+ };
+ }
+
+ if (coupon.montoMinimoSubtotal > 0 && subtotal < coupon.montoMinimoSubtotal) {
+ return {
+ valid: false,
+ subtotalOriginal: subtotal,
+ descuentoCalculado: 0,
+ totalFinal: subtotal,
+ message: `Este cupón requiere un subtotal mínimo de $${coupon.montoMinimoSubtotal.toLocaleString('es-CO')} COP.`,
+ };
+ }
+
+ // Cálculo del descuento
+ let descuento = 0;
+ if (coupon.tipoDescuento === 'PORCENTAJE') {
+ descuento = Math.round(subtotal * (coupon.valorDescuento / 100));
+ } else {
+ descuento = Math.min(coupon.valorDescuento, subtotal);
+ }
+
+ // Evitar que el total quede menor a cero
+ descuento = Math.min(descuento, subtotal);
+ const totalFinal = Math.max(0, subtotal - descuento);
+
+ const descTexto = coupon.tipoDescuento === 'PORCENTAJE'
+ ? `${coupon.valorDescuento}%`
+ : `$${coupon.valorDescuento.toLocaleString('es-CO')} COP`;
+
+ return {
+ valid: true,
+ coupon: {
+ id: coupon._id.toString(),
+ codigo: coupon.codigo,
+ descripcion: coupon.descripcion,
+ tipoDescuento: coupon.tipoDescuento,
+ valorDescuento: coupon.valorDescuento,
+ aplicaPara: coupon.aplicaPara,
+ },
+ subtotalOriginal: subtotal,
+ descuentoCalculado: descuento,
+ totalFinal,
+ message: `✅ ¡Cupón "${coupon.codigo}" aplicado con éxito! Ahorro: $${descuento.toLocaleString('es-CO')} COP (${descTexto} OFF).`,
+ };
+ }
+
+ /**
+ * Canjea formalmente el cupón al crearse un viaje o encomienda
+ */
+ public async redeemCoupon(code: string): Promise {
+ const cleanCode = (code || '').trim().toUpperCase();
+ if (!cleanCode) return false;
+
+ const res = await Coupon.findOneAndUpdate(
+ { codigo: cleanCode, estaActivo: true },
+ { $inc: { canjesActuales: 1 } },
+ { new: true }
+ );
+ return !!res;
+ }
+
+ /**
+ * Alterna el estado activo/inactivo de un cupón
+ */
+ public async toggleCouponStatus(id: string, estaActivo?: boolean): Promise {
+ const coupon = await Coupon.findById(id);
+ if (!coupon) return null;
+
+ coupon.estaActivo = estaActivo !== undefined ? estaActivo : !coupon.estaActivo;
+ await coupon.save();
+ return coupon;
+ }
+
+ /**
+ * Elimina un cupón del sistema
+ */
+ public async deleteCoupon(id: string): Promise {
+ const res = await Coupon.findByIdAndDelete(id);
+ return !!res;
+ }
+}
+
+export const couponService = CouponService.getInstance();
diff --git a/transporte-cali-buenaventura/backend/src/services/messaging-flete.service.ts b/transporte-cali-buenaventura/backend/src/services/messaging-flete.service.ts
index 99ed52d..f97b5d1 100644
--- a/transporte-cali-buenaventura/backend/src/services/messaging-flete.service.ts
+++ b/transporte-cali-buenaventura/backend/src/services/messaging-flete.service.ts
@@ -3,6 +3,7 @@ import { User } from '../models/User';
import { Trip, ITrip, IPackageInfo } from '../models/Trip';
import { TarifasConfig, ITarifasConfig } from '../models/TarifasConfig';
import { boldPaymentService, BoldPaymentResponse } from './bold.service';
+import { couponService } from './coupon.service';
export interface PackageQuoteRequest {
remitenteId: string;
@@ -23,6 +24,7 @@ export interface PackageQuoteRequest {
descripcion: string;
pesoAproxKg?: number;
incluirSeguro?: boolean;
+ codigoCupon?: string;
}
export interface PackageFareBreakdown {
@@ -33,6 +35,13 @@ export interface PackageFareBreakdown {
factorBulto: number;
tipoBulto: string;
valorSeguro: number;
+ subtotalOriginal?: number;
+ descuentoCupon?: {
+ codigo: string;
+ tipo: string;
+ valor: number;
+ montoDescontado: number;
+ };
tarifaTotal: number;
porcentajeComisionAdmin: number;
comisionAdmin: number;
@@ -152,6 +161,34 @@ export class MessagingFleteService {
const distanciaKm = this.calcularDistanciaKm(request.coordenadasRecogida, request.coordenadasEntrega);
const desglose = await this.cotizarFlete(distanciaKm, request.tipoBulto, request.incluirSeguro ?? true);
+ const subtotalBruto = desglose.tarifaTotal;
+ let descuentoTotal = 0;
+ let cuponDescInfo: any = null;
+
+ if (request.codigoCupon) {
+ try {
+ const valCup = await couponService.validateCoupon(request.codigoCupon, 'ENCOMIENDAS', subtotalBruto);
+ if (valCup.valid && valCup.descuentoCalculado > 0) {
+ descuentoTotal = valCup.descuentoCalculado;
+ cuponDescInfo = {
+ codigo: valCup.coupon!.codigo,
+ tipo: valCup.coupon!.tipoDescuento,
+ valor: valCup.coupon!.valorDescuento,
+ montoDescontado: descuentoTotal,
+ };
+ desglose.subtotalOriginal = subtotalBruto;
+ desglose.descuentoCupon = cuponDescInfo;
+ desglose.tarifaTotal = Math.max(0, subtotalBruto - descuentoTotal);
+ desglose.comisionAdmin = Math.round(desglose.tarifaTotal * (desglose.porcentajeComisionAdmin / 100));
+ desglose.pagoConductor = desglose.tarifaTotal - desglose.comisionAdmin;
+ await couponService.redeemCoupon(valCup.coupon!.codigo);
+ console.log(`🎟️ [Cupón Aplicado en Encomienda] Código: ${valCup.coupon!.codigo}, Descuento: $${descuentoTotal} COP`);
+ }
+ } catch (errCup) {
+ console.warn('⚠️ [Error validando cupón en flete]:', errCup);
+ }
+ }
+
const paqueteInfo: IPackageInfo = {
remitente: {
nombre: request.remitenteNombre,
@@ -196,6 +233,7 @@ export class MessagingFleteService {
estado: 'PENDING_APPROVAL',
metodoPago: 'BOLD',
tarifaTotal: desglose.tarifaTotal,
+ descuentoCupon: cuponDescInfo || undefined,
comisionAdmin: desglose.comisionAdmin,
pagoConductor: desglose.pagoConductor,
horaEstimadaRecogida: new Date(Date.now() + 60 * 60 * 1000), // En 1 hora
@@ -225,7 +263,7 @@ export class MessagingFleteService {
const boldRes = await boldPaymentService.createPayment({
referenceId: trip._id.toString(),
amount: desglose.tarifaTotal,
- description: `Flete Encomienda Cali-Buenaventura #${trip._id.toString().slice(-6)} - Bulto: ${request.tipoBulto}`,
+ description: `Flete Encomienda Cali-Buenaventura #${trip._id.toString().slice(-6)} - Bulto: ${request.tipoBulto}${cuponDescInfo ? ` (Bono ${cuponDescInfo.codigo})` : ''}`,
payerEmail: request.remitenteEmail || remitente.email,
payerPhone: request.remitenteTelefono || remitente.telefono,
paymentType: 'MESSAGING_PACKAGE',
diff --git a/transporte-cali-buenaventura/backend/src/services/pooling.service.ts b/transporte-cali-buenaventura/backend/src/services/pooling.service.ts
index de5414b..a6c53ab 100644
--- a/transporte-cali-buenaventura/backend/src/services/pooling.service.ts
+++ b/transporte-cali-buenaventura/backend/src/services/pooling.service.ts
@@ -4,9 +4,21 @@ import { Trip, ITrip, ITripPassenger, IWazeWaypoint } from '../models/Trip';
import { TarifasConfig } from '../models/TarifasConfig';
import { DriverProfile } from '../models/DriverProfile';
import { whatsAppNotificationService } from './whatsapp-notification.service';
+import { boldPaymentService } from './bold.service';
+import { couponService } from './coupon.service';
+
+export interface PassengerItemInput {
+ nombre: string;
+ telefono: string;
+ documentoIdentidad?: string;
+ direccionRecogida?: string;
+ coordenadas?: [number, number]; // [lng, lat]
+ notas?: string;
+ esTitular?: boolean;
+}
export interface PassengerBookingRequest {
- usuarioId: string;
+ usuarioId?: string;
direccionTexto: string;
coordenadas: [number, number]; // [lng, lat]
sentido: 'CALI_A_BUENAVENTURA' | 'BUENAVENTURA_A_CALI';
@@ -14,7 +26,11 @@ export interface PassengerBookingRequest {
nombre: string;
coordenadas: [number, number]; // [lng, lat]
};
- metodoPago: 'BOLD' | 'NEQUI' | 'EFECTIVO';
+ metodoPago: 'BOLD' | 'NEQUI' | 'EFECTIVO' | 'BRE_B';
+ cuposSolicitados?: number;
+ pasajeros?: PassengerItemInput[];
+ email?: string;
+ codigoCupon?: string;
}
export interface PoolingResult {
@@ -22,9 +38,14 @@ export interface PoolingResult {
message: string;
trip?: ITrip;
tripId?: string;
+ cuposReservados?: number;
+ totalCuposOcupados?: number;
pasajerosAgrupados?: number;
secuenciaParadas?: IWazeWaypoint[];
estado: string;
+ boldPayment?: any;
+ descuentoAplicado?: number;
+ codigoCupon?: string;
motivoRechazo?: string;
}
@@ -41,49 +62,86 @@ export class PoolingService {
}
/**
- * Procesa una nueva solicitud de pasajero y ejecuta el algoritmo de pooling geoespacial con $near
+ * Procesa una nueva solicitud de reserva de cupos (1 a 4 pasajeros) y ejecuta el algoritmo de pooling geoespacial
*/
public async processBookingAndPool(request: PassengerBookingRequest): Promise {
- // 1. Validar que el usuario exista y no tenga penalidades monetarias pendientes
- const user = await User.findById(request.usuarioId);
- if (!user) {
+ // 1. Extraer y normalizar los pasajeros solicitados
+ const rawPasajeros = (request.pasajeros && request.pasajeros.length > 0)
+ ? request.pasajeros
+ : [{
+ nombre: 'Pasajero Principal',
+ telefono: '3000000000',
+ documentoIdentidad: '',
+ direccionRecogida: request.direccionTexto,
+ coordenadas: request.coordenadas,
+ notas: '',
+ esTitular: true,
+ }];
+
+ const numPasajerosNuevos = rawPasajeros.length;
+
+ if (numPasajerosNuevos < 1 || numPasajerosNuevos > 4) {
return {
success: false,
- message: 'El usuario solicitante no existe en el sistema.',
+ message: 'La cantidad de cupos a reservar debe ser entre 1 y 4 pasajeros.',
estado: 'REJECTED',
- motivoRechazo: 'USUARIO_INEXISTENTE',
+ motivoRechazo: 'CUPOS_INVALIDOS',
};
}
- if (user.saldoPenalidad > 0) {
+ // 2. Identificar o registrar al usuario titular
+ let user: any = null;
+ if (request.usuarioId && Types.ObjectId.isValid(request.usuarioId)) {
+ user = await User.findById(request.usuarioId);
+ }
+
+ const primerPasajero = rawPasajeros[0];
+ const cleanPhone = (primerPasajero.telefono || '').replace(/\D/g, '');
+
+ if (!user && cleanPhone) {
+ user = await User.findOne({ telefono: { $regex: cleanPhone.slice(-10) } });
+ }
+
+ if (!user) {
+ user = await User.create({
+ nombre: primerPasajero.nombre?.trim() || 'Pasajero Titular',
+ telefono: cleanPhone || '573000000000',
+ email: request.email ? request.email.trim().toLowerCase() : undefined,
+ rol: 'PASAJERO',
+ estaActivo: true,
+ saldoPenalidad: 0,
+ });
+ }
+
+ if (user && user.saldoPenalidad > 0) {
return {
success: false,
- message: `El usuario tiene una penalidad pendiente de $${user.saldoPenalidad.toLocaleString('es-CO')} COP. Debe saldarla antes de solicitar un viaje.`,
+ message: `El usuario titular tiene una penalidad pendiente de $${user.saldoPenalidad.toLocaleString('es-CO')} COP. Debe saldarla antes de solicitar un viaje.`,
estado: 'REJECTED',
motivoRechazo: 'BLOQUEADO_POR_PENALIDAD',
};
}
- // 2. Obtener la configuración activa de tarifas y radio de pooling
+ // 3. Obtener la configuración activa de tarifas y radio de pooling
const config = (await TarifasConfig.findOne({ estaActiva: true })) || (await TarifasConfig.create({}));
const maxRadiusMeters = config.radioMaxPoolingMetros || 4000;
const tarifaPorPasajero = config.tarifaPasajeroFija || 35000;
const [reqLng, reqLat] = request.coordenadas;
- // 3. Buscar si existe un Trip en estado PENDING_APPROVAL en la misma ruta con cupos disponibles (< 4 pasajeros)
- // Usamos consulta geoespacial $near sobre el origen (para Cali->BV) o sobre el destino (para BV->Cali)
+ // 4. Buscar si existe un Trip en estado PENDING_APPROVAL en la misma ruta con cupos disponibles para TODOS los pasajeros nuevos
let candidateTrip: ITrip | null = null;
if (request.sentido === 'CALI_A_BUENAVENTURA') {
- // Buscar viajes con origen en Cali cerca del nuevo pasajero
candidateTrip = await Trip.findOne({
tipoServicio: 'TRANSPORTE',
estado: 'PENDING_APPROVAL',
'origen.ciudad': 'CALI',
'destino.ciudad': 'BUENAVENTURA',
'destino.direccion': request.puntoFijoBuenaventura.nombre,
- $expr: { $lt: [{ $size: '$pasajeros' }, 4] },
+ $expr: {
+ $lte: [{ $add: [{ $size: '$pasajeros' }, numPasajerosNuevos] }, 4],
+ },
'origen.ubicacion': {
$near: {
$geometry: {
@@ -95,14 +153,15 @@ export class PoolingService {
},
});
} else {
- // Buenaventura -> Cali: El punto fijo de salida coincide, y agrupamos por cercanía de destinos en Cali
candidateTrip = await Trip.findOne({
tipoServicio: 'TRANSPORTE',
estado: 'PENDING_APPROVAL',
'origen.ciudad': 'BUENAVENTURA',
'destino.ciudad': 'CALI',
'origen.direccion': request.puntoFijoBuenaventura.nombre,
- $expr: { $lt: [{ $size: '$pasajeros' }, 4] },
+ $expr: {
+ $lte: [{ $add: [{ $size: '$pasajeros' }, numPasajerosNuevos] }, 4],
+ },
'destino.ubicacion': {
$near: {
$geometry: {
@@ -115,31 +174,45 @@ export class PoolingService {
});
}
- const nuevoPasajeroItem: ITripPassenger = {
- usuarioId: user._id,
- nombre: user.nombre,
- telefono: user.telefono,
- direccionRecogida: request.direccionTexto,
- coordenadas: {
- type: 'Point',
- coordinates: [reqLng, reqLat],
- },
- estadoPago:
- request.metodoPago === 'BOLD'
- ? 'PAGADO_BOLD'
- : request.metodoPago === 'NEQUI'
- ? 'PAGADO_NEQUI'
- : 'PENDIENTE_EFECTIVO',
- valor: tarifaPorPasajero,
- secuenciaOrden: 1,
- recogido: false,
- };
+ const paymentStatus =
+ request.metodoPago === 'BOLD'
+ ? 'PAGADO_BOLD'
+ : request.metodoPago === 'NEQUI'
+ ? 'PAGADO_NEQUI'
+ : request.metodoPago === 'BRE_B'
+ ? 'PAGADO_BRE_B'
+ : 'PENDIENTE_EFECTIVO';
+
+ // Construir la lista de nuevos pasajeros con sus datos individuales
+ const nuevosPasajerosItems: ITripPassenger[] = rawPasajeros.map((p, idx) => {
+ const pLng = (p.coordenadas && p.coordenadas.length === 2 && !isNaN(p.coordenadas[0])) ? p.coordenadas[0] : reqLng;
+ const pLat = (p.coordenadas && p.coordenadas.length === 2 && !isNaN(p.coordenadas[1])) ? p.coordenadas[1] : reqLat;
+ const pDir = p.direccionRecogida?.trim() || request.direccionTexto;
+
+ return {
+ usuarioId: user._id,
+ nombre: p.nombre?.trim() || (idx === 0 ? user.nombre : `Acompañante ${idx + 1}`),
+ telefono: p.telefono?.trim() || user.telefono,
+ documentoIdentidad: p.documentoIdentidad?.trim() || '',
+ direccionRecogida: pDir,
+ coordenadas: {
+ type: 'Point',
+ coordinates: [pLng, pLat],
+ },
+ estadoPago: paymentStatus,
+ valor: tarifaPorPasajero,
+ secuenciaOrden: idx + 1,
+ recogido: false,
+ notas: p.notas?.trim() || '',
+ esTitular: idx === 0 || !!p.esTitular,
+ };
+ });
let targetTrip: ITrip;
if (candidateTrip) {
- // Agrupar al pasajero en el viaje existente
- candidateTrip.pasajeros.push(nuevoPasajeroItem);
+ // Agrupar todos los nuevos pasajeros en el viaje existente
+ candidateTrip.pasajeros.push(...nuevosPasajerosItems);
targetTrip = candidateTrip;
} else {
// Crear un nuevo grupo de viaje en estado PENDING_APPROVAL
@@ -169,14 +242,14 @@ export class PoolingService {
esPuntoFijo: request.sentido === 'CALI_A_BUENAVENTURA',
ciudad: request.sentido === 'CALI_A_BUENAVENTURA' ? 'BUENAVENTURA' : 'CALI',
},
- pasajeros: [nuevoPasajeroItem],
+ pasajeros: nuevosPasajerosItems,
estado: 'PENDING_APPROVAL',
metodoPago: request.metodoPago,
horaEstimadaRecogida: horaRecogida,
});
}
- // 4. Calcular optimización de ruta y secuencia Waze parada por parada (Nearest Neighbor TSP)
+ // 5. Calcular optimización de ruta y secuencia Waze parada por parada (Nearest Neighbor TSP)
const puntosAOptimizar = targetTrip.pasajeros.map((p, idx) => ({
index: idx,
pasajeroId: p.usuarioId,
@@ -193,8 +266,7 @@ export class PoolingService {
targetTrip.pasajeros[punto.index].secuenciaOrden = step + 1;
});
- // Construir deep links oficiales de Waze para cada parada:
- // https://waze.com/ul?ll=lat,lng&navigate=yes
+ // Construir deep links oficiales de Waze para cada parada
const paradasWaze: IWazeWaypoint[] = secuenciaOptimizada.map((punto, step) => {
return {
orden: step + 1,
@@ -209,9 +281,34 @@ export class PoolingService {
targetTrip.secuenciaRutaWaze = paradasWaze;
- // 5. Recalcular métricas financieras
+ // 6. Recalcular métricas financieras y aplicar cupón de descuento si existe
+ const subtotalReserva = numPasajerosNuevos * tarifaPorPasajero;
+ let descuentoTotal = 0;
+ let cuponAplicadoCodigo = '';
+
+ if (request.codigoCupon) {
+ try {
+ const valCup = await couponService.validateCoupon(request.codigoCupon, 'VIAJES', subtotalReserva);
+ if (valCup.valid && valCup.descuentoCalculado > 0) {
+ descuentoTotal = valCup.descuentoCalculado;
+ cuponAplicadoCodigo = valCup.coupon!.codigo;
+ targetTrip.descuentoCupon = {
+ codigo: valCup.coupon!.codigo,
+ tipo: valCup.coupon!.tipoDescuento,
+ valor: valCup.coupon!.valorDescuento,
+ montoDescontado: descuentoTotal,
+ };
+ await couponService.redeemCoupon(valCup.coupon!.codigo);
+ console.log(`🎟️ [Cupón Aplicado en Viaje] Código: ${valCup.coupon!.codigo}, Descuento: $${descuentoTotal} COP`);
+ }
+ } catch (errCup) {
+ console.warn('⚠️ [Error validando cupón en pooling]:', errCup);
+ }
+ }
+
const cantidadPasajeros = targetTrip.pasajeros.length;
- targetTrip.tarifaTotal = cantidadPasajeros * tarifaPorPasajero;
+ const tarifaBruta = cantidadPasajeros * tarifaPorPasajero;
+ targetTrip.tarifaTotal = Math.max(0, tarifaBruta - descuentoTotal);
targetTrip.comisionAdmin = Math.round(targetTrip.tarifaTotal * (config.porcentajeComisionAdmin / 100));
targetTrip.pagoConductor = targetTrip.tarifaTotal - targetTrip.comisionAdmin;
@@ -222,11 +319,36 @@ export class PoolingService {
targetTrip.estado = 'POOL_COMPLETED';
}
+ // 7. Generar cobro con Pasarela Bold si el método seleccionado es BOLD
+ let boldPaymentResult: any = null;
+ if (request.metodoPago === 'BOLD') {
+ const montoReservaCobrar = Math.max(0, subtotalReserva - descuentoTotal);
+ try {
+ const boldRes = await boldPaymentService.createPayment({
+ referenceId: targetTrip._id.toString(),
+ amount: montoReservaCobrar,
+ description: `Reserva ${numPasajerosNuevos} Cupo(s) Cali-Buenaventura #${targetTrip._id.toString().slice(-6)}${cuponAplicadoCodigo ? ` (Bono ${cuponAplicadoCodigo})` : ''}`,
+ payerEmail: request.email || user.email,
+ payerPhone: primerPasajero.telefono || user.telefono,
+ paymentType: 'TRIP_PASSENGER',
+ });
+ if (boldRes.success) {
+ boldPaymentResult = boldRes;
+ targetTrip.boldPayment = {
+ paymentLinkId: boldRes.paymentLinkId,
+ checkoutUrl: boldRes.checkoutUrl,
+ qrImageUrl: boldRes.qrImageUrl,
+ transactionStatus: 'ACTIVE',
+ };
+ }
+ } catch (errBold) {
+ console.warn('⚠️ [Bold Gateway Notice]:', errBold);
+ }
+ }
+
await targetTrip.save();
// Notificación al completar el cupo del vehículo:
- // Se notifica EXCLUSIVAMENTE al Administrador para asignación. La notificación a los 4 pasajeros
- // se dispara formalmente cuando el conductor esté asignado y confirmado.
if (cupoLleno) {
console.log(`🔥 [Pool Completo 4/4] Viaje #${targetTrip._id} en estado POOL_COMPLETED listo para asignación.`);
@@ -260,7 +382,6 @@ export class PoolingService {
}
}
- // Notificar al Administrador para que confirme el conductor y defina la hora de salida
await whatsAppNotificationService.notificarAdminPoolLleno({
tripId: targetTrip._id.toString(),
ruta: `${targetTrip.origen.ciudad} ➔ ${targetTrip.destino.ciudad}`,
@@ -269,16 +390,23 @@ export class PoolingService {
});
}
+ const pluralCupos = numPasajerosNuevos > 1 ? `${numPasajerosNuevos} cupos reservados` : '1 cupo reservado';
+
return {
success: true,
message: cupoLleno
- ? '¡Cupo completo (4/4 pasajeros)! Viaje en estado POOL_COMPLETED listo para programación.'
- : `Pasajero agrupado exitosamente (${cantidadPasajeros}/4 cupos). En espera de aprobación.`,
+ ? `¡Vehículo Completo (4/4 pasajeros)! Se reservaron ${pluralCupos}. Viaje listo para asignación de conductor.`
+ : `¡${pluralCupos.toUpperCase()} CON ÉXITO! Ocupación actual: (${cantidadPasajeros}/4 cupos).`,
trip: targetTrip,
tripId: targetTrip._id.toString(),
+ cuposReservados: numPasajerosNuevos,
+ totalCuposOcupados: cantidadPasajeros,
pasajerosAgrupados: cantidadPasajeros,
secuenciaParadas: paradasWaze,
estado: targetTrip.estado,
+ boldPayment: boldPaymentResult,
+ descuentoAplicado: descuentoTotal,
+ codigoCupon: cuponAplicadoCodigo || undefined,
};
}
diff --git a/transporte-cali-buenaventura/backend/src/services/settlement.service.ts b/transporte-cali-buenaventura/backend/src/services/settlement.service.ts
index ece4522..944c3d7 100644
--- a/transporte-cali-buenaventura/backend/src/services/settlement.service.ts
+++ b/transporte-cali-buenaventura/backend/src/services/settlement.service.ts
@@ -11,12 +11,15 @@ export interface PassengerPaymentSummary {
pasajeroId: string;
nombre: string;
telefono: string;
+ documentoIdentidad?: string;
direccionRecogida: string;
estadoPago: PassengerPaymentStatus;
valor: number;
esRecaudadoPlataforma: boolean;
esRecaudadoEfectivo: boolean;
esPendienteEfectivo: boolean;
+ notas?: string;
+ esTitular?: boolean;
}
export interface TripFinancialSummary {
@@ -145,12 +148,15 @@ export class SettlementService {
pasajeroId: p._id?.toString() || p.usuarioId?.toString(),
nombre: p.nombre,
telefono: p.telefono,
+ documentoIdentidad: p.documentoIdentidad || '',
direccionRecogida: p.direccionRecogida,
estadoPago: p.estadoPago,
valor,
esRecaudadoPlataforma: esPlataforma,
esRecaudadoEfectivo,
esPendienteEfectivo,
+ notas: p.notas || '',
+ esTitular: p.esTitular || false,
};
});