300 lines
9.2 KiB
TypeScript
300 lines
9.2 KiB
TypeScript
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();
|