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,21 @@
"use strict";
module.exports={async up(q,S){await q.sequelize.transaction(async transaction=>{const ts={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};const table=(name,fields)=>q.createTable(name,{...fields,...ts},{transaction});
await table("brands",{id:{type:S.STRING,primaryKey:true},name:{type:S.STRING(160),allowNull:false},slug:{type:S.STRING(180),allowNull:false,unique:true},description:S.TEXT,logo_upload_id:S.INTEGER,website:S.STRING,status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("brands",["status","sort_order"],{name:"brands_public_idx",transaction});
await table("categories",{id:{type:S.STRING,primaryKey:true},parent_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"RESTRICT"},code:{type:S.STRING(80),allowNull:false,unique:true},slug:{type:S.STRING(180),allowNull:false,unique:true},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},image_upload_id:S.INTEGER});await q.addIndex("categories",["parent_id","status","sort_order"],{name:"categories_tree_idx",transaction});
await table("category_translations",{id:{type:S.STRING,primaryKey:true},category_id:{type:S.STRING,allowNull:false,references:{model:"categories",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(180),allowNull:false},description:S.TEXT,meta_title:S.STRING(180),meta_description:S.STRING(320)});await q.addIndex("category_translations",["category_id","locale"],{unique:true,name:"category_translation_locale_unique",transaction});
await table("size_guides",{id:{type:S.STRING,primaryKey:true},code:{type:S.STRING(80),allowNull:false,unique:true},name:{type:S.STRING(160),allowNull:false},locale:{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},data:{type:S.JSON,allowNull:false},category_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"SET NULL"},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"}});
await table("products",{id:{type:S.STRING,primaryKey:true},brand_id:{type:S.STRING,allowNull:false,references:{model:"brands",key:"id"},onDelete:"RESTRICT"},default_category_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"SET NULL"},slug:{type:S.STRING(200),allowNull:false,unique:true},product_code:{type:S.STRING(100),allowNull:false,unique:true},status:{type:S.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},visibility:{type:S.ENUM("PUBLIC","HIDDEN"),allowNull:false,defaultValue:"HIDDEN"},product_type:{type:S.STRING(80),allowNull:false,defaultValue:"STANDARD"},featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false},new_arrival_until:S.DATE,published_at:S.DATE,created_by:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"}},updated_by:S.STRING,size_guide_id:{type:S.STRING,allowNull:true,references:{model:"size_guides",key:"id"},onDelete:"SET NULL"}});await q.addIndex("products",["status","visibility","published_at"],{name:"products_public_idx",transaction});await q.addIndex("products",["brand_id","featured","new_arrival_until"],{name:"products_merchandising_idx",transaction});
await table("product_translations",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(220),allowNull:false},short_description:S.STRING(500),description:S.TEXT,care_instructions:S.TEXT,materials:S.TEXT,origin:S.STRING(120),meta_title:S.STRING(180),meta_description:S.STRING(320)});await q.addIndex("product_translations",["product_id","locale"],{unique:true,name:"product_translation_locale_unique",transaction});await q.addIndex("product_translations",["name"],{name:"product_translation_name_idx",transaction});
await table("product_categories",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},category_id:{type:S.STRING,allowNull:false,references:{model:"categories",key:"id"},onDelete:"RESTRICT"}});await q.addIndex("product_categories",["product_id","category_id"],{unique:true,name:"product_category_unique",transaction});await q.addIndex("product_categories",["category_id","product_id"],{name:"product_category_lookup_idx",transaction});
await table("product_variants",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},sku:{type:S.STRING(100),allowNull:false,unique:true},barcode:{type:S.STRING(100),allowNull:true,unique:true},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},base_price:{type:S.DECIMAL(15,2),allowNull:false},compare_at_price:S.DECIMAL(15,2),currency:{type:S.STRING(3),allowNull:false,defaultValue:"LKR"},weight:S.DECIMAL(10,3),sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_variants",["product_id","status","base_price"],{name:"product_variant_public_idx",transaction});
await table("product_options",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},name:{type:S.STRING(100),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_options",["product_id","name"],{unique:true,name:"product_option_name_unique",transaction});
await table("product_option_values",{id:{type:S.STRING,primaryKey:true},option_id:{type:S.STRING,allowNull:false,references:{model:"product_options",key:"id"},onDelete:"CASCADE"},value:{type:S.STRING(100),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_option_values",["option_id","value"],{unique:true,name:"product_option_value_unique",transaction});
await table("variant_option_values",{id:{type:S.STRING,primaryKey:true},variant_id:{type:S.STRING,allowNull:false,references:{model:"product_variants",key:"id"},onDelete:"CASCADE"},option_value_id:{type:S.STRING,allowNull:false,references:{model:"product_option_values",key:"id"},onDelete:"CASCADE"}});await q.addIndex("variant_option_values",["variant_id","option_value_id"],{unique:true,name:"variant_option_value_unique",transaction});
await table("product_attributes",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},name:{type:S.STRING(100),allowNull:false},value:{type:S.STRING(500),allowNull:false},locale:{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_attributes",["product_id","locale"],{name:"product_attribute_lookup_idx",transaction});
await table("product_media",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},variant_id:{type:S.STRING,allowNull:true,references:{model:"product_variants",key:"id"},onDelete:"CASCADE"},upload_id:{type:S.INTEGER,allowNull:false,references:{model:"uploads",key:"id"},onDelete:"RESTRICT"},type:{type:S.ENUM("IMAGE"),allowNull:false,defaultValue:"IMAGE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},alt_text:S.STRING(250),is_primary:{type:S.BOOLEAN,allowNull:false,defaultValue:false}});await q.addIndex("product_media",["product_id","upload_id"],{unique:true,name:"product_media_upload_unique",transaction});await q.addIndex("product_media",["product_id","is_primary","sort_order"],{name:"product_media_gallery_idx",transaction});
await table("collections",{id:{type:S.STRING,primaryKey:true},slug:{type:S.STRING(180),allowNull:false,unique:true},type:{type:S.STRING(80),allowNull:false},status:{type:S.ENUM("DRAFT","ACTIVE","INACTIVE"),allowNull:false,defaultValue:"DRAFT"},starts_at:S.DATE,ends_at:S.DATE,hero_upload_id:S.INTEGER,sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("collections",["status","starts_at","ends_at"],{name:"collections_publish_window_idx",transaction});
await table("collection_translations",{id:{type:S.STRING,primaryKey:true},collection_id:{type:S.STRING,allowNull:false,references:{model:"collections",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(180),allowNull:false},description:S.TEXT,headline:S.STRING(250),subheadline:S.STRING(300)});await q.addIndex("collection_translations",["collection_id","locale"],{unique:true,name:"collection_translation_locale_unique",transaction});
await table("collection_products",{id:{type:S.STRING,primaryKey:true},collection_id:{type:S.STRING,allowNull:false,references:{model:"collections",key:"id"},onDelete:"CASCADE"},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false}});await q.addIndex("collection_products",["collection_id","product_id"],{unique:true,name:"collection_product_unique",transaction});await q.addIndex("collection_products",["collection_id","sort_order"],{name:"collection_product_order_idx",transaction});
await table("product_relations",{id:{type:S.STRING,primaryKey:true},source_product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},target_product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},relation_type:{type:S.ENUM("RELATED","SIMILAR","COMPLETE_THE_LOOK"),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_relations",["source_product_id","target_product_id","relation_type"],{unique:true,name:"product_relation_unique",transaction});await q.sequelize.query("ALTER TABLE product_relations ADD CONSTRAINT product_relation_not_self CHECK (source_product_id <> target_product_id)",{transaction});
await table("product_reviews",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},user_id:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"},onDelete:"RESTRICT"},rating:{type:S.INTEGER,allowNull:false},title:{type:S.STRING(160),allowNull:false},body:{type:S.TEXT,allowNull:false},status:{type:S.ENUM("PENDING","APPROVED","REJECTED"),allowNull:false,defaultValue:"PENDING"},verified_purchase:{type:S.BOOLEAN,allowNull:false,defaultValue:false},moderated_by:S.STRING,moderated_at:S.DATE});await q.addIndex("product_reviews",["user_id","product_id"],{unique:true,name:"product_review_user_unique",transaction});await q.addIndex("product_reviews",["product_id","status","createdAt"],{name:"product_review_public_idx",transaction});await q.sequelize.query("ALTER TABLE product_reviews ADD CONSTRAINT product_review_rating_check CHECK (rating BETWEEN 1 AND 5)",{transaction});
});},async down(){throw new Error("Phase 4 migration is forward-only; restore from backup for rollback");}};
@@ -0,0 +1,5 @@
"use strict";
module.exports={async up(q,S){const ID={type:S.STRING,allowNull:false,primaryKey:true},STRING={type:S.STRING},DATE={type:S.DATE},INT={type:S.INTEGER,allowNull:false,defaultValue:0},DEC={type:S.DECIMAL(15,2)},timestamps={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("warehouses",{id:ID,code:{type:S.STRING(50),allowNull:false,unique:true},name:{type:S.STRING(150),allowNull:false},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false},is_default:{type:S.BOOLEAN,allowNull:false,defaultValue:false},timezone:{type:S.STRING(80),allowNull:false},address_line_1:STRING,address_line_2:STRING,city:STRING,province:STRING,postal_code:STRING,country_code:{type:S.STRING(2),allowNull:false},...timestamps});await q.createTable("inventory_balances",{id:ID,warehouse_id:{type:S.STRING,allowNull:false},variant_id:{type:S.STRING,allowNull:false},on_hand:INT,reserved:INT,low_stock_threshold:INT,...timestamps});await q.addConstraint("inventory_balances",{fields:["warehouse_id","variant_id"],type:"unique",name:"uq_inventory_balance_warehouse_variant"});
await q.createTable("inventory_transactions",{id:ID,event_id:{type:S.STRING,allowNull:false,unique:true},warehouse_id:str(false),variant_id:str(false),type:str(false),quantity_delta:INT,reserved_delta:INT,reference_type:STRING,reference_id:STRING,reason:{type:S.STRING(500)},actor_user_id:STRING,request_id:STRING,metadata:{type:S.JSON},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.createTable("inventory_reservations",{id:ID,reservation_key:{type:S.STRING(160),allowNull:false,unique:true},warehouse_id:str(false),variant_id:str(false),quantity:{type:S.INTEGER,allowNull:false},status:str(false),reference_type:STRING,reference_id:STRING,user_id:STRING,expires_at:{type:S.DATE,allowNull:false},released_at:DATE,consumed_at:DATE,...timestamps});await q.createTable("inventory_transfers",{id:ID,event_id:{type:S.STRING,allowNull:false,unique:true},transfer_number:{type:S.STRING(80),allowNull:false,unique:true},source_warehouse_id:str(false),destination_warehouse_id:str(false),variant_id:str(false),quantity:{type:S.INTEGER,allowNull:false},status:str(false),created_by:str(false),completed_by:str(false),completed_at:{type:S.DATE,allowNull:false},...timestamps});
await q.createTable("business_tiers",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:str(false),description:{type:S.TEXT},status:str(false),sort_order:INT,...timestamps});await q.addColumn("business_customers","business_tier_id",STRING);await q.createTable("variant_business_prices",{id:ID,variant_id:str(false),business_tier_id:STRING,business_customer_id:STRING,currency:{type:S.STRING(3),allowNull:false},price:{...DEC,allowNull:false},minimum_quantity:{type:S.INTEGER,allowNull:false},status:str(false),starts_at:DATE,ends_at:DATE,...timestamps});await q.createTable("business_price_tiers",{id:ID,business_price_id:str(false),min_quantity:{type:S.INTEGER,allowNull:false},max_quantity:{type:S.INTEGER},unit_price:{...DEC,allowNull:false},...timestamps});
await q.createTable("promotions",{id:ID,name:str(false),type:str(false),status:str(false),starts_at:DATE,ends_at:DATE,priority:INT,stackable:{type:S.BOOLEAN,allowNull:false},discount_percent:{type:S.DECIMAL(5,2)},discount_amount:DEC,fixed_price:DEC,minimum_subtotal:DEC,minimum_quantity:{type:S.INTEGER},customer_type:STRING,created_by:str(false),updated_by:STRING,...timestamps});await q.createTable("promotion_targets",{id:ID,promotion_id:str(false),target_type:str(false),target_id:STRING,...timestamps});await q.createTable("coupons",{id:ID,code:{type:S.STRING(50),allowNull:false,unique:true},promotion_id:str(false),status:str(false),starts_at:DATE,expires_at:DATE,max_uses:{type:S.INTEGER},max_uses_per_user:{type:S.INTEGER},...timestamps});await q.createTable("banners",{id:ID,placement:str(false),status:str(false),starts_at:DATE,ends_at:DATE,image_upload_id:{type:S.INTEGER,allowNull:false},mobile_image_upload_id:{type:S.INTEGER},target_url:STRING,audience_type:str(false),business_tier_id:STRING,sort_order:INT,created_by:str(false),updated_by:STRING,...timestamps});await q.createTable("banner_translations",{id:ID,banner_id:str(false),locale:{type:S.STRING(5),allowNull:false},headline:STRING,subheadline:{type:S.TEXT},cta_text:STRING,alt_text:STRING,...timestamps});await q.addConstraint("banner_translations",{fields:["banner_id","locale"],type:"unique",name:"uq_banner_translation_locale"});},async down(){throw new Error("Phase 5 migration is forward-only; restore from a verified backup instead");}};