using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Xunit.Abstractions; using static MeterVault.Integration.Tests.Costing.CostSandbox; using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport; namespace MeterVault.Integration.Tests.Costing; /// /// The whole bill (D-34 – D-44): the seeded reference instance against the Kosten sheet's Jahreskosten, the /// category composition that reconciles with it, standing charges once per scope, and an instance with nothing but /// manual costs. The portfolio is everything in the database, so every test starts from — and leaves — an instance /// without meters, tariffs or manual costs (like ). /// [Collection("Timescale")] public sealed class SeededBillTests(TimescaleFixture fx, ITestOutputHelper output) : IAsyncLifetime { private const int KostenHeizung = 3; private const int KostenStrom = 4; private const int KostenWasser = 5; public async Task InitializeAsync() { await using var db = fx.CreateContext(); await ClearDataAsync(db); } public async Task DisposeAsync() { await using var db = fx.CreateContext(); await ClearDataAsync(db); } [Fact] public async Task The_seeded_yearly_bill_equals_the_sheet_s_Jahreskosten() { // D-44: with the tank unpriced and on a clock after the data ends (31 May 2026), the seeded bill is the sheet's // yearly cost within 2 cents: Strom = Netz × price, water metered, Heizung from the imported manual costs. await LoadReferenceDataAsync(); var reader = new CostSandbox(fx).Reader(); var sheet = ReadRows(Costs); await using var db = fx.CreateContext(); var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Name, m => m.Id); var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => t.Key, t => (int)t.Id); var categories = await db.CostCategories.AsNoTracking().ToDictionaryAsync(c => c.Name, c => c.Id); foreach (var (year, jahreskosten) in new[] { (2022, 421.52), (2025, 7907.64), (2026, 2940.19) }) { var bill = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(year)) { Bucket = BucketSize.Month, IncludeCategories = true }); output.WriteLine(FormattableString.Invariant($"{year}: bill {bill.Total.Cost:F4} (sheet {jahreskosten:F2}), {bill.Total.Status}")); // The yearly bill, with nothing unavailable in it: the tank is "not priced", an attention item only. Assert.Equal(CostStatus.Priced, bill.Total.Status); Assert.InRange(bill.Total.Cost!.Value, jahreskosten - 0.02, jahreskosten + 0.02); Assert.All(bill.MissingPrices, m => Assert.Equal((CostStatus.NotPriced, (int?)meters["Öltank"]), (m.Reason, m.MeterId))); Assert.Equal(bill.Total.Cost!.Value, bill.Buckets.Sum(b => b.Cost ?? 0), 6); // Strom is the grid import alone, priced month by month: the sheet's Kosten = Netz × €/kWh. var strom = bill.EnergyTypes.Single(t => t.EnergyTypeId == types["electricity"]); Assert.Equal([meters["Zähler Netz"]], strom.LineMeterIds); var netz = bill.Lines.Single(l => l.MeterId == meters["Zähler Netz"]); for (var b = 0; b < bill.Buckets.Count; b++) { var month = bill.Plan.Buckets[b].FirstDay; if (netz.Quantities[b] is { } kWh && netz.Buckets[b].Cost is { } cost) { Assert.Equal(kWh * StromPrice(month), cost, 6); } } Assert.InRange(strom.Total.Cost!.Value - SheetSum(sheet, KostenStrom, year), -0.02, 0.02); var wasser = bill.EnergyTypes.Single(t => t.EnergyTypeId == types["water"]); Assert.InRange(wasser.Total.Cost!.Value - SheetSum(sheet, KostenWasser, year), -0.02, 0.02); // Heizung comes from manual costs, each booked once. var heizung = SheetSum(sheet, KostenHeizung, year); Assert.InRange((bill.ManualCosts.Total.Cost ?? 0) - heizung, -0.02, 0.02); Assert.Equal(bill.ManualCosts.Bookings.Count, bill.ManualCosts.Bookings.Select(m => m.ManualCostId).Distinct().Count()); Assert.All(bill.ManualCosts.Bookings, m => Assert.Equal(categories["Heizung"], m.CategoryId)); // The composition — disjoint categories, Uncategorized, standing charges — is the bill (D-42). var composition = bill.Composition!; Assert.Equal(bill.Total.Cost!.Value, composition.Total.Cost!.Value, 6); for (var b = 0; b < bill.Buckets.Count; b++) { Assert.Equal(bill.Buckets[b].Cost ?? 0, composition.Buckets[b].Cost ?? 0, 6); } Assert.Equal(strom.Total.Cost, Slice(composition, categories["Strom"]).Total.Cost); Assert.Equal(wasser.Total.Cost, Slice(composition, categories["Wasser"]).Total.Cost); Assert.Equal(bill.ManualCosts.Total.Cost, Slice(composition, categories["Heizung"]).Total.Cost); var uncategorized = composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized); Assert.Equal([meters["Öltank"]], uncategorized.MeterIds); Assert.Null(uncategorized.Total.Cost); Assert.All(composition.Categories, c => Assert.False(c.IsOverlappingView)); Assert.True(composition.DonutAllowed); } // Every seeded meter names how its own cost is formed (D-34, D-39). var rules = new Dictionary { ["Zähler Netz"] = (MeterCostRule.BillLine, true), ["Zähler Haus"] = (MeterCostRule.UnitPriceView, false), ["Zähler Auto"] = (MeterCostRule.UnitPriceView, false), ["Zähler Solar 1"] = (MeterCostRule.None, false), ["Brenner"] = (MeterCostRule.None, false), ["Öltank"] = (MeterCostRule.BillLine, true), ["Summe Solar"] = (MeterCostRule.None, false), }; foreach (var (name, expected) in rules) { var own = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters[name]), Year(2025)) { Bucket = BucketSize.Month }); Assert.Equal(expected, (own.Meter!.Rule, own.Meter.OnBill)); } // Summe Solar is generation: never a purchase cost (review R1, A-15) — not 4,750 kWh at 0.36 €. var summe = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters["Summe Solar"]), Year(2025)) { Bucket = BucketSize.Month }); Assert.Null(summe.Total.Cost); Assert.Empty(summe.Lines); Assert.Equal(MeterNotCostedReason.Generation, summe.Meter!.NotCosted); var haus = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters["Zähler Haus"]), Year(2025)) { Bucket = BucketSize.Month }); Assert.Equal(haus.Lines.Single().TotalQuantity!.Value * 0.36, haus.Total.Cost!.Value, 6); var tank = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters["Öltank"]), Year(2025)) { Bucket = BucketSize.Month }); Assert.Equal(CostStatus.NotPriced, tank.Total.Status); // Water, December 2022: 14 m³ × 5,00 € (D-56), through the Wasser category. var december = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(categories["Wasser"]), Month(2022, 12)) { Bucket = BucketSize.Month }); CostAssert.Priced(70.00, december.Total, 0.005); Assert.Equal([meters["Zähler Wasser"]], december.Category!.Cover.BilledMeterIds); // 2023 and 2024 differ from the sheet by 3.78 € and 0.46 € (D-44): the sheet multiplies by unrounded prices it // displays rounded (e.g. May 2023, 414,33 € for a 0,37 €/kWh month). Documented, not tuned away. foreach (var (year, jahreskosten, difference) in new[] { (2023, 7904.46, 3.78), (2024, 6783.05, 0.46) }) { var bill = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(year)) { Bucket = BucketSize.Month }); output.WriteLine(FormattableString.Invariant($"{year}: bill {bill.Total.Cost:F4} (sheet {jahreskosten:F2})")); Assert.Equal(CostStatus.Priced, bill.Total.Status); Assert.InRange(Math.Abs(bill.Total.Cost!.Value - jahreskosten), difference - 0.02, difference + 0.02); } // The bucket size never changes the year: months, weeks, the year as one bucket. var watch = System.Diagnostics.Stopwatch.StartNew(); var monthly = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(2025)) { Bucket = BucketSize.Month }); output.WriteLine(FormattableString.Invariant($"portfolio 2025 by month: {watch.ElapsedMilliseconds} ms")); foreach (var size in new[] { BucketSize.Year, BucketSize.Week }) { var other = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(2025)) { Bucket = size }); Assert.Equal(monthly.Total.Cost!.Value, other.Total.Cost!.Value, 6); Assert.Equal(CostStatus.Priced, other.Total.Status); } // Auto charts the bill by the resolution of what is priced: the monthly sheets, not the unpriced tank. var auto = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Preset(PeriodPreset.Last24Months))); Assert.Equal(BucketSize.Month, auto.Plan.Size); Assert.Equal(24, auto.Buckets.Count); // The latest period with data is May 2026, from meters and manual costs alike (D-19). var latest = await reader.GetAvailabilityAsync(CostScope.Portfolio, Now); Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Both), latest.Latest); } [Fact] public async Task Standing_charges_and_categories_compose_the_portfolio_bill() { await using var box = new CostSandbox(fx); var (t1, t2, t3) = (await box.TypeAsync(), await box.TypeAsync(), await box.TypeAsync()); var a = await box.MonthlyAsync(t1, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100); var b = await box.MonthlyAsync(t2, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100); var grid = await box.MeterAsync(t3, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); var export = await box.MeterAsync(t3, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport)); await box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 100, 100, 100); await box.MonthlyReadingsAsync(export, D(2026, 1, 1), 1000, 1000, 1000); foreach (var type in new[] { t1, t2, t3 }) { await box.TypePriceAsync(type, 0.10, D(2026, 1, 1)); } await box.TariffAsync(TariffScope.EnergyType, t3, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2026, 1, 1)); await box.TariffAsync(TariffScope.EnergyType, t1, TariffComponent.BasePrice, 3, "EUR/month", D(2026, 1, 1)); await box.TariffAsync(TariffScope.Global, null, TariffComponent.BasePrice, 10, "EUR/month", D(2026, 1, 1)); var viewA = await box.CategoryAsync($"A {Guid.NewGuid():N}", 100, meters: [a]); var viewA2 = await box.CategoryAsync($"A2 {Guid.NewGuid():N}", 101, meters: [a]); var typeB = await box.CategoryAsync($"B {Guid.NewGuid():N}", 102, types: [t2]); var credit = await box.CategoryAsync($"X {Guid.NewGuid():N}", 103, meters: [export]); var onMeter = await box.ManualCostAsync(D(2026, 2, 1), 25, meterId: b); var onView = await box.ManualCostAsync(D(2026, 2, 1), 7, categoryId: viewA); // Two categories that price nothing but share a manual cost (on a generator, never billed) cannot both be // slices: the cost would be added twice. var t4 = await box.TypeAsync(); var pv = await box.MonthlyAsync(t4, MeterMode.GenerationCounter, D(2026, 1, 1), 50, 50, 50); var sharedG1 = await box.CategoryAsync($"G1 {Guid.NewGuid():N}", 104, meters: [pv]); var sharedG2 = await box.CategoryAsync($"G2 {Guid.NewGuid():N}", 105, meters: [pv]); var onPv = await box.ManualCostAsync(D(2026, 2, 1), 11, meterId: pv); var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31)); var bill = await box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, quarter) { Bucket = BucketSize.Month, IncludeCategories = true }); // Lines: a, b, the grid at 0.10 on 300 kWh each; the export credit 3000 × 0.08. Rows: the type's 3 × 3 €, the // global 3 × 10 € — each once, however many meters are in service (D-40). Manual costs once each (D-41): 25 € // on b goes with b's type, 7 € on a category with the portfolio. Assert.Equal( [(TariffScope.EnergyType, (int?)t1, 9d), (TariffScope.Global, (int?)null, 30d)], bill.StandingCharges.Select(r => (r.Scope, r.ScopeId, Math.Round(r.Total.Cost!.Value, 6)))); CostAssert.Priced(90 - 240 + 9 + 30 + 25 + 7 + 11, bill.Total); Assert.Equal([onMeter, onView, onPv], bill.ManualCosts.Bookings.Select(m => m.ManualCostId).Order()); Assert.Equal(D(2026, 1, 1), bill.StandingCharges[1].Service!.FirstDay); Assert.Equal([39d, 55d, -210d, 11d], bill.EnergyTypes.Select(t => Math.Round(t.Total.Cost!.Value, 6))); // A type's bill carries its own standing charge, never the global one. var typeBill = await box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(t1), quarter) { Bucket = BucketSize.Month }); Assert.Equal((TariffScope.EnergyType, (int?)t1), (Assert.Single(typeBill.StandingCharges).Scope, typeBill.StandingCharges[0].ScopeId)); CostAssert.Priced(39, typeBill.Total); // The composition: B (with b's manual cost) and X are slices, A and A2 share a's line (and t1's charge) and are // views; a's line and A's manual cost go to Uncategorized with the grid, and the two charges no slice holds are // rows of their own. var composition = bill.Composition!; Assert.Equal(bill.Total.Cost!.Value, composition.Total.Cost!.Value, 6); Assert.Equal(30 + 25, Slice(composition, typeB).Total.Cost!.Value, 6); Assert.Equal([onMeter], Slice(composition, typeB).ManualCostIds); Assert.Equal(-240, Slice(composition, credit).Total.Cost!.Value, 6); var uncategorized = composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized); Assert.Equal([a, grid], uncategorized.MeterIds); Assert.Equal([onView, onPv], uncategorized.ManualCostIds); Assert.Equal(60 + 7 + 11, uncategorized.Total.Cost!.Value, 6); Assert.All(composition.Categories.Where(c => c.CategoryId == sharedG1 || c.CategoryId == sharedG2), c => { Assert.True(c.IsOverlappingView); Assert.Equal(11, c.Total.Cost!.Value, 6); }); Assert.Equal([onPv], composition.Overlaps.Single(o => o.CategoryId == sharedG1 && o.OtherCategoryId == sharedG2).SharedManualCostIds); Assert.Equal( [(TariffScope.EnergyType, (int?)t1), (TariffScope.Global, (int?)null)], composition.Slices.Where(s => s.Kind == CompositionSliceKind.StandingCharge).Select(s => (s.StandingCharge!.Scope, s.StandingCharge.ScopeId))); Assert.DoesNotContain(composition.Slices, s => s.CategoryId == viewA || s.CategoryId == viewA2); var figureA = composition.Categories.Single(c => c.CategoryId == viewA); Assert.True(figureA.IsOverlappingView); Assert.Equal([viewA2], figureA.OverlapsWith); Assert.Equal(30 + 9 + 7, figureA.Total.Cost!.Value, 6); var overlap = Assert.Single(composition.Overlaps, o => o.CategoryId == Math.Min(viewA, viewA2) && o.OtherCategoryId == Math.Max(viewA, viewA2)); Assert.Equal([a], overlap.SharedMeterIds); Assert.Equal([new StandingChargeKey(TariffScope.EnergyType, t1)], overlap.SharedStandingCharges); Assert.False(composition.Categories.Single(c => c.CategoryId == typeB).IsOverlappingView); // A credit larger than its charges is a negative slice: signed bars, not a donut (D-42). Assert.False(composition.DonutAllowed); // The category on its own reads the same figure as in the composition. var alone = await box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(viewA), quarter) { Bucket = BucketSize.Month }); Assert.True(alone.Category!.IsOverlappingView); Assert.Equal(figureA.Total.Cost, alone.Total.Cost); } [Fact] public async Task A_manual_cost_only_instance_agrees_across_overview_trend_categories_and_latest_month() { // Brief §11: no meter at all, only manual costs — overview, trend, category breakdown and the latest month agree. await using var box = new CostSandbox(fx); var category = await box.CategoryAsync($"Manual {Guid.NewGuid():N}", 100); var march = await box.ManualCostAsync(D(2026, 3, 5), 100, categoryId: category); var loose = await box.ManualCostAsync(D(2026, 3, 20), 40); var may = await box.ManualCostAsync(D(2026, 5, 10), 60, categoryId: category); var reader = box.Reader(); var latest = await reader.GetAvailabilityAsync(CostScope.Portfolio, Now); Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Manual), latest.Latest); var overview = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Month(2026, 5))); CostAssert.Priced(60, overview.Total); Assert.Equal(latest.Latest, overview.Availability.Latest); Assert.Empty(overview.Lines); var trend = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Preset(PeriodPreset.Last12Months)) { Bucket = BucketSize.Month, IncludeCategories = true }); Assert.Equal(12, trend.Buckets.Count); var byMonth = trend.Plan.Buckets.Select((b, i) => (b.FirstDay, trend.Buckets[i].Cost)).ToDictionary(x => x.FirstDay, x => x.Cost); Assert.Equal(140, byMonth[D(2026, 3, 1)]); Assert.Equal(overview.Total.Cost, byMonth[D(2026, 5, 1)]); Assert.Null(byMonth[D(2026, 4, 1)]); CostAssert.Priced(200, trend.Total); var composition = trend.Composition!; Assert.Equal(160, Slice(composition, category).Total.Cost); Assert.Equal([march, may], Slice(composition, category).ManualCostIds); var uncategorized = composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized); Assert.Equal([loose], uncategorized.ManualCostIds); Assert.Equal(40, uncategorized.Total.Cost); Assert.Equal(200, composition.Total.Cost); var alone = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(category), Preset(PeriodPreset.Last12Months)) { Bucket = BucketSize.Month }); CostAssert.Priced(160, alone.Total); } private async Task LoadReferenceDataAsync() { await using var db = fx.CreateContext(); var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter()); await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures")); } /// The seeded electricity price on the 15th of a month (ReferenceDataImporter). private static double StromPrice(DateOnly month) => month switch { _ when month >= D(2026, 1, 1) => 0.27, _ when month >= D(2025, 1, 1) => 0.36, _ when month >= D(2023, 11, 1) => 0.27, _ when month >= D(2023, 5, 1) => 0.37, _ when month >= D(2023, 1, 1) => 0.44, _ => 0.16, }; private static double SheetSum(IReadOnlyList rows, int column, int year) => OracleByMonth(rows, dateColumn: 0, valueColumn: column, firstDataRow: 1).Where(m => m.Key.Year == year).Sum(m => m.Value); private static CompositionSlice Slice(CategoryComposition composition, int categoryId) => composition.Slices.Single(s => s.Kind == CompositionSliceKind.Category && s.CategoryId == categoryId); private static async Task ClearDataAsync(MeterVaultDbContext db) { await db.MeterLinks.ExecuteDeleteAsync(); await db.Consumption.ExecuteDeleteAsync(); await db.Readings.ExecuteDeleteAsync(); await db.MeterEvents.ExecuteDeleteAsync(); await db.ManualCosts.ExecuteDeleteAsync(); await db.CostCategoryMembers.ExecuteDeleteAsync(); await db.Tariffs.ExecuteDeleteAsync(); await db.Tanks.ExecuteDeleteAsync(); await db.MeterSources.ExecuteDeleteAsync(); await db.Meters.ExecuteDeleteAsync(); await db.ImportBatches.ExecuteDeleteAsync(); } }