From 4df669811440f475a87b409ab845f71b13e37312 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Wed, 14 Oct 2026 09:03:31 -0500 Subject: [PATCH] feat: tarifas base configurables, sistema de cupones/bonos de descuento y reserva multi-cupos con datos por pasajero --- scripts/deploy_pacifictrans_apk.py | 15 +- .../backend/public/css/style.css | 416 ++++++++ .../backend/public/index.html | 336 +++++- .../backend/public/js/app.js | 993 ++++++++++++++++-- .../backend/src/index.ts | 44 + .../backend/src/models/Coupon.ts | 83 ++ .../backend/src/models/Trip.ts | 33 +- .../backend/src/services/coupon.service.ts | 299 ++++++ .../src/services/messaging-flete.service.ts | 40 +- .../backend/src/services/pooling.service.ts | 226 +++- .../src/services/settlement.service.ts | 6 + 11 files changed, 2334 insertions(+), 157 deletions(-) create mode 100644 transporte-cali-buenaventura/backend/src/models/Coupon.ts create mode 100644 transporte-cali-buenaventura/backend/src/services/coupon.service.ts 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 @@
- +
@@ -235,20 +235,86 @@ -
-
- - + +
+
+ + 1 Cupo Seleccionado
-
- - +
+ + + +
-
- - + +
+
+ + Requerido para Seguro SOAT y Planilla +
+
+ +
+
+ + +
+ +
+ + + +
+
+ + +
+
+ + (Ej: PACIFICO10, BONOVIAJE5K) +
+
+ + +
+
@@ -263,53 +329,69 @@
Capacidad del Vehículo: - Máximo 4 Pasajeros (1 cupo solicitado) + Máximo 4 Pasajeros (1 cupo solicitado) +
+
+ Cupos a Reservar: + 1 Pasajero (1 cupo)
Recogida Puerta a Puerta: Incluida en Cali
- Navegación Conductor: - Waze Deep Link con TSP + Seguro Contractual: + Póliza y Planilla SOAT
-
+ + + + +
- Pago 100% Prepagado con Bold Smart Checkout (QR Bre-B, Nequi, PSE) + Pago seguro con Bold Smart Checkout (QR Bre-B, Nequi, PSE)
-
- Tarifa por Pasajero: - $35.000 COP +
+ Total a Pagar (1 Cupo): + $35.000 COP
- - @@ -617,11 +729,14 @@ 📡 1. Radar Satelital & Despacho +
@@ -907,6 +1022,163 @@ + + + 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 ? '👤' : '👥'} + Pasajero ${i} ${isTitular ? '(Titular de la Reserva)' : `(Acompañante #${i - 1})`} +
+ + Cupo #${i} • ${isTitular ? 'Titular' : 'Acompañante'} + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + ${!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 = ` +
+
+ 💳 Enlace de Pago Bold Smart Checkout Generado +
+

+ Total a pagar: $${(selectedSeats * (currentTariffs.tarifaPasajeroFija || 35000)).toLocaleString('es-CO')} COP por ${selectedSeats} cupo(s). +

+ + 👉 Abrir Pasarela Bold (Pagar con QR Bre-B, Nequi o PSE) + +
+ `; + } 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 = ` + + `; + + 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, }; });