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