diff --git a/src/ExpenseExplorer.Api/Budget/BudgetEndpoints.cs b/src/ExpenseExplorer.Api/Budget/BudgetEndpoints.cs index 95c6889..c8ef186 100644 --- a/src/ExpenseExplorer.Api/Budget/BudgetEndpoints.cs +++ b/src/ExpenseExplorer.Api/Budget/BudgetEndpoints.cs @@ -17,6 +17,7 @@ public static RouteGroupBuilder MapBudget(this RouteGroupBuilder api) budget.MapGet("/periods", async (IBudgetQueries queries, CancellationToken ct) => Results.Ok(await queries.PeriodsAsync(ct))); budget.MapGet("/periods/current", CurrentAsync); + budget.MapGet("/history", HistoryAsync); budget.MapGet("/periods/{periodId:guid}", GetAsync); budget.MapPost("/periods", CreatePeriodAsync); budget.MapDelete("/periods/{periodId:guid}", (Guid periodId, IBudgetStore store, CancellationToken ct) => @@ -70,6 +71,14 @@ private static async Task CurrentAsync(IBudgetQueries queries, TimeProv : ErrorResults.From([BudgetErrors.NoCurrentPeriod]); } + /// The last periods (6 by default) that have started, oldest first. + private static Task HistoryAsync(int? count, IBudgetQueries queries, TimeProvider clock, CancellationToken cancellationToken) => + Input.InRange(count, defaultValue: 6, min: 1, max: 24) + .ForTarget("count") + .ToHttpAsync( + async periods => Result.Success(await queries.HistoryAsync(periods, clock.Today(), cancellationToken)), + history => Results.Ok(history)); + private static async Task GetAsync(Guid periodId, IBudgetQueries queries, TimeProvider clock, CancellationToken cancellationToken) => await queries.GetAsync(periodId, clock.Today(), cancellationToken) is { } budget ? Results.Ok(budget) diff --git a/src/ExpenseExplorer.Api/Reports/ReportEndpoints.cs b/src/ExpenseExplorer.Api/Reports/ReportEndpoints.cs index de48018..4576b52 100644 --- a/src/ExpenseExplorer.Api/Reports/ReportEndpoints.cs +++ b/src/ExpenseExplorer.Api/Reports/ReportEndpoints.cs @@ -9,6 +9,7 @@ internal static class ReportEndpoints public static RouteGroupBuilder MapReports(this RouteGroupBuilder api) { api.MapGet("/reports/categories", CategoriesAsync).WithTags("Reports"); + api.MapGet("/reports/monthly", MonthlyAsync).WithTags("Reports"); return api; } @@ -24,6 +25,18 @@ private static Task CategoriesAsync( async period => Result.Success(await reports.CategoriesAsync(period.From, period.To, cancellationToken)), report => Results.Ok(report)); + /// The last calendar months (6 by default), the current one included. + private static Task MonthlyAsync( + int? months, + IReportQueries reports, + TimeProvider clock, + CancellationToken cancellationToken) => + Input.InRange(months, defaultValue: 6, min: 1, max: 24) + .ForTarget("months") + .ToHttpAsync( + async count => Result.Success(await reports.MonthlyAsync(clock.Today(), count, cancellationToken)), + report => Results.Ok(report)); + internal static Result<(DateOnly From, DateOnly To)> ParsePeriod(DateOnly? from, DateOnly? to, DateOnly today) => Input.OrderedRange(from ?? new DateOnly(today.Year, today.Month, 1), to ?? today) .ForTarget("from") diff --git a/src/ExpenseExplorer.Application/Budget/IBudgetQueries.cs b/src/ExpenseExplorer.Application/Budget/IBudgetQueries.cs index bf475f0..de18e46 100644 --- a/src/ExpenseExplorer.Application/Budget/IBudgetQueries.cs +++ b/src/ExpenseExplorer.Application/Budget/IBudgetQueries.cs @@ -9,6 +9,9 @@ public interface IBudgetQueries /// The period with spending from receipts so far; null when there is no such period. Task GetAsync(Guid periodId, DateOnly today, CancellationToken cancellationToken); + /// The last periods that have started by , oldest first. + Task> HistoryAsync(int count, DateOnly today, CancellationToken cancellationToken); + Task PeriodOnAsync(DateOnly day, CancellationToken cancellationToken); Task> GroupsAsync(CancellationToken cancellationToken); diff --git a/src/ExpenseExplorer.Application/Receipts/ReceiptResponses.cs b/src/ExpenseExplorer.Application/Receipts/ReceiptResponses.cs index 368e22f..b0f33cd 100644 --- a/src/ExpenseExplorer.Application/Receipts/ReceiptResponses.cs +++ b/src/ExpenseExplorer.Application/Receipts/ReceiptResponses.cs @@ -11,6 +11,7 @@ public static ReceiptResponse From(Receipt receipt) => receipt.Store.Value, receipt.PurchaseDate.Value, receipt.Total.Value, + receipt.Discount.Value, [.. receipt.Items.Select(From)]); public static ReceiptItemResponse From(ReceiptItem item) => diff --git a/src/ExpenseExplorer.Application/Reports/IReportQueries.cs b/src/ExpenseExplorer.Application/Reports/IReportQueries.cs index 1446b9d..f68518d 100644 --- a/src/ExpenseExplorer.Application/Reports/IReportQueries.cs +++ b/src/ExpenseExplorer.Application/Reports/IReportQueries.cs @@ -5,4 +5,7 @@ namespace ExpenseExplorer.Application.Reports; public interface IReportQueries { Task CategoriesAsync(DateOnly start, DateOnly end, CancellationToken cancellationToken); + + /// Spending per category in calendar months up to the one with . + Task MonthlyAsync(DateOnly lastDay, int count, CancellationToken cancellationToken); } diff --git a/src/ExpenseExplorer.Contracts/Budget/BudgetContracts.cs b/src/ExpenseExplorer.Contracts/Budget/BudgetContracts.cs index 1bcb6c2..d76fb81 100644 --- a/src/ExpenseExplorer.Contracts/Budget/BudgetContracts.cs +++ b/src/ExpenseExplorer.Contracts/Budget/BudgetContracts.cs @@ -61,6 +61,21 @@ public sealed record BudgetResponse( int DaysLeft, decimal? PerDay); +/// +/// One period in short, for comparing periods: plan and spending per group, spending outside groups, +/// and , roughly what is left at the end. +/// +public sealed record PeriodResultResponse( + PeriodResponse Period, + decimal TotalFunds, + decimal Planned, + decimal Spent, + decimal FreePool, + IReadOnlyList Groups, + decimal OutsideGroups); + +public sealed record GroupResultResponse(Guid Id, string Name, decimal Planned, decimal Spent); + public sealed record TemplateResponse(IReadOnlyList Funds, IReadOnlyList Items); public sealed record TemplateItemResponse(Guid Id, string Group, string Name, decimal Amount); diff --git a/src/ExpenseExplorer.Contracts/Receipts/ReceiptResponses.cs b/src/ExpenseExplorer.Contracts/Receipts/ReceiptResponses.cs index c8f89c8..75dc185 100644 --- a/src/ExpenseExplorer.Contracts/Receipts/ReceiptResponses.cs +++ b/src/ExpenseExplorer.Contracts/Receipts/ReceiptResponses.cs @@ -7,6 +7,7 @@ public sealed record ReceiptResponse( string Store, DateOnly PurchaseDate, decimal Total, + decimal Discount, IReadOnlyList Items); public sealed record ReceiptItemResponse( @@ -26,10 +27,11 @@ public sealed record ReceiptSummaryResponse( DateOnly PurchaseDate, decimal Total); -/// One page of receipts plus the total cost of every receipt matching the filter. +/// One page of receipts plus the total cost and total discount of every receipt matching the filter. public sealed record ReceiptListResponse( PageResponse Receipts, - decimal TotalCost); + decimal TotalCost, + decimal TotalDiscount); /// /// A receipt read from a photo, with what to check against the paper: diff --git a/src/ExpenseExplorer.Contracts/Reports/MonthlyReportResponse.cs b/src/ExpenseExplorer.Contracts/Reports/MonthlyReportResponse.cs new file mode 100644 index 0000000..50021d7 --- /dev/null +++ b/src/ExpenseExplorer.Contracts/Reports/MonthlyReportResponse.cs @@ -0,0 +1,12 @@ +namespace ExpenseExplorer.Contracts.Reports; + +/// +/// Spending per calendar month, oldest month first. and each category's totals +/// have one value per month, in the order of . Categories come largest first. +/// +public sealed record MonthlyReportResponse( + IReadOnlyList Months, + IReadOnlyList Totals, + IReadOnlyList Categories); + +public sealed record CategoryMonthsResponse(string Category, IReadOnlyList Totals, decimal Total); diff --git a/src/ExpenseExplorer.Domain/Receipts/Receipt.cs b/src/ExpenseExplorer.Domain/Receipts/Receipt.cs index 1678ccd..829a17d 100644 --- a/src/ExpenseExplorer.Domain/Receipts/Receipt.cs +++ b/src/ExpenseExplorer.Domain/Receipts/Receipt.cs @@ -28,6 +28,9 @@ private Receipt(ReceiptId id, StoreName store, PurchaseDate purchaseDate, List Money.Sum(_items.Select(item => item.Total)); + /// What the discounts on all lines took off. + public Money Discount => Money.Sum(_items.Select(item => item.Purchase.Price.Discount)); + public static Receipt Create(ReceiptId id, StoreName store, PurchaseDate purchaseDate) { ArgumentNullException.ThrowIfNull(id); diff --git a/src/ExpenseExplorer.Infrastructure/Budget/BudgetQueries.cs b/src/ExpenseExplorer.Infrastructure/Budget/BudgetQueries.cs index 404912e..01aaba1 100644 --- a/src/ExpenseExplorer.Infrastructure/Budget/BudgetQueries.cs +++ b/src/ExpenseExplorer.Infrastructure/Budget/BudgetQueries.cs @@ -14,6 +14,34 @@ await db.BudgetPeriods.AsNoTracking() .Select(p => new PeriodResponse(p.Id, p.Start, p.End)) .ToListAsync(cancellationToken); + public async Task> HistoryAsync(int count, DateOnly today, CancellationToken cancellationToken) + { + List periodIds = await db.BudgetPeriods.AsNoTracking() + .Where(p => p.Start <= today) + .OrderByDescending(p => p.Start) + .Take(count) + .Select(p => p.Id) + .ToListAsync(cancellationToken); + + List history = []; + foreach (Guid periodId in Enumerable.Reverse(periodIds)) + { + if (await GetAsync(periodId, today, cancellationToken) is { } budget) + { + history.Add(new PeriodResultResponse( + budget.Period, + budget.TotalFunds, + budget.Planned, + budget.Spent, + budget.FreePool, + [.. budget.Groups.Select(group => new GroupResultResponse(group.Id, group.Name, group.Planned, group.Spent))], + budget.Unassigned.Sum(category => category.Spent))); + } + } + + return history; + } + public async Task PeriodOnAsync(DateOnly day, CancellationToken cancellationToken) => await db.BudgetPeriods.AsNoTracking() .Where(p => p.Start <= day && p.End >= day) diff --git a/src/ExpenseExplorer.Infrastructure/Receipts/ReceiptQueries.cs b/src/ExpenseExplorer.Infrastructure/Receipts/ReceiptQueries.cs index 5158884..0218bd8 100644 --- a/src/ExpenseExplorer.Infrastructure/Receipts/ReceiptQueries.cs +++ b/src/ExpenseExplorer.Infrastructure/Receipts/ReceiptQueries.cs @@ -25,6 +25,7 @@ public async Task ListAsync(ReceiptListQuery query, Cancell int totalCount = await filtered.CountAsync(cancellationToken); decimal totalCost = await filtered.SumAsync(r => r.Total, cancellationToken); + decimal totalDiscount = await filtered.SumAsync(r => r.Discount, cancellationToken); List page = await Sort(filtered, query) .Skip((query.Page - 1) * query.PageSize) .Take(query.PageSize) @@ -33,7 +34,8 @@ public async Task ListAsync(ReceiptListQuery query, Cancell return new ReceiptListResponse( new PageResponse(page, query.Page, query.PageSize, totalCount), - totalCost); + totalCost, + totalDiscount); } /// Totals computed in SQL the same way as : amount minus discount. @@ -44,6 +46,7 @@ private static IQueryable Summaries(IQueryable receipts) Store = r.Store, PurchaseDate = r.PurchaseDate, Total = r.Items.Sum(i => i.Amount - i.Discount), + Discount = r.Items.Sum(i => i.Discount), }); private static IQueryable Filter(IQueryable receipts, ReceiptListQuery query) @@ -104,5 +107,7 @@ private sealed class SummaryRow public DateOnly PurchaseDate { get; init; } public decimal Total { get; init; } + + public decimal Discount { get; init; } } } diff --git a/src/ExpenseExplorer.Infrastructure/Reports/ReportQueries.cs b/src/ExpenseExplorer.Infrastructure/Reports/ReportQueries.cs index 8fe386d..49ba62e 100644 --- a/src/ExpenseExplorer.Infrastructure/Reports/ReportQueries.cs +++ b/src/ExpenseExplorer.Infrastructure/Reports/ReportQueries.cs @@ -23,4 +23,39 @@ group item by item.Category into category [.. categories.OrderByDescending(c => c.Total).ThenBy(c => c.Category, StringComparer.Ordinal)], categories.Sum(c => c.Total)); } + + public async Task MonthlyAsync(DateOnly lastDay, int count, CancellationToken cancellationToken) + { + DateOnly lastMonth = new(lastDay.Year, lastDay.Month, 1); + DateOnly start = lastMonth.AddMonths(1 - count); + DateOnly end = lastMonth.AddMonths(1).AddDays(-1); + var spent = await ( + from item in db.ReceiptItems.AsNoTracking() + join receipt in db.Receipts.AsNoTracking() on item.ReceiptId equals receipt.Id + where receipt.PurchaseDate >= start && receipt.PurchaseDate <= end + group item by new { item.Category, receipt.PurchaseDate.Year, receipt.PurchaseDate.Month } into month + select new { month.Key.Category, month.Key.Year, month.Key.Month, Total = month.Sum(i => i.Amount - i.Discount) }) + .ToListAsync(cancellationToken); + + DateOnly[] months = [.. Enumerable.Range(0, count).Select(start.AddMonths)]; + List categories = + [ + .. spent + .GroupBy(row => row.Category, StringComparer.Ordinal) + .Select(category => + { + decimal[] totals = [.. months.Select(month => category + .Where(row => row.Year == month.Year && row.Month == month.Month) + .Sum(row => row.Total))]; + return new CategoryMonthsResponse(category.Key, totals, totals.Sum()); + }) + .OrderByDescending(category => category.Total) + .ThenBy(category => category.Category, StringComparer.Ordinal), + ]; + + return new MonthlyReportResponse( + months, + [.. months.Select((_, index) => categories.Sum(category => category.Totals[index]))], + categories); + } } diff --git a/src/ExpenseExplorer.Web/Api/ExpenseApi.cs b/src/ExpenseExplorer.Web/Api/ExpenseApi.cs index 88e0d7c..216ceee 100644 --- a/src/ExpenseExplorer.Web/Api/ExpenseApi.cs +++ b/src/ExpenseExplorer.Web/Api/ExpenseApi.cs @@ -86,6 +86,9 @@ public Task> ReceiptItemsAsync(ReceiptItemLis public Task> CategoryReportAsync(DateOnly? from, DateOnly? to) => GetAsync("api/v1/reports/categories" + new QueryString().Add("from", from).Add("to", to)); + public Task> MonthlyReportAsync(int months) => + GetAsync("api/v1/reports/monthly" + new QueryString().Add("months", months)); + public Task> LogsAsync(LogListRequest request) => GetAsync("api/v1/logs" + ListQueries.ToQuery(request)); @@ -106,6 +109,10 @@ public Task> RenameAsync(Suggestions kind, RenameN public Task>> BudgetPeriodsAsync() => GetAsync>($"{Budget}/periods"); + /// The last periods that have started, oldest first. + public Task>> BudgetHistoryAsync(int count) => + GetAsync>($"{Budget}/history" + new QueryString().Add("count", count)); + /// The period covering today, or the given one. public Task> BudgetAsync(Guid? periodId) => GetAsync($"{Budget}/periods/{(periodId is { } id ? id.ToString() : "current")}"); diff --git a/src/ExpenseExplorer.Web/Api/ListQueries.cs b/src/ExpenseExplorer.Web/Api/ListQueries.cs index 8560aeb..4a6b206 100644 --- a/src/ExpenseExplorer.Web/Api/ListQueries.cs +++ b/src/ExpenseExplorer.Web/Api/ListQueries.cs @@ -11,17 +11,7 @@ namespace ExpenseExplorer.Web.Api; public static class ListQueries { public static string ToQuery(ReceiptListRequest request) => - new QueryString() - .Add("stores", request.Stores) - .Add("from", request.From) - .Add("to", request.To) - .Add("totalMin", request.TotalMin) - .Add("totalMax", request.TotalMax) - .Add("sortBy", request.SortBy) - .Add("direction", request.Direction) - .Add("page", request.Page) - .Add("pageSize", request.PageSize) - .ToString(); + ReceiptListQuery(request, new QueryString().Add("from", request.From)); public static string ToQuery(ReceiptItemListRequest request) => new QueryString() @@ -56,6 +46,28 @@ public static string ToQuery(LogListRequest request) => .Add("pageSize", request.PageSize) .ToString(); + /// + /// The receipts page address. It always names "from": without it the page starts from + /// , and with it empty the page shows receipts of every date. + /// + public static string ToPageQuery(ReceiptListRequest request) => + ReceiptListQuery(request, new QueryString().AddKeepingName("from", request.From)); + + /// The first day of the previous month: the whole of last month and the current one so far. + public static DateOnly DefaultReceiptsFrom(DateOnly today) => new DateOnly(today.Year, today.Month, 1).AddMonths(-1); + + private static string ReceiptListQuery(ReceiptListRequest request, QueryString query) => + query + .Add("stores", request.Stores) + .Add("to", request.To) + .Add("totalMin", request.TotalMin) + .Add("totalMax", request.TotalMax) + .Add("sortBy", request.SortBy) + .Add("direction", request.Direction) + .Add("page", request.Page) + .Add("pageSize", request.PageSize) + .ToString(); + public static ReceiptItemListRequest ReceiptItemList(QueryReader query) => new() { @@ -81,11 +93,11 @@ public static ReceiptItemListRequest ReceiptItemList(QueryReader query) => PageSize = query.WholeNumber("pageSize"), }; - public static ReceiptListRequest ReceiptList(QueryReader query) => + public static ReceiptListRequest ReceiptList(QueryReader query, DateOnly today) => new() { Stores = query.Texts("stores"), - From = query.Date("from"), + From = query.Has("from") ? query.Date("from") : DefaultReceiptsFrom(today), To = query.Date("to"), TotalMin = query.Number("totalMin"), TotalMax = query.Number("totalMax"), diff --git a/src/ExpenseExplorer.Web/Api/QueryReader.cs b/src/ExpenseExplorer.Web/Api/QueryReader.cs index 6690528..0aad975 100644 --- a/src/ExpenseExplorer.Web/Api/QueryReader.cs +++ b/src/ExpenseExplorer.Web/Api/QueryReader.cs @@ -11,6 +11,8 @@ public sealed class QueryReader(string query) public static QueryReader Of(Uri uri) => new(uri.Query); + public bool Has(string name) => _values.AllKeys.Contains(name); + public string? Text(string name) => _values[name] is { Length: > 0 } value ? value : null; public string[]? Texts(string name) => _values.GetValues(name) is { Length: > 0 } values ? values : null; diff --git a/src/ExpenseExplorer.Web/Api/QueryString.cs b/src/ExpenseExplorer.Web/Api/QueryString.cs index a248970..b3b84ef 100644 --- a/src/ExpenseExplorer.Web/Api/QueryString.cs +++ b/src/ExpenseExplorer.Web/Api/QueryString.cs @@ -41,6 +41,18 @@ public QueryString Add(string name, TEnum? value) where TEnum : struct, Enum => Add(name, value?.ToString()); + /// Writes the name even without a date, so "no date" can be told apart from "not given". + public QueryString AddKeepingName(string name, DateOnly? value) + { + if (value is null) + { + _parts.Add($"{name}="); + return this; + } + + return Add(name, value); + } + public override string ToString() => _parts.Count == 0 ? "" : new StringBuilder("?").AppendJoin('&', _parts).ToString(); } diff --git a/src/ExpenseExplorer.Web/Components/BudgetHistory.razor b/src/ExpenseExplorer.Web/Components/BudgetHistory.razor new file mode 100644 index 0000000..a461302 --- /dev/null +++ b/src/ExpenseExplorer.Web/Components/BudgetHistory.razor @@ -0,0 +1,143 @@ +@inject ExpenseApi Api +@inject Language Language + + + + @foreach (int count in CountChoices) + { + @Language.Text.LastPeriods(count) + } + + + +@if (_problem is not null) +{ + +} +else if (_periods is null) +{ + +} +else if (_periods.Count == 0) +{ + @Language.Text.NoPeriodsYet +} +else +{ + IReadOnlyList finished = [.. _periods.Where(period => period.Period.End < Today)]; + + + + + @Language.Text.Group + @foreach (PeriodResponse period in _periods.Select(period => period.Period)) + { + @ShortDate(period.Start) – @ShortDate(period.End) + } + @Language.Text.Average + + + + @foreach ((Guid id, string name) in Groups) + { + + @name + @foreach (PeriodResultResponse period in _periods) + { + @Cell(period.Groups.FirstOrDefault(group => group.Id == id)) + } + + @Cell(finished.Count == 0 ? null : new GroupResultResponse( + id, + name, + finished.Average(period => PlannedIn(period, id)), + finished.Average(period => SpentIn(period, id)))) + + + } + + @Language.Text.OutsideGroups + @foreach (PeriodResultResponse period in _periods) + { + @Display.WholeMoney(period.OutsideGroups) + } + @AverageOf(finished, period => period.OutsideGroups) + + + @Language.Text.TotalSpent + @foreach (PeriodResultResponse period in _periods) + { + @Display.WholeMoney(period.Spent) + } + @AverageOf(finished, period => period.Spent) + + + @Language.Text.Funds + @foreach (PeriodResultResponse period in _periods) + { + @Display.WholeMoney(period.TotalFunds) + } + @AverageOf(finished, period => period.TotalFunds) + + + @Language.Text.FreePool + @foreach (decimal freePool in _periods.Select(period => period.FreePool)) + { + @Display.WholeMoney(freePool) + } + @AverageOf(finished, period => period.FreePool) + + + + + @Language.Text.AverageOfFinishedPeriods +} + +@code { + private static readonly int[] CountChoices = [3, 6, 12]; + + private int _count = 6; + private IReadOnlyList? _periods; + private ApiProblem? _problem; + + private static DateOnly Today => DateOnly.FromDateTime(DateTime.Today); + + /// Every group seen in the periods, in the order the newest period lists them. + private IEnumerable<(Guid Id, string Name)> Groups => + (_periods ?? []).Reverse().SelectMany(period => period.Groups).DistinctBy(group => group.Id).Select(group => (group.Id, group.Name)); + + protected override Task OnInitializedAsync() => LoadAsync(_count); + + private async Task LoadAsync(int count) + { + _count = count; + ApiResult> result = await Api.BudgetHistoryAsync(count); + _periods = result.ValueOrDefault; + _problem = result.Problem; + } + + private static decimal PlannedIn(PeriodResultResponse period, Guid groupId) => + period.Groups.FirstOrDefault(group => group.Id == groupId)?.Planned ?? 0m; + + private static decimal SpentIn(PeriodResultResponse period, Guid groupId) => + period.Groups.FirstOrDefault(group => group.Id == groupId)?.Spent ?? 0m; + + private static string AverageOf(IReadOnlyList finished, Func value) => + finished.Count == 0 ? "–" : Display.WholeMoney(finished.Average(value)); + + private static string ShortDate(DateOnly date) => date.ToString("dd.MM", System.Globalization.CultureInfo.InvariantCulture); + + /// What was spent, with the plan under it; red once spending goes over the plan. + private RenderFragment Cell(GroupResultResponse? group) => + @ + @if (group is null) + { + – + } + else + { + @Display.WholeMoney(group.Spent) + @Language.Text.OfPlanned(Display.WholeMoney(group.Planned)) + } + ; +} diff --git a/src/ExpenseExplorer.Web/Components/CategoryReport.razor b/src/ExpenseExplorer.Web/Components/CategoryReport.razor new file mode 100644 index 0000000..fc0d7ad --- /dev/null +++ b/src/ExpenseExplorer.Web/Components/CategoryReport.razor @@ -0,0 +1,78 @@ +@inject ExpenseApi Api +@inject Language Language + + + + + + + + +@if (_problem is not null) +{ + +} +else if (_report is null) +{ + +} +else if (_report.Categories.Count == 0) +{ + @Language.Text.NothingFound +} +else +{ + + + @foreach ((CategoryExpenseResponse category, int rank) in _report.Categories.Select((category, rank) => (category, rank))) + { + +
+ @category.Category + @Share(category.Total) + @Display.Money(category.Total) +
+ +
+ + } +
+ @Language.Text.Total + @Display.Money(_report.Total) +
+
+
+} + +@code { + // Neighbouring bars get different colours so the categories are easy to tell apart. + private static readonly Color[] BarColors = [Color.Primary, Color.Info, Color.Warning, Color.Secondary]; + + private DateOnly? _from; + private DateOnly? _to; + private CategoryReportResponse? _report; + private ApiProblem? _problem; + + // Without dates the API reports the current month, and says which dates it used. + protected override Task OnInitializedAsync() => ChangeAsync(null, null); + + private async Task ChangeAsync(DateOnly? from, DateOnly? to) + { + (_from, _to) = (from, to); + ApiResult result = await Api.CategoryReportAsync(from, to); + _report = result.ValueOrDefault; + _problem = result.Problem; + if (_report is not null) + { + (_from, _to) = (_report.From, _report.To); + } + } + + // Bars are relative to the largest category, the percentage to the whole. + private string Share(decimal total) => + _report is { Total: > 0 } report ? (total / report.Total).ToString("P0", System.Globalization.CultureInfo.CurrentCulture) : ""; + + private string ItemsOf(string category) => + Routes.ReceiptItems(new ReceiptItemListRequest { Categories = [category], From = _from, To = _to }); +} diff --git a/src/ExpenseExplorer.Web/Components/DateInput.razor b/src/ExpenseExplorer.Web/Components/DateInput.razor index 339f8a7..9b88b0c 100644 --- a/src/ExpenseExplorer.Web/Components/DateInput.razor +++ b/src/ExpenseExplorer.Web/Components/DateInput.razor @@ -1,12 +1,12 @@ @using System.Globalization -@* The browser's own date picker: the most convenient one on phones. *@ - +@* MudBlazor's picker rather than the browser's: the browser shows dates in its own language, not the app's. + Tapping the field opens the calendar, so no icon takes room from the date. *@ + @code { - private const string Format = "yyyy-MM-dd"; - [Parameter] public DateOnly? Value { get; set; } @@ -22,9 +22,8 @@ [Parameter] public bool Clearable { get; set; } - private string? Text => Value?.ToString(Format, CultureInfo.InvariantCulture); + private DateTime? Date => Value?.ToDateTime(TimeOnly.MinValue); - private Task OnChanged(string? text) => - ValueChanged.InvokeAsync( - DateOnly.TryParseExact(text, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly date) ? date : null); + private Task OnChanged(DateTime? date) => + ValueChanged.InvokeAsync(date is { } value ? DateOnly.FromDateTime(value) : null); } diff --git a/src/ExpenseExplorer.Web/Components/MonthlyReport.razor b/src/ExpenseExplorer.Web/Components/MonthlyReport.razor new file mode 100644 index 0000000..85ed3a9 --- /dev/null +++ b/src/ExpenseExplorer.Web/Components/MonthlyReport.razor @@ -0,0 +1,130 @@ +@inject ExpenseApi Api +@inject Language Language +@inject IJSRuntime Js + + +
+ + @foreach (int months in MonthChoices) + { + @Language.Text.LastMonths(months) + } + + + @Language.Text.AllCategories + @foreach (string category in (_report?.Categories ?? []).Select(category => category.Category)) + { + @category + } + +
+
+ +@if (_problem is not null) +{ + +} +else if (_report is null) +{ + +} +else +{ + IReadOnlyList totals = Selected?.Totals ?? _report.Totals; + decimal highest = Math.Max(totals.Max(), 1m); + +
+ @(Selected?.Category ?? Language.Text.AllCategories) + @if (AverageOf(totals) is { } average) + { + @Language.Text.AveragePerMonth: @Display.WholeMoney(average) + } +
+ @* One bar per month, scaled to the highest; the current month is paler because it is not over yet. *@ + + @Language.Text.AverageOfFullMonths +
+ + @if (_report.Categories.Count == 0) + { + @Language.Text.NothingFound + } + else + { + + + @foreach (CategoryMonthsResponse category in _report.Categories) + { + +
+ @category.Category +
+ @Display.WholeMoney(AverageOf(category.Totals) ?? category.Total) + @Language.Text.Total @Display.WholeMoney(category.Total) +
+
+
+ + } +
+
+ } +} + +@code { + private const string AllCategories = ""; + private static readonly int[] MonthChoices = [6, 12, 24]; + + private int _months = MonthChoices[0]; + private string _category = AllCategories; + private MonthlyReportResponse? _report; + private ApiProblem? _problem; + + private CategoryMonthsResponse? Selected => _report?.Categories.FirstOrDefault(category => category.Category == _category); + + protected override Task OnInitializedAsync() => LoadAsync(_months); + + private async Task LoadAsync(int months) + { + _months = months; + ApiResult result = await Api.MonthlyReportAsync(months); + _report = result.ValueOrDefault; + _problem = result.Problem; + } + + /// The average of the full months; the current one is left out, as it has only just begun. + private static decimal? AverageOf(IReadOnlyList totals) => + totals.Count > 1 ? totals.Take(totals.Count - 1).Average() : null; + + private static string Percent(decimal value, decimal highest) => + (value / highest * 100).ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) + "%"; + + // The chart is above the list, out of sight on a phone. + private async Task SelectAsync(string category) + { + _category = category; + await Js.InvokeVoidAsync("window.scrollTo", 0, 0); + } + + private string RowClass(CategoryMonthsResponse category) => + category.Category == _category ? "list-row mud-selected-item" : "list-row"; +} diff --git a/src/ExpenseExplorer.Web/Display.cs b/src/ExpenseExplorer.Web/Display.cs index 2981c86..f499980 100644 --- a/src/ExpenseExplorer.Web/Display.cs +++ b/src/ExpenseExplorer.Web/Display.cs @@ -7,6 +7,17 @@ public static class Display { public static string Money(decimal value) => value.ToString("N2", CultureInfo.CurrentCulture) + " zł"; + /// Whole złoty, for tables and charts where the grosze would only crowd the numbers. + public static string WholeMoney(decimal value) => value.ToString("N0", CultureInfo.CurrentCulture) + " zł"; + + /// Short enough for a bar label: thousands as "k". + public static string Compact(decimal value) => + value >= 1000m + ? (value / 1000m).ToString("0.#", CultureInfo.CurrentCulture) + "k" + : value.ToString("0", CultureInfo.CurrentCulture); + + public static string Month(DateOnly value) => value.ToString("MMM yy", CultureInfo.CurrentCulture); + public static string Quantity(decimal value) => value.ToString("0.####", CultureInfo.CurrentCulture); public static string Date(DateOnly value) => value.ToString("d", CultureInfo.CurrentCulture); diff --git a/src/ExpenseExplorer.Web/Localization/UiText.cs b/src/ExpenseExplorer.Web/Localization/UiText.cs index 17b93a7..8cabe8d 100644 --- a/src/ExpenseExplorer.Web/Localization/UiText.cs +++ b/src/ExpenseExplorer.Web/Localization/UiText.cs @@ -74,6 +74,20 @@ public sealed class UiText public required string Deleted { get; init; } public required string CategoryReport { get; init; } public required string ShowItems { get; init; } + public required string Discounts { get; init; } + public required string ByMonth { get; init; } + public required string Range { get; init; } + public required Func LastMonths { get; init; } + public required Func LastPeriods { get; init; } + public required string AllCategories { get; init; } + public required string AveragePerMonth { get; init; } + public required string AverageOfFullMonths { get; init; } + public required string SoFar { get; init; } + public required string Average { get; init; } + public required string AverageOfFinishedPeriods { get; init; } + public required string TotalSpent { get; init; } + public required Func OfPlanned { get; init; } + public required string NoPeriodsYet { get; init; } public required string NotFound { get; init; } public required string UnexpectedError { get; init; } public required string InvalidNumber { get; init; } @@ -201,6 +215,20 @@ public sealed class UiText Saved = "Zapisano.", Deleted = "Usunięto.", CategoryReport = "Wydatki według kategorii", + Discounts = "Rabaty", + ByMonth = "Miesiące", + Range = "Zakres", + LastMonths = count => $"Ostatnie {count} mies.", + LastPeriods = count => count is >= 2 and <= 4 ? $"Ostatnie {count} okresy" : $"Ostatnie {count} okresów", + AllCategories = "Wszystkie kategorie", + AveragePerMonth = "Średnio na miesiąc", + AverageOfFullMonths = "Średnia z pełnych miesięcy, bez bieżącego.", + SoFar = "do dziś", + Average = "Średnio", + AverageOfFinishedPeriods = "Średnio liczone z zakończonych okresów. Na czerwono: wydano więcej niż zaplanowano.", + TotalSpent = "Razem wydane", + OfPlanned = planned => $"z {planned}", + NoPeriodsYet = "Nie ma jeszcze okresów budżetu.", ShowItems = "Pokaż pozycje", NotFound = "Nie ma takiej strony.", UnexpectedError = "Coś poszło nie tak. Spróbuj ponownie.", @@ -418,6 +446,20 @@ public sealed class UiText Saved = "Saved.", Deleted = "Deleted.", CategoryReport = "Spending by category", + Discounts = "Discounts", + ByMonth = "Months", + Range = "Range", + LastMonths = count => $"Last {count} months", + LastPeriods = count => $"Last {count} periods", + AllCategories = "All categories", + AveragePerMonth = "Average per month", + AverageOfFullMonths = "Average of full months, the current one left out.", + SoFar = "so far", + Average = "Average", + AverageOfFinishedPeriods = "Average of finished periods. In red: more spent than planned.", + TotalSpent = "Total spent", + OfPlanned = planned => $"of {planned}", + NoPeriodsYet = "There are no budget periods yet.", ShowItems = "Show items", NotFound = "There is no such page.", UnexpectedError = "Something went wrong. Please try again.", diff --git a/src/ExpenseExplorer.Web/Pages/Receipt.razor b/src/ExpenseExplorer.Web/Pages/Receipt.razor index e2e2f3e..05d7c03 100644 --- a/src/ExpenseExplorer.Web/Pages/Receipt.razor +++ b/src/ExpenseExplorer.Web/Pages/Receipt.razor @@ -27,7 +27,13 @@ else @_receipt.Store @Display.Date(_receipt.PurchaseDate) - @Display.Money(_receipt.Total) +
+ @Display.Money(_receipt.Total) + @if (_receipt.Discount > 0) + { + @Language.Text.Discount: @Display.Money(_receipt.Discount) + } +
@if (Auth.CanEdit) { diff --git a/src/ExpenseExplorer.Web/Pages/Receipts.razor b/src/ExpenseExplorer.Web/Pages/Receipts.razor index d05728b..288c964 100644 --- a/src/ExpenseExplorer.Web/Pages/Receipts.razor +++ b/src/ExpenseExplorer.Web/Pages/Receipts.razor @@ -61,6 +61,10 @@
@Language.Text.TotalOfFiltered @Display.Money(_list.TotalCost) + @if (_list.TotalDiscount > 0) + { + @Language.Text.Discounts: @Display.Money(_list.TotalDiscount) + }
} @@ -136,7 +140,7 @@ else // The address is the single source of the filter: reading it after every change keeps the back button working. private async Task LoadAsync() { - _filter = ListQueries.ReceiptList(QueryReader.Of(new Uri(Navigation.Uri))); + _filter = ListQueries.ReceiptList(QueryReader.Of(new Uri(Navigation.Uri)), DateOnly.FromDateTime(DateTime.Today)); _draft = _filter; ApiResult result = await Api.ReceiptsAsync(_filter); _list = result.ValueOrDefault; diff --git a/src/ExpenseExplorer.Web/Pages/Reports.razor b/src/ExpenseExplorer.Web/Pages/Reports.razor index 9452fad..cef60c4 100644 --- a/src/ExpenseExplorer.Web/Pages/Reports.razor +++ b/src/ExpenseExplorer.Web/Pages/Reports.razor @@ -1,84 +1,40 @@ @page "/reports" -@inject ExpenseApi Api +@inject AuthSession Auth @inject Language Language @inject NavigationManager Navigation @Language.Text.Reports · Expense Explorer -@Language.Text.CategoryReport - - - - - - - - -@if (_problem is not null) -{ - -} -else if (_report is null) -{ - -} -else if (_report.Categories.Count == 0) -{ - @Language.Text.NothingFound -} -else -{ - - - @foreach ((CategoryExpenseResponse category, int rank) in _report.Categories.Select((category, rank) => (category, rank))) - { - -
- @category.Category - @Share(category.Total) - @Display.Money(category.Total) -
- -
- - } -
- @Language.Text.Total - @Display.Money(_report.Total) -
-
-
-} +@Language.Text.Reports + +@* The chosen tab is kept in the address, so the back button returns to it. *@ + + @* Each tab takes an equal share of the width, so all of them fit on a phone. *@ + + + + + + + @if (Auth.CanEdit) + { + + + + } + @code { - // Neighbouring bars get different colours so the categories are easy to tell apart. - private static readonly Color[] BarColors = [Color.Primary, Color.Info, Color.Warning, Color.Secondary]; - - private DateOnly? _from; - private DateOnly? _to; - private CategoryReportResponse? _report; - private ApiProblem? _problem; + [SupplyParameterFromQuery(Name = "view")] + public string? RequestedView { get; set; } - // Without dates the API reports the current month, and says which dates it used. - protected override Task OnInitializedAsync() => ChangeAsync(null, null); - - private async Task ChangeAsync(DateOnly? from, DateOnly? to) - { - (_from, _to) = (from, to); - ApiResult result = await Api.CategoryReportAsync(from, to); - _report = result.ValueOrDefault; - _problem = result.Problem; - if (_report is not null) - { - (_from, _to) = (_report.From, _report.To); - } - } + // The budget is for editors only, so its tab is the last one and simply missing for readers. + private List Views => Auth.CanEdit ? [ReportView.Categories, ReportView.Months, ReportView.Budget] + : [ReportView.Categories, ReportView.Months]; - // Bars are relative to the largest category, the percentage to the whole. - private string Share(decimal total) => - _report is { Total: > 0 } report ? (total / report.Total).ToString("P0", System.Globalization.CultureInfo.CurrentCulture) : ""; + private ReportView View => + Enum.TryParse(RequestedView, ignoreCase: true, out ReportView view) && Views.Contains(view) ? view : ReportView.Categories; - private string ItemsOf(string category) => - Routes.ReceiptItems(new ReceiptItemListRequest { Categories = [category], From = _from, To = _to }); + private void Show(int index) => Navigation.NavigateTo(Routes.ReportsView(Views[index]), replace: true); } diff --git a/src/ExpenseExplorer.Web/ReportView.cs b/src/ExpenseExplorer.Web/ReportView.cs new file mode 100644 index 0000000..6e0476e --- /dev/null +++ b/src/ExpenseExplorer.Web/ReportView.cs @@ -0,0 +1,9 @@ +namespace ExpenseExplorer.Web; + +/// The tabs of the reports page. +public enum ReportView +{ + Categories, + Months, + Budget, +} diff --git a/src/ExpenseExplorer.Web/Routes.cs b/src/ExpenseExplorer.Web/Routes.cs index 5ee6f97..d45059c 100644 --- a/src/ExpenseExplorer.Web/Routes.cs +++ b/src/ExpenseExplorer.Web/Routes.cs @@ -10,9 +10,12 @@ public static class Routes { public const string Reports = "reports"; + public static string ReportsView(ReportView view) => + Reports + (view == ReportView.Categories ? "" : new QueryString().Add("view", view).ToString()); + // "./" rather than "": links and buttons treat an empty address as no link at all. public static string Receipts(ReceiptListRequest? filter = null) => - "./" + (filter is null ? "" : ListQueries.ToQuery(filter)); + "./" + (filter is null ? "" : ListQueries.ToPageQuery(filter)); public static string ReceiptItems(ReceiptItemListRequest? filter = null) => "receipt-items" + (filter is null ? "" : ListQueries.ToQuery(filter)); diff --git a/src/ExpenseExplorer.Web/wwwroot/css/app.css b/src/ExpenseExplorer.Web/wwwroot/css/app.css index 469db88..439ddd6 100644 --- a/src/ExpenseExplorer.Web/wwwroot/css/app.css +++ b/src/ExpenseExplorer.Web/wwwroot/css/app.css @@ -155,3 +155,70 @@ html[data-theme="dark"] body { align-items: center; gap: 8px; } + +/* Monthly report: one bar per month, the value above and the month below. */ +.month-bars { + display: flex; + align-items: stretch; + gap: 2px; + height: 180px; +} + +.month-bar { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.month-bar-track { + flex: 1; + width: 100%; + max-width: 40px; + display: flex; + align-items: flex-end; +} + +.month-bar-fill { + width: 100%; + min-height: 1px; + border-radius: 4px 4px 0 0; + background: var(--mud-palette-primary); +} + +.month-bar-fill.current { + opacity: 0.5; +} + +.month-bar-value, +.month-bar-label { + font-size: 0.7rem; + line-height: 1.2; + white-space: nowrap; + color: var(--mud-palette-text-secondary); +} + +/* Budget across periods: the group names stay in view while the periods scroll sideways. */ +.history-table th:first-child, +.history-table td:first-child { + position: sticky; + left: 0; + z-index: 1; + background: var(--mud-palette-surface); +} + +.history-table td, +.history-table th { + white-space: nowrap; +} + +.flex-1 { + flex: 1; +} + +/* Report tabs share the width equally instead of scrolling on a phone. */ +.report-tabs .mud-tab { + flex: 1; +} diff --git a/tests/ExpenseExplorer.Api.Tests/BudgetApiTests.cs b/tests/ExpenseExplorer.Api.Tests/BudgetApiTests.cs index 033e534..f9e369a 100644 --- a/tests/ExpenseExplorer.Api.Tests/BudgetApiTests.cs +++ b/tests/ExpenseExplorer.Api.Tests/BudgetApiTests.cs @@ -179,6 +179,29 @@ public async Task Only_editors_see_the_budget() Assert.Equal(HttpStatusCode.Unauthorized, (await _app.CreateClient().Get($"{Budget}/periods")).StatusCode); } + [Fact] + public async Task History_lists_started_periods_oldest_first_with_plan_and_spending_per_group() + { + Assert.Equal(HttpStatusCode.NoContent, (await _editor.Put($"{Budget}/template", Template)).StatusCode); + IReadOnlyList groups = await _editor.Get($"{Budget}/groups").Then>(); + await AssignAsync("Spożywcze", groups.Single(group => group.Name == "Jedzenie").Id); + await _editor.Post($"{Budget}/periods", new CreatePeriodRequest(new DateOnly(2026, 7, 5), null)); + await _editor.Post($"{Budget}/periods", new CreatePeriodRequest(null, null)); + await _editor.Post($"{Budget}/periods", new CreatePeriodRequest(null, null)); + await _editor.Post($"{Budget}/periods", new CreatePeriodRequest(null, null)); + await _editor.ReceiptAsync("Lidl", new DateOnly(2026, 8, 10), Line("Chleb", "Spożywcze", 2_100m), Line("Prezent", "Prezenty", 50m)); + await _editor.ReceiptAsync("Lidl", new DateOnly(2026, 9, 10), Line("Chleb", "Spożywcze", 1_500m)); + + List history = await _editor.Get($"{Budget}/history?count=2").Then>(); + + Assert.Equal([new DateOnly(2026, 8, 5), new DateOnly(2026, 9, 5)], history.Select(period => period.Period.Start)); + Assert.Equal( + [("Dom", 3_300m, 0m), ("Jedzenie", 2_000m, 2_100m)], + history[0].Groups.Select(group => (group.Name, group.Planned, group.Spent))); + Assert.Equal((50m, 2_150m, 8_000m - 3_300m - 2_100m - 50m), (history[0].OutsideGroups, history[0].Spent, history[0].FreePool)); + Assert.Equal(1_500m, history[1].Groups.Single(group => group.Name == "Jedzenie").Spent); + } + private static ReceiptItemRequest Line(string item, string category, decimal amount) => new(item, category, 1m, amount, null, null); private async Task AssignAsync(string category, Guid groupId) => diff --git a/tests/ExpenseExplorer.Api.Tests/ReceiptApiTests.cs b/tests/ExpenseExplorer.Api.Tests/ReceiptApiTests.cs index 41c769e..c934a64 100644 --- a/tests/ExpenseExplorer.Api.Tests/ReceiptApiTests.cs +++ b/tests/ExpenseExplorer.Api.Tests/ReceiptApiTests.cs @@ -67,6 +67,7 @@ public async Task Items_added_to_a_receipt_are_returned_with_totals() ReceiptResponse stored = await (await _client.Get($"{Receipts}/{receipt.Id}")).Read(); Assert.Equal(11.48m, stored.Total); + Assert.Equal(0.50m, stored.Discount); Assert.Collection( stored.Items, milk => @@ -245,7 +246,7 @@ public async Task List_filters_sorts_and_sums_the_matching_receipts() { string store = $"Sklep-{Guid.NewGuid():N}"; ReceiptResponse cheap = await CreateReceiptAsync(store, Today.AddDays(-2)); - await AddItemAsync(cheap.Id, new ReceiptItemRequest("Woda", "Napoje", 1m, 2.00m, null, null)); + await AddItemAsync(cheap.Id, new ReceiptItemRequest("Woda", "Napoje", 1m, 2.50m, 0.50m, null)); ReceiptResponse expensive = await CreateReceiptAsync(store, Today.AddDays(-1)); await AddItemAsync(expensive.Id, new ReceiptItemRequest("Kawa", "Napoje", 1m, 30.00m, null, null)); ReceiptResponse old = await CreateReceiptAsync(store, Today.AddDays(-30)); @@ -258,6 +259,7 @@ public async Task List_filters_sorts_and_sums_the_matching_receipts() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(2, list.Receipts.TotalCount); Assert.Equal(32m, list.TotalCost); + Assert.Equal(0.50m, list.TotalDiscount); Assert.Equal(expensive.Id, Assert.Single(list.Receipts.Items).Id); } diff --git a/tests/ExpenseExplorer.Api.Tests/ReportApiTests.cs b/tests/ExpenseExplorer.Api.Tests/ReportApiTests.cs index 2fc73b6..d47c5e2 100644 --- a/tests/ExpenseExplorer.Api.Tests/ReportApiTests.cs +++ b/tests/ExpenseExplorer.Api.Tests/ReportApiTests.cs @@ -7,6 +7,7 @@ namespace ExpenseExplorer.Api.Tests; public class ReportApiTests(ApiFixture api) { private const string Categories = "/api/v1/reports/categories"; + private const string Monthly = "/api/v1/reports/monthly"; // A period no other test writes to. private static readonly DateOnly Start = new(2019, 3, 1); @@ -41,6 +42,37 @@ public async Task Without_dates_the_report_covers_this_month_up_to_today() Assert.Equal(ApiFixture.Today, report.To); } + [Fact] + public async Task Monthly_report_spreads_each_category_over_the_months_up_to_this_one() + { + // Other tests write to recent months too, so only this test's own category is checked. + string category = $"Kategoria-{Guid.NewGuid():N}"; + await api.Editor.ReceiptAsync("Lidl", new DateOnly(2026, 6, 30), new ReceiptItemRequest("Mleko", category, 1m, 10.00m, 1.00m, null)); + await api.Editor.ReceiptAsync("Lidl", new DateOnly(2026, 8, 1), new ReceiptItemRequest("Mleko", category, 2m, 20.00m, null, null)); + await api.Editor.ReceiptAsync("Lidl", new DateOnly(2026, 5, 31), new ReceiptItemRequest("Mleko", category, 1m, 99.00m, null, null)); + + MonthlyReportResponse report = await (await api.Reader.Get($"{Monthly}?months=4")).Read(); + + Assert.Equal( + [new DateOnly(2026, 6, 1), new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 1), new DateOnly(2026, 9, 1)], + report.Months); + CategoryMonthsResponse mine = Assert.Single(report.Categories, c => c.Category == category); + Assert.Equal([9.00m, 0m, 20.00m, 0m], mine.Totals); + Assert.Equal(29.00m, mine.Total); + Assert.Equal( + report.Months.Select((_, index) => report.Categories.Sum(c => c.Totals[index])), + report.Totals); + } + + [Fact] + public async Task Monthly_report_covers_at_most_two_years() + { + HttpResponseMessage response = await api.Reader.Get($"{Monthly}?months=25"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(["Input.OutOfRange"], (await response.ErrorCodes())["months"]); + } + [Fact] public async Task Reversed_period_is_rejected() {