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