UNPKG

softseti-sale-calculator-library

Version:

Sales calculation engine by Softseti

1,031 lines (872 loc) 34.6 kB
const RuleEngine = require('./rule-engine'); const RuleManager = require('../rules/RuleManager'); const RuleTaxApplicator = require('../rules/RuleTaxApplicator'); let ruleEngine = null; let ruleManager = null; let products = {}; let productTaxes = {}; let taxes = {}; let wholesaleLevels = {}; let settings = {}; let exchangeRates = {}; let currencyConverterRates = {}; let currencies = {}; let businessRules = {}; let decimalPlaces = 2; let roundingFactor = 100; let useRounding = false; let preprocessedSale = {}; let zenEngineDependency = null; // Injectable seam that owns all tax rule integration (engine + manager + // selection + rule_tracking). Recreated per init() by initializeRuleSystem(). let ruleApplicator = null; /** Rule trace collected during the current calculation. */ function getRuleTracking() { return ruleApplicator ? ruleApplicator.getTracking() : []; } /** Builds the rule engine context for one tax (same shape for every trigger). */ function buildTaxRuleContext(tax, sale) { return { sale: { ...preprocessedSale, subtotal: preprocessedSale.subtotal || calcSubtotal(sale), customers: preprocessedSale.customers || sale.customers || [] }, tax: { ...tax, tax_id: tax.id, dw_product_id: tax.product_id || null, sale_id: null, amount: 0, branch_id: sale.branch_id, company_id: sale.company_id } }; } /** * 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 */ /** * 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, dependencies = {}) { products = params.products || {}; productTaxes = params.product_taxes || {}; taxes = params.taxes || {}; wholesaleLevels = params.wholesale_levels || {}; settings = params.settings || {}; // Set currencies conversion exchangeRates = params.exchange_rates || {}; currencyConverterRates = params.currency_converter_rates || {}; currencies = params.currencies || {}; businessRules = params.businessRules || {}; decimalPlaces = params.decimal_places !== undefined ? params.decimal_places : 2; roundingFactor = Math.pow(10, decimalPlaces); useRounding = params.use_rounding !== undefined ? params.use_rounding : false; const shouldInitRules = params.rules_apply !== false; // Only bring the rule system up when rules are enabled AND an engine was // injected. Without an injected engine there is nothing to run, and forcing // initialization on every call (even with no engine/rules) is wasteful; in // that case the calculation just runs without rules. if (shouldInitRules && dependencies.zenEngine) { zenEngineDependency = dependencies.zenEngine; initializeRuleSystem(businessRules); } else { if (!shouldInitRules) { console.log('Rule system skipped (rules_apply = false)'); } ruleEngine = null; ruleManager = null; ruleApplicator = null; zenEngineDependency = null; } } // ============================================== // Rule engine // ============================================== function initializeRuleSystem(businessRules) { try { ruleEngine = new RuleEngine(zenEngineDependency); ruleManager = new RuleManager(); if (businessRules && Object.keys(businessRules).length > 0) { ruleManager.loadRules(businessRules); console.log('Rule system initialized successfully'); } else { console.warn('No business rules found to load'); } // Single seam the calculator uses to apply tax rules. ruleApplicator = new RuleTaxApplicator(ruleEngine, ruleManager); } catch (error) { console.error('Error initializing rule system:', error); // Fallback: without rules ruleEngine = null; ruleManager = null; ruleApplicator = null; } } // ============================================== // VALIDATIONS ENGINE // ============================================== function validateSaleStructure(sale) { const required = ['receipt_type_key', 'company_id', 'branch_id', 'dwSaleProducts']; if (required.some(field => !sale[field])) { throw new Error("Invalid sale structure"); } if (Object.keys(products).length === 0) { throw new Error("Products not loaded"); } } // ============================================== // CORE CALCULATION ENGINE (V2.1 SCOPE) // ============================================== async function calcTotal(sale) { validateSaleStructure(sale); let total = 0; for (const product of sale.dwSaleProducts) { const productSum = calcDwSaleProductSum(product, sale); const discount = calcDwSaleProductDiscount(product, sale); const netAmount = roundCurrency(productSum - discount); const taxes = await getApplicableProductTaxes(product, netAmount, sale); total += netAmount + taxes; } return roundCurrency(total); } 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 roundCurrency(productSum + dealsSum); } /** * preprocessSale creates a transform section to prepare all the dynamic fields create througout the params * @param {Object} params * - sale: */ async 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 })); } // Product prices are converted to the sale currency BEFORE any totals math: // payment processing below runs calcTotal/exchange over the sale, so the // lines (and the rule context) must already be expressed in the sale currency. if (sale.dwSaleProducts) { sale.dwSaleProducts = sale.dwSaleProducts.map(product => { const productData = products[product.product_id]; const productCurrency = currencies[productData?.currency_id]; // Resolve per-line unit price: custom_price wins over wholesale/exchange. // Lazy on the right side so a present custom_price short-circuits exchange/wholesale resolution // (same precedence and short-circuit semantics as calcDwSaleProductSum). const resolvedUnitPrice = product.custom_price ?? roundCurrency(calcDwSaleProductPrice(product, sale)); const productSum = calcDwSaleProductSum(product, sale); return { ...product, price: resolvedUnitPrice, public_price: resolvedUnitPrice, sum: productSum, currency_iso: sale.currency_iso, currency_id: sale.currency_id, original_public_price: productData?.public_price ?? product.public_price, original_currency_id: productData?.currency_id ?? product.currency_id ?? sale.currency_id, original_currency_iso: productData?.currency_iso || productCurrency?.iso || sale.currency_iso }; }); } if (wholesaleLevels) { Object.keys(wholesaleLevels).forEach(productId => { wholesaleLevels[productId] = wholesaleLevels[productId].map(level => ({ ...level, price: roundCurrency(level.price) })); }); } // Expose the current sale (with converted lines) to the rule context BEFORE // payment totals run; otherwise rules evaluate against the previous call's sale. preprocessedSale = sale; if (sale.payments) { const processedPayments = []; for (const payment of sale.payments) { const originalCurrency = currencies[payment.currency_id]; // If payment already has calculated fields, use them if (payment.amount && payment.original_amount) { processedPayments.push({ ...payment, original_currency_id: payment.currency_id, original_currency_iso: originalCurrency?.iso, currency_id: sale.currency_id, currency_iso: sale.currency_iso, change_amount: 0 }); } else { // Otherwise calculate from basic payment data const details = await calculatePaymentDetails({ ...payment, original_gived_amount: payment.gived_amount || payment.amount, original_currency_iso: originalCurrency?.iso }, sale); processedPayments.push({ ...payment, original_currency_id: payment.currency_id, original_currency_iso: originalCurrency?.iso, currency_id: sale.currency_id, currency_iso: sale.currency_iso, original_gived_amount: payment.gived_amount || payment.amount, ...details }); } } sale.payments = processedPayments; } 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 roundCurrency(productRatio * totalDiscount); } function calcDwSaleProductSum(saleProduct, sale) { // A product missing from the synced map degrades to the line's own data // instead of aborting the whole quote (which rendered Total $0.00). const product = products[saleProduct.product_id]; const price = saleProduct.custom_price ?? calcDwSaleProductPrice(saleProduct, sale); const baseQuantity = (product && product.quantity) || 1; //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); return roundCurrency((saleProduct.quantity * price) / baseQuantity); } function calcDwSaleProductPrice(saleProduct, sale) { const product = products[saleProduct.product_id]; // GET the Wholesalelevel for the product const wholeSaleLevel = getProductWholesaleLevel(saleProduct, sale); // BasePrice: wholesale level > synced product > the sale line's own price const basePrice = wholeSaleLevel?.price ?? product?.public_price ?? saleProduct.public_price ?? saleProduct.price ?? 0; let productCurrency = product?.currency_iso; // When the syncronized product has not the currency iso provides the currency iso by the currency id // (falling back to the sale line's own currency when the product isn't synced) if (!productCurrency) { productCurrency = currencies[(product ?? saleProduct)?.currency_id]?.iso; } const saleCurrency = sale.currency_iso; // Currency conversion if (productCurrency && saleCurrency && productCurrency !== saleCurrency) { return exchange( basePrice, productCurrency, saleCurrency ); } // If no exchange or conversion rate exists, return the basePrice. return basePrice; } function getProductWholesaleLevel(saleProduct, sale) { // Keyed by the line's own product_id — dereferencing products[] here crashed // with "reading 'id'" whenever the product was missing from the synced map. const quantity = settings.wholesale_level_quantity_restriction === 'sale' ? sumProductQuantity(sale.dwSaleProducts, saleProduct.product_id) : saleProduct.quantity; // Get ALL wholesale levels for this product const levels = wholesaleLevels[saleProduct.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); } async function getApplicableProductTaxes(saleProduct, productSum, sale) { // GET Taxes for specific products const specificTaxes = getProductSpecificTaxes(saleProduct, sale); // GET General Taxes const generalTaxes = getGeneralTaxes(sale); // Combine all applicable taxes const taxes = [...specificTaxes, ...generalTaxes]; // Get rules for taxes const convertedRuleTaxes = ruleApplicator ? ruleApplicator.rulesFor('calculate_taxes') : []; // If no rules or no engine, calculate normally if (convertedRuleTaxes.length === 0 || !ruleApplicator) { const totalTax = taxes.reduce( (total, tax) => { const taxAmount = productSum * (tax.rate / 100); return total + roundCurrency(taxAmount); }, 0 ); return roundCurrency(totalTax); } // Process each tax with rules let totalTax = 0; for (const tax of taxes) { const context = buildTaxRuleContext(tax, sale); const { adjustedRate, taxAmount: ruledAmount } = await ruleApplicator.resolveTax('calculate_taxes', convertedRuleTaxes, tax, context, saleProduct, { track: false }); let taxAmount = ruledAmount; // Calculate tax amount if (taxAmount > 0) { totalTax += roundCurrency(taxAmount); } else { taxAmount = productSum * (adjustedRate / 100); totalTax += roundCurrency(taxAmount); } } return roundCurrency(totalTax); } /** */ function isTruthy(value) { return Boolean(value) || value === 1; } function isFalsy(value) { return !Boolean(value) && value !== 1; } function getProductSpecificTaxes(saleProduct, sale) { const taxReceiptTypeKey = getTaxReceiptTypeKey(sale.receipt_type_key); const taxMap = {}; const associations = productTaxes[saleProduct.product_id] || []; associations.forEach(association => { const tax = taxes[association.tax_id]; if (tax && isFalsy(tax.general) && isTruthy(tax[taxReceiptTypeKey])) { // Include product_id in the tax object for product-specific taxes const taxWithProductId = { ...tax, product_id: saleProduct.product_id }; // Use a unique key that includes both tax ID and product ID to avoid merging taxMap[getTaxProductIndex(tax, saleProduct.product_id)] = taxWithProductId; } }); return Object.values(taxMap); } // Index generator to include product_id for uniqueness function getTaxProductIndex(tax, productId) { if (productId) { return `${tax.id}-${productId}-${tax.abbreviation.toLowerCase()}${tax.rate.toFixed(6).replace('.', '')}`; } return `${tax.id}-${tax.abbreviation.toLowerCase()}${tax.rate.toFixed(6).replace('.', '')}`; } function getGeneralTaxes(sale) { const taxReceiptTypeKey = getTaxReceiptTypeKey(sale.receipt_type_key); return Object.values(taxes).filter(tax => isTruthy(tax.general) && isTruthy(tax[taxReceiptTypeKey]) ).map(tax => ({ ...tax, product_id: null // Set product_id as null for general taxes })); } 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 roundCurrency(amount); // GET EXCHANGE RATE const exchangeRateModel = findExchangeRate(fromCurrency, toCurrency); if (exchangeRateModel) { return roundCurrency(applyExchangeStrategy(amount, exchangeRateModel)); } // GET CURRENCY CONVERTER RATE WHEN NO EXCHANGE RATE EXISTS const currencyConverterRateModel = findCurrencyConverterRate(fromCurrency, toCurrency); if (!currencyConverterRateModel) { throw new Error(`No exchange rate found for ${fromCurrency}->${toCurrency}`); } // Exchange With CURRENCY CONVERTER RATE return roundCurrency(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 */ async function calcChange(sale) { const total = await calcTotal(sale); const paymentSum = sumGivedAmountPayments(sale); const change = paymentSum - total; return roundCurrency(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) */ async function calcDebt(sale) { const total = await calcTotal(sale); const paymentSum = await sumAmountPayments(sale); const debt = total - paymentSum; return roundCurrency(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 */ async function sumAmountPayments(sale) { const payments = sale.payments || []; let sum = 0; for (const payment of payments) { sum += await calcPaymentAmount(payment, sale); } return sum; } /** * 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 */ async function calcPaymentAmount(payment, sale) { const total = await calcTotal(sale); const payments = sale.payments || []; // Find current payment index 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; // Calculate how much previous payments have covered 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); } // Calculate current payment amount that can be applied const currentAmount = exchange( payment.original_gived_amount, payment.original_currency_iso, sale.currency_iso ); const remaining = total - accumulated; const appliedAmount = Math.min(currentAmount, Math.max(remaining, 0)); return roundCurrency(appliedAmount); } /** * Calculates the original amount applied to sale (in payment's original currency) * @param {Object} payment * @param {Object} sale * @returns {number} Amount in payment's original currency */ async function calcOriginalPaymentAmount(payment, sale) { const effectiveAmount = await calcPaymentAmount(payment, sale); // Convert FROM sale currency TO payment's original currency return exchange( effectiveAmount, sale.currency_iso, // From sale currency payment.original_currency_iso // To payment's original currency ); } /** * : calcGivedPaymentAmount() * @param {Object} payment * @param {Object} sale * @returns {number} Converted amount without debt adjustment */ function calcGivedPaymentAmount(payment, sale) { return roundCurrency(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 */ async function calculatePaymentDetails(payment, sale) { // Calculate the actual amount that can be applied to the sale (in sale currency) const amount = await calcPaymentAmount(payment, sale); // Calculate the given amount in sale currency (full conversion without debt cap) const gived_amount = exchange( payment.original_gived_amount, payment.original_currency_iso, sale.currency_iso ); // Calculate the original amount applied (in payment's original currency) const original_amount = exchange( amount, // This is in sale currency sale.currency_iso, payment.original_currency_iso ); // Calculate exchange rate (sale currency per 1 unit of payment currency) const exchange_rate = gived_amount / payment.original_gived_amount; return { ...payment, amount, // Applied amount in sale currency gived_amount, // Full given amount in sale currency original_amount, // Applied amount in payment's original currency original_gived_amount: payment.original_gived_amount, // Original given amount unchanged exchange_rate, change_amount: roundCurrency(gived_amount - amount) // Change in sale currency }; } /** * 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; // For generating sequential IDs as in the example 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 applicable taxes for the sale and returns them in API format * @param {Object} sale - Sale data object * @returns {Array} Array of tax objects with id, abbreviation, and amount */ /** * Calculates all applicable taxes for the sale and returns them in API format * @param {Object} sale - Sale data object * @returns {Array} Array of tax objects with id, abbreviation, and amount */ async function getApplicableDwTaxes(sale) { validateSaleStructure(sale); const taxMap = {}; const taxBatches = []; // Get rules for taxes const convertedRuleTaxes = ruleApplicator ? ruleApplicator.rulesFor('calculate_taxes') : []; for (const [productIndex, product] of sale.dwSaleProducts.entries()) { const productSum = calcDwSaleProductSum(product, sale); const discount = calcDwSaleProductDiscount(product, sale); const netAmount = roundCurrency(productSum - discount); // Get applicable taxes for this product const specificTaxes = getProductSpecificTaxes(product, sale); const generalTaxes = getGeneralTaxes(sale); let allTaxes = [...specificTaxes, ...generalTaxes]; // Process each tax with rules for (const tax of allTaxes) { const context = buildTaxRuleContext(tax, sale); const resolved = ruleApplicator ? await ruleApplicator.resolveTax('calculate_taxes', convertedRuleTaxes, tax, context, product) : { adjustedRate: tax.rate, taxAmount: 0 }; const adjustedRate = resolved.adjustedRate; let taxAmount = resolved.taxAmount; if (taxAmount <= 0) { taxAmount = netAmount * (adjustedRate / 100); } taxAmount = roundCurrency(taxAmount); const taxKey = getTaxProductIndex(tax, tax.product_id); const taxBatch = { product_id: product.product_id, product_index: productIndex, sum: netAmount, amount: taxAmount, rate: adjustedRate, original_rate: tax.rate, tax_type: tax.product_id ? 'specific' : 'general', adjusted_by_rule: adjustedRate !== tax.rate }; taxBatches.push(taxBatch); const mapTaxKey = `${tax.id}-${adjustedRate}-${tax.abbreviation}`; if (taxMap[mapTaxKey]) { taxMap[mapTaxKey].amount += taxAmount; taxMap[mapTaxKey].batches.push(taxBatch); } else { taxMap[mapTaxKey] = { id: tax.id, abbreviation: tax.abbreviation, rate: adjustedRate, original_rate: tax.rate, amount: taxAmount, product_id: tax.product_id || null, batches: [taxBatch], adjusted_by_rule: adjustedRate !== tax.rate }; } } } return Object.values(taxMap).map(tax => ({ ...tax, amount: roundCurrency(tax.amount) })).filter(tax => tax.amount > 0); } /** * 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 */ async function calculateSaleTotals(sale) { // Calculate base totals in sale currency const subtotal = calcSubtotal(sale); const total = await calcTotal(sale); const change = await calcChange(sale); const debt = await 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 = roundCurrency(productSum - discount); const specificTaxes = getProductSpecificTaxes(product, sale); const generalTaxes = getGeneralTaxes(sale); const allTaxes = [...specificTaxes, ...generalTaxes]; allTaxes.filter(tax => tax.abbreviation === abbreviation) .forEach(tax => { totalTax += roundCurrency(netAmount * (tax.rate / 100)); }); }); return roundCurrency(totalTax); } /** * Gets getConsolidatedTaxes taxes by merging taxes with same id, abbreviation and rate * @param {Object} sale - Sale data object * @returns {Array} Array of unified tax objects with id, abbreviation, rate and total amount */ function getConsolidatedTaxes(sale) { validateSaleStructure(sale); const taxMap = {}; sale.dwSaleProducts.forEach((product, productIndex) => { const productSum = calcDwSaleProductSum(product, sale); const discount = calcDwSaleProductDiscount(product, sale); const netAmount = roundCurrency(productSum - discount); // Get applicable taxes for this product const specificTaxes = getProductSpecificTaxes(product, sale); const generalTaxes = getGeneralTaxes(sale); const allTaxes = [...specificTaxes, ...generalTaxes]; allTaxes.forEach(tax => { const taxAmount = roundCurrency(netAmount * (tax.rate / 100)); const taxKey = `${tax.id}-${tax.abbreviation}-${tax.rate}`; if (taxMap[taxKey]) { // Merge with existing tax taxMap[taxKey].amount += taxAmount; taxMap[taxKey].product_count = (taxMap[taxKey].product_count || 1) + 1; } else { // Create new tax entry taxMap[taxKey] = { id: tax.id, abbreviation: tax.abbreviation, name: tax.name || tax.abbreviation, rate: tax.rate, amount: taxAmount, product_count: 1, is_general: tax.general || false }; } }); }); // Return unified taxes sorted by amount (descending) return Object.values(taxMap) .map(tax => ({ ...tax, amount: roundCurrency(tax.amount) })) .filter(tax => tax.amount > 0) .sort((a, b) => b.amount - a.amount); } /** * Rounds or truncates currency values based on configuration * @param {number} value - Value to process * @param {boolean} overrideRounding - Optional override for rounding behavior * @returns {number} Processed value */ function roundCurrency(value, overrideRounding) { if (typeof value !== 'number' || !isFinite(value)) { console.warn('roundCurrency: Invalid input', value); return 0; } // Handle zero and very small values if (Math.abs(value) < Number.EPSILON) { return 0; } const shouldRound = overrideRounding !== undefined ? overrideRounding : useRounding; let result; if (shouldRound) { // Rounding behavior result = Math.round(value * roundingFactor) / roundingFactor; } else { // Truncation behavior (default) result = Math.trunc(value * roundingFactor) / roundingFactor; } // Additional precision handling return parseFloat(result.toFixed(decimalPlaces)); } module.exports = { init, preprocessSale, // Async calcTotal, // Async calcSubtotal, calcDebt, // Async calcChange, // Async calcOriginalPaymentAmount, // Async calcGivedPaymentAmount, calcTaxesByAbbreviation, calculateSaleTotals, // Async calculatePaymentDetails, // Async appliedWholesaleLevels, getApplicableDwTaxes, // Async getRuleTracking, getConsolidatedTaxes, calcDwSaleProductPrice, _internals: { calcDwSaleProductSum, calcDwSaleProductDiscount, getApplicableProductTaxes, // Async exchange, // Live view of the loaded currency catalog (getter: `currencies` is // reassigned on every init(), a plain reference would freeze the first one). get currencies() { return currencies; } } };