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'; 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'; descripcion: string; pesoAproxKg?: number; incluirSeguro?: boolean; } export interface PackageFareBreakdown { distanciaKm: number; precioBase: number; costoPorKm: number; subtotalDistancia: number; factorBulto: number; tipoBulto: string; valorSeguro: number; tarifaTotal: number; porcentajeComisionAdmin: number; comisionAdmin: number; pagoConductor: number; prepagoRequerido: boolean; } export interface PackageBookingResult { success: boolean; message: string; tripId?: string; 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 ): Promise { const config: ITarifasConfig = (await TarifasConfig.findOne({ estaActiva: true })) || (await TarifasConfig.create({})); const precioBase = config.precioBasePaquete || 12000; const costoPorKm = config.precioKmPaquete || 750; 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 const subtotalKm = distanciaKm * costoPorKm; const tarifaBruta = (precioBase + subtotalKm) * factor + seguro; 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, 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. Validar remitente y verificar que no tenga sanciones monetarias const remitente = await User.findById(request.remitenteId); if (!remitente) { throw new Error('El usuario remitente no está registrado en el sistema.'); } if (remitente.saldoPenalidad > 0) { throw new Error( `El usuario presenta una penalidad pendiente de $${remitente.saldoPenalidad.toLocaleString('es-CO')} COP. No puede solicitar encomiendas.` ); } // 2. Calcular distancia y cotizar flete const distanciaKm = this.calcularDistanciaKm(request.coordenadasRecogida, request.coordenadasEntrega); const desglose = await this.cotizarFlete(distanciaKm, request.tipoBulto, request.incluirSeguro ?? true); const paqueteInfo: IPackageInfo = { remitente: { nombre: request.remitenteNombre, telefono: request.remitenteTelefono, direccionRecogida: request.direccionRecogida, }, destinatario: { nombre: request.destinatarioNombre, telefono: request.destinatarioTelefono, direccionEntrega: request.direccionEntrega, }, tipoBulto: request.tipoBulto, descripcion: request.descripcion, pesoAproxKg: request.pesoAproxKg, distanciaKm, seguroIncluido: request.incluirSeguro ?? true, }; // 3. Crear el Trip en estado PENDING_APPROVAL const trip = new Trip({ tipoServicio: 'MENSAJERIA', origen: { direccion: request.direccionRecogida, ubicacion: { type: 'Point', coordinates: request.coordenadasRecogida, }, esPuntoFijo: false, ciudad: request.ciudadOrigen, }, destino: { direccion: request.direccionEntrega, ubicacion: { type: 'Point', coordinates: request.coordenadasEntrega, }, esPuntoFijo: false, ciudad: request.ciudadDestino, }, pasajeros: [], paqueteInfo, estado: 'PENDING_APPROVAL', metodoPago: 'BOLD', tarifaTotal: desglose.tarifaTotal, comisionAdmin: desglose.comisionAdmin, pagoConductor: desglose.pagoConductor, horaEstimadaRecogida: new Date(Date.now() + 60 * 60 * 1000), // En 1 hora secuenciaRutaWaze: [ { orden: 1, direccion: request.direccionRecogida, lat: request.coordenadasRecogida[1], lng: request.coordenadasRecogida[0], wazeDeepLink: `https://waze.com/ul?ll=${request.coordenadasRecogida[1]},${request.coordenadasRecogida[0]}&navigate=yes`, completada: false, }, { orden: 2, direccion: request.direccionEntrega, lat: request.coordenadasEntrega[1], lng: request.coordenadasEntrega[0], wazeDeepLink: `https://waze.com/ul?ll=${request.coordenadasEntrega[1]},${request.coordenadasEntrega[0]}&navigate=yes`, completada: false, }, ], }); await trip.save(); // 4. Generar cobro oficial 100% prepagado a través de Bold Smart Checkout 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}`, payerEmail: request.remitenteEmail || remitente.email, payerPhone: request.remitenteTelefono || remitente.telefono, paymentType: 'MESSAGING_PACKAGE', }); if (boldRes.success) { trip.boldPayment = { paymentLinkId: boldRes.paymentLinkId, checkoutUrl: boldRes.checkoutUrl, qrImageUrl: boldRes.qrImageUrl, transactionStatus: 'ACTIVE', }; await trip.save(); } return { success: true, message: 'Servicio de mensajería cotizado y registrado. Pago en línea 100% generado en Bold.', tripId: trip._id.toString(), desglose, boldPayment: boldRes, }; } } export const messagingFleteService = MessagingFleteService.getInstance();