feat: implement business pricing service and money utilities

- Added `businessPricing.service.js` to handle business customer pricing logic.
- Introduced `money.js` for money parsing, formatting, and calculations.
- Created `pricing.service.js` to manage variant quoting and promotion application.
- Updated validation schemas in `catalogue.schemas.js` and `inventoryMerchandising.schemas.js` for new pricing and inventory features.
- Implemented cron job for inventory reservation expiry in `inventoryReservationExpiry.cron.js`.
- Created database migrations for catalogue and inventory structures.
- Added unit tests for catalogue localization, validation, and inventory merchandising functionalities.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 19:25:13 +05:30
parent fd4e324571
commit 5643d89236
75 changed files with 663 additions and 1 deletions
@@ -0,0 +1,15 @@
const { Op } = require("sequelize"); const db = require("../../models");
const activeAt = at => ({ status: "ACTIVE", [Op.and]: [{ [Op.or]: [{ starts_at: null }, { starts_at: { [Op.lte]: at } }] }, { [Op.or]: [{ ends_at: null }, { ends_at: { [Op.gt]: at } }] }] });
async function resolvePrice({ businessCustomer, variant, quantity = 1, currency = variant.currency, at = new Date() }) {
const retail = { source: "RETAIL", unitPrice: String(variant.base_price), minimumQuantity: 1, volumeTier: null };
if (!businessCustomer || businessCustomer.status !== "ACTIVE" || !businessCustomer.approvedAt) return retail;
const common = { variant_id: variant.id, currency, ...activeAt(at) };
let rule = await db.VariantBusinessPrice.findOne({ where: { ...common, business_customer_id: businessCustomer.business_customer_id }, order: [["createdAt", "DESC"]] });
let source = "CUSTOMER";
if (!rule && businessCustomer.business_tier_id) { rule = await db.VariantBusinessPrice.findOne({ where: { ...common, business_customer_id: null, business_tier_id: businessCustomer.business_tier_id }, order: [["createdAt", "DESC"]] }); source = "TIER"; }
if (!rule) return retail;
if (quantity < rule.minimum_quantity) throw Object.assign(new Error(`Minimum quantity is ${rule.minimum_quantity}`), { code: "MOQ_NOT_MET", status: 400 });
const tier = await db.BusinessPriceTier.findOne({ where: { business_price_id: rule.id, min_quantity: { [Op.lte]: quantity }, [Op.or]: [{ max_quantity: null }, { max_quantity: { [Op.gte]: quantity } }] }, order: [["min_quantity", "DESC"]] });
return { source, unitPrice: String(tier ? tier.unit_price : rule.price), minimumQuantity: rule.minimum_quantity, volumeTier: tier ? { minQuantity: tier.min_quantity, maxQuantity: tier.max_quantity, unitPrice: String(tier.unit_price) } : null };
}
module.exports = { resolvePrice };
+5
View File
@@ -0,0 +1,5 @@
const parse = value => { const match = String(value).match(/^(\d+)(?:\.(\d{1,2}))?$/); if (!match) throw Object.assign(new Error("Invalid money"), { code: "INVALID_MONEY", status: 400 }); return BigInt(match[1]) * 100n + BigInt((match[2] || "").padEnd(2, "0")); };
const format = cents => `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`;
const subtractFloor = (a, b) => format(parse(a) > parse(b) ? parse(a) - parse(b) : 0n);
const percentOff = (amount, percent) => { const [whole,fraction=""] = String(percent).split("."); const basis = BigInt(whole) * 100n + BigInt(fraction.padEnd(2,"0")); return format(parse(amount) * basis / 10000n); };
module.exports = { parse, format, subtractFloor, percentOff };
+14
View File
@@ -0,0 +1,14 @@
const db = require("../../models"); const business = require("./businessPricing.service"); const money = require("./money");
const applies = (promotion, quantity, at) => promotion.status === "ACTIVE" && (!promotion.starts_at || new Date(promotion.starts_at) <= at) && (!promotion.ends_at || new Date(promotion.ends_at) > at) && (!promotion.minimum_quantity || quantity >= promotion.minimum_quantity);
const discount = (price, promotion) => promotion.type === "PERCENTAGE" ? money.percentOff(price, promotion.discount_percent) : promotion.type === "FIXED_AMOUNT" ? String(promotion.discount_amount) : money.subtractFloor(price, promotion.fixed_price);
async function quoteVariant({ variantId, businessCustomer, quantity = 1, couponCode, at = new Date() }) {
const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" } }); if (!variant) throw Object.assign(new Error("Variant not found"), { code: "VARIANT_NOT_FOUND", status: 404 });
const base = await business.resolvePrice({ businessCustomer, variant, quantity, at });
const promotions = await db.Promotion.findAll({ where: { status: "ACTIVE" }, order: [["priority", "DESC"], ["id", "ASC"]] });
const automatic = promotions.filter(p => applies(p, quantity, at))[0] || null; let effective = base.unitPrice; let automaticDiscount = "0.00";
if (automatic) { automaticDiscount = discount(effective, automatic); effective = money.subtractFloor(effective, automaticDiscount); }
let coupon = null;
if (couponCode) { coupon = await db.Coupon.findOne({ where: { code: couponCode.trim().toUpperCase(), status: "ACTIVE" }, include: [{ model: db.Promotion, as: "promotion" }] }); if (!coupon || (coupon.starts_at && new Date(coupon.starts_at) > at) || (coupon.expires_at && new Date(coupon.expires_at) <= at) || !applies(coupon.promotion, quantity, at)) throw Object.assign(new Error("Coupon is not valid"), { code: "INVALID_COUPON", status: 400 }); if (!automatic || automatic.stackable) effective = money.subtractFloor(effective, discount(effective, coupon.promotion)); }
return { cataloguePrice: String(variant.base_price), businessPrice: base.source === "RETAIL" ? null : base.unitPrice, baseEligiblePrice: base.unitPrice, effectiveUnitPrice: effective, currency: variant.currency, minimumQuantity: base.minimumQuantity, automaticPromotion: automatic && { id: automatic.id, name: automatic.name }, coupon: coupon && { code: coupon.code } };
}
module.exports = { quoteVariant, applies, discount };