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,6 @@
|
||||
const {selectTranslation}=require("../../app/services/catalogue/locale.service");const {cents}=require("../../app/services/catalogue/serializer.service");
|
||||
describe("catalogue localization and money helpers",()=>{
|
||||
test("selects requested locale",()=>expect(selectTranslation([{locale:"en",name:"A"},{locale:"si",name:"B"}],"si").name).toBe("B"));
|
||||
test("falls back to English",()=>expect(selectTranslation([{locale:"en",name:"A"}],"ta").name).toBe("A"));
|
||||
test("compares decimal values without Number conversion",()=>{expect(cents("10.09")>cents("9.99")).toBe(true);expect(cents("1.5")).toBe(150n);});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
const s=require("../../app/validation/catalogue.schemas");const wrap=body=>({body,params:{},query:{}});const base=()=>({brandId:"brand-1",slug:"linen-shirt",productCode:"SHIRT-1",translations:[{locale:"en",name:"Linen Shirt"}],categoryIds:["cat-1"],variants:[{sku:"SHIRT-1-M",basePrice:"1990.00",currency:"LKR"}],mediaUploadIds:[1]});
|
||||
describe("Phase 4 catalogue validation",()=>{
|
||||
test("accepts a minimal strict draft product",()=>expect(s.productCreate.safeParse(wrap(base())).success).toBe(true));
|
||||
test("rejects unknown administrative product fields",()=>expect(s.productCreate.safeParse(wrap({...base(),stock:100})).success).toBe(false));
|
||||
test("rejects invalid slug",()=>expect(s.productCreate.safeParse(wrap({...base(),slug:"Unsafe Slug!"})).success).toBe(false));
|
||||
test.each(["12","12.3","12.34"])("accepts decimal money %s",v=>expect(s.money.safeParse(v).success).toBe(true));
|
||||
test.each(["-1","1.234","NaN","1e3"])("rejects invalid money %s",v=>expect(s.money.safeParse(v).success).toBe(false));
|
||||
test("supports en si ta and rejects other translations",()=>{for(const locale of ["en","si","ta"])expect(s.productCreate.safeParse(wrap({...base(),translations:[{locale,name:"Name"}]})).success).toBe(true);expect(s.productCreate.safeParse(wrap({...base(),translations:[{locale:"fr",name:"Name"}]})).success).toBe(false);});
|
||||
test("client cannot set review verification or moderation",()=>{expect(s.review.safeParse(wrap({rating:5,title:"Great",body:"Excellent product"})).success).toBe(true);expect(s.review.safeParse(wrap({rating:5,title:"Great",body:"Excellent product",verifiedPurchase:true,status:"APPROVED"})).success).toBe(false);});
|
||||
test("validates rating range",()=>{expect(s.review.safeParse(wrap({rating:0,title:"Bad",body:"Not valid"})).success).toBe(false);expect(s.review.safeParse(wrap({rating:5,title:"Good",body:"Valid review"})).success).toBe(true);});
|
||||
test("size guide accepts structured data and not HTML",()=>{expect(s.sizeGuide.safeParse(wrap({code:"MEN_TOPS",name:"Men tops",locale:"en",data:{columns:["Size","Chest"],rows:[["M","40"]]}})).success).toBe(true);expect(s.sizeGuide.safeParse(wrap({code:"X",name:"X",locale:"en",data:{html:"<script>"}})).success).toBe(false);});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
describe("catalogue hierarchy and publication rules",()=>{beforeEach(()=>jest.resetModules());
|
||||
const setup=(nodes={})=>{const models={Category:{findByPk:jest.fn(async id=>nodes[id]||null)},ProductTranslation:{findOne:jest.fn()},ProductVariant:{findOne:jest.fn()},ProductMedia:{findOne:jest.fn()}};jest.doMock("../../app/models",()=>models);return{service:require("../../app/services/catalogue/catalogue.service"),models};};
|
||||
test("blocks self-parent",async()=>{const {service}=setup();await expect(service.assertCategoryParent("a","a",{})).rejects.toMatchObject({code:"CATEGORY_SELF_PARENT"});});
|
||||
test("blocks category cycle",async()=>{const {service}=setup({b:{id:"b",parent_id:"a"}});await expect(service.assertCategoryParent("a","b",{})).rejects.toMatchObject({code:"CATEGORY_CYCLE"});});
|
||||
test("accepts valid hierarchy",async()=>{const {service}=setup({b:{id:"b",parent_id:null}});await expect(service.assertCategoryParent("a","b",{})).resolves.toBeUndefined();});
|
||||
test("publish requires English translation, variant, and primary media",async()=>{const {service,models}=setup();models.ProductTranslation.findOne.mockResolvedValue(null);await expect(service.validatePublish({id:"p",slug:"p",brand_id:"b"},{})).rejects.toMatchObject({code:"PUBLISH_REQUIREMENTS_NOT_MET"});models.ProductTranslation.findOne.mockResolvedValue({});models.ProductVariant.findOne.mockResolvedValue({});models.ProductMedia.findOne.mockResolvedValue({});await expect(service.validatePublish({id:"p",slug:"p",brand_id:"b"},{})).resolves.toBeUndefined();});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
const money=require("../../app/services/pricing/money");const inventory=require("../../app/services/inventory/inventory.service");
|
||||
describe("Phase 5 decimal and availability rules",()=>{test("money remains integer-scaled and floors discounts at zero",()=>{expect(money.parse("900.05")).toBe(90005n);expect(money.percentOff("1000.00","12.50")).toBe("125.00");expect(money.subtractFloor("5.00","10.00")).toBe("0.00");});test.each([[{on_hand:10,reserved:1,low_stock_threshold:2},"IN_STOCK"],[{on_hand:3,reserved:1,low_stock_threshold:2},"LOW_STOCK"],[{on_hand:3,reserved:3,low_stock_threshold:2},"OUT_OF_STOCK"]])("availability projection",(balance,status)=>expect(inventory.availabilityStatus(balance)).toBe(status));test("reservation TTL is centralized",()=>expect(inventory.ttlMinutes()).toBeGreaterThan(0));});
|
||||
@@ -0,0 +1,2 @@
|
||||
const {checkPermission}=require("../../app/middleware/permission.middleware");
|
||||
describe("Phase 5 permission boundaries",()=>{test("denies an authenticated customer without inventory permission",()=>{const req={user:{accountType:"CUSTOMER",permissions:[]},method:"POST"},res={status:jest.fn().mockReturnThis(),json:jest.fn()},next=jest.fn();checkPermission("inventory.adjust",{custom:true})(req,res,next);expect(res.status).toHaveBeenCalledWith(403);expect(next).not.toHaveBeenCalled();});test("allows explicit permission",()=>{const req={user:{accountType:"ADMIN",permissions:["pricing.business.manage"]},method:"POST"},res={},next=jest.fn();checkPermission("pricing.business.manage",{custom:true})(req,res,next);expect(next).toHaveBeenCalled();});});
|
||||
@@ -0,0 +1,2 @@
|
||||
const s=require("../../app/validation/inventoryMerchandising.schemas");const input=body=>({body,params:{},query:{}});
|
||||
describe("Phase 5 strict validation",()=>{test("normalizes coupon codes",()=>expect(s.coupon.parse(input({code:"save_10",promotionId:"p",status:"ACTIVE"})).body.code).toBe("SAVE_10"));test("rejects sensitive unknown fields",()=>expect(()=>s.adjustment.parse(input({warehouseId:"w",variantId:"v",quantityDelta:1,reason:"receipt",onHand:999}))).toThrow());test("requires exactly one business audience",()=>expect(()=>s.businessPrice.parse(input({variantId:"v",currency:"LKR",price:"10.00",businessTierId:"t",businessCustomerId:"c"}))).toThrow());test("rejects floating quantities",()=>expect(()=>s.transfer.parse(input({sourceWarehouseId:"a",destinationWarehouseId:"b",variantId:"v",quantity:1.5}))).toThrow());});
|
||||
Reference in New Issue
Block a user