UNPKG

softseti-sale-calculator-library

Version:

Sales calculation engine by Softseti

632 lines (537 loc) 19.9 kB
let products = {}; let productTaxes = {}; let taxes = {}; let wholesaleLevels = {}; let settings = {}; let exchangeRates = {}; let currencyConverterRates = {}; let currencies = {}; /** * Sales Calculation Engine (V1) * @author softseti * * Core logic for calculating sale totals with support for: * - Wholesale pricing levels * - Product-specific and general taxes * - Deal/discount allocations */ // ============================================== // INITIALIZATION // ============================================== /** * Initializes the calculator with indexed data * @param {Object} params - Data collections: * - products: {id: Product} * - product_taxes: {product_id: TaxAssociation[]} * - taxes: {id: Tax} * - wholesale_levels: {product_id: WholesaleLevel[]} * - settings: GlobalSettings */ function init(params) { products = params.products || {}; productTaxes = params.product_taxes || {}; taxes = params.taxes || {}; wholesaleLevels = params.wholesale_levels || {}; settings = params.settings || {}; // Set currencies convertion exchangeRates = params.exchange_rates || {}; currencyConverterRates = params.currency_converter_rates || {}; currencies = params.currencies || {}; } // ============================================== // VALIDATIONS ENGINE // ============================================== function validateSaleStructure(sale) { const required = ['receipt_type_key', 'company_id', 'branch_id', 'dwSaleProducts']; if (required.some(field => !sale[field])) { throw new Error("Estructura de venta inválida"); } if (Object.keys(products).length === 0) { throw new Error("Productos no cargados"); } } // ============================================== // CORE CALCULATION ENGINE (V2.1 SCOPE) // ============================================== function calcTotal(sale) { validateSaleStructure(sale); return roundCurrency( sale.dwSaleProducts.reduce((total, product) => { const productSum = calcDwSaleProductSum(product, sale); const discount = calcDwSaleProductDiscount(product, sale); const netAmount = productSum - discount; const taxes = getApplicableProductTaxes(product, netAmount, sale); return total + netAmount + taxes; }, 0) ); } function calcSubtotal(sale) { const productSum = sale.dwSaleProducts.reduce( (sum, saleProduct) => sum + calcDwSaleProductSum(saleProduct, sale), 0); // Without discounts set default 0 let dealsSum = 0 if (sale.dwSaleDeals) { dealsSum = sale.dwSaleDeals.reduce((sum, d) => sum + d.sum, 0); } return productSum + dealsSum; } /** * preprocessSale creates a transform section to prepare all the dynamic fields create througout the params * @param {Object} params * - sale: */ function preprocessSale(sale) { const saleCurrency = currencies[sale.currency_id]; // Add currency_iso if not provided if (!sale.currency_iso && saleCurrency?.iso) { sale.currency_iso = saleCurrency.iso; } // Set default currency if none specified if (!sale.currency_id && !sale.currency_iso) { sale.currency_id = 88; // Default currency ID sale.currency_iso = 'MXN'; // Default currency } if (sale.dwSaleDeals) { sale.dwSaleDeals = sale.dwSaleDeals.map(deal => ({ ...deal, sum: deal.amount ? deal.amount * -1 : 0 })); } if (sale.payments) { sale.payments = sale.payments.map(payment => { const originalCurrency = currencies[payment.currency_id]; return { ...payment, original_currency_id: payment.currency_id, original_currency_iso: originalCurrency?.iso, currency_id: sale.currency_id, currency_iso: currencies[sale.currency_id]?.iso, original_gived_amount: payment.gived_amount, gived_amount: calcGivedPaymentAmount({ ...payment, original_gived_amount: payment.gived_amount, currency_iso: currencies[sale.currency_id]?.iso, original_currency_iso: originalCurrency?.iso }, sale) }; }); } if (sale.dwSaleProducts) { sale.dwSaleProducts = sale.dwSaleProducts.map(product => { const productData = products[product.product_id]; const productCurrency = currencies[productData?.currency_id]; return { ...product, currency_iso: productCurrency?.iso || sale.currency_iso, currency_id: productData?.currency_id || sale.currency_id }; }); } return sale; } function calcDwSaleProductDiscount(product, sale) { if (!sale.dwSaleDeals || sale.dwSaleDeals.length === 0) { return 0; } const totalDiscount = sale.dwSaleDeals.reduce( (total, deal) => total + Math.abs(deal.sum), 0 ); const productSum = calcDwSaleProductSum(product, sale); const subtotal = calcSubtotal(sale) + totalDiscount; if (subtotal === 0) return 0; const productRatio = productSum / subtotal; return productRatio * totalDiscount; } function calcDwSaleProductSum(saleProduct, sale) { const product = products[saleProduct.product_id]; if (!product) { throw new Error(`Product ${saleProduct.product_id} not found`); } const price = saleProduct.custom_price ?? calcDwSaleProductPrice(saleProduct, sale); //return roundCurrency((saleProduct.quantity * price) / 2); original logic to calc based on legacy // Calc sum for products in relation with price product return roundCurrency(saleProduct.quantity * price); } function calcDwSaleProductPrice(saleProduct, sale) { const product = products[saleProduct.product_id]; // GET the Wholesalelevel for the product const wholeSaleLevel = getProductWholesaleLevel(saleProduct, sale); // GET BasePrice for the product checking if exist in wholesalelevel or take the default original price const basePrice = wholeSaleLevel?.price ?? product.public_price; let productCurrency = product.currency_iso; // When the syncronized product has not the currency iso provides the currency iso by the currency id if (!productCurrency) { productCurrency = currencies[product?.currency_id].iso; } const saleCurrency = sale.currency_iso; // Convertion currency if (productCurrency !== saleCurrency) { return exchange( basePrice, productCurrency, saleCurrency ); } // If not exist exchange and convert rate return the basePrice. return basePrice; } function getProductWholesaleLevel(saleProduct, sale) { const product = products[saleProduct.product_id]; const quantity = settings.wholesale_level_quantity_restriction === 'sale' ? sumProductQuantity(sale.dwSaleProducts, product.id) : saleProduct.quantity; // Get ALL wholesale levels for this product const levels = wholesaleLevels[product.id] || []; // Find FIRST matching level for quantity range return levels.find(level => quantity >= level.quantity_min && quantity <= level.quantity_max ) || null; } function sumProductQuantity(products, productId) { return products.filter(p => p.product_id === productId) .reduce((sum, p) => sum + p.quantity, 0); } function getApplicableProductTaxes(saleProduct, productSum, sale) { // GET Taxes for specific products const specificTaxes = getProductSpecificTaxes(saleProduct, sale); // GET General Taxes const generalTaxes = getGeneralTaxes(sale); return [...specificTaxes, ...generalTaxes].reduce( (total, tax) => total + (productSum * (tax.rate / 100)), 0 ); } function getProductSpecificTaxes(saleProduct, sale) { const taxReceiptTypeKey = getTaxReceiptTypeKey(sale.receipt_type_key); const taxMap = {}; // Get ALL tax associations for this product const associations = productTaxes[saleProduct.product_id] || []; // Process EACH tax association associations.forEach(association => { const tax = taxes[association.tax_id]; if (tax && !tax.general && tax[taxReceiptTypeKey]) { taxMap[getTaxIndex(tax)] = tax; } }); return Object.values(taxMap); } function getTaxIndex(tax) { return `${tax.abbreviation.toLowerCase()}${tax.rate.toFixed(6).replace('.', '')}`; } function getGeneralTaxes(sale) { const taxReceiptTypeKey = getTaxReceiptTypeKey(sale.receipt_type_key); return Object.values(taxes).filter(tax => tax.general && tax[taxReceiptTypeKey] ); } function getTaxReceiptTypeKey(receiptType) { return receiptType === 'note' ? 'sales_note' : receiptType; } // ============================================== // CURRENCY EXCHANGE IMPLEMENTATION // ============================================== /** * Converts amount between currencies using company-specific or default rates * @param {number} amount - Amount to convert * @param {string} fromCurrency - ISO code (e.g. 'MXN') * @param {string} toCurrency - ISO code (e.g. 'USD') */ function exchange(amount, fromCurrency, toCurrency) { if (fromCurrency === toCurrency) return amount; // GET EXANGE RATE const exchangeRateModel = findExchangeRate(fromCurrency, toCurrency); if (exchangeRateModel) { return applyExchangeStrategy(amount, exchangeRateModel); } // GET CURRENCY CONVERTER RATE WHEN NOT EXIST THE EXANGE RATE const currencyConverterRateModel = findCurrencyConverterRate(fromCurrency, toCurrency); if (!currencyConverterRateModel) { throw new Error(`No exchange rate found for ${fromCurrency}->${toCurrency}`); } // Exchange With CURRENCY CONVERTER RATE return amount * currencyConverterRateModel.rate; } // Get exchange rate function findExchangeRate(from, to) { return Object.values(exchangeRates).find(rate => rate.from_currency_iso === from && rate.to_currency_iso === to ); } // Get CURRENCY CONVERTER RATE function findCurrencyConverterRate(from, to) { return Object.values(currencyConverterRates).find(rate => rate.from_currency_iso === from && rate.to_currency_iso === to ); } // Exchange calculation function applyExchangeStrategy(amount, exchangeRate) { return (amount * exchangeRate.to_currency_value) / exchangeRate.from_currency_value; } // ============================================== // PAYMENT METHODS // ============================================== /** * Calculates the physical change to return to the customer * @param {Object} sale - Sale object containing payments and calculation context * @returns {number} Positive change amount if overpaid, otherwise 0 */ function calcChange(sale) { const total = calcTotal(sale); const paymentSum = sumGivedAmountPayments(sale); const change = paymentSum - total; return change > 0 ? change : 0; } /** * Calculates remaining debt amount after applied payments * @param {Object} sale - Sale object containing payments and calculation context * @returns {number} Remaining debt amount (0 if fully paid) */ function calcDebt(sale) { const total = calcTotal(sale); const paymentSum = sumAmountPayments(sale); const debt = total - paymentSum; return debt > 0 ? debt : 0; } /** * Sums effective payment amounts applied to the sale total * @param {Object} sale - Sale object containing payments array * @returns {number} Total effective payment amount in sale currency */ function sumAmountPayments(sale) { return (sale.payments || []).reduce((sum, payment) => { return sum + calcPaymentAmount(payment, sale); }, 0); } /** * Sums raw payment amounts before conversion/adjustment * @param {Object} sale - Sale object containing payments array * @returns {number} Total given payment amount before any adjustments */ function sumGivedAmountPayments(sale) { return (sale.payments || []).reduce((sum, payment) => { return sum + calcGivedPaymentAmount(payment, sale); }, 0); } /** * Calculates effective payment amount applied to sale total * @param {Object} payment - Payment object to calculate * @param {Object} sale - Parent sale object for context * @returns {number} Effective amount applied to sale total in sale currency */ function calcPaymentAmount(payment, sale) { const total = calcTotal(sale); const payments = sale.payments || []; const index = payments.findIndex(p => p.original_gived_amount === payment.original_gived_amount && p.currency_id === payment.currency_id && p.payment_type_id === payment.payment_type_id ); if (index === -1) return 0; let accumulated = 0; for (let i = 0; i < index; i++) { const prevPayment = payments[i]; const amount = exchange( prevPayment.original_gived_amount, prevPayment.original_currency_iso, sale.currency_iso ); const remaining = total - accumulated; accumulated += Math.min(amount, remaining); } const currentAmount = exchange( payment.original_gived_amount, payment.original_currency_iso, sale.currency_iso ); const remaining = total - accumulated; return Math.min(currentAmount, Math.max(remaining, 0)); } /** * : calcOriginalPaymentAmount() * @param {Object} payment * @param {Object} sale * @returns {number} Amount in payment's original currency */ function calcOriginalPaymentAmount(payment, sale) { const effectiveAmount = calcPaymentAmount(payment, sale); return exchange( effectiveAmount, payment.original_currency_iso, sale.currency_iso ); } /** * : calcGivedPaymentAmount() * @param {Object} payment * @param {Object} sale * @returns {number} Converted amount without debt adjustment */ function calcGivedPaymentAmount(payment, sale) { return exchange( payment.original_gived_amount, payment.original_currency_iso, sale.currency_iso ); } /** * Calculates complete payment details including currency conversions * @param {Object} payment - Payment object containing: * - currency_id: ID of payment currency * - original_currency_iso: Original currency ISO code (optional) * - original_gived_amount: Amount given in original currency * @param {Object} sale - Sale object containing: * - currency_id: ID of sale currency * - currency_iso: Sale currency ISO code * - original_currency_iso: Original sale currency ISO code (optional) * @returns {Object} Payment details object containing: * - amount: Effective payment amount applied to sale (in sale currency) * - gived_amount: Converted given amount (in sale currency) * - original_amount: Original amount (in payment currency) * - original_gived_amount: Original given amount (in payment currency) * - currency_iso: Sale currency ISO code * - original_currency_iso: Payment currency ISO code * - exchange_rate: Calculated exchange rate between currencies */ function calculatePaymentDetails(payment, enhancedSale) { // Calculate amounts const gived_amount = calcGivedPaymentAmount(payment, enhancedSale); const amount = calcPaymentAmount(payment, enhancedSale); const exchange_rate = gived_amount / payment.original_gived_amount; return { ...payment, amount, gived_amount, original_amount: payment.original_gived_amount, original_gived_amount: payment.original_gived_amount, exchange_rate, }; } /** * Gets all wholesale levels that were applied to products in the sale * @param {Object} sale - The sale object * @returns {Array} Array of applied wholesale levels in the exact API format */ function appliedWholesaleLevels(sale) { if (!sale.dwSaleProducts || !sale.dwSaleProducts.length) { return []; } const result = []; let levelCounter = 1; // Para generar IDs secuenciales como en el ejemplo sale.dwSaleProducts.forEach(product => { const productData = products[product.product_id]; if (!productData) return; const quantity = settings.wholesale_level_quantity_restriction === 'sale' ? sumProductQuantity(sale.dwSaleProducts, product.product_id) : product.quantity; // Get ALL levels for product (always array now) const levels = wholesaleLevels[product.product_id] || []; // Find FIRST applicable level const appliedLevel = levels.find(level => quantity >= level.quantity_min && quantity <= level.quantity_max ); if (!appliedLevel) return; result.push({ dw_product_id: appliedLevel.product_id, wholesale_level_id: appliedLevel.id, name: appliedLevel.name, description: appliedLevel.description || '', price: appliedLevel.price, quantity_min: appliedLevel.quantity_min, quantity_max: appliedLevel.quantity_max, branch_id: sale.branch_id, company_id: sale.company_id }); }); return result; } /** * Calculates all sale totals including potential currency conversions * @param {Object} sale - Sale object containing: * - currency_id: ID of sale currency * - currency_iso: Sale currency ISO code * - original_currency_iso: Original currency ISO code (optional) * @returns {Object} Sale totals object containing: * - subtotal: Sale subtotal in sale currency * - total: Sale total in sale currency * - change: Change due in sale currency * - debt: Remaining debt in sale currency * - original_subtotal: Optional - subtotal in original currency * - original_total: Optional - total in original currency */ function calculateSaleTotals(sale) { // Calculate base totals in sale currency const subtotal = calcSubtotal(sale); const total = calcTotal(sale); const change = calcChange(sale); const debt = calcDebt(sale); const result = { subtotal, // Sum of products before taxes total, // Final total including taxes change, // Amount to return to customer debt // Remaining unpaid amount }; // If sale involves currency conversion (different from original currency) if (sale.original_currency_iso && sale.original_currency_iso !== sale.currency_iso) { // Convert subtotal to original currency result.original_subtotal = exchange( subtotal, sale.currency_iso, sale.original_currency_iso ); // Convert total to original currency result.original_total = exchange( total, sale.currency_iso, sale.original_currency_iso ); } return result; } // ============================================== // TAX SPECIFIC CALCULATIONS // ============================================== function calcTaxesByAbbreviation(sale, abbreviation) { let totalTax = 0; sale.dwSaleProducts.forEach(product => { const productSum = calcDwSaleProductSum(product, sale); const discount = calcDwSaleProductDiscount(product, sale); const netAmount = productSum - discount; const specificTaxes = getProductSpecificTaxes(product, sale); const generalTaxes = getGeneralTaxes(sale); const allTaxes = [...specificTaxes, ...generalTaxes]; allTaxes.filter(tax => tax.abbreviation === abbreviation) .forEach(tax => { totalTax += netAmount * (tax.rate / 100); }); }); return totalTax; } function roundCurrency(value) { return Math.round(value * 100) / 100; } module.exports = { init, preprocessSale, calcTotal, calcSubtotal, calcDebt, calcChange, calcOriginalPaymentAmount, calcGivedPaymentAmount, calcTaxesByAbbreviation, calculateSaleTotals, calculatePaymentDetails, appliedWholesaleLevels, _internals: { calcDwSaleProductSum, calcDwSaleProductDiscount, getApplicableProductTaxes, exchange } };