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.Analysis;
using MeterVault.Infrastructure.Costing;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Costing;
///
/// The cost engine against real rollups (D-34 – D-41, brief §6.2, §11): missing versus free prices, gaps and unit
/// mismatches, standing charges once per scope, prices month by month whatever the bucket size, separately billed
/// subsections, virtual cost rules, feed-in credits, manual costs booked once, and the latest period with data.
/// Every test builds its own energy types and meters (never the portfolio, which other tests share) and removes them.
///
[Collection("Timescale")]
public sealed class CostReaderTests(TimescaleFixture fx) : IAsyncLifetime
{
private CostSandbox _box = null!;
public Task InitializeAsync()
{
_box = new CostSandbox(fx);
return Task.CompletedTask;
}
public async Task DisposeAsync() => await _box.DisposeAsync();
[Fact]
public async Task A_missing_tariff_is_not_priced_and_a_zero_tariff_is_a_valid_zero()
{
// Brief §11 "Missing versus free tariff": a valid quantity without a required price has no cost; an explicit
// zero tariff costs exactly zero.
var (missingType, freeType) = (await _box.TypeAsync(), await _box.TypeAsync());
var missing = await _box.MonthlyAsync(missingType, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var free = await _box.MonthlyAsync(freeType, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
await _box.TypePriceAsync(freeType, 0, D(2026, 1, 1));
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
var unpriced = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(missingType), period) { Bucket = BucketSize.Month });
Assert.Equal(CostStatus.NotPriced, unpriced.Total.Status);
Assert.Null(unpriced.Total.Cost);
Assert.True(unpriced.Total.IncludesNotPriced);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, missingType, missing, D(2026, 1, 1), D(2026, 2, 1)),
Assert.Single(unpriced.MissingPrices));
var attention = Assert.Single(unpriced.Attention);
Assert.Equal((CostAttentionKind.MissingPrice, (int?)missing), (attention.Kind, attention.MeterId));
Assert.False(attention.Price!.IsCredit);
// The quantity is still there; only its price is missing.
var line = Assert.Single(unpriced.Lines);
Assert.Equal([100d, 80d], line.Quantities);
Assert.Equal(180, line.TotalQuantity);
Assert.Equal(BillingBasis.Use, Assert.Single(unpriced.EnergyTypes).Basis);
var priced = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(freeType), period) { Bucket = BucketSize.Month });
CostAssert.Priced(0, priced.Total);
Assert.All(priced.Buckets, b => CostAssert.Priced(0, b));
Assert.Empty(priced.MissingPrices);
Assert.Equal(free, Assert.Single(priced.Lines).MeterId);
Assert.Equal("EUR", priced.Currency);
}
[Fact]
public async Task A_gap_in_a_price_history_makes_those_months_unavailable()
{
var type = await _box.TypeAsync();
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100, 100);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1), to: D(2026, 1, 31));
await _box.TypePriceAsync(type, 0.40, D(2026, 3, 1));
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 1, 1), D(2026, 4, 30))) { Bucket = BucketSize.Month });
Assert.Equal([CostStatus.Priced, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], result.Buckets.Select(b => b.Status));
Assert.Null(result.Buckets[1].Cost);
CostAssert.Cost(30, result.Buckets[0]);
Assert.Equal(CostStatus.Partial, result.Total.Status);
CostAssert.Cost(110, result.Total);
var gap = Assert.Single(result.MissingPrices);
Assert.Equal((CostStatus.PriceGap, TariffScope.EnergyType, (int?)type, (int?)meter, D(2026, 2, 1), D(2026, 2, 1)),
(gap.Reason, gap.Scope, gap.ScopeId, gap.MeterId, gap.FirstMonth, gap.LastMonth));
Assert.Contains(result.Attention, a => a.Kind == CostAttentionKind.MissingPrice && a.Price == gap);
}
[Fact]
public async Task A_price_in_another_unit_is_a_unit_mismatch_and_a_currency_follows_the_options()
{
var type = await _box.TypeAsync();
await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100);
var tariff = await _box.TypePriceAsync(type, 5, D(2026, 1, 1), unit: "EUR/m3");
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Month });
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Null(result.Total.Cost);
var mismatch = Assert.Single(result.MissingPrices);
Assert.Equal((CostStatus.UnitMismatch, (int?)tariff), (mismatch.Reason, mismatch.TariffId));
// D-43: the instance currency is the options'; a "EUR" price does not fit a CHF instance.
var swiss = await _box.Reader("CHF").ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Month });
Assert.Equal("CHF", swiss.Currency);
Assert.Equal(CostStatus.UnitMismatch, swiss.Total.Status);
}
[Fact]
public async Task A_standing_charge_accrues_once_per_scope_and_a_meter_fee_on_its_meter()
{
// Two consumption roots of one type: the type's standing charge is one row, never one per meter (D-40).
var type = await _box.TypeAsync();
var first = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
var second = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.BasePrice, 31, "EUR/month", D(2026, 1, 1));
await _box.TariffAsync(TariffScope.Meter, second, TariffComponent.BasePrice, 5, "EUR/month", D(2026, 1, 1));
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month });
var row = Assert.Single(bill.StandingCharges);
Assert.Equal((TariffScope.EnergyType, (int?)type), (row.Scope, row.ScopeId));
CostAssert.Priced(93, row.Total);
Assert.Equal(D(2026, 1, 1), row.Service!.FirstDay);
Assert.All(row.Buckets, b => CostAssert.Priced(31, b));
// 600 kWh × 0.10 + 3 × 31 € + the second meter's own fee 3 × 5 €, on its line.
CostAssert.Priced(60 + 93 + 15, bill.Total);
Assert.Null(bill.Lines.Single(l => l.MeterId == first).Total.StandingCharge);
Assert.Equal(15, bill.Lines.Single(l => l.MeterId == second).Total.StandingCharge!.Value, 6);
// A meter's own cost carries its own fee, never the type's charge.
var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(second), quarter) { Bucket = BucketSize.Month });
Assert.Empty(own.StandingCharges);
CostAssert.Priced(30 + 15, own.Total);
Assert.Equal(MeterCostRule.BillLine, own.Meter!.Rule);
// A standing charge accrues per day: half of February is half of February's charge.
var half = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 2, 1), D(2026, 2, 14))) { Bucket = BucketSize.Day });
CostAssert.Cost(31 * 14 / 28d, Assert.Single(half.StandingCharges).Total);
}
[Fact]
public async Task The_bucket_size_never_changes_a_total_and_every_part_is_priced_in_its_month()
{
// D-36: a year with a price change on 1 July, at daily resolution. Year, month, week and day buckets all
// price January to June at 0.30 and July to December at 0.40 — a week across the change is split.
var type = await _box.TypeAsync();
await _box.DailyAsync(type, D(2025, 1, 1), D(2026, 1, 1), perDay: 10);
await _box.TypePriceAsync(type, 0.30, D(2025, 1, 1), to: D(2025, 6, 30));
await _box.TypePriceAsync(type, 0.40, D(2025, 7, 1));
const double expected = (181 * 10 * 0.30) + (184 * 10 * 0.40);
foreach (var (size, count) in new[] { (BucketSize.Year, 1), (BucketSize.Month, 12), (BucketSize.Week, 53), (BucketSize.Day, 365) })
{
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Year(2025)) { Bucket = size });
Assert.Equal(count, result.Buckets.Count);
CostAssert.Priced(expected, result.Total, 1e-6);
Assert.Equal(expected, result.Buckets.Sum(b => b.Cost!.Value), 6);
Assert.All(result.Buckets, b => Assert.Equal(BucketStatus.Available, b.Availability));
if (size == BucketSize.Week)
{
// Monday 30 June – Sunday 6 July: one day at June's price, six at July's.
var index = result.Plan.Buckets.ToList().FindIndex(b => b.FirstDay == D(2025, 6, 30));
CostAssert.Cost((10 * 0.30) + (60 * 0.40), result.Buckets[index]);
}
}
}
[Fact]
public async Task A_monthly_import_prices_its_month_although_its_days_are_unresolved()
{
var type = await _box.TypeAsync();
await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var daily = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Day });
Assert.Equal(31, daily.Buckets.Count);
Assert.All(daily.Buckets, b =>
{
Assert.Null(b.Cost);
Assert.Equal(BucketStatus.Unresolved, b.Availability);
});
CostAssert.Priced(10, daily.Total);
Assert.Equal(BucketStatus.Available, daily.Total.Availability);
// Auto chooses a size the data resolves, so the chart has values.
var auto = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 1, 1), D(2026, 2, 28))));
Assert.Equal(BucketSize.Month, auto.Plan.Size);
Assert.Equal([10d, 8d], auto.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
}
[Fact]
public async Task A_retired_meter_is_a_known_zero_on_the_bill_and_missing_on_its_own_page()
{
// D-24: outside its service period a meter contributes a known zero to the bill, so a meter replaced at the end
// of January leaves the quarter's bill complete; its own page still shows no data after it was retired.
var type = await _box.TypeAsync();
var retired = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), retiredAt: D(2026, 1, 31));
await _box.MonthlyReadingsAsync(retired, D(2026, 1, 1), 100);
var successor = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1));
await _box.MonthlyReadingsAsync(successor, D(2026, 2, 1), 80, 90);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month });
Assert.Equal([10d, 8d, 9d], bill.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
Assert.All(bill.Buckets, b => Assert.Equal(BucketStatus.Available, b.Availability));
CostAssert.Priced(27, bill.Total);
Assert.Equal(BucketStatus.Available, bill.Total.Availability);
var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(retired), quarter) { Bucket = BucketSize.Month });
CostAssert.Priced(10, own.Total);
Assert.Equal(BucketStatus.Partial, own.Total.Availability);
Assert.Equal(BucketStatus.Missing, own.Buckets[1].Availability);
}
[Fact]
public async Task An_unpriced_line_does_not_coarsen_the_automatic_buckets()
{
// A delta meter reporting once a quarter resolves only years; it has no tariff, so it has no cost to chart and
// must not turn the priced monthly meter's chart into one yearly bar.
var type = await _box.TypeAsync();
var monthly = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100, 100, 100, 100);
await _box.MeterPriceAsync(monthly, 0.10, D(2026, 1, 1));
var quarterly = await _box.MeterAsync(type, MeterMode.DirectDelta, "kWh", D(2026, 1, 1));
await _box.ReadingsAsync(quarterly, (Midnight(2026, 1, 1), 0), (Midnight(2026, 4, 1), 300), (Midnight(2026, 7, 1), 300));
var half = Custom(D(2026, 1, 1), D(2026, 6, 30));
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), half));
Assert.Equal(BucketSize.Month, result.Plan.Size);
Assert.Equal(6, result.Buckets.Count);
Assert.All(result.Buckets, b => Assert.Equal(10, b.Cost!.Value, 6));
Assert.Contains(result.MissingPrices, m => m.MeterId == quarterly && m.Reason == CostStatus.NotPriced);
}
[Fact]
public async Task A_separately_billed_subsection_is_priced_at_its_own_price_out_of_its_parent()
{
// D-35: house 300 kWh a month, a heat pump below it 100 kWh at its own 0.22 — billed (300 − 100) × 0.30 + 100 × 0.22.
var type = await _box.TypeAsync();
var house = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 300, 300);
var pump = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100);
await _box.LinkAsync(house, pump);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
await _box.MeterPriceAsync(pump, 0.22, D(2026, 1, 1));
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
CostAssert.Priced((400 * 0.30) + (200 * 0.22), bill.Total);
var houseLine = bill.Lines.Single(l => l.MeterId == house);
Assert.Equal(BillLineKind.UnitPrice, houseLine.Kind);
Assert.Equal([200d, 200d], houseLine.Quantities);
Assert.Equal(pump, Assert.Single(houseLine.Deductions).MeterId);
var pumpLine = bill.Lines.Single(l => l.MeterId == pump);
Assert.Equal(BillLineKind.OwnPrice, pumpLine.Kind);
CostAssert.Priced(44, pumpLine.Total);
var pumpCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(pump), period) { Bucket = BucketSize.Month });
Assert.Equal((MeterCostRule.BillLine, true), (pumpCost.Meter!.Rule, pumpCost.Meter.OnBill));
CostAssert.Priced(44, pumpCost.Total);
var houseCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(house), period) { Bucket = BucketSize.Month });
CostAssert.Priced(120, houseCost.Total);
}
[Fact]
public async Task Virtual_meters_are_costed_by_their_named_rule()
{
// D-39: a pure sum adds its sources' costs at their own prices; a linear formula with ownQuantity prices its
// quantity; a difference has no cost. The two rules differ when the sources are priced differently.
var type = await _box.TypeAsync();
var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 150, 120);
var generator = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 40, 50);
await _box.TypePriceAsync(type, 0.25, D(2026, 1, 1));
await _box.MeterPriceAsync(a, 0.20, D(2026, 1, 1));
await _box.MeterPriceAsync(b, 0.30, D(2026, 1, 1));
var sources = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var own = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
var difference = await _box.VirtualAsync(type, $"m{a} - m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
async Task CostOf(int meter) =>
await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), period) { Bucket = BucketSize.Month });
var summed = await CostOf(sources);
Assert.Equal(MeterCostRule.SourceCosts, summed.Meter!.Rule);
Assert.False(summed.Meter.OnBill);
Assert.Equal([a, b], summed.Meter.SourceIds);
CostAssert.Priced((180 * 0.20) + (270 * 0.30), summed.Total);
Assert.All(summed.Lines, l => Assert.Equal(sources, l.ForMeterId));
var repriced = await CostOf(own);
Assert.Equal(MeterCostRule.OwnQuantity, repriced.Meter!.Rule);
CostAssert.Priced(450 * 0.25, repriced.Total);
Assert.Equal(own, Assert.Single(repriced.Lines).MeterId);
var none = await CostOf(difference);
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.NoCostRule), (none.Meter!.Rule, none.Meter.NotCosted));
Assert.Empty(none.Lines);
Assert.Null(none.Total.Cost);
var generation = await CostOf(generator);
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.Generation), (generation.Meter!.Rule, generation.Meter.NotCosted));
// The sources are the type's bill; the virtual views never add to it.
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
Assert.Equal([a, b], bill.Lines.Select(l => l.MeterId).Order());
CostAssert.Priced((180 * 0.20) + (270 * 0.30), bill.Total);
}
[Fact]
public async Task A_virtual_meter_counted_by_an_override_is_billed_by_its_cost_rule()
{
// D-23 + D-39: "always" puts a virtual pure sum on the bill in place of its sources; the bill then prices it by
// its rule — its sources at their own prices, or its own quantity — and never both. With the rule "none" it is
// left out and named.
const string always = "{\"totals\":\"always\"}";
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
async Task<(int A, int B, short Type)> SourcesAsync()
{
var type = await _box.TypeAsync();
var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 150, 120);
await _box.TypePriceAsync(type, 0.25, D(2026, 1, 1));
await _box.MeterPriceAsync(a, 0.20, D(2026, 1, 1));
await _box.MeterPriceAsync(b, 0.30, D(2026, 1, 1));
return (a, b, type);
}
async Task BillOf(short type) =>
await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
var (a1, b1, bySources) = await SourcesAsync();
var summed = await _box.VirtualAsync(bySources, $"m{a1} + m{b1}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts, always);
var sourcesBill = await BillOf(bySources);
Assert.Equal([a1, b1], sourcesBill.Lines.Select(l => l.MeterId).Order());
Assert.All(sourcesBill.Lines, l => Assert.Equal(summed, l.ForMeterId));
CostAssert.Priced((180 * 0.20) + (270 * 0.30), sourcesBill.Total);
var summedCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(summed), period) { Bucket = BucketSize.Month });
Assert.Equal((MeterCostRule.SourceCosts, true), (summedCost.Meter!.Rule, summedCost.Meter.OnBill));
var (a2, b2, byQuantity) = await SourcesAsync();
var own = await _box.VirtualAsync(byQuantity, $"m{a2} + m{b2}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity, always);
var ownBill = await BillOf(byQuantity);
Assert.Equal(own, Assert.Single(ownBill.Lines).MeterId);
CostAssert.Priced(450 * 0.25, ownBill.Total);
var (a3, b3, uncosted) = await SourcesAsync();
var none = await _box.VirtualAsync(uncosted, $"m{a3} + m{b3}", QuantityKind.Consumption, "kWh", VirtualCostRule.None, always);
var noneBill = await BillOf(uncosted);
Assert.Empty(noneBill.Lines);
Assert.Null(noneBill.Total.Cost);
Assert.Equal(none, Assert.Single(noneBill.Attention, x => x.Kind == CostAttentionKind.VirtualNotCosted).MeterId);
}
[Fact]
public async Task Export_is_credited_at_the_feed_in_price_and_a_missing_one_is_an_optional_credit()
{
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, 300);
await _box.MonthlyReadingsAsync(export, D(2026, 1, 1), 50, 50);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
var noCredit = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
CostAssert.Priced(180, noCredit.Total);
var credit = Assert.Single(noCredit.MissingPrices);
Assert.True(credit.IsCredit);
Assert.Equal((CostStatus.NotPriced, (int?)export), (credit.Reason, credit.MeterId));
await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2026, 1, 1));
var credited = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
CostAssert.Priced(180 - 8, credited.Total);
Assert.Equal(180, credited.Total.Charges!.Value, 6);
Assert.Equal(8, credited.Total.FeedInCredit!.Value, 6);
Assert.Equal(BillLineKind.FeedIn, credited.Lines.Single(l => l.MeterId == export).Kind);
Assert.Empty(credited.MissingPrices);
}
[Fact]
public async Task Manual_costs_are_booked_once_on_their_start_day_wherever_they_belong()
{
// D-41: a manual cost on a meter belongs to that meter, its type and its categories — once each — and one
// dated after today is reported, not booked.
var type = await _box.TypeAsync();
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var booked = await _box.ManualCostAsync(D(2026, 2, 10), 50, meterId: meter);
var later = await _box.ManualCostAsync(D(2026, 10, 1), 20, meterId: meter);
var category = await _box.CategoryAsync($"cost-{Guid.NewGuid():N}", 90, meters: [meter]);
var year = Year(2026);
foreach (var scope in new[] { CostScope.ForEnergyType(type), CostScope.ForMeter(meter), CostScope.ForCategory(category) })
{
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(scope, year) { Bucket = BucketSize.Month });
CostAssert.Priced(30 + 50, result.Total);
var booking = Assert.Single(result.ManualCosts.Bookings);
Assert.Equal((booked, D(2026, 2, 10), 1, 50d), (booking.ManualCostId, booking.Day, booking.BucketIndex, booking.Amount));
CostAssert.Priced(50, result.ManualCosts.Buckets[1]);
Assert.Equal([later], result.ManualCosts.AfterTodayIds);
Assert.Equal([later], Assert.Single(result.Attention, a => a.Kind == CostAttentionKind.ManualCostAfterToday).ManualCostIds);
}
var figure = (await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(category), year) { Bucket = BucketSize.Month })).Category!;
Assert.Contains(booked, figure.ManualCostIds);
Assert.Equal([meter], figure.Cover.BilledMeterIds);
}
[Fact]
public async Task The_latest_period_with_data_includes_manual_costs()
{
// D-19: metered data ends in February; a manual cost on the meter in April makes April the latest month.
var type = await _box.TypeAsync();
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var metered = await _box.Reader().GetAvailabilityAsync(CostScope.ForEnergyType(type), Now);
Assert.Equal(new LatestPeriod(D(2026, 2, 1), LatestPeriodBasis.Meters), metered.Latest);
await _box.ManualCostAsync(D(2026, 4, 10), 12, meterId: meter);
var withManual = await _box.Reader().GetAvailabilityAsync(CostScope.ForEnergyType(type), Now);
Assert.Equal(new LatestPeriod(D(2026, 4, 1), LatestPeriodBasis.Manual), withManual.Latest);
Assert.Equal((D(2026, 1, 1), D(2026, 4, 10)), (withManual.Range!.FirstDay, withManual.Range.LastDay));
// The priced result reports the same, whatever period it prices.
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), Preset(PeriodPreset.MonthToDate)));
Assert.Equal(withManual.Latest, result.Availability.Latest);
// A month with both is both.
await _box.ManualCostAsync(D(2026, 2, 3), 5, meterId: meter);
var both = await _box.Reader().GetAvailabilityAsync(CostScope.ForMeter(meter), new DateTimeOffset(2026, 3, 15, 12, 0, 0, TimeSpan.Zero));
Assert.Equal(new LatestPeriod(D(2026, 2, 1), LatestPeriodBasis.Both), both.Latest);
}
[Fact]
public async Task Unknown_scopes_and_too_many_points_are_refused_before_pricing()
{
var reader = _box.Reader();
Assert.Equal(CostRefusal.UnknownScope, (await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(-1), Month(2026, 1)))).Refusal);
Assert.Equal(CostRefusal.UnknownScope, (await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(-1), Month(2026, 1)))).Refusal);
var days = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Preset(PeriodPreset.Last24Months)) { Bucket = BucketSize.Day });
Assert.Equal(CostRefusal.TooManyPoints, days.Refusal);
Assert.Equal(BucketSize.Week, days.Plan.Suggested);
Assert.Null(days.Total.Cost);
var other = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, TimeZoneInfo.Utc);
await Assert.ThrowsAsync(() => reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, other)));
}
}