b-b-calculations
Version:
A cart calculation engine for buffalo burger restaurants
378 lines (366 loc) • 15.6 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 {CartTotalsResults} 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 {Item[]} items - Array of cart items, each with `requiredNetPrice`, `requiredTotalPrice`, and `quantity`.
* @returns {PricedCartItem} 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} totalNetPrice - The total net price 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(totalNetPrice, promoCodeDiscount = 0, loyaltyDiscount = 0) {
// Step 1: Apply promo code discount (cannot exceed totalNetPrice)
const appliedPromoCode = Math.min(promoCodeDiscount, totalNetPrice);
const afterPromoCode = totalNetPrice - appliedPromoCode;
// Step 2: Apply loyalty discount (cannot exceed what's left)
const appliedLoyalty = Math.min(loyaltyDiscount, afterPromoCode);
const afterLoyalty = afterPromoCode - appliedLoyalty;
return {
appliedPromoCode,
appliedLoyalty,
netAfterDiscounts: 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) {
// Prevent division by zero - if totalNetPrice is 0, avgVat should be 0
const avgVat = data.itemsNetPrice > 0 ? (data.itemsTotalPrice - data.itemsNetPrice) / data.itemsNetPrice : 0;
// Prevent negative net price after discounts
const totalNetPriceAfterDiscount = Math.max(data.itemsNetPrice - (data.appliedPromoCode ?? 0) - (data.appliedLoyalty ?? 0), 0);
const vatableFees = data.dineinExtraCharge || data.effectiveDeliveryFeesEgp || 0;
const vatOnCharges = vatableFees * 0.14;
const netPriceAfterDiscountVat = totalNetPriceAfterDiscount * avgVat;
return {
avgVat,
vatOnCharges,
netPriceAfterDiscountVat,
totalVat: netPriceAfterDiscountVat + vatOnCharges
};
}
function calculateFinalTotal({ itemsTotalPrice, dineinExtraChargeWithVat, effectiveDeliveryFeesEgp, loyaltyDiscount, promocodeDiscount, }) {
const charges = (dineinExtraChargeWithVat ?? 0) + (effectiveDeliveryFeesEgp ?? 0);
const totalBeforeDiscount = itemsTotalPrice + charges;
const afterPromo = Math.max(0, totalBeforeDiscount - (promocodeDiscount ?? 0));
const afterLoyalty = Math.max(0, afterPromo - (loyaltyDiscount ?? 0));
return afterLoyalty;
}
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 validateCheckoutConfig(config) {
if (!config)
throw new ValidationError('Checkout config is required.');
const { cartMenuItems, cartOffers, deliveryFeesEgp, loyaltyBalance, dineinExtraCharge, } = 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 (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.');
}
}
const appTypeMap = {
1: 'mobile',
2: 'web',
3: 'kiosk',
10: 'all',
};
/**
* 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} totalNetPrice - 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, totalNetPrice, 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 = totalNetPrice >= (couponData.min_basket || 0);
const matchesDeliveryType = couponData.delivery_type === 'all' ||
(couponData.delivery_type === 'delivery_only' && deliveryType === 'DELIVERY') ||
(couponData.delivery_type === 'pickup_only' && deliveryType === 'PICKUP') ||
(couponData.delivery_type === 'dinein_only' && deliveryType === 'DINEIN');
let matchesAppType = true;
if (appType && couponData.allowedAppTypeId) {
const allowedAppTypeKey = couponData.allowedAppTypeId;
matchesAppType = appTypeMap[allowedAppTypeKey] === appTypeMap[appType];
}
if (!meetsMinBasket || !matchesDeliveryType || !matchesAppType)
return 0;
switch (couponData.discount_type) {
case 'percentage':
return (totalNetPrice * couponData.discount_value) / 100;
case 'fixed':
case 'absolute':
return couponData.discount_value;
case 'delivery_free':
return deliveryFeesEgp;
default:
return 0;
}
}
exports.deliveryType = void 0;
(function (deliveryType) {
deliveryType["DELIVERY"] = "DELIVERY";
deliveryType["PICKUP"] = "PICKUP";
deliveryType["DINEIN"] = "DINEIN";
})(exports.deliveryType || (exports.deliveryType = {}));
function checkout(config) {
validateCheckoutConfig(config);
let dineinExtraChargeWithVat = 0;
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 = itemsTotalPrice - itemsNetPrice;
const promocodeValueEgp = calculatePromocodeValue(coupon, itemsNetPrice, deliveryFeesEgp, deliveryType, appType);
let validLoyaltyDiscount = loyaltyBalance;
let effectiveDeliveryFeesEgp = 0;
if (coupon && coupon?.data && !coupon?.data.allow_loyalty) {
validLoyaltyDiscount = 0;
}
if (coupon && coupon?.data && coupon?.data?.discount_type === 'delivery_free') {
effectiveDeliveryFeesEgp = 0;
}
else {
effectiveDeliveryFeesEgp = deliveryFeesEgp;
}
const { appliedPromoCode, appliedLoyalty } = applyDiscountsInOrder(itemsNetPrice, promocodeValueEgp, validLoyaltyDiscount);
const { totalVat } = calculateVatDetails({
itemsNetPrice,
itemsTotalPrice,
dineinExtraCharge,
effectiveDeliveryFeesEgp,
appliedPromoCode,
appliedLoyalty,
});
if (dineinExtraCharge && dineinExtraCharge > 0) {
dineinExtraChargeWithVat = dineinExtraCharge * 1.14;
}
const finalTotal = calculateFinalTotal({
itemsTotalPrice,
dineinExtraChargeWithVat,
effectiveDeliveryFeesEgp,
loyaltyDiscount: appliedPromoCode ?? 0,
promocodeDiscount: appliedLoyalty ?? 0
});
return {
subtotal: Number(itemsNetPrice.toFixed(2)),
subtotalWithVat: Number(itemsTotalPrice.toFixed(2)),
loyaltyDiscountAmount: Number(appliedLoyalty.toFixed(2)),
promocodeDiscountAmount: Number(appliedPromoCode.toFixed(2)),
subtotalVat: Number(subtotalVat.toFixed(2)),
totalVat: Number(totalVat.toFixed(2)),
finalTotal: Number(finalTotal.toFixed(2)),
};
}
exports.ValidationError = ValidationError;
exports.applyDiscountsInOrder = applyDiscountsInOrder;
exports.calcItemPrices = calcItemPrices;
exports.calculateFinalTotal = calculateFinalTotal;
exports.calculatePromocodeValue = calculatePromocodeValue;
exports.calculateVatDetails = calculateVatDetails;
exports.checkout = checkout;
exports.collectPricedCartItems = collectPricedCartItems;
exports.sumCart = sumCart;
exports.validateCheckoutConfig = validateCheckoutConfig;