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,7 @@
const crypto=require("crypto");const db=require("../../models");
const validateMedia=async(ids,userId,transaction)=>{if(!ids.length)return[];const rows=await db.Upload.findAll({where:{id:ids,status:"AVAILABLE"},transaction,lock:transaction?.LOCK?.UPDATE});const imageTypes=new Set(["image/jpeg","image/png","image/webp"]);if(rows.length!==new Set(ids).size||rows.some(x=>!imageTypes.has(x.file_type)||(x.owner_id!==userId&&x.owner_type!=="CATALOGUE")))throw Object.assign(new Error("Catalogue media must be a supported available image owned by the actor"),{status:403,code:"INVALID_MEDIA_OWNERSHIP"});return rows;};
const validatePublish=async(product,transaction)=>{const translation=await db.ProductTranslation.findOne({where:{product_id:product.id,locale:"en"},transaction});const variant=await db.ProductVariant.findOne({where:{product_id:product.id,status:"ACTIVE"},transaction});const media=await db.ProductMedia.findOne({where:{product_id:product.id,is_primary:true},transaction});if(!translation||!product.slug||!product.brand_id||!variant||!media)throw Object.assign(new Error("Publishing requires English content, brand, active variant, and primary media"),{status:409,code:"PUBLISH_REQUIREMENTS_NOT_MET"});};
const createProduct=(actor,body)=>db.sequelize.transaction(async t=>{await validateMedia(body.mediaUploadIds,actor.id,t);const product=await db.Product.create({id:crypto.randomUUID(),brand_id:body.brandId,default_category_id:body.defaultCategoryId||body.categoryIds[0],slug:body.slug,product_code:body.productCode,status:"DRAFT",visibility:"HIDDEN",product_type:body.productType,featured:body.featured,new_arrival_until:body.newArrivalUntil,created_by:actor.id,updated_by:actor.id},{transaction:t});await db.ProductTranslation.bulkCreate(body.translations.map(x=>({id:crypto.randomUUID(),product_id:product.id,locale:x.locale,name:x.name,short_description:x.shortDescription,description:x.description,care_instructions:x.careInstructions,materials:x.materials,origin:x.origin,meta_title:x.metaTitle,meta_description:x.metaDescription})),{transaction:t});await db.ProductCategory.bulkCreate([...new Set(body.categoryIds)].map(category_id=>({id:crypto.randomUUID(),product_id:product.id,category_id})),{transaction:t});await db.ProductVariant.bulkCreate(body.variants.map(x=>({id:crypto.randomUUID(),product_id:product.id,sku:x.sku,barcode:x.barcode,base_price:x.basePrice,compare_at_price:x.compareAtPrice,currency:x.currency,weight:x.weight,sort_order:x.sortOrder})),{transaction:t});if(body.mediaUploadIds.length){await db.ProductMedia.bulkCreate(body.mediaUploadIds.map((upload_id,i)=>({id:crypto.randomUUID(),product_id:product.id,upload_id,type:"IMAGE",sort_order:i,is_primary:i===0})),{transaction:t});await db.Upload.update({owner_type:"CATALOGUE",owner_id:product.id,use_for:"PRODUCT_MEDIA"},{where:{id:body.mediaUploadIds},transaction:t});}return product;});
const setProductState=(id,actor,updates)=>db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});if(product.published_at&&updates.slug&&updates.slug!==product.slug)throw Object.assign(new Error("Published product slugs are immutable"),{status:409,code:"PUBLISHED_SLUG_IMMUTABLE"});if(updates.status==="ACTIVE"||updates.visibility==="PUBLIC")await validatePublish(product,t);const mapped={...(updates.brandId!==undefined&&{brand_id:updates.brandId}),...(updates.defaultCategoryId!==undefined&&{default_category_id:updates.defaultCategoryId}),...(updates.productType!==undefined&&{product_type:updates.productType}),...(updates.newArrivalUntil!==undefined&&{new_arrival_until:updates.newArrivalUntil}),...(updates.slug!==undefined&&{slug:updates.slug}),...(updates.featured!==undefined&&{featured:updates.featured}),...(updates.status!==undefined&&{status:updates.status}),...(updates.visibility!==undefined&&{visibility:updates.visibility})};await product.update({...mapped,updated_by:actor.id,...((updates.status==="ACTIVE"||updates.visibility==="PUBLIC")&&!product.published_at&&{published_at:new Date()})},{transaction:t});return product;});
const assertCategoryParent=async(id,parentId,transaction)=>{if(!parentId)return;if(id===parentId)throw Object.assign(new Error("Category cannot parent itself"),{status:400,code:"CATEGORY_SELF_PARENT"});let cursor=parentId;const seen=new Set([id]);for(let depth=0;cursor&&depth<100;depth++){if(seen.has(cursor))throw Object.assign(new Error("Category hierarchy cycle detected"),{status:409,code:"CATEGORY_CYCLE"});seen.add(cursor);const node=await db.Category.findByPk(cursor,{transaction,attributes:["id","parent_id"]});if(!node)throw Object.assign(new Error("Parent category not found"),{status:400,code:"INVALID_PARENT"});cursor=node.parent_id;}if(cursor)throw Object.assign(new Error("Category hierarchy exceeds safe depth"),{status:400,code:"CATEGORY_DEPTH"});};
module.exports={createProduct,setProductState,validatePublish,validateMedia,assertCategoryParent};
+1
View File
@@ -0,0 +1 @@
const {SUPPORTED_LOCALES,DEFAULT_LOCALE}=require("../../constants/locales");const normalize=v=>SUPPORTED_LOCALES.includes(String(v||"").slice(0,2).toLowerCase())?String(v).slice(0,2).toLowerCase():null;const resolveLocale=req=>normalize(req.query.locale)||normalize(req.get?.("x-locale"))||normalize(req.user?.profile?.preferred_language)||normalize(req.get?.("accept-language"))||DEFAULT_LOCALE;const selectTranslation=(rows=[],locale=DEFAULT_LOCALE)=>rows.find(x=>x.locale===locale)||rows.find(x=>x.locale===DEFAULT_LOCALE)||rows[0]||null;module.exports={resolveLocale,selectTranslation};
@@ -0,0 +1,3 @@
const storage=require("../storage/storage.service");const {selectTranslation}=require("./locale.service");const cents=v=>{const [a,b=""]=String(v).split(".");return BigInt(a)*100n+BigInt((b+"00").slice(0,2));};
const mediaDto=async rows=>Promise.all((rows||[]).sort((a,b)=>a.sort_order-b.sort_order).map(async m=>({id:m.id,type:m.type,altText:m.alt_text,isPrimary:m.is_primary,variantId:m.variant_id,url:await storage.createSignedDownloadUrl(m.upload.file_path)})));
const summary=async(product,locale)=>{const tr=selectTranslation(product.translations,locale);const prices=(product.variants||[]).filter(v=>v.status==="ACTIVE").map(v=>String(v.base_price));prices.sort((a,b)=>cents(a)<cents(b)?-1:cents(a)>cents(b)?1:0);const primary=(product.media||[]).filter(m=>m.is_primary);return{id:product.id,slug:product.slug,name:tr?.name||null,brand:product.brand?{id:product.brand.id,name:product.brand.name,slug:product.brand.slug}:null,primaryImage:(await mediaDto(primary))[0]||null,minPrice:prices[0]||null,maxPrice:prices.at(-1)||null,currency:product.variants?.[0]?.currency||null,featured:product.featured,newArrival:Boolean(product.new_arrival_until&&new Date(product.new_arrival_until)>new Date()),compareAt:product.variants?.find(v=>v.compare_at_price)?.compare_at_price||null};};module.exports={summary,mediaDto,cents};
+103
View File
@@ -0,0 +1,103 @@
const crypto = require("crypto");
const { Op } = require("sequelize");
const db = require("../../models");
const fault = (message, code, status = 400) => Object.assign(new Error(message), { code, status });
const ttlMinutes = () => Number(process.env.INVENTORY_RESERVATION_TTL_MINUTES || 15);
const assertQuantity = (value) => { if (!Number.isInteger(value) || value <= 0) throw fault("Quantity must be a positive integer", "INVALID_QUANTITY"); };
const lockBalance = (warehouseId, variantId, transaction) => db.InventoryBalance.findOne({ where: { warehouse_id: warehouseId, variant_id: variantId }, transaction, lock: transaction.LOCK.UPDATE });
const ledger = (data, transaction) => db.InventoryTransaction.create({ id: crypto.randomUUID(), occurred_at: new Date(), ...data }, { transaction });
const availabilityStatus = ({ on_hand, reserved, low_stock_threshold }) => {
const available = Number(on_hand) - Number(reserved);
return available <= 0 ? "OUT_OF_STOCK" : available <= Number(low_stock_threshold) ? "LOW_STOCK" : "IN_STOCK";
};
async function adjustStock({ eventId, warehouseId, variantId, quantityDelta, reason, actorUserId, requestId }) {
if (!eventId || !Number.isInteger(quantityDelta) || quantityDelta === 0) throw fault("A non-zero integer adjustment and eventId are required", "INVALID_ADJUSTMENT");
return db.sequelize.transaction(async transaction => {
const prior = await db.InventoryTransaction.findOne({ where: { event_id: eventId }, transaction, lock: transaction.LOCK.UPDATE });
if (prior) return { idempotent: true, transaction: prior };
let balance = await lockBalance(warehouseId, variantId, transaction);
if (!balance) balance = await db.InventoryBalance.create({ id: crypto.randomUUID(), warehouse_id: warehouseId, variant_id: variantId, on_hand: 0, reserved: 0 }, { transaction });
const next = Number(balance.on_hand) + quantityDelta;
if (next < 0 || next < Number(balance.reserved)) throw fault("Adjustment would violate available stock", "INSUFFICIENT_STOCK", 409);
await balance.update({ on_hand: next }, { transaction });
const entry = await ledger({ event_id: eventId, warehouse_id: warehouseId, variant_id: variantId, type: "ADJUSTMENT", quantity_delta: quantityDelta, reason, actor_user_id: actorUserId, request_id: requestId }, transaction);
return { balance, transaction: entry, idempotent: false };
});
}
async function selectWarehouse(variantId, quantity, transaction) {
const warehouses = await db.Warehouse.findAll({ where: { status: "ACTIVE" }, order: [["is_default", "DESC"], ["code", "ASC"]], transaction });
for (const warehouse of warehouses) {
const balance = await lockBalance(warehouse.id, variantId, transaction);
if (balance && Number(balance.on_hand) - Number(balance.reserved) >= quantity) return { warehouse, balance };
}
throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409);
}
async function reserveStock({ reservationKey, warehouseId, variantId, quantity, referenceType, referenceId, userId, expiresAt, requestId }) {
assertQuantity(quantity); if (!reservationKey) throw fault("reservationKey is required", "INVALID_RESERVATION");
return db.sequelize.transaction(async transaction => {
const existing = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE });
if (existing) return { reservation: existing, idempotent: true };
const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" }, transaction });
if (!variant) throw fault("Active variant not found", "VARIANT_UNAVAILABLE", 404);
let selected;
if (warehouseId) {
const warehouse = await db.Warehouse.findOne({ where: { id: warehouseId, status: "ACTIVE" }, transaction });
if (!warehouse) throw fault("Active warehouse not found", "WAREHOUSE_UNAVAILABLE", 404);
const balance = await lockBalance(warehouseId, variantId, transaction);
if (!balance || Number(balance.on_hand) - Number(balance.reserved) < quantity) throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409);
selected = { warehouse, balance };
} else selected = await selectWarehouse(variantId, quantity, transaction);
await selected.balance.update({ reserved: Number(selected.balance.reserved) + quantity }, { transaction });
const reservation = await db.InventoryReservation.create({ id: crypto.randomUUID(), reservation_key: reservationKey, warehouse_id: selected.warehouse.id, variant_id: variantId, quantity, reference_type: referenceType, reference_id: referenceId, user_id: userId, expires_at: expiresAt || new Date(Date.now() + ttlMinutes() * 60000) }, { transaction });
await ledger({ event_id: `reserve:${reservationKey}`, warehouse_id: selected.warehouse.id, variant_id: variantId, type: "RESERVATION", reserved_delta: quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction);
return { reservation, idempotent: false };
});
}
async function finishReservation(reservationKey, status, requestId) {
return db.sequelize.transaction(async transaction => {
const reservation = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE });
if (!reservation) throw fault("Reservation not found", "RESERVATION_NOT_FOUND", 404);
if (reservation.status === status) return { reservation, idempotent: true };
if (reservation.status !== "ACTIVE") throw fault(`Reservation is ${reservation.status}`, "RESERVATION_NOT_ACTIVE", 409);
if (status === "CONSUMED" && new Date(reservation.expires_at) <= new Date()) throw fault("Reservation has expired", "RESERVATION_EXPIRED", 409);
const balance = await lockBalance(reservation.warehouse_id, reservation.variant_id, transaction);
if (!balance || Number(balance.reserved) < reservation.quantity) throw fault("Inventory invariant violated", "INVENTORY_INVARIANT", 409);
const consume = status === "CONSUMED";
await balance.update({ reserved: Number(balance.reserved) - reservation.quantity, on_hand: Number(balance.on_hand) - (consume ? reservation.quantity : 0) }, { transaction });
await reservation.update({ status, released_at: consume ? null : new Date(), consumed_at: consume ? new Date() : null }, { transaction });
await ledger({ event_id: `${status.toLowerCase()}:${reservationKey}`, warehouse_id: reservation.warehouse_id, variant_id: reservation.variant_id, type: consume ? "RESERVATION_CONSUME" : "RESERVATION_RELEASE", quantity_delta: consume ? -reservation.quantity : 0, reserved_delta: -reservation.quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction);
return { reservation, idempotent: false };
});
}
const releaseReservation = args => finishReservation(args.reservationKey, args.expired ? "EXPIRED" : "RELEASED", args.requestId);
const consumeReservation = args => finishReservation(args.reservationKey, "CONSUMED", args.requestId);
async function transferStock({ eventId, sourceWarehouseId, destinationWarehouseId, variantId, quantity, actorUserId, requestId }) {
assertQuantity(quantity); if (sourceWarehouseId === destinationWarehouseId) throw fault("Warehouses must differ", "INVALID_TRANSFER");
return db.sequelize.transaction(async transaction => {
const prior = await db.InventoryTransfer.findOne({ where: { event_id: eventId }, transaction, lock: transaction.LOCK.UPDATE }); if (prior) return { transfer: prior, idempotent: true };
const ids = [sourceWarehouseId, destinationWarehouseId].sort(); const balances = {};
for (const id of ids) balances[id] = await lockBalance(id, variantId, transaction);
const source = balances[sourceWarehouseId]; if (!source || Number(source.on_hand) - Number(source.reserved) < quantity) throw fault("Insufficient transferable stock", "INSUFFICIENT_STOCK", 409);
let destination = balances[destinationWarehouseId]; if (!destination) destination = await db.InventoryBalance.create({ id: crypto.randomUUID(), warehouse_id: destinationWarehouseId, variant_id: variantId, on_hand: 0, reserved: 0 }, { transaction });
await source.update({ on_hand: Number(source.on_hand) - quantity }, { transaction }); await destination.update({ on_hand: Number(destination.on_hand) + quantity }, { transaction });
const transfer = await db.InventoryTransfer.create({ id: crypto.randomUUID(), event_id: eventId, transfer_number: eventId, source_warehouse_id: sourceWarehouseId, destination_warehouse_id: destinationWarehouseId, variant_id: variantId, quantity, created_by: actorUserId, completed_by: actorUserId, completed_at: new Date() }, { transaction });
await ledger({ event_id: `${eventId}:out`, warehouse_id: sourceWarehouseId, variant_id: variantId, type: "TRANSFER_OUT", quantity_delta: -quantity, reference_type: "INVENTORY_TRANSFER", reference_id: transfer.id, actor_user_id: actorUserId, request_id: requestId }, transaction);
await ledger({ event_id: `${eventId}:in`, warehouse_id: destinationWarehouseId, variant_id: variantId, type: "TRANSFER_IN", quantity_delta: quantity, reference_type: "INVENTORY_TRANSFER", reference_id: transfer.id, actor_user_id: actorUserId, request_id: requestId }, transaction);
return { transfer, idempotent: false };
});
}
async function expireReservations(limit = 100) {
const rows = await db.InventoryReservation.findAll({ where: { status: "ACTIVE", expires_at: { [Op.lte]: new Date() } }, order: [["expires_at", "ASC"]], limit });
for (const row of rows) { try { await releaseReservation({ reservationKey: row.reservation_key, expired: true }); } catch (error) { if (error.code !== "RESERVATION_NOT_ACTIVE") throw error; } }
return rows.length;
}
module.exports = { adjustStock, reserveStock, releaseReservation, consumeReservation, transferStock, expireReservations, availabilityStatus, ttlMinutes };
@@ -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 };