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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user