UNPKG

b-b-calculations

Version:

A cart calculation engine for buffalo burger restaurants

378 lines (366 loc) 15.6 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 {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;