using Microsoft.Extensions.Logging; using Moq; using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces.Repositories; 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 CalculateCoatPriceAsync_InventoryPowder_UsesCalculatedUsageOnly() { var unitOfWork = CreateUnitOfWorkMock( CreateOperatingCosts(), inventoryItem: new InventoryItem { Id = 12, CompanyId = 1, Name = "Gloss Black", UnitCost = 12m }); 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 = "Gloss Black", InventoryItemId = 12, CoverageSqFtPerLb = 24m, TransferEfficiency = 50m }; var result = await service.CalculateCoatPriceAsync( coat, itemSurfaceAreaSqFt: 10m, quantity: 2m, coatIndex: 0, estimatedMinutesBase: 30, companyId: 1); Assert.Equal(20m, result.CoatMaterialCost, 2); Assert.Equal(60m, result.CoatLaborCost); Assert.Equal(80m, result.CoatTotalCost, 2); } [Fact] public async Task CalculateCoatPriceAsync_MetricTenant_ConvertsSurfaceAreaBeforePricing() { var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts()); var tenantContext = new Mock(); tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(true); var service = new PricingCalculationService( unitOfWork.Object, Mock.Of>(), new MeasurementConversionService(), tenantContext.Object); var coat = new CreateQuoteItemCoatDto { CoatName = "Metric Blue", PowderCostPerLb = 5m, CoverageSqFtPerLb = 10m, TransferEfficiency = 100m }; var result = await service.CalculateCoatPriceAsync( coat, itemSurfaceAreaSqFt: 1m, quantity: 1m, coatIndex: 0, estimatedMinutesBase: 0, companyId: 1); Assert.Equal(5.38m, result.CoatMaterialCost); Assert.Equal(0m, result.CoatLaborCost); Assert.Equal(5.38m, result.CoatTotalCost); } [Fact] public async Task CalculateCoatPriceAsync_AdditionalCoatWithNoExtraLayerCharge_SkipsLabor() { 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 coat = new CreateQuoteItemCoatDto { CoatName = "Clear Coat", PowderCostPerLb = 4m, PowderToOrder = 1m, CoverageSqFtPerLb = 20m, TransferEfficiency = 100m, NoExtraLayerCharge = true }; var result = await service.CalculateCoatPriceAsync( coat, itemSurfaceAreaSqFt: 0m, quantity: 2m, coatIndex: 1, estimatedMinutesBase: 45, companyId: 1); Assert.Equal(4m, result.CoatMaterialCost); Assert.Equal(0m, result.CoatLaborCost); Assert.Equal(4m, result.CoatTotalCost); } [Fact] public async Task CalculateCoatPriceAsync_WhenOperatingCostsMissing_ReturnsZeros() { var unitOfWork = CreateUnitOfWorkMock(costs: null); var tenantContext = new Mock(); tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false); var service = new PricingCalculationService( unitOfWork.Object, Mock.Of>(), new MeasurementConversionService(), tenantContext.Object); var result = await service.CalculateCoatPriceAsync( new CreateQuoteItemCoatDto { CoatName = "Unpriced" }, itemSurfaceAreaSqFt: 10m, quantity: 1m, coatIndex: 0, estimatedMinutesBase: 30, companyId: 1); Assert.Equal(0m, result.CoatMaterialCost); Assert.Equal(0m, result.CoatLaborCost); Assert.Equal(0m, result.CoatTotalCost); } [Fact] public async Task CalculateQuoteItemPriceAsync_CatalogItem_UsesPowderCostOverrideAsBasePrice() { var unitOfWork = CreateUnitOfWorkMock( CreateOperatingCosts(), catalogItem: new CatalogItem { Id = 50, CompanyId = 1, Name = "Wheel", DefaultPrice = 10m }); 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 = "Override catalog item", CatalogItemId = 50, PowderCostOverride = 77m, Quantity = 3m }; var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); Assert.Equal(0m, result.MaterialCost); Assert.Equal(0m, result.LaborCost); Assert.Equal(77m, result.UnitPrice); Assert.Equal(231m, result.TotalPrice); } [Fact] public async Task CalculateQuoteItemPriceAsync_CatalogItem_AddsPrepCostAndCustomPowder() { var unitOfWork = CreateUnitOfWorkMock( CreateOperatingCosts(), catalogItem: new CatalogItem { Id = 51, CompanyId = 1, Name = "Bracket", DefaultPrice = 50m }); 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 = "Bracket with prep", CatalogItemId = 51, Quantity = 2m, IncludePrepCost = true, PrepServices = new List { new() { PrepServiceId = 1, EstimatedMinutes = 30 } }, Coats = new List { new() { CoatName = "Custom Green", PowderCostPerLb = 5m, PowderToOrder = 2m } } }; var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); // Custom powder material ($10) is excluded from the item price — it moves to the // auto-generated "Custom Powder Order" line item so users can add shipping. Assert.Equal(0m, result.MaterialCost); Assert.Equal(30m, result.LaborCost); Assert.Equal(0m, result.EquipmentCost); Assert.Equal(65m, result.UnitPrice); // catalog $50 + prep $15/unit Assert.Equal(130m, result.TotalPrice); } [Fact] public async Task CalculateQuoteItemPriceAsync_MarginMode_AppliesAdditionalCoatAndComplexity() { var costs = CreateOperatingCosts(); costs.PricingMode = PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost; costs.TargetMarginPercent = 50m; costs.CoatingBoothCostPerHour = 0m; costs.ComplexityModeratePercent = 5m; 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 = "Complex fabricated part", Quantity = 1m, SurfaceAreaSqFt = 10m, EstimatedMinutes = 60, Complexity = "Moderate", Coats = new List { new() { CoatName = "Base", PowderCostPerLb = 10m, CoverageSqFtPerLb = 10m, TransferEfficiency = 100m }, new() { CoatName = "Top", PowderCostPerLb = 10m, CoverageSqFtPerLb = 10m, TransferEfficiency = 100m } } }; var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1); Assert.Equal(10.5m, result.MaterialCost); Assert.Equal(60m, result.LaborCost); Assert.Equal(0m, result.EquipmentCost); Assert.Equal(222.075m, result.ItemSubtotal); Assert.Equal(222.075m, result.TotalPrice); } [Fact] public async Task CalculateQuoteTotalsAsync_MixedAiAndManualItems_ScalesOvenCostBySurfaceAreaAndUsesManualTax() { var costs = CreateOperatingCosts(); costs.OvenOperatingCostPerHour = 30m; costs.DefaultOvenCycleMinutes = 60; costs.ShopSuppliesRate = 0m; costs.TaxPercent = 5m; costs.MonthlyRent = 0m; costs.MonthlyUtilities = 0m; 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 items = new List { new() { Description = "AI estimate", IsAiItem = true, ManualUnitPrice = 200m, Quantity = 1m, SurfaceAreaSqFt = 50m, Coats = new List { new() { CoatName = "Base Coat", Sequence = 1 } } }, new() { Description = "Labor item", IsLaborItem = true, Quantity = 1m, SurfaceAreaSqFt = 50m, EstimatedMinutes = 60, Coats = new List { new() { CoatName = "Base Coat", Sequence = 1 } } } }; var result = await service.CalculateQuoteTotalsAsync( items, companyId: 1, manualTaxPercent: 8m); Assert.Equal(260m, result.ItemsSubtotal); Assert.Equal(15m, result.OvenBatchCost); Assert.Equal(275m, result.SubtotalBeforeDiscount); Assert.Equal(8m, result.TaxPercent); Assert.Equal(22m, result.TaxAmount); Assert.Equal(297m, result.Total); } [Fact] public async Task CalculateQuoteTotalsAsync_ZeroSurfaceAreaFallback_UsesItemCountForOvenFraction() { var costs = CreateOperatingCosts(); costs.OvenOperatingCostPerHour = 20m; costs.DefaultOvenCycleMinutes = 60; costs.ShopSuppliesRate = 0m; costs.TaxPercent = 0m; costs.MonthlyRent = 0m; costs.MonthlyUtilities = 0m; 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 items = new List { new() { Description = "AI item", IsAiItem = true, ManualUnitPrice = 100m, Quantity = 1m, Coats = new List { new() { CoatName = "Base Coat", Sequence = 1 } } }, new() { Description = "Shelf item", IsSalesItem = true, ManualUnitPrice = 40m, Quantity = 1m, Coats = new List { new() { CoatName = "Base Coat", Sequence = 1 } } } }; var result = await service.CalculateQuoteTotalsAsync(items, companyId: 1); Assert.Equal(140m, result.ItemsSubtotal); Assert.Equal(10m, result.OvenBatchCost); Assert.Equal(150m, result.Total); } [Fact] public async Task CalculateQuoteTotalsAsync_FixedRushAndFacilityOverhead_AreAppliedBeforeTotal() { var costs = CreateOperatingCosts(); costs.OvenOperatingCostPerHour = 0m; costs.MonthlyRent = 1600m; costs.MonthlyUtilities = 0m; costs.MonthlyBillableHours = 160; costs.ShopSuppliesRate = 10m; costs.RushChargeType = "FixedAmount"; costs.RushChargeFixedAmount = 25m; costs.TaxPercent = 0m; 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 items = new List { new() { Description = "Labor item", IsLaborItem = true, Quantity = 2m, EstimatedMinutes = 60 } }; var result = await service.CalculateQuoteTotalsAsync( items, companyId: 1, isRushJob: true); Assert.Equal(120m, result.ItemsSubtotal); Assert.Equal(10m, result.FacilityOverheadRatePerHour); Assert.Equal(20m, result.FacilityOverheadCost); Assert.Equal(12m, result.ShopSuppliesAmount); Assert.Equal(152m, result.SubtotalBeforeDiscount); Assert.Equal(25m, result.RushFee); Assert.Equal(177m, result.Total); } [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, InventoryItem? inventoryItem = null, CatalogItem? catalogItem = null) { var unitOfWork = new Mock(); var companyOperatingCostsRepo = new Mock>(); companyOperatingCostsRepo .Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>())) .ReturnsAsync(costs != null ? new[] { costs } : Array.Empty()); var inventoryRepo = new Mock>(); inventoryRepo .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) .ReturnsAsync(inventoryItem); var catalogRepo = new Mock>(); catalogRepo .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) .ReturnsAsync(catalogItem); 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 }; } }