UNPKG

softseti-sale-calculator-library

Version:

Sales calculation engine by Softseti

557 lines (474 loc) 18.8 kB
// src/core/purchase-calculator.js const RuleEngine = require('./rule-engine'); const RuleManager = require('../rules/RuleManager'); // State let exchangeRates = {}; let currencyConverterRates = {}; let businessRules = {}; let decimalPlaces = 2; let roundingFactor = 100; let useRounding = false; let ruleEngine = null; let ruleManager = null; let zenEngineDependency = null; // Init /** * Purchase Calculation Engine * @author softseti * * Core logic for calculating purchase totals with support for: * - Product-specific and general taxes * - Business rules via RuleEngine (optional) * - Multi-currency exchange */ /** * Initializes the purchase calculator with exchange and settings data. * Unlike the sale calculator, products are NOT indexed here — * cost and taxes are read directly from each dwPurchaseProduct entry. * * @param {Object} params * - exchange_rates {id: ExchangeRate} * - currency_converter_rates {id: CurrencyConverterRate} * - businessRules {Object} * - decimal_places {number} default 2 * - use_rounding {boolean} default false * - rules_apply {boolean} default true * @param {Object} dependencies * - zenEngine: ZenEngine constructor (optional) */ function init(params, dependencies = {}) { exchangeRates = params.exchange_rates || {}; currencyConverterRates = params.currency_converter_rates || {}; 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; if (dependencies.zenEngine) { zenEngineDependency = dependencies.zenEngine; } if (params.rules_apply !== false) { initializeRuleSystem(businessRules); } else { ruleEngine = null; ruleManager = null; } } // Rule System /** * Initializes the RuleEngine and RuleManager with the provided business rules. * Falls back gracefully to null if initialization fails. * @param {Object} rules - Business rules object */ function initializeRuleSystem(rules) { try { ruleEngine = new RuleEngine(zenEngineDependency); ruleManager = new RuleManager(); if (rules && Object.keys(rules).length > 0) { ruleManager.loadRules(rules); console.log('[purchase-calculator] Rule system initialized'); } else { console.warn('[purchase-calculator] No business rules found'); } } catch (err) { console.error('[purchase-calculator] Error initializing rule system:', err); ruleEngine = null; ruleManager = null; } } // Validation /** * Validates the minimum required structure of a purchase object. * @param {Object} purchase - Purchase object to validate * @throws {Error} If required fields are missing or dwPurchaseProducts is empty */ function validatePurchaseStructure(purchase) { const required = ['company_id', 'branch_id', 'dwPurchaseProducts']; if (required.some(field => !purchase[field])) { throw new Error('[purchase-calculator] Invalid purchase structure: missing required fields'); } if (!Array.isArray(purchase.dwPurchaseProducts) || purchase.dwPurchaseProducts.length === 0) { throw new Error('[purchase-calculator] dwPurchaseProducts must be a non-empty array'); } } // Core Calculation /** * Calculates the purchase subtotal (sum of all product lines before taxes). * Each product must have: { quantity, cost } where cost is already * in the purchase document currency. * * @param {Object} purchase - Purchase object containing dwPurchaseProducts * @returns {number} Subtotal in purchase currency */ function calcSubtotal(purchase) { return roundCurrency( purchase.dwPurchaseProducts.reduce( (sum, p) => sum + calcProductLineSum(p), 0 ) ); } /** * Calculates the purchase total including all applicable taxes. * Product-specific taxes are applied per line; general taxes are applied * over the full subtotal. * * @param {Object} purchase - Purchase object * @returns {Promise<number>} Total in purchase currency */ async function calcTotal(purchase) { validatePurchaseStructure(purchase); const subtotal = calcSubtotal(purchase); let total = 0; for (const product of purchase.dwPurchaseProducts) { const lineSum = calcProductLineSum(product); const taxAmount = await getApplicableProductTaxes(product, lineSum, purchase); total += lineSum + taxAmount; } const generalTaxAmount = await calcGeneralTaxesAmount(purchase, subtotal); return roundCurrency(total + generalTaxAmount); } /** * Calculates the net amount for a single product line. * cost is expected to already be converted to the purchase document currency. * * @param {Object} purchaseProduct - { quantity, cost } * @returns {number} Line total in purchase currency */ function calcProductLineSum(purchaseProduct) { const qty = Number(purchaseProduct.quantity ?? 1); const cost = Number(purchaseProduct.cost ?? 0); return roundCurrency(qty * cost); } // Taxes /** * Calculates the total tax amount for a single product line. * If business rules are loaded, they are evaluated before falling back * to flat rate calculation. * * @param {Object} purchaseProduct - { taxes: DwPurchaseTax[] } * @param {number} lineSum - Pre-calculated net amount for this line * @param {Object} purchase - Full purchase object (used for rule context) * @returns {Promise<number>} Total tax amount for this line */ async function getApplicableProductTaxes(purchaseProduct, lineSum, purchase) { const productTaxes = purchaseProduct.taxes ?? []; if (productTaxes.length === 0) return 0; const ruleTriggers = ruleManager ? (ruleManager.getRulesForTrigger('calculate_taxes') || []) : []; let totalTax = 0; for (const tax of productTaxes) { let adjustedRate = Number(tax.rate ?? 0); let taxAmount = 0; if (ruleTriggers.length > 0 && ruleEngine) { const context = buildTaxRuleContext(purchase, tax, lineSum); try { const results = await ruleEngine.executeRules(ruleTriggers, context); const applied = extractRuleResult(results); if (applied !== null) { if (applied.rate !== undefined) adjustedRate = parseFloat(applied.rate); if (applied.amount !== undefined) taxAmount = parseFloat(applied.amount); } } catch (err) { console.error(`[purchase-calculator] Rule error for tax ${tax.abbreviation}:`, err); } } totalTax += taxAmount > 0 ? roundCurrency(taxAmount) : roundCurrency(lineSum * (adjustedRate / 100)); } return roundCurrency(totalTax); } /** * Calculates the total amount of general taxes applied over the full subtotal. * General taxes live at purchase.generalTaxes[]. * RuleEngine is applied if rules are loaded. * * @param {Object} purchase - Purchase object containing generalTaxes[] * @param {number} subtotal - Pre-calculated subtotal to apply taxes over * @returns {Promise<number>} Total general tax amount */ async function calcGeneralTaxesAmount(purchase, subtotal) { const generalTaxes = purchase.generalTaxes ?? []; if (generalTaxes.length === 0) return 0; const ruleTriggers = ruleManager ? (ruleManager.getRulesForTrigger('calculate_taxes') || []) : []; let totalTax = 0; for (const tax of generalTaxes) { let adjustedRate = Number(tax.rate ?? 0); let taxAmount = 0; if (ruleTriggers.length > 0 && ruleEngine) { const context = buildTaxRuleContext(purchase, tax, subtotal); try { const results = await ruleEngine.executeRules(ruleTriggers, context); const applied = extractRuleResult(results); if (applied !== null) { if (applied.rate !== undefined) adjustedRate = parseFloat(applied.rate); if (applied.amount !== undefined) taxAmount = parseFloat(applied.amount); } } catch (err) { console.error(`[purchase-calculator] Rule error for general tax ${tax.abbreviation}:`, err); } } totalTax += taxAmount > 0 ? roundCurrency(taxAmount) : roundCurrency(subtotal * (adjustedRate / 100)); } return roundCurrency(totalTax); } /** * Returns all taxes consolidated by key (tax id + abbreviation + adjusted rate), * with is_general flag. Respects RuleEngine adjustments — output is consistent * with calcTotal. Used to build taxLines in the UI summary. * * @param {Object} purchase - Purchase object * @returns {Promise<Array<{ id, abbreviation, rate, amount, is_general }>>} */ async function getConsolidatedTaxes(purchase) { validatePurchaseStructure(purchase); const subtotal = calcSubtotal(purchase); const ruleTriggers = ruleManager ? (ruleManager.getRulesForTrigger('calculate_taxes') || []) : []; const taxMap = new Map(); const addToMap = (key, entry) => { const prev = taxMap.get(key); if (!prev) { taxMap.set(key, { ...entry }); } else { taxMap.set(key, { ...prev, amount: roundCurrency(prev.amount + entry.amount) }); } }; // Product-specific taxes for (const product of purchase.dwPurchaseProducts) { const lineSum = calcProductLineSum(product); const productTaxes = product.taxes ?? []; for (const tax of productTaxes) { let adjustedRate = Number(tax.rate ?? 0); let taxAmount = 0; if (ruleTriggers.length > 0 && ruleEngine) { const context = buildTaxRuleContext(purchase, tax, lineSum); try { const results = await ruleEngine.executeRules(ruleTriggers, context); const applied = extractRuleResult(results); if (applied !== null) { if (applied.rate !== undefined) adjustedRate = parseFloat(applied.rate); if (applied.amount !== undefined) taxAmount = parseFloat(applied.amount); } } catch (err) { console.error(`[purchase-calculator] Rule error in consolidation for ${tax.abbreviation}:`, err); } } const finalAmount = taxAmount > 0 ? roundCurrency(taxAmount) : roundCurrency(lineSum * (adjustedRate / 100)); const key = `P|${tax.tax_id ?? tax.id}|${tax.abbreviation}|${adjustedRate}`; addToMap(key, { id: tax.tax_id ?? tax.id ?? null, abbreviation: String(tax.abbreviation ?? ''), rate: adjustedRate, amount: finalAmount, is_general: false, }); } } // General taxes for (const tax of purchase.generalTaxes ?? []) { let adjustedRate = Number(tax.rate ?? 0); let taxAmount = 0; if (ruleTriggers.length > 0 && ruleEngine) { const context = buildTaxRuleContext(purchase, tax, subtotal); try { const results = await ruleEngine.executeRules(ruleTriggers, context); const applied = extractRuleResult(results); if (applied !== null) { if (applied.rate !== undefined) adjustedRate = parseFloat(applied.rate); if (applied.amount !== undefined) taxAmount = parseFloat(applied.amount); } } catch (err) { console.error(`[purchase-calculator] Rule error in consolidation for general tax ${tax.abbreviation}:`, err); } } const finalAmount = taxAmount > 0 ? roundCurrency(taxAmount) : roundCurrency(subtotal * (adjustedRate / 100)); const key = `G|${tax.tax_id ?? tax.id}|${tax.abbreviation}|${adjustedRate}`; addToMap(key, { id: tax.tax_id ?? tax.id ?? null, abbreviation: String(tax.abbreviation ?? ''), rate: adjustedRate, amount: finalAmount, is_general: true, }); } return Array.from(taxMap.values()).filter(t => t.amount > 0); } /** * Main entry point. Calculates all purchase totals in a single pass. * Subtotal is computed once and shared across all internal calculations * to avoid redundant iterations. * * @param {Object} purchase - Purchase object containing: * - company_id {number} * - branch_id {number} * - currency_iso {string} * - dwPurchaseProducts {Array<{ product_id, quantity, cost, taxes[] }>} * - generalTaxes {Array<DwPurchaseTax>} (optional) * @returns {Promise<{ subtotal: number, total: number, taxLines: Array }>} */ async function calculatePurchaseTotals(purchase) { validatePurchaseStructure(purchase); const subtotal = calcSubtotal(purchase); let productLinesTotal = 0; for (const product of purchase.dwPurchaseProducts) { const lineSum = calcProductLineSum(product); const taxAmount = await getApplicableProductTaxes(product, lineSum, purchase); productLinesTotal += lineSum + taxAmount; } const generalTaxAmount = await calcGeneralTaxesAmount(purchase, subtotal); const total = roundCurrency(productLinesTotal + generalTaxAmount); const taxLines = await getConsolidatedTaxes(purchase); return { subtotal, total, taxLines }; } // Rule Helpers /** * Builds the context object passed to the RuleEngine for tax evaluation. * @param {Object} purchase - Full purchase object * @param {Object} tax - Tax being evaluated * @param {number} baseAmount - Amount the tax rate will be applied to * @returns {Object} Rule execution context */ function buildTaxRuleContext(purchase, tax, baseAmount) { return { purchase: { ...purchase, subtotal: calcSubtotal(purchase), }, tax: { ...tax, tax_id: tax.tax_id ?? tax.id ?? null, amount: 0, base_amount: baseAmount, branch_id: purchase.branch_id, company_id: purchase.company_id, }, }; } /** * Extracts the first successful rule result that contains rate or amount. * Supports multiple result shapes from the RuleEngine output. * * @param {Array} ruleResults - Array of rule execution results * @returns {{ rate?: number, amount?: number } | null} */ function extractRuleResult(ruleResults) { for (const result of ruleResults) { if (!result.success || !result.data) continue; const rd = result.data; const rate = rd.result?.tax?.rate ?? rd['tax.rate'] ?? rd.rate ?? undefined; const amount = rd.result?.tax?.amount ?? rd['tax.amount'] ?? rd.amount ?? undefined; if (rate !== undefined || amount !== undefined) { return { rate, amount }; } } return null; } // Currency Exchange /** * Converts an amount between two currencies using company exchange rates * or fallback currency converter rates. * * @param {number} amount - Amount to convert * @param {string} fromCurrency - ISO code (e.g. 'MXN') * @param {string} toCurrency - ISO code (e.g. 'USD') * @returns {number} Converted amount * @throws {Error} If no exchange rate is found for the currency pair */ function exchange(amount, fromCurrency, toCurrency) { if (fromCurrency === toCurrency) return roundCurrency(amount); const rate = findExchangeRate(fromCurrency, toCurrency); if (rate) return roundCurrency(applyExchangeStrategy(amount, rate)); const converterRate = findCurrencyConverterRate(fromCurrency, toCurrency); if (!converterRate) { throw new Error(`[purchase-calculator] No exchange rate found: ${fromCurrency}${toCurrency}`); } return roundCurrency(amount * converterRate.rate); } /** * Finds a company-defined exchange rate for the given currency pair. * @param {string} from - Source currency ISO * @param {string} to - Target currency ISO * @returns {ExchangeRate|null} */ function findExchangeRate(from, to) { return Object.values(exchangeRates).find( r => r.from_currency_iso === from && r.to_currency_iso === to ) || null; } /** * Finds a currency converter rate for the given currency pair. * Used as fallback when no company exchange rate exists. * @param {string} from - Source currency ISO * @param {string} to - Target currency ISO * @returns {CurrencyConverterRate|null} */ function findCurrencyConverterRate(from, to) { return Object.values(currencyConverterRates).find( r => r.from_currency_iso === from && r.to_currency_iso === to ) || null; } /** * Applies the exchange rate strategy: (amount * to_value) / from_value * @param {number} amount - Amount to convert * @param {Object} exchangeRate - Exchange rate model * @returns {number} Converted amount */ function applyExchangeStrategy(amount, exchangeRate) { return (amount * exchangeRate.to_currency_value) / exchangeRate.from_currency_value; } // Rounding /** * Rounds or truncates a currency value based on configuration. * Default behavior is truncation (useRounding = false), matching * the sale calculator behavior. * * @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('[purchase-calculator] roundCurrency: invalid input', value); return 0; } if (Math.abs(value) < Number.EPSILON) return 0; const shouldRound = overrideRounding !== undefined ? overrideRounding : useRounding; const result = shouldRound ? Math.round(value * roundingFactor) / roundingFactor : Math.trunc(value * roundingFactor) / roundingFactor; return parseFloat(result.toFixed(decimalPlaces)); } // Exports module.exports = { init, calcSubtotal, calcTotal, calculatePurchaseTotals, getConsolidatedTaxes, _internals: { calcProductLineSum, getApplicableProductTaxes, calcGeneralTaxesAmount, exchange, roundCurrency, }, };