feat: tarifas base configurables, sistema de cupones/bonos de descuento y reserva multi-cupos con datos por pasajero

This commit is contained in:
AI Bot 2026-10-14 09:03:31 -05:00
parent e80309adad
commit 4df6698114
11 changed files with 2334 additions and 157 deletions

View File

@ -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}"
)

View File

@ -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;
}

View File

@ -221,7 +221,7 @@
</div>
<div class="form-group" id="cali-address-group">
<label class="form-label" for="cali-address">Dirección de Recogida en Cali (Domicilio)</label>
<label class="form-label" for="cali-address">Dirección Principal de Recogida en Cali (Domicilio)</label>
<input type="text" class="form-input" id="cali-address" placeholder="Ej: Calle 5 #66-20, Barrio Limonar, Cali" value="">
</div>
@ -235,20 +235,86 @@
</select>
</div>
<div class="grid-2col" style="gap: 1rem; margin-bottom: 0;">
<div class="form-group">
<label class="form-label" for="user-name">Nombre Completo</label>
<input type="text" class="form-input" id="user-name" placeholder="Ej: María Fernanda Gómez" value="">
<!-- SELECTOR DE CANTIDAD DE CUPOS (1 a 4 Pasajeros) -->
<div class="form-group" style="margin-top: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<label class="form-label" style="margin-bottom: 0;">👥 ¿Cuántos cupos deseas reservar?</label>
<span id="badge-cupos-selected" class="badge-seats-counter">1 Cupo Seleccionado</span>
</div>
<div class="form-group">
<label class="form-label" for="user-phone">WhatsApp (+57)</label>
<input type="tel" class="form-input" id="user-phone" placeholder="Ej: 300 123 4567" value="">
<div class="seat-qty-selector" id="seat-qty-selector">
<button type="button" class="btn-qty-seat active" data-seats="1">
<span class="seat-qty-number">1</span>
<span class="seat-qty-desc">Solo Yo</span>
</button>
<button type="button" class="btn-qty-seat" data-seats="2">
<span class="seat-qty-number">2</span>
<span class="seat-qty-desc">2 Cupos</span>
</button>
<button type="button" class="btn-qty-seat" data-seats="3">
<span class="seat-qty-number">3</span>
<span class="seat-qty-desc">3 Cupos</span>
</button>
<button type="button" class="btn-qty-seat" data-seats="4">
<span class="seat-qty-number">4</span>
<span class="seat-qty-desc">Vehículo 4/4</span>
</button>
</div>
</div>
<div class="form-group">
<label class="form-label" for="user-notes">Notas / Equipaje</label>
<input type="text" class="form-input" id="user-notes" placeholder="Maletín mediano de 10kg">
<!-- CONTENEDOR DINÁMICO DE DATOS DE CADA PASAJERO -->
<div style="margin-top: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<label class="form-label" style="margin-bottom: 0;">📋 Información de los Pasajeros</label>
<span style="font-size: 0.75rem; color: #94a3b8;">Requerido para Seguro SOAT y Planilla</span>
</div>
<div id="passengers-cards-container" style="display: flex; flex-direction: column; gap: 1rem;">
<!-- Se genera dinámicamente con JavaScript -->
</div>
</div>
<!-- SELECTOR DE MÉTODO DE PAGO -->
<div class="form-group" style="margin-top: 1.5rem;">
<label class="form-label">💳 Método de Pago</label>
<div class="payment-method-selector" id="payment-method-options">
<label class="payment-method-card active" data-method="BOLD">
<input type="radio" name="pool-payment-radio" value="BOLD" checked>
<div class="payment-card-body">
<span class="payment-title">Bold Smart Checkout</span>
<span class="payment-sub">QR Bre-B, Nequi, PSE, Tarjeta</span>
</div>
</label>
<label class="payment-method-card" data-method="EFECTIVO">
<input type="radio" name="pool-payment-radio" value="EFECTIVO">
<div class="payment-card-body">
<span class="payment-title">Efectivo al Conductor</span>
<span class="payment-sub">Pagas directamente al abordar</span>
</div>
</label>
<label class="payment-method-card" data-method="NEQUI">
<input type="radio" name="pool-payment-radio" value="NEQUI">
<div class="payment-card-body">
<span class="payment-title">Nequi Directo</span>
<span class="payment-sub">Transferencia móvil inmediata</span>
</div>
</label>
</div>
</div>
<!-- BONO O CUPÓN DE DESCUENTO (PASAJEROS) -->
<div class="coupon-input-group" style="margin-top: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;">
<label class="form-label" for="pool-coupon-code" style="margin-bottom: 0; font-size: 0.85rem; display: flex; align-items: center; gap: 0.35rem;">
<span>🎟️</span> ¿Tienes un Bono o Cupón de Descuento?
</label>
<span style="font-size: 0.72rem; color: #38bdf8; font-weight: 600;">(Ej: PACIFICO10, BONOVIAJE5K)</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<input type="text" class="form-input" id="pool-coupon-code" placeholder="Código (ej: PACIFICO10)" style="text-transform: uppercase; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0;">
<button type="button" class="btn-toggle-payment" id="btn-apply-pool-coupon" style="white-space: nowrap; padding: 0.5rem 1.2rem; font-weight: 700;">
Aplicar
</button>
</div>
<div id="pool-coupon-feedback" style="display: none;"></div>
</div>
</div>
@ -263,53 +329,69 @@
</div>
<div class="summary-item">
<span style="color: var(--text-muted);">Capacidad del Vehículo:</span>
<span style="color: #fff; font-weight: 600;">Máximo 4 Pasajeros (1 cupo solicitado)</span>
<span style="color: #fff; font-weight: 600;" id="summary-capacity-text">Máximo 4 Pasajeros (1 cupo solicitado)</span>
</div>
<div class="summary-item">
<span style="color: var(--text-muted);">Cupos a Reservar:</span>
<span style="color: var(--accent-cyan); font-weight: 700;" id="summary-seats-pill">1 Pasajero (1 cupo)</span>
</div>
<div class="summary-item">
<span style="color: var(--text-muted);">Recogida Puerta a Puerta:</span>
<span style="color: var(--accent-cyan); font-weight: 600;">Incluida en Cali</span>
</div>
<div class="summary-item">
<span style="color: var(--text-muted);">Navegación Conductor:</span>
<span style="color: #fff; font-weight: 600;">Waze Deep Link con TSP</span>
<span style="color: var(--text-muted);">Seguro Contractual:</span>
<span style="color: var(--accent-emerald); font-weight: 600;">Póliza y Planilla SOAT</span>
</div>
<div class="bold-gateway-badge">
<div class="summary-item" id="summary-subtotal-row" style="display: none; border-top: 1px dashed var(--border-glass); padding-top: 0.5rem; margin-top: 0.5rem;">
<span style="color: var(--text-muted);">Subtotal:</span>
<span style="color: #fff; font-weight: 600;" id="summary-subtotal-price">$35.000 COP</span>
</div>
<div class="summary-discount-row" id="summary-discount-row" style="display: none;">
<span>🎟️ Descuento Cupón:</span>
<span id="summary-discount-amount">- $0 COP</span>
</div>
<div class="bold-gateway-badge" id="summary-gateway-badge">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2"></rect>
<line x1="2" y1="10" x2="22" y2="10"></line>
</svg>
<span>Pago 100% Prepagado con <strong>Bold Smart Checkout</strong> (QR Bre-B, Nequi, PSE)</span>
<span>Pago seguro con <strong>Bold Smart Checkout</strong> (QR Bre-B, Nequi, PSE)</span>
</div>
</div>
<div>
<div class="summary-total">
<span style="font-weight: 600; color: #fff;">Tarifa por Pasajero:</span>
<span class="total-price">$35.000 COP</span>
<div class="summary-total" style="display: flex; justify-content: space-between; align-items: baseline;">
<span style="font-weight: 600; color: #fff;" id="summary-price-label">Total a Pagar (1 Cupo):</span>
<span class="total-price" id="summary-total-price">$35.000 COP</span>
</div>
<button class="btn-primary" id="btn-book-pool" style="margin-top: 1rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
Confirmar y Pagar Cupo ($35.000 COP)
<span id="btn-book-text">Confirmar y Pagar 1 Cupo ($35.000 COP)</span>
</button>
</div>
</div>
</div>
<!-- Alerta tras completar cupo -->
<div id="pool-dispatch-alert" style="display: none; margin-top: 2rem; background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.4); border-radius: 14px; padding: 1.5rem;">
<!-- Alerta tras completar cupo o confirmación -->
<div id="pool-dispatch-alert" style="display: none; margin-top: 2rem; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.4); border-radius: 14px; padding: 1.5rem;">
<div style="display: flex; gap: 1rem; align-items: flex-start;">
<span style="font-size: 2rem;">🚗💨</span>
<div>
<h4 style="color: #fbbf24; font-size: 1.15rem; margin-bottom: 0.4rem;">¡Vehículo con 4/4 Cupos Completados!</h4>
<p style="color: #cbd5e1; font-size: 0.92rem; margin-bottom: 0.75rem;">
El viaje ha cambiado su estado a <code>POOL_COMPLETED</code>. Se ha enviado una alerta inmediata al Administrador para seleccionar el conductor de acuerdo a la cercanía de su residencia en Cali respecto a la primera recogida.
<span style="font-size: 2.2rem;" id="alert-icon">🚗💨</span>
<div style="flex: 1;">
<h4 style="color: #38bdf8; font-size: 1.15rem; margin-bottom: 0.4rem;" id="alert-title">¡Reserva de Cupos Confirmada!</h4>
<p style="color: #cbd5e1; font-size: 0.92rem; margin-bottom: 0.75rem;" id="alert-message">
Tu reserva ha sido procesada exitosamente en el sistema de pooling inteligente de Pacifictrans Go.
</p>
<div style="font-size: 0.85rem; color: #94a3b8;">
📲 Una vez asignado, se disparará automáticamente el mensaje de WhatsApp a los 4 pasajeros con los datos del conductor y horario exacto de recogida.
<div id="alert-passengers-breakdown" style="margin-bottom: 0.75rem; font-size: 0.88rem; color: #e2e8f0;"></div>
<div id="alert-bold-payment-area" style="display: none; margin-top: 1rem;"></div>
<div style="font-size: 0.85rem; color: #94a3b8;" id="alert-footer-note">
📲 Notificaciones de confirmación e itinerario enviadas por WhatsApp oficial (+57 316 745 5877).
</div>
</div>
</div>
@ -358,6 +440,23 @@
<label class="form-label" for="package-desc">Descripción del Contenido</label>
<input type="text" class="form-input" id="package-desc" placeholder="Repuestos automotrices / Ropa / Medicamentos">
</div>
<!-- BONO O CUPÓN DE DESCUENTO (ENCOMIENDAS) -->
<div class="coupon-input-group" style="margin-top: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;">
<label class="form-label" for="freight-coupon-code" style="margin-bottom: 0; font-size: 0.85rem; display: flex; align-items: center; gap: 0.35rem;">
<span>🎟️</span> ¿Tienes un Bono o Cupón para tu Envío?
</label>
<span style="font-size: 0.72rem; color: #fbbf24; font-weight: 600;">(Ej: ENVIOYA, PACIFICO10)</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<input type="text" class="form-input" id="freight-coupon-code" placeholder="Código de cupón (ej: ENVIOYA)" style="text-transform: uppercase; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0;">
<button type="button" class="btn-toggle-payment" id="btn-apply-freight-coupon" style="white-space: nowrap; padding: 0.5rem 1.2rem; font-weight: 700;">
Aplicar
</button>
</div>
<div id="freight-coupon-feedback" style="display: none;"></div>
</div>
</div>
<div class="summary-box">
@ -380,6 +479,16 @@
<span style="color: var(--accent-emerald); font-weight: 600;" id="freight-insurance-cost">$1.500 COP</span>
</div>
<div class="summary-item" id="freight-subtotal-row" style="display: none; border-top: 1px dashed var(--border-glass); padding-top: 0.5rem; margin-top: 0.5rem;">
<span style="color: var(--text-muted);">Subtotal Flete:</span>
<span style="color: #fff; font-weight: 600;" id="freight-subtotal-price">$197.100 COP</span>
</div>
<div class="summary-discount-row" id="freight-discount-row" style="display: none;">
<span>🎟️ Descuento Cupón:</span>
<span id="freight-discount-amount">- $0 COP</span>
</div>
<div class="bold-gateway-badge">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
@ -395,7 +504,7 @@
<span class="total-price" id="freight-total-price">$197.100 COP</span>
</div>
<button class="btn-primary" style="margin-top: 1rem;">
<button class="btn-primary" id="btn-book-freight" style="margin-top: 1rem;">
Generar Guía y Enlace de Pago Bold
</button>
</div>
@ -600,7 +709,10 @@
💰 2. Control de Pagos, Comisiones & Bre-B <span id="admin-payouts-badge" style="display:none; background:#ec4899; color:#fff; font-size:0.72rem; padding:1px 6px; border-radius:10px; margin-left:4px; font-weight:700;">0</span>
</button>
<button type="button" id="btn-admin-subtab-tarifas" class="admin-pill-btn">
⚙️ 3. Tarifas & Canales de WhatsApp
⚙️ 3. Tarifas Base & Canales
</button>
<button type="button" id="btn-admin-subtab-cupones" class="admin-pill-btn">
🎟️ 4. Bonos & Cupones de Descuento
</button>
</div>
@ -617,11 +729,14 @@
📡 1. Radar Satelital & Despacho
</button>
<button type="button" id="btn-admin-subtab-tarifas" class="admin-pill-btn">
⚙️ 2. Tarifas & Canales de Atención
⚙️ 2. Tarifas Base & Canales
</button>
<button type="button" id="btn-admin-subtab-liquidaciones" class="admin-pill-btn">
💰 3. Control de Pagos, Comisiones & Bre-B <span id="admin-payouts-badge" style="display:none; background:#ec4899; color:#fff; font-size:0.72rem; padding:1px 6px; border-radius:10px; margin-left:4px; font-weight:700;">0</span>
</button>
<button type="button" id="btn-admin-subtab-cupones" class="admin-pill-btn">
🎟️ 4. Bonos & Cupones de Descuento
</button>
</div>
</div>
@ -907,6 +1022,163 @@
</div>
</div>
</div>
<!-- SUB-PANEL 4: SISTEMA DE BONOS Y CUPONES DE DESCUENTO -->
<div id="admin-view-cupones" style="display: none;">
<!-- BANNER INFORMATIVO CUPONES -->
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(14, 165, 233, 0.2)); border: 1px solid rgba(14, 165, 233, 0.35); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.75rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<div>
<div style="display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.35rem;">
<span style="font-size: 1.4rem;">🎟️</span>
<strong style="color: #fff; font-size: 1.15rem;">Sistema Centralizado de Bonos & Cupones de Descuento</strong>
</div>
<p style="color: #cbd5e1; font-size: 0.88rem; margin: 0; max-width: 680px;">
Crea y administra promociones para el corredor Cali - Buenaventura. Define descuentos en <strong>Porcentaje (%)</strong> o <strong>Monto Fijo ($ COP)</strong> aplicables a viajes de pasajeros, encomiendas o ambos.
</p>
</div>
<div>
<button type="button" id="btn-refresh-coupons" class="btn-toggle-payment" style="padding: 0.6rem 1.25rem;">
🔄 Actualizar Cupones
</button>
</div>
</div>
<!-- KPIS DE CUPONES -->
<div class="financial-summary-grid" style="margin-bottom: 1.75rem;">
<div class="fin-metric-card" style="border-top: 3px solid #38bdf8;">
<div class="fin-metric-label">Total Cupones Creados</div>
<div class="fin-metric-val" id="kpi-total-coupons" style="color: #38bdf8;">0</div>
<span style="font-size: 0.72rem; color: #94a3b8;">En base de datos MongoDB</span>
</div>
<div class="fin-metric-card" style="border-top: 3px solid #34d399;">
<div class="fin-metric-label">Cupones Activos</div>
<div class="fin-metric-val" id="kpi-active-coupons" style="color: #34d399;">0</div>
<span style="font-size: 0.72rem; color: #94a3b8;">Listos para canje inmediato</span>
</div>
<div class="fin-metric-card" style="border-top: 3px solid #f59e0b;">
<div class="fin-metric-label">Total de Canjes</div>
<div class="fin-metric-val" id="kpi-redeemed-coupons" style="color: #fbbf24;">0</div>
<span style="font-size: 0.72rem; color: #94a3b8;">Usados en reservas o envíos</span>
</div>
<div class="fin-metric-card" style="border-top: 3px solid #c084fc;">
<div class="fin-metric-label">Modalidades de Bono</div>
<div class="fin-metric-val" style="color: #c084fc; font-size: 1.25rem; font-weight: 700; margin-top: 0.35rem;">% y $ COP</div>
<span style="font-size: 0.72rem; color: #94a3b8;">Porcentaje y Monto Fijo</span>
</div>
</div>
<div class="grid-2col" style="gap: 1.5rem; align-items: flex-start; margin-bottom: 2rem;">
<!-- FORMULARIO DE CREACIÓN DE CUPÓN -->
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--border-glass); border-radius: 12px; padding: 1.5rem;">
<h3 style="color: #38bdf8; font-size: 1.15rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span>➕</span> Crear Nuevo Bono / Cupón de Descuento
</h3>
<p style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1.25rem;">
Completa los datos para habilitar una nueva promoción en la pasarela y en las reservas directas.
</p>
<form id="form-create-coupon">
<div class="form-group">
<label class="form-label" for="coupon-code-input">Código Promocional (Mayúsculas) *</label>
<input type="text" class="form-input" id="coupon-code-input" placeholder="Ej: CALIBUENA15" style="text-transform: uppercase; font-weight: 700; letter-spacing: 0.05em;" required>
</div>
<div class="form-group">
<label class="form-label" for="coupon-desc-input">Descripción / Motivo de la Campaña</label>
<input type="text" class="form-input" id="coupon-desc-input" placeholder="Ej: 15% de descuento especial por lanzamiento">
</div>
<div class="grid-2col" style="gap: 1rem;">
<div class="form-group">
<label class="form-label" for="coupon-type-select">Tipo de Descuento *</label>
<select class="form-select" id="coupon-type-select">
<option value="PORCENTAJE">Porcentaje (% de Descuento)</option>
<option value="MONTO_FIJO">Monto Fijo ($ COP de Descuento)</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="coupon-value-input" id="label-coupon-value">Valor (% Descuento) *</label>
<input type="number" class="form-input" id="coupon-value-input" placeholder="Ej: 15" min="1" step="1" required>
</div>
</div>
<div class="grid-2col" style="gap: 1rem;">
<div class="form-group">
<label class="form-label" for="coupon-service-select">Aplica Para *</label>
<select class="form-select" id="coupon-service-select">
<option value="TODOS">🌟 Todos (Viajes y Encomiendas)</option>
<option value="VIAJES">🚗 Exclusivo Viajes de Pasajeros</option>
<option value="ENCOMIENDAS">📦 Exclusivo Fletes de Encomiendas</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="coupon-min-subtotal">Subtotal Mínimo ($ COP)</label>
<input type="number" class="form-input" id="coupon-min-subtotal" placeholder="0 (Sin mínimo)" min="0" step="1000" value="0">
</div>
</div>
<div class="grid-2col" style="gap: 1rem;">
<div class="form-group">
<label class="form-label" for="coupon-usage-limit">Límite Máximo de Canjes</label>
<input type="number" class="form-input" id="coupon-usage-limit" placeholder="Dejar vacío para ilimitado" min="1" step="1">
</div>
<div class="form-group">
<label class="form-label" for="coupon-exp-date">Fecha de Expiración</label>
<input type="date" class="form-input" id="coupon-exp-date">
</div>
</div>
<div class="form-group">
<label class="same-address-toggle" for="coupon-active-check" style="margin-bottom: 0;">
<input type="checkbox" id="coupon-active-check" checked>
<span>🟢 Activar cupón inmediatamente para su uso público</span>
</label>
</div>
<div id="coupon-form-feedback" style="display: none; margin-bottom: 1rem;"></div>
<button type="submit" class="btn-primary" id="btn-submit-coupon" style="margin-top: 0.5rem;">
➕ Crear y Publicar Cupón de Descuento
</button>
</form>
</div>
<!-- LISTADO DE CUPONES VIGENTES -->
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--border-glass); border-radius: 12px; padding: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<h3 style="color: #fff; font-size: 1.15rem; margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<span>🎟️</span> Catálogo de Cupones en MongoDB
</h3>
<span style="font-size: 0.8rem; color: #94a3b8;" id="coupons-counter-label">Cargando...</span>
</div>
<div class="coupons-table-responsive">
<table class="coupons-table">
<thead>
<tr>
<th>Código</th>
<th>Descuento</th>
<th>Aplica Para</th>
<th>Canjes / Límite</th>
<th>Estado</th>
<th>Acción</th>
</tr>
</thead>
<tbody id="coupons-table-body">
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: #94a3b8;">
Cargando catálogo de cupones...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -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);

View File

@ -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<ICoupon>(
{
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<ICoupon> =
mongoose.models.Coupon || mongoose.model<ICoupon>('Coupon', CouponSchema);

View File

@ -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<ITripPassenger>(
usuarioId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
required: false,
},
nombre: {
type: String,
@ -163,6 +174,10 @@ const TripPassengerSchema = new Schema<ITripPassenger>(
type: String,
required: true,
},
documentoIdentidad: {
type: String,
default: '',
},
direccionRecogida: {
type: String,
required: true,
@ -189,6 +204,14 @@ const TripPassengerSchema = new Schema<ITripPassenger>(
type: Boolean,
default: false,
},
notas: {
type: String,
default: '',
},
esTitular: {
type: Boolean,
default: false,
},
},
{ _id: true }
);
@ -270,6 +293,12 @@ const TripSchema = new Schema<ITrip>(
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,

View File

@ -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<void> {
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<ICoupon[]> {
await this.ensureSeedCoupons();
return Coupon.find().sort({ createdAt: -1 });
}
/**
* Crea un nuevo cupón desde el panel administrativo
*/
public async createCoupon(data: CreateCouponDTO): Promise<ICoupon> {
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<CouponValidationResult> {
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<boolean> {
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<ICoupon | null> {
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<boolean> {
const res = await Coupon.findByIdAndDelete(id);
return !!res;
}
}
export const couponService = CouponService.getInstance();

View File

@ -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',

View File

@ -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<PoolingResult> {
// 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,
};
}

View File

@ -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,
};
});