using Tau.Acuvim.Portal.Domain.Rates; using Tau.Acuvim.Portal.Services; namespace Tau.Acuvim.Portal.Tests; public class CostCalculatorTests { private static Tariff BuildTariff( decimal defaultRate = 2.50m, decimal fixedCharge = 100m, decimal vatPct = 15m, params TariffPeriod[] periods) { var t = new Tariff { Id = 1, Name = "Test", EffectiveFrom = new DateOnly(2026, 1, 1), DefaultRatePerKwh = defaultRate, FixedMonthlyCharge = fixedCharge, VatPercentage = vatPct, IsActive = true, Periods = periods.ToList() }; return t; } private static TariffPeriod Period(string name, DayOfWeekFlag days, string start, string end, decimal rate) => new() { Name = name, DaysOfWeek = days, StartTime = TimeOnly.Parse(start), EndTime = TimeOnly.Parse(end), RatePerKwh = rate }; [Fact] public void EmptySamples_ReturnsZeroBase_StillIncludesFixedAndVat() { var t = BuildTariff(); var calc = new CostCalculator(); var r = calc.Calculate(t, Array.Empty()); Assert.Equal(0m, r.BaseCost); Assert.Equal(100m, r.FixedCharges); Assert.Equal(100m, r.Subtotal); Assert.Equal(15m, r.Vat); Assert.Equal(115m, r.TotalCost); } [Fact] public void NoPeriods_UsesDefaultRate() { var t = BuildTariff(defaultRate: 2m, fixedCharge: 0, vatPct: 0); var calc = new CostCalculator(); var samples = new[] { new ConsumptionSample(new DateTime(2026, 5, 17, 10, 0, 0, DateTimeKind.Utc), 5m) }; var r = calc.Calculate(t, samples); Assert.Equal(10m, r.BaseCost); Assert.Equal(10m, r.TotalCost); } [Fact] public void MatchingPeriod_UsesPeriodRate() { var peak = Period("Peak", DayOfWeekFlag.Weekdays, "17:00", "20:00", 5m); var t = BuildTariff(defaultRate: 2m, fixedCharge: 0, vatPct: 0, periods: peak); var calc = new CostCalculator(); // Sunday 17:30 UTC — Sunday is NOT in Weekdays, expect default var sundaySample = new ConsumptionSample(new DateTime(2026, 5, 17, 17, 30, 0, DateTimeKind.Utc), 1m); // Monday 18:00 UTC — should hit peak var mondaySample = new ConsumptionSample(new DateTime(2026, 5, 18, 18, 0, 0, DateTimeKind.Utc), 1m); var r = calc.Calculate(t, new[] { sundaySample, mondaySample }); Assert.Equal(2m + 5m, r.BaseCost); } [Fact] public void SampleOutsideAllPeriods_FallsBackToDefault() { var morning = Period("Morning", DayOfWeekFlag.All, "06:00", "09:00", 4m); var t = BuildTariff(defaultRate: 1m, fixedCharge: 0, vatPct: 0, periods: morning); var calc = new CostCalculator(); var sample = new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 10m); var r = calc.Calculate(t, new[] { sample }); Assert.Equal(10m, r.BaseCost); } [Fact] public void OverlappingPeriods_FirstByStartTimeWins() { var early = Period("Early", DayOfWeekFlag.All, "08:00", "18:00", 5m); var late = Period("Late", DayOfWeekFlag.All, "10:00", "14:00", 99m); var t = BuildTariff(0m, 0m, 0m, late, early); var calc = new CostCalculator(); var noonSample = new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 1m); var r = calc.Calculate(t, new[] { noonSample }); Assert.Equal(5m, r.BaseCost); } [Fact] public void TimezoneConversion_ShiftsSampleIntoLocalPeriod() { var sastEvening = Period("SAST evening", DayOfWeekFlag.All, "17:00", "20:00", 10m); var t = BuildTariff(defaultRate: 1m, fixedCharge: 0, vatPct: 0, periods: sastEvening); var calc = new CostCalculator(); var jhb = TimeZoneInfo.CreateCustomTimeZone("SAST", TimeSpan.FromHours(2), "SAST", "SAST"); // 16:00 UTC == 18:00 SAST → inside the period var inWindow = new ConsumptionSample(new DateTime(2026, 5, 18, 16, 0, 0, DateTimeKind.Utc), 1m); // 16:00 UTC default behaviour (UTC tz) is outside the period → default rate var defaultR = calc.Calculate(t, new[] { inWindow }); Assert.Equal(1m, defaultR.BaseCost); // Now with SAST → in window var sastR = calc.Calculate(t, new[] { inWindow }, timeZone: jhb); Assert.Equal(10m, sastR.BaseCost); } [Fact] public void VatAndFixedCharge_AppliedToBase() { var t = BuildTariff(defaultRate: 2m, fixedCharge: 100m, vatPct: 15m); var calc = new CostCalculator(); var samples = new[] { new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 100m) }; var r = calc.Calculate(t, samples); Assert.Equal(200m, r.BaseCost); // 100 kWh * 2.00 Assert.Equal(100m, r.FixedCharges); Assert.Equal(300m, r.Subtotal); Assert.Equal(45m, r.Vat); // 15% of 300 Assert.Equal(345m, r.TotalCost); } [Fact] public void IncludeFixedMonthlyCharge_False_ExcludesFixed() { var t = BuildTariff(defaultRate: 2m, fixedCharge: 100m, vatPct: 10m); var calc = new CostCalculator(); var samples = new[] { new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 10m) }; var r = calc.Calculate(t, samples, includeFixedMonthlyCharge: false); Assert.Equal(20m, r.BaseCost); Assert.Equal(0m, r.FixedCharges); Assert.Equal(20m, r.Subtotal); Assert.Equal(2m, r.Vat); Assert.Equal(22m, r.TotalCost); } [Fact] public void DayOfWeekFiltering_HonoursWeekendOnlyPeriod() { var weekend = Period("Weekend special", DayOfWeekFlag.Weekends, "00:00", "23:59", 0.5m); var t = BuildTariff(defaultRate: 2m, fixedCharge: 0, vatPct: 0, periods: weekend); var calc = new CostCalculator(); var monday = new ConsumptionSample(new DateTime(2026, 5, 18, 10, 0, 0, DateTimeKind.Utc), 10m); // Monday var saturday = new ConsumptionSample(new DateTime(2026, 5, 16, 10, 0, 0, DateTimeKind.Utc), 10m); // Saturday var r = calc.Calculate(t, new[] { monday, saturday }); Assert.Equal(20m + 5m, r.BaseCost); // Mon at default 2.00 + Sat at 0.50 } [Fact] public void NullTariff_Throws() { var calc = new CostCalculator(); Assert.Throws(() => calc.Calculate(null!, Array.Empty())); } }