import { Types } from 'mongoose'; 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'; import { whatsAppNotificationService } from './whatsapp-notification.service'; import { isDatabaseConnected } from '../config/database'; import { tariffsService } from './tariffs.service'; import { inMemoryTripsService } from './in-memory-trips.service'; export interface PackageQuoteRequest { remitenteId?: string; remitenteNombre?: string; remitenteTelefono?: string; remitenteEmail?: string; direccionRecogida?: string; coordenadasRecogida?: [number, number]; // [lng, lat] ciudadOrigen?: 'CALI' | 'BUENAVENTURA'; destinatarioNombre?: string; destinatarioTelefono?: string; direccionEntrega?: string; coordenadasEntrega?: [number, number]; // [lng, lat] ciudadDestino?: 'CALI' | 'BUENAVENTURA'; tipoBulto?: 'SOBRE' | 'MEDIANO' | 'GRANDE'; tipoPaquete?: string; descripcion?: string; descripcionContenido?: string; pesoAproxKg?: number; incluirSeguro?: boolean; codigoCupon?: string; modalidadTiempo?: 'INMEDIATO' | 'PROGRAMADO'; tipoProgramacion?: 'HORA_RECOGIDA' | 'HORA_LLEGADA_DESTINO'; fechaProgramada?: string; horaProgramada?: string; horaSugeridaRecogida?: string; esEnvioUrgente?: boolean; // Aliases from front telefonoRemitente?: string; telefonoDestinatario?: string; } export interface PackageFareBreakdown { distanciaKm: number; precioBase: number; costoPorKm: number; subtotalDistancia: number; factorBulto: number; tipoBulto: string; valorSeguro: number; recargoUrgencia?: number; esEnvioUrgente?: boolean; modalidadTiempo?: 'INMEDIATO' | 'PROGRAMADO'; subtotalOriginal?: number; descuentoCupon?: { codigo: string; tipo: string; valor: number; montoDescontado: number; }; tarifaTotal: number; porcentajeComisionAdmin: number; comisionAdmin: number; pagoConductor: number; prepagoRequerido: boolean; } export interface PackageBookingResult { success: boolean; message: string; tripId?: string; guiaSeguimiento?: string; tarifaCalculada?: number; tarifaFinal?: number; descuentoCupon?: any; boldPaymentLink?: string; trip?: any; desglose: PackageFareBreakdown; boldPayment?: BoldPaymentResponse; } export class MessagingFleteService { private static instance: MessagingFleteService; private constructor() {} public static getInstance(): MessagingFleteService { if (!MessagingFleteService.instance) { MessagingFleteService.instance = new MessagingFleteService(); } return MessagingFleteService.instance; } /** * Calcula la distancia geodésica exacta en kilómetros entre dos coordenadas usando Haversine */ public calcularDistanciaKm(coord1: [number, number], coord2: [number, number]): number { const [lon1, lat1] = coord1; const [lon2, lat2] = coord2; const R = 6371; // Radio de la Tierra en km const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const distancia = R * c; // Redondear a 2 decimales y garantizar mínimo de 5 km para trayectos locales return Math.max(5, Math.round(distancia * 100) / 100); } /** * Cotiza el flete aplicando estrictamente la fórmula de ingeniería definida: * Tarifa Total = (Base + (Distancia Km * Costo Km)) * Factor Tamaño/Peso + Seguro */ public async cotizarFlete( distanciaKm: number, tipoBulto: 'SOBRE' | 'MEDIANO' | 'GRANDE', incluirSeguro: boolean = true, esEnvioUrgente: boolean = false ): Promise { const config = await tariffsService.getConfig(); const precioBase = config.precioBasePaquete || 12000; const costoPorKm = config.precioKmPaquete || 750; const recargoUrgencia = esEnvioUrgente ? (config.recargoEnvioUrgente || 15000) : 0; let factor = 1.0; if (tipoBulto === 'SOBRE') factor = config.factorPaquetes.sobre || 1.0; else if (tipoBulto === 'MEDIANO') factor = config.factorPaquetes.mediano || 1.5; else if (tipoBulto === 'GRANDE') factor = config.factorPaquetes.grande || 2.0; const seguro = incluirSeguro ? config.seguroBasePaquete || 5000 : 0; // Fórmula maestra de ingeniería con recargo opcional por urgencia const subtotalKm = distanciaKm * costoPorKm; const tarifaBruta = (precioBase + subtotalKm) * factor + seguro + recargoUrgencia; const tarifaTotal = Math.round(tarifaBruta / 500) * 500; // Redondeado comercialmente a múltiplos de $500 COP const comisionAdmin = Math.round(tarifaTotal * (config.porcentajeComisionAdmin / 100)); const pagoConductor = tarifaTotal - comisionAdmin; return { distanciaKm, precioBase, costoPorKm, subtotalDistancia: Math.round(subtotalKm), factorBulto: factor, tipoBulto, valorSeguro: seguro, recargoUrgencia, esEnvioUrgente, modalidadTiempo: esEnvioUrgente ? 'INMEDIATO' : 'PROGRAMADO', tarifaTotal, porcentajeComisionAdmin: config.porcentajeComisionAdmin, comisionAdmin, pagoConductor, prepagoRequerido: true, // 100% prepagado obligatorio }; } /** * Crea la orden de mensajería, genera el cobro en Bold (QR Bre-B / PSE / Nequi / Tarjetas) * y registra el servicio en estado PENDING_APPROVAL */ public async registrarEnvioPaquete(request: PackageQuoteRequest): Promise { // 1. Normalizar parámetros de entrada (soporte para aliases y valores por defecto) const remitenteNombre = (request.remitenteNombre || 'Remitente').trim(); const remitenteTelefono = (request.remitenteTelefono || request.telefonoRemitente || '573000000000').replace(/\D/g, ''); const remitenteEmail = request.remitenteEmail || ''; const destinatarioNombre = (request.destinatarioNombre || 'Destinatario Encomienda').trim(); const destinatarioTelefono = (request.destinatarioTelefono || request.telefonoDestinatario || '573000000000').replace(/\D/g, ''); const direccionRecogida = request.direccionRecogida || 'Cali, Valle del Cauca'; const direccionEntrega = request.direccionEntrega || 'Buenaventura, Valle del Cauca'; const coordenadasRecogida = request.coordenadasRecogida || [-76.5320, 3.4215]; const coordenadasEntrega = request.coordenadasEntrega || [-77.0250, 3.8820]; const ciudadOrigen = request.ciudadOrigen || 'CALI'; const ciudadDestino = request.ciudadDestino || 'BUENAVENTURA'; const descripcion = request.descripcion || request.descripcionContenido || 'Encomienda / Paquete'; // Determinar tipo de bulto de forma inteligente let tipoBulto: 'SOBRE' | 'MEDIANO' | 'GRANDE' = request.tipoBulto || 'MEDIANO'; if (!request.tipoBulto && request.tipoPaquete) { const tp = request.tipoPaquete.toLowerCase(); if (tp.includes('sobre') || tp.includes('doc')) tipoBulto = 'SOBRE'; else if (tp.includes('grande') || tp.includes('pesada') || tp.includes('carga')) tipoBulto = 'GRANDE'; else tipoBulto = 'MEDIANO'; } // 2. Validar penalidades si la base de datos está en línea y se proporcionó remitenteId if (isDatabaseConnected() && request.remitenteId && Types.ObjectId.isValid(request.remitenteId)) { try { const remitente = await User.findById(request.remitenteId); if (remitente && remitente.saldoPenalidad > 0) { throw new Error( `El usuario presenta una penalidad pendiente de $${remitente.saldoPenalidad.toLocaleString('es-CO')} COP. No puede solicitar encomiendas.` ); } } catch (err: any) { if (err.message && err.message.includes('penalidad')) throw err; console.warn('⚠️ [Aviso DB remitente]:', err.message); } } // 3. Determinar si es envío urgente o programado const isUrgent = request.esEnvioUrgente ?? (request.modalidadTiempo === 'INMEDIATO' || request.modalidadTiempo === undefined); // 4. Calcular distancia y cotizar flete const distanciaKm = this.calcularDistanciaKm(coordenadasRecogida, coordenadasEntrega); const desglose = await this.cotizarFlete(distanciaKm, tipoBulto, request.incluirSeguro ?? true, isUrgent); 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: remitenteNombre, telefono: remitenteTelefono, direccionRecogida, }, destinatario: { nombre: destinatarioNombre, telefono: destinatarioTelefono, direccionEntrega, }, tipoBulto, descripcion, pesoAproxKg: request.pesoAproxKg, distanciaKm, seguroIncluido: request.incluirSeguro ?? true, }; const tripId = new Types.ObjectId().toString(); // 5. Crear el Trip en estado PENDING_APPROVAL con modalidad de tiempo const tripData: any = { _id: tripId, tipoServicio: 'MENSAJERIA', modalidadTiempo: request.modalidadTiempo || (isUrgent ? 'INMEDIATO' : 'PROGRAMADO'), tipoProgramacion: request.tipoProgramacion, fechaProgramada: request.fechaProgramada, horaProgramada: request.horaProgramada, horaSugeridaRecogida: request.horaSugeridaRecogida, esEnvioUrgente: isUrgent, recargoUrgencia: desglose.recargoUrgencia || 0, origen: { direccion: direccionRecogida, ubicacion: { type: 'Point', coordinates: coordenadasRecogida, }, esPuntoFijo: false, ciudad: ciudadOrigen, }, destino: { direccion: direccionEntrega, ubicacion: { type: 'Point', coordinates: coordenadasEntrega, }, esPuntoFijo: false, ciudad: ciudadDestino, }, pasajeros: [], paqueteInfo, estado: 'PENDING_APPROVAL', metodoPago: 'BOLD', tarifaTotal: desglose.tarifaTotal, precio: desglose.tarifaTotal, descuentoCupon: cuponDescInfo || undefined, comisionAdmin: desglose.comisionAdmin, pagoConductor: desglose.pagoConductor, horaEstimadaRecogida: new Date(Date.now() + 60 * 60 * 1000), // En 1 hora secuenciaRutaWaze: [ { orden: 1, direccion: direccionRecogida, lat: coordenadasRecogida[1], lng: coordenadasRecogida[0], wazeDeepLink: `https://waze.com/ul?ll=${coordenadasRecogida[1]},${coordenadasRecogida[0]}&navigate=yes`, completada: false, }, { orden: 2, direccion: direccionEntrega, lat: coordenadasEntrega[1], lng: coordenadasEntrega[0], wazeDeepLink: `https://waze.com/ul?ll=${coordenadasEntrega[1]},${coordenadasEntrega[0]}&navigate=yes`, completada: false, }, ], createdAt: new Date(), }; // Guardar en caché autónoma en memoria siempre inMemoryTripsService.saveTrip(tripData); // Guardar en MongoDB si está conectado if (isDatabaseConnected()) { try { const mongoTrip = new Trip(tripData); await mongoTrip.save(); } catch (saveErr) { console.warn('⚠️ [Aviso al guardar paquete en MongoDB]:', saveErr); } } // Notificar por WhatsApp a la central / admin const horarioEnvioStr = !isUrgent ? `${request.fechaProgramada || ''} ${request.horaProgramada ? `(${request.tipoProgramacion === 'HORA_LLEGADA_DESTINO' ? 'Entrega requerida: ' : 'Recogida: '}${request.horaProgramada})` : ''}` : `Despacho Inmediato Prioritario (Recargo Urgencia: +$${(desglose.recargoUrgencia || 0).toLocaleString('es-CO')} COP)`; whatsAppNotificationService.notificarAdminNuevoServicio({ tipo: 'MENSAJERIA', tripId, solicitante: remitenteNombre, telefono: remitenteTelefono, origen: direccionRecogida, destino: direccionEntrega, valorTotal: desglose.tarifaTotal, pasajerosCount: 0, modalidad: isUrgent ? '🚨 ENVÍO URGENTE INMEDIATO' : '📅 ENVÍO PROGRAMADO', detalleHorario: horarioEnvioStr, }).catch((err) => console.warn('⚠️ [WhatsApp notify admin package error]:', err)); // 6. Generar cobro oficial 100% prepagado a través de Bold Smart Checkout let boldRes: any = null; try { boldRes = await boldPaymentService.createPayment({ referenceId: tripId, amount: desglose.tarifaTotal, description: `Flete Encomienda Cali-Buenaventura #${tripId.slice(-6)} - Bulto: ${tipoBulto}${isUrgent ? ' (Urgente)' : ''}${cuponDescInfo ? ` (Bono ${cuponDescInfo.codigo})` : ''}`, payerEmail: remitenteEmail || 'cliente@pacifictrans.co', payerPhone: remitenteTelefono, paymentType: 'MESSAGING_PACKAGE', }); if (boldRes && boldRes.success) { tripData.boldPayment = { paymentLinkId: boldRes.paymentLinkId, checkoutUrl: boldRes.checkoutUrl, qrImageUrl: boldRes.qrImageUrl, transactionStatus: 'ACTIVE', }; inMemoryTripsService.saveTrip(tripData); } } catch (errBold) { console.warn('⚠️ [Bold Gateway Notice]:', errBold); } return { success: true, message: 'Servicio de mensajería cotizado y registrado. Pago en línea seguro generado con éxito.', tripId, guiaSeguimiento: `PT-${tripId.slice(-6).toUpperCase()}`, tarifaCalculada: desglose.tarifaTotal, tarifaFinal: desglose.tarifaTotal, descuentoCupon: cuponDescInfo, desglose, boldPayment: boldRes, boldPaymentLink: boldRes?.checkoutUrl, trip: tripData, }; } } export const messagingFleteService = MessagingFleteService.getInstance();