diff --git a/backend/src/RestaurantPOS.API/Contracts/Expenses/ExpenseRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Expenses/ExpenseRequests.cs
new file mode 100644
index 0000000..b104724
--- /dev/null
+++ b/backend/src/RestaurantPOS.API/Contracts/Expenses/ExpenseRequests.cs
@@ -0,0 +1,51 @@
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.API.Contracts.Expenses;
+
+public sealed record CreateExpenseCategoryRequest(
+ string Name, string? Description, decimal? MonthlyBudget, Guid? ParentCategoryId);
+
+public sealed record UpdateExpenseCategoryRequest(
+ string Name, string? Description, decimal? MonthlyBudget, Guid? ParentCategoryId);
+
+public sealed record SetExpenseCategoryActiveRequest(bool IsActive);
+
+public sealed record CreateExpenseRequest(
+ DateOnly ExpenseDate,
+ Guid CategoryId,
+ decimal Amount,
+ string? Description,
+ ExpensePaymentMethod PaymentMethod,
+ string? PaymentReference,
+ DateOnly? PaymentDate,
+ bool IsPaid = false,
+ bool SubmitForApproval = false);
+
+public sealed record UpdateExpenseRequest(
+ DateOnly ExpenseDate,
+ Guid CategoryId,
+ decimal Amount,
+ string? Description,
+ ExpensePaymentMethod PaymentMethod,
+ string? PaymentReference,
+ DateOnly? PaymentDate,
+ bool IsPaid);
+
+public sealed record SubmitExpenseRequest(string? Comments);
+
+///
+/// Takes a list so the day's expenses can be signed off together, which is how a manager actually
+/// reviews them. A single decision is a list of one.
+///
+public sealed record DecideExpensesRequest(IReadOnlyCollection ExpenseIds, string? Comments);
+
+public sealed record SetExpensePaidRequest(bool IsPaid, DateOnly? PaymentDate);
+
+public sealed record SaveRecurringExpenseRequest(
+ Guid CategoryId,
+ decimal Amount,
+ string? Description,
+ ExpensePaymentMethod PaymentMethod,
+ int DayOfMonth);
+
+public sealed record SetRecurringExpenseActiveRequest(bool IsActive);
diff --git a/backend/src/RestaurantPOS.API/Endpoints/ExpenseEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/ExpenseEndpoints.cs
new file mode 100644
index 0000000..70a2058
--- /dev/null
+++ b/backend/src/RestaurantPOS.API/Endpoints/ExpenseEndpoints.cs
@@ -0,0 +1,364 @@
+using MediatR;
+
+using Microsoft.AspNetCore.Mvc;
+
+using RestaurantPOS.API.Contracts.Expenses;
+using RestaurantPOS.API.Extensions;
+using RestaurantPOS.API.Security;
+using RestaurantPOS.Application.Expenses.Commands.AddExpenseAttachment;
+using RestaurantPOS.Application.Expenses.Commands.CreateExpense;
+using RestaurantPOS.Application.Expenses.Commands.CreateExpenseCategory;
+using RestaurantPOS.Application.Expenses.Commands.DecideExpenses;
+using RestaurantPOS.Application.Expenses.Commands.DeleteExpense;
+using RestaurantPOS.Application.Expenses.Commands.DeleteExpenseCategory;
+using RestaurantPOS.Application.Expenses.Commands.GenerateDueRecurringExpenses;
+using RestaurantPOS.Application.Expenses.Commands.RemoveExpenseAttachment;
+using RestaurantPOS.Application.Expenses.Commands.SaveRecurringExpense;
+using RestaurantPOS.Application.Expenses.Commands.SetExpenseCategoryActive;
+using RestaurantPOS.Application.Expenses.Commands.SetExpensePaid;
+using RestaurantPOS.Application.Expenses.Commands.SetRecurringExpenseActive;
+using RestaurantPOS.Application.Expenses.Commands.SubmitExpense;
+using RestaurantPOS.Application.Expenses.Commands.UpdateExpense;
+using RestaurantPOS.Application.Expenses.Commands.UpdateExpenseCategory;
+using RestaurantPOS.Application.Expenses.Queries.GetDailyExpenseReport;
+using RestaurantPOS.Application.Expenses.Queries.GetExpenseAttachment;
+using RestaurantPOS.Application.Expenses.Queries.GetExpenseById;
+using RestaurantPOS.Application.Expenses.Queries.GetExpenseCategories;
+using RestaurantPOS.Application.Expenses.Queries.GetExpenseRangeReport;
+using RestaurantPOS.Application.Expenses.Queries.GetExpenses;
+using RestaurantPOS.Application.Expenses.Queries.GetMonthlyExpenseReport;
+using RestaurantPOS.Application.Expenses.Queries.GetRecurringExpenses;
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.API.Endpoints;
+
+/// Expense recording, approval, categories, recurring costs and reporting.
+public static class ExpenseEndpoints
+{
+ /// Largest receipt accepted, matching the limit the command enforces.
+ private const long MaxAttachmentBytes = 10 * 1024 * 1024;
+
+ public static IEndpointRouteBuilder MapExpenseEndpoints(this IEndpointRouteBuilder routes)
+ {
+ ArgumentNullException.ThrowIfNull(routes);
+
+ var group = routes.MapGroup("/expenses")
+ .WithTags("Expenses")
+ .RequireAuthorization(AuthorizationPolicies.ForModule(AppModule.ExpensesManagement));
+
+ MapCategories(group);
+ MapExpenses(group);
+ MapApproval(group);
+ MapAttachments(group);
+ MapRecurring(group);
+ MapReports(group);
+
+ return routes;
+ }
+
+ private static void MapCategories(RouteGroupBuilder group)
+ {
+ group.MapGet("/categories", async (bool? isActive, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GetExpenseCategoriesQuery(isActive), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("GetExpenseCategories")
+ .WithSummary("Every expense category, built-in and custom.");
+
+ group.MapPost("/categories", async (
+ CreateExpenseCategoryRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var command = new CreateExpenseCategoryCommand(
+ request.Name, request.Description, request.MonthlyBudget, request.ParentCategoryId);
+
+ var result = await sender.Send(command, ct);
+ return result.ToCreatedResult(c => $"/api/v1/expenses/categories/{c.Id}");
+ })
+ .WithName("CreateExpenseCategory")
+ .WithSummary("Adds a custom expense category.");
+
+ group.MapPut("/categories/{id:guid}", async (
+ Guid id, UpdateExpenseCategoryRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var command = new UpdateExpenseCategoryCommand(
+ id, request.Name, request.Description, request.MonthlyBudget, request.ParentCategoryId);
+
+ var result = await sender.Send(command, ct);
+ return result.ToHttpResult();
+ })
+ .WithName("UpdateExpenseCategory")
+ .WithSummary("Renames a category or changes its monthly budget.");
+
+ group.MapPut("/categories/{id:guid}/status", async (
+ Guid id, SetExpenseCategoryActiveRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new SetExpenseCategoryActiveCommand(id, request.IsActive), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("SetExpenseCategoryActive")
+ .WithSummary("Retires a category or brings it back into use.");
+
+ group.MapDelete("/categories/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new DeleteExpenseCategoryCommand(id), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("DeleteExpenseCategory")
+ .WithSummary("Removes a custom category that has never been used.");
+ }
+
+ private static void MapExpenses(RouteGroupBuilder group)
+ {
+ group.MapGet("/", async (
+ DateOnly? from,
+ DateOnly? to,
+ Guid? categoryId,
+ ExpensePaymentMethod? paymentMethod,
+ ExpenseStatus? status,
+ bool? isPaid,
+ string? search,
+ ISender sender,
+ CancellationToken ct) =>
+ {
+ var query = new GetExpensesQuery(from, to, categoryId, paymentMethod, status, isPaid, search);
+ var result = await sender.Send(query, ct);
+
+ return result.ToHttpResult();
+ })
+ .WithName("GetExpenses")
+ .WithSummary("Lists expenses with every filter the screens offer.");
+
+ group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GetExpenseByIdQuery(id), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("GetExpenseById")
+ .WithSummary("One expense with its attachments and approval history.");
+
+ group.MapPost("/", async (CreateExpenseRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var command = new CreateExpenseCommand(
+ request.ExpenseDate,
+ request.CategoryId,
+ request.Amount,
+ request.Description,
+ request.PaymentMethod,
+ request.PaymentReference,
+ request.PaymentDate,
+ request.IsPaid,
+ request.SubmitForApproval);
+
+ var result = await sender.Send(command, ct);
+ return result.ToCreatedResult(e => $"/api/v1/expenses/{e.Id}");
+ })
+ .WithName("CreateExpense")
+ .WithSummary("Records money paid out.");
+
+ group.MapPut("/{id:guid}", async (
+ Guid id, UpdateExpenseRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var command = new UpdateExpenseCommand(
+ id,
+ request.ExpenseDate,
+ request.CategoryId,
+ request.Amount,
+ request.Description,
+ request.PaymentMethod,
+ request.PaymentReference,
+ request.PaymentDate,
+ request.IsPaid);
+
+ var result = await sender.Send(command, ct);
+ return result.ToHttpResult();
+ })
+ .WithName("UpdateExpense")
+ .WithSummary("Corrects an expense that has not yet been approved.");
+
+ group.MapDelete("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new DeleteExpenseCommand(id), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("DeleteExpense")
+ .WithSummary("Discards an expense that was never ruled on.");
+
+ group.MapPut("/{id:guid}/paid", async (
+ Guid id, SetExpensePaidRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(
+ new SetExpensePaidCommand(id, request.IsPaid, request.PaymentDate), ct);
+
+ return result.ToHttpResult();
+ })
+ .WithName("SetExpensePaid")
+ .WithSummary("Records whether the money has actually gone out.");
+ }
+
+ private static void MapApproval(RouteGroupBuilder group)
+ {
+ group.MapPost("/{id:guid}/submit", async (
+ Guid id, SubmitExpenseRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new SubmitExpenseCommand(id, request.Comments), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("SubmitExpense")
+ .WithSummary("Puts a draft expense forward for approval.");
+
+ group.MapPost("/approve", async (
+ DecideExpensesRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(
+ new DecideExpensesCommand(request.ExpenseIds, Approve: true, request.Comments), ct);
+
+ return result.ToHttpResult();
+ })
+ .WithName("ApproveExpenses")
+ .WithSummary("Approves one or more expenses in a single decision.");
+
+ group.MapPost("/reject", async (
+ DecideExpensesRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(
+ new DecideExpensesCommand(request.ExpenseIds, Approve: false, request.Comments), ct);
+
+ return result.ToHttpResult();
+ })
+ .WithName("RejectExpenses")
+ .WithSummary("Rejects one or more expenses, keeping them on record.");
+ }
+
+ private static void MapAttachments(RouteGroupBuilder group)
+ {
+ group.MapPost("/{id:guid}/attachments", async (
+ Guid id, IFormFile file, ISender sender, CancellationToken ct) =>
+ {
+ await using var stream = file.OpenReadStream();
+
+ var command = new AddExpenseAttachmentCommand(
+ id, stream, file.FileName, file.ContentType ?? "application/octet-stream", file.Length);
+
+ var result = await sender.Send(command, ct);
+ return result.ToHttpResult();
+ })
+ .WithName("AddExpenseAttachment")
+ .WithSummary("Files a receipt or invoice against an expense.")
+ .DisableAntiforgery()
+ .WithMetadata(new RequestSizeLimitAttribute(MaxAttachmentBytes));
+
+ group.MapGet("/attachments/{attachmentId:guid}", async (
+ Guid attachmentId, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GetExpenseAttachmentQuery(attachmentId), ct);
+
+ if (result.IsFailure)
+ {
+ return result.ToHttpResult();
+ }
+
+ var attachment = result.Value;
+
+ // Inline so a receipt opens in a viewer rather than forcing a download, which is
+ // what somebody checking an expense actually wants.
+ return Results.File(attachment.Content, attachment.ContentType, attachment.FileName);
+ })
+ .WithName("GetExpenseAttachment")
+ .WithSummary("Opens a filed receipt.");
+
+ group.MapDelete("/{id:guid}/attachments/{attachmentId:guid}", async (
+ Guid id, Guid attachmentId, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new RemoveExpenseAttachmentCommand(id, attachmentId), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("RemoveExpenseAttachment")
+ .WithSummary("Removes a filed receipt.");
+ }
+
+ private static void MapRecurring(RouteGroupBuilder group)
+ {
+ group.MapGet("/recurring", async (ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GetRecurringExpensesQuery(), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("GetRecurringExpenses")
+ .WithSummary("Standing monthly costs such as rent and salaries.");
+
+ group.MapPost("/recurring", async (
+ SaveRecurringExpenseRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var command = new SaveRecurringExpenseCommand(
+ null, request.CategoryId, request.Amount, request.Description,
+ request.PaymentMethod, request.DayOfMonth);
+
+ var result = await sender.Send(command, ct);
+ return result.ToCreatedResult(r => $"/api/v1/expenses/recurring/{r.Id}");
+ })
+ .WithName("CreateRecurringExpense")
+ .WithSummary("Sets up a standing monthly cost.");
+
+ group.MapPut("/recurring/{id:guid}", async (
+ Guid id, SaveRecurringExpenseRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var command = new SaveRecurringExpenseCommand(
+ id, request.CategoryId, request.Amount, request.Description,
+ request.PaymentMethod, request.DayOfMonth);
+
+ var result = await sender.Send(command, ct);
+ return result.ToHttpResult();
+ })
+ .WithName("UpdateRecurringExpense")
+ .WithSummary("Changes a standing monthly cost.");
+
+ group.MapPut("/recurring/{id:guid}/status", async (
+ Guid id, SetRecurringExpenseActiveRequest request, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(
+ new SetRecurringExpenseActiveCommand(id, request.IsActive), ct);
+
+ return result.ToHttpResult();
+ })
+ .WithName("SetRecurringExpenseActive")
+ .WithSummary("Stops or resumes a standing monthly cost.");
+
+ group.MapPost("/recurring/generate", async (ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GenerateDueRecurringExpensesCommand(), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("GenerateDueRecurringExpenses")
+ .WithSummary("Creates any recurring expense still owed for this month. Safe to call repeatedly.");
+ }
+
+ private static void MapReports(RouteGroupBuilder group)
+ {
+ group.MapGet("/reports/daily", async (DateOnly date, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GetDailyExpenseReportQuery(date), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("GetDailyExpenseReport")
+ .WithSummary("A day's expenses against that day's takings.");
+
+ group.MapGet("/reports/monthly", async (
+ int year, int month, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GetMonthlyExpenseReportQuery(year, month), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("GetMonthlyExpenseReport")
+ .WithSummary("A month's expenses, trend, weekly split, comparison and budget alerts.");
+
+ group.MapGet("/reports/range", async (
+ DateOnly from, DateOnly to, ISender sender, CancellationToken ct) =>
+ {
+ var result = await sender.Send(new GetExpenseRangeReportQuery(from, to), ct);
+ return result.ToHttpResult();
+ })
+ .WithName("GetExpenseRangeReport")
+ .WithSummary("Expenses, revenue and profit across any span of dates.");
+ }
+}
diff --git a/backend/src/RestaurantPOS.API/Program.cs b/backend/src/RestaurantPOS.API/Program.cs
index 48739cf..232eae4 100644
--- a/backend/src/RestaurantPOS.API/Program.cs
+++ b/backend/src/RestaurantPOS.API/Program.cs
@@ -116,6 +116,7 @@
api.MapSupplierEndpoints();
api.MapOrderEndpoints();
api.MapKitchenEndpoints();
+ api.MapExpenseEndpoints();
app.MapGet("/health", () => Results.Ok(new { status = "Healthy", timestamp = DateTime.UtcNow }))
.AllowAnonymous()
diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs
index 935d57c..e0a2c7e 100644
--- a/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs
+++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs
@@ -55,5 +55,15 @@ public interface IAppDbContext
DbSet Receipts { get; }
+ DbSet ExpenseCategories { get; }
+
+ DbSet Expenses { get; }
+
+ DbSet ExpenseAttachments { get; }
+
+ DbSet ExpenseApprovalEntries { get; }
+
+ DbSet RecurringExpenses { get; }
+
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IExpenseAttachmentStore.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IExpenseAttachmentStore.cs
new file mode 100644
index 0000000..eea52cf
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IExpenseAttachmentStore.cs
@@ -0,0 +1,26 @@
+namespace RestaurantPOS.Application.Common.Interfaces;
+
+/// A stored receipt file, ready to be streamed back to the browser.
+public sealed record StoredAttachment(Stream Content, string ContentType, string FileName);
+
+///
+/// Where receipt and invoice files live.
+///
+///
+/// Files sit in a folder beside the database rather than inside it: a year of phone photos would
+/// otherwise dwarf every other table and drag on every backup. Paths handed back are relative to
+/// the attachments root, so moving or copying the data folder does not strand them.
+///
+public interface IExpenseAttachmentStore
+{
+ /// Writes a file and returns its path relative to the attachments root.
+ Task SaveAsync(
+ Stream content, string fileName, DateOnly expenseDate, CancellationToken cancellationToken);
+
+ /// Opens a stored file, or returns null when it is no longer on disk.
+ Task OpenAsync(
+ string storedPath, string contentType, string fileName, CancellationToken cancellationToken);
+
+ /// Deletes a stored file. Missing files are not an error — the goal is that it is gone.
+ Task DeleteAsync(string storedPath, CancellationToken cancellationToken);
+}
diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/ExpenseMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/ExpenseMappings.cs
new file mode 100644
index 0000000..986a7c3
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Common/Mappings/ExpenseMappings.cs
@@ -0,0 +1,107 @@
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Entities;
+
+namespace RestaurantPOS.Application.Common.Mappings;
+
+/// Projections from the expense aggregate to the shapes the screens read.
+public static class ExpenseMappings
+{
+ public static ExpenseCategoryDto ToDto(
+ this ExpenseCategory category, string? parentName = null, int expenseCount = 0)
+ {
+ ArgumentNullException.ThrowIfNull(category);
+
+ return new ExpenseCategoryDto(
+ category.Id,
+ category.Name,
+ category.Description,
+ category.MonthlyBudget,
+ category.ParentCategoryId,
+ parentName,
+ category.IsSystem,
+ category.IsActive,
+ expenseCount);
+ }
+
+ public static ExpenseDto ToDto(
+ this Expense expense,
+ string categoryName,
+ string recordedByName,
+ IReadOnlyDictionary userNames)
+ {
+ ArgumentNullException.ThrowIfNull(expense);
+ ArgumentNullException.ThrowIfNull(userNames);
+
+ return new ExpenseDto(
+ expense.Id,
+ expense.ExpenseNumber,
+ expense.ExpenseDate,
+ expense.CategoryId,
+ categoryName,
+ expense.Amount,
+ expense.Description,
+ expense.Status,
+ expense.PaymentMethod,
+ expense.PaymentReference,
+ expense.PaymentDate,
+ expense.IsPaid,
+ expense.RecordedByUserId,
+ recordedByName,
+ expense.CreatedAtUtc,
+ expense.ApprovedByUserId,
+ expense.ApprovedByUserId is null ? null : userNames.GetValueOrDefault(expense.ApprovedByUserId.Value),
+ expense.ApprovedAtUtc,
+ expense.ApprovalComments,
+ expense.RecurringExpenseId is not null,
+ expense.IsEditable,
+ [.. expense.Attachments
+ .OrderBy(a => a.UploadedAtUtc)
+ .Select(a => new ExpenseAttachmentDto(
+ a.Id, a.FileName, a.ContentType, a.SizeBytes, a.UploadedAtUtc,
+ userNames.GetValueOrDefault(a.UploadedByUserId, string.Empty)))],
+ [.. expense.ApprovalTrail
+ .OrderBy(e => e.ActedAtUtc)
+ .Select(e => new ExpenseApprovalEntryDto(
+ e.FromStatus, e.ToStatus, e.ActedByUserId,
+ userNames.GetValueOrDefault(e.ActedByUserId, string.Empty), e.ActedAtUtc, e.Comments))]);
+ }
+
+ public static ExpenseSummaryDto ToSummaryDto(
+ this Expense expense, string categoryName, string recordedByName)
+ {
+ ArgumentNullException.ThrowIfNull(expense);
+
+ return new ExpenseSummaryDto(
+ expense.Id,
+ expense.ExpenseNumber,
+ expense.ExpenseDate,
+ expense.CategoryId,
+ categoryName,
+ expense.Amount,
+ expense.Description,
+ expense.Status,
+ expense.PaymentMethod,
+ expense.PaymentReference,
+ expense.IsPaid,
+ expense.RecurringExpenseId is not null,
+ expense.Attachments.Count,
+ recordedByName);
+ }
+
+ public static RecurringExpenseDto ToDto(this RecurringExpense recurring, string categoryName)
+ {
+ ArgumentNullException.ThrowIfNull(recurring);
+
+ return new RecurringExpenseDto(
+ recurring.Id,
+ recurring.CategoryId,
+ categoryName,
+ recurring.Amount,
+ recurring.Description,
+ recurring.PaymentMethod,
+ recurring.DayOfMonth,
+ recurring.IsActive,
+ recurring.LastGeneratedYear,
+ recurring.LastGeneratedMonth);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/AddExpenseAttachment/AddExpenseAttachmentCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/AddExpenseAttachment/AddExpenseAttachmentCommand.cs
new file mode 100644
index 0000000..e9e958a
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/AddExpenseAttachment/AddExpenseAttachmentCommand.cs
@@ -0,0 +1,76 @@
+using MediatR;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.AddExpenseAttachment;
+
+/// Files a receipt or invoice against an expense (EXP-037).
+public sealed record AddExpenseAttachmentCommand(
+ Guid ExpenseId, Stream Content, string FileName, string ContentType, long SizeBytes)
+ : IRequest>;
+
+internal sealed class AddExpenseAttachmentCommandHandler(
+ IAppDbContext db, IExpenseAttachmentStore store, ICurrentUser currentUser, IDateTimeProvider clock)
+ : IRequestHandler>
+{
+ private const int MaxMegabytes = 10;
+ private const long MaxSizeBytes = MaxMegabytes * 1024L * 1024L;
+
+ ///
+ /// What a receipt is allowed to be. Restricted deliberately: these files are served back to a
+ /// browser, and anything outside this list is either useless as a receipt or a liability to
+ /// hand back to a client.
+ ///
+ private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "application/pdf",
+ };
+
+ public async Task> Handle(
+ AddExpenseAttachmentCommand request, CancellationToken cancellationToken)
+ {
+ var expense = await ExpenseRepository.FindAsync(db, request.ExpenseId, cancellationToken);
+
+ if (expense is null)
+ {
+ return Result.Failure(ExpenseErrors.NotFound(request.ExpenseId));
+ }
+
+ if (!expense.IsEditable)
+ {
+ return Result.Failure(ExpenseErrors.NotEditable);
+ }
+
+ if (!AllowedContentTypes.Contains(request.ContentType))
+ {
+ return Result.Failure(ExpenseErrors.AttachmentTypeNotAllowed);
+ }
+
+ if (request.SizeBytes > MaxSizeBytes)
+ {
+ return Result.Failure(ExpenseErrors.AttachmentTooLarge(MaxMegabytes));
+ }
+
+ var storedPath = await store.SaveAsync(
+ request.Content, request.FileName, expense.ExpenseDate, cancellationToken);
+
+ expense.AddAttachment(
+ request.FileName,
+ storedPath,
+ request.ContentType,
+ request.SizeBytes,
+ currentUser.UserId!.Value,
+ clock.UtcNow);
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(await ExpenseResultFactory.BuildAsync(db, expense, cancellationToken));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/CreateExpense/CreateExpenseCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/CreateExpense/CreateExpenseCommand.cs
new file mode 100644
index 0000000..52aae9a
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/CreateExpense/CreateExpenseCommand.cs
@@ -0,0 +1,106 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Enums;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.CreateExpense;
+
+///
+/// Records money paid out (EXP-001 to EXP-008). Created as a draft; whoever records it can put it
+/// forward, and a manager can approve it there and then.
+///
+///
+/// Sends the expense straight to Pending instead of leaving it a draft — the "Submit for
+/// Approval" button rather than "Save as Draft".
+///
+public sealed record CreateExpenseCommand(
+ DateOnly ExpenseDate,
+ Guid CategoryId,
+ decimal Amount,
+ string? Description,
+ ExpensePaymentMethod PaymentMethod,
+ string? PaymentReference,
+ DateOnly? PaymentDate,
+ bool IsPaid,
+ bool SubmitForApproval) : IRequest>;
+
+public sealed class CreateExpenseCommandValidator : AbstractValidator
+{
+ public CreateExpenseCommandValidator()
+ {
+ RuleFor(x => x.CategoryId).NotEmpty();
+ RuleFor(x => x.Amount).GreaterThan(0).WithMessage("The amount must be greater than zero.");
+ RuleFor(x => x.Description).MaximumLength(Expense.DescriptionMaxLength);
+ RuleFor(x => x.PaymentMethod).IsInEnum();
+ RuleFor(x => x.PaymentReference).MaximumLength(Expense.ReferenceMaxLength);
+
+ RuleFor(x => x.PaymentReference)
+ .NotEmpty()
+ .When(x => x.PaymentMethod != ExpensePaymentMethod.Cash)
+ .WithMessage("A reference is required for cheque, card and bank transfer payments.");
+ }
+}
+
+internal sealed class CreateExpenseCommandHandler(
+ IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock)
+ : IRequestHandler>
+{
+ public async Task> Handle(CreateExpenseCommand request, CancellationToken cancellationToken)
+ {
+ // Backdating is expected — a manager keys in yesterday's bills this morning (BR-EXP-017).
+ // A future date is not: it would book spending into a day that has not happened.
+ if (request.ExpenseDate > clock.Today)
+ {
+ return Result.Failure(ExpenseErrors.FutureDate);
+ }
+
+ var category = await db.ExpenseCategories
+ .FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
+
+ if (category is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(request.CategoryId));
+ }
+
+ if (!category.IsActive)
+ {
+ return Result.Failure(ExpenseErrors.CategoryInactive);
+ }
+
+ var number = await ExpenseNumbering.NextNumberAsync(db, request.ExpenseDate.Year, cancellationToken);
+ var now = clock.UtcNow;
+
+ var expense = Expense.Create(
+ number,
+ request.ExpenseDate,
+ request.CategoryId,
+ request.Amount,
+ request.Description,
+ request.PaymentMethod,
+ request.PaymentReference,
+ request.PaymentDate,
+ request.IsPaid,
+ currentUser.UserId!.Value);
+
+ if (request.SubmitForApproval)
+ {
+ expense.Submit(currentUser.UserId!.Value, now);
+ }
+
+ db.Expenses.Add(expense);
+ await db.SaveChangesAsync(cancellationToken);
+
+ var saved = await ExpenseRepository.FindAsync(db, expense.Id, cancellationToken);
+
+ return Result.Success(await ExpenseResultFactory.BuildAsync(db, saved!, cancellationToken));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/CreateExpenseCategory/CreateExpenseCategoryCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/CreateExpenseCategory/CreateExpenseCategoryCommand.cs
new file mode 100644
index 0000000..8d7797f
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/CreateExpenseCategory/CreateExpenseCategoryCommand.cs
@@ -0,0 +1,74 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.CreateExpenseCategory;
+
+/// Adds a custom expense category (EXP-011), optionally under an existing one.
+public sealed record CreateExpenseCategoryCommand(
+ string Name, string? Description, decimal? MonthlyBudget, Guid? ParentCategoryId)
+ : IRequest>;
+
+public sealed class CreateExpenseCategoryCommandValidator : AbstractValidator
+{
+ public CreateExpenseCategoryCommandValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(ExpenseCategory.NameMaxLength);
+ RuleFor(x => x.Description).MaximumLength(ExpenseCategory.DescriptionMaxLength);
+ RuleFor(x => x.MonthlyBudget).GreaterThanOrEqualTo(0).When(x => x.MonthlyBudget.HasValue);
+ }
+}
+
+internal sealed class CreateExpenseCategoryCommandHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ CreateExpenseCategoryCommand request, CancellationToken cancellationToken)
+ {
+ var name = request.Name.Trim();
+
+ var taken = await db.ExpenseCategories
+ .AnyAsync(c => c.Name.ToLower() == name.ToLower(), cancellationToken);
+
+ if (taken)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNameTaken);
+ }
+
+ ExpenseCategory? parent = null;
+
+ if (request.ParentCategoryId is { } parentId)
+ {
+ parent = await db.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == parentId, cancellationToken);
+
+ if (parent is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(parentId));
+ }
+
+ // One level only: a deeper tree makes "total for Utilities" ambiguous about how far
+ // down it should reach, and nobody running a restaurant wants to litigate that.
+ if (parent.ParentCategoryId is not null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNestingTooDeep);
+ }
+ }
+
+ var category = ExpenseCategory.Create(
+ name, request.Description, request.MonthlyBudget, request.ParentCategoryId);
+
+ db.ExpenseCategories.Add(category);
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(category.ToDto(parent?.Name));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/DecideExpenses/DecideExpensesCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/DecideExpenses/DecideExpensesCommand.cs
new file mode 100644
index 0000000..4b9568a
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/DecideExpenses/DecideExpensesCommand.cs
@@ -0,0 +1,92 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Enums;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.DecideExpenses;
+
+///
+/// Approves or rejects expenses (EXP-013, EXP-014).
+///
+///
+/// Takes a list rather than a single id because the day's expenses are reviewed together — the
+/// manager looks down five bills and signs them off in one go. One expense is just a list of one,
+/// so there is no second code path to keep in step.
+///
+/// All or nothing: if any expense in the batch has already been decided, none are changed. A
+/// partial bulk approval would leave the manager guessing which of the five went through.
+///
+///
+public sealed record DecideExpensesCommand(
+ IReadOnlyCollection ExpenseIds, bool Approve, string? Comments)
+ : IRequest>>;
+
+public sealed class DecideExpensesCommandValidator : AbstractValidator
+{
+ public DecideExpensesCommandValidator()
+ {
+ RuleFor(x => x.ExpenseIds).NotEmpty().WithMessage("Choose at least one expense.");
+ RuleFor(x => x.Comments).MaximumLength(ExpenseApprovalEntry.CommentsMaxLength);
+ }
+}
+
+internal sealed class DecideExpensesCommandHandler(
+ IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock)
+ : IRequestHandler>>
+{
+ public async Task>> Handle(
+ DecideExpensesCommand request, CancellationToken cancellationToken)
+ {
+ var ids = request.ExpenseIds.Distinct().ToList();
+
+ var expenses = await ExpenseRepository.WithAggregate(db.Expenses)
+ .Where(e => ids.Contains(e.Id))
+ .ToListAsync(cancellationToken);
+
+ var missing = ids.FirstOrDefault(id => expenses.All(e => e.Id != id));
+ if (missing != Guid.Empty)
+ {
+ return Result.Failure>(ExpenseErrors.NotFound(missing));
+ }
+
+ if (expenses.Any(e => e.Status is ExpenseStatus.Approved or ExpenseStatus.Rejected))
+ {
+ return Result.Failure>(ExpenseErrors.AlreadyDecided);
+ }
+
+ var userId = currentUser.UserId!.Value;
+ var now = clock.UtcNow;
+
+ foreach (var expense in expenses)
+ {
+ if (request.Approve)
+ {
+ expense.Approve(userId, now, request.Comments);
+ }
+ else
+ {
+ expense.Reject(userId, now, request.Comments);
+ }
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ var dtos = new List(expenses.Count);
+
+ foreach (var expense in expenses)
+ {
+ dtos.Add(await ExpenseResultFactory.BuildAsync(db, expense, cancellationToken));
+ }
+
+ return Result.Success>(dtos);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/DeleteExpense/DeleteExpenseCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/DeleteExpense/DeleteExpenseCommand.cs
new file mode 100644
index 0000000..d4df69e
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/DeleteExpense/DeleteExpenseCommand.cs
@@ -0,0 +1,53 @@
+using FluentValidation;
+
+using MediatR;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.DeleteExpense;
+
+///
+/// Discards an expense that was never ruled on — a mis-key, or a bill entered twice. Once
+/// approved or rejected it stays on the books for good (BR-EXP-006): a rejected expense is a
+/// decision somebody made, and deleting it would erase the reason.
+///
+public sealed record DeleteExpenseCommand(Guid ExpenseId) : IRequest;
+
+public sealed class DeleteExpenseCommandValidator : AbstractValidator
+{
+ public DeleteExpenseCommandValidator() => RuleFor(x => x.ExpenseId).NotEmpty();
+}
+
+internal sealed class DeleteExpenseCommandHandler(IAppDbContext db, IExpenseAttachmentStore attachments)
+ : IRequestHandler
+{
+ public async Task Handle(DeleteExpenseCommand request, CancellationToken cancellationToken)
+ {
+ var expense = await ExpenseRepository.FindAsync(db, request.ExpenseId, cancellationToken);
+
+ if (expense is null)
+ {
+ return Result.Failure(ExpenseErrors.NotFound(request.ExpenseId));
+ }
+
+ if (!expense.IsEditable)
+ {
+ return Result.Failure(ExpenseErrors.NotEditable);
+ }
+
+ // Files first: an orphaned row is recoverable, an orphaned file is invisible clutter that
+ // nothing will ever clean up.
+ foreach (var attachment in expense.Attachments)
+ {
+ await attachments.DeleteAsync(attachment.StoredPath, cancellationToken);
+ }
+
+ db.Expenses.Remove(expense);
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/DeleteExpenseCategory/DeleteExpenseCategoryCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/DeleteExpenseCategory/DeleteExpenseCategoryCommand.cs
new file mode 100644
index 0000000..e5fb490
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/DeleteExpenseCategory/DeleteExpenseCategoryCommand.cs
@@ -0,0 +1,57 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.DeleteExpenseCategory;
+
+///
+/// Removes a custom category that has never been used. Anything with history behind it — or any
+/// built-in category — is refused, because deleting it would leave recorded spending pointing at
+/// a category that no longer exists.
+///
+public sealed record DeleteExpenseCategoryCommand(Guid CategoryId) : IRequest;
+
+public sealed class DeleteExpenseCategoryCommandValidator : AbstractValidator
+{
+ public DeleteExpenseCategoryCommandValidator() => RuleFor(x => x.CategoryId).NotEmpty();
+}
+
+internal sealed class DeleteExpenseCategoryCommandHandler(IAppDbContext db)
+ : IRequestHandler
+{
+ public async Task Handle(DeleteExpenseCategoryCommand request, CancellationToken cancellationToken)
+ {
+ var category = await db.ExpenseCategories
+ .FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
+
+ if (category is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(request.CategoryId));
+ }
+
+ if (category.IsSystem)
+ {
+ return Result.Failure(ExpenseErrors.CategoryIsSystem);
+ }
+
+ var inUse = await db.Expenses.AnyAsync(e => e.CategoryId == category.Id, cancellationToken)
+ || await db.RecurringExpenses.AnyAsync(r => r.CategoryId == category.Id, cancellationToken)
+ || await db.ExpenseCategories.AnyAsync(c => c.ParentCategoryId == category.Id, cancellationToken);
+
+ if (inUse)
+ {
+ return Result.Failure(ExpenseErrors.CategoryInUse);
+ }
+
+ db.ExpenseCategories.Remove(category);
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/GenerateDueRecurringExpenses/GenerateDueRecurringExpensesCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/GenerateDueRecurringExpenses/GenerateDueRecurringExpensesCommand.cs
new file mode 100644
index 0000000..a4e3c2b
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/GenerateDueRecurringExpenses/GenerateDueRecurringExpensesCommand.cs
@@ -0,0 +1,32 @@
+using MediatR;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Application.Expenses.Commands.GenerateDueRecurringExpenses;
+
+///
+/// Creates any recurring expense the restaurant still owes for this month, and for any month
+/// missed since (BR-EXP-008).
+///
+///
+/// Called by the expenses screen on load, which is what makes recurring costs appear without a
+/// scheduler. Safe to call as often as the screen is opened — each instruction remembers the last
+/// month it produced.
+///
+public sealed record GenerateDueRecurringExpensesCommand : IRequest>;
+
+internal sealed class GenerateDueRecurringExpensesCommandHandler(
+ IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GenerateDueRecurringExpensesCommand request, CancellationToken cancellationToken)
+ {
+ var created = await RecurringExpenseGenerator.GenerateDueAsync(
+ db, clock.Today, currentUser.UserId!.Value, cancellationToken);
+
+ return Result.Success(created);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/RemoveExpenseAttachment/RemoveExpenseAttachmentCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/RemoveExpenseAttachment/RemoveExpenseAttachmentCommand.cs
new file mode 100644
index 0000000..3325e88
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/RemoveExpenseAttachment/RemoveExpenseAttachmentCommand.cs
@@ -0,0 +1,59 @@
+using FluentValidation;
+
+using MediatR;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.RemoveExpenseAttachment;
+
+/// Removes a receipt filed against an expense that has not yet been ruled on.
+public sealed record RemoveExpenseAttachmentCommand(Guid ExpenseId, Guid AttachmentId)
+ : IRequest>;
+
+public sealed class RemoveExpenseAttachmentCommandValidator : AbstractValidator
+{
+ public RemoveExpenseAttachmentCommandValidator()
+ {
+ RuleFor(x => x.ExpenseId).NotEmpty();
+ RuleFor(x => x.AttachmentId).NotEmpty();
+ }
+}
+
+internal sealed class RemoveExpenseAttachmentCommandHandler(IAppDbContext db, IExpenseAttachmentStore store)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ RemoveExpenseAttachmentCommand request, CancellationToken cancellationToken)
+ {
+ var expense = await ExpenseRepository.FindAsync(db, request.ExpenseId, cancellationToken);
+
+ if (expense is null)
+ {
+ return Result.Failure(ExpenseErrors.NotFound(request.ExpenseId));
+ }
+
+ var attachment = expense.Attachments.FirstOrDefault(a => a.Id == request.AttachmentId);
+
+ if (attachment is null)
+ {
+ return Result.Failure(ExpenseErrors.AttachmentNotFound(request.AttachmentId));
+ }
+
+ if (!expense.IsEditable)
+ {
+ return Result.Failure(ExpenseErrors.NotEditable);
+ }
+
+ expense.RemoveAttachment(attachment);
+ db.ExpenseAttachments.Remove(attachment);
+ await store.DeleteAsync(attachment.StoredPath, cancellationToken);
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(await ExpenseResultFactory.BuildAsync(db, expense, cancellationToken));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/SaveRecurringExpense/SaveRecurringExpenseCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/SaveRecurringExpense/SaveRecurringExpenseCommand.cs
new file mode 100644
index 0000000..c270e2f
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/SaveRecurringExpense/SaveRecurringExpenseCommand.cs
@@ -0,0 +1,91 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Enums;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.SaveRecurringExpense;
+
+///
+/// Creates or updates a standing monthly cost (EXP-012). One command for both, since the form is
+/// identical and the only difference is whether an id came with it.
+///
+public sealed record SaveRecurringExpenseCommand(
+ Guid? RecurringExpenseId,
+ Guid CategoryId,
+ decimal Amount,
+ string? Description,
+ ExpensePaymentMethod PaymentMethod,
+ int DayOfMonth) : IRequest>;
+
+public sealed class SaveRecurringExpenseCommandValidator : AbstractValidator
+{
+ public SaveRecurringExpenseCommandValidator()
+ {
+ RuleFor(x => x.CategoryId).NotEmpty();
+ RuleFor(x => x.Amount).GreaterThan(0).WithMessage("The amount must be greater than zero.");
+ RuleFor(x => x.Description).MaximumLength(RecurringExpense.DescriptionMaxLength);
+ RuleFor(x => x.PaymentMethod).IsInEnum();
+
+ RuleFor(x => x.DayOfMonth)
+ .InclusiveBetween(1, 31)
+ .WithMessage("The day of the month must be between 1 and 31.");
+ }
+}
+
+internal sealed class SaveRecurringExpenseCommandHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ SaveRecurringExpenseCommand request, CancellationToken cancellationToken)
+ {
+ var category = await db.ExpenseCategories
+ .FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
+
+ if (category is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(request.CategoryId));
+ }
+
+ if (!category.IsActive)
+ {
+ return Result.Failure(ExpenseErrors.CategoryInactive);
+ }
+
+ RecurringExpense recurring;
+
+ if (request.RecurringExpenseId is { } id)
+ {
+ var existing = await db.RecurringExpenses.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
+
+ if (existing is null)
+ {
+ return Result.Failure(ExpenseErrors.RecurringNotFound(id));
+ }
+
+ existing.UpdateDetails(
+ request.CategoryId, request.Amount, request.Description, request.PaymentMethod, request.DayOfMonth);
+
+ recurring = existing;
+ }
+ else
+ {
+ recurring = RecurringExpense.Create(
+ request.CategoryId, request.Amount, request.Description, request.PaymentMethod, request.DayOfMonth);
+
+ db.RecurringExpenses.Add(recurring);
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(recurring.ToDto(category.Name));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/SetExpenseCategoryActive/SetExpenseCategoryActiveCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/SetExpenseCategoryActive/SetExpenseCategoryActiveCommand.cs
new file mode 100644
index 0000000..c29be6a
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/SetExpenseCategoryActive/SetExpenseCategoryActiveCommand.cs
@@ -0,0 +1,56 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.SetExpenseCategoryActive;
+
+///
+/// Retires a category or brings it back. Retiring rather than deleting is what keeps last year's
+/// reports intact when the restaurant stops using a category.
+///
+public sealed record SetExpenseCategoryActiveCommand(Guid CategoryId, bool IsActive)
+ : IRequest>;
+
+public sealed class SetExpenseCategoryActiveCommandValidator : AbstractValidator
+{
+ public SetExpenseCategoryActiveCommandValidator() => RuleFor(x => x.CategoryId).NotEmpty();
+}
+
+internal sealed class SetExpenseCategoryActiveCommandHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ SetExpenseCategoryActiveCommand request, CancellationToken cancellationToken)
+ {
+ var category = await db.ExpenseCategories
+ .FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
+
+ if (category is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(request.CategoryId));
+ }
+
+ if (request.IsActive)
+ {
+ category.Activate();
+ }
+ else
+ {
+ category.Deactivate();
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ var expenseCount = await db.Expenses.CountAsync(e => e.CategoryId == category.Id, cancellationToken);
+
+ return Result.Success(category.ToDto(expenseCount: expenseCount));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/SetExpensePaid/SetExpensePaidCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/SetExpensePaid/SetExpensePaidCommand.cs
new file mode 100644
index 0000000..ac71124
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/SetExpensePaid/SetExpensePaidCommand.cs
@@ -0,0 +1,43 @@
+using FluentValidation;
+
+using MediatR;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.SetExpensePaid;
+
+///
+/// Records that the money has actually gone out (EXP-034). Unlike editing, this stays available
+/// after approval: a cheque approved on the 15th and cleared on the 20th has not changed as an
+/// expense, only in whether the bank has paid it.
+///
+public sealed record SetExpensePaidCommand(Guid ExpenseId, bool IsPaid, DateOnly? PaymentDate)
+ : IRequest>;
+
+public sealed class SetExpensePaidCommandValidator : AbstractValidator
+{
+ public SetExpensePaidCommandValidator() => RuleFor(x => x.ExpenseId).NotEmpty();
+}
+
+internal sealed class SetExpensePaidCommandHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(SetExpensePaidCommand request, CancellationToken cancellationToken)
+ {
+ var expense = await ExpenseRepository.FindAsync(db, request.ExpenseId, cancellationToken);
+
+ if (expense is null)
+ {
+ return Result.Failure(ExpenseErrors.NotFound(request.ExpenseId));
+ }
+
+ expense.SetPaid(request.IsPaid, request.PaymentDate);
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(await ExpenseResultFactory.BuildAsync(db, expense, cancellationToken));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/SetRecurringExpenseActive/SetRecurringExpenseActiveCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/SetRecurringExpenseActive/SetRecurringExpenseActiveCommand.cs
new file mode 100644
index 0000000..a3bade8
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/SetRecurringExpenseActive/SetRecurringExpenseActiveCommand.cs
@@ -0,0 +1,58 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.SetRecurringExpenseActive;
+
+/// Stops or resumes a standing monthly cost without losing its history.
+public sealed record SetRecurringExpenseActiveCommand(Guid RecurringExpenseId, bool IsActive)
+ : IRequest>;
+
+public sealed class SetRecurringExpenseActiveCommandValidator
+ : AbstractValidator
+{
+ public SetRecurringExpenseActiveCommandValidator() => RuleFor(x => x.RecurringExpenseId).NotEmpty();
+}
+
+internal sealed class SetRecurringExpenseActiveCommandHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ SetRecurringExpenseActiveCommand request, CancellationToken cancellationToken)
+ {
+ var recurring = await db.RecurringExpenses
+ .FirstOrDefaultAsync(r => r.Id == request.RecurringExpenseId, cancellationToken);
+
+ if (recurring is null)
+ {
+ return Result.Failure(
+ ExpenseErrors.RecurringNotFound(request.RecurringExpenseId));
+ }
+
+ if (request.IsActive)
+ {
+ recurring.Activate();
+ }
+ else
+ {
+ recurring.Deactivate();
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ var categoryName = await db.ExpenseCategories
+ .Where(c => c.Id == recurring.CategoryId)
+ .Select(c => c.Name)
+ .FirstAsync(cancellationToken);
+
+ return Result.Success(recurring.ToDto(categoryName));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/SubmitExpense/SubmitExpenseCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/SubmitExpense/SubmitExpenseCommand.cs
new file mode 100644
index 0000000..5d618eb
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/SubmitExpense/SubmitExpenseCommand.cs
@@ -0,0 +1,50 @@
+using FluentValidation;
+
+using MediatR;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Enums;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.SubmitExpense;
+
+/// Puts a draft expense forward for a manager to rule on (EXP-015).
+public sealed record SubmitExpenseCommand(Guid ExpenseId, string? Comments) : IRequest>;
+
+public sealed class SubmitExpenseCommandValidator : AbstractValidator
+{
+ public SubmitExpenseCommandValidator()
+ {
+ RuleFor(x => x.ExpenseId).NotEmpty();
+ RuleFor(x => x.Comments).MaximumLength(ExpenseApprovalEntry.CommentsMaxLength);
+ }
+}
+
+internal sealed class SubmitExpenseCommandHandler(
+ IAppDbContext db, ICurrentUser currentUser, IDateTimeProvider clock)
+ : IRequestHandler>
+{
+ public async Task> Handle(SubmitExpenseCommand request, CancellationToken cancellationToken)
+ {
+ var expense = await ExpenseRepository.FindAsync(db, request.ExpenseId, cancellationToken);
+
+ if (expense is null)
+ {
+ return Result.Failure(ExpenseErrors.NotFound(request.ExpenseId));
+ }
+
+ if (expense.Status != ExpenseStatus.Draft)
+ {
+ return Result.Failure(ExpenseErrors.NotSubmittable);
+ }
+
+ expense.Submit(currentUser.UserId!.Value, clock.UtcNow, request.Comments);
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(await ExpenseResultFactory.BuildAsync(db, expense, cancellationToken));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/UpdateExpense/UpdateExpenseCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/UpdateExpense/UpdateExpenseCommand.cs
new file mode 100644
index 0000000..54f4d3a
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/UpdateExpense/UpdateExpenseCommand.cs
@@ -0,0 +1,100 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Enums;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.UpdateExpense;
+
+///
+/// Corrects an expense before anyone has ruled on it (EXP-035). Once approved it is frozen
+/// (EXP-036) — it has already been counted into a day's profit, so the way to fix an approved
+/// expense is to reject it and record the right one, leaving both on the books.
+///
+public sealed record UpdateExpenseCommand(
+ Guid ExpenseId,
+ DateOnly ExpenseDate,
+ Guid CategoryId,
+ decimal Amount,
+ string? Description,
+ ExpensePaymentMethod PaymentMethod,
+ string? PaymentReference,
+ DateOnly? PaymentDate,
+ bool IsPaid) : IRequest>;
+
+public sealed class UpdateExpenseCommandValidator : AbstractValidator
+{
+ public UpdateExpenseCommandValidator()
+ {
+ RuleFor(x => x.ExpenseId).NotEmpty();
+ RuleFor(x => x.CategoryId).NotEmpty();
+ RuleFor(x => x.Amount).GreaterThan(0).WithMessage("The amount must be greater than zero.");
+ RuleFor(x => x.Description).MaximumLength(Expense.DescriptionMaxLength);
+ RuleFor(x => x.PaymentMethod).IsInEnum();
+ RuleFor(x => x.PaymentReference).MaximumLength(Expense.ReferenceMaxLength);
+
+ RuleFor(x => x.PaymentReference)
+ .NotEmpty()
+ .When(x => x.PaymentMethod != ExpensePaymentMethod.Cash)
+ .WithMessage("A reference is required for cheque, card and bank transfer payments.");
+ }
+}
+
+internal sealed class UpdateExpenseCommandHandler(IAppDbContext db, IDateTimeProvider clock)
+ : IRequestHandler>
+{
+ public async Task> Handle(UpdateExpenseCommand request, CancellationToken cancellationToken)
+ {
+ var expense = await ExpenseRepository.FindAsync(db, request.ExpenseId, cancellationToken);
+
+ if (expense is null)
+ {
+ return Result.Failure(ExpenseErrors.NotFound(request.ExpenseId));
+ }
+
+ if (!expense.IsEditable)
+ {
+ return Result.Failure(ExpenseErrors.NotEditable);
+ }
+
+ if (request.ExpenseDate > clock.Today)
+ {
+ return Result.Failure(ExpenseErrors.FutureDate);
+ }
+
+ var category = await db.ExpenseCategories
+ .FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
+
+ if (category is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(request.CategoryId));
+ }
+
+ if (!category.IsActive)
+ {
+ return Result.Failure(ExpenseErrors.CategoryInactive);
+ }
+
+ expense.UpdateDetails(
+ request.ExpenseDate,
+ request.CategoryId,
+ request.Amount,
+ request.Description,
+ request.PaymentMethod,
+ request.PaymentReference,
+ request.PaymentDate,
+ request.IsPaid);
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(await ExpenseResultFactory.BuildAsync(db, expense, cancellationToken));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Commands/UpdateExpenseCategory/UpdateExpenseCategoryCommand.cs b/backend/src/RestaurantPOS.Application/Expenses/Commands/UpdateExpenseCategory/UpdateExpenseCategoryCommand.cs
new file mode 100644
index 0000000..94d0f35
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Commands/UpdateExpenseCategory/UpdateExpenseCategoryCommand.cs
@@ -0,0 +1,95 @@
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Commands.UpdateExpenseCategory;
+
+/// Renames a category or changes its budget. Built-in categories can be edited, not deleted.
+public sealed record UpdateExpenseCategoryCommand(
+ Guid CategoryId, string Name, string? Description, decimal? MonthlyBudget, Guid? ParentCategoryId)
+ : IRequest>;
+
+public sealed class UpdateExpenseCategoryCommandValidator : AbstractValidator
+{
+ public UpdateExpenseCategoryCommandValidator()
+ {
+ RuleFor(x => x.CategoryId).NotEmpty();
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(ExpenseCategory.NameMaxLength);
+ RuleFor(x => x.Description).MaximumLength(ExpenseCategory.DescriptionMaxLength);
+ RuleFor(x => x.MonthlyBudget).GreaterThanOrEqualTo(0).When(x => x.MonthlyBudget.HasValue);
+ }
+}
+
+internal sealed class UpdateExpenseCategoryCommandHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ UpdateExpenseCategoryCommand request, CancellationToken cancellationToken)
+ {
+ var category = await db.ExpenseCategories
+ .FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
+
+ if (category is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(request.CategoryId));
+ }
+
+ var name = request.Name.Trim();
+
+ var taken = await db.ExpenseCategories
+ .AnyAsync(c => c.Id != request.CategoryId && c.Name.ToLower() == name.ToLower(), cancellationToken);
+
+ if (taken)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNameTaken);
+ }
+
+ ExpenseCategory? parent = null;
+
+ if (request.ParentCategoryId is { } parentId)
+ {
+ if (parentId == request.CategoryId)
+ {
+ return Result.Failure(ExpenseErrors.CategoryOwnParent);
+ }
+
+ parent = await db.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == parentId, cancellationToken);
+
+ if (parent is null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNotFound(parentId));
+ }
+
+ if (parent.ParentCategoryId is not null)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNestingTooDeep);
+ }
+
+ // Taking on a parent while having children of its own would build the second level
+ // this model deliberately does not have.
+ var hasChildren = await db.ExpenseCategories
+ .AnyAsync(c => c.ParentCategoryId == request.CategoryId, cancellationToken);
+
+ if (hasChildren)
+ {
+ return Result.Failure(ExpenseErrors.CategoryNestingTooDeep);
+ }
+ }
+
+ category.UpdateDetails(name, request.Description, request.MonthlyBudget, request.ParentCategoryId);
+ await db.SaveChangesAsync(cancellationToken);
+
+ var expenseCount = await db.Expenses.CountAsync(e => e.CategoryId == category.Id, cancellationToken);
+
+ return Result.Success(category.ToDto(parent?.Name, expenseCount));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseAnalytics.cs b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseAnalytics.cs
new file mode 100644
index 0000000..a67a9e8
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseAnalytics.cs
@@ -0,0 +1,205 @@
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.Application.Expenses.Common;
+
+///
+/// The arithmetic behind every expense report — one place, so the daily summary, the monthly
+/// report and the analysis dashboard can never disagree about what a month cost.
+///
+public static class ExpenseAnalytics
+{
+ ///
+ /// Takings for a date range, from settled bills at the till.
+ ///
+ ///
+ /// Summed from recorded payments rather than from each order's total. An order's total is
+ /// computed in memory from its lines, so totalling it would mean loading every bill and its
+ /// items for the period; payments are stored, and settling a bill requires them to equal it
+ /// exactly (BR-POS-013), so the two figures agree by construction and this one is a single
+ /// query.
+ ///
+ public static async Task RevenueBetweenAsync(
+ IAppDbContext db, DateOnly from, DateOnly to, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+
+ return await db.OrderPayments.AsNoTracking()
+ .Join(
+ db.Orders.AsNoTracking().Where(o =>
+ o.Status == OrderStatus.Completed
+ && o.OrderDate != null
+ && o.OrderDate >= from
+ && o.OrderDate <= to),
+ payment => payment.OrderId,
+ order => order.Id,
+ (payment, _) => payment.Amount)
+ .SumAsync(cancellationToken);
+ }
+
+ /// Takings per business day, for a trend line.
+ public static async Task> RevenueByDayAsync(
+ IAppDbContext db, DateOnly from, DateOnly to, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+
+ var rows = await db.OrderPayments.AsNoTracking()
+ .Join(
+ db.Orders.AsNoTracking().Where(o =>
+ o.Status == OrderStatus.Completed
+ && o.OrderDate != null
+ && o.OrderDate >= from
+ && o.OrderDate <= to),
+ payment => payment.OrderId,
+ order => order.Id,
+ (payment, order) => new { order.OrderDate, payment.Amount })
+ .GroupBy(x => x.OrderDate!.Value)
+ .Select(g => new { Date = g.Key, Total = g.Sum(x => x.Amount) })
+ .ToListAsync(cancellationToken);
+
+ return rows.ToDictionary(r => r.Date, r => r.Total);
+ }
+
+ ///
+ /// Approved expenses in a range. Only approved ones are loaded anywhere reports are built,
+ /// which is what enforces BR-EXP-005 and BR-EXP-010 in one place instead of at every caller.
+ ///
+ public static Task> ApprovedExpensesBetweenAsync(
+ IAppDbContext db, DateOnly from, DateOnly to, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+
+ return db.Expenses.AsNoTracking()
+ .Where(e => e.Status == ExpenseStatus.Approved && e.ExpenseDate >= from && e.ExpenseDate <= to)
+ .ToListAsync(cancellationToken);
+ }
+
+ /// Revenue against expenses, with the ratios an owner actually reads.
+ public static ProfitSummaryDto BuildProfitSummary(decimal revenue, decimal expenses)
+ {
+ var profit = revenue - expenses;
+
+ // Percentages are meaningless against no takings — a closed day is not 100% expenses,
+ // it is a day with nothing to compare against. Null says that; zero would lie.
+ var expenseRatio = revenue > 0 ? Round(expenses / revenue * 100m) : (decimal?)null;
+ var profitMargin = revenue > 0 ? Round(profit / revenue * 100m) : (decimal?)null;
+
+ return new ProfitSummaryDto(revenue, expenses, profit, expenseRatio, profitMargin);
+ }
+
+ /// Splits a period's spending by category, with each one's share and budget usage.
+ public static IReadOnlyCollection BuildCategoryBreakdown(
+ IEnumerable expenses, IReadOnlyDictionary categories)
+ {
+ ArgumentNullException.ThrowIfNull(expenses);
+ ArgumentNullException.ThrowIfNull(categories);
+
+ var grouped = expenses
+ .GroupBy(e => e.CategoryId)
+ .Select(g => new { CategoryId = g.Key, Total = g.Sum(e => e.Amount), Count = g.Count() })
+ .ToList();
+
+ var overall = grouped.Sum(g => g.Total);
+
+ return [.. grouped
+ .Select(g =>
+ {
+ var category = categories.GetValueOrDefault(g.CategoryId);
+ var budget = category?.MonthlyBudget;
+
+ return new CategoryBreakdownDto(
+ g.CategoryId,
+ category?.Name ?? string.Empty,
+ g.Total,
+ overall > 0 ? Round(g.Total / overall * 100m) : 0m,
+ g.Count,
+ budget,
+ budget is > 0 ? Round(g.Total / budget.Value * 100m) : null);
+ })
+ .OrderByDescending(c => c.Total)];
+ }
+
+ ///
+ /// Compares two periods and names the category that moved most (EXP-025) — the first thing
+ /// an owner wants when a month costs more than the last one.
+ ///
+ public static PeriodComparisonDto BuildComparison(
+ string previousLabel,
+ IReadOnlyCollection previous,
+ IReadOnlyCollection current,
+ IReadOnlyDictionary categories)
+ {
+ ArgumentNullException.ThrowIfNull(previous);
+ ArgumentNullException.ThrowIfNull(current);
+ ArgumentNullException.ThrowIfNull(categories);
+
+ var previousTotal = previous.Sum(e => e.Amount);
+ var currentTotal = current.Sum(e => e.Amount);
+ var change = currentTotal - previousTotal;
+
+ var previousByCategory = previous
+ .GroupBy(e => e.CategoryId)
+ .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount));
+
+ var movements = current
+ .GroupBy(e => e.CategoryId)
+ .Select(g => new
+ {
+ CategoryId = g.Key,
+ Delta = g.Sum(e => e.Amount) - previousByCategory.GetValueOrDefault(g.Key),
+ })
+ .Where(m => m.Delta > 0)
+ .OrderByDescending(m => m.Delta)
+ .ToList();
+
+ var largest = movements.FirstOrDefault();
+
+ return new PeriodComparisonDto(
+ previousLabel,
+ previousTotal,
+ currentTotal,
+ change,
+ previousTotal > 0 ? Round(change / previousTotal * 100m) : null,
+ largest is null ? null : categories.GetValueOrDefault(largest.CategoryId)?.Name,
+ largest?.Delta);
+ }
+
+ ///
+ /// Categories at or near their monthly limit (EXP-038). Warns from 80% rather than only once
+ /// the budget has been blown, since a warning that arrives after the money is spent is not a
+ /// warning at all.
+ ///
+ public static IReadOnlyCollection BuildBudgetAlerts(
+ IEnumerable monthExpenses, IEnumerable categories)
+ {
+ ArgumentNullException.ThrowIfNull(monthExpenses);
+ ArgumentNullException.ThrowIfNull(categories);
+
+ const decimal warnAtPercentage = 80m;
+
+ var spentByCategory = monthExpenses
+ .GroupBy(e => e.CategoryId)
+ .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount));
+
+ return [.. categories
+ .Where(c => c.MonthlyBudget is > 0)
+ .Select(c =>
+ {
+ var budget = c.MonthlyBudget!.Value;
+ var spent = spentByCategory.GetValueOrDefault(c.Id);
+ var used = Round(spent / budget * 100m);
+
+ return new BudgetAlertDto(
+ c.Id, c.Name, budget, spent, budget - spent, used, spent > budget);
+ })
+ .Where(a => a.UsedPercentage >= warnAtPercentage)
+ .OrderByDescending(a => a.UsedPercentage)];
+ }
+
+ /// Money is reported to two decimals; percentages to one, which is all anyone reads.
+ private static decimal Round(decimal value) => Math.Round(value, 1, MidpointRounding.AwayFromZero);
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseNumbering.cs b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseNumbering.cs
new file mode 100644
index 0000000..ece19b7
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseNumbering.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+
+namespace RestaurantPOS.Application.Expenses.Common;
+
+/// Hands out the yearly expense sequence behind "EXP-001-2026" (EXP-009, BR-EXP-003).
+public static class ExpenseNumbering
+{
+ ///
+ /// The next number for a year. Taken from the highest already issued rather than a count, so
+ /// a rejected expense keeps its number and the sequence never hands the same one out twice.
+ ///
+ public static async Task NextNumberAsync(IAppDbContext db, int year, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+
+ var highest = await db.Expenses
+ .Where(e => e.Year == year)
+ .MaxAsync(e => (int?)e.Number, cancellationToken);
+
+ return (highest ?? 0) + 1;
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseRepository.cs b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseRepository.cs
new file mode 100644
index 0000000..1e697e5
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseRepository.cs
@@ -0,0 +1,34 @@
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Domain.Entities;
+
+namespace RestaurantPOS.Application.Expenses.Common;
+
+///
+/// The one way to load an expense.
+///
+///
+/// The approval trail and attachments sit behind private backing fields, so a handler that loads
+/// an expense without eager-loading them sees an empty history and quietly writes a transition on
+/// top of nothing. Centralised here rather than left to each handler to remember, for the same
+/// reason orders are.
+///
+public static class ExpenseRepository
+{
+ public static Task FindAsync(IAppDbContext db, Guid expenseId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+
+ return WithAggregate(db.Expenses).FirstOrDefaultAsync(e => e.Id == expenseId, cancellationToken);
+ }
+
+ public static IQueryable WithAggregate(IQueryable expenses)
+ {
+ ArgumentNullException.ThrowIfNull(expenses);
+
+ return expenses
+ .Include(e => e.ApprovalTrail)
+ .Include(e => e.Attachments);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseResultFactory.cs b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseResultFactory.cs
new file mode 100644
index 0000000..472b53b
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Common/ExpenseResultFactory.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Entities;
+
+namespace RestaurantPOS.Application.Expenses.Common;
+
+///
+/// Builds the full expense response, gathering the category and the names behind every user id on
+/// the approval trail — which is what turns an audit history into something a person can read.
+///
+public static class ExpenseResultFactory
+{
+ public static async Task BuildAsync(
+ IAppDbContext db, Expense expense, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+ ArgumentNullException.ThrowIfNull(expense);
+
+ var categoryName = await db.ExpenseCategories
+ .Where(c => c.Id == expense.CategoryId)
+ .Select(c => c.Name)
+ .FirstAsync(cancellationToken);
+
+ var userIds = expense.ApprovalTrail.Select(t => t.ActedByUserId)
+ .Concat(expense.Attachments.Select(a => a.UploadedByUserId))
+ .Append(expense.RecordedByUserId)
+ .Concat(expense.ApprovedByUserId is { } approver ? [approver] : Array.Empty())
+ .Distinct()
+ .ToList();
+
+ var userNames = await db.Users.AsNoTracking()
+ .Where(u => userIds.Contains(u.Id))
+ .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken);
+
+ return expense.ToDto(
+ categoryName, userNames.GetValueOrDefault(expense.RecordedByUserId, string.Empty), userNames);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Common/RecurringExpenseGenerator.cs b/backend/src/RestaurantPOS.Application/Expenses/Common/RecurringExpenseGenerator.cs
new file mode 100644
index 0000000..b8b5e71
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Common/RecurringExpenseGenerator.cs
@@ -0,0 +1,137 @@
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Domain.Entities;
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.Application.Expenses.Common;
+
+///
+/// Materialises standing monthly costs into real expenses (BR-EXP-008).
+///
+///
+/// Run whenever the expense screens are opened rather than on a timer. The restaurant's machine
+/// is switched off overnight and may not be turned on for days, so anything that fires "on the
+/// 1st" would simply miss a month; asking on every visit instead means the rent appears the first
+/// time somebody looks, whenever that is.
+///
+/// Safe to call repeatedly: each instruction records the month it last produced, so opening the
+/// screen five times in a morning still yields one rent. Everything is generated as a draft —
+/// never approved — so a figure that has changed is corrected before it reaches any report.
+///
+///
+public static class RecurringExpenseGenerator
+{
+ ///
+ /// Creates any recurring expense owed for the month containing , plus
+ /// any month missed since each instruction last ran, and returns how many were created.
+ ///
+ public static async Task GenerateDueAsync(
+ IAppDbContext db, DateOnly today, Guid actingUserId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+
+ var instructions = await db.RecurringExpenses
+ .Where(r => r.IsActive)
+ .ToListAsync(cancellationToken);
+
+ if (instructions.Count == 0)
+ {
+ return 0;
+ }
+
+ var categoryIds = instructions.Select(r => r.CategoryId).Distinct().ToList();
+
+ var activeCategories = await db.ExpenseCategories.AsNoTracking()
+ .Where(c => categoryIds.Contains(c.Id) && c.IsActive)
+ .Select(c => c.Id)
+ .ToListAsync(cancellationToken);
+
+ var created = 0;
+
+ // The yearly sequence is tracked per year in memory across the whole run: several months
+ // may be generated at once, and nothing is written to the database until the end, so
+ // re-querying the highest number would hand the same one out twice.
+ var nextNumberByYear = new Dictionary();
+
+ foreach (var instruction in instructions)
+ {
+ // A retired category cannot take new expenses, so its standing instruction waits
+ // rather than failing the whole generation run.
+ if (!activeCategories.Contains(instruction.CategoryId))
+ {
+ continue;
+ }
+
+ foreach (var (year, month) in MonthsDue(instruction, today))
+ {
+ // Belt and braces against a half-finished earlier run: the marker says it is due,
+ // but an expense for that month already exists.
+ var alreadyExists = await db.Expenses.AnyAsync(
+ e => e.RecurringExpenseId == instruction.Id
+ && e.ExpenseDate.Year == year
+ && e.ExpenseDate.Month == month,
+ cancellationToken);
+
+ if (!alreadyExists)
+ {
+ if (!nextNumberByYear.TryGetValue(year, out var number))
+ {
+ number = await ExpenseNumbering.NextNumberAsync(db, year, cancellationToken);
+ }
+
+ nextNumberByYear[year] = number + 1;
+
+ db.Expenses.Add(Expense.Create(
+ number,
+ instruction.DateFor(year, month),
+ instruction.CategoryId,
+ instruction.Amount,
+ instruction.Description,
+ instruction.PaymentMethod,
+ // A standing instruction has no cheque number of its own; the manager
+ // fills one in when the payment is actually made.
+ instruction.PaymentMethod == ExpensePaymentMethod.Cash ? null : "Pending",
+ paymentDate: null,
+ isPaid: false,
+ actingUserId,
+ instruction.Id));
+
+ created++;
+ }
+
+ instruction.MarkGenerated(year, month);
+ }
+ }
+
+ // Saved unconditionally: even when nothing new was created, the "last generated" markers
+ // may have moved forward past months that turned out to already exist.
+ await db.SaveChangesAsync(cancellationToken);
+
+ return created;
+ }
+
+ ///
+ /// Every month an instruction still owes, oldest first. A machine unopened since March
+ /// produces March, April and May rather than only the current month.
+ ///
+ private static IEnumerable<(int Year, int Month)> MonthsDue(RecurringExpense instruction, DateOnly today)
+ {
+ var cursor = instruction.LastGeneratedYear is { } lastYear && instruction.LastGeneratedMonth is { } lastMonth
+ ? new DateOnly(lastYear, lastMonth, 1).AddMonths(1)
+ // Never run before: start from the month it was set up in, not from whenever the
+ // restaurant opened, so adding rent today does not back-fill the whole year.
+ : new DateOnly(
+ DateOnly.FromDateTime(instruction.CreatedAtUtc).Year,
+ DateOnly.FromDateTime(instruction.CreatedAtUtc).Month,
+ 1);
+
+ var currentMonth = new DateOnly(today.Year, today.Month, 1);
+
+ while (cursor <= currentMonth)
+ {
+ yield return (cursor.Year, cursor.Month);
+ cursor = cursor.AddMonths(1);
+ }
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Dtos/ExpenseDtos.cs b/backend/src/RestaurantPOS.Application/Expenses/Dtos/ExpenseDtos.cs
new file mode 100644
index 0000000..37b94a2
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Dtos/ExpenseDtos.cs
@@ -0,0 +1,80 @@
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.Application.Expenses.Dtos;
+
+public sealed record ExpenseCategoryDto(
+ Guid Id,
+ string Name,
+ string? Description,
+ decimal? MonthlyBudget,
+ Guid? ParentCategoryId,
+ string? ParentCategoryName,
+ bool IsSystem,
+ bool IsActive,
+ /// Expenses recorded against it, so the UI can explain why it cannot be deleted.
+ int ExpenseCount);
+
+public sealed record ExpenseDto(
+ Guid Id,
+ string ExpenseNumber,
+ DateOnly ExpenseDate,
+ Guid CategoryId,
+ string CategoryName,
+ decimal Amount,
+ string? Description,
+ ExpenseStatus Status,
+ ExpensePaymentMethod PaymentMethod,
+ string? PaymentReference,
+ DateOnly? PaymentDate,
+ bool IsPaid,
+ Guid RecordedByUserId,
+ string RecordedByName,
+ DateTime CreatedAtUtc,
+ Guid? ApprovedByUserId,
+ string? ApprovedByName,
+ DateTime? ApprovedAtUtc,
+ string? ApprovalComments,
+ bool IsRecurring,
+ bool IsEditable,
+ IReadOnlyCollection Attachments,
+ IReadOnlyCollection ApprovalTrail);
+
+public sealed record ExpenseAttachmentDto(
+ Guid Id, string FileName, string ContentType, long SizeBytes, DateTime UploadedAtUtc, string UploadedByName);
+
+public sealed record ExpenseApprovalEntryDto(
+ ExpenseStatus FromStatus,
+ ExpenseStatus ToStatus,
+ Guid ActedByUserId,
+ string ActedByName,
+ DateTime ActedAtUtc,
+ string? Comments);
+
+/// An expense's header for the list screen, without its trail or attachments.
+public sealed record ExpenseSummaryDto(
+ Guid Id,
+ string ExpenseNumber,
+ DateOnly ExpenseDate,
+ Guid CategoryId,
+ string CategoryName,
+ decimal Amount,
+ string? Description,
+ ExpenseStatus Status,
+ ExpensePaymentMethod PaymentMethod,
+ string? PaymentReference,
+ bool IsPaid,
+ bool IsRecurring,
+ int AttachmentCount,
+ string RecordedByName);
+
+public sealed record RecurringExpenseDto(
+ Guid Id,
+ Guid CategoryId,
+ string CategoryName,
+ decimal Amount,
+ string? Description,
+ ExpensePaymentMethod PaymentMethod,
+ int DayOfMonth,
+ bool IsActive,
+ int? LastGeneratedYear,
+ int? LastGeneratedMonth);
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Dtos/ExpenseReportDtos.cs b/backend/src/RestaurantPOS.Application/Expenses/Dtos/ExpenseReportDtos.cs
new file mode 100644
index 0000000..9e27b8d
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Dtos/ExpenseReportDtos.cs
@@ -0,0 +1,84 @@
+namespace RestaurantPOS.Application.Expenses.Dtos;
+
+/// One category's share of a period's spending (EXP-016, EXP-019).
+public sealed record CategoryBreakdownDto(
+ Guid CategoryId,
+ string CategoryName,
+ decimal Total,
+ /// Share of the period's expenses, 0-100.
+ decimal PercentageOfTotal,
+ int ExpenseCount,
+ decimal? MonthlyBudget,
+ /// How much of the monthly budget this has used, 0-100+. Null when unbudgeted.
+ decimal? BudgetUsedPercentage);
+
+/// One day's figures, used for both the daily report and the monthly trend line.
+public sealed record DailyFigureDto(DateOnly Date, decimal Revenue, decimal Expenses, decimal Profit);
+
+///
+/// Revenue against expenses for a period (EXP-026, EXP-027).
+///
+/// Taken from settled bills at the till.
+/// Approved expenses only (BR-EXP-010).
+/// Expenses as a share of revenue, 0-100. Null when nothing was sold.
+/// Profit as a share of revenue, 0-100. Null when nothing was sold.
+public sealed record ProfitSummaryDto(
+ decimal Revenue, decimal Expenses, decimal Profit, decimal? ExpenseRatio, decimal? ProfitMargin);
+
+/// How a period compares with the one before it (EXP-025).
+public sealed record PeriodComparisonDto(
+ string PreviousLabel,
+ decimal PreviousTotal,
+ decimal CurrentTotal,
+ decimal Change,
+ /// Percentage change. Null when the previous period had no spending to compare against.
+ decimal? ChangePercentage,
+ /// The category that moved the most, which is where an owner will want to look first.
+ string? LargestIncreaseCategory,
+ decimal? LargestIncreaseAmount);
+
+/// A day's expense report (EXP-028).
+public sealed record DailyExpenseReportDto(
+ DateOnly Date,
+ ProfitSummaryDto Summary,
+ IReadOnlyCollection Categories,
+ PeriodComparisonDto Comparison,
+ IReadOnlyCollection Expenses,
+ string? HighestCategory,
+ string? LowestCategory);
+
+/// A month's expense report (EXP-029), including its daily trend and weekly split.
+public sealed record MonthlyExpenseReportDto(
+ int Year,
+ int Month,
+ string MonthLabel,
+ ProfitSummaryDto Summary,
+ IReadOnlyCollection Categories,
+ IReadOnlyCollection DailyFigures,
+ IReadOnlyCollection WeeklyFigures,
+ PeriodComparisonDto Comparison,
+ IReadOnlyCollection BudgetAlerts);
+
+public sealed record WeeklyFigureDto(
+ int WeekNumber, DateOnly StartDate, DateOnly EndDate, decimal Revenue, decimal Expenses, decimal Profit);
+
+///
+/// A category at or over its monthly budget (EXP-038, BR-EXP-016).
+///
+/// True once spending has passed the limit rather than merely neared it.
+public sealed record BudgetAlertDto(
+ Guid CategoryId,
+ string CategoryName,
+ decimal MonthlyBudget,
+ decimal SpentThisMonth,
+ decimal Remaining,
+ decimal UsedPercentage,
+ bool IsOverBudget);
+
+/// A free-range summary used by the analysis dashboard and the year-to-date view.
+public sealed record ExpenseRangeReportDto(
+ DateOnly From,
+ DateOnly To,
+ ProfitSummaryDto Summary,
+ IReadOnlyCollection Categories,
+ IReadOnlyCollection DailyFigures);
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetDailyExpenseReport/GetDailyExpenseReportQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetDailyExpenseReport/GetDailyExpenseReportQuery.cs
new file mode 100644
index 0000000..edd1c87
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetDailyExpenseReport/GetDailyExpenseReportQuery.cs
@@ -0,0 +1,68 @@
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetDailyExpenseReport;
+
+///
+/// One day's expenses against that day's takings (EXP-017, EXP-026, EXP-028), with yesterday for
+/// comparison.
+///
+public sealed record GetDailyExpenseReportQuery(DateOnly Date) : IRequest>;
+
+internal sealed class GetDailyExpenseReportQueryHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetDailyExpenseReportQuery request, CancellationToken cancellationToken)
+ {
+ var date = request.Date;
+ var previousDate = date.AddDays(-1);
+
+ var categories = await db.ExpenseCategories.AsNoTracking()
+ .ToDictionaryAsync(c => c.Id, cancellationToken);
+
+ var todaysExpenses = await ExpenseAnalytics.ApprovedExpensesBetweenAsync(db, date, date, cancellationToken);
+ var yesterdaysExpenses = await ExpenseAnalytics.ApprovedExpensesBetweenAsync(
+ db, previousDate, previousDate, cancellationToken);
+
+ var revenue = await ExpenseAnalytics.RevenueBetweenAsync(db, date, date, cancellationToken);
+ var summary = ExpenseAnalytics.BuildProfitSummary(revenue, todaysExpenses.Sum(e => e.Amount));
+
+ var breakdown = ExpenseAnalytics.BuildCategoryBreakdown(todaysExpenses, categories);
+ var comparison = ExpenseAnalytics.BuildComparison(
+ previousDate.ToString("yyyy-MM-dd"), yesterdaysExpenses, todaysExpenses, categories);
+
+ var userNames = await db.Users.AsNoTracking()
+ .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken);
+
+ // The listing shows the whole day, not only what was approved: a manager opening the
+ // daily report wants to see the two bills still waiting on them, not a total that
+ // quietly excludes them.
+ var allOfToday = await ExpenseRepository.WithAggregate(db.Expenses.AsNoTracking())
+ .Where(e => e.ExpenseDate == date)
+ .ToListAsync(cancellationToken);
+
+ var listed = allOfToday
+ .OrderByDescending(e => e.Number)
+ .Select(e => e.ToSummaryDto(
+ categories.GetValueOrDefault(e.CategoryId)?.Name ?? string.Empty,
+ userNames.GetValueOrDefault(e.RecordedByUserId, string.Empty)))
+ .ToList();
+
+ return Result.Success(new DailyExpenseReportDto(
+ date,
+ summary,
+ breakdown,
+ comparison,
+ listed,
+ breakdown.FirstOrDefault()?.CategoryName,
+ breakdown.LastOrDefault()?.CategoryName));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseAttachment/GetExpenseAttachmentQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseAttachment/GetExpenseAttachmentQuery.cs
new file mode 100644
index 0000000..9dbf023
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseAttachment/GetExpenseAttachmentQuery.cs
@@ -0,0 +1,37 @@
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetExpenseAttachment;
+
+/// Opens a filed receipt so it can be viewed or downloaded.
+public sealed record GetExpenseAttachmentQuery(Guid AttachmentId) : IRequest>;
+
+internal sealed class GetExpenseAttachmentQueryHandler(IAppDbContext db, IExpenseAttachmentStore store)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetExpenseAttachmentQuery request, CancellationToken cancellationToken)
+ {
+ var attachment = await db.ExpenseAttachments.AsNoTracking()
+ .FirstOrDefaultAsync(a => a.Id == request.AttachmentId, cancellationToken);
+
+ if (attachment is null)
+ {
+ return Result.Failure(ExpenseErrors.AttachmentNotFound(request.AttachmentId));
+ }
+
+ var stored = await store.OpenAsync(
+ attachment.StoredPath, attachment.ContentType, attachment.FileName, cancellationToken);
+
+ // The row survived but the file did not — someone tidied the folder, or a restore brought
+ // back the database without the attachments beside it.
+ return stored is null
+ ? Result.Failure(ExpenseErrors.AttachmentMissing)
+ : Result.Success(stored);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseById/GetExpenseByIdQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseById/GetExpenseByIdQuery.cs
new file mode 100644
index 0000000..640b24a
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseById/GetExpenseByIdQuery.cs
@@ -0,0 +1,31 @@
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetExpenseById;
+
+/// One expense in full, with its attachments and approval history.
+public sealed record GetExpenseByIdQuery(Guid ExpenseId) : IRequest>;
+
+internal sealed class GetExpenseByIdQueryHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(GetExpenseByIdQuery request, CancellationToken cancellationToken)
+ {
+ var expense = await ExpenseRepository.WithAggregate(db.Expenses.AsNoTracking())
+ .FirstOrDefaultAsync(e => e.Id == request.ExpenseId, cancellationToken);
+
+ if (expense is null)
+ {
+ return Result.Failure(ExpenseErrors.NotFound(request.ExpenseId));
+ }
+
+ return Result.Success(await ExpenseResultFactory.BuildAsync(db, expense, cancellationToken));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseCategories/GetExpenseCategoriesQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseCategories/GetExpenseCategoriesQuery.cs
new file mode 100644
index 0000000..b278e75
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseCategories/GetExpenseCategoriesQuery.cs
@@ -0,0 +1,50 @@
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetExpenseCategories;
+
+/// Every expense category, built-in and custom (EXP-010).
+public sealed record GetExpenseCategoriesQuery(bool? IsActive)
+ : IRequest>>;
+
+internal sealed class GetExpenseCategoriesQueryHandler(IAppDbContext db)
+ : IRequestHandler>>
+{
+ public async Task>> Handle(
+ GetExpenseCategoriesQuery request, CancellationToken cancellationToken)
+ {
+ var query = db.ExpenseCategories.AsNoTracking();
+
+ if (request.IsActive.HasValue)
+ {
+ query = query.Where(c => c.IsActive == request.IsActive.Value);
+ }
+
+ var categories = await query.ToListAsync(cancellationToken);
+
+ var names = categories.ToDictionary(c => c.Id, c => c.Name);
+
+ var counts = await db.Expenses.AsNoTracking()
+ .GroupBy(e => e.CategoryId)
+ .Select(g => new { CategoryId = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(g => g.CategoryId, g => g.Count, cancellationToken);
+
+ var dtos = categories
+ .Select(c => c.ToDto(
+ c.ParentCategoryId is null ? null : names.GetValueOrDefault(c.ParentCategoryId.Value),
+ counts.GetValueOrDefault(c.Id)))
+ // Parents first with their children beneath, so the list reads as the tree it is.
+ .OrderBy(c => c.ParentCategoryName ?? c.Name, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(c => c.ParentCategoryName is null ? 0 : 1)
+ .ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return Result.Success>(dtos);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseRangeReport/GetExpenseRangeReportQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseRangeReport/GetExpenseRangeReportQuery.cs
new file mode 100644
index 0000000..fa0330d
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenseRangeReport/GetExpenseRangeReportQuery.cs
@@ -0,0 +1,65 @@
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetExpenseRangeReport;
+
+///
+/// Expenses, revenue and profit across any span of dates — what the weekly analysis dashboard and
+/// the year-to-date summary both run on (EXP-030).
+///
+public sealed record GetExpenseRangeReportQuery(DateOnly From, DateOnly To)
+ : IRequest>;
+
+internal sealed class GetExpenseRangeReportQueryHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetExpenseRangeReportQuery request, CancellationToken cancellationToken)
+ {
+ if (request.From > request.To)
+ {
+ return Result.Failure(ExpenseErrors.InvalidDateRange);
+ }
+
+ var categories = await db.ExpenseCategories.AsNoTracking()
+ .ToDictionaryAsync(c => c.Id, cancellationToken);
+
+ var expenses = await ExpenseAnalytics.ApprovedExpensesBetweenAsync(
+ db, request.From, request.To, cancellationToken);
+
+ var revenueByDay = await ExpenseAnalytics.RevenueByDayAsync(
+ db, request.From, request.To, cancellationToken);
+
+ var expensesByDay = expenses
+ .GroupBy(e => e.ExpenseDate)
+ .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount));
+
+ var dayCount = request.To.DayNumber - request.From.DayNumber + 1;
+
+ var dailyFigures = Enumerable
+ .Range(0, dayCount)
+ .Select(offset => request.From.AddDays(offset))
+ .Select(day =>
+ {
+ var dayRevenue = revenueByDay.GetValueOrDefault(day);
+ var dayExpenses = expensesByDay.GetValueOrDefault(day);
+
+ return new DailyFigureDto(day, dayRevenue, dayExpenses, dayRevenue - dayExpenses);
+ })
+ .ToList();
+
+ return Result.Success(new ExpenseRangeReportDto(
+ request.From,
+ request.To,
+ ExpenseAnalytics.BuildProfitSummary(revenueByDay.Values.Sum(), expenses.Sum(e => e.Amount)),
+ ExpenseAnalytics.BuildCategoryBreakdown(expenses, categories),
+ dailyFigures));
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenses/GetExpensesQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenses/GetExpensesQuery.cs
new file mode 100644
index 0000000..a031b5b
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetExpenses/GetExpensesQuery.cs
@@ -0,0 +1,107 @@
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Enums;
+using RestaurantPOS.Domain.Errors;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetExpenses;
+
+///
+/// The expense list with every filter the screens offer (EXP-020 to EXP-024).
+///
+/// Matches description, reference or expense number — however it is remembered.
+public sealed record GetExpensesQuery(
+ DateOnly? From,
+ DateOnly? To,
+ Guid? CategoryId,
+ ExpensePaymentMethod? PaymentMethod,
+ ExpenseStatus? Status,
+ bool? IsPaid,
+ string? Search) : IRequest>>;
+
+internal sealed class GetExpensesQueryHandler(IAppDbContext db)
+ : IRequestHandler>>
+{
+ public async Task>> Handle(
+ GetExpensesQuery request, CancellationToken cancellationToken)
+ {
+ if (request.From is { } from && request.To is { } to && from > to)
+ {
+ return Result.Failure>(ExpenseErrors.InvalidDateRange);
+ }
+
+ var query = ExpenseRepository.WithAggregate(db.Expenses.AsNoTracking());
+
+ if (request.From is { } start)
+ {
+ query = query.Where(e => e.ExpenseDate >= start);
+ }
+
+ if (request.To is { } end)
+ {
+ query = query.Where(e => e.ExpenseDate <= end);
+ }
+
+ if (request.CategoryId is { } categoryId)
+ {
+ // A parent category includes whatever hangs beneath it, so filtering by "Utilities"
+ // does not hide the electricity bills filed under it.
+ var childIds = await db.ExpenseCategories.AsNoTracking()
+ .Where(c => c.ParentCategoryId == categoryId)
+ .Select(c => c.Id)
+ .ToListAsync(cancellationToken);
+
+ childIds.Add(categoryId);
+ query = query.Where(e => childIds.Contains(e.CategoryId));
+ }
+
+ if (request.PaymentMethod is { } method)
+ {
+ query = query.Where(e => e.PaymentMethod == method);
+ }
+
+ if (request.Status is { } status)
+ {
+ query = query.Where(e => e.Status == status);
+ }
+
+ if (request.IsPaid is { } isPaid)
+ {
+ query = query.Where(e => e.IsPaid == isPaid);
+ }
+
+ var expenses = await query.ToListAsync(cancellationToken);
+
+ if (!string.IsNullOrWhiteSpace(request.Search))
+ {
+ var term = request.Search.Trim();
+
+ expenses = [.. expenses.Where(e =>
+ (e.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false)
+ || (e.PaymentReference?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false)
+ || e.ExpenseNumber.Contains(term, StringComparison.OrdinalIgnoreCase))];
+ }
+
+ var categoryNames = await db.ExpenseCategories.AsNoTracking()
+ .ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken);
+
+ var userNames = await db.Users.AsNoTracking()
+ .ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken);
+
+ var dtos = expenses
+ .OrderByDescending(e => e.ExpenseDate)
+ .ThenByDescending(e => e.Number)
+ .Select(e => e.ToSummaryDto(
+ categoryNames.GetValueOrDefault(e.CategoryId, string.Empty),
+ userNames.GetValueOrDefault(e.RecordedByUserId, string.Empty)))
+ .ToList();
+
+ return Result.Success>(dtos);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetMonthlyExpenseReport/GetMonthlyExpenseReportQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetMonthlyExpenseReport/GetMonthlyExpenseReportQuery.cs
new file mode 100644
index 0000000..0164e87
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetMonthlyExpenseReport/GetMonthlyExpenseReportQuery.cs
@@ -0,0 +1,105 @@
+using System.Globalization;
+
+using FluentValidation;
+
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Expenses.Common;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetMonthlyExpenseReport;
+
+///
+/// A month's expenses with its daily trend, weekly split, category totals, comparison against the
+/// month before and any budget alerts (EXP-018, EXP-019, EXP-025, EXP-029, EXP-038).
+///
+public sealed record GetMonthlyExpenseReportQuery(int Year, int Month)
+ : IRequest>;
+
+public sealed class GetMonthlyExpenseReportQueryValidator : AbstractValidator
+{
+ public GetMonthlyExpenseReportQueryValidator()
+ {
+ RuleFor(x => x.Year).InclusiveBetween(2000, 2200);
+ RuleFor(x => x.Month).InclusiveBetween(1, 12);
+ }
+}
+
+internal sealed class GetMonthlyExpenseReportQueryHandler(IAppDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetMonthlyExpenseReportQuery request, CancellationToken cancellationToken)
+ {
+ var from = new DateOnly(request.Year, request.Month, 1);
+ var to = from.AddMonths(1).AddDays(-1);
+
+ var previousMonth = from.AddMonths(-1);
+ var previousFrom = previousMonth;
+ var previousTo = previousMonth.AddMonths(1).AddDays(-1);
+
+ var categories = await db.ExpenseCategories.AsNoTracking()
+ .ToDictionaryAsync(c => c.Id, cancellationToken);
+
+ var expenses = await ExpenseAnalytics.ApprovedExpensesBetweenAsync(db, from, to, cancellationToken);
+ var previousExpenses = await ExpenseAnalytics.ApprovedExpensesBetweenAsync(
+ db, previousFrom, previousTo, cancellationToken);
+
+ var revenueByDay = await ExpenseAnalytics.RevenueByDayAsync(db, from, to, cancellationToken);
+ var revenue = revenueByDay.Values.Sum();
+
+ var expensesByDay = expenses
+ .GroupBy(e => e.ExpenseDate)
+ .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount));
+
+ // Every day of the month is emitted, including quiet ones, so the trend line has no gaps
+ // that would make a closed Monday look like a missing reading.
+ var dailyFigures = Enumerable
+ .Range(0, to.Day)
+ .Select(offset => from.AddDays(offset))
+ .Select(day =>
+ {
+ var dayRevenue = revenueByDay.GetValueOrDefault(day);
+ var dayExpenses = expensesByDay.GetValueOrDefault(day);
+
+ return new DailyFigureDto(day, dayRevenue, dayExpenses, dayRevenue - dayExpenses);
+ })
+ .ToList();
+
+ return Result.Success(new MonthlyExpenseReportDto(
+ request.Year,
+ request.Month,
+ from.ToString("MMMM yyyy", CultureInfo.InvariantCulture),
+ ExpenseAnalytics.BuildProfitSummary(revenue, expenses.Sum(e => e.Amount)),
+ ExpenseAnalytics.BuildCategoryBreakdown(expenses, categories),
+ dailyFigures,
+ BuildWeeks(dailyFigures),
+ ExpenseAnalytics.BuildComparison(
+ previousMonth.ToString("MMMM yyyy", CultureInfo.InvariantCulture),
+ previousExpenses,
+ expenses,
+ categories),
+ ExpenseAnalytics.BuildBudgetAlerts(expenses, categories.Values)));
+ }
+
+ ///
+ /// Splits the month into calendar weeks of seven days from the 1st. Deliberately not ISO
+ /// weeks: the report is read as "the first week of January", and a week that starts on the
+ /// 29th of December would confuse everyone looking at it.
+ ///
+ private static IReadOnlyCollection BuildWeeks(IReadOnlyCollection days) =>
+ [.. days
+ .Select((day, index) => (day, week: index / 7))
+ .GroupBy(x => x.week)
+ .Select(g => new WeeklyFigureDto(
+ g.Key + 1,
+ g.First().day.Date,
+ g.Last().day.Date,
+ g.Sum(x => x.day.Revenue),
+ g.Sum(x => x.day.Expenses),
+ g.Sum(x => x.day.Profit)))];
+}
diff --git a/backend/src/RestaurantPOS.Application/Expenses/Queries/GetRecurringExpenses/GetRecurringExpensesQuery.cs b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetRecurringExpenses/GetRecurringExpensesQuery.cs
new file mode 100644
index 0000000..1c13256
--- /dev/null
+++ b/backend/src/RestaurantPOS.Application/Expenses/Queries/GetRecurringExpenses/GetRecurringExpensesQuery.cs
@@ -0,0 +1,34 @@
+using MediatR;
+
+using Microsoft.EntityFrameworkCore;
+
+using RestaurantPOS.Application.Common.Interfaces;
+using RestaurantPOS.Application.Common.Mappings;
+using RestaurantPOS.Application.Expenses.Dtos;
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Application.Expenses.Queries.GetRecurringExpenses;
+
+/// Every standing monthly cost the restaurant has set up.
+public sealed record GetRecurringExpensesQuery : IRequest>>;
+
+internal sealed class GetRecurringExpensesQueryHandler(IAppDbContext db)
+ : IRequestHandler>>
+{
+ public async Task>> Handle(
+ GetRecurringExpensesQuery request, CancellationToken cancellationToken)
+ {
+ var recurring = await db.RecurringExpenses.AsNoTracking().ToListAsync(cancellationToken);
+
+ var categoryNames = await db.ExpenseCategories.AsNoTracking()
+ .ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken);
+
+ var dtos = recurring
+ .Select(r => r.ToDto(categoryNames.GetValueOrDefault(r.CategoryId, string.Empty)))
+ .OrderBy(r => r.DayOfMonth)
+ .ThenBy(r => r.CategoryName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return Result.Success>(dtos);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Domain/Entities/Expense.cs b/backend/src/RestaurantPOS.Domain/Entities/Expense.cs
new file mode 100644
index 0000000..4344efc
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Entities/Expense.cs
@@ -0,0 +1,287 @@
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.Domain.Entities;
+
+///
+/// Money the restaurant paid out — a gas refill, the CEB bill, staff lunch.
+///
+///
+/// Freezes on approval (BR-EXP-007, EXP-036). An approved expense has been counted into a day's
+/// profit and quite possibly reported to the owner, so letting the figure move afterwards would
+/// silently rewrite history that somebody has already acted on. Correcting an approved expense
+/// means rejecting it and recording the right one, which leaves both visible.
+///
+public sealed class Expense : BaseEntity
+{
+ public const int DescriptionMaxLength = 500;
+ public const int ReferenceMaxLength = 100;
+
+ private readonly List _approvalTrail = [];
+ private readonly List _attachments = [];
+
+ // EF Core materialisation.
+ private Expense()
+ {
+ }
+
+ private Expense(
+ int number,
+ DateOnly expenseDate,
+ Guid categoryId,
+ decimal amount,
+ string? description,
+ ExpensePaymentMethod paymentMethod,
+ string? paymentReference,
+ DateOnly? paymentDate,
+ bool isPaid,
+ Guid recordedByUserId,
+ Guid? recurringExpenseId)
+ {
+ Number = number;
+ Year = expenseDate.Year;
+ ExpenseDate = expenseDate;
+ CategoryId = categoryId;
+ Amount = ValidateAmount(amount);
+ Description = NormaliseDescription(description);
+ PaymentMethod = paymentMethod;
+ PaymentReference = ValidateReference(paymentMethod, paymentReference);
+ PaymentDate = paymentDate;
+ IsPaid = isPaid;
+ RecordedByUserId = recordedByUserId;
+ RecurringExpenseId = recurringExpenseId;
+ Status = ExpenseStatus.Draft;
+ }
+
+ /// Sequence within , part of the reference staff quote (EXP-009).
+ public int Number { get; private set; }
+
+ public int Year { get; private set; }
+
+ /// The customer-facing reference, e.g. "EXP-001-2026".
+ public string ExpenseNumber => $"EXP-{Number:000}-{Year}";
+
+ /// The day the money was spent, which is the day it counts against (EXP-002).
+ public DateOnly ExpenseDate { get; private set; }
+
+ public Guid CategoryId { get; private set; }
+
+ public decimal Amount { get; private set; }
+
+ public string? Description { get; private set; }
+
+ public ExpenseStatus Status { get; private set; }
+
+ public ExpensePaymentMethod PaymentMethod { get; private set; }
+
+ /// Cheque number, card slip or transfer reference. Required for everything but cash.
+ public string? PaymentReference { get; private set; }
+
+ /// When the money actually leaves, which can be later than the expense date.
+ public DateOnly? PaymentDate { get; private set; }
+
+ /// Whether the money has gone out yet (EXP-034).
+ public bool IsPaid { get; private set; }
+
+ public Guid RecordedByUserId { get; private set; }
+
+ /// The standing instruction that generated this, when it was not keyed in by hand.
+ public Guid? RecurringExpenseId { get; private set; }
+
+ public Guid? ApprovedByUserId { get; private set; }
+
+ public DateTime? ApprovedAtUtc { get; private set; }
+
+ public string? ApprovalComments { get; private set; }
+
+ public IReadOnlyCollection ApprovalTrail => _approvalTrail.AsReadOnly();
+
+ public IReadOnlyCollection Attachments => _attachments.AsReadOnly();
+
+ /// True while the expense can still be changed — before anybody has ruled on it.
+ public bool IsEditable => Status is ExpenseStatus.Draft or ExpenseStatus.Pending;
+
+ /// True when this expense counts towards reports and profit (BR-EXP-005, BR-EXP-010).
+ public bool CountsTowardsReports => Status == ExpenseStatus.Approved;
+
+ public static Expense Create(
+ int number,
+ DateOnly expenseDate,
+ Guid categoryId,
+ decimal amount,
+ string? description,
+ ExpensePaymentMethod paymentMethod,
+ string? paymentReference,
+ DateOnly? paymentDate,
+ bool isPaid,
+ Guid recordedByUserId,
+ Guid? recurringExpenseId = null) =>
+ new(number, expenseDate, categoryId, amount, description, paymentMethod,
+ paymentReference, paymentDate, isPaid, recordedByUserId, recurringExpenseId);
+
+ public void UpdateDetails(
+ DateOnly expenseDate,
+ Guid categoryId,
+ decimal amount,
+ string? description,
+ ExpensePaymentMethod paymentMethod,
+ string? paymentReference,
+ DateOnly? paymentDate,
+ bool isPaid)
+ {
+ EnsureEditable();
+
+ ExpenseDate = expenseDate;
+ Year = expenseDate.Year;
+ CategoryId = categoryId;
+ Amount = ValidateAmount(amount);
+ Description = NormaliseDescription(description);
+ PaymentMethod = paymentMethod;
+ PaymentReference = ValidateReference(paymentMethod, paymentReference);
+ PaymentDate = paymentDate;
+ IsPaid = isPaid;
+ }
+
+ /// Puts a draft forward for a manager to rule on.
+ public void Submit(Guid submittedByUserId, DateTime nowUtc, string? comments = null)
+ {
+ if (Status != ExpenseStatus.Draft)
+ {
+ throw new InvalidOperationException("Only a draft expense can be submitted for approval.");
+ }
+
+ RecordTransition(ExpenseStatus.Pending, submittedByUserId, nowUtc, comments);
+ }
+
+ /// Signs the expense off, freezing it and letting it count towards reports.
+ public void Approve(Guid approvedByUserId, DateTime nowUtc, string? comments = null)
+ {
+ EnsureAwaitingDecision();
+
+ ApprovedByUserId = approvedByUserId;
+ ApprovedAtUtc = nowUtc;
+ ApprovalComments = NormaliseComments(comments);
+
+ RecordTransition(ExpenseStatus.Approved, approvedByUserId, nowUtc, comments);
+ }
+
+ /// Turns the expense down. It stays on record rather than being deleted (BR-EXP-006).
+ public void Reject(Guid rejectedByUserId, DateTime nowUtc, string? comments = null)
+ {
+ EnsureAwaitingDecision();
+
+ ApprovedByUserId = rejectedByUserId;
+ ApprovedAtUtc = nowUtc;
+ ApprovalComments = NormaliseComments(comments);
+
+ RecordTransition(ExpenseStatus.Rejected, rejectedByUserId, nowUtc, comments);
+ }
+
+ /// Records that the money has, or has not, actually gone out (EXP-034).
+ public void SetPaid(bool isPaid, DateOnly? paymentDate)
+ {
+ IsPaid = isPaid;
+ PaymentDate = isPaid ? paymentDate ?? PaymentDate : null;
+ }
+
+ /// Files a receipt against the expense, while it can still be changed.
+ public ExpenseAttachment AddAttachment(
+ string fileName, string storedPath, string contentType, long sizeBytes, Guid uploadedByUserId, DateTime nowUtc)
+ {
+ EnsureEditable();
+
+ var attachment = new ExpenseAttachment(
+ Id, fileName, storedPath, contentType, sizeBytes, uploadedByUserId, nowUtc);
+
+ _attachments.Add(attachment);
+
+ return attachment;
+ }
+
+ /// Takes a filed receipt back off the expense.
+ public void RemoveAttachment(ExpenseAttachment attachment)
+ {
+ EnsureEditable();
+ _attachments.Remove(attachment);
+ }
+
+ private void RecordTransition(ExpenseStatus toStatus, Guid actedByUserId, DateTime nowUtc, string? comments)
+ {
+ _approvalTrail.Add(new ExpenseApprovalEntry(
+ Id, Status, toStatus, actedByUserId, nowUtc, NormaliseComments(comments)));
+
+ Status = toStatus;
+ }
+
+ private void EnsureAwaitingDecision()
+ {
+ if (Status is not (ExpenseStatus.Draft or ExpenseStatus.Pending))
+ {
+ throw new InvalidOperationException("Only a draft or pending expense can be approved or rejected.");
+ }
+ }
+
+ private void EnsureEditable()
+ {
+ if (!IsEditable)
+ {
+ throw new InvalidOperationException($"An expense that is {Status} can no longer be changed.");
+ }
+ }
+
+ private static decimal ValidateAmount(decimal amount) =>
+ amount > 0
+ ? amount
+ : throw new ArgumentOutOfRangeException(nameof(amount), amount, "An expense amount must be greater than zero.");
+
+ ///
+ /// Cash leaves no paper trail of its own, so it needs no reference; every other method
+ /// produces a cheque number, slip or transfer id, and an expense without one cannot be tied
+ /// back to the bank statement.
+ ///
+ private static string? ValidateReference(ExpensePaymentMethod method, string? reference)
+ {
+ var trimmed = string.IsNullOrWhiteSpace(reference) ? null : reference.Trim();
+
+ if (trimmed is not null && trimmed.Length > ReferenceMaxLength)
+ {
+ throw new ArgumentException(
+ $"Reference cannot exceed {ReferenceMaxLength} characters.", nameof(reference));
+ }
+
+ return method != ExpensePaymentMethod.Cash && trimmed is null
+ ? throw new ArgumentException(
+ "A payment reference is required for cheque, card and bank transfer payments.", nameof(reference))
+ : trimmed;
+ }
+
+ private static string? NormaliseDescription(string? description)
+ {
+ if (string.IsNullOrWhiteSpace(description))
+ {
+ return null;
+ }
+
+ var trimmed = description.Trim();
+
+ return trimmed.Length > DescriptionMaxLength
+ ? throw new ArgumentException(
+ $"Description cannot exceed {DescriptionMaxLength} characters.", nameof(description))
+ : trimmed;
+ }
+
+ private static string? NormaliseComments(string? comments)
+ {
+ if (string.IsNullOrWhiteSpace(comments))
+ {
+ return null;
+ }
+
+ var trimmed = comments.Trim();
+
+ return trimmed.Length > ExpenseApprovalEntry.CommentsMaxLength
+ ? throw new ArgumentException(
+ $"Comments cannot exceed {ExpenseApprovalEntry.CommentsMaxLength} characters.", nameof(comments))
+ : trimmed;
+ }
+}
diff --git a/backend/src/RestaurantPOS.Domain/Entities/ExpenseApprovalEntry.cs b/backend/src/RestaurantPOS.Domain/Entities/ExpenseApprovalEntry.cs
new file mode 100644
index 0000000..23dc2de
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Entities/ExpenseApprovalEntry.cs
@@ -0,0 +1,50 @@
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.Domain.Entities;
+
+///
+/// One step in an expense's approval history (EXP-014).
+///
+///
+/// Append-only. The expense itself carries who approved it and when, but that is only the last
+/// word — this keeps the whole conversation, so an expense rejected on Monday and approved on
+/// Wednesday still shows why it was turned down the first time.
+///
+public sealed class ExpenseApprovalEntry : BaseEntity
+{
+ public const int CommentsMaxLength = 500;
+
+ // EF Core materialisation.
+ private ExpenseApprovalEntry()
+ {
+ }
+
+ internal ExpenseApprovalEntry(
+ Guid expenseId,
+ ExpenseStatus fromStatus,
+ ExpenseStatus toStatus,
+ Guid actedByUserId,
+ DateTime actedAtUtc,
+ string? comments)
+ {
+ ExpenseId = expenseId;
+ FromStatus = fromStatus;
+ ToStatus = toStatus;
+ ActedByUserId = actedByUserId;
+ ActedAtUtc = actedAtUtc;
+ Comments = comments;
+ }
+
+ public Guid ExpenseId { get; private set; }
+
+ public ExpenseStatus FromStatus { get; private set; }
+
+ public ExpenseStatus ToStatus { get; private set; }
+
+ public Guid ActedByUserId { get; private set; }
+
+ public DateTime ActedAtUtc { get; private set; }
+
+ public string? Comments { get; private set; }
+}
diff --git a/backend/src/RestaurantPOS.Domain/Entities/ExpenseAttachment.cs b/backend/src/RestaurantPOS.Domain/Entities/ExpenseAttachment.cs
new file mode 100644
index 0000000..6648747
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Entities/ExpenseAttachment.cs
@@ -0,0 +1,57 @@
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Domain.Entities;
+
+///
+/// A receipt or invoice filed against an expense (EXP-037).
+///
+///
+/// Only the metadata lives in the database; the file itself sits in a folder beside it. A few
+/// hundred phone photos of receipts would otherwise dwarf every other table in the install and
+/// make each backup drag the lot along.
+///
+public sealed class ExpenseAttachment : BaseEntity
+{
+ public const int FileNameMaxLength = 255;
+ public const int ContentTypeMaxLength = 120;
+ public const int StoredPathMaxLength = 400;
+
+ // EF Core materialisation.
+ private ExpenseAttachment()
+ {
+ }
+
+ internal ExpenseAttachment(
+ Guid expenseId,
+ string fileName,
+ string storedPath,
+ string contentType,
+ long sizeBytes,
+ Guid uploadedByUserId,
+ DateTime uploadedAtUtc)
+ {
+ ExpenseId = expenseId;
+ FileName = fileName;
+ StoredPath = storedPath;
+ ContentType = contentType;
+ SizeBytes = sizeBytes;
+ UploadedByUserId = uploadedByUserId;
+ UploadedAtUtc = uploadedAtUtc;
+ }
+
+ public Guid ExpenseId { get; private set; }
+
+ /// The name the file had when it was chosen, shown back to the user.
+ public string FileName { get; private set; } = string.Empty;
+
+ /// Where it lives, relative to the attachments root — never an absolute path.
+ public string StoredPath { get; private set; } = string.Empty;
+
+ public string ContentType { get; private set; } = string.Empty;
+
+ public long SizeBytes { get; private set; }
+
+ public Guid UploadedByUserId { get; private set; }
+
+ public DateTime UploadedAtUtc { get; private set; }
+}
diff --git a/backend/src/RestaurantPOS.Domain/Entities/ExpenseCategory.cs b/backend/src/RestaurantPOS.Domain/Entities/ExpenseCategory.cs
new file mode 100644
index 0000000..5a2424c
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Entities/ExpenseCategory.cs
@@ -0,0 +1,103 @@
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Domain.Entities;
+
+///
+/// What an expense is for — Gas, Electricity, Staff Meals (EXP-010, EXP-011).
+///
+///
+/// A category may sit under another to give subcategories one level deep, which is enough to say
+/// "Utilities → Electricity" without letting the tree grow into something nobody can total up.
+/// Seeded categories are marked : they can be renamed and re-budgeted, but
+/// not deleted, so a restaurant cannot remove "Rent" and orphan a year of history.
+///
+public sealed class ExpenseCategory : BaseEntity
+{
+ public const int NameMaxLength = 100;
+ public const int DescriptionMaxLength = 250;
+
+ // EF Core materialisation.
+ private ExpenseCategory()
+ {
+ }
+
+ private ExpenseCategory(
+ string name, string? description, decimal? monthlyBudget, Guid? parentCategoryId, bool isSystem)
+ {
+ Name = NormaliseName(name);
+ Description = NormaliseDescription(description);
+ MonthlyBudget = ValidateBudget(monthlyBudget);
+ ParentCategoryId = parentCategoryId;
+ IsSystem = isSystem;
+ IsActive = true;
+ }
+
+ public string Name { get; private set; } = string.Empty;
+
+ public string? Description { get; private set; }
+
+ ///
+ /// What the restaurant expects to spend here in a month. Null means unbudgeted, which
+ /// suppresses the overspend alerts rather than treating the limit as zero (BR-EXP-015).
+ ///
+ public decimal? MonthlyBudget { get; private set; }
+
+ /// The parent this sits under, or null for a top-level category.
+ public Guid? ParentCategoryId { get; private set; }
+
+ /// True for the categories shipped with the system, which cannot be deleted.
+ public bool IsSystem { get; private set; }
+
+ public bool IsActive { get; private set; }
+
+ public static ExpenseCategory Create(
+ string name,
+ string? description = null,
+ decimal? monthlyBudget = null,
+ Guid? parentCategoryId = null,
+ bool isSystem = false) =>
+ new(name, description, monthlyBudget, parentCategoryId, isSystem);
+
+ public void UpdateDetails(string name, string? description, decimal? monthlyBudget, Guid? parentCategoryId)
+ {
+ Name = NormaliseName(name);
+ Description = NormaliseDescription(description);
+ MonthlyBudget = ValidateBudget(monthlyBudget);
+ ParentCategoryId = parentCategoryId;
+ }
+
+ public void Activate() => IsActive = true;
+
+ public void Deactivate() => IsActive = false;
+
+ private static string NormaliseName(string name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ var trimmed = name.Trim();
+
+ return trimmed.Length > NameMaxLength
+ ? throw new ArgumentException($"Name cannot exceed {NameMaxLength} characters.", nameof(name))
+ : trimmed;
+ }
+
+ private static string? NormaliseDescription(string? description)
+ {
+ if (string.IsNullOrWhiteSpace(description))
+ {
+ return null;
+ }
+
+ var trimmed = description.Trim();
+
+ return trimmed.Length > DescriptionMaxLength
+ ? throw new ArgumentException(
+ $"Description cannot exceed {DescriptionMaxLength} characters.", nameof(description))
+ : trimmed;
+ }
+
+ private static decimal? ValidateBudget(decimal? budget) =>
+ budget is null or >= 0
+ ? budget
+ : throw new ArgumentOutOfRangeException(nameof(budget), budget, "A budget cannot be negative.");
+}
diff --git a/backend/src/RestaurantPOS.Domain/Entities/RecurringExpense.cs b/backend/src/RestaurantPOS.Domain/Entities/RecurringExpense.cs
new file mode 100644
index 0000000..418b9e1
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Entities/RecurringExpense.cs
@@ -0,0 +1,137 @@
+using RestaurantPOS.Domain.Common;
+using RestaurantPOS.Domain.Enums;
+
+namespace RestaurantPOS.Domain.Entities;
+
+///
+/// A standing monthly cost — rent, salaries — that should appear every month without being
+/// keyed in again (EXP-012, BR-EXP-008).
+///
+///
+/// The generated expense arrives as a , never approved. Rent
+/// changes, a month gets skipped, a salary bill differs — a standing instruction is a reminder of
+/// what to expect, not permission to book money the manager has not looked at.
+///
+/// and are what make generation
+/// safe to run whenever the app is opened: a month that has already produced its expense is
+/// simply skipped, so opening the screen five times in one morning cannot create five rents.
+///
+///
+public sealed class RecurringExpense : BaseEntity
+{
+ public const int DescriptionMaxLength = 500;
+
+ // EF Core materialisation.
+ private RecurringExpense()
+ {
+ }
+
+ private RecurringExpense(
+ Guid categoryId,
+ decimal amount,
+ string? description,
+ ExpensePaymentMethod paymentMethod,
+ int dayOfMonth)
+ {
+ CategoryId = categoryId;
+ Amount = ValidateAmount(amount);
+ Description = NormaliseDescription(description);
+ PaymentMethod = paymentMethod;
+ DayOfMonth = ValidateDayOfMonth(dayOfMonth);
+ IsActive = true;
+ }
+
+ public Guid CategoryId { get; private set; }
+
+ public decimal Amount { get; private set; }
+
+ public string? Description { get; private set; }
+
+ public ExpensePaymentMethod PaymentMethod { get; private set; }
+
+ ///
+ /// Which day of the month the expense falls on. Clamped to the length of a short month, so a
+ /// rent set to the 31st still lands on the 28th of February rather than being skipped.
+ ///
+ public int DayOfMonth { get; private set; }
+
+ public bool IsActive { get; private set; }
+
+ public int? LastGeneratedYear { get; private set; }
+
+ public int? LastGeneratedMonth { get; private set; }
+
+ public static RecurringExpense Create(
+ Guid categoryId,
+ decimal amount,
+ string? description,
+ ExpensePaymentMethod paymentMethod,
+ int dayOfMonth) =>
+ new(categoryId, amount, description, paymentMethod, dayOfMonth);
+
+ public void UpdateDetails(
+ Guid categoryId, decimal amount, string? description, ExpensePaymentMethod paymentMethod, int dayOfMonth)
+ {
+ CategoryId = categoryId;
+ Amount = ValidateAmount(amount);
+ Description = NormaliseDescription(description);
+ PaymentMethod = paymentMethod;
+ DayOfMonth = ValidateDayOfMonth(dayOfMonth);
+ }
+
+ public void Activate() => IsActive = true;
+
+ public void Deactivate() => IsActive = false;
+
+ /// True when this instruction still owes an expense for the given month.
+ public bool IsDueFor(int year, int month)
+ {
+ if (!IsActive)
+ {
+ return false;
+ }
+
+ if (LastGeneratedYear is null || LastGeneratedMonth is null)
+ {
+ return true;
+ }
+
+ return (year, month).CompareTo((LastGeneratedYear.Value, LastGeneratedMonth.Value)) > 0;
+ }
+
+ /// The date this month's expense falls on, clamped to the month's length.
+ public DateOnly DateFor(int year, int month) =>
+ new(year, month, Math.Min(DayOfMonth, DateTime.DaysInMonth(year, month)));
+
+ public void MarkGenerated(int year, int month)
+ {
+ LastGeneratedYear = year;
+ LastGeneratedMonth = month;
+ }
+
+ private static decimal ValidateAmount(decimal amount) =>
+ amount > 0
+ ? amount
+ : throw new ArgumentOutOfRangeException(nameof(amount), amount, "An amount must be greater than zero.");
+
+ private static int ValidateDayOfMonth(int dayOfMonth) =>
+ dayOfMonth is >= 1 and <= 31
+ ? dayOfMonth
+ : throw new ArgumentOutOfRangeException(
+ nameof(dayOfMonth), dayOfMonth, "The day of the month must be between 1 and 31.");
+
+ private static string? NormaliseDescription(string? description)
+ {
+ if (string.IsNullOrWhiteSpace(description))
+ {
+ return null;
+ }
+
+ var trimmed = description.Trim();
+
+ return trimmed.Length > DescriptionMaxLength
+ ? throw new ArgumentException(
+ $"Description cannot exceed {DescriptionMaxLength} characters.", nameof(description))
+ : trimmed;
+ }
+}
diff --git a/backend/src/RestaurantPOS.Domain/Enums/ExpensePaymentMethod.cs b/backend/src/RestaurantPOS.Domain/Enums/ExpensePaymentMethod.cs
new file mode 100644
index 0000000..30d0d81
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Enums/ExpensePaymentMethod.cs
@@ -0,0 +1,14 @@
+namespace RestaurantPOS.Domain.Enums;
+
+///
+/// How an expense was settled (BR-EXP-011). Separate from the till's
+/// and the supplier ledger's : money
+/// paid out for a gas cylinder accepts a different set of methods than a customer settling a bill.
+///
+public enum ExpensePaymentMethod
+{
+ Cash = 1,
+ Cheque = 2,
+ Card = 3,
+ BankTransfer = 4,
+}
diff --git a/backend/src/RestaurantPOS.Domain/Enums/ExpenseStatus.cs b/backend/src/RestaurantPOS.Domain/Enums/ExpenseStatus.cs
new file mode 100644
index 0000000..7956523
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Enums/ExpenseStatus.cs
@@ -0,0 +1,25 @@
+namespace RestaurantPOS.Domain.Enums;
+
+///
+/// Where an expense sits in its approval life (EXP-015).
+///
+///
+/// is not a compulsory stop. A manager recording the restaurant's own bills
+/// approves them straight from ; Pending is what an expense enters when
+/// somebody without approval rights submits one for a manager to decide on. Forcing a manager to
+/// submit an expense to themselves before approving it would be ceremony, not control.
+///
+public enum ExpenseStatus
+{
+ /// Recorded but not yet put forward. Freely editable.
+ Draft = 1,
+
+ /// Submitted and waiting on a manager. Still editable until decided.
+ Pending = 2,
+
+ /// Signed off. Frozen, and the only state that counts towards reports (BR-EXP-005).
+ Approved = 3,
+
+ /// Turned down. Kept rather than deleted so the record survives (BR-EXP-006).
+ Rejected = 4,
+}
diff --git a/backend/src/RestaurantPOS.Domain/Errors/ExpenseErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/ExpenseErrors.cs
new file mode 100644
index 0000000..0038a39
--- /dev/null
+++ b/backend/src/RestaurantPOS.Domain/Errors/ExpenseErrors.cs
@@ -0,0 +1,74 @@
+using RestaurantPOS.Domain.Common;
+
+namespace RestaurantPOS.Domain.Errors;
+
+/// Errors raised by expense, category, recurring-expense and expense-report use cases.
+public static class ExpenseErrors
+{
+ public static Error CategoryNotFound(Guid id) =>
+ Error.NotFound("ExpenseCategory.NotFound", $"No expense category was found with id '{id}'.");
+
+ public static readonly Error CategoryNameTaken =
+ Error.Conflict("ExpenseCategory.NameTaken", "An expense category with that name already exists.");
+
+ public static readonly Error CategoryInactive =
+ Error.Validation("ExpenseCategory.Inactive", "This category is inactive and cannot take new expenses.");
+
+ public static readonly Error CategoryIsSystem =
+ Error.Conflict(
+ "ExpenseCategory.IsSystem",
+ "This is a built-in category and cannot be deleted. Deactivate it instead.");
+
+ public static readonly Error CategoryInUse =
+ Error.Conflict(
+ "ExpenseCategory.InUse",
+ "Expenses have already been recorded against this category. Deactivate it instead of deleting it.");
+
+ public static readonly Error CategoryOwnParent =
+ Error.Validation("ExpenseCategory.OwnParent", "A category cannot be its own parent.");
+
+ public static readonly Error CategoryNestingTooDeep =
+ Error.Validation(
+ "ExpenseCategory.NestingTooDeep",
+ "Subcategories can only be one level deep. Choose a top-level category as the parent.");
+
+ public static Error NotFound(Guid id) =>
+ Error.NotFound("Expense.NotFound", $"No expense was found with id '{id}'.");
+
+ public static readonly Error NotEditable =
+ Error.Conflict(
+ "Expense.NotEditable", "This expense has already been approved or rejected and can no longer be changed.");
+
+ public static readonly Error NotSubmittable =
+ Error.Conflict("Expense.NotSubmittable", "Only a draft expense can be submitted for approval.");
+
+ public static readonly Error AlreadyDecided =
+ Error.Conflict("Expense.AlreadyDecided", "This expense has already been approved or rejected.");
+
+ public static readonly Error FutureDate =
+ Error.Validation("Expense.FutureDate", "An expense cannot be dated in the future.");
+
+ public static readonly Error ReferenceRequired =
+ Error.Validation(
+ "Expense.ReferenceRequired",
+ "A payment reference is required for cheque, card and bank transfer payments.");
+
+ public static Error AttachmentNotFound(Guid id) =>
+ Error.NotFound("Expense.AttachmentNotFound", $"No attachment was found with id '{id}'.");
+
+ public static readonly Error AttachmentTypeNotAllowed =
+ Error.Validation(
+ "Expense.AttachmentTypeNotAllowed", "Only JPEG, PNG, WebP and PDF receipts can be attached.");
+
+ public static Error AttachmentTooLarge(int maxMegabytes) =>
+ Error.Validation("Expense.AttachmentTooLarge", $"A receipt cannot be larger than {maxMegabytes} MB.");
+
+ public static readonly Error AttachmentMissing =
+ Error.NotFound("Expense.AttachmentMissing", "The stored file for this attachment could not be found.");
+
+ public static Error RecurringNotFound(Guid id) =>
+ Error.NotFound("RecurringExpense.NotFound", $"No recurring expense was found with id '{id}'.");
+
+ public static readonly Error InvalidDateRange =
+ Error.Validation("Expense.InvalidDateRange", "The start of the range must not be after its end.");
+}
diff --git a/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs b/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs
index 7d06e20..4ec40ef 100644
--- a/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs
+++ b/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs
@@ -9,6 +9,7 @@
using RestaurantPOS.Infrastructure.Persistence.Interceptors;
using RestaurantPOS.Infrastructure.Persistence.Seeding;
using RestaurantPOS.Infrastructure.Settings;
+using RestaurantPOS.Infrastructure.Storage;
namespace RestaurantPOS.Infrastructure;
@@ -37,6 +38,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddSingleton();
services.AddScoped();
+ services.AddSingleton();
+
services.AddScoped();
services.AddDbContext((serviceProvider, options) =>
diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs
index 82324df..8578377 100644
--- a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs
+++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs
@@ -61,6 +61,16 @@ public AppDbContext(DbContextOptions options, IPublisher? publishe
public DbSet Receipts => Set();
+ public DbSet ExpenseCategories => Set();
+
+ public DbSet Expenses => Set();
+
+ public DbSet ExpenseAttachments => Set();
+
+ public DbSet ExpenseApprovalEntries => Set();
+
+ public DbSet RecurringExpenses => Set();
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/ExpenseConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/ExpenseConfiguration.cs
new file mode 100644
index 0000000..e8ebe96
--- /dev/null
+++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/ExpenseConfiguration.cs
@@ -0,0 +1,139 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+using RestaurantPOS.Domain.Entities;
+
+namespace RestaurantPOS.Infrastructure.Persistence.Configurations;
+
+internal sealed class ExpenseCategoryConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("ExpenseCategories");
+
+ builder.HasKey(c => c.Id);
+ builder.Property(c => c.Id).ValueGeneratedNever();
+
+ builder.Property(c => c.Name).IsRequired().HasMaxLength(ExpenseCategory.NameMaxLength);
+ builder.Property(c => c.Description).HasMaxLength(ExpenseCategory.DescriptionMaxLength);
+
+ builder.HasIndex(c => c.Name).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(c => c.ParentCategoryId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class ExpenseConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("Expenses");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).ValueGeneratedNever();
+
+ builder.Property(e => e.Status).IsRequired().HasConversion();
+ builder.Property(e => e.PaymentMethod).IsRequired().HasConversion();
+ builder.Property(e => e.Amount).IsRequired();
+ builder.Property(e => e.Description).HasMaxLength(Expense.DescriptionMaxLength);
+ builder.Property(e => e.PaymentReference).HasMaxLength(Expense.ReferenceMaxLength);
+ builder.Property(e => e.ApprovalComments).HasMaxLength(ExpenseApprovalEntry.CommentsMaxLength);
+
+ // The yearly sequence must be unique within its year (BR-EXP-003).
+ builder.HasIndex(e => new { e.Year, e.Number }).IsUnique();
+ builder.HasIndex(e => e.ExpenseDate);
+ builder.HasIndex(e => new { e.Status, e.ExpenseDate });
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.CategoryId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ foreach (var navigation in new[] { nameof(Expense.ApprovalTrail), nameof(Expense.Attachments) })
+ {
+ builder.Metadata.FindNavigation(navigation)!.SetPropertyAccessMode(PropertyAccessMode.Field);
+ }
+
+ builder.HasMany(e => e.ApprovalTrail)
+ .WithOne()
+ .HasForeignKey(t => t.ExpenseId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasMany(e => e.Attachments)
+ .WithOne()
+ .HasForeignKey(a => a.ExpenseId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Derived from Number and Year, so it is composed on read rather than stored twice.
+ builder.Ignore(e => e.ExpenseNumber);
+ builder.Ignore(e => e.IsEditable);
+ builder.Ignore(e => e.CountsTowardsReports);
+ }
+}
+
+internal sealed class ExpenseApprovalEntryConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("ExpenseApprovalEntries");
+
+ builder.HasKey(t => t.Id);
+ builder.Property(t => t.Id).ValueGeneratedNever();
+
+ builder.Property(t => t.FromStatus).IsRequired().HasConversion();
+ builder.Property(t => t.ToStatus).IsRequired().HasConversion();
+ builder.Property(t => t.Comments).HasMaxLength(ExpenseApprovalEntry.CommentsMaxLength);
+
+ builder.HasIndex(t => t.ExpenseId);
+ }
+}
+
+internal sealed class ExpenseAttachmentConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("ExpenseAttachments");
+
+ builder.HasKey(a => a.Id);
+ builder.Property(a => a.Id).ValueGeneratedNever();
+
+ builder.Property(a => a.FileName).IsRequired().HasMaxLength(ExpenseAttachment.FileNameMaxLength);
+ builder.Property(a => a.StoredPath).IsRequired().HasMaxLength(ExpenseAttachment.StoredPathMaxLength);
+ builder.Property(a => a.ContentType).IsRequired().HasMaxLength(ExpenseAttachment.ContentTypeMaxLength);
+
+ builder.HasIndex(a => a.ExpenseId);
+ }
+}
+
+internal sealed class RecurringExpenseConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("RecurringExpenses");
+
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Id).ValueGeneratedNever();
+
+ builder.Property(r => r.PaymentMethod).IsRequired().HasConversion();
+ builder.Property(r => r.Amount).IsRequired();
+ builder.Property(r => r.Description).HasMaxLength(RecurringExpense.DescriptionMaxLength);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(r => r.CategoryId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260806104706_AddExpenseManagement.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260806104706_AddExpenseManagement.Designer.cs
new file mode 100644
index 0000000..360540d
--- /dev/null
+++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260806104706_AddExpenseManagement.Designer.cs
@@ -0,0 +1,1353 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RestaurantPOS.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace RestaurantPOS.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260806104706_AddExpenseManagement")]
+ partial class AddExpenseManagement
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.18");
+
+ modelBuilder.Entity("RestaurantPOS.Domain.Entities.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("DetailsJson")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("PerformedByName")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.Property("PerformedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("AuditLogEntries", (string)null);
+ });
+
+ modelBuilder.Entity("RestaurantPOS.Domain.Entities.Expense", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Amount")
+ .HasColumnType("TEXT");
+
+ b.Property("ApprovalComments")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ApprovedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ApprovedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("CategoryId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ExpenseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("IsPaid")
+ .HasColumnType("INTEGER");
+
+ b.Property("Number")
+ .HasColumnType("INTEGER");
+
+ b.Property("PaymentDate")
+ .HasColumnType("TEXT");
+
+ b.Property("PaymentMethod")
+ .HasColumnType("INTEGER");
+
+ b.Property("PaymentReference")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("RecordedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("RecurringExpenseId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("ExpenseDate");
+
+ b.HasIndex("Status", "ExpenseDate");
+
+ b.HasIndex("Year", "Number")
+ .IsUnique();
+
+ b.ToTable("Expenses", (string)null);
+ });
+
+ modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseApprovalEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ActedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ActedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Comments")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpenseId")
+ .HasColumnType("TEXT");
+
+ b.Property("FromStatus")
+ .HasColumnType("INTEGER");
+
+ b.Property("ToStatus")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpenseId");
+
+ b.ToTable("ExpenseApprovalEntries", (string)null);
+ });
+
+ modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseAttachment", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property