Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/ExpenseExplorer.Api/Budget/BudgetEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -70,6 +71,14 @@ private static async Task<IResult> CurrentAsync(IBudgetQueries queries, TimeProv
: ErrorResults.From([BudgetErrors.NoCurrentPeriod]);
}

/// <summary>The last <paramref name="count"/> periods (6 by default) that have started, oldest first.</summary>
private static Task<IResult> 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<IResult> GetAsync(Guid periodId, IBudgetQueries queries, TimeProvider clock, CancellationToken cancellationToken) =>
await queries.GetAsync(periodId, clock.Today(), cancellationToken) is { } budget
? Results.Ok(budget)
Expand Down
13 changes: 13 additions & 0 deletions src/ExpenseExplorer.Api/Reports/ReportEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -24,6 +25,18 @@ private static Task<IResult> CategoriesAsync(
async period => Result.Success(await reports.CategoriesAsync(period.From, period.To, cancellationToken)),
report => Results.Ok(report));

/// <summary>The last <paramref name="months"/> calendar months (6 by default), the current one included.</summary>
private static Task<IResult> 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<DateOnly>(from ?? new DateOnly(today.Year, today.Month, 1), to ?? today)
.ForTarget("from")
Expand Down
3 changes: 3 additions & 0 deletions src/ExpenseExplorer.Application/Budget/IBudgetQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public interface IBudgetQueries
/// <summary>The period with spending from receipts so far; <c>null</c> when there is no such period.</summary>
Task<BudgetResponse?> GetAsync(Guid periodId, DateOnly today, CancellationToken cancellationToken);

/// <summary>The last <paramref name="count"/> periods that have started by <paramref name="today"/>, oldest first.</summary>
Task<IReadOnlyList<PeriodResultResponse>> HistoryAsync(int count, DateOnly today, CancellationToken cancellationToken);

Task<Guid?> PeriodOnAsync(DateOnly day, CancellationToken cancellationToken);

Task<IReadOnlyList<GroupResponse>> GroupsAsync(CancellationToken cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
3 changes: 3 additions & 0 deletions src/ExpenseExplorer.Application/Reports/IReportQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ namespace ExpenseExplorer.Application.Reports;
public interface IReportQueries
{
Task<CategoryReportResponse> CategoriesAsync(DateOnly start, DateOnly end, CancellationToken cancellationToken);

/// <summary>Spending per category in <paramref name="count"/> calendar months up to the one with <paramref name="lastDay"/>.</summary>
Task<MonthlyReportResponse> MonthlyAsync(DateOnly lastDay, int count, CancellationToken cancellationToken);
}
15 changes: 15 additions & 0 deletions src/ExpenseExplorer.Contracts/Budget/BudgetContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ public sealed record BudgetResponse(
int DaysLeft,
decimal? PerDay);

/// <summary>
/// One period in short, for comparing periods: plan and spending per group, spending outside groups,
/// and <see cref="FreePool"/>, roughly what is left at the end.
/// </summary>
public sealed record PeriodResultResponse(
PeriodResponse Period,
decimal TotalFunds,
decimal Planned,
decimal Spent,
decimal FreePool,
IReadOnlyList<GroupResultResponse> Groups,
decimal OutsideGroups);

public sealed record GroupResultResponse(Guid Id, string Name, decimal Planned, decimal Spent);

public sealed record TemplateResponse(IReadOnlyList<FundResponse> Funds, IReadOnlyList<TemplateItemResponse> Items);

public sealed record TemplateItemResponse(Guid Id, string Group, string Name, decimal Amount);
6 changes: 4 additions & 2 deletions src/ExpenseExplorer.Contracts/Receipts/ReceiptResponses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public sealed record ReceiptResponse(
string Store,
DateOnly PurchaseDate,
decimal Total,
decimal Discount,
IReadOnlyList<ReceiptItemResponse> Items);

public sealed record ReceiptItemResponse(
Expand All @@ -26,10 +27,11 @@ public sealed record ReceiptSummaryResponse(
DateOnly PurchaseDate,
decimal Total);

/// <summary>One page of receipts plus the total cost of every receipt matching the filter.</summary>
/// <summary>One page of receipts plus the total cost and total discount of every receipt matching the filter.</summary>
public sealed record ReceiptListResponse(
PageResponse<ReceiptSummaryResponse> Receipts,
decimal TotalCost);
decimal TotalCost,
decimal TotalDiscount);

/// <summary>
/// A receipt read from a photo, with what to check against the paper: <paramref name="PrintedTotal"/>
Expand Down
12 changes: 12 additions & 0 deletions src/ExpenseExplorer.Contracts/Reports/MonthlyReportResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace ExpenseExplorer.Contracts.Reports;

/// <summary>
/// Spending per calendar month, oldest month first. <see cref="Totals"/> and each category's totals
/// have one value per month, in the order of <see cref="Months"/>. Categories come largest first.
/// </summary>
public sealed record MonthlyReportResponse(
IReadOnlyList<DateOnly> Months,
IReadOnlyList<decimal> Totals,
IReadOnlyList<CategoryMonthsResponse> Categories);

public sealed record CategoryMonthsResponse(string Category, IReadOnlyList<decimal> Totals, decimal Total);
3 changes: 3 additions & 0 deletions src/ExpenseExplorer.Domain/Receipts/Receipt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ private Receipt(ReceiptId id, StoreName store, PurchaseDate purchaseDate, List<R

public Money Total => Money.Sum(_items.Select(item => item.Total));

/// <summary>What the discounts on all lines took off.</summary>
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);
Expand Down
28 changes: 28 additions & 0 deletions src/ExpenseExplorer.Infrastructure/Budget/BudgetQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,34 @@ await db.BudgetPeriods.AsNoTracking()
.Select(p => new PeriodResponse(p.Id, p.Start, p.End))
.ToListAsync(cancellationToken);

public async Task<IReadOnlyList<PeriodResultResponse>> HistoryAsync(int count, DateOnly today, CancellationToken cancellationToken)
{
List<Guid> periodIds = await db.BudgetPeriods.AsNoTracking()
.Where(p => p.Start <= today)
.OrderByDescending(p => p.Start)
.Take(count)
.Select(p => p.Id)
.ToListAsync(cancellationToken);

List<PeriodResultResponse> 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<Guid?> PeriodOnAsync(DateOnly day, CancellationToken cancellationToken) =>
await db.BudgetPeriods.AsNoTracking()
.Where(p => p.Start <= day && p.End >= day)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public async Task<ReceiptListResponse> 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<ReceiptSummaryResponse> page = await Sort(filtered, query)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
Expand All @@ -33,7 +34,8 @@ public async Task<ReceiptListResponse> ListAsync(ReceiptListQuery query, Cancell

return new ReceiptListResponse(
new PageResponse<ReceiptSummaryResponse>(page, query.Page, query.PageSize, totalCount),
totalCost);
totalCost,
totalDiscount);
}

/// <summary>Totals computed in SQL the same way as <see cref="Domain.Receipts.LinePrice"/>: amount minus discount.</summary>
Expand All @@ -44,6 +46,7 @@ private static IQueryable<SummaryRow> Summaries(IQueryable<ReceiptRow> 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<SummaryRow> Filter(IQueryable<SummaryRow> receipts, ReceiptListQuery query)
Expand Down Expand Up @@ -104,5 +107,7 @@ private sealed class SummaryRow
public DateOnly PurchaseDate { get; init; }

public decimal Total { get; init; }

public decimal Discount { get; init; }
}
}
35 changes: 35 additions & 0 deletions src/ExpenseExplorer.Infrastructure/Reports/ReportQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MonthlyReportResponse> 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<CategoryMonthsResponse> 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);
}
}
7 changes: 7 additions & 0 deletions src/ExpenseExplorer.Web/Api/ExpenseApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ public Task<ApiResult<ReceiptItemListResponse>> ReceiptItemsAsync(ReceiptItemLis
public Task<ApiResult<CategoryReportResponse>> CategoryReportAsync(DateOnly? from, DateOnly? to) =>
GetAsync<CategoryReportResponse>("api/v1/reports/categories" + new QueryString().Add("from", from).Add("to", to));

public Task<ApiResult<MonthlyReportResponse>> MonthlyReportAsync(int months) =>
GetAsync<MonthlyReportResponse>("api/v1/reports/monthly" + new QueryString().Add("months", months));

public Task<ApiResult<LogListResponse>> LogsAsync(LogListRequest request) =>
GetAsync<LogListResponse>("api/v1/logs" + ListQueries.ToQuery(request));

Expand All @@ -106,6 +109,10 @@ public Task<ApiResult<RenameNameResponse>> RenameAsync(Suggestions kind, RenameN
public Task<ApiResult<IReadOnlyList<PeriodResponse>>> BudgetPeriodsAsync() =>
GetAsync<IReadOnlyList<PeriodResponse>>($"{Budget}/periods");

/// <summary>The last periods that have started, oldest first.</summary>
public Task<ApiResult<IReadOnlyList<PeriodResultResponse>>> BudgetHistoryAsync(int count) =>
GetAsync<IReadOnlyList<PeriodResultResponse>>($"{Budget}/history" + new QueryString().Add("count", count));

/// <summary>The period covering today, or the given one.</summary>
public Task<ApiResult<BudgetResponse>> BudgetAsync(Guid? periodId) =>
GetAsync<BudgetResponse>($"{Budget}/periods/{(periodId is { } id ? id.ToString() : "current")}");
Expand Down
38 changes: 25 additions & 13 deletions src/ExpenseExplorer.Web/Api/ListQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -56,6 +46,28 @@ public static string ToQuery(LogListRequest request) =>
.Add("pageSize", request.PageSize)
.ToString();

/// <summary>
/// The receipts page address. It always names "from": without it the page starts from
/// <see cref="DefaultReceiptsFrom"/>, and with it empty the page shows receipts of every date.
/// </summary>
public static string ToPageQuery(ReceiptListRequest request) =>
ReceiptListQuery(request, new QueryString().AddKeepingName("from", request.From));

/// <summary>The first day of the previous month: the whole of last month and the current one so far.</summary>
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()
{
Expand All @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions src/ExpenseExplorer.Web/Api/QueryReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/ExpenseExplorer.Web/Api/QueryString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ public QueryString Add<TEnum>(string name, TEnum? value)
where TEnum : struct, Enum =>
Add(name, value?.ToString());

/// <summary>Writes the name even without a date, so "no date" can be told apart from "not given".</summary>
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();
}
Loading
Loading