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();
}
}