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};