using Microsoft.Extensions.Logging; using Moq; using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; using Xunit; namespace PowderCoating.UnitTests; public class PricingCalculationServiceTests { [Fact] public async Task CalculateCoatPriceAsync_CustomPowder_ChargesFullOrderQuantity() { var costs = CreateOperatingCosts(); var unitOfWork = CreateUnitOfWorkMock(costs); var tenantContext = new Mock(); tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); var service = new PricingCalculationService( unitOfWork.Object, Mock.Of>(), new MeasurementConversionService(), tenantContext.Object); var coat = new CreateQuoteItemCoatDto { CoatName = "Custom Red", PowderCostPerLb = 10m, PowderToOrder = 3m, CoverageSqFtPerLb = 30m, TransferEfficiency = 65m }; var result = await service.CalculateCoatPriceAsync( coat, itemSurfaceAreaSqFt: 5m, quantity: 2m, coatIndex: 0, estimatedMinutesBase: 15, companyId: 1); Assert.Equal(30m, result.CoatMaterialCost); Assert.Equal(30m, result.CoatLaborCost); Assert.Equal(60m, result.CoatTotalCost); } [Fact] public async Task CalculateQuoteItemPriceAsync_LaborItem_UsesStandardLaborRate() { var costs = CreateOperatingCosts(); costs.StandardLaborRate = 80m; var unitOfWork = CreateUnitOfWorkMock(costs); var tenantContext = new Mock(); tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); var service = new PricingCalculationService( unitOfWork.Object, Mock.Of>(), new MeasurementConversionService(), tenantContext.Object); var item = new CreateQuoteItemDto { Description = "Shop labor", IsLaborItem = true, Quantity = 2.5m }; var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); Assert.Equal(0m, result.MaterialCost); Assert.Equal(200m, result.LaborCost); Assert.Equal(80m, result.UnitPrice); Assert.Equal(200m, result.TotalPrice); } [Fact] public async Task CalculateQuoteItemPriceAsync_AiItem_UsesManualUnitPriceWithoutAdditionalCosts() { var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts()); var tenantContext = new Mock(); tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); var service = new PricingCalculationService( unitOfWork.Object, Mock.Of>(), new MeasurementConversionService(), tenantContext.Object); var item = new CreateQuoteItemDto { Description = "AI wheel estimate", IsAiItem = true, ManualUnitPrice = 123m, Quantity = 2m }; var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); Assert.Equal(0m, result.MaterialCost); Assert.Equal(0m, result.LaborCost); Assert.Equal(123m, result.UnitPrice); Assert.Equal(246m, result.TotalPrice); } [Fact] public async Task CalculateQuoteTotalsAsync_AppliesTierDiscount_QuoteDiscount_RushFee_AndTax() { var costs = CreateOperatingCosts(); costs.StandardLaborRate = 100m; costs.ShopSuppliesRate = 10m; costs.RushChargeType = "Percentage"; costs.RushChargePercentage = 20m; costs.TaxPercent = 5m; costs.OvenOperatingCostPerHour = 0m; costs.MonthlyRent = 0m; costs.MonthlyUtilities = 0m; var customerRepo = new Mock>(); customerRepo .Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>())) .ReturnsAsync(new[] { new Customer { Id = 1, CompanyId = 1, PricingTierId = 10 } }); var pricingTierRepo = new Mock>(); pricingTierRepo .Setup(x => x.GetByIdAsync(10, false, It.IsAny>[]>())) .ReturnsAsync(new PricingTier { Id = 10, CompanyId = 1, DiscountPercent = 10m }); var unitOfWork = CreateUnitOfWorkMock(costs); unitOfWork.SetupGet(x => x.Customers).Returns(customerRepo.Object); unitOfWork.SetupGet(x => x.PricingTiers).Returns(pricingTierRepo.Object); var tenantContext = new Mock(); tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); var service = new PricingCalculationService( unitOfWork.Object, Mock.Of>(), new MeasurementConversionService(), tenantContext.Object); var items = new List { new() { Description = "Labor item", IsLaborItem = true, Quantity = 2m } }; var result = await service.CalculateQuoteTotalsAsync( items, companyId: 1, customerId: 1, discountType: "FixedAmount", discountValue: 5m, isRushJob: true); Assert.Equal(200m, result.ItemsSubtotal); Assert.Equal(20m, result.ShopSuppliesAmount); Assert.Equal(220m, result.SubtotalBeforeDiscount); Assert.Equal(22m, result.PricingTierDiscountAmount); Assert.Equal(5m, result.QuoteDiscountAmount); Assert.Equal(193m, result.SubtotalAfterDiscount); Assert.Equal(38.6m, result.RushFee); Assert.Equal(11.58m, result.TaxAmount); Assert.Equal(243.18m, result.Total); } private static Mock CreateUnitOfWorkMock(CompanyOperatingCosts costs) { var unitOfWork = new Mock(); var companyOperatingCostsRepo = new Mock>(); companyOperatingCostsRepo .Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>())) .ReturnsAsync(new[] { costs }); var inventoryRepo = new Mock>(); inventoryRepo .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) .ReturnsAsync((InventoryItem?)null); var catalogRepo = new Mock>(); catalogRepo .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) .ReturnsAsync((CatalogItem?)null); var customerRepo = new Mock>(); customerRepo .Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>())) .ReturnsAsync(Array.Empty()); var pricingTierRepo = new Mock>(); pricingTierRepo .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) .ReturnsAsync((PricingTier?)null); unitOfWork.SetupGet(x => x.CompanyOperatingCosts).Returns(companyOperatingCostsRepo.Object); unitOfWork.SetupGet(x => x.InventoryItems).Returns(inventoryRepo.Object); unitOfWork.SetupGet(x => x.CatalogItems).Returns(catalogRepo.Object); unitOfWork.SetupGet(x => x.Customers).Returns(customerRepo.Object); unitOfWork.SetupGet(x => x.PricingTiers).Returns(pricingTierRepo.Object); return unitOfWork; } private static CompanyOperatingCosts CreateOperatingCosts() { return new CompanyOperatingCosts { Id = 1, CompanyId = 1, StandardLaborRate = 60m, AdditionalCoatLaborPercent = 50m, OvenOperatingCostPerHour = 25m, SandblasterCostPerHour = 20m, CoatingBoothCostPerHour = 10m, PowderCoatingCostPerSqFt = 1m, PricingMode = PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial, GeneralMarkupPercentage = 20m, TargetMarginPercent = 40m, TaxPercent = 5m, ShopSuppliesRate = 10m, DefaultOvenCycleMinutes = 60, RushChargeType = "Percentage", RushChargePercentage = 15m, RushChargeFixedAmount = 50m, ShopMinimumCharge = 0m, ComplexitySimplePercent = 0m, ComplexityModeratePercent = 5m, ComplexityComplexPercent = 15m, ComplexityExtremePercent = 25m, MonthlyBillableHours = 160 }; } }