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