b-b-calculations
Version:
A cart calculation engine for buffalo burger restaurants
361 lines (349 loc) • 15.2 kB
JavaScript
;
/**
* Calculates the sum of net prices and total prices for a list of cart items.
*
* @param {Isumcart[]} items - Array of items, each containing:
* - `netPrice`: Net price for the item
* - `totalPrice`: Total price for the item including VAT
*
* @returns {ICartTotalsResults} Object containing:
* - `itemsNetPrice`: Sum of all net prices
* - `itemsTotalPrice`: Sum of all total prices
*
* @example
* const cartItems = [
* { netPrice: 100, totalPrice: 114 },
* { netPrice: 50, totalPrice: 57 }
* ];
*
* const totals = sumCart(cartItems);
* console.log(totals);
* // output { itemsTotalPrice: 171, itemsNetPrice: 150 }
*/
function sumCart(items) {
return items.reduce((sum, i) => ({
itemsTotalPrice: sum.itemsTotalPrice + i.totalPrice,
itemsNetPrice: sum.itemsNetPrice + i.netPrice,
}), { itemsTotalPrice: 0, itemsNetPrice: 0 });
}
/**
* Calculates the total net price and total price for a list of cart items.
*
* @param {ICartItem[]} items - Array of cart items, each with `requiredNetPrice`, `requiredTotalPrice`, and `quantity`.
* @returns {IPricedCartItem} Object containing:
* - `netPrice`: Sum of (requiredNetPrice × quantity) for all items
* - `totalPrice`: Sum of (requiredTotalPrice × quantity) for all items
*
* @example
* calcItemPrices([
* { requiredNetPrice: 10, requiredTotalPrice: 12, quantity: 2 },
* { requiredNetPrice: 5, requiredTotalPrice: 6, quantity: 1 }
* ]);
* // output: { netPrice: 25, totalPrice: 30 }
*/
function calcItemPrices(items) {
return (items || []).reduce((acc, e) => {
acc.netPrice += e.requiredNetPrice * e.quantity;
acc.totalPrice += e.requiredTotalPrice * e.quantity;
return acc;
}, { netPrice: 0, totalPrice: 0 });
}
/**
* Combines priced items and priced offers into a single array, ignoring undefined values.
*
* @param {PricedCartItem} [pricedItems] - Calculated prices for regular cart items.
* @param {PricedCartItem} [pricedOffers] - Calculated prices for offers.
* @returns {PricedCartItem[]} Array containing all defined priced items/offers.
*
* @example
* collectPricedCartItems(
* { netPrice: 20, totalPrice: 25 },
* undefined
* );
* // output: [{ netPrice: 20, totalPrice: 25 }]
*/
function collectPricedCartItems(pricedItems, pricedOffers) {
return [pricedItems, pricedOffers].filter((x) => x !== undefined);
}
/**
* Applies discounts in a specific order: first the promocode discount, then the loyalty discount.
* Ensures that neither discount exceeds the remaining total at its step.
*
* @param {number} itemsNetPrice - The total net price of items before applying any discounts.
* @param {number} [promoCodeDiscount=0] - The discount amount from a promocode (default is 0).
* @param {number} [loyaltyDiscount=0] - The discount amount from loyalty points/balance (default is 0).
*
* @returns {{ appliedPromoCode: number, appliedLoyalty: number, netAfterDiscounts: number }}
* An object containing:
* - `appliedPromoCode`: The actual applied promocode discount.
* - `appliedLoyalty`: The actual applied loyalty discount.
* - `netAfterDiscounts`: The net total after both discounts.
*
* @example
* // Example 1: Both discounts fully applicable
* const result1 = applyDiscountsInOrder(200, 50, 30);
* console.log(result1);
* // ➜ { appliedPromoCode: 50, appliedLoyalty: 30, netAfterDiscounts: 120 }
*
* @example
* // Example 2: Promocode discount exceeds totalNetPrice
* const result2 = applyDiscountsInOrder(100, 150, 50);
* console.log(result2);
* // ➜ { appliedPromoCode: 100, appliedLoyalty: 0, netAfterDiscounts: 0 }
*
* @example
* // Example 3: Loyalty discount exceeds remaining after promo
* const result3 = applyDiscountsInOrder(100, 30, 80);
* console.log(result3);
* // ➜ { appliedPromoCode: 30, appliedLoyalty: 70, netAfterDiscounts: 0 }
*/
function applyDiscountsInOrder(itemsNetPrice, promoCodeDiscount = 0, loyaltyDiscount = 0) {
// Step 1: Apply promo code discount (cannot exceed itemsNetPrice)
const appliedPromoCode = Math.min(promoCodeDiscount, itemsNetPrice);
const afterPromoCode = itemsNetPrice - appliedPromoCode;
// Step 2: Apply loyalty discount (cannot exceed what's left)
const appliedLoyalty = Math.min(loyaltyDiscount, afterPromoCode);
const afterLoyalty = afterPromoCode - appliedLoyalty;
return {
appliedPromoCode,
appliedLoyalty,
itemsNetPriceAfterDiscount: afterLoyalty
};
}
/**
* Calculates detailed VAT breakdown including:
* - Average VAT percentage.
* - VAT on additional charges.
* - VAT on the net price after discounts.
* - Total VAT amount.
*
* @param {number} itemsNetPrice - Total net price of items before VAT.
* @param {number} itemsTotalPrice - Total price of items including VAT.
* @param {number} dineinExtraCharge - Extra charge for dine-in service.
* @param {number} effectiveDeliveryFeesEgp - Delivery fees in EGP.
* @param {number} appliedPromoCode - Applied promocode discount amount.
* @param {number} appliedLoyalty - Applied loyalty discount amount.
*
* @returns {{
* avgVat: number,
* vatOnCharges: number,
* netPriceAfterDiscountVat: number,
* totalVat: number
* }} An object containing VAT breakdown.
*
* @example
* const vatDetails = calculateVatDetails(100, 114, 10, 0, 5, 0);
* console.log(vatDetails);
* // ➜ {
* // avgVat: 0.14,
* // vatOnCharges: 1.4,
* // netPriceAfterDiscountVat: 13.3,
* // totalVat: 14.7
* // }
*/
function calculateVatDetails(data) {
const vatableFees = (data.dineinExtraCharge ?? 0) + (data.effectiveDeliveryFeesEgp ?? 0);
const vatOnCharges = vatableFees * 0.14;
const VatOnItems = data.itemsNetPriceAfterDiscount > 0 ? data.itemsNetPriceAfterDiscount * 0.14 : 0;
return {
vatOnCharges,
netPriceAfterDiscountVat: VatOnItems,
totalVat: VatOnItems + vatOnCharges
};
}
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
function validateMenuItem(item, index) {
if (!item || typeof item !== 'object') {
throw new ValidationError(`menuItems[${index}] must be a valid object.`);
}
if (typeof item.quantity !== 'number' || item.quantity < 1) {
throw new ValidationError(`menuItems[${index}].quantity must be a positive number.`);
}
if (typeof item.requiredNetPrice !== 'number' || item.requiredNetPrice < 1) {
throw new ValidationError(`menuItems[${index}].requiredNetPrice must be a non-negative number.`);
}
if (typeof item.requiredNetPrice !== 'number' || item.requiredNetPrice < 1) {
throw new ValidationError(`menuItems[${index}].requiredNetPrice must be a non-negative number.`);
}
}
function validateOfferItem(offer, index) {
if (!offer || typeof offer !== 'object') {
throw new ValidationError(`offers[${index}] must be a valid object.`);
}
if (typeof offer.quantity !== 'number' || offer.quantity < 1) {
throw new ValidationError(`offers[${index}].quantity must be a positive number.`);
}
if (typeof offer.requiredNetPrice !== 'number' || offer.requiredNetPrice < 1) {
throw new ValidationError(`offers[${index}].requiredNetPrice must be a non-negative number.`);
}
if (typeof offer.requiredNetPrice !== 'number' || offer.requiredNetPrice < 1) {
throw new ValidationError(`offers[${index}].requiredNetPrice must be a non-negative number.`);
}
}
function validateICheckoutConfig(config) {
if (!config)
throw new ValidationError('Checkout config is required.');
const { cartMenuItems, cartOffers, deliveryFeesEgp, loyaltyBalance, dineinExtraCharge, deliveryType, appType, coupon } = config;
if (!Array.isArray(cartMenuItems) && !Array.isArray(cartOffers)) {
throw new ValidationError('At least one of cartMenuItems or cartOffers must be provided.');
}
if (cartMenuItems) {
if (!Array.isArray(cartMenuItems))
throw new ValidationError('cartMenuItems must be an array.');
cartMenuItems.forEach(validateMenuItem);
}
if (cartOffers) {
if (!Array.isArray(cartOffers))
throw new ValidationError('cartOffers must be an array.');
cartOffers.forEach(validateOfferItem);
}
// Default loyaltyBalance to 0 if not provided or invalid
if (loyaltyBalance !== undefined && (typeof loyaltyBalance !== 'number' || loyaltyBalance < 0)) {
throw new ValidationError('loyaltyBalance must be a non-negative number.');
}
if (![1, 2, 3].includes(deliveryType)) {
throw new ValidationError('deliveryType must be a valid ID (1 = DELIVERY, 2 = PICKUP, 3 = DINEIN).');
}
if (deliveryFeesEgp !== undefined && (typeof deliveryFeesEgp !== 'number' || deliveryFeesEgp < 0)) {
throw new ValidationError('deliveryFeesEgp must be a non-negative number.');
}
if (dineinExtraCharge !== undefined && (typeof dineinExtraCharge !== 'number' || dineinExtraCharge < 0)) {
throw new ValidationError('dineinExtraCharge must be a non-negative number.');
}
const hasDinein = (dineinExtraCharge ?? 0) > 0;
const hasDelivery = (deliveryFeesEgp ?? 0) > 0;
if (hasDinein && hasDelivery) {
throw new ValidationError('Cannot have both dine-in charge and delivery fees in the same order.');
}
if (appType !== undefined && ![1, 2, 3, 10].includes(appType)) {
throw new ValidationError('appType must be one of: 1 (mobile), 2 (web), 3 (kiosk), 10 (all).');
}
if (coupon !== null) {
if (typeof coupon !== 'object' || typeof coupon.valid !== 'boolean' || typeof coupon.data !== 'object' || coupon.data === null) {
throw new ValidationError('coupon must be a valid IPromocodeConfig object.');
}
}
}
const DELIVERY_TYPES = {
DELIVERY: 1,
PICKUP: 2,
DINEIN: 3,
};
/**
* Calculates the discount value from a given promocode (coupon) based on its configuration
* and the current checkout context.
*
* @param {IPromocodeConfig | undefined} coupon - The promocode configuration object (or undefined if none is applied).
* @param {number} itemsNetPrice - The total net price of items in the cart (before VAT and fees).
* @param {number} deliveryFeesEgp - Delivery fees in EGP.
* @param {string} deliveryType - The type of delivery ("DELIVERY", "PICKUP", "DINEIN", etc.).
* @param {number} appType - Numeric code representing the app type (mapped in `appTypeMap`).
*
* @returns {number} The calculated discount amount in EGP. Returns 0 if the promocode is not valid or does not match conditions.
*
* @example
* const coupon = {
* valid: true,
* data: {
* is_active: true,
* start_date: Date.now() - 1000,
* end_date: Date.now() + 100000,
* min_basket: 100,
* delivery_type: 'all',
* discount_type: 'percentage',
* discount_value: 10
* }
* };
*
* const discount = calculatePromocodeValue(coupon, 200, 20, 'DELIVERY', 1);
* console.log(discount);
* // output 20 (10% of 200)
*/
function calculatePromocodeValue(coupon, itemsNetPrice, deliveryFeesEgp, deliveryType, appType) {
const now = Date.now();
if (!coupon?.valid ||
!coupon.data?.is_active ||
coupon.data.start_date > now ||
now > coupon.data.end_date)
return 0;
const couponData = coupon.data;
const meetsMinBasket = itemsNetPrice >= (couponData.min_basket || 0);
const matchesDeliveryType = couponData.delivery_type === 'all' ||
(couponData.delivery_type === 'delivery_only' && deliveryType === DELIVERY_TYPES.DELIVERY) ||
(couponData.delivery_type === 'pickup_only' && deliveryType === DELIVERY_TYPES.PICKUP) ||
(couponData.delivery_type === 'dinein_only' && deliveryType === DELIVERY_TYPES.DINEIN);
let matchesAppType = true;
if (appType && couponData.allowedAppTypeId) {
matchesAppType = appType === couponData.allowedAppTypeId;
}
if (!meetsMinBasket || !matchesDeliveryType || !matchesAppType)
return 0;
switch (couponData.discount_type) {
case 'percentage':
return (itemsNetPrice * couponData.discount_value) / 100;
case 'fixed':
case 'absolute':
return couponData.discount_value;
case 'delivery_free':
return deliveryFeesEgp;
default:
return 0;
}
}
function calculateFinalTotal({ totalVat, itemsNetPriceAfterDiscount, dineinExtraCharge, deliveryFeesEgp }) {
return totalVat + itemsNetPriceAfterDiscount + deliveryFeesEgp + dineinExtraCharge;
}
function calculateSubTotalVat(itemsTotalPrice, itemsNetPrice) {
return itemsTotalPrice - itemsNetPrice;
}
const to2 = (n) => Number(n.toFixed(2));
function checkout(config) {
validateICheckoutConfig(config);
const { cartMenuItems, cartOffers, deliveryFeesEgp = 0, loyaltyBalance = 0, dineinExtraCharge = 0, coupon, deliveryType, appType } = config;
const menuItemsTotal = cartMenuItems ? calcItemPrices(cartMenuItems) : undefined;
const offersTotal = cartOffers ? calcItemPrices(cartOffers) : undefined;
const allItemTotals = collectPricedCartItems(menuItemsTotal, offersTotal);
const { itemsNetPrice, itemsTotalPrice } = sumCart(allItemTotals);
const subtotalVat = calculateSubTotalVat(itemsTotalPrice, itemsNetPrice);
const isDeliveryFree = !!coupon?.data && coupon.data.discount_type === 'delivery_free';
const allowLoyalty = !(coupon?.data && !coupon.data.allow_loyalty);
let validLoyaltyDiscount = allowLoyalty ? loyaltyBalance : 0;
const effectiveDeliveryFeesEgp = isDeliveryFree ? 0 : deliveryFeesEgp;
const promocodeValueEgp = isDeliveryFree
? 0
: calculatePromocodeValue(coupon ?? null, itemsNetPrice, deliveryFeesEgp, deliveryType, appType ?? 10);
const { appliedPromoCode, appliedLoyalty, itemsNetPriceAfterDiscount } = applyDiscountsInOrder(itemsNetPrice, promocodeValueEgp, validLoyaltyDiscount);
const { totalVat } = calculateVatDetails({
itemsNetPriceAfterDiscount,
itemsTotalPrice,
dineinExtraCharge,
effectiveDeliveryFeesEgp,
appliedPromoCode,
appliedLoyalty,
});
const finalTotal = calculateFinalTotal({ totalVat, itemsNetPriceAfterDiscount, dineinExtraCharge, deliveryFeesEgp: effectiveDeliveryFeesEgp });
const promoShown = isDeliveryFree ? deliveryFeesEgp : appliedPromoCode;
return {
subtotal: to2(itemsNetPrice),
subtotalWithVat: to2(itemsTotalPrice),
loyaltyDiscountAmount: to2(appliedLoyalty),
promocodeDiscountAmount: to2(promoShown),
subtotalVat: to2(subtotalVat),
totalVat: to2(totalVat),
finalTotal: to2(finalTotal),
};
}
exports.ValidationError = ValidationError;
exports.applyDiscountsInOrder = applyDiscountsInOrder;
exports.calcItemPrices = calcItemPrices;
exports.calculatePromocodeValue = calculatePromocodeValue;
exports.calculateVatDetails = calculateVatDetails;
exports.checkout = checkout;
exports.collectPricedCartItems = collectPricedCartItems;
exports.sumCart = sumCart;
exports.validateICheckoutConfig = validateICheckoutConfig;