softseti-sale-calculator-library
Version:
Sales calculation engine by Softseti
557 lines (474 loc) • 18.8 kB
JavaScript
// 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,
},
};