/** * PMPro Banda Dynamic Pricing - JavaScript CON PRORRATEO VISUAL v2.8.0 * * Nombre del archivo: nextcloud-banda-dynamic-pricing.js * * RESPONSABILIDAD: Cálculos dinámicos de precio, prorrateo y actualización de UI * CORREGIDO: Sistema completo de prorrateo para upgrades * MEJORADO: Sanitización defensiva, control de doble init, bloqueo de downgrades * * @version 2.8.0 */ /* global jQuery, window, console */ (function($) { 'use strict'; // ==== // CONFIGURACIÓN Y CONSTANTES // ==== const BANDA_CONFIG = { version: '2.8.0', debug: false, selectors: { storageField: '#storage_space', usersField: '#num_users', frequencyField: '#payment_frequency', priceDisplay: '#total_price_display', priceLabel: '.pmpro_checkout-field-price-display label', submitButtons: 'input[name="submit"], button[type="submit"], #pmpro_btn_submit' }, classes: { proratedPrice: 'prorated-price', proratedLabel: 'prorated-label', proratedNotice: 'pmpro-prorated-notice', downgradeWarning: 'pmpro-downgrade-warning', messageNotice: 'pmpro-proration-message' }, debounceDelay: 100, animationDuration: 50, initTimeout: 1000 }; // Variables globales let pricingData = null; let currentProrationData = null; let debounceTimer = null; let isCalculating = false; let isInitialized = false; let originalTextsCache = {}; let initialUserValues = {}; // ==== // SISTEMA DE LOGGING // ==== function log(level, message, data = null) { if (!BANDA_CONFIG.debug && level === 'debug') return; const logMessage = `[PMPro Banda ${BANDA_CONFIG.version}] ${message}`; if (level === 'error') { console.error(logMessage, data || ''); } else if (level === 'warn') { console.warn(logMessage, data || ''); } else if (level === 'info') { console.info(logMessage, data || ''); } else { console.log(logMessage, data || ''); } } // ==== // INICIALIZACIÓN Y VALIDACIÓN // ==== function waitForPricingData(callback, timeoutMs = BANDA_CONFIG.initTimeout) { if (typeof window.nextcloud_banda_pricing !== 'undefined') { return callback(window.nextcloud_banda_pricing); } let waited = 0; const interval = setInterval(function() { waited += 100; if (typeof window.nextcloud_banda_pricing !== 'undefined') { clearInterval(interval); callback(window.nextcloud_banda_pricing); } else if (waited >= timeoutMs) { clearInterval(interval); callback(null); } }, 100); } function initializePricingSystem() { if (isInitialized) { log('debug', 'System already initialized, skipping'); return true; } log('info', 'Initializing pricing system...'); // Esperar a que los datos estén disponibles waitForPricingData(function(config) { if (!config) { log('error', 'Pricing data not available'); return false; } pricingData = config; BANDA_CONFIG.debug = pricingData.debug || false; log('debug', 'Pricing data loaded', { level_id: pricingData.level_id, base_price: pricingData.base_price, has_active_membership: pricingData.hasActiveMembership, current_subscription_data: pricingData.current_subscription_data }); if (!pricingData.level_id || parseInt(pricingData.level_id, 10) !== 2) { log('debug', 'Not on Banda level, skipping initialization', { level_id: pricingData.level_id }); return false; } waitForRequiredElements(function(elementsFound) { if (!elementsFound) { log('warn', 'Required elements not found after timeout'); return; } initializeFieldValues(); storeInitialUserValues(); bindEvents(); setInitialValues(); isInitialized = true; log('info', 'PMPro Banda Dynamic Pricing initialized successfully', { version: BANDA_CONFIG.version, debug: BANDA_CONFIG.debug, hasActiveMembership: pricingData.hasActiveMembership, currentSubscriptionData: !!pricingData.current_subscription_data }); // Disparar evento de inicialización completada $(document).trigger('nextcloud_banda_initialized'); }); }); return true; } function waitForRequiredElements(callback, timeoutMs = 3000) { const requiredElements = [ BANDA_CONFIG.selectors.storageField, BANDA_CONFIG.selectors.usersField, BANDA_CONFIG.selectors.frequencyField, BANDA_CONFIG.selectors.priceDisplay ]; let waited = 0; const interval = setInterval(function() { const foundElements = requiredElements.filter(selector => $(selector).length > 0); if (foundElements.length >= 3) { clearInterval(interval); callback(true); } else { waited += 100; if (waited >= timeoutMs) { clearInterval(interval); log('warn', 'Timeout waiting for elements', { found: foundElements.length, required: requiredElements.length }); callback(false); } } }, 100); } // ==== // INICIALIZACIÓN DE CAMPOS // ==== function initializeFieldValues() { let defaultStorage = '1tb'; let defaultUsers = 2; let defaultFrequency = 'monthly'; if (pricingData.hasActiveMembership && pricingData.has_previous_config && pricingData.current_subscription_data) { defaultStorage = pricingData.current_subscription_data.storage_space || '1tb'; defaultUsers = pricingData.current_subscription_data.num_users || 2; defaultFrequency = pricingData.current_subscription_data.payment_frequency || 'monthly'; log('debug', 'Using previous config values for active membership', { storage: defaultStorage, users: defaultUsers, frequency: defaultFrequency }); } else { log('debug', 'Using default values (no active membership or no previous config)', { storage: defaultStorage, users: defaultUsers, frequency: defaultFrequency, hasActiveMembership: pricingData.hasActiveMembership, hasPreviousConfig: pricingData.has_previous_config }); } const $storageField = $(BANDA_CONFIG.selectors.storageField); const $usersField = $(BANDA_CONFIG.selectors.usersField); const $frequencyField = $(BANDA_CONFIG.selectors.frequencyField); if ($storageField.length && (!$storageField.val() || $storageField.val() === '')) { $storageField.val(defaultStorage); } if ($usersField.length && (!$usersField.val() || $usersField.val() === '')) { $usersField.val(defaultUsers); } if ($frequencyField.length && (!$frequencyField.val() || $frequencyField.val() === '')) { $frequencyField.val(defaultFrequency); } log('debug', 'Field values initialized', { storage: $storageField.val(), users: $usersField.val(), frequency: $frequencyField.val() }); } function storeInitialUserValues() { initialUserValues = { storage: $(BANDA_CONFIG.selectors.storageField).val() || '1tb', users: parseInt($(BANDA_CONFIG.selectors.usersField).val(), 10) || 2, frequency: $(BANDA_CONFIG.selectors.frequencyField).val() || 'monthly', hasPreviousConfig: !!(pricingData && pricingData.has_previous_config && pricingData.hasActiveMembership), hasActiveMembership: !!(pricingData && pricingData.hasActiveMembership), subscriptionData: pricingData.current_subscription_data || null }; log('debug', 'Initial user values stored', initialUserValues); } // ==== // CÁLCULOS DE PRECIO // ==== function calculatePrice(storageSpace, numUsers, paymentFrequency) { if (!pricingData) { log('error', 'Pricing data not available for calculation'); return pricingData?.base_price || 70.0; } try { const sanitizedStorage = String(storageSpace || '1tb').toLowerCase(); const sanitizedUsers = parseInt(numUsers, 10) || 2; const sanitizedFrequency = String(paymentFrequency || 'monthly').toLowerCase(); if (!sanitizedStorage || !sanitizedUsers || !sanitizedFrequency) { log('warn', 'Invalid parameters for price calculation', { storageSpace: sanitizedStorage, numUsers: sanitizedUsers, paymentFrequency: sanitizedFrequency }); return pricingData.base_price; } const storageTb = parseInt( sanitizedStorage.replace('tb', '').replace('gb', ''), 10 ) || 1; const users = sanitizedUsers; const additionalTb = Math.max(0, storageTb - pricingData.base_storage_included); const storagePrice = pricingData.base_price + (pricingData.price_per_tb * additionalTb); const additionalUsers = Math.max(0, users - pricingData.base_users_included); const userPrice = pricingData.price_per_user * additionalUsers; const combinedPrice = storagePrice + userPrice; const frequencyMultiplier = pricingData.frequency_multipliers[sanitizedFrequency]; if (typeof frequencyMultiplier === 'undefined') { log('warn', 'Unknown frequency, using 1.0 multiplier', { frequency: sanitizedFrequency }); } const multiplier = frequencyMultiplier || 1.0; const totalPrice = Math.ceil(combinedPrice * multiplier); log('debug', 'Price calculated', { storageSpace: sanitizedStorage, storageTb, additionalTb, numUsers: users, additionalUsers, paymentFrequency: sanitizedFrequency, storagePrice, userPrice, combinedPrice, frequencyMultiplier: multiplier, totalPrice }); return totalPrice; } catch (error) { log('error', 'Error calculating price', error); return pricingData.base_price; } } // ==== // SISTEMA DE PRORRATEO // ==== function isUpgrade(newStorage, newUsers, newFrequency) { if (!pricingData.current_subscription_data) { log('debug', 'No current subscription data for upgrade check'); return false; } const current = pricingData.current_subscription_data; log('debug', 'Upgrade check input data', { current: current, new: { storage: newStorage, users: newUsers, frequency: newFrequency } }); // Parsear storage correctamente const parseStorageValue = (value) => { if (typeof value !== 'string') return 1; const sanitized = String(value).toLowerCase(); const match = sanitized.match(/^(\d+(?:\.\d+)?)\s*(tb|gb)$/i); if (match) { const num = parseFloat(match[1]); const unit = match[2].toLowerCase(); return unit === 'gb' ? num / 1024 : num; } return parseFloat(value) || 1; }; const currentStorageValue = parseStorageValue(current.storage_space || '1tb'); const newStorageValue = parseStorageValue(newStorage); log('debug', 'Storage values parsed', { currentStorageValue: currentStorageValue, newStorageValue: newStorageValue }); const currentUsers = parseInt(current.num_users || 2, 10); const newUsersParsed = parseInt(newUsers, 10); log('debug', 'Users values parsed', { currentUsers: currentUsers, newUsersParsed: newUsersParsed }); const frequencyOrder = { 'monthly': 1, 'semiannual': 2, 'annual': 3, 'biennial': 4, 'triennial': 5, 'quadrennial': 6, 'quinquennial': 7 }; const currentFreqOrder = frequencyOrder[current.payment_frequency] || 1; const newFreqOrder = frequencyOrder[newFrequency] || 1; log('debug', 'Frequency orders', { current: current.payment_frequency, currentOrder: currentFreqOrder, new: newFrequency, newOrder: newFreqOrder }); const storageUpgrade = newStorageValue > currentStorageValue; const usersUpgrade = newUsersParsed > currentUsers; const frequencyUpgrade = newFreqOrder > currentFreqOrder; const isUpgradeResult = storageUpgrade || usersUpgrade || frequencyUpgrade; log('debug', 'Upgrade analysis result', { storageUpgrade: storageUpgrade, usersUpgrade: usersUpgrade, frequencyUpgrade: frequencyUpgrade, isUpgrade: isUpgradeResult, currentStorage: currentStorageValue, newStorage: newStorageValue, currentUser: currentUsers, newUser: newUsersParsed }); return isUpgradeResult; } // Función auxiliar para sanitizar datos de prorrateo - CORREGIDA function sanitizeProrationData(data, newTotalPrice) { const safeNum = (n, fallback = 0) => { const v = Number(n); return Number.isFinite(v) ? v : fallback; }; const safeInt = (n, fallback = 0) => { const v = parseInt(n, 10); return Number.isFinite(v) ? v : fallback; }; if (!data || typeof data !== 'object') { return { raw: null, isUpgrade: false, shouldDisplay: false, message: 'Dados de prorrateo indisponíveis.', priceDiff: 0, amountDueNow: safeNum(newTotalPrice, 0), creditAmount: 0, newTotalPrice: safeNum(newTotalPrice, 0), currentAmount: 0, currentProratedAmount: 0, newProratedAmount: 0, fractionRemaining: 0, daysRemaining: 0, totalDays: 1, nextPaymentDate: '', currentFrequency: 'monthly', newFrequency: 'monthly', currentCycleLabel: '', newCycleLabel: '' }; } const amountDueNow = safeNum( data.amount_due_now ?? data.amountDueNow ?? data.prorated_amount ?? data.proratedAmount ?? NaN, NaN ); const creditAmount = safeNum( data.credit_amount ?? data.creditAmount ?? data.current_prorated_amount ?? data.currentProratedAmount ?? 0 ); const sanitized = { raw: data, isUpgrade: Boolean( data.is_upgrade ?? data.isUpgrade ?? (safeNum(data.price_diff ?? data.priceDiff, 0) > 0) ?? (Number.isFinite(amountDueNow) && amountDueNow > 0) ), shouldDisplay: Boolean(data.success ?? data.shouldDisplay ?? false), message: String(data.message ?? data.Message ?? ''), priceDiff: safeNum(data.price_diff ?? data.priceDiff ?? 0), amountDueNow: Number.isFinite(amountDueNow) ? amountDueNow : safeNum(newTotalPrice, 0), creditAmount: creditAmount, newTotalPrice: safeNum(data.new_total_price ?? data.newTotalPrice ?? newTotalPrice, 0), currentAmount: safeNum(data.current_price ?? data.currentAmount ?? 0), currentProratedAmount: safeNum(data.current_prorated_amount ?? data.currentProratedAmount ?? creditAmount), newProratedAmount: safeNum(data.new_prorated_amount ?? data.newProratedAmount ?? data.prorated_new_amount ?? 0), fractionRemaining: safeNum(data.fraction_remaining ?? data.fractionRemaining ?? 0), daysRemaining: safeInt(data.days_remaining ?? data.daysRemaining ?? 0), totalDays: Math.max(1, safeInt(data.total_days ?? data.totalDays ?? 1)), nextPaymentDate: String(data.next_payment_date ?? data.nextPaymentDate ?? ''), currentFrequency: String(data.current_frequency ?? data.currentFrequency ?? 'monthly'), newFrequency: String(data.new_frequency ?? data.newFrequency ?? 'monthly'), currentCycleLabel: String(data.current_cycle_label ?? data.currentCycleLabel ?? ''), newCycleLabel: String(data.new_cycle_label ?? data.newCycleLabel ?? '') }; sanitized.shouldDisplay = sanitized.shouldDisplay || sanitized.amountDueNow > 0 || (sanitized.message && sanitized.message !== 'Success'); return sanitized; } // Modificar calculateProration para manejo de errores del spinner function calculateProration(newTotalPrice, callback) { if (!pricingData.hasActiveMembership || !pricingData.current_subscription_data) { log('debug', 'No active membership for proration', { hasActiveMembership: pricingData?.hasActiveMembership, hasSubscriptionData: !!pricingData?.current_subscription_data }); hideSpinner(); // Asegurar que el spinner se oculte callback(null); return; } const storageSpace = $(BANDA_CONFIG.selectors.storageField).val(); const numUsers = $(BANDA_CONFIG.selectors.usersField).val(); const paymentFrequency = $(BANDA_CONFIG.selectors.frequencyField).val(); // Verificar que los valores sean válidos if (!storageSpace || !numUsers || !paymentFrequency) { log('warn', 'Missing field values for proration calculation', { storageSpace, numUsers, paymentFrequency }); hideSpinner(); // Asegurar que el spinner se oculte callback(null); return; } log('debug', 'Starting AJAX proration calculation', { action: 'nextcloud_banda_calculate_proration', level_id: pricingData.level_id, storage: storageSpace, users: numUsers, frequency: paymentFrequency }); $.ajax({ url: pricingData.ajax_url, type: 'POST', dataType: 'json', timeout: 10000, data: { action: 'nextcloud_banda_calculate_proration', security: pricingData.nonce, level_id: pricingData.level_id, storage: storageSpace, users: numUsers, frequency: paymentFrequency }, success: function(response) { try { log('debug', 'Proration AJAX response received', { success: response?.success, hasData: !!response?.data, rawData: response }); if (!response) { log('warn', 'Empty AJAX response'); hideSpinner(); // Ocultar spinner en caso de error callback(null); return; } // Manejar respuesta de éxito o error if (response.success) { const data = response.data || response; if (!data || typeof data !== 'object') { log('warn', 'Invalid data structure in response', { response }); hideSpinner(); // Ocultar spinner en caso de error callback(null); return; } // Sanitizar y procesar datos const prorationResult = sanitizeProrationData(data, newTotalPrice); log('debug', 'Processed proration result', prorationResult); callback(prorationResult); } else { // Manejar errores del servidor const errorMessage = response.data?.message || 'Erro no cálculo de prorrateo'; log('warn', 'Server error in proration calculation', { message: errorMessage, data: response.data }); callback({ isUpgrade: false, shouldDisplay: true, message: errorMessage, priceDiff: 0, proratedAmount: 0, newTotalPrice: newTotalPrice, currentAmount: 0, currentProratedAmount: 0, newProratedAmount: 0, fractionRemaining: 0, daysRemaining: 0, totalDays: 1, nextPaymentDate: '', currentFrequency: 'monthly', newFrequency: 'monthly', raw: response.data }); } } catch (error) { log('error', 'Error processing proration response', { error: error.message, stack: error.stack }); hideSpinner(); // Ocultar spinner en caso de error callback(null); } }, error: function(xhr, status, error) { log('error', 'AJAX error calculating proration', { status: status, error: error, responseText: xhr.responseText, statusCode: xhr.status }); // Manejar error de red o timeout let errorMessage = 'Não foi possível calcular o prorrateo. '; if (status === 'timeout') { errorMessage += 'Tempo limite excedido.'; } else if (xhr.status === 401) { errorMessage += 'Usuário não autenticado.'; } else if (xhr.status === 403) { errorMessage += 'Acesso negado.'; } else { errorMessage += 'Tente novamente.'; } callback({ isUpgrade: false, shouldDisplay: true, message: errorMessage, priceDiff: 0, proratedAmount: 0, newTotalPrice: newTotalPrice, currentAmount: 0, currentProratedAmount: 0, newProratedAmount: 0, fractionRemaining: 0, daysRemaining: 0, totalDays: 1, nextPaymentDate: '', currentFrequency: 'monthly', newFrequency: 'monthly', raw: null }); }, complete: function() { // Asegurar que el spinner se oculte siempre al completar la llamada AJAX hideSpinner(); } }); } // ==== // SPINNER FUNCTIONS // ==== function showSpinner() { // Crear spinner si no existe let $spinner = $('#banda-pricing-spinner'); if ($spinner.length === 0) { const spinnerHtml = `
`; const $priceField = $(BANDA_CONFIG.selectors.priceDisplay).closest('.pmpro_checkout-field-price-display'); if ($priceField.length > 0) { $priceField.after(spinnerHtml); } else { $('.pmpro_form').prepend(spinnerHtml); } $spinner = $('#banda-pricing-spinner'); } $spinner.fadeIn(200); log('debug', 'Spinner shown'); } function hideSpinner() { const $spinner = $('#banda-pricing-spinner'); if ($spinner.length > 0) { $spinner.fadeOut(200); log('debug', 'Spinner hidden'); } } // ==== // ACTUALIZACIÓN DE UI (VERSIÓN SEGURA) - CORREGIDA // ==== function updatePriceDisplaySafe(price, prorationData = null) { const $priceField = $(BANDA_CONFIG.selectors.priceDisplay); const $priceLabel = $(BANDA_CONFIG.selectors.priceLabel); if (!$priceField.length) { log('warn', 'Price display field not found'); return; } const toFloat = (value, fallback = null) => { if (value === null || typeof value === 'undefined') { return fallback; } let normalized = value; if (typeof normalized === 'string') { normalized = normalized.replace(/\s+/g, ''); normalized = normalized.replace(/[^\d,.-]/g, ''); const commaCount = (normalized.match(/,/g) || []).length; const dotCount = (normalized.match(/\./g) || []).length; if (commaCount > 0 && dotCount > 0) { if (normalized.lastIndexOf(',') > normalized.lastIndexOf('.')) { normalized = normalized.replace(/\./g, '').replace(',', '.'); } else { normalized = normalized.replace(/,/g, ''); } } else if (commaCount > 0 && dotCount === 0) { normalized = normalized.replace(',', '.'); } } const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : fallback; }; const safeNum = (n, fallback = 0) => { const num = toFloat(n, null); if (num === null) { return fallback; } const min = toFloat(fallback, 0); return Math.max(min, num); }; $priceField.removeClass(BANDA_CONFIG.classes.proratedPrice); $priceLabel.removeClass(BANDA_CONFIG.classes.proratedLabel); $('.' + BANDA_CONFIG.classes.proratedNotice).remove(); $('.' + BANDA_CONFIG.classes.downgradeWarning).remove(); $('.' + BANDA_CONFIG.classes.messageNotice).remove(); let displayPrice = safeNum(price); let labelText = 'Preço total'; let shouldShowProration = false; if (prorationData && prorationData.shouldDisplay) { const amountDueNow = toFloat(prorationData.amountDueNow, null); const creditAmount = safeNum( prorationData.creditAmount ?? prorationData.currentProratedAmount ?? 0, 0 ); if (amountDueNow !== null && amountDueNow >= 0) { displayPrice = amountDueNow; labelText = 'Valor a pagar agora'; shouldShowProration = true; } else if (prorationData.isUpgrade) { displayPrice = safeNum(prorationData.newTotalPrice, displayPrice); labelText = 'Valor a pagar agora'; shouldShowProration = true; } else { displayPrice = safeNum(prorationData.newTotalPrice, displayPrice); labelText = 'Preço total'; shouldShowProration = Boolean(prorationData.message && prorationData.message !== 'Success'); } if (shouldShowProration) { $priceField.addClass(BANDA_CONFIG.classes.proratedPrice); $priceLabel.addClass(BANDA_CONFIG.classes.proratedLabel); } const newTotalPrice = safeNum(prorationData.newTotalPrice, displayPrice); const daysRemaining = Math.max(0, parseInt(prorationData.daysRemaining, 10) || 0); const totalDays = Math.max(1, parseInt(prorationData.totalDays, 10) || 1); const fraction = Number.isFinite(prorationData.fractionRemaining) ? prorationData.fractionRemaining : (totalDays > 0 ? daysRemaining / totalDays : 0); const fractionPercent = (fraction * 100).toFixed(2); const currentFrequencyLabel = prorationData.currentCycleLabel || getFrequencyLabel(prorationData.currentFrequency || 'monthly'); const newFrequencyLabel = prorationData.newCycleLabel || getFrequencyLabel(prorationData.newFrequency || 'monthly'); let noticeContent = ''; if (prorationData.isUpgrade && amountDueNow !== null && amountDueNow >= 0) { noticeContent = `