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("ExpenseId") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StoredPath") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UploadedByUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseId"); + + b.ToTable("ExpenseAttachments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseCategory", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MonthlyBudget") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentCategoryId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentCategoryId"); + + b.ToTable("ExpenseCategories", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("HasIssue") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("QualityRating") + .HasColumnType("INTEGER"); + + b.Property("ReceivedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReceivedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("ReceivedAtUtc"); + + b.ToTable("GoodsReceivedNotes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PrintCount") + .HasColumnType("INTEGER"); + + b.Property("PrintedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReadyAtUtc") + .HasColumnType("TEXT"); + + b.Property("ServedAtUtc") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TicketNumber") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrderId", "TicketNumber"); + + b.ToTable("KitchenTickets", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicketLine", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("KitchenTicketId") + .HasColumnType("TEXT"); + + b.Property("MenuItemName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("OrderItemId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SpecialInstructions") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KitchenTicketId"); + + b.ToTable("KitchenTicketLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CancelledAtUtc") + .HasColumnType("TEXT"); + + b.Property("CancelledByUserId") + .HasColumnType("TEXT"); + + b.Property("CashierUserId") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DiscountType") + .HasColumnType("INTEGER"); + + b.Property("DiscountValue") + .HasColumnType("TEXT"); + + b.Property("OrderDate") + .HasColumnType("TEXT"); + + b.Property("OrderNumber") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TableId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderDate", "OrderNumber"); + + b.HasIndex("TableId", "Status"); + + b.ToTable("Orders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CancelledAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsCancelled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("MenuItemName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SpecialInstructions") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenderedAmount") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "Status"); + + b.ToTable("PurchaseOrders", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("PurchaseOrderId", "RawMaterialId"); + + b.ToTable("PurchaseOrderLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RawMaterial", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("KitchenParLevel") + .HasColumnType("TEXT"); + + b.Property("MainStoreReorderLevel") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("UnitOfMeasurement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("RawMaterials", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Receipt", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IssuedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastPrintedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PrintCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("Receipts", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MenuItemId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId") + .IsUnique(); + + b.ToTable("Recipes", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.Property("RecipeId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.HasKey("RecipeId", "RawMaterialId"); + + b.ToTable("RecipeLines", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecurringExpense", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastGeneratedMonth") + .HasColumnType("INTEGER"); + + b.Property("LastGeneratedYear") + .HasColumnType("INTEGER"); + + b.Property("PaymentMethod") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("RecurringExpenses", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RestaurantTable", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("RestaurantTables", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockLevel", b => + { + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("QuantityOnHand") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("RawMaterialId", "Store"); + + b.ToTable("StockLevels", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .HasColumnType("TEXT"); + + b.Property("QuantityDelta") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasColumnType("TEXT"); + + b.Property("Store") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId"); + + b.HasIndex("Store", "RawMaterialId", "OccurredAtUtc"); + + b.ToTable("StockMovements", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.StockRelease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequestedAtUtc"); + + b.ToTable("StockReleases", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Supplier", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Address") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreditLimit") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("PaymentTermsDays") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPayment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PaymentDateUtc") + .HasColumnType("TEXT"); + + b.Property("PurchaseOrderId") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseOrderId"); + + b.ToTable("SupplierPayments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPrice", b => + { + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("SupplierId", "RawMaterialId"); + + b.ToTable("SupplierPrices", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.SupplierPriceHistoryEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("RawMaterialId") + .HasColumnType("TEXT"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RecordedByUserId") + .HasColumnType("TEXT"); + + b.Property("SupplierId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId", "RawMaterialId", "RecordedAtUtc"); + + b.ToTable("SupplierPriceHistoryEntries", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Expense", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.ExpenseCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseApprovalEntry", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Expense", null) + .WithMany("ApprovalTrail") + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseAttachment", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Expense", null) + .WithMany("Attachments") + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseCategory", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.ExpenseCategory", null) + .WithMany() + .HasForeignKey("ParentCategoryId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Tickets") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicketLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.KitchenTicket", null) + .WithMany("Lines") + .HasForeignKey("KitchenTicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.RestaurantTable", null) + .WithMany() + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderItem", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.MenuItem", null) + .WithMany() + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.OrderPayment", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithMany("Payments") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrderLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.PurchaseOrder", null) + .WithMany("Lines") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Receipt", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Order", null) + .WithOne("Receipt") + .HasForeignKey("RestaurantPOS.Domain.Entities.Receipt", "OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecipeLine", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Recipe", null) + .WithMany("Lines") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecurringExpense", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.ExpenseCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Expense", b => + { + b.Navigation("ApprovalTrail"); + + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Order", b => + { + b.Navigation("Items"); + + b.Navigation("Payments"); + + b.Navigation("Receipt"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Recipe", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260806104706_AddExpenseManagement.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260806104706_AddExpenseManagement.cs new file mode 100644 index 0000000..ae2e57b --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260806104706_AddExpenseManagement.cs @@ -0,0 +1,219 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddExpenseManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExpenseCategories", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 250, nullable: true), + MonthlyBudget = table.Column(type: "TEXT", nullable: true), + ParentCategoryId = table.Column(type: "TEXT", nullable: true), + IsSystem = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExpenseCategories", x => x.Id); + table.ForeignKey( + name: "FK_ExpenseCategories_ExpenseCategories_ParentCategoryId", + column: x => x.ParentCategoryId, + principalTable: "ExpenseCategories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Expenses", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Number = table.Column(type: "INTEGER", nullable: false), + Year = table.Column(type: "INTEGER", nullable: false), + ExpenseDate = table.Column(type: "TEXT", nullable: false), + CategoryId = table.Column(type: "TEXT", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + PaymentMethod = table.Column(type: "INTEGER", nullable: false), + PaymentReference = table.Column(type: "TEXT", maxLength: 100, nullable: true), + PaymentDate = table.Column(type: "TEXT", nullable: true), + IsPaid = table.Column(type: "INTEGER", nullable: false), + RecordedByUserId = table.Column(type: "TEXT", nullable: false), + RecurringExpenseId = table.Column(type: "TEXT", nullable: true), + ApprovedByUserId = table.Column(type: "TEXT", nullable: true), + ApprovedAtUtc = table.Column(type: "TEXT", nullable: true), + ApprovalComments = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Expenses", x => x.Id); + table.ForeignKey( + name: "FK_Expenses_ExpenseCategories_CategoryId", + column: x => x.CategoryId, + principalTable: "ExpenseCategories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RecurringExpenses", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CategoryId = table.Column(type: "TEXT", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + PaymentMethod = table.Column(type: "INTEGER", nullable: false), + DayOfMonth = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + LastGeneratedYear = table.Column(type: "INTEGER", nullable: true), + LastGeneratedMonth = table.Column(type: "INTEGER", nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RecurringExpenses", x => x.Id); + table.ForeignKey( + name: "FK_RecurringExpenses_ExpenseCategories_CategoryId", + column: x => x.CategoryId, + principalTable: "ExpenseCategories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ExpenseApprovalEntries", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ExpenseId = table.Column(type: "TEXT", nullable: false), + FromStatus = table.Column(type: "INTEGER", nullable: false), + ToStatus = table.Column(type: "INTEGER", nullable: false), + ActedByUserId = table.Column(type: "TEXT", nullable: false), + ActedAtUtc = table.Column(type: "TEXT", nullable: false), + Comments = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExpenseApprovalEntries", x => x.Id); + table.ForeignKey( + name: "FK_ExpenseApprovalEntries_Expenses_ExpenseId", + column: x => x.ExpenseId, + principalTable: "Expenses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ExpenseAttachments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ExpenseId = table.Column(type: "TEXT", nullable: false), + FileName = table.Column(type: "TEXT", maxLength: 255, nullable: false), + StoredPath = table.Column(type: "TEXT", maxLength: 400, nullable: false), + ContentType = table.Column(type: "TEXT", maxLength: 120, nullable: false), + SizeBytes = table.Column(type: "INTEGER", nullable: false), + UploadedByUserId = table.Column(type: "TEXT", nullable: false), + UploadedAtUtc = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExpenseAttachments", x => x.Id); + table.ForeignKey( + name: "FK_ExpenseAttachments_Expenses_ExpenseId", + column: x => x.ExpenseId, + principalTable: "Expenses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseApprovalEntries_ExpenseId", + table: "ExpenseApprovalEntries", + column: "ExpenseId"); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseAttachments_ExpenseId", + table: "ExpenseAttachments", + column: "ExpenseId"); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseCategories_Name", + table: "ExpenseCategories", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseCategories_ParentCategoryId", + table: "ExpenseCategories", + column: "ParentCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Expenses_CategoryId", + table: "Expenses", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Expenses_ExpenseDate", + table: "Expenses", + column: "ExpenseDate"); + + migrationBuilder.CreateIndex( + name: "IX_Expenses_Status_ExpenseDate", + table: "Expenses", + columns: new[] { "Status", "ExpenseDate" }); + + migrationBuilder.CreateIndex( + name: "IX_Expenses_Year_Number", + table: "Expenses", + columns: new[] { "Year", "Number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RecurringExpenses_CategoryId", + table: "RecurringExpenses", + column: "CategoryId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExpenseApprovalEntries"); + + migrationBuilder.DropTable( + name: "ExpenseAttachments"); + + migrationBuilder.DropTable( + name: "RecurringExpenses"); + + migrationBuilder.DropTable( + name: "Expenses"); + + migrationBuilder.DropTable( + name: "ExpenseCategories"); + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index a14ceac..10a5653 100644 --- a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -67,6 +67,206 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("ExpenseId") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StoredPath") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UploadedByUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseId"); + + b.ToTable("ExpenseAttachments", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseCategory", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(250) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MonthlyBudget") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentCategoryId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentCategoryId"); + + b.ToTable("ExpenseCategories", (string)null); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.GoodsReceivedNote", b => { b.Property("Id") @@ -537,6 +737,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RecipeLines", (string)null); }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecurringExpense", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastGeneratedMonth") + .HasColumnType("INTEGER"); + + b.Property("LastGeneratedYear") + .HasColumnType("INTEGER"); + + b.Property("PaymentMethod") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("RecurringExpenses", (string)null); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => { b.Property("Id") @@ -922,6 +1165,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserModulePermissions", (string)null); }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Expense", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.ExpenseCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseApprovalEntry", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Expense", null) + .WithMany("ApprovalTrail") + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseAttachment", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.Expense", null) + .WithMany("Attachments") + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.ExpenseCategory", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.ExpenseCategory", null) + .WithMany() + .HasForeignKey("ParentCategoryId") + .OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => { b.HasOne("RestaurantPOS.Domain.Entities.Order", null) @@ -1000,6 +1278,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RecurringExpense", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.ExpenseCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => { b.HasOne("RestaurantPOS.Domain.Entities.User", null) @@ -1018,6 +1305,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.Expense", b => + { + b.Navigation("ApprovalTrail"); + + b.Navigation("Attachments"); + }); + modelBuilder.Entity("RestaurantPOS.Domain.Entities.KitchenTicket", b => { b.Navigation("Lines"); diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs index b72050f..71f06bf 100644 --- a/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs @@ -27,6 +27,7 @@ public sealed partial class DatabaseSeeder( public async Task SeedAsync(CancellationToken cancellationToken = default) { await db.Database.MigrateAsync(cancellationToken); + await SeedExpenseCategoriesAsync(cancellationToken); var anyAdmin = await db.Users.AnyAsync(u => u.Role == UserRole.Admin, cancellationToken); if (anyAdmin) @@ -56,6 +57,51 @@ public async Task SeedAsync(CancellationToken cancellationToken = default) SeededAdmin(logger, username); } + /// + /// The expense categories a restaurant starts with (EXP-010), each with the budget from the + /// requirements. Only ever added when missing by name, so renaming "Gas/LPG" to something the + /// staff prefer does not cause it to be recreated on the next start-up. + /// + private async Task SeedExpenseCategoriesAsync(CancellationToken cancellationToken) + { + (string Name, string Description, decimal Budget)[] defaults = + [ + ("Rent/Lease", "Monthly rent for the premises", 50_000m), + ("Electricity", "CEB power bills", 35_000m), + ("Gas/LPG", "Cooking gas cylinders", 30_000m), + ("Water/Waste", "Water supply and waste collection", 20_000m), + ("Staff Meals", "Meals provided to employees", 15_000m), + ("Salaries", "Staff wages", 180_000m), + ("Maintenance", "Repairs to equipment and premises", 25_000m), + ("Miscellaneous", "Everything without a category of its own", 50_000m), + ]; + + var existing = await db.ExpenseCategories + .Select(c => c.Name.ToLower()) + .ToListAsync(cancellationToken); + + var missing = defaults + .Where(d => !existing.Contains(d.Name.ToLowerInvariant())) + .Select(d => ExpenseCategory.Create(d.Name, d.Description, d.Budget, parentCategoryId: null, isSystem: true)) + .ToList(); + + if (missing.Count == 0) + { + return; + } + + db.ExpenseCategories.AddRange(missing); + await db.SaveChangesAsync(cancellationToken); + + SeededExpenseCategories(logger, missing.Count); + } + + [LoggerMessage( + EventId = 2002, + Level = LogLevel.Information, + Message = "Seeded {Count} built-in expense categories.")] + private static partial void SeededExpenseCategories(ILogger logger, int count); + [LoggerMessage( EventId = 2000, Level = LogLevel.Warning, diff --git a/backend/src/RestaurantPOS.Infrastructure/Storage/FileSystemExpenseAttachmentStore.cs b/backend/src/RestaurantPOS.Infrastructure/Storage/FileSystemExpenseAttachmentStore.cs new file mode 100644 index 0000000..7efbb58 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Storage/FileSystemExpenseAttachmentStore.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Configuration; + +using RestaurantPOS.Application.Common.Interfaces; + +namespace RestaurantPOS.Infrastructure.Storage; + +/// +/// Keeps receipt files in a folder beside the database. +/// +/// +/// Files are filed under year/month so a folder never grows to tens of thousands of entries, and +/// each is given a fresh GUID name. That name is deliberately not the one the user chose: two +/// receipts called "IMG_0001.jpg" would otherwise collide, and a crafted name like +/// ..\..\appsettings.json would let an upload escape the folder entirely. The original +/// name is kept in the database and handed back on download, so the user never sees the change. +/// +internal sealed class FileSystemExpenseAttachmentStore : IExpenseAttachmentStore +{ + private readonly string _root; + + public FileSystemExpenseAttachmentStore(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var configured = configuration["Expenses:AttachmentsPath"]; + + _root = string.IsNullOrWhiteSpace(configured) + ? Path.Combine(AppContext.BaseDirectory, "attachments") + : Path.GetFullPath(configured); + } + + public async Task SaveAsync( + Stream content, string fileName, DateOnly expenseDate, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(content); + + var relativeFolder = Path.Combine(expenseDate.Year.ToString("0000"), expenseDate.Month.ToString("00")); + var extension = SafeExtension(fileName); + var relativePath = Path.Combine(relativeFolder, $"{Guid.NewGuid():N}{extension}"); + + var absoluteFolder = Path.Combine(_root, relativeFolder); + Directory.CreateDirectory(absoluteFolder); + + var absolutePath = Path.Combine(_root, relativePath); + + await using var file = File.Create(absolutePath); + await content.CopyToAsync(file, cancellationToken); + + // Stored with forward slashes so a database copied between machines still resolves. + return relativePath.Replace(Path.DirectorySeparatorChar, '/'); + } + + public Task OpenAsync( + string storedPath, string contentType, string fileName, CancellationToken cancellationToken) + { + var absolutePath = ResolveWithinRoot(storedPath); + + if (absolutePath is null || !File.Exists(absolutePath)) + { + return Task.FromResult(null); + } + + Stream stream = File.OpenRead(absolutePath); + + return Task.FromResult(new StoredAttachment(stream, contentType, fileName)); + } + + public Task DeleteAsync(string storedPath, CancellationToken cancellationToken) + { + var absolutePath = ResolveWithinRoot(storedPath); + + if (absolutePath is not null && File.Exists(absolutePath)) + { + File.Delete(absolutePath); + } + + return Task.CompletedTask; + } + + /// + /// Turns a stored relative path into an absolute one, refusing anything that resolves outside + /// the attachments root. A path only ever comes from this store's own , + /// but a database that has been edited by hand should not be able to read arbitrary files. + /// + private string? ResolveWithinRoot(string storedPath) + { + if (string.IsNullOrWhiteSpace(storedPath)) + { + return null; + } + + var candidate = Path.GetFullPath(Path.Combine(_root, storedPath)); + var root = Path.GetFullPath(_root); + + return candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase) ? candidate : null; + } + + /// Keeps a recognisable extension without trusting the rest of the supplied name. + private static string SafeExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + + return extension.Length is > 1 and <= 10 && extension.All(c => char.IsAsciiLetterOrDigit(c) || c == '.') + ? extension.ToLowerInvariant() + : string.Empty; + } +} diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Expenses.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Expenses.cs new file mode 100644 index 0000000..36c09dd --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.Expenses.cs @@ -0,0 +1,253 @@ +using System.Net.Http.Json; + +namespace RestaurantPOS.IntegrationTests.Common; + +public sealed record ExpenseCategoryResponse( + Guid Id, + string Name, + string? Description, + decimal? MonthlyBudget, + Guid? ParentCategoryId, + string? ParentCategoryName, + bool IsSystem, + bool IsActive, + int ExpenseCount); + +public sealed record ExpenseResponse( + Guid Id, + string ExpenseNumber, + DateOnly ExpenseDate, + Guid CategoryId, + string CategoryName, + decimal Amount, + string? Description, + string Status, + string PaymentMethod, + string? PaymentReference, + DateOnly? PaymentDate, + bool IsPaid, + string RecordedByName, + string? ApprovedByName, + string? ApprovalComments, + bool IsRecurring, + bool IsEditable, + IReadOnlyCollection Attachments, + IReadOnlyCollection ApprovalTrail); + +public sealed record ExpenseAttachmentResponse( + Guid Id, string FileName, string ContentType, long SizeBytes, string UploadedByName); + +public sealed record ExpenseApprovalEntryResponse( + string FromStatus, string ToStatus, string ActedByName, string? Comments); + +public sealed record ExpenseSummaryResponse( + Guid Id, + string ExpenseNumber, + DateOnly ExpenseDate, + string CategoryName, + decimal Amount, + string? Description, + string Status, + string PaymentMethod, + bool IsPaid, + bool IsRecurring, + int AttachmentCount); + +public sealed record RecurringExpenseResponse( + Guid Id, + Guid CategoryId, + string CategoryName, + decimal Amount, + string? Description, + string PaymentMethod, + int DayOfMonth, + bool IsActive, + int? LastGeneratedYear, + int? LastGeneratedMonth); + +public sealed record CategoryBreakdownResponse( + Guid CategoryId, + string CategoryName, + decimal Total, + decimal PercentageOfTotal, + int ExpenseCount, + decimal? MonthlyBudget, + decimal? BudgetUsedPercentage); + +public sealed record ProfitSummaryResponse( + decimal Revenue, decimal Expenses, decimal Profit, decimal? ExpenseRatio, decimal? ProfitMargin); + +public sealed record PeriodComparisonResponse( + string PreviousLabel, + decimal PreviousTotal, + decimal CurrentTotal, + decimal Change, + decimal? ChangePercentage, + string? LargestIncreaseCategory, + decimal? LargestIncreaseAmount); + +public sealed record DailyExpenseReportResponse( + DateOnly Date, + ProfitSummaryResponse Summary, + IReadOnlyCollection Categories, + PeriodComparisonResponse Comparison, + IReadOnlyCollection Expenses, + string? HighestCategory, + string? LowestCategory); + +public sealed record DailyFigureResponse(DateOnly Date, decimal Revenue, decimal Expenses, decimal Profit); + +public sealed record WeeklyFigureResponse( + int WeekNumber, DateOnly StartDate, DateOnly EndDate, decimal Revenue, decimal Expenses, decimal Profit); + +public sealed record BudgetAlertResponse( + Guid CategoryId, + string CategoryName, + decimal MonthlyBudget, + decimal SpentThisMonth, + decimal Remaining, + decimal UsedPercentage, + bool IsOverBudget); + +public sealed record MonthlyExpenseReportResponse( + int Year, + int Month, + string MonthLabel, + ProfitSummaryResponse Summary, + IReadOnlyCollection Categories, + IReadOnlyCollection DailyFigures, + IReadOnlyCollection WeeklyFigures, + PeriodComparisonResponse Comparison, + IReadOnlyCollection BudgetAlerts); + +public sealed partial class PosApiClient +{ + public Task GetExpenseCategoriesAsync(bool? isActive = null) => + Http.GetAsync($"{BaseUrl}/expenses/categories{(isActive.HasValue ? $"?isActive={isActive}" : string.Empty)}"); + + public Task CreateExpenseCategoryAsync( + string name, string? description = null, decimal? monthlyBudget = null, Guid? parentCategoryId = null) => + Http.PostAsJsonAsync( + $"{BaseUrl}/expenses/categories", + new { name, description, monthlyBudget, parentCategoryId }, + Json); + + public Task UpdateExpenseCategoryAsync( + Guid id, string name, string? description = null, decimal? monthlyBudget = null, Guid? parentCategoryId = null) => + Http.PutAsJsonAsync( + $"{BaseUrl}/expenses/categories/{id}", + new { name, description, monthlyBudget, parentCategoryId }, + Json); + + public Task SetExpenseCategoryActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/expenses/categories/{id}/status", new { isActive }, Json); + + public Task DeleteExpenseCategoryAsync(Guid id) => + Http.DeleteAsync($"{BaseUrl}/expenses/categories/{id}"); + + public Task CreateExpenseAsync( + DateOnly expenseDate, + Guid categoryId, + decimal amount, + string? description = null, + string paymentMethod = "Cash", + string? paymentReference = null, + DateOnly? paymentDate = null, + bool isPaid = false, + bool submitForApproval = false) => + Http.PostAsJsonAsync( + $"{BaseUrl}/expenses", + new + { + expenseDate, + categoryId, + amount, + description, + paymentMethod, + paymentReference, + paymentDate, + isPaid, + submitForApproval, + }, + Json); + + public Task UpdateExpenseAsync( + Guid id, + DateOnly expenseDate, + Guid categoryId, + decimal amount, + string? description = null, + string paymentMethod = "Cash", + string? paymentReference = null, + DateOnly? paymentDate = null, + bool isPaid = false) => + Http.PutAsJsonAsync( + $"{BaseUrl}/expenses/{id}", + new { expenseDate, categoryId, amount, description, paymentMethod, paymentReference, paymentDate, isPaid }, + Json); + + public Task GetExpensesAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/expenses{query}"); + + public Task GetExpenseAsync(Guid id) => Http.GetAsync($"{BaseUrl}/expenses/{id}"); + + public Task DeleteExpenseAsync(Guid id) => Http.DeleteAsync($"{BaseUrl}/expenses/{id}"); + + public Task SubmitExpenseAsync(Guid id, string? comments = null) => + Http.PostAsJsonAsync($"{BaseUrl}/expenses/{id}/submit", new { comments }, Json); + + public Task ApproveExpensesAsync(string? comments = null, params Guid[] expenseIds) => + Http.PostAsJsonAsync($"{BaseUrl}/expenses/approve", new { expenseIds, comments }, Json); + + public Task RejectExpensesAsync(string? comments = null, params Guid[] expenseIds) => + Http.PostAsJsonAsync($"{BaseUrl}/expenses/reject", new { expenseIds, comments }, Json); + + public Task SetExpensePaidAsync(Guid id, bool isPaid, DateOnly? paymentDate = null) => + Http.PutAsJsonAsync($"{BaseUrl}/expenses/{id}/paid", new { isPaid, paymentDate }, Json); + + /// + /// Awaited rather than returning the task directly: the multipart content has to outlive the + /// request, and disposing it at the end of a non-async method closes the stream mid-flight. + /// + public async Task AddExpenseAttachmentAsync( + Guid id, string fileName, string contentType, byte[] content) + { + using var form = new MultipartFormDataContent(); + var file = new ByteArrayContent(content); + file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); + form.Add(file, "file", fileName); + + return await Http.PostAsync($"{BaseUrl}/expenses/{id}/attachments", form); + } + + public Task GetExpenseAttachmentAsync(Guid attachmentId) => + Http.GetAsync($"{BaseUrl}/expenses/attachments/{attachmentId}"); + + public Task RemoveExpenseAttachmentAsync(Guid expenseId, Guid attachmentId) => + Http.DeleteAsync($"{BaseUrl}/expenses/{expenseId}/attachments/{attachmentId}"); + + public Task GetRecurringExpensesAsync() => + Http.GetAsync($"{BaseUrl}/expenses/recurring"); + + public Task CreateRecurringExpenseAsync( + Guid categoryId, decimal amount, string? description, string paymentMethod, int dayOfMonth) => + Http.PostAsJsonAsync( + $"{BaseUrl}/expenses/recurring", + new { categoryId, amount, description, paymentMethod, dayOfMonth }, + Json); + + public Task SetRecurringExpenseActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/expenses/recurring/{id}/status", new { isActive }, Json); + + public Task GenerateRecurringExpensesAsync() => + Http.PostAsync($"{BaseUrl}/expenses/recurring/generate", null); + + public Task GetDailyExpenseReportAsync(DateOnly date) => + Http.GetAsync($"{BaseUrl}/expenses/reports/daily?date={date:yyyy-MM-dd}"); + + public Task GetMonthlyExpenseReportAsync(int year, int month) => + Http.GetAsync($"{BaseUrl}/expenses/reports/monthly?year={year}&month={month}"); + + public Task GetExpenseRangeReportAsync(DateOnly from, DateOnly to) => + Http.GetAsync($"{BaseUrl}/expenses/reports/range?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}"); +} diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Expenses/ExpenseManagementTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Expenses/ExpenseManagementTests.cs new file mode 100644 index 0000000..d9b2998 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Expenses/ExpenseManagementTests.cs @@ -0,0 +1,391 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Expenses; + +/// +/// Covers expense recording, approval and categories, including the day-of-five-expenses +/// walkthrough from the requirements. +/// +public class ExpenseManagementTests : IntegrationTestBase +{ + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Now); + + private async Task> GetCategoriesAsync() + { + var response = await Client.GetExpenseCategoriesAsync(); + response.EnsureSuccessStatusCode(); + + return await PosApiClient.ReadAsync>(response); + } + + private async Task CategoryIdAsync(string name) + { + var categories = await GetCategoriesAsync(); + + return categories.Single(c => c.Name == name).Id; + } + + private async Task RecordAsync( + string categoryName, + decimal amount, + string? description = null, + string method = "Cash", + string? reference = null) + { + var response = await Client.CreateExpenseAsync( + Today, await CategoryIdAsync(categoryName), amount, description, method, reference); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + return await PosApiClient.ReadAsync(response); + } + + [Fact] + public async Task AFreshInstall_ComesWithTheBuiltInCategories() + { + await SignInAsAdminAsync(); + + var categories = await GetCategoriesAsync(); + + categories.Should().Contain(c => c.Name == "Gas/LPG" && c.IsSystem); + categories.Should().Contain(c => c.Name == "Electricity" && c.MonthlyBudget == 35_000m); + categories.Should().Contain(c => c.Name == "Staff Meals"); + categories.Should().OnlyContain(c => c.IsActive); + } + + [Fact] + public async Task RecordingAnExpense_NumbersItAndLeavesItADraft() + { + await SignInAsAdminAsync(); + + var expense = await RecordAsync("Gas/LPG", 2500m, "Gas cylinder refill"); + + expense.ExpenseNumber.Should().Be($"EXP-001-{Today.Year}"); + expense.Status.Should().Be("Draft"); + expense.Amount.Should().Be(2500m); + expense.CategoryName.Should().Be("Gas/LPG"); + expense.IsEditable.Should().BeTrue(); + expense.RecordedByName.Should().Be("System Administrator"); + } + + [Fact] + public async Task ExpenseNumbers_RunInSequenceWithinTheYear() + { + await SignInAsAdminAsync(); + + var first = await RecordAsync("Gas/LPG", 2500m); + var second = await RecordAsync("Electricity", 3500m, method: "Cheque", reference: "CHK-001"); + + first.ExpenseNumber.Should().Be($"EXP-001-{Today.Year}"); + second.ExpenseNumber.Should().Be($"EXP-002-{Today.Year}"); + } + + [Fact] + public async Task AnExpenseCannotBeDatedInTheFuture() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateExpenseAsync( + Today.AddDays(1), await CategoryIdAsync("Gas/LPG"), 500m); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Expense.FutureDate"); + } + + [Fact] + public async Task BackdatingAnExpense_IsAllowed() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateExpenseAsync( + Today.AddDays(-3), await CategoryIdAsync("Gas/LPG"), 500m, "Entered late"); + + response.StatusCode.Should().Be(HttpStatusCode.Created, + "yesterday's bills are keyed in this morning (BR-EXP-017)"); + } + + [Fact] + public async Task ANonCashPayment_RequiresAReference() + { + await SignInAsAdminAsync(); + var categoryId = await CategoryIdAsync("Electricity"); + + var withoutReference = await Client.CreateExpenseAsync( + Today, categoryId, 3500m, "Jan bill", "Cheque"); + withoutReference.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var withReference = await Client.CreateExpenseAsync( + Today, categoryId, 3500m, "Jan bill", "Cheque", "CHK-001"); + withReference.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task AnExpenseCanBeCorrectedBeforeApprovalButNotAfter() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Gas/LPG", 2500m); + var categoryId = await CategoryIdAsync("Gas/LPG"); + + var corrected = await Client.UpdateExpenseAsync(expense.Id, Today, categoryId, 2600m, "Corrected"); + corrected.EnsureSuccessStatusCode(); + (await PosApiClient.ReadAsync(corrected)).Amount.Should().Be(2600m); + + (await Client.ApproveExpensesAsync(null, expense.Id)).EnsureSuccessStatusCode(); + + var afterApproval = await Client.UpdateExpenseAsync(expense.Id, Today, categoryId, 9999m); + afterApproval.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(afterApproval)).Should().Be("Expense.NotEditable"); + } + + [Fact] + public async Task AManagerCanApproveADraftDirectly() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Gas/LPG", 2500m); + + var approved = await Client.ApproveExpensesAsync("Bulk approved", expense.Id); + + approved.EnsureSuccessStatusCode(); + var results = await PosApiClient.ReadAsync>(approved); + results.Should().ContainSingle(); + results[0].Status.Should().Be("Approved"); + results[0].ApprovedByName.Should().Be("System Administrator"); + results[0].ApprovalComments.Should().Be("Bulk approved"); + } + + [Fact] + public async Task SubmittingThenApproving_RecordsTheWholeTrail() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Staff Meals", 800m, "Lunch for 5 staff"); + + (await Client.SubmitExpenseAsync(expense.Id, "Please review")).EnsureSuccessStatusCode(); + (await Client.ApproveExpensesAsync("Fine", expense.Id)).EnsureSuccessStatusCode(); + + var loaded = await PosApiClient.ReadAsync(await Client.GetExpenseAsync(expense.Id)); + + loaded.Status.Should().Be("Approved"); + loaded.ApprovalTrail.Should().HaveCount(2); + loaded.ApprovalTrail.First().ToStatus.Should().Be("Pending"); + loaded.ApprovalTrail.First().Comments.Should().Be("Please review"); + loaded.ApprovalTrail.Last().ToStatus.Should().Be("Approved"); + } + + [Fact] + public async Task RejectingAnExpense_KeepsItOnRecord() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Miscellaneous", 500m); + + (await Client.RejectExpensesAsync("Not a business cost", expense.Id)).EnsureSuccessStatusCode(); + + var loaded = await PosApiClient.ReadAsync(await Client.GetExpenseAsync(expense.Id)); + loaded.Status.Should().Be("Rejected"); + loaded.ApprovalComments.Should().Be("Not a business cost"); + } + + [Fact] + public async Task ApprovingABatchIsAllOrNothing() + { + await SignInAsAdminAsync(); + var first = await RecordAsync("Gas/LPG", 2500m); + var second = await RecordAsync("Staff Meals", 800m); + + (await Client.ApproveExpensesAsync(null, first.Id)).EnsureSuccessStatusCode(); + + // The batch includes one already decided, so none of it should apply. + var batch = await Client.ApproveExpensesAsync(null, first.Id, second.Id); + + batch.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(batch)).Should().Be("Expense.AlreadyDecided"); + + var reloaded = await PosApiClient.ReadAsync(await Client.GetExpenseAsync(second.Id)); + reloaded.Status.Should().Be("Draft", "a partial bulk approval would leave the manager guessing"); + } + + [Fact] + public async Task ADecidedExpenseCannotBeDeleted() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Gas/LPG", 2500m); + + var draftDelete = await Client.DeleteExpenseAsync(expense.Id); + draftDelete.EnsureSuccessStatusCode(); + + var second = await RecordAsync("Gas/LPG", 900m); + (await Client.RejectExpensesAsync(null, second.Id)).EnsureSuccessStatusCode(); + + var afterReject = await Client.DeleteExpenseAsync(second.Id); + afterReject.StatusCode.Should().Be(HttpStatusCode.Conflict, "a rejection is a decision worth keeping"); + } + + [Fact] + public async Task ExpensesCanBeFilteredAndSearched() + { + await SignInAsAdminAsync(); + await RecordAsync("Gas/LPG", 2500m, "Gas cylinder refill"); + await RecordAsync("Electricity", 3500m, "Jan monthly bill", "Cheque", "12345-INV-2024"); + var meals = await RecordAsync("Staff Meals", 800m, "Lunch for 5 staff"); + (await Client.ApproveExpensesAsync(null, meals.Id)).EnsureSuccessStatusCode(); + + var byCategory = await PosApiClient.ReadAsync>( + await Client.GetExpensesAsync($"?categoryId={await CategoryIdAsync("Gas/LPG")}")); + byCategory.Should().ContainSingle().Which.CategoryName.Should().Be("Gas/LPG"); + + var byMethod = await PosApiClient.ReadAsync>( + await Client.GetExpensesAsync("?paymentMethod=Cheque")); + byMethod.Should().ContainSingle().Which.PaymentMethod.Should().Be("Cheque"); + + var byStatus = await PosApiClient.ReadAsync>( + await Client.GetExpensesAsync("?status=Approved")); + byStatus.Should().ContainSingle().Which.CategoryName.Should().Be("Staff Meals"); + + var byReference = await PosApiClient.ReadAsync>( + await Client.GetExpensesAsync("?search=12345-INV")); + byReference.Should().ContainSingle().Which.CategoryName.Should().Be("Electricity"); + + var byDescription = await PosApiClient.ReadAsync>( + await Client.GetExpensesAsync("?search=cylinder")); + byDescription.Should().ContainSingle(); + } + + [Fact] + public async Task ACustomCategoryCanBeAddedAndNested() + { + await SignInAsAdminAsync(); + + var parentId = await CategoryIdAsync("Miscellaneous"); + + var created = await Client.CreateExpenseCategoryAsync("Cleaning Supplies", "Mops and detergent", 8000m, parentId); + + created.StatusCode.Should().Be(HttpStatusCode.Created); + var category = await PosApiClient.ReadAsync(created); + category.ParentCategoryName.Should().Be("Miscellaneous"); + category.IsSystem.Should().BeFalse(); + category.MonthlyBudget.Should().Be(8000m); + } + + [Fact] + public async Task SubcategoriesCannotNestMoreThanOneLevel() + { + await SignInAsAdminAsync(); + var parentId = await CategoryIdAsync("Miscellaneous"); + + var child = await PosApiClient.ReadAsync( + await Client.CreateExpenseCategoryAsync("Cleaning", null, null, parentId)); + + var grandchild = await Client.CreateExpenseCategoryAsync("Mops", null, null, child.Id); + + grandchild.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(grandchild)).Should().Be("ExpenseCategory.NestingTooDeep"); + } + + [Fact] + public async Task FilteringByAParentCategory_IncludesItsChildren() + { + await SignInAsAdminAsync(); + var parentId = await CategoryIdAsync("Miscellaneous"); + var child = await PosApiClient.ReadAsync( + await Client.CreateExpenseCategoryAsync("Cleaning", null, null, parentId)); + + await Client.CreateExpenseAsync(Today, child.Id, 500m, "Floor cleaner"); + await Client.CreateExpenseAsync(Today, parentId, 300m, "Other"); + + var results = await PosApiClient.ReadAsync>( + await Client.GetExpensesAsync($"?categoryId={parentId}")); + + results.Should().HaveCount(2, "a parent totals whatever hangs beneath it"); + } + + [Fact] + public async Task ABuiltInCategoryCannotBeDeleted() + { + await SignInAsAdminAsync(); + + var response = await Client.DeleteExpenseCategoryAsync(await CategoryIdAsync("Gas/LPG")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("ExpenseCategory.IsSystem"); + } + + [Fact] + public async Task ACategoryWithHistoryCannotBeDeleted() + { + await SignInAsAdminAsync(); + var category = await PosApiClient.ReadAsync( + await Client.CreateExpenseCategoryAsync("Cleaning")); + + await Client.CreateExpenseAsync(Today, category.Id, 500m); + + var response = await Client.DeleteExpenseCategoryAsync(category.Id); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("ExpenseCategory.InUse"); + } + + [Fact] + public async Task AReceiptCanBeAttachedAndReadBack() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Staff Meals", 800m); + var content = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3, 4 }; + + var uploaded = await Client.AddExpenseAttachmentAsync(expense.Id, "receipt.jpg", "image/jpeg", content); + + uploaded.EnsureSuccessStatusCode(); + var withAttachment = await PosApiClient.ReadAsync(uploaded); + withAttachment.Attachments.Should().ContainSingle(); + + var attachment = withAttachment.Attachments.Single(); + attachment.FileName.Should().Be("receipt.jpg"); + attachment.SizeBytes.Should().Be(content.Length); + + var downloaded = await Client.GetExpenseAttachmentAsync(attachment.Id); + downloaded.EnsureSuccessStatusCode(); + (await downloaded.Content.ReadAsByteArrayAsync()).Should().Equal(content); + } + + [Fact] + public async Task AnExecutableCannotBeAttachedAsAReceipt() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Staff Meals", 800m); + + var response = await Client.AddExpenseAttachmentAsync( + expense.Id, "payload.exe", "application/x-msdownload", [1, 2, 3]); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Expense.AttachmentTypeNotAllowed"); + } + + [Fact] + public async Task AnExpenseCanBeMarkedPaidAfterApproval() + { + await SignInAsAdminAsync(); + var expense = await RecordAsync("Electricity", 3500m, "Jan bill", "Cheque", "CHK-001"); + (await Client.ApproveExpensesAsync(null, expense.Id)).EnsureSuccessStatusCode(); + + var paid = await Client.SetExpensePaidAsync(expense.Id, true, Today); + + paid.EnsureSuccessStatusCode(); + var loaded = await PosApiClient.ReadAsync(paid); + loaded.IsPaid.Should().BeTrue("a cheque clearing later has not changed the expense itself"); + loaded.PaymentDate.Should().Be(Today); + } + + [Fact] + public async Task StaffWithoutExpensesManagement_CannotReachExpenses() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "PosBilling"); + + (await staff.GetExpensesAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await staff.GetExpenseCategoriesAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } +} diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Expenses/ExpenseReportTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Expenses/ExpenseReportTests.cs new file mode 100644 index 0000000..dfb1c85 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Expenses/ExpenseReportTests.cs @@ -0,0 +1,298 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Expenses; + +/// +/// Expense reporting, including the figures that pull revenue across from the till, and the +/// recurring costs that materialise themselves. +/// +public class ExpenseReportTests : IntegrationTestBase +{ + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Now); + + private async Task CategoryIdAsync(string name) + { + var categories = await PosApiClient.ReadAsync>( + await Client.GetExpenseCategoriesAsync()); + + return categories.Single(c => c.Name == name).Id; + } + + private async Task RecordApprovedAsync(string categoryName, decimal amount, DateOnly? date = null) + { + var created = await Client.CreateExpenseAsync( + date ?? Today, await CategoryIdAsync(categoryName), amount, $"{categoryName} spend"); + + created.StatusCode.Should().Be(HttpStatusCode.Created); + var expense = await PosApiClient.ReadAsync(created); + + (await Client.ApproveExpensesAsync(null, expense.Id)).EnsureSuccessStatusCode(); + + return expense.Id; + } + + /// Rings a sale through the till so the reports have real revenue to work against. + private async Task TakeSaleAsync(string tableNumber, decimal price) + { + var table = await PosApiClient.ReadAsync(await Client.CreateTableAsync(tableNumber)); + var menuItem = await PosApiClient.ReadAsync( + await Client.CreateMenuItemAsync($"Dish {tableNumber}", "Mains", price)); + + var order = await PosApiClient.ReadAsync(await Client.CreateOrderAsync(table.Id)); + await Client.AddOrderItemsAsync(order.Id, (menuItem.Id, 1, null)); + (await Client.ConfirmOrderAsync(order.Id)).EnsureSuccessStatusCode(); + (await Client.StartCheckoutAsync(order.Id)).EnsureSuccessStatusCode(); + (await Client.PayOrderAsync(order.Id, ("Cash", price, price))).EnsureSuccessStatusCode(); + } + + [Fact] + public async Task TheDailyReport_BreaksSpendingDownByCategory() + { + await SignInAsAdminAsync(); + await RecordApprovedAsync("Gas/LPG", 2500m); + await RecordApprovedAsync("Electricity", 3500m); + await RecordApprovedAsync("Water/Waste", 1200m); + await RecordApprovedAsync("Staff Meals", 800m); + await RecordApprovedAsync("Miscellaneous", 500m); + + var report = await PosApiClient.ReadAsync( + await Client.GetDailyExpenseReportAsync(Today)); + + report.Summary.Expenses.Should().Be(8500m); + report.Categories.Should().HaveCount(5); + + var electricity = report.Categories.Single(c => c.CategoryName == "Electricity"); + electricity.Total.Should().Be(3500m); + electricity.PercentageOfTotal.Should().BeApproximately(41.2m, 0.2m); + + report.HighestCategory.Should().Be("Electricity"); + report.LowestCategory.Should().Be("Miscellaneous"); + } + + [Fact] + public async Task TheDailyReport_CalculatesProfitAgainstTakings() + { + await SignInAsAdminAsync(); + await TakeSaleAsync("1", 30_000m); + await TakeSaleAsync("2", 15_000m); + await RecordApprovedAsync("Gas/LPG", 8500m); + + var report = await PosApiClient.ReadAsync( + await Client.GetDailyExpenseReportAsync(Today)); + + report.Summary.Revenue.Should().Be(45_000m, "revenue comes from settled bills at the till"); + report.Summary.Expenses.Should().Be(8500m); + report.Summary.Profit.Should().Be(36_500m); + report.Summary.ProfitMargin.Should().BeApproximately(81.1m, 0.2m); + report.Summary.ExpenseRatio.Should().BeApproximately(18.9m, 0.2m); + } + + [Fact] + public async Task OnlyApprovedExpensesReachTheReports() + { + await SignInAsAdminAsync(); + await RecordApprovedAsync("Gas/LPG", 2500m); + + // A draft and a rejected expense must both be invisible to the totals. + var draft = await PosApiClient.ReadAsync( + await Client.CreateExpenseAsync(Today, await CategoryIdAsync("Electricity"), 9999m)); + + var rejected = await PosApiClient.ReadAsync( + await Client.CreateExpenseAsync(Today, await CategoryIdAsync("Water/Waste"), 7777m)); + (await Client.RejectExpensesAsync("No", rejected.Id)).EnsureSuccessStatusCode(); + + var report = await PosApiClient.ReadAsync( + await Client.GetDailyExpenseReportAsync(Today)); + + report.Summary.Expenses.Should().Be(2500m, "only approved expenses count (BR-EXP-005)"); + report.Categories.Should().ContainSingle(); + + // The listing still shows everything, so a manager sees what is waiting on them. + report.Expenses.Should().HaveCount(3); + report.Expenses.Should().Contain(e => e.Id == draft.Id); + } + + [Fact] + public async Task ProfitPercentagesAreNullOnADayWithNoTakings() + { + await SignInAsAdminAsync(); + await RecordApprovedAsync("Gas/LPG", 2500m); + + var report = await PosApiClient.ReadAsync( + await Client.GetDailyExpenseReportAsync(Today)); + + report.Summary.Revenue.Should().Be(0m); + report.Summary.Profit.Should().Be(-2500m); + report.Summary.ProfitMargin.Should().BeNull("a closed day has nothing to take a percentage of"); + report.Summary.ExpenseRatio.Should().BeNull(); + } + + [Fact] + public async Task TheDailyReport_ComparesWithYesterday() + { + await SignInAsAdminAsync(); + await RecordApprovedAsync("Gas/LPG", 8200m, Today.AddDays(-1)); + await RecordApprovedAsync("Gas/LPG", 8500m); + + var report = await PosApiClient.ReadAsync( + await Client.GetDailyExpenseReportAsync(Today)); + + report.Comparison.PreviousTotal.Should().Be(8200m); + report.Comparison.CurrentTotal.Should().Be(8500m); + report.Comparison.Change.Should().Be(300m); + report.Comparison.ChangePercentage.Should().BeApproximately(3.7m, 0.1m); + } + + [Fact] + public async Task TheMonthlyReport_TotalsCategoriesAndSplitsIntoWeeks() + { + await SignInAsAdminAsync(); + + // Last month, so every date used is safely in the past whenever this runs — an expense + // dated in the future is rejected, and today may well be the 2nd. + var firstOfLastMonth = new DateOnly(Today.Year, Today.Month, 1).AddMonths(-1); + + await RecordApprovedAsync("Gas/LPG", 25_000m, firstOfLastMonth); + await RecordApprovedAsync("Electricity", 32_500m, firstOfLastMonth.AddDays(9)); + + var report = await PosApiClient.ReadAsync( + await Client.GetMonthlyExpenseReportAsync(firstOfLastMonth.Year, firstOfLastMonth.Month)); + + report.Summary.Expenses.Should().Be(57_500m); + report.Categories.Should().HaveCount(2); + report.DailyFigures.Should().HaveCount( + DateTime.DaysInMonth(firstOfLastMonth.Year, firstOfLastMonth.Month), + "every day appears so the trend line has no gaps"); + + report.WeeklyFigures.Should().NotBeEmpty(); + report.WeeklyFigures.First().Expenses.Should().Be(25_000m, "the 1st falls in week one"); + report.WeeklyFigures.Skip(1).First().Expenses.Should().Be(32_500m, "the 10th falls in week two"); + } + + [Fact] + public async Task TheMonthlyReport_NamesTheCategoryThatRoseMost() + { + await SignInAsAdminAsync(); + var thisMonth = new DateOnly(Today.Year, Today.Month, 1); + var lastMonth = thisMonth.AddMonths(-1); + + await RecordApprovedAsync("Miscellaneous", 5000m, lastMonth); + await RecordApprovedAsync("Gas/LPG", 20_000m, lastMonth); + await RecordApprovedAsync("Miscellaneous", 11_200m, thisMonth); + await RecordApprovedAsync("Gas/LPG", 21_000m, thisMonth); + + var report = await PosApiClient.ReadAsync( + await Client.GetMonthlyExpenseReportAsync(Today.Year, Today.Month)); + + report.Comparison.PreviousTotal.Should().Be(25_000m); + report.Comparison.CurrentTotal.Should().Be(32_200m); + report.Comparison.LargestIncreaseCategory.Should().Be("Miscellaneous"); + report.Comparison.LargestIncreaseAmount.Should().Be(6200m); + } + + [Fact] + public async Task BudgetAlerts_FireWhenACategoryNearsAndPassesItsLimit() + { + await SignInAsAdminAsync(); + var thisMonth = new DateOnly(Today.Year, Today.Month, 1); + + // Miscellaneous is budgeted at 50,000 by default; go over it. + await RecordApprovedAsync("Miscellaneous", 52_300m, thisMonth); + // Electricity is budgeted at 35,000; sit just under at 93%. + await RecordApprovedAsync("Electricity", 32_500m, thisMonth); + // Gas is budgeted at 30,000; a small spend should stay quiet. + await RecordApprovedAsync("Gas/LPG", 1000m, thisMonth); + + var report = await PosApiClient.ReadAsync( + await Client.GetMonthlyExpenseReportAsync(Today.Year, Today.Month)); + + var over = report.BudgetAlerts.Single(a => a.CategoryName == "Miscellaneous"); + over.IsOverBudget.Should().BeTrue(); + over.SpentThisMonth.Should().Be(52_300m); + over.Remaining.Should().Be(-2300m); + + var near = report.BudgetAlerts.Single(a => a.CategoryName == "Electricity"); + near.IsOverBudget.Should().BeFalse(); + near.UsedPercentage.Should().BeApproximately(92.9m, 0.2m); + + report.BudgetAlerts.Should().NotContain(a => a.CategoryName == "Gas/LPG", + "a category well inside its budget is not worth interrupting anyone about"); + } + + [Fact] + public async Task TheRangeReport_CoversAnyStretchOfDays() + { + await SignInAsAdminAsync(); + await RecordApprovedAsync("Gas/LPG", 1000m, Today.AddDays(-2)); + await RecordApprovedAsync("Gas/LPG", 2000m, Today); + await RecordApprovedAsync("Gas/LPG", 4000m, Today.AddDays(-10)); + + var response = await Client.GetExpenseRangeReportAsync(Today.AddDays(-3), Today); + response.EnsureSuccessStatusCode(); + + var report = await PosApiClient.ReadAsync(response); + report.Summary.Expenses.Should().Be(3000m, "the expense ten days ago falls outside the range"); + report.DailyFigures.Should().HaveCount(4); + } + + [Fact] + public async Task ARangeEndingBeforeItStarts_IsRejected() + { + await SignInAsAdminAsync(); + + var response = await Client.GetExpenseRangeReportAsync(Today, Today.AddDays(-5)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Expense.InvalidDateRange"); + } + + [Fact] + public async Task RecurringExpenses_AppearOnceForTheMonthHoweverOftenGenerationRuns() + { + await SignInAsAdminAsync(); + var rentCategory = await CategoryIdAsync("Rent/Lease"); + + var created = await Client.CreateRecurringExpenseAsync( + rentCategory, 50_000m, "Monthly rent", "BankTransfer", 1); + created.StatusCode.Should().Be(HttpStatusCode.Created); + + var first = await Client.GenerateRecurringExpensesAsync(); + first.EnsureSuccessStatusCode(); + (await PosApiClient.ReadAsync(first)).Should().Be(1); + + // Called again, as it would be every time the screen is opened. + var second = await Client.GenerateRecurringExpensesAsync(); + second.EnsureSuccessStatusCode(); + (await PosApiClient.ReadAsync(second)).Should().Be(0, "opening the screen twice must not create two rents"); + + var expenses = await PosApiClient.ReadAsync>( + await Client.GetExpensesAsync("?search=Monthly rent")); + + expenses.Should().ContainSingle(); + expenses[0].IsRecurring.Should().BeTrue(); + expenses[0].Status.Should().Be("Draft", "a generated expense is reviewed before it counts"); + expenses[0].Amount.Should().Be(50_000m); + } + + [Fact] + public async Task AStoppedRecurringExpense_GeneratesNothing() + { + await SignInAsAdminAsync(); + var recurring = await PosApiClient.ReadAsync( + await Client.CreateRecurringExpenseAsync( + await CategoryIdAsync("Salaries"), 180_000m, "Staff salaries", "BankTransfer", 5)); + + (await Client.SetRecurringExpenseActiveAsync(recurring.Id, false)).EnsureSuccessStatusCode(); + + var generated = await Client.GenerateRecurringExpensesAsync(); + + generated.EnsureSuccessStatusCode(); + (await PosApiClient.ReadAsync(generated)).Should().Be(0); + } +} diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/ExpenseTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/ExpenseTests.cs new file mode 100644 index 0000000..581e2a7 --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/ExpenseTests.cs @@ -0,0 +1,289 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class ExpenseTests +{ + private static readonly DateTime Now = new(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc); + private static readonly DateOnly Today = new(2026, 8, 15); + + private static Expense NewExpense( + decimal amount = 2500m, + ExpensePaymentMethod method = ExpensePaymentMethod.Cash, + string? reference = null) => + Expense.Create( + 1, Today, Guid.NewGuid(), amount, "Gas cylinder refill", method, reference, null, false, Guid.NewGuid()); + + [Fact] + public void Create_StartsAsADraftAndIsEditable() + { + var expense = NewExpense(); + + expense.Status.Should().Be(ExpenseStatus.Draft); + expense.IsEditable.Should().BeTrue(); + expense.CountsTowardsReports.Should().BeFalse("only approved expenses reach reports"); + } + + [Fact] + public void ExpenseNumber_ReadsAsTheReferenceStaffQuote() + { + var expense = Expense.Create( + 7, new DateOnly(2026, 3, 4), Guid.NewGuid(), 100m, null, + ExpensePaymentMethod.Cash, null, null, false, Guid.NewGuid()); + + expense.ExpenseNumber.Should().Be("EXP-007-2026"); + } + + [Fact] + public void Create_RejectsAnAmountOfZeroOrLess() + { + var act = () => NewExpense(amount: 0m); + + act.Should().Throw("an expense must be greater than zero (BR-EXP-002)"); + } + + [Theory] + [InlineData(ExpensePaymentMethod.Cheque)] + [InlineData(ExpensePaymentMethod.Card)] + [InlineData(ExpensePaymentMethod.BankTransfer)] + public void Create_RequiresAReferenceForEverythingButCash(ExpensePaymentMethod method) + { + var act = () => NewExpense(method: method, reference: null); + + act.Should().Throw("without one the payment cannot be tied to the bank statement"); + } + + [Fact] + public void Create_AcceptsCashWithoutAReference() + { + var act = () => NewExpense(method: ExpensePaymentMethod.Cash, reference: null); + + act.Should().NotThrow(); + } + + [Fact] + public void Approve_FreezesTheExpenseAndLetsItCount() + { + var expense = NewExpense(); + var manager = Guid.NewGuid(); + + expense.Approve(manager, Now, "Bulk approved"); + + expense.Status.Should().Be(ExpenseStatus.Approved); + expense.ApprovedByUserId.Should().Be(manager); + expense.ApprovedAtUtc.Should().Be(Now); + expense.ApprovalComments.Should().Be("Bulk approved"); + expense.IsEditable.Should().BeFalse(); + expense.CountsTowardsReports.Should().BeTrue(); + } + + [Fact] + public void Approve_WorksStraightFromDraftWithoutSubmitting() + { + var expense = NewExpense(); + + var act = () => expense.Approve(Guid.NewGuid(), Now); + + act.Should().NotThrow("a manager recording their own bills approves them directly"); + } + + [Fact] + public void Submit_MovesADraftToPending() + { + var expense = NewExpense(); + + expense.Submit(Guid.NewGuid(), Now); + + expense.Status.Should().Be(ExpenseStatus.Pending); + expense.IsEditable.Should().BeTrue("a pending expense can still be corrected before a decision"); + } + + [Fact] + public void Submit_RejectsAnExpenseThatIsNotADraft() + { + var expense = NewExpense(); + expense.Submit(Guid.NewGuid(), Now); + + var act = () => expense.Submit(Guid.NewGuid(), Now); + + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_IsRefusedOnceApproved() + { + var expense = NewExpense(); + expense.Approve(Guid.NewGuid(), Now); + + var act = () => expense.UpdateDetails( + Today, expense.CategoryId, 9999m, "Changed", ExpensePaymentMethod.Cash, null, null, false); + + act.Should().Throw( + "an approved expense has already been counted into a day's profit (EXP-036)"); + } + + [Fact] + public void Reject_KeepsTheExpenseOnRecord() + { + var expense = NewExpense(); + + expense.Reject(Guid.NewGuid(), Now, "Not a business cost"); + + expense.Status.Should().Be(ExpenseStatus.Rejected); + expense.ApprovalComments.Should().Be("Not a business cost"); + expense.CountsTowardsReports.Should().BeFalse(); + } + + [Fact] + public void Approve_IsRefusedOnAnExpenseAlreadyDecided() + { + var expense = NewExpense(); + expense.Reject(Guid.NewGuid(), Now); + + var act = () => expense.Approve(Guid.NewGuid(), Now); + + act.Should().Throw(); + } + + [Fact] + public void ApprovalTrail_RecordsEveryStepInOrder() + { + var expense = NewExpense(); + var recorder = Guid.NewGuid(); + var manager = Guid.NewGuid(); + + expense.Submit(recorder, Now, "Please review"); + expense.Approve(manager, Now.AddHours(2), "Fine"); + + expense.ApprovalTrail.Should().HaveCount(2); + + var submitted = expense.ApprovalTrail.First(); + submitted.FromStatus.Should().Be(ExpenseStatus.Draft); + submitted.ToStatus.Should().Be(ExpenseStatus.Pending); + submitted.ActedByUserId.Should().Be(recorder); + submitted.Comments.Should().Be("Please review"); + + var approved = expense.ApprovalTrail.Last(); + approved.FromStatus.Should().Be(ExpenseStatus.Pending); + approved.ToStatus.Should().Be(ExpenseStatus.Approved); + approved.ActedByUserId.Should().Be(manager); + } + + [Fact] + public void SetPaid_StaysAvailableAfterApproval() + { + var expense = NewExpense(method: ExpensePaymentMethod.Cheque, reference: "CHK-001"); + expense.Approve(Guid.NewGuid(), Now); + + var act = () => expense.SetPaid(true, new DateOnly(2026, 8, 20)); + + act.Should().NotThrow("a cheque clearing later has not changed the expense, only its settlement"); + expense.IsPaid.Should().BeTrue(); + expense.PaymentDate.Should().Be(new DateOnly(2026, 8, 20)); + } + + [Fact] + public void AddAttachment_IsRefusedOnceApproved() + { + var expense = NewExpense(); + expense.Approve(Guid.NewGuid(), Now); + + var act = () => expense.AddAttachment("receipt.jpg", "2026/08/x.jpg", "image/jpeg", 1024, Guid.NewGuid(), Now); + + act.Should().Throw(); + } +} + +public class ExpenseCategoryTests +{ + [Fact] + public void Create_StartsActiveAndCustom() + { + var category = ExpenseCategory.Create("Cleaning", "Cleaning supplies", 10_000m); + + category.IsActive.Should().BeTrue(); + category.IsSystem.Should().BeFalse(); + category.MonthlyBudget.Should().Be(10_000m); + } + + [Fact] + public void Create_RejectsANegativeBudget() + { + var act = () => ExpenseCategory.Create("Cleaning", null, -1m); + + act.Should().Throw(); + } + + [Fact] + public void Create_AllowsNoBudget() + { + var category = ExpenseCategory.Create("Cleaning"); + + category.MonthlyBudget.Should().BeNull("an unbudgeted category simply raises no alerts"); + } +} + +public class RecurringExpenseTests +{ + private static RecurringExpense NewRecurring(int dayOfMonth = 1) => + RecurringExpense.Create(Guid.NewGuid(), 50_000m, "Monthly rent", ExpensePaymentMethod.BankTransfer, dayOfMonth); + + [Fact] + public void IsDueFor_IsTrueBeforeItHasEverRun() + { + NewRecurring().IsDueFor(2026, 8).Should().BeTrue(); + } + + [Fact] + public void IsDueFor_IsFalseForAMonthAlreadyGenerated() + { + var recurring = NewRecurring(); + recurring.MarkGenerated(2026, 8); + + recurring.IsDueFor(2026, 8).Should().BeFalse("opening the screen twice must not create two rents"); + recurring.IsDueFor(2026, 9).Should().BeTrue(); + recurring.IsDueFor(2026, 7).Should().BeFalse("a month already past is not owed again"); + } + + [Fact] + public void IsDueFor_RollsOverTheYearCorrectly() + { + var recurring = NewRecurring(); + recurring.MarkGenerated(2026, 12); + + recurring.IsDueFor(2027, 1).Should().BeTrue(); + recurring.IsDueFor(2026, 12).Should().BeFalse(); + } + + [Fact] + public void IsDueFor_IsFalseWhenStopped() + { + var recurring = NewRecurring(); + recurring.Deactivate(); + + recurring.IsDueFor(2026, 8).Should().BeFalse(); + } + + [Fact] + public void DateFor_ClampsToTheLengthOfAShortMonth() + { + var recurring = NewRecurring(dayOfMonth: 31); + + recurring.DateFor(2026, 2).Should().Be(new DateOnly(2026, 2, 28), + "rent set to the 31st must still land in February rather than being skipped"); + recurring.DateFor(2026, 8).Should().Be(new DateOnly(2026, 8, 31)); + } + + [Fact] + public void Create_RejectsADayOutsideTheMonth() + { + var act = () => NewRecurring(dayOfMonth: 32); + + act.Should().Throw(); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index de6b0bc..6d0ffce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,8 @@ "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.8", "lucide-react": "^0.577.0", "qrcode": "^1.5.4", "react": "19.2.8", @@ -34,6 +36,7 @@ "socket.io-client": "^4.8.1", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", + "xlsx": "^0.18.5", "zod": "^3.24.1" }, "devDependencies": { @@ -386,7 +389,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3841,6 +3843,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -3850,6 +3858,13 @@ "@types/node": "*" } }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -3897,6 +3912,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -4429,6 +4451,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5042,6 +5073,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5434,6 +5475,39 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -5673,6 +5747,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5825,6 +5908,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5832,6 +5930,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -5874,6 +5984,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -6206,6 +6326,16 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -7458,6 +7588,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -7512,6 +7653,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7674,6 +7821,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -8203,6 +8359,20 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -8377,6 +8547,12 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -9121,6 +9297,32 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz", + "integrity": "sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2 || ^3 || ^4" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10036,6 +10238,22 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10161,6 +10379,13 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10915,6 +11140,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -11217,6 +11452,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -11367,6 +11609,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -11932,6 +12184,18 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -11946,6 +12210,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -12289,6 +12563,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -12529,6 +12813,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -13167,6 +13461,16 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -13688,6 +13992,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -13834,6 +14156,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 521f1e4..e9f5ac6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,8 @@ "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.8", "lucide-react": "^0.577.0", "qrcode": "^1.5.4", "react": "19.2.8", @@ -48,6 +50,7 @@ "socket.io-client": "^4.8.1", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", + "xlsx": "^0.18.5", "zod": "^3.24.1" }, "devDependencies": { diff --git a/frontend/src/app/routing/router.tsx b/frontend/src/app/routing/router.tsx index 9d9d2a1..85bdad3 100644 --- a/frontend/src/app/routing/router.tsx +++ b/frontend/src/app/routing/router.tsx @@ -24,6 +24,10 @@ const TableManagementPage = lazy(() => import("@/pages/pos/tables")); const OrderScreen = lazy(() => import("@/pages/pos/order")); const CheckoutScreen = lazy(() => import("@/pages/pos/checkout")); const KitchenDisplayPage = lazy(() => import("@/pages/kitchen")); +const ExpensesPage = lazy(() => import("@/pages/expenses")); +const ExpenseCategoriesPage = lazy(() => import("@/pages/expenses/categories")); +const RecurringExpensesPage = lazy(() => import("@/pages/expenses/recurring")); +const ExpenseReportsPage = lazy(() => import("@/pages/expenses/reports")); function withSuspense(element: ReactNode) { return }>{element}; @@ -58,6 +62,15 @@ export const router = createBrowserRouter([ element: , children: [{ path: "kitchen", element: withSuspense() }], }, + { + element: , + children: [ + { path: "expenses", element: withSuspense() }, + { path: "expenses/categories", element: withSuspense() }, + { path: "expenses/recurring", element: withSuspense() }, + { path: "expenses/reports", element: withSuspense() }, + ], + }, { element: , children: [{ path: "reports", element: withSuspense() }], diff --git a/frontend/src/entities/expense/index.ts b/frontend/src/entities/expense/index.ts new file mode 100644 index 0000000..16c896d --- /dev/null +++ b/frontend/src/entities/expense/index.ts @@ -0,0 +1,29 @@ +export type { + ExpenseStatus, + ExpensePaymentMethod, + ExpenseCategory, + ExpenseCategoryPayload, + ExpenseAttachment, + ExpenseApprovalEntry, + Expense, + ExpenseSummary, + ExpensePayload, + ExpenseFilters, + RecurringExpense, + RecurringExpensePayload, + CategoryBreakdown, + DailyFigure, + WeeklyFigure, + ProfitSummary, + PeriodComparison, + BudgetAlert, + DailyExpenseReport, + MonthlyExpenseReport, + ExpenseRangeReport, +} from "./model/types"; +export { + EXPENSE_STATUSES, + EXPENSE_PAYMENT_METHODS, + EXPENSE_PAYMENT_METHOD_LABELS, + requiresReference, +} from "./model/types"; diff --git a/frontend/src/entities/expense/model/types.ts b/frontend/src/entities/expense/model/types.ts new file mode 100644 index 0000000..a511883 --- /dev/null +++ b/frontend/src/entities/expense/model/types.ts @@ -0,0 +1,232 @@ +/** Where an expense sits in its approval life (EXP-015). */ +export type ExpenseStatus = "Draft" | "Pending" | "Approved" | "Rejected"; + +export const EXPENSE_STATUSES: ExpenseStatus[] = ["Draft", "Pending", "Approved", "Rejected"]; + +/** How an expense was settled (BR-EXP-011). */ +export type ExpensePaymentMethod = "Cash" | "Cheque" | "Card" | "BankTransfer"; + +export const EXPENSE_PAYMENT_METHODS: ExpensePaymentMethod[] = ["Cash", "Cheque", "Card", "BankTransfer"]; + +export const EXPENSE_PAYMENT_METHOD_LABELS: Record = { + Cash: "Cash", + Cheque: "Cheque", + Card: "Card", + BankTransfer: "Bank Transfer", +}; + +/** Cash needs no paper trail of its own; everything else must carry one. */ +export const requiresReference = (method: ExpensePaymentMethod): boolean => method !== "Cash"; + +export interface ExpenseCategory { + id: string; + name: string; + description: string | null; + /** What the restaurant expects to spend here monthly. Null means unbudgeted — no alerts. */ + monthlyBudget: number | null; + parentCategoryId: string | null; + parentCategoryName: string | null; + /** Built-in categories can be renamed and re-budgeted but never deleted. */ + isSystem: boolean; + isActive: boolean; + expenseCount: number; +} + +export interface ExpenseCategoryPayload { + name: string; + description: string | null; + monthlyBudget: number | null; + parentCategoryId: string | null; +} + +export interface ExpenseAttachment { + id: string; + fileName: string; + contentType: string; + sizeBytes: number; + uploadedAtUtc: string; + uploadedByName: string; +} + +export interface ExpenseApprovalEntry { + fromStatus: ExpenseStatus; + toStatus: ExpenseStatus; + actedByUserId: string; + actedByName: string; + actedAtUtc: string; + comments: string | null; +} + +export interface Expense { + id: string; + /** Customer-facing reference, e.g. "EXP-001-2026". */ + expenseNumber: string; + expenseDate: string; + categoryId: string; + categoryName: string; + amount: number; + description: string | null; + status: ExpenseStatus; + paymentMethod: ExpensePaymentMethod; + paymentReference: string | null; + paymentDate: string | null; + isPaid: boolean; + recordedByUserId: string; + recordedByName: string; + createdAtUtc: string; + approvedByUserId: string | null; + approvedByName: string | null; + approvedAtUtc: string | null; + approvalComments: string | null; + isRecurring: boolean; + /** False once approved or rejected — an approved expense is frozen (EXP-036). */ + isEditable: boolean; + attachments: ExpenseAttachment[]; + approvalTrail: ExpenseApprovalEntry[]; +} + +export interface ExpenseSummary { + id: string; + expenseNumber: string; + expenseDate: string; + categoryId: string; + categoryName: string; + amount: number; + description: string | null; + status: ExpenseStatus; + paymentMethod: ExpensePaymentMethod; + paymentReference: string | null; + isPaid: boolean; + isRecurring: boolean; + attachmentCount: number; + recordedByName: string; +} + +export interface ExpensePayload { + expenseDate: string; + categoryId: string; + amount: number; + description: string | null; + paymentMethod: ExpensePaymentMethod; + paymentReference: string | null; + paymentDate: string | null; + isPaid: boolean; + submitForApproval?: boolean; +} + +export interface ExpenseFilters { + from?: string; + to?: string; + categoryId?: string; + paymentMethod?: ExpensePaymentMethod; + status?: ExpenseStatus; + isPaid?: boolean; + search?: string; +} + +export interface RecurringExpense { + id: string; + categoryId: string; + categoryName: string; + amount: number; + description: string | null; + paymentMethod: ExpensePaymentMethod; + dayOfMonth: number; + isActive: boolean; + lastGeneratedYear: number | null; + lastGeneratedMonth: number | null; +} + +export interface RecurringExpensePayload { + categoryId: string; + amount: number; + description: string | null; + paymentMethod: ExpensePaymentMethod; + dayOfMonth: number; +} + +export interface CategoryBreakdown { + categoryId: string; + categoryName: string; + total: number; + /** Share of the period's expenses, 0-100. */ + percentageOfTotal: number; + expenseCount: number; + monthlyBudget: number | null; + budgetUsedPercentage: number | null; +} + +export interface DailyFigure { + date: string; + revenue: number; + expenses: number; + profit: number; +} + +export interface WeeklyFigure { + weekNumber: number; + startDate: string; + endDate: string; + revenue: number; + expenses: number; + profit: number; +} + +/** Revenue against expenses. Percentages are null on a day with no takings to divide by. */ +export interface ProfitSummary { + revenue: number; + expenses: number; + profit: number; + expenseRatio: number | null; + profitMargin: number | null; +} + +export interface PeriodComparison { + previousLabel: string; + previousTotal: number; + currentTotal: number; + change: number; + changePercentage: number | null; + largestIncreaseCategory: string | null; + largestIncreaseAmount: number | null; +} + +export interface BudgetAlert { + categoryId: string; + categoryName: string; + monthlyBudget: number; + spentThisMonth: number; + remaining: number; + usedPercentage: number; + isOverBudget: boolean; +} + +export interface DailyExpenseReport { + date: string; + summary: ProfitSummary; + categories: CategoryBreakdown[]; + comparison: PeriodComparison; + expenses: ExpenseSummary[]; + highestCategory: string | null; + lowestCategory: string | null; +} + +export interface MonthlyExpenseReport { + year: number; + month: number; + monthLabel: string; + summary: ProfitSummary; + categories: CategoryBreakdown[]; + dailyFigures: DailyFigure[]; + weeklyFigures: WeeklyFigure[]; + comparison: PeriodComparison; + budgetAlerts: BudgetAlert[]; +} + +export interface ExpenseRangeReport { + from: string; + to: string; + summary: ProfitSummary; + categories: CategoryBreakdown[]; + dailyFigures: DailyFigure[]; +} diff --git a/frontend/src/features/expenses/api/expensesApi.ts b/frontend/src/features/expenses/api/expensesApi.ts new file mode 100644 index 0000000..db8e353 --- /dev/null +++ b/frontend/src/features/expenses/api/expensesApi.ts @@ -0,0 +1,101 @@ +import type { + DailyExpenseReport, + Expense, + ExpenseCategory, + ExpenseCategoryPayload, + ExpenseFilters, + ExpensePayload, + ExpenseRangeReport, + ExpenseSummary, + MonthlyExpenseReport, + RecurringExpense, + RecurringExpensePayload, +} from "@/entities/expense"; +import { axiosClient } from "@/shared/api/axiosClient"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const expensesApi = { + list: (filters: ExpenseFilters = {}) => + apiService.get(API_ENDPOINTS.EXPENSES.BASE, { + from: filters.from, + to: filters.to, + categoryId: filters.categoryId, + paymentMethod: filters.paymentMethod, + status: filters.status, + isPaid: filters.isPaid, + search: filters.search || undefined, + }), + + getById: (id: string) => apiService.get(API_ENDPOINTS.EXPENSES.BY_ID(id)), + + create: (payload: ExpensePayload) => apiService.post(API_ENDPOINTS.EXPENSES.BASE, payload), + + update: (id: string, payload: ExpensePayload) => + apiService.put(API_ENDPOINTS.EXPENSES.BY_ID(id), payload), + + remove: (id: string) => apiService.delete(API_ENDPOINTS.EXPENSES.BY_ID(id)), + + submit: (id: string, comments: string | null) => + apiService.post(API_ENDPOINTS.EXPENSES.SUBMIT(id), { comments }), + + approve: (expenseIds: string[], comments: string | null) => + apiService.post(API_ENDPOINTS.EXPENSES.APPROVE, { expenseIds, comments }), + + reject: (expenseIds: string[], comments: string | null) => + apiService.post(API_ENDPOINTS.EXPENSES.REJECT, { expenseIds, comments }), + + setPaid: (id: string, isPaid: boolean, paymentDate: string | null) => + apiService.put(API_ENDPOINTS.EXPENSES.PAID(id), { isPaid, paymentDate }), + + addAttachment: (id: string, file: File) => { + const form = new FormData(); + form.append("file", file); + + return axiosClient + .post(API_ENDPOINTS.EXPENSES.ATTACHMENTS(id), form) + .then((response) => response.data); + }, + + removeAttachment: (id: string, attachmentId: string) => + apiService.delete(API_ENDPOINTS.EXPENSES.REMOVE_ATTACHMENT(id, attachmentId)), + + /** The URL a receipt can be opened at. Served inline so an image shows in a viewer. */ + attachmentUrl: (attachmentId: string) => + `${axiosClient.defaults.baseURL ?? ""}${API_ENDPOINTS.EXPENSES.ATTACHMENT_BY_ID(attachmentId)}`, + + categories: (isActive?: boolean) => + apiService.get(API_ENDPOINTS.EXPENSES.CATEGORIES, { isActive }), + + createCategory: (payload: ExpenseCategoryPayload) => + apiService.post(API_ENDPOINTS.EXPENSES.CATEGORIES, payload), + + updateCategory: (id: string, payload: ExpenseCategoryPayload) => + apiService.put(API_ENDPOINTS.EXPENSES.CATEGORY_BY_ID(id), payload), + + setCategoryActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.EXPENSES.CATEGORY_STATUS(id), { isActive }), + + deleteCategory: (id: string) => apiService.delete(API_ENDPOINTS.EXPENSES.CATEGORY_BY_ID(id)), + + recurring: () => apiService.get(API_ENDPOINTS.EXPENSES.RECURRING), + + createRecurring: (payload: RecurringExpensePayload) => + apiService.post(API_ENDPOINTS.EXPENSES.RECURRING, payload), + + updateRecurring: (id: string, payload: RecurringExpensePayload) => + apiService.put(API_ENDPOINTS.EXPENSES.RECURRING_BY_ID(id), payload), + + setRecurringActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.EXPENSES.RECURRING_STATUS(id), { isActive }), + + generateRecurring: () => apiService.post(API_ENDPOINTS.EXPENSES.RECURRING_GENERATE), + + dailyReport: (date: string) => + apiService.get(API_ENDPOINTS.EXPENSES.REPORT_DAILY, { date }), + + monthlyReport: (year: number, month: number) => + apiService.get(API_ENDPOINTS.EXPENSES.REPORT_MONTHLY, { year, month }), + + rangeReport: (from: string, to: string) => + apiService.get(API_ENDPOINTS.EXPENSES.REPORT_RANGE, { from, to }), +}; diff --git a/frontend/src/features/expenses/index.ts b/frontend/src/features/expenses/index.ts new file mode 100644 index 0000000..6965f28 --- /dev/null +++ b/frontend/src/features/expenses/index.ts @@ -0,0 +1,25 @@ +export { expensesApi } from "./api/expensesApi"; +export { + useExpenses, + useExpense, + useExpenseCategories, + useRecurringExpenses, + useGenerateDueRecurringExpenses, + useExpenseMutations, + useExpenseCategoryMutations, + useRecurringExpenseMutations, + useDailyExpenseReport, + useMonthlyExpenseReport, + EXPENSES_KEY, + EXPENSE_CATEGORIES_KEY, +} from "./model/useExpenses"; +export { ExpenseFormDialog } from "./ui/ExpenseFormDialog"; +export { ExpenseCategoryDialog } from "./ui/ExpenseCategoryDialog"; +export { RecurringExpenseDialog } from "./ui/RecurringExpenseDialog"; +export { + exportDailyReportPdf, + exportMonthlyReportPdf, + exportDailyReportExcel, + exportMonthlyReportExcel, + exportExpenseListExcel, +} from "./lib/reportExport"; diff --git a/frontend/src/features/expenses/lib/reportExport.ts b/frontend/src/features/expenses/lib/reportExport.ts new file mode 100644 index 0000000..71e17fb --- /dev/null +++ b/frontend/src/features/expenses/lib/reportExport.ts @@ -0,0 +1,311 @@ +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import * as XLSX from "xlsx"; +import type { + DailyExpenseReport, + ExpenseSummary, + MonthlyExpenseReport, +} from "@/entities/expense"; + +/** + * Turns reports into files the owner can keep or email (EXP-031, EXP-032). + * + * Both are produced in the browser and handed straight to the download, so nothing is sent to a + * printer and no round trip to the server is needed for figures already on screen. The PDF is a + * real document rather than a print job — it lands in Downloads, opens in any viewer and can be + * attached to an email as it is. + */ + +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +const percent = (value: number | null) => (value === null ? "—" : `${value.toFixed(1)}%`); + +/** Triggers a browser download for a blob, cleaning up the object URL afterwards. */ +function download(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + + // Revoked on the next tick: revoking synchronously can cancel the download in some browsers. + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** The letterhead every exported report carries, and the y position content should start at. */ +function writeHeader(doc: jsPDF, title: string, subtitle: string): number { + doc.setFontSize(16); + doc.setFont("helvetica", "bold"); + doc.text("Sri Lakshmi Family Restaurant", 14, 18); + + doc.setFontSize(12); + doc.text(title, 14, 27); + + doc.setFontSize(10); + doc.setFont("helvetica", "normal"); + doc.setTextColor(110); + doc.text(subtitle, 14, 33); + doc.setTextColor(0); + + return 40; +} + +/** Writes the revenue/expenses/profit block and returns the y position after it. */ +function writeSummary( + doc: jsPDF, + startY: number, + summary: { revenue: number; expenses: number; profit: number; profitMargin: number | null }, +): number { + autoTable(doc, { + startY, + theme: "plain", + styles: { fontSize: 10, cellPadding: 1.5 }, + columnStyles: { 0: { fontStyle: "bold", cellWidth: 45 }, 1: { halign: "right", cellWidth: 40 } }, + body: [ + ["Revenue", money(summary.revenue)], + ["Expenses", money(summary.expenses)], + ["Profit", money(summary.profit)], + ["Profit margin", percent(summary.profitMargin)], + ], + }); + + return (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 8; +} + +export function exportDailyReportPdf(report: DailyExpenseReport): void { + const doc = new jsPDF(); + + let y = writeHeader(doc, "Daily Expense Report", report.date); + y = writeSummary(doc, y, report.summary); + + autoTable(doc, { + startY: y, + head: [["Category", "Expenses", "Amount", "Share"]], + body: report.categories.map((c) => [ + c.categoryName, + String(c.expenseCount), + money(c.total), + `${c.percentageOfTotal.toFixed(1)}%`, + ]), + foot: [["Total", "", money(report.summary.expenses), "100.0%"]], + styles: { fontSize: 9 }, + headStyles: { fillColor: [15, 82, 66] }, + footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" }, + columnStyles: { 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" } }, + }); + + y = (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 8; + + doc.setFontSize(10); + doc.text( + `Compared with ${report.comparison.previousLabel}: ${money(report.comparison.previousTotal)} → ` + + `${money(report.comparison.currentTotal)} (${report.comparison.change >= 0 ? "+" : ""}` + + `${money(report.comparison.change)}${ + report.comparison.changePercentage === null + ? "" + : `, ${report.comparison.changePercentage >= 0 ? "+" : ""}${report.comparison.changePercentage.toFixed(1)}%` + })`, + 14, + y, + ); + + doc.save(`expense-report-${report.date}.pdf`); +} + +export function exportMonthlyReportPdf(report: MonthlyExpenseReport): void { + const doc = new jsPDF(); + + let y = writeHeader(doc, "Monthly Expense Report", report.monthLabel); + y = writeSummary(doc, y, report.summary); + + autoTable(doc, { + startY: y, + head: [["Category", "Amount", "Share", "Budget", "Used"]], + body: report.categories.map((c) => [ + c.categoryName, + money(c.total), + `${c.percentageOfTotal.toFixed(1)}%`, + c.monthlyBudget === null ? "—" : money(c.monthlyBudget), + c.budgetUsedPercentage === null ? "—" : `${c.budgetUsedPercentage.toFixed(1)}%`, + ]), + foot: [["Total", money(report.summary.expenses), "100.0%", "", ""]], + styles: { fontSize: 9 }, + headStyles: { fillColor: [15, 82, 66] }, + footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" }, + columnStyles: { 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" } }, + }); + + y = (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 8; + + autoTable(doc, { + startY: y, + head: [["Week", "From", "To", "Revenue", "Expenses", "Profit"]], + body: report.weeklyFigures.map((w) => [ + `Week ${w.weekNumber}`, + w.startDate, + w.endDate, + money(w.revenue), + money(w.expenses), + money(w.profit), + ]), + styles: { fontSize: 9 }, + headStyles: { fillColor: [15, 82, 66] }, + columnStyles: { 3: { halign: "right" }, 4: { halign: "right" }, 5: { halign: "right" } }, + }); + + y = (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 8; + + doc.setFontSize(10); + doc.text( + `Compared with ${report.comparison.previousLabel}: ${money(report.comparison.previousTotal)} → ` + + `${money(report.comparison.currentTotal)}` + + `${ + report.comparison.largestIncreaseCategory + ? `. Largest increase: ${report.comparison.largestIncreaseCategory} ` + + `(+${money(report.comparison.largestIncreaseAmount ?? 0)})` + : "" + }`, + 14, + y, + ); + + doc.save(`expense-report-${report.year}-${String(report.month).padStart(2, "0")}.pdf`); +} + +/** Writes a workbook to a real .xlsx file, so figures land in cells ready to be summed. */ +function saveWorkbook(sheets: { name: string; rows: unknown[][] }[], fileName: string): void { + const workbook = XLSX.utils.book_new(); + + for (const sheet of sheets) { + const worksheet = XLSX.utils.aoa_to_sheet(sheet.rows); + // Sheet names are capped at 31 characters by the format itself. + XLSX.utils.book_append_sheet(workbook, worksheet, sheet.name.slice(0, 31)); + } + + const buffer = XLSX.write(workbook, { bookType: "xlsx", type: "array" }); + + download( + new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }), + fileName, + ); +} + +export function exportDailyReportExcel(report: DailyExpenseReport): void { + saveWorkbook( + [ + { + name: "Summary", + rows: [ + ["Daily Expense Report", report.date], + [], + ["Revenue", report.summary.revenue], + ["Expenses", report.summary.expenses], + ["Profit", report.summary.profit], + ["Profit margin %", report.summary.profitMargin], + [], + ["Category", "Expenses", "Amount", "Share %"], + ...report.categories.map((c) => [c.categoryName, c.expenseCount, c.total, c.percentageOfTotal]), + ], + }, + { + name: "Expenses", + rows: [ + ["Number", "Date", "Category", "Description", "Method", "Reference", "Status", "Paid", "Amount"], + ...report.expenses.map((e) => [ + e.expenseNumber, + e.expenseDate, + e.categoryName, + e.description ?? "", + e.paymentMethod, + e.paymentReference ?? "", + e.status, + e.isPaid ? "Yes" : "No", + e.amount, + ]), + ], + }, + ], + `expense-report-${report.date}.xlsx`, + ); +} + +export function exportMonthlyReportExcel(report: MonthlyExpenseReport): void { + saveWorkbook( + [ + { + name: "Summary", + rows: [ + ["Monthly Expense Report", report.monthLabel], + [], + ["Revenue", report.summary.revenue], + ["Expenses", report.summary.expenses], + ["Profit", report.summary.profit], + ["Profit margin %", report.summary.profitMargin], + [], + ["Category", "Amount", "Share %", "Budget", "Used %"], + ...report.categories.map((c) => [ + c.categoryName, + c.total, + c.percentageOfTotal, + c.monthlyBudget, + c.budgetUsedPercentage, + ]), + ], + }, + { + name: "Daily", + rows: [ + ["Date", "Revenue", "Expenses", "Profit"], + ...report.dailyFigures.map((d) => [d.date, d.revenue, d.expenses, d.profit]), + ], + }, + { + name: "Weekly", + rows: [ + ["Week", "From", "To", "Revenue", "Expenses", "Profit"], + ...report.weeklyFigures.map((w) => [ + w.weekNumber, + w.startDate, + w.endDate, + w.revenue, + w.expenses, + w.profit, + ]), + ], + }, + ], + `expense-report-${report.year}-${String(report.month).padStart(2, "0")}.xlsx`, + ); +} + +/** Exports whatever the expense list is currently showing, filters and all. */ +export function exportExpenseListExcel(expenses: ExpenseSummary[], label: string): void { + saveWorkbook( + [ + { + name: "Expenses", + rows: [ + ["Number", "Date", "Category", "Description", "Method", "Reference", "Status", "Paid", "Amount"], + ...expenses.map((e) => [ + e.expenseNumber, + e.expenseDate, + e.categoryName, + e.description ?? "", + e.paymentMethod, + e.paymentReference ?? "", + e.status, + e.isPaid ? "Yes" : "No", + e.amount, + ]), + [], + ["Total", "", "", "", "", "", "", "", expenses.reduce((sum, e) => sum + e.amount, 0)], + ], + }, + ], + `expenses-${label}.xlsx`, + ); +} diff --git a/frontend/src/features/expenses/model/useExpenses.ts b/frontend/src/features/expenses/model/useExpenses.ts new file mode 100644 index 0000000..d1bc693 --- /dev/null +++ b/frontend/src/features/expenses/model/useExpenses.ts @@ -0,0 +1,195 @@ +import { useEffect, useRef } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + Expense, + ExpenseCategory, + ExpenseCategoryPayload, + ExpenseFilters, + ExpensePayload, + RecurringExpense, + RecurringExpensePayload, +} from "@/entities/expense"; +import { expensesApi } from "../api/expensesApi"; + +export const EXPENSES_KEY = "expenses"; +export const EXPENSE_KEY = "expense"; +export const EXPENSE_CATEGORIES_KEY = "expense-categories"; +export const RECURRING_EXPENSES_KEY = "recurring-expenses"; +export const EXPENSE_REPORT_KEY = "expense-report"; + +export function useExpenses(filters: ExpenseFilters = {}) { + return useQuery({ + queryKey: [EXPENSES_KEY, filters], + queryFn: () => expensesApi.list(filters), + placeholderData: (previous) => previous, + }); +} + +export function useExpense(id: string | undefined) { + return useQuery({ + queryKey: [EXPENSE_KEY, id], + queryFn: () => expensesApi.getById(id!), + enabled: !!id, + }); +} + +export function useExpenseCategories(isActive?: boolean) { + return useQuery({ + queryKey: [EXPENSE_CATEGORIES_KEY, isActive], + queryFn: () => expensesApi.categories(isActive), + }); +} + +export function useRecurringExpenses() { + return useQuery({ queryKey: [RECURRING_EXPENSES_KEY], queryFn: expensesApi.recurring }); +} + +/** + * Materialises any recurring expense still owed, once per mount. + * + * This is what stands in for a scheduler: the restaurant's machine is off overnight, so rent + * appears the first time somebody opens the expenses screen rather than at midnight on the 1st. + * The ref guards against React's development double-mount firing it twice — the server is + * idempotent regardless, but a duplicate request would still show a misleading toast. + */ +export function useGenerateDueRecurringExpenses(onGenerated?: (count: number) => void) { + const queryClient = useQueryClient(); + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + hasRun.current = true; + + expensesApi + .generateRecurring() + .then((count) => { + if (count > 0) { + queryClient.invalidateQueries({ queryKey: [EXPENSES_KEY] }); + queryClient.invalidateQueries({ queryKey: [RECURRING_EXPENSES_KEY] }); + onGenerated?.(count); + } + }) + // A failure here must not break the screen: the expense list itself is unaffected, and the + // generation will simply be retried the next time the page is opened. + .catch(() => undefined); + }, [queryClient, onGenerated]); +} + +export function useExpenseMutations() { + const queryClient = useQueryClient(); + + const invalidate = (id?: string) => { + queryClient.invalidateQueries({ queryKey: [EXPENSES_KEY] }); + queryClient.invalidateQueries({ queryKey: [EXPENSE_REPORT_KEY] }); + if (id) queryClient.invalidateQueries({ queryKey: [EXPENSE_KEY, id] }); + }; + + const create = useMutation({ + mutationFn: expensesApi.create, + onSuccess: (expense) => invalidate(expense.id), + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => expensesApi.update(id, payload), + onSuccess: (expense) => invalidate(expense.id), + }); + + const remove = useMutation({ + mutationFn: expensesApi.remove, + onSuccess: () => invalidate(), + }); + + const submit = useMutation({ + mutationFn: ({ id, comments }) => expensesApi.submit(id, comments), + onSuccess: (expense) => invalidate(expense.id), + }); + + const approve = useMutation({ + mutationFn: ({ ids, comments }) => expensesApi.approve(ids, comments), + onSuccess: () => invalidate(), + }); + + const reject = useMutation({ + mutationFn: ({ ids, comments }) => expensesApi.reject(ids, comments), + onSuccess: () => invalidate(), + }); + + const setPaid = useMutation({ + mutationFn: ({ id, isPaid, paymentDate }) => expensesApi.setPaid(id, isPaid, paymentDate), + onSuccess: (expense) => invalidate(expense.id), + }); + + const addAttachment = useMutation({ + mutationFn: ({ id, file }) => expensesApi.addAttachment(id, file), + onSuccess: (expense) => invalidate(expense.id), + }); + + const removeAttachment = useMutation({ + mutationFn: ({ id, attachmentId }) => expensesApi.removeAttachment(id, attachmentId), + onSuccess: (expense) => invalidate(expense.id), + }); + + return { create, update, remove, submit, approve, reject, setPaid, addAttachment, removeAttachment }; +} + +export function useExpenseCategoryMutations() { + const queryClient = useQueryClient(); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: [EXPENSE_CATEGORIES_KEY] }); + queryClient.invalidateQueries({ queryKey: [EXPENSE_REPORT_KEY] }); + }; + + const create = useMutation({ + mutationFn: expensesApi.createCategory, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => expensesApi.updateCategory(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => expensesApi.setCategoryActive(id, isActive), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: expensesApi.deleteCategory, + onSuccess: invalidate, + }); + + return { create, update, setActive, remove }; +} + +export function useRecurringExpenseMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [RECURRING_EXPENSES_KEY] }); + + const save = useMutation({ + mutationFn: ({ id, payload }) => + id ? expensesApi.updateRecurring(id, payload) : expensesApi.createRecurring(payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => expensesApi.setRecurringActive(id, isActive), + onSuccess: invalidate, + }); + + return { save, setActive }; +} + +export function useDailyExpenseReport(date: string) { + return useQuery({ + queryKey: [EXPENSE_REPORT_KEY, "daily", date], + queryFn: () => expensesApi.dailyReport(date), + }); +} + +export function useMonthlyExpenseReport(year: number, month: number) { + return useQuery({ + queryKey: [EXPENSE_REPORT_KEY, "monthly", year, month], + queryFn: () => expensesApi.monthlyReport(year, month), + }); +} diff --git a/frontend/src/features/expenses/ui/ExpenseCategoryDialog.tsx b/frontend/src/features/expenses/ui/ExpenseCategoryDialog.tsx new file mode 100644 index 0000000..3f49a53 --- /dev/null +++ b/frontend/src/features/expenses/ui/ExpenseCategoryDialog.tsx @@ -0,0 +1,174 @@ +import { useForm, Controller } from "react-hook-form"; +import { toast } from "sonner"; +import type { ExpenseCategory } from "@/entities/expense"; +import { toApiError } from "@/shared/api/problem"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useExpenseCategoryMutations } from "../model/useExpenses"; + +export interface ExpenseCategoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + categories: ExpenseCategory[]; + category?: ExpenseCategory; +} + +interface FormValues { + name: string; + description: string; + monthlyBudget: string; + parentCategoryId: string; +} + +const NO_PARENT = "none"; + +/** Adds or edits an expense category and its monthly budget (EXP-011, BR-EXP-015). */ +export function ExpenseCategoryDialog({ + open, + onOpenChange, + categories, + category, +}: ExpenseCategoryDialogProps) { + const isEditing = !!category; + const { create, update } = useExpenseCategoryMutations(); + const pending = create.isPending || update.isPending; + + const defaults: FormValues = { + name: category?.name ?? "", + description: category?.description ?? "", + monthlyBudget: category?.monthlyBudget != null ? String(category.monthlyBudget) : "", + parentCategoryId: category?.parentCategoryId ?? NO_PARENT, + }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + // Only top-level categories can be parents, and nothing can parent itself. + const parentOptions = categories.filter( + (c) => c.parentCategoryId === null && c.id !== category?.id && c.isActive, + ); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const budget = values.monthlyBudget.trim(); + + if (budget !== "" && (!Number.isFinite(Number(budget)) || Number(budget) < 0)) { + toast.error("Enter a budget of zero or more, or leave it blank."); + return; + } + + const payload = { + name: values.name.trim(), + description: values.description.trim() || null, + monthlyBudget: budget === "" ? null : Number(budget), + parentCategoryId: values.parentCategoryId === NO_PARENT ? null : values.parentCategoryId, + }; + + try { + if (isEditing) { + await update.mutateAsync({ id: category.id, payload }); + toast.success(`${payload.name} was updated.`); + } else { + await create.mutateAsync(payload); + toast.success(`${payload.name} was added.`); + } + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + {isEditing ? "Edit category" : "Add category"} + + A budget is optional. Setting one turns on overspend alerts for this category. + + + +
+ + + + + + + + +
+ + + + + + ( + + )} + /> + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/expenses/ui/ExpenseFormDialog.tsx b/frontend/src/features/expenses/ui/ExpenseFormDialog.tsx new file mode 100644 index 0000000..c699cdb --- /dev/null +++ b/frontend/src/features/expenses/ui/ExpenseFormDialog.tsx @@ -0,0 +1,337 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Paperclip, Trash2 } from "lucide-react"; +import type { Expense, ExpenseCategory, ExpensePaymentMethod } from "@/entities/expense"; +import { EXPENSE_PAYMENT_METHODS, EXPENSE_PAYMENT_METHOD_LABELS, requiresReference } from "@/entities/expense"; +import { toApiError } from "@/shared/api/problem"; +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { expensesApi } from "../api/expensesApi"; +import { useExpenseMutations } from "../model/useExpenses"; + +export interface ExpenseFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + categories: ExpenseCategory[]; + /** The expense being corrected, or omitted to record a new one. */ + expense?: Expense; +} + +interface FormValues { + expenseDate: string; + categoryId: string; + amount: string; + description: string; + paymentMethod: ExpensePaymentMethod; + paymentReference: string; + paymentDate: string; + isPaid: boolean; +} + +const today = () => new Date().toISOString().slice(0, 10); + +/** Records money paid out, or corrects an expense nobody has ruled on yet (EXP-001 to EXP-008). */ +export function ExpenseFormDialog({ open, onOpenChange, categories, expense }: ExpenseFormDialogProps) { + const isEditing = !!expense; + const { create, update, addAttachment, removeAttachment } = useExpenseMutations(); + const [pendingFile, setPendingFile] = useState(null); + + const pending = create.isPending || update.isPending || addAttachment.isPending; + + const defaults: FormValues = { + expenseDate: expense?.expenseDate ?? today(), + categoryId: expense?.categoryId ?? "", + amount: expense ? String(expense.amount) : "", + description: expense?.description ?? "", + paymentMethod: expense?.paymentMethod ?? "Cash", + paymentReference: expense?.paymentReference ?? "", + paymentDate: expense?.paymentDate ?? "", + isPaid: expense?.isPaid ?? false, + }; + + const { + control, + register, + handleSubmit, + reset, + watch, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const method = watch("paymentMethod"); + const referenceRequired = requiresReference(method); + + const close = (isOpen: boolean) => { + if (!isOpen) { + reset(defaults); + setPendingFile(null); + } + onOpenChange(isOpen); + }; + + const build = (values: FormValues) => ({ + expenseDate: values.expenseDate, + categoryId: values.categoryId, + amount: Number(values.amount), + description: values.description.trim() || null, + paymentMethod: values.paymentMethod, + paymentReference: values.paymentReference.trim() || null, + paymentDate: values.paymentDate || null, + isPaid: values.isPaid, + }); + + const save = (submitForApproval: boolean) => + handleSubmit(async (values) => { + const amount = Number(values.amount); + + if (!Number.isFinite(amount) || amount <= 0) { + toast.error("Enter an amount greater than zero."); + return; + } + + if (values.expenseDate > today()) { + toast.error("An expense cannot be dated in the future."); + return; + } + + try { + const saved = isEditing + ? await update.mutateAsync({ id: expense.id, payload: build(values) }) + : await create.mutateAsync({ ...build(values), submitForApproval }); + + if (pendingFile) { + await addAttachment.mutateAsync({ id: saved.id, file: pendingFile }); + } + + toast.success( + isEditing + ? `${saved.expenseNumber} was updated.` + : `${saved.expenseNumber} recorded${submitForApproval ? " and submitted for approval" : " as a draft"}.`, + ); + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + })(); + + const dropAttachment = async (attachmentId: string) => { + try { + await removeAttachment.mutateAsync({ id: expense!.id, attachmentId }); + toast.success("Receipt removed."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( + + + + {isEditing ? `Edit ${expense.expenseNumber}` : "Record expense"} + + {isEditing + ? "An expense can be corrected until it is approved." + : "Save it as a draft, or send it straight for approval."} + + + +
+
+ + + + + + ( + + )} + /> + +
+ + + + + + + + + +
+ + ( + + )} + /> + + + + + !requiresReference(method) || value.trim().length > 0 + ? true + : "A reference is required for cheque, card and bank transfer.", + })} + id="paymentReference" + placeholder={method === "Cheque" ? "CHK-001" : "TRF-WTR-12345"} + /> + +
+ +
+ + + + +
+ ( + + )} + /> +
+
+ +
+

Receipt / invoice

+ + {isEditing && expense.attachments.length > 0 && ( +
    + {expense.attachments.map((attachment) => ( +
  • + + + {attachment.fileName} + + {expense.isEditable && ( + + )} +
  • + ))} +
+ )} + + setPendingFile(event.target.files?.[0] ?? null)} + className="cursor-pointer text-sm file:mr-3 file:cursor-pointer file:rounded file:border-0 file:bg-muted file:px-2 file:py-1" + /> +

JPEG, PNG, WebP or PDF, up to 10 MB. Optional.

+
+ + + + + {!isEditing && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/features/expenses/ui/RecurringExpenseDialog.tsx b/frontend/src/features/expenses/ui/RecurringExpenseDialog.tsx new file mode 100644 index 0000000..5c08e05 --- /dev/null +++ b/frontend/src/features/expenses/ui/RecurringExpenseDialog.tsx @@ -0,0 +1,196 @@ +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { ExpenseCategory, ExpensePaymentMethod, RecurringExpense } from "@/entities/expense"; +import { EXPENSE_PAYMENT_METHODS, EXPENSE_PAYMENT_METHOD_LABELS } from "@/entities/expense"; +import { toApiError } from "@/shared/api/problem"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useRecurringExpenseMutations } from "../model/useExpenses"; + +export interface RecurringExpenseDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + categories: ExpenseCategory[]; + recurring?: RecurringExpense; +} + +interface FormValues { + categoryId: string; + amount: string; + description: string; + paymentMethod: ExpensePaymentMethod; + dayOfMonth: string; +} + +/** Sets up a standing monthly cost such as rent or salaries (EXP-012). */ +export function RecurringExpenseDialog({ + open, + onOpenChange, + categories, + recurring, +}: RecurringExpenseDialogProps) { + const isEditing = !!recurring; + const { save } = useRecurringExpenseMutations(); + + const defaults: FormValues = { + categoryId: recurring?.categoryId ?? "", + amount: recurring ? String(recurring.amount) : "", + description: recurring?.description ?? "", + paymentMethod: recurring?.paymentMethod ?? "BankTransfer", + dayOfMonth: String(recurring?.dayOfMonth ?? 1), + }; + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaults }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset(defaults); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async (values) => { + const amount = Number(values.amount); + const dayOfMonth = Number(values.dayOfMonth); + + if (!Number.isFinite(amount) || amount <= 0) { + toast.error("Enter an amount greater than zero."); + return; + } + + if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) { + toast.error("The day of the month must be between 1 and 31."); + return; + } + + try { + await save.mutateAsync({ + id: recurring?.id, + payload: { + categoryId: values.categoryId, + amount, + description: values.description.trim() || null, + paymentMethod: values.paymentMethod, + dayOfMonth, + }, + }); + + toast.success(isEditing ? "Recurring expense updated." : "Recurring expense set up."); + close(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + return ( + + + + {isEditing ? "Edit recurring expense" : "Add recurring expense"} + + Created as a draft each month when the expenses screen is first opened, so the figure can be + checked before it counts. + + + +
+ + ( + + )} + /> + + +
+ + + + + + + +
+ + + ( + + )} + /> + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/expenses/Charts.tsx b/frontend/src/pages/expenses/Charts.tsx new file mode 100644 index 0000000..bf1a013 --- /dev/null +++ b/frontend/src/pages/expenses/Charts.tsx @@ -0,0 +1,198 @@ +import { useId } from "react"; +import type { CategoryBreakdown, DailyFigure } from "@/entities/expense"; + +/** + * Small SVG charts, hand-rolled rather than pulled from a charting library. + * + * The shapes needed here are a donut and two trend lines over at most 31 points — a few dozen + * lines of SVG against roughly half a megabyte of dependency, in an app that ships to a single + * restaurant PC. They also inherit the app's own colours, so the charts match the rest of the + * interface instead of bringing a second palette with them. + */ + +/** Chart colours, walked in order. Chosen to stay distinguishable in the light and dark themes. */ +const SERIES_COLOURS = [ + "hsl(168 62% 28%)", + "hsl(28 88% 52%)", + "hsl(214 72% 50%)", + "hsl(340 65% 52%)", + "hsl(268 55% 55%)", + "hsl(48 90% 45%)", + "hsl(190 65% 42%)", + "hsl(0 65% 52%)", +]; + +export const seriesColour = (index: number) => SERIES_COLOURS[index % SERIES_COLOURS.length]; + +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + +/** Where a slice's arc lands on the circle, given its share of the whole. */ +function arcPath(cx: number, cy: number, radius: number, inner: number, from: number, to: number): string { + const start = (from - 90) * (Math.PI / 180); + const end = (to - 90) * (Math.PI / 180); + const large = to - from > 180 ? 1 : 0; + + const x1 = cx + radius * Math.cos(start); + const y1 = cy + radius * Math.sin(start); + const x2 = cx + radius * Math.cos(end); + const y2 = cy + radius * Math.sin(end); + + const ix1 = cx + inner * Math.cos(end); + const iy1 = cy + inner * Math.sin(end); + const ix2 = cx + inner * Math.cos(start); + const iy2 = cy + inner * Math.sin(start); + + return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${inner} ${inner} 0 ${large} 0 ${ix2} ${iy2} Z`; +} + +/** Category shares as a donut, with the period's total in the middle. */ +export function CategoryDonut({ categories }: { categories: CategoryBreakdown[] }) { + const total = categories.reduce((sum, c) => sum + c.total, 0); + + if (total <= 0) { + return ( +

+ No approved expenses in this period yet. +

+ ); + } + + let cursor = 0; + + return ( +
+ + {categories.map((category, index) => { + const sweep = (category.total / total) * 360; + const path = arcPath(100, 100, 88, 54, cursor, cursor + sweep); + cursor += sweep; + + return ( + + {`${category.categoryName}: ${money(category.total)} (${category.percentageOfTotal.toFixed(1)}%)`} + + ); + })} + + {money(total)} + + + total + + + +
    + {categories.map((category, index) => ( +
  • +
  • + ))} +
+
+ ); +} + +/** Revenue against expenses across a month, as two filled trend lines (EXP-033). */ +export function DailyTrendChart({ figures }: { figures: DailyFigure[] }) { + const gradientId = useId(); + + if (figures.length === 0) { + return

Nothing to chart yet.

; + } + + const width = 720; + const height = 220; + const padding = { top: 12, right: 12, bottom: 24, left: 52 }; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + + // Both series share one scale, or the comparison between them would be meaningless. + const peak = Math.max(...figures.map((f) => Math.max(f.revenue, f.expenses)), 1); + + const x = (index: number) => + padding.left + (figures.length === 1 ? plotWidth / 2 : (index / (figures.length - 1)) * plotWidth); + + const y = (value: number) => padding.top + plotHeight - (value / peak) * plotHeight; + + const line = (pick: (figure: DailyFigure) => number) => + figures.map((figure, index) => `${index === 0 ? "M" : "L"} ${x(index)} ${y(pick(figure))}`).join(" "); + + const area = `${line((f) => f.expenses)} L ${x(figures.length - 1)} ${y(0)} L ${x(0)} ${y(0)} Z`; + + const gridValues = [0, 0.25, 0.5, 0.75, 1].map((fraction) => fraction * peak); + + return ( + + + + + + + + + {gridValues.map((value) => ( + + + + {money(value)} + + + ))} + + + f.revenue)} fill="none" stroke={seriesColour(0)} strokeWidth="2.5" /> + f.expenses)} fill="none" stroke={seriesColour(1)} strokeWidth="2.5" /> + + {/* Only a few day labels, or a 31-day month turns the axis into a smear. */} + {figures.map((figure, index) => + index % Math.ceil(figures.length / 8) === 0 || index === figures.length - 1 ? ( + + {figure.date.slice(8)} + + ) : null, + )} + + ); +} + +/** The key for {@link DailyTrendChart}, kept separate so it can sit beside the heading. */ +export function TrendLegend() { + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/pages/expenses/ExpenseDetailDialog.tsx b/frontend/src/pages/expenses/ExpenseDetailDialog.tsx new file mode 100644 index 0000000..ced3627 --- /dev/null +++ b/frontend/src/pages/expenses/ExpenseDetailDialog.tsx @@ -0,0 +1,193 @@ +import { CheckCircle2, Paperclip, XCircle } from "lucide-react"; +import { toast } from "sonner"; +import { EXPENSE_PAYMENT_METHOD_LABELS } from "@/entities/expense"; +import { expensesApi, useExpense, useExpenseMutations } from "@/features/expenses"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + LoadingState, + Separator, +} from "@/shared/ui"; +import { STATUS_BADGE } from "./ExpensesTable"; + +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +/** One expense in full, with its receipts, approval history and the decisions still open on it. */ +export function ExpenseDetailDialog({ + expenseId, + onOpenChange, + onEdit, +}: { + expenseId: string | null; + onOpenChange: (open: boolean) => void; + onEdit: (expenseId: string) => void; +}) { + const { data: expense, isLoading } = useExpense(expenseId ?? undefined); + const { approve, reject, setPaid } = useExpenseMutations(); + + const busy = approve.isPending || reject.isPending || setPaid.isPending; + + const decide = async (isApproval: boolean) => { + if (!expense) return; + + try { + const mutation = isApproval ? approve : reject; + await mutation.mutateAsync({ ids: [expense.id], comments: null }); + toast.success(isApproval ? "Expense approved." : "Expense rejected."); + onOpenChange(false); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const togglePaid = async () => { + if (!expense) return; + + try { + await setPaid.mutateAsync({ + id: expense.id, + isPaid: !expense.isPaid, + paymentDate: expense.isPaid ? null : new Date().toISOString().slice(0, 10), + }); + toast.success(expense.isPaid ? "Marked as unpaid." : "Marked as paid."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( + + + {isLoading || !expense ? ( + + ) : ( + <> + + + {expense.expenseNumber} + {expense.status} + {expense.isRecurring && Recurring} + + + {expense.categoryName} · {expense.expenseDate} + + + +
+

{money(expense.amount)}

+ + {expense.description &&

{expense.description}

} + +
+
Payment method
+
{EXPENSE_PAYMENT_METHOD_LABELS[expense.paymentMethod]}
+ +
Reference
+
{expense.paymentReference ?? "—"}
+ +
Payment date
+
{expense.paymentDate ?? "—"}
+ +
Paid
+
{expense.isPaid ? "Yes" : "Not yet"}
+ +
Recorded by
+
{expense.recordedByName}
+ + {expense.approvedByName && ( + <> +
+ {expense.status === "Rejected" ? "Rejected by" : "Approved by"} +
+
{expense.approvedByName}
+ + )} +
+ + {expense.approvalComments && ( +

“{expense.approvalComments}”

+ )} + + {expense.attachments.length > 0 && ( +
+

Receipts

+ +
+ )} + + {expense.approvalTrail.length > 0 && ( + <> + +
+

History

+
    + {expense.approvalTrail.map((entry, index) => ( +
  1. + {entry.toStatus} + + {" "} + by {entry.actedByName} · {new Date(entry.actedAtUtc).toLocaleString()} + + {entry.comments && ( +

    “{entry.comments}”

    + )} +
  2. + ))} +
+
+ + )} +
+ + + + + {expense.isEditable && ( + <> + + + + + )} + + + )} +
+
+ ); +} diff --git a/frontend/src/pages/expenses/ExpensesTable.tsx b/frontend/src/pages/expenses/ExpensesTable.tsx new file mode 100644 index 0000000..8466e77 --- /dev/null +++ b/frontend/src/pages/expenses/ExpensesTable.tsx @@ -0,0 +1,131 @@ +import { Paperclip, Receipt, Repeat } from "lucide-react"; +import type { ExpenseStatus, ExpenseSummary } from "@/entities/expense"; +import { EXPENSE_PAYMENT_METHOD_LABELS } from "@/entities/expense"; +import { + Badge, + Checkbox, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +export const STATUS_BADGE: Record = { + Draft: "outline", + Pending: "warning", + Approved: "success", + Rejected: "destructive", +}; + +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +export function ExpensesTable({ + expenses, + isLoading, + selected, + onToggle, + onToggleAll, + onOpen, +}: { + expenses: ExpenseSummary[] | undefined; + isLoading: boolean; + selected: Set; + onToggle: (id: string) => void; + onToggleAll: (ids: string[]) => void; + onOpen: (expense: ExpenseSummary) => void; +}) { + if (isLoading) { + return ; + } + + if (!expenses || expenses.length === 0) { + return ( + } + title="No expenses match" + description="Record one, or widen the filters." + /> + ); + } + + // Only undecided expenses can be selected — approving something already approved is not an action. + const selectableIds = expenses.filter((e) => e.status === "Draft" || e.status === "Pending").map((e) => e.id); + const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selected.has(id)); + + return ( + + + + + onToggleAll(selectableIds)} + aria-label="Select all expenses awaiting a decision" + /> + + Number + Date + Category + Description + Method + Status + Amount + + + + {expenses.map((expense) => { + const selectable = expense.status === "Draft" || expense.status === "Pending"; + + return ( + onOpen(expense)} + > + event.stopPropagation()}> + onToggle(expense.id)} + aria-label={`Select ${expense.expenseNumber}`} + /> + + + + {expense.expenseNumber} + {expense.isRecurring && ( + + )} + {expense.attachmentCount > 0 && ( + + )} + + + + {expense.expenseDate} + + {expense.categoryName} + + {expense.description ?? "—"} + + + {EXPENSE_PAYMENT_METHOD_LABELS[expense.paymentMethod]} + {expense.isPaid ? "" : " · unpaid"} + + + {expense.status} + + {money(expense.amount)} + + ); + })} + +
+ ); +} diff --git a/frontend/src/pages/expenses/categories/index.tsx b/frontend/src/pages/expenses/categories/index.tsx new file mode 100644 index 0000000..0c032d9 --- /dev/null +++ b/frontend/src/pages/expenses/categories/index.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { ArrowLeft, Layers, MoreHorizontal, Pencil, Plus, ShieldCheck, ShieldOff, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { ExpenseCategory } from "@/entities/expense"; +import { ExpenseCategoryDialog, useExpenseCategories, useExpenseCategoryMutations } from "@/features/expenses"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + +/** Which categories exist and what each is budgeted at (EXP-010, EXP-011, BR-EXP-015). */ +export default function ExpenseCategoriesPage() { + const [form, setForm] = useState(undefined); + const { data: categories, isLoading } = useExpenseCategories(); + const { setActive, remove } = useExpenseCategoryMutations(); + + const toggleActive = async (category: ExpenseCategory) => { + try { + await setActive.mutateAsync({ id: category.id, isActive: !category.isActive }); + toast.success(category.isActive ? `${category.name} retired.` : `${category.name} is back in use.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const deleteCategory = async (category: ExpenseCategory) => { + try { + await remove.mutateAsync(category.id); + toast.success(`${category.name} was deleted.`); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+ +

Expense categories

+

+ Built-in categories can be renamed and re-budgeted but not deleted, so past reports keep their + labels. +

+
+ +
+ + + {isLoading ? ( + + ) : !categories || categories.length === 0 ? ( + } title="No categories yet" /> + ) : ( + + + + Category + Description + Monthly budget + Expenses + State + + + + + {categories.map((category) => ( + + + {category.parentCategoryName && ( + {category.parentCategoryName} › + )} + {category.name} + {category.isSystem && ( + + Built-in + + )} + + + {category.description ?? "—"} + + + {category.monthlyBudget === null ? ( + Not budgeted + ) : ( + money(category.monthlyBudget) + )} + + + {category.expenseCount} + + + {category.isActive ? ( + Active + ) : ( + Retired + )} + + + + + + + + setForm(category)}> + Edit + + toggleActive(category)} + > + {category.isActive ? ( + <> + Retire + + ) : ( + <> + Put back in use + + )} + + {!category.isSystem && category.expenseCount === 0 && ( + deleteCategory(category)}> + Delete + + )} + + + + + ))} + +
+ )} +
+ + !open && setForm(undefined)} + categories={categories ?? []} + category={form ?? undefined} + /> +
+ ); +} diff --git a/frontend/src/pages/expenses/index.tsx b/frontend/src/pages/expenses/index.tsx new file mode 100644 index 0000000..e344578 --- /dev/null +++ b/frontend/src/pages/expenses/index.tsx @@ -0,0 +1,313 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + BarChart3, + CheckCircle2, + FileSpreadsheet, + Plus, + Repeat, + Search, + Settings2, + XCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import type { Expense, ExpenseFilters, ExpensePaymentMethod, ExpenseStatus } from "@/entities/expense"; +import { + EXPENSE_PAYMENT_METHODS, + EXPENSE_PAYMENT_METHOD_LABELS, + EXPENSE_STATUSES, +} from "@/entities/expense"; +import { + ExpenseFormDialog, + exportExpenseListExcel, + expensesApi, + useExpenseCategories, + useExpenseMutations, + useExpenses, + useGenerateDueRecurringExpenses, +} from "@/features/expenses"; +import { toApiError } from "@/shared/api/problem"; +import { + Button, + Card, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { ExpenseDetailDialog } from "./ExpenseDetailDialog"; +import { ExpensesTable } from "./ExpensesTable"; + +const ALL = "all"; +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +const startOfMonth = () => { + const now = new Date(); + + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); +}; + +const today = () => new Date().toISOString().slice(0, 10); + +/** + * The expense register: everything spent, with the filters to find any of it (EXP-020 to EXP-024) + * and bulk approval for signing off a day's bills in one go. + */ +export default function ExpensesPage() { + const [from, setFrom] = useState(startOfMonth()); + const [to, setTo] = useState(today()); + const [categoryId, setCategoryId] = useState(ALL); + const [paymentMethod, setPaymentMethod] = useState(ALL); + const [status, setStatus] = useState(ALL); + const [search, setSearch] = useState(""); + + const [selected, setSelected] = useState>(new Set()); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(undefined); + const [detailId, setDetailId] = useState(null); + + // Standing costs materialise the first time this screen is opened in a month. + useGenerateDueRecurringExpenses((count) => + toast.info(`${count} recurring expense${count === 1 ? "" : "s"} created as ${count === 1 ? "a draft" : "drafts"}.`), + ); + + const filters: ExpenseFilters = useMemo( + () => ({ + from, + to, + categoryId: categoryId === ALL ? undefined : categoryId, + paymentMethod: paymentMethod === ALL ? undefined : (paymentMethod as ExpensePaymentMethod), + status: status === ALL ? undefined : (status as ExpenseStatus), + search: search.trim() || undefined, + }), + [from, to, categoryId, paymentMethod, status, search], + ); + + const { data: expenses, isLoading } = useExpenses(filters); + const { data: categories } = useExpenseCategories(); + const { approve, reject } = useExpenseMutations(); + + const activeCategories = (categories ?? []).filter((c) => c.isActive); + + const total = (expenses ?? []).reduce((sum, e) => sum + e.amount, 0); + const approvedTotal = (expenses ?? []) + .filter((e) => e.status === "Approved") + .reduce((sum, e) => sum + e.amount, 0); + const awaiting = (expenses ?? []).filter((e) => e.status === "Draft" || e.status === "Pending").length; + + const toggle = (id: string) => + setSelected((current) => { + const next = new Set(current); + next.has(id) ? next.delete(id) : next.add(id); + + return next; + }); + + const toggleAll = (ids: string[]) => + setSelected((current) => (ids.every((id) => current.has(id)) ? new Set() : new Set(ids))); + + const decide = async (isApproval: boolean) => { + const ids = [...selected]; + + try { + const mutation = isApproval ? approve : reject; + await mutation.mutateAsync({ ids, comments: isApproval ? "Bulk approved" : null }); + toast.success(`${ids.length} expense${ids.length === 1 ? "" : "s"} ${isApproval ? "approved" : "rejected"}.`); + setSelected(new Set()); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const openForEdit = async (expenseId: string) => { + try { + const expense = await expensesApi.getById(expenseId); + setDetailId(null); + setEditing(expense); + setFormOpen(true); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+

Expenses

+

+ {money(total)} recorded in this range · {money(approvedTotal)} approved + {awaiting > 0 && ` · ${awaiting} awaiting a decision`} +

+
+ +
+ + + + + +
+
+ + +
+
+ + setFrom(e.target.value)} /> +
+ +
+ + setTo(e.target.value)} /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + setSearch(e.target.value)} + placeholder="Description or reference" + className="pl-9" + /> +
+
+
+
+ + {selected.size > 0 && ( +
+ + {selected.size} selected ·{" "} + {money( + (expenses ?? []) + .filter((e) => selected.has(e.id)) + .reduce((sum, e) => sum + e.amount, 0), + )} + +
+ + + +
+
+ )} + + + setDetailId(expense.id)} + /> + + + { + setFormOpen(open); + if (!open) setEditing(undefined); + }} + categories={activeCategories} + expense={editing} + /> + + !open && setDetailId(null)} + onEdit={openForEdit} + /> +
+ ); +} diff --git a/frontend/src/pages/expenses/recurring/index.tsx b/frontend/src/pages/expenses/recurring/index.tsx new file mode 100644 index 0000000..fbde453 --- /dev/null +++ b/frontend/src/pages/expenses/recurring/index.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { ArrowLeft, MoreHorizontal, Pencil, Plus, Repeat, ShieldCheck, ShieldOff } from "lucide-react"; +import { toast } from "sonner"; +import type { RecurringExpense } from "@/entities/expense"; +import { EXPENSE_PAYMENT_METHOD_LABELS } from "@/entities/expense"; +import { + RecurringExpenseDialog, + useExpenseCategories, + useRecurringExpenseMutations, + useRecurringExpenses, +} from "@/features/expenses"; +import { toApiError } from "@/shared/api/problem"; +import { + Alert, + AlertDescription, + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + LoadingState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +const ordinal = (day: number) => { + const suffix = day % 10 === 1 && day !== 11 ? "st" : day % 10 === 2 && day !== 12 ? "nd" : day % 10 === 3 && day !== 13 ? "rd" : "th"; + + return `${day}${suffix}`; +}; + +/** Standing monthly costs such as rent and salaries (EXP-012, BR-EXP-008). */ +export default function RecurringExpensesPage() { + const [form, setForm] = useState(undefined); + const { data: recurring, isLoading } = useRecurringExpenses(); + const { data: categories } = useExpenseCategories(); + const { setActive } = useRecurringExpenseMutations(); + + const activeCategories = (categories ?? []).filter((c) => c.isActive); + + const toggleActive = async (item: RecurringExpense) => { + try { + await setActive.mutateAsync({ id: item.id, isActive: !item.isActive }); + toast.success(item.isActive ? "Stopped." : "Resumed."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+ +

Recurring expenses

+

+ Costs that repeat every month without being keyed in again. +

+
+ +
+ + + + + Each month's expenses are created as drafts the first time the Expenses screen is opened that + month — including any month the restaurant was closed for. Nothing counts towards a report until + it has been approved. + + + + + {isLoading ? ( + + ) : !recurring || recurring.length === 0 ? ( + } + title="No recurring expenses" + description="Set up rent or salaries so they appear each month on their own." + /> + ) : ( + + + + Category + Description + Falls on + Method + Last created + State + Amount + + + + + {recurring.map((item) => ( + + {item.categoryName} + + {item.description ?? "—"} + + + {ordinal(item.dayOfMonth)} of the month + + + {EXPENSE_PAYMENT_METHOD_LABELS[item.paymentMethod]} + + + {item.lastGeneratedYear && item.lastGeneratedMonth + ? `${item.lastGeneratedYear}-${String(item.lastGeneratedMonth).padStart(2, "0")}` + : "Never"} + + + {item.isActive ? ( + Active + ) : ( + Stopped + )} + + {money(item.amount)} + + + + + + + setForm(item)}> + Edit + + toggleActive(item)}> + {item.isActive ? ( + <> + Stop + + ) : ( + <> + Resume + + )} + + + + + + ))} + +
+ )} +
+ + !open && setForm(undefined)} + categories={activeCategories} + recurring={form ?? undefined} + /> +
+ ); +} diff --git a/frontend/src/pages/expenses/reports/index.tsx b/frontend/src/pages/expenses/reports/index.tsx new file mode 100644 index 0000000..69ed8fc --- /dev/null +++ b/frontend/src/pages/expenses/reports/index.tsx @@ -0,0 +1,357 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { ArrowLeft, FileSpreadsheet, FileText, TrendingDown, TrendingUp, TriangleAlert } from "lucide-react"; +import type { PeriodComparison, ProfitSummary } from "@/entities/expense"; +import { + exportDailyReportExcel, + exportDailyReportPdf, + exportMonthlyReportExcel, + exportMonthlyReportPdf, + useDailyExpenseReport, + useMonthlyExpenseReport, +} from "@/features/expenses"; +import { + Alert, + AlertDescription, + AlertTitle, + Badge, + Button, + Card, + Input, + Label, + LoadingState, + SegmentedTabs, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; +import { cn } from "@/shared/lib/utils"; +import { CategoryDonut, DailyTrendChart, TrendLegend } from "../Charts"; + +type Tab = "daily" | "monthly"; + +const money = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +const today = () => new Date().toISOString().slice(0, 10); + +/** The four headline figures every report leads with (EXP-026, EXP-027). */ +function SummaryCards({ summary }: { summary: ProfitSummary }) { + const cards = [ + { label: "Revenue", value: money(summary.revenue), hint: "Settled bills at the till" }, + { label: "Expenses", value: money(summary.expenses), hint: "Approved expenses only" }, + { + label: "Profit", + value: money(summary.profit), + hint: summary.profit >= 0 ? "Revenue less expenses" : "Spent more than was taken", + negative: summary.profit < 0, + }, + { + label: "Profit margin", + value: summary.profitMargin === null ? "—" : `${summary.profitMargin.toFixed(1)}%`, + hint: summary.profitMargin === null ? "No takings to compare against" : "Share of revenue kept", + }, + ]; + + return ( +
+ {cards.map((card) => ( + +

{card.label}

+

+ {card.value} +

+

{card.hint}

+
+ ))} +
+ ); +} + +/** How the period compares with the one before it (EXP-025). */ +function ComparisonCard({ comparison }: { comparison: PeriodComparison }) { + const up = comparison.change > 0; + const flat = comparison.change === 0; + + return ( + +

Compared with {comparison.previousLabel}

+ +
+ {money(comparison.previousTotal)} + + {money(comparison.currentTotal)} + + {!flat && ( + + {up ? : } + {up ? "+" : ""} + {money(comparison.change)} + {comparison.changePercentage !== null && + ` (${up ? "+" : ""}${comparison.changePercentage.toFixed(1)}%)`} + + )} +
+ + {comparison.largestIncreaseCategory && ( +

+ Largest increase: {comparison.largestIncreaseCategory}{" "} + (+{money(comparison.largestIncreaseAmount ?? 0)}) +

+ )} +
+ ); +} + +/** + * Expense reporting: a day at a time or a month at a time, each exportable as a real PDF or + * spreadsheet the owner can keep or email (EXP-028 to EXP-033). + */ +export default function ExpenseReportsPage() { + const [tab, setTab] = useState("daily"); + const [date, setDate] = useState(today()); + const [month, setMonth] = useState(() => today().slice(0, 7)); + + const [year, monthNumber] = month.split("-").map(Number); + + const daily = useDailyExpenseReport(date); + const monthly = useMonthlyExpenseReport(year, monthNumber); + + return ( +
+
+ +

Expense reports

+

+ Revenue comes from settled bills; only approved expenses are counted. +

+
+ +
+ setTab(value as Tab)} + /> + +
+ {tab === "daily" ? ( +
+ + setDate(event.target.value)} + className="w-44" + /> +
+ ) : ( +
+ + setMonth(event.target.value)} + className="w-44" + /> +
+ )} + + + + +
+
+ + {tab === "daily" && + (daily.isLoading || !daily.data ? ( + + ) : ( +
+ + +
+ +

Where the money went

+ +
+ +
+ + + {daily.data.highestCategory && ( + +

+ Highest:{" "} + {daily.data.highestCategory} +

+

+ Lowest:{" "} + {daily.data.lowestCategory} +

+
+ )} +
+
+ + +
+

Expenses on {daily.data.date}

+

+ Everything recorded, including anything still awaiting a decision. +

+
+ + + + Number + Category + Description + Status + Amount + + + + {daily.data.expenses.map((expense) => ( + + {expense.expenseNumber} + {expense.categoryName} + + {expense.description ?? "—"} + + + + {expense.status} + + + {money(expense.amount)} + + ))} + +
+
+
+ ))} + + {tab === "monthly" && + (monthly.isLoading || !monthly.data ? ( + + ) : ( +
+ + + {monthly.data.budgetAlerts.length > 0 && ( +
+ {monthly.data.budgetAlerts.map((alert) => ( + + +
+ + {alert.categoryName} · {alert.usedPercentage.toFixed(1)}% of budget + + + {money(alert.spentThisMonth)} of {money(alert.monthlyBudget)} —{" "} + {alert.isOverBudget + ? `over by ${money(Math.abs(alert.remaining))}` + : `${money(alert.remaining)} left`} + +
+
+ ))} +
+ )} + + +
+

Daily trend — {monthly.data.monthLabel}

+ +
+ +
+ +
+ +

Category breakdown

+ +
+ +
+ + + +
+

Weekly breakdown

+
+ + + + Week + Revenue + Expenses + Profit + + + + {monthly.data.weeklyFigures.map((week) => ( + + + Week {week.weekNumber} + + {week.startDate.slice(5)} – {week.endDate.slice(5)} + + + {money(week.revenue)} + {money(week.expenses)} + + {money(week.profit)} + + + ))} + +
+
+
+
+
+ ))} +
+ ); +} diff --git a/frontend/src/shared/api/endpoints/index.ts b/frontend/src/shared/api/endpoints/index.ts index 57cc8c5..4e1a76d 100644 --- a/frontend/src/shared/api/endpoints/index.ts +++ b/frontend/src/shared/api/endpoints/index.ts @@ -69,6 +69,27 @@ export const API_ENDPOINTS = { PAYMENTS: (id: string) => `/orders/${id}/payments`, REPRINT_RECEIPT: (id: string) => `/orders/${id}/receipt/reprint`, }, + EXPENSES: { + BASE: "/expenses", + BY_ID: (id: string) => `/expenses/${id}`, + PAID: (id: string) => `/expenses/${id}/paid`, + SUBMIT: (id: string) => `/expenses/${id}/submit`, + APPROVE: "/expenses/approve", + REJECT: "/expenses/reject", + CATEGORIES: "/expenses/categories", + CATEGORY_BY_ID: (id: string) => `/expenses/categories/${id}`, + CATEGORY_STATUS: (id: string) => `/expenses/categories/${id}/status`, + ATTACHMENTS: (id: string) => `/expenses/${id}/attachments`, + ATTACHMENT_BY_ID: (attachmentId: string) => `/expenses/attachments/${attachmentId}`, + REMOVE_ATTACHMENT: (id: string, attachmentId: string) => `/expenses/${id}/attachments/${attachmentId}`, + RECURRING: "/expenses/recurring", + RECURRING_BY_ID: (id: string) => `/expenses/recurring/${id}`, + RECURRING_STATUS: (id: string) => `/expenses/recurring/${id}/status`, + RECURRING_GENERATE: "/expenses/recurring/generate", + REPORT_DAILY: "/expenses/reports/daily", + REPORT_MONTHLY: "/expenses/reports/monthly", + REPORT_RANGE: "/expenses/reports/range", + }, KITCHEN: { TICKETS: "/kitchen/tickets", TICKET_STATUS: (id: string) => `/kitchen/tickets/${id}/status`, diff --git a/frontend/src/shared/config/moduleRoutes.ts b/frontend/src/shared/config/moduleRoutes.ts index e645d57..5dfe884 100644 --- a/frontend/src/shared/config/moduleRoutes.ts +++ b/frontend/src/shared/config/moduleRoutes.ts @@ -44,7 +44,7 @@ export const MODULE_ROUTES: Record = { Notifications: { icon: Bell }, UserManagement: { path: "/users", icon: Users }, SupplierManagement: { path: "/suppliers", icon: Truck }, - ExpensesManagement: { icon: Receipt }, + ExpensesManagement: { path: "/expenses", icon: Receipt }, SystemSettings: { icon: Settings }, };