softseti-sale-calculator-library
Version:
Sales calculation engine by Softseti
1,031 lines (872 loc) • 34.6 kB
JavaScript
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; }
}
};