using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Costing; using static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests.Costing; /// /// Regressions from the cost review (R1 – R6): costs that intervals longer than a month still allow, virtual source /// costs priced the way each source's own scope is, meter fees on meters the bill has no line for, the months a grid /// meter was not in service, and a priced consumer linked directly below the grid meter. /// [Collection("Timescale")] public sealed class CostReviewFixTests(TimescaleFixture fx) : IAsyncLifetime { private CostSandbox _box = null!; public Task InitializeAsync() { _box = new CostSandbox(fx); return Task.CompletedTask; } public async Task DisposeAsync() => await _box.DisposeAsync(); // ------------------------------------------------------------------------------------------------ R5 [Fact] public async Task A_quarterly_delta_meter_with_one_price_has_a_yearly_cost() { // R5 (D-36, D-14): read once a quarter, the meter cannot be cut into months, so no month has a cost. One price // covers every month of every interval, though, so the year has one: 1,200 kWh × 0.10. var type = await _box.TypeAsync(); var quarterly = await QuarterlyAsync(type); await _box.TypePriceAsync(type, 0.10, D(2025, 1, 1)); var yearly = await BillAsync(type, Year(2025), BucketSize.Year); CostAssert.Priced(120, yearly.Total); CostAssert.Priced(120, Assert.Single(yearly.Buckets)); var monthly = await BillAsync(type, Year(2025), BucketSize.Month); Assert.All(monthly.Buckets, b => Assert.Null(b.Cost)); Assert.All(monthly.Buckets, b => Assert.Equal(BucketStatus.Unresolved, b.Availability)); CostAssert.Priced(120, monthly.Total); var auto = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Year(2025))); Assert.Equal(BucketSize.Year, auto.Plan.Size); CostAssert.Priced(120, auto.Total); var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(quarterly), Year(2025)) { Bucket = BucketSize.Year }); CostAssert.Priced(120, own.Total); Assert.DoesNotContain(own.Attention, a => a.Kind == CostAttentionKind.PriceChangeInsideInterval); } [Fact] public async Task A_price_change_inside_an_interval_leaves_the_span_unavailable_and_says_why() { // R5: the price rises from August; the July – September interval spans both prices and cannot be split, so the // year has no cost — never a confident one, never 0 — and the attention item names the meter. var type = await _box.TypeAsync(); var quarterly = await QuarterlyAsync(type); await _box.TypePriceAsync(type, 0.10, D(2025, 1, 1), to: D(2025, 7, 31)); await _box.TypePriceAsync(type, 0.20, D(2025, 8, 1)); var yearly = await BillAsync(type, Year(2025), BucketSize.Year); Assert.Null(yearly.Total.Cost); Assert.Equal(BucketStatus.Unresolved, yearly.Total.Availability); var attention = Assert.Single(yearly.Attention, a => a.Kind == CostAttentionKind.PriceChangeInsideInterval); Assert.Equal(quarterly, attention.MeterId); Assert.Equal((D(2025, 1, 1), D(2025, 12, 1)), (attention.FirstMonth, attention.LastMonth)); } // ------------------------------------------------------------------------------------------------ R1 [Fact] public async Task A_sum_of_generation_meters_has_no_purchase_cost() { // R1 (D-34, D-39): the sources' own costs are "none (generation)", so their sum costs nothing — not 250 kWh at // the purchase price. var type = await _box.TypeAsync(); var s1 = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100); var s2 = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 150); await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1)); var sum = await _box.VirtualAsync(type, $"m{s1} + m{s2}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts); var summed = await CostOfAsync(sum, Month(2026, 1)); Assert.Null(summed.Total.Cost); Assert.Empty(summed.Lines); Assert.Equal((MeterCostRule.None, MeterNotCostedReason.Generation), (summed.Meter!.Rule, summed.Meter.NotCosted)); // A generation sum is not costed by default either. Assert.Equal(VirtualCostRule.None, VirtualValidator.DefaultCostRule(Formula.Parse($"m{s1} + m{s2}"), QuantityKind.Generation)); } [Fact] public async Task An_export_view_costs_its_source_s_feed_in_credit() { // R1: a view over the export meter adds the export meter's own cost — a feed-in credit, not a purchase. var type = await _box.TypeAsync(); var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); var export = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport)); await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 300); await _box.MonthlyReadingsAsync(export, D(2026, 1, 1), 50); await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1)); await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2026, 1, 1)); var view = await _box.VirtualAsync(type, $"m{export}", QuantityKind.Net, "kWh", VirtualCostRule.SourceCosts); var own = await CostOfAsync(export, Month(2026, 1)); var viewed = await CostOfAsync(view, Month(2026, 1)); CostAssert.Priced(-4, own.Total); CostAssert.Priced(-4, viewed.Total); Assert.Equal(BillLineKind.FeedIn, Assert.Single(viewed.Lines).Kind); } // ------------------------------------------------------------------------------------------------ R2 [Fact] public async Task Source_costs_follow_the_formula_and_never_price_a_subtrahend() { // R2 (D-39): a sum over a nested difference is not a sum of metered costs — pricing its tokens would add the // subtrahend's cost (18 instead of 12). It is not costed, and says why. A repeated token counts by its weight. var type = await _box.TypeAsync(); var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100); var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 30); var c = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 50); await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1)); var difference = await _box.VirtualAsync(type, $"m{a} - m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.None); var nested = await _box.VirtualAsync(type, $"m{difference} + m{c}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); var repeated = await _box.VirtualAsync(type, $"m{a} + m{a} - m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); var overDifference = await CostOfAsync(nested, Month(2026, 1)); Assert.Null(overDifference.Total.Cost); Assert.Empty(overDifference.Lines); Assert.Equal((MeterCostRule.None, MeterNotCostedReason.SourcesNotPureSum), (overDifference.Meter!.Rule, overDifference.Meter.NotCosted)); var weighted = await CostOfAsync(repeated, Month(2026, 1)); CostAssert.Priced(13, weighted.Total); Assert.Equal([a, b], weighted.Lines.Select(l => l.MeterId).Order()); // Nested pure sums are expanded to their sources, each once. var inner = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); var outer = await _box.VirtualAsync(type, $"m{inner} + m{c}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); CostAssert.Priced(18, (await CostOfAsync(outer, Month(2026, 1))).Total); } // ------------------------------------------------------------------------------------------------ R3 [Fact] public async Task A_meter_fee_on_a_meter_without_a_bill_line_is_still_charged_on_its_meter() { // R3 (D-40): grid 100 kWh a month, the house behind it 150 kWh, a PV meter; fees of 2 €/month on the PV meter // and 5 €/month on the house. The bill prices the grid import, and still charges both fees — each as its own // row on its meter. var type = await _box.TypeAsync(); var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); var house = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad)); await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 100, 100, 100); await _box.MonthlyReadingsAsync(house, D(2026, 1, 1), 150, 150, 150); await _box.LinkAsync(grid, house); var pv = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 40, 40, 40); await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1)); await _box.TariffAsync(TariffScope.Meter, pv, TariffComponent.BasePrice, 2, "EUR/month", D(2026, 1, 1)); await _box.TariffAsync(TariffScope.Meter, house, TariffComponent.BasePrice, 5, "EUR/month", D(2026, 1, 1)); var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31)); var bill = await BillAsync(type, quarter, BucketSize.Month); CostAssert.Priced(90 + 6 + 15, bill.Total); Assert.Equal([grid], bill.Lines.Select(l => l.MeterId)); Assert.Equal( [(TariffScope.Meter, (int?)house, 15d), (TariffScope.Meter, (int?)pv, 6d)], bill.StandingCharges.Select(r => (r.Scope, r.ScopeId, Math.Round(r.Total.Cost!.Value, 6))).OrderBy(r => r.Item2)); var pvOwn = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(pv), quarter) { Bucket = BucketSize.Month }); CostAssert.Priced(6, pvOwn.Total); var houseOwn = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(house), quarter) { Bucket = BucketSize.Month }); CostAssert.Priced(135 + 15, houseOwn.Total); // The portfolio's composition reconciles to the bill, the fees included. var portfolio = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month }); CostAssert.Priced(111, portfolio.EnergyTypes.Single(t => t.EnergyTypeId == type).Total); } // ------------------------------------------------------------------------------------------------ R4 [Fact] public async Task Months_before_the_grid_meter_was_installed_are_not_a_confident_zero_bill() { // R4 (D-34 + D-24): the house was measured from January, the grid meter only installed on 1 April. Before then // the grid meter's known zero must not stand in for the bill: those months are unavailable, and say why. var type = await _box.TypeAsync(); var house = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad)); await _box.MonthlyReadingsAsync(house, D(2025, 1, 1), 100, 100, 100, 100, 100, 100); var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 4, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await _box.MonthlyReadingsAsync(grid, D(2025, 4, 1), 60, 60, 60); await _box.LinkAsync(grid, house); await _box.TypePriceAsync(type, 0.30, D(2025, 1, 1)); var q1 = await BillAsync(type, Custom(D(2025, 1, 1), D(2025, 3, 31)), BucketSize.Month); Assert.Null(q1.Total.Cost); Assert.NotEqual(BucketStatus.Available, q1.Total.Availability); Assert.All(q1.Buckets, b => Assert.Null(b.Cost)); var gap = Assert.Single(q1.Attention, a => a.Kind == CostAttentionKind.BillingBasisGap); Assert.Equal((grid, D(2025, 1, 1), D(2025, 3, 1)), (gap.MeterId!.Value, gap.FirstMonth, gap.LastMonth)); var q2 = await BillAsync(type, Custom(D(2025, 4, 1), D(2025, 6, 30)), BucketSize.Month); CostAssert.Priced(54, q2.Total); Assert.DoesNotContain(q2.Attention, a => a.Kind == CostAttentionKind.BillingBasisGap); var half = await BillAsync(type, Custom(D(2025, 1, 1), D(2025, 6, 30)), BucketSize.Month); Assert.Equal(54, half.Total.Cost!.Value, 6); Assert.Equal(BucketStatus.Partial, half.Total.Availability); } [Fact] public async Task Months_after_the_grid_meter_retired_without_a_successor_are_not_a_confident_zero_bill() { var type = await _box.TypeAsync(); var house = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad)); await _box.MonthlyReadingsAsync(house, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 12)]); var grid = await _box.MeterAsync( type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport), retiredAt: D(2025, 6, 30)); await _box.MonthlyReadingsAsync(grid, D(2025, 1, 1), 60, 60, 60, 60, 60, 60); await _box.LinkAsync(grid, house); await _box.TypePriceAsync(type, 0.30, D(2025, 1, 1)); var h1 = await BillAsync(type, Custom(D(2025, 1, 1), D(2025, 6, 30)), BucketSize.Month); CostAssert.Priced(108, h1.Total); var h2 = await BillAsync(type, Custom(D(2025, 7, 1), D(2025, 12, 31)), BucketSize.Month); Assert.Null(h2.Total.Cost); var gap = Assert.Single(h2.Attention, a => a.Kind == CostAttentionKind.BillingBasisGap); Assert.Equal((grid, D(2025, 7, 1), D(2025, 12, 1)), (gap.MeterId!.Value, gap.FirstMonth, gap.LastMonth)); } // ------------------------------------------------------------------------------------------------ R6 [Fact] public async Task A_priced_consumer_linked_below_the_grid_meter_is_billed_at_its_own_price() { // R6 (D-35, Kaskade): grid 300 kWh a month, a heat pump behind it 100 kWh at its own 0.22, linked grid → pump: // 2 × ((300 − 100) × 0.30 + 100 × 0.22) = 164, not the whole import at 0.30. var type = await _box.TypeAsync(); var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 300, 300); var pump = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100); await _box.LinkAsync(grid, pump); await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1)); await _box.MeterPriceAsync(pump, 0.22, D(2026, 1, 1)); var bill = await BillAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28)), BucketSize.Month); CostAssert.Priced(164, bill.Total); Assert.Equal([200d, 200d], bill.Lines.Single(l => l.MeterId == grid).Quantities); Assert.Equal(BillLineKind.OwnPrice, bill.Lines.Single(l => l.MeterId == pump).Kind); Assert.DoesNotContain(bill.Attention, a => a.Kind == CostAttentionKind.BillingConfiguration); } // ------------------------------------------------------------------------------------------------ acceptance review [Fact] public async Task A_category_whose_members_price_nothing_says_why_instead_of_reading_empty() { // D-39/D-42 keep a calculated view out of a category's cost; brief §4.3 asks for an explained result, not an empty // one. A category holding only a costed consumption view (sourceCosts) prices nothing and says which members // add nothing; one that also holds its metered source prices the source and says nothing. var type = await _box.TypeAsync(); var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2025, 1, 1), 100, 80); await _box.TypePriceAsync(type, 0.30, D(2024, 1, 1)); var view = await _box.VirtualAsync(type, $"m{a}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); var viewOnly = await _box.CategoryAsync($"view-only-{Guid.NewGuid():N}", 93, meters: [view]); var withSource = await _box.CategoryAsync($"with-source-{Guid.NewGuid():N}", 94, meters: [a, view]); var period = Custom(D(2025, 1, 1), D(2025, 2, 28)); // The view has a cost of its own: its source's. CostAssert.Priced(54, (await CostOfAsync(view, period)).Total); var alone = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(viewOnly), period) { Bucket = BucketSize.Month }); Assert.Null(alone.Total.Cost); var note = Assert.Single(alone.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing); Assert.Equal(viewOnly, note.CategoryId); Assert.Equal([view], note.MeterIds); Assert.Equal(view, note.MeterId); var both = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(withSource), period) { Bucket = BucketSize.Month }); CostAssert.Priced(54, both.Total); Assert.DoesNotContain(both.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing); // The portfolio with its categories names it too, for the Overview. var portfolio = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, period) { Bucket = BucketSize.Month, IncludeCategories = true }); Assert.Contains(portfolio.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing && x.CategoryId == viewOnly); Assert.DoesNotContain(portfolio.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing && x.CategoryId == withSource); } // ------------------------------------------------------------------------------------------------ helpers private async Task QuarterlyAsync(short type) { var meter = await _box.MeterAsync(type, MeterMode.DirectDelta, "kWh", D(2025, 1, 1)); await _box.ReadingsAsync( meter, (Midnight(2025, 1, 1), 0), (Midnight(2025, 4, 1), 300), (Midnight(2025, 7, 1), 300), (Midnight(2025, 10, 1), 300), (Midnight(2026, 1, 1), 300)); return meter; } private Task BillAsync(short type, ResolvedPeriod period, BucketSize size) => _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = size }); private Task CostOfAsync(int meter, ResolvedPeriod period) => _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), period) { Bucket = BucketSize.Month }); }