// PNG Receipt System - Based on broad_idea.png design
// Phase 1: Setup with Assets
class PNGReceiptGenerator {
constructor() {
this.pngConfig = {
scale: 2,
width: 540, // 1080/2 for mobile format
height: 960, // 1920/2 for mobile format
backgroundColor: 'white',
useCORS: true,
allowTaint: true,
ignoreElements: (element) => {
return element.classList?.contains('no-png');
}
};
}
hasAnyDiscount(movement) {
// Check discountInfo.amount
if (movement.discountInfo && parseFloat(movement.discountInfo.amount || '0') > 0) {
console.log('🎯 Found discount in discountInfo.amount:', movement.discountInfo.amount);
return true;
}
// Check for descuento field directly
if (movement.descuento && parseFloat(movement.descuento) > 0) {
console.log('🎯 Found discount in movement.descuento:', movement.descuento);
return true;
}
// Check for anticipo aplicado in concepto text
if (movement.concepto && movement.concepto.toLowerCase().includes('anticipo aplicado')) {
console.log('🎯 Found anticipo in concepto text');
return true;
}
// Check for subtotal vs monto difference (indicating discount)
const subtotal = parseFloat(movement.subtotal || movement.monto || '0');
const monto = parseFloat(movement.monto || '0');
if (subtotal > monto && (subtotal - monto) > 0.01) {
console.log('🎯 Found discount from subtotal/monto difference:', subtotal - monto);
return true;
}
console.log('❌ No discount found in movement');
return false;
}
extractDiscountInfo(data) {
// Priority 1: discountInfo structured data
if (data.discountInfo && parseFloat(data.discountInfo.amount || '0') > 0) {
return {
amount: data.discountInfo.amount,
label: data.tipoDescuento ? `Descuento (${data.tipoDescuento})` : 'Descuento',
detail: data.motivoDescuento || null
};
}
// Priority 2: direct descuento field
if (data.descuento && parseFloat(data.descuento) > 0) {
return {
amount: data.descuento,
label: data.tipoDescuento ? `Descuento (${data.tipoDescuento})` : 'Descuento',
detail: data.motivoDescuento || null
};
}
// Priority 3: Calculate from subtotal vs monto
const subtotal = parseFloat(data.subtotal || data.monto || '0');
const monto = parseFloat(data.monto || '0');
if (subtotal > monto && (subtotal - monto) > 0.01) {
const discountAmount = (subtotal - monto).toFixed(2);
return {
amount: discountAmount,
label: 'Anticipo Aplicado',
detail: 'Anticipo manual - no registrado previamente'
};
}
// Priority 4: Extract from concepto text if it mentions anticipo
if (data.concepto && data.concepto.toLowerCase().includes('anticipo aplicado')) {
// Try to extract amount from text
const match = data.concepto.match(/\$(\d+(?:\.\d{2})?)/);
if (match) {
return {
amount: match[1],
label: 'Anticipo Aplicado',
detail: 'Anticipo manual'
};
}
}
// Fallback
return {
amount: '0.00',
label: 'Descuento',
detail: null
};
}
analyzeTicketType(movement) {
console.log('🔍 Analyzing movement for discounts:', {
discountInfo: movement.discountInfo,
descuento: movement.descuento,
subtotal: movement.subtotal,
monto: movement.monto,
concepto: movement.concepto
});
const analysis = {
// Basic ticket analysis
hasAppointment: !!(movement.fechaCita && movement.horaCita),
isAnticipo: movement.tipo === 'Anticipo' ||
movement.concepto?.toLowerCase().includes('anticipo'),
hasAnticipoApplied: movement.discountInfo?.type === 'anticipo',
hasConsent: movement.client?.consentimiento || movement.client?.esOncologico,
isOncology: movement.client?.esOncologico,
// Service type detection
isService: movement.tipo === 'service' || movement.tipo === 'Service',
isCourse: movement.tipo === 'course' || movement.tipo === 'Curso',
// Payment analysis - check multiple fields for discounts/anticipos
hasDiscount: this.hasAnyDiscount(movement),
isWarriorDiscount: movement.discountInfo?.type === 'warrior',
// Client type analysis
hasClientInfo: !!(movement.client && movement.client.nombre),
hasClientPhone: !!(movement.client && movement.client.telefono),
// Medical info analysis
hasMedicalInfo: function() {
return this.isOncology && movement.client && (
movement.client.nombreMedico ||
movement.client.telefonoMedico ||
movement.client.cedulaMedico
);
},
// Anticipo logic
needsAnticipoNotes: function() {
// Show anticipo notes only for pure anticipos (not applied to services)
return this.isAnticipo && !this.hasAnticipoApplied;
},
// Check if service is PMU related
isPMUService: function() {
const pmuKeywords = ['microblading', 'pmu', 'pigmentacion', 'cejas', 'labios', 'vanity brows', 'brows'];
const concepto = movement.concepto?.toLowerCase() || '';
return pmuKeywords.some(keyword => concepto.includes(keyword));
},
// Determine main ticket category
getTicketCategory: function() {
if (this.isAnticipo && !this.hasAnticipoApplied) return 'anticipo-puro';
if (this.isService && this.hasAnticipoApplied) return 'servicio-con-anticipo';
if (this.isService && this.hasConsent && this.isOncology && this.isPMUService()) return 'servicio-oncologico';
if (this.isService && this.hasConsent && this.isPMUService()) return 'servicio-con-consentimiento';
if (this.isService && this.hasAppointment) return 'servicio-con-cita';
if (this.isService) return 'servicio-simple';
if (this.isCourse) return 'curso';
return 'otros';
}
};
console.log('🔍 Analysis result - hasDiscount:', analysis.hasDiscount);
console.log('🔍 Analysis result - discountInfo amount:', movement.discountInfo?.amount);
return analysis;
}
// Enhanced data mapping system
mapMovementData(movement) {
return {
// Basic info
folio: movement.folio || 'N/A',
fecha: this.formatDate(movement.fecha) || new Date().toLocaleDateString('es-MX'),
concepto: movement.concepto || movement.serviceName || 'Servicio',
monto: this.formatAmount(movement.monto || movement.total || '0'),
metodo: movement.metodo || movement.paymentMethod || 'No especificado',
staff: movement.staff || movement.attendedBy || 'Ale Ponce',
// Client info
cliente: this.getClientName(movement),
telefonoCliente: this.getClientPhone(movement),
// Appointment info
fechaCita: movement.fechaCita || movement.appointmentDate,
horaCita: movement.horaCita || movement.appointmentTime,
// Discount/anticipo info
subtotal: this.formatAmount(movement.subtotal || movement.monto),
descuento: this.formatAmount(movement.discountInfo?.amount || '0'),
tipoDescuento: movement.discountInfo?.type || null,
motivoDescuento: movement.discountInfo?.reason || null,
// Client object
client: movement.client || {},
// Original movement for reference
originalMovement: movement
};
}
// Helper functions for data formatting
formatDate(dateStr) {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr; // Invalid date
return date.toLocaleDateString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
} catch {
return dateStr;
}
}
formatAmount(amount) {
if (!amount) return '0.00';
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
return numAmount.toFixed(2);
}
getClientName(movement) {
if (movement.cliente) return movement.cliente;
if (movement.client?.nombre) return movement.client.nombre;
if (movement.clientName) return movement.clientName;
return 'Cliente General';
}
getClientPhone(movement) {
if (movement.telefonoCliente) return movement.telefonoCliente;
if (movement.client?.telefono) return movement.client.telefono;
if (movement.clientPhone) return movement.clientPhone;
return null;
}
// Dynamic template generation based on ticket type
generateReceiptHTML(movement, ticketType) {
const data = this.mapMovementData(movement);
const category = ticketType.getTicketCategory();
return `
${this.generateMobileHeader()}
${this.generateMobileTitle()}
${this.generateMobileBasicInfo(data)}
${this.generateMobileServicesTable(data, ticketType)}
${this.generateMobileTotalsSection(data, ticketType)}
${this.generateCategorySpecificSections(data, ticketType, category)}
${this.generateMobileFooter()}
`;
}
generateMobileHeader() {
return `
`;
}
generateHeader() {
return `
`;
}
generateMobileTitle() {
return `
`;
}
generateTitle() {
return `
COMPROBANTE DE PAGO
`;
}
generateMobileBasicInfo(data) {
return `
Cliente:
${data.cliente}
${data.telefonoCliente ? `
Contacto:
${data.telefonoCliente}
` : ''}
`;
}
generateBasicInfo(data) {
return `
Folio
${data.folio}
Fecha
${data.fecha}
`;
}
generateMobileClientInfo(data, ticketType) {
return ``; // Esta función ya no se usa, la info del cliente se movió a generateMobileBasicInfo
}
generateClientInfo(data, ticketType) {
return `
Cliente:
${data.cliente}
${data.telefonoCliente ? `
Contacto:
${data.telefonoCliente}
` : ''}
`;
}
generateMobileServicesTable(data, ticketType) {
console.log('🔍 Services table - hasDiscount:', ticketType.hasDiscount);
console.log('🔍 Services table - descuento amount:', data.descuento);
console.log('🔍 Services table - discountInfo:', data.originalMovement?.discountInfo);
return `
| Descripción |
Monto |
| ${data.concepto} |
$${data.monto} |
${ticketType.hasDiscount ? this.generateMobileDiscountTableRow(data) : ''}
`;
}
generateServicesTable(data, ticketType) {
return `
${data.concepto}
$${data.monto}
${ticketType.hasDiscount ? this.generateDiscountRow(data) : ''}
`;
}
generateMobileDiscountTableRow(data) {
const discountInfo = this.extractDiscountInfo(data);
console.log('🎨 Generating discount row with extracted info:', discountInfo);
return `
${discountInfo.label}
${discountInfo.detail ? ` ${discountInfo.detail}` : ''}
|
-$${discountInfo.amount} |
`;
}
generateAppointmentInTotals(data) {
const fechaCita = data.fechaCita ? this.formatDate(data.fechaCita) : null;
const citaText = fechaCita && data.horaCita ? `${fechaCita} - ${data.horaCita}` : 'Por confirmar';
return `
🗓️ Tu cita es: ${citaText}
`;
}
generateMobileDiscountRow(data) {
const discountInfo = this.extractDiscountInfo(data);
return `
${discountInfo.label}
${discountInfo.detail ? `
${discountInfo.detail}` : ''}
-$${discountInfo.amount}
`;
}
generateDiscountRow(data) {
const discountInfo = this.extractDiscountInfo(data);
return `
${discountInfo.label}
${discountInfo.detail ? `
${discountInfo.detail}` : ''}
-$${discountInfo.amount}
`;
}
generateMobileTotalsSection(data, ticketType) {
return `
Total Pagado
$${data.monto}
Método de Pago:
${data.metodo}
Te atendió:
${data.staff}
${ticketType.hasAppointment ? this.generateAppointmentInTotals(data) : ''}
`;
}
generateTotalsSection(data, ticketType) {
return `
Total Paid
$${data.monto}
Payment Method:
${data.metodo}
Te atendió:
${data.staff}
`;
}
generateCategorySpecificSections(data, ticketType, category) {
let sections = '';
// Add category-specific content
switch (category) {
case 'anticipo-puro':
sections += this.generateAnticipoSection();
break;
case 'servicio-oncologico':
sections += this.generateOncologySection(data);
break;
case 'servicio-con-consentimiento':
sections += this.generateConsentSection();
break;
case 'curso':
sections += this.generateCourseSection();
break;
}
return sections;
}
generateAppointmentSection(data) {
return `
📅 Información de Cita
Fecha: ${data.fechaCita || 'Por confirmar'}
Hora: ${data.horaCita || 'Por confirmar'}
`;
}
generateAnticipoSection() {
return `
💰 Notas del Anticipo
Al dejar tu anticipo, te agradecemos tu compromiso con nuestro tiempo, de la misma forma en que nosotros respetamos el tuyo.
Las cancelaciones con menos de 48 horas no son reembolsables.
`;
}
generateConsentSection() {
return `
📋 Consentimiento Médico
✅ Cliente ha proporcionado consentimiento médico informado completo
Se han explicado los procedimientos, riesgos y cuidados post-tratamiento
`;
}
generateOncologySection(data) {
const client = data.client;
return `
🎗️ Consentimiento Oncológico
El cliente declara ser paciente oncológico y que la información de su médico es veraz.
${client.nombreMedico ? `
Médico: ${client.nombreMedico}
` : ''}
${client.cedulaMedico ? `
Cédula: ${client.cedulaMedico}
` : ''}
Al consentir el servicio, declara que la información médica proporcionada es veraz.
`;
}
generateCourseSection() {
return `
🎓 Información del Curso
✅ Inscripción confirmada al programa educativo
Términos y condiciones del curso aceptados
Para consultas sobre fechas y material, contactar directamente
`;
}
generateMobileFooter() {
return `
`;
}
generateFooter() {
return `
`;
}
async downloadReceiptPNG(movementId, movementData = null) {
try {
console.log('🎨 PNG Receipt Generator - Starting generation for:', movementId);
console.log('📋 Movement data provided:', movementData);
// Check dependencies
if (typeof html2canvas === 'undefined') {
throw new Error('html2canvas library not loaded');
}
if (typeof saveAs === 'undefined') {
throw new Error('FileSaver library not loaded');
}
// Get movement data - use provided data or fetch test data
const movement = movementData || await this.getMovementById(movementId);
console.log('📄 Final movement data:', movement);
// Analyze ticket type
const ticketType = this.analyzeTicketType(movement);
console.log('🔍 Ticket type analysis:', ticketType);
// Generate HTML
const receiptHTML = this.generateReceiptHTML(movement, ticketType);
console.log('🏗️ Generated HTML length:', receiptHTML.length);
// Create temporary DOM element with mobile dimensions
const tempDiv = document.createElement('div');
tempDiv.innerHTML = receiptHTML;
tempDiv.style.position = 'absolute';
tempDiv.style.left = '-9999px';
tempDiv.style.top = '-9999px';
tempDiv.style.width = '540px';
tempDiv.style.height = '960px';
tempDiv.style.backgroundColor = 'white';
tempDiv.style.fontFamily = "'Montserrat', sans-serif";
tempDiv.style.overflow = 'hidden';
// Apply receipt styles and ensure CSS is loaded
tempDiv.className = 'receipt-container';
// Create and inject CSS link to ensure styles are applied
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = 'receipt.css';
if (!document.querySelector('link[href="receipt.css"]')) {
document.head.appendChild(cssLink);
}
// Ensure child element has proper mobile dimensions too
const tempElement = tempDiv.firstElementChild;
if (tempElement) {
tempElement.style.width = '540px';
tempElement.style.height = '960px';
tempElement.style.backgroundColor = 'white';
tempElement.style.fontFamily = "'Montserrat', sans-serif";
tempElement.style.display = 'block';
tempElement.style.overflow = 'hidden';
}
document.body.appendChild(tempDiv);
console.log('📐 Temp div created and added to DOM with explicit dimensions');
// Wait for DOM to settle, CSS to load, and images to load
console.log('⏳ Waiting for DOM to settle, CSS to load, and images to load...');
await new Promise(resolve => setTimeout(resolve, 1000)); // Longer wait for CSS
await this.waitForImages(tempDiv);
console.log('✅ Images loaded and DOM settled');
// Log element dimensions before canvas creation
const rect = tempDiv.getBoundingClientRect();
const childRect = tempElement?.getBoundingClientRect();
console.log('📏 Temp div dimensions:', rect?.width, 'x', rect?.height);
console.log('📏 Child element dimensions:', childRect?.width, 'x', childRect?.height);
// Force a reflow to ensure all dimensions are calculated
tempDiv.offsetHeight;
if (tempElement) tempElement.offsetHeight;
// Use the element with the most reliable dimensions
const elementToCapture = tempElement || tempDiv;
const finalRect = elementToCapture.getBoundingClientRect();
console.log('📏 Final element to capture dimensions:', finalRect?.width, 'x', finalRect?.height);
// Convert to PNG with mobile config
console.log('🖼️ Converting to PNG with html2canvas...');
const adjustedConfig = {
scale: 2,
width: 540,
height: 960,
backgroundColor: 'white',
useCORS: true,
allowTaint: true,
removeContainer: false
};
console.log('🔧 Using canvas config:', adjustedConfig);
const canvas = await html2canvas(elementToCapture, adjustedConfig);
console.log('✅ Canvas created, dimensions:', canvas.width, 'x', canvas.height);
// Clean up
document.body.removeChild(tempDiv);
console.log('🧹 Temporary DOM element removed');
// Download
console.log('💾 Creating blob and downloading...');
canvas.toBlob(blob => {
if (blob) {
saveAs(blob, `Ticket_${movement.folio}.png`);
console.log('🎉 PNG receipt downloaded successfully:', `Ticket_${movement.folio}.png`);
} else {
throw new Error('Failed to create blob from canvas');
}
});
} catch (error) {
console.error('❌ Error generating receipt:', error);
alert('Error al generar el recibo PNG: ' + error.message);
}
}
async waitForImages(container) {
const images = container.querySelectorAll('img');
const imagePromises = Array.from(images).map(img => {
return new Promise((resolve) => {
if (img.complete) {
resolve();
} else {
img.onload = resolve;
img.onerror = resolve; // Continue even if image fails to load
}
});
});
await Promise.all(imagePromises);
}
async getMovementById(movementId) {
// Placeholder - will integrate with actual movement retrieval
// For now, return different test scenarios based on movementId
const testScenarios = {
'TEST-001': {
// Servicio simple
id: movementId,
folio: 'AP-001',
fecha: '2025-09-14',
cliente: 'Ana García',
telefonoCliente: '+52 614 123 4567',
concepto: 'Pestañas Volumen Ruso',
monto: '1200.00',
metodo: 'Efectivo',
staff: 'Ale Ponce',
tipo: 'service',
client: { nombre: 'Ana García', telefono: '+52 614 123 4567' }
},
'TEST-002': {
// Servicio con cita
id: movementId,
folio: 'AP-002',
fecha: '2025-09-14',
cliente: 'María López',
concepto: 'Microblading Completo',
monto: '2500.00',
metodo: 'Tarjeta',
staff: 'Ale Ponce',
fechaCita: '2025-09-20',
horaCita: '10:00',
tipo: 'service',
client: { nombre: 'María López' }
},
'TEST-003': {
// Anticipo puro
id: movementId,
folio: 'AP-003',
fecha: '2025-09-14',
cliente: 'Carmen Ruiz',
concepto: 'Anticipo - Pestañas',
monto: '500.00',
metodo: 'Transferencia',
staff: 'Ale Ponce',
fechaCita: '2025-09-25',
horaCita: '14:00',
tipo: 'Anticipo',
client: { nombre: 'Carmen Ruiz' }
},
'TEST-004': {
// Servicio con anticipo aplicado
id: movementId,
folio: 'AP-004',
fecha: '2025-09-14',
cliente: 'Laura Pérez',
concepto: 'Pestañas Mega Volumen',
monto: '800.00',
subtotal: '1300.00',
metodo: 'Efectivo',
staff: 'Ale Ponce',
tipo: 'service',
discountInfo: {
type: 'anticipo',
amount: '500.00',
reason: 'Anticipo aplicado'
},
client: { nombre: 'Laura Pérez' }
},
'TEST-005': {
// Paciente oncológico
id: movementId,
folio: 'AP-005',
fecha: '2025-09-14',
cliente: 'Elena Martínez',
concepto: 'Microblading Oncológico',
monto: '0.00',
metodo: 'Cortesía',
staff: 'Ale Ponce',
fechaCita: '2025-09-18',
horaCita: '11:00',
tipo: 'service',
discountInfo: {
type: 'warrior',
amount: '2500.00',
reason: 'Programa Vanity Warriors'
},
client: {
nombre: 'Elena Martínez',
telefono: '+52 614 987 6543',
consentimiento: true,
esOncologico: true,
nombreMedico: 'Dr. Carlos Hernández',
telefonoMedico: '+52 614 555 0123',
cedulaMedico: 'CED-789456'
}
},
'TEST-006': {
// Curso
id: movementId,
folio: 'AP-006',
fecha: '2025-09-14',
cliente: 'Sofia Vargas',
concepto: 'Curso Básico de Pestañas',
monto: '3500.00',
metodo: 'Transferencia',
staff: 'Ale Ponce',
tipo: 'course',
client: {
nombre: 'Sofia Vargas',
telefono: '+52 614 456 7890',
consentimiento: true
}
}
};
// Return test scenario or default
return testScenarios[movementId] || testScenarios['TEST-001'];
}
}
// Global instance
window.pngReceiptGenerator = new PNGReceiptGenerator();
// Global function for easy access
window.downloadReceiptPNG = function(movementId) {
window.pngReceiptGenerator.downloadReceiptPNG(movementId);
};
console.log('PNG Receipt Generator initialized successfully (based on broad_idea.png design)');