UNPKG

b-b-calculations

Version:

A cart calculation engine for buffalo burger restaurants

361 lines (349 loc) 15.2 kB
'use strict'; /** * 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;