diff --git a/Distribution/Features/Invoice/IInvoiceRepository.cs b/Distribution/Features/Invoice/IInvoiceRepository.cs index ccbcfab..f8fb3b4 100644 --- a/Distribution/Features/Invoice/IInvoiceRepository.cs +++ b/Distribution/Features/Invoice/IInvoiceRepository.cs @@ -10,5 +10,10 @@ public interface IInvoiceRepository Task GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus); Task> GetOutstandingAsync(); Task AddAsync(Models.Invoice invoice); + Task GetItemAsync(Guid itemId); + Task AddItemsAsync(IEnumerable items); + Task AddVersionAsync(InvoiceVersion version); + Task GetLatestVersionNumberAsync(Guid invoiceId); + Task> GetVersionsAsync(Guid invoiceId); void Update(Models.Invoice invoice); } diff --git a/Distribution/Features/Invoice/InvoiceController.cs b/Distribution/Features/Invoice/InvoiceController.cs index b67b22b..1cc9d80 100644 --- a/Distribution/Features/Invoice/InvoiceController.cs +++ b/Distribution/Features/Invoice/InvoiceController.cs @@ -52,6 +52,29 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } + [HttpGet("{id:guid}/versions")] + public async Task GetVersions(Guid id) + { + var result = await service.GetVersionsAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("{id:guid}/items")] + public async Task UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct) + { + // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware + // (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. + var userId = currentUser.UserId ?? request.EditedByUserId; + if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" }); + try + { + var result = await service.UpdateItemsAsync(id, request, userId.Value, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + [HttpPost("{id:guid}/print")] public async Task Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct) { diff --git a/Distribution/Features/Invoice/InvoiceDTOs.cs b/Distribution/Features/Invoice/InvoiceDTOs.cs index 51b4466..0966786 100644 --- a/Distribution/Features/Invoice/InvoiceDTOs.cs +++ b/Distribution/Features/Invoice/InvoiceDTOs.cs @@ -3,10 +3,35 @@ namespace Distribution.Features.Invoice; public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null); -public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null); +public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null); public record FinalizeInvoiceRequest(List? Adjustments = null, Guid? FinalizedByUserId = null); public record PrintInvoiceRequest(Guid? PrintedByUserId = null); +// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line. +public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false); +public record UpdateInvoiceItemsRequest(List Items, Guid? EditedByUserId = null); + +public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved); + +public record InvoiceVersionResponse( + Guid Id, + int VersionNumber, + InvoiceVersionStage Stage, + decimal TotalAmount, + Guid CreatedBy, + DateTime CreatedAt, + List Items); + +public record InvoiceItemResponse( + Guid Id, + Guid? ShopPOItemId, + Guid ItemId, + string ItemName, + int? OrderedQuantity, + int Quantity, + decimal UnitPrice, + bool IsRemoved); + public record InvoiceResponse( Guid Id, Guid ShopPOId, @@ -22,6 +47,7 @@ public record InvoiceResponse( decimal TotalAmount, decimal PaidAmount, bool IsActive, - DateTime CreatedAt); + DateTime CreatedAt, + List Items); public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount); diff --git a/Distribution/Features/Invoice/InvoiceRepository.cs b/Distribution/Features/Invoice/InvoiceRepository.cs index cfc6e4d..482f4ed 100644 --- a/Distribution/Features/Invoice/InvoiceRepository.cs +++ b/Distribution/Features/Invoice/InvoiceRepository.cs @@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository await context.Invoices .Include(i => i.ShopPO).ThenInclude(p => p.Store) .Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive)) + .Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item) + .Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem) .Include(i => i.Payments.Where(p => p.IsActive)) .FirstOrDefaultAsync(i => i.Id == id && i.IsActive); @@ -52,6 +54,27 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository public async Task AddAsync(Models.Invoice invoice) => await context.Invoices.AddAsync(invoice); + public async Task GetItemAsync(Guid itemId) => + await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive); + + public async Task AddItemsAsync(IEnumerable items) => + await context.InvoiceItems.AddRangeAsync(items); + + public async Task AddVersionAsync(InvoiceVersion version) => + await context.InvoiceVersions.AddAsync(version); + + public async Task GetLatestVersionNumberAsync(Guid invoiceId) => + await context.InvoiceVersions + .Where(v => v.FK_InvoiceId == invoiceId) + .MaxAsync(v => (int?)v.VersionNumber) ?? 0; + + public async Task> GetVersionsAsync(Guid invoiceId) => + await context.InvoiceVersions + .Include(v => v.Items).ThenInclude(i => i.Item) + .Where(v => v.FK_InvoiceId == invoiceId && v.IsActive) + .OrderBy(v => v.VersionNumber) + .ToListAsync(); + public void Update(Models.Invoice invoice) => context.Invoices.Update(invoice); } diff --git a/Distribution/Features/Invoice/InvoiceService.cs b/Distribution/Features/Invoice/InvoiceService.cs index 85bdf03..bed5e68 100644 --- a/Distribution/Features/Invoice/InvoiceService.cs +++ b/Distribution/Features/Invoice/InvoiceService.cs @@ -21,7 +21,61 @@ public class InvoiceService( i.FinalizedAt, i.FinalizedBy, i.TotalAmount, i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m, - i.IsActive, i.CreatedAt); + i.IsActive, i.CreatedAt, + i.Items.Where(x => x.IsActive).Select(x => + new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId, + x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity, + x.Quantity, x.UnitPrice, x.IsRemoved)).ToList()); + + private static decimal BillableTotal(IEnumerable items) => + items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice); + + // Immutable copy of the invoice lines as they stand right now. + public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable items, int versionNumber, InvoiceVersionStage stage, Guid userId) => + new() + { + FK_InvoiceId = invoice.Id, + VersionNumber = versionNumber, + Stage = stage, + TotalAmount = invoice.TotalAmount, + CreatedBy = userId, + Items = items.Select(i => new InvoiceVersionItem + { + FK_ShopPOItemId = i.FK_ShopPOItemId, + FK_ItemId = i.FK_ItemId, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice, + IsRemoved = i.IsRemoved + }).ToList() + }; + + public async Task?> GetVersionsAsync(Guid id) + { + if (await repo.GetByIdAsync(id) is null) return null; + var versions = await repo.GetVersionsAsync(id); + return versions.Select(v => new InvoiceVersionResponse( + v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt, + v.Items.Select(i => new InvoiceVersionItemResponse( + i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList(); + } + + private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable? addedLines = null) + { + var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1; + // Added lines may or may not be in invoice.Items yet, so merge by reference. + var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct(); + await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId)); + } + + public static List BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) => + po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem + { + FK_InvoiceId = invoiceId, + FK_ShopPOItemId = i.Id, + FK_ItemId = i.FK_ItemId, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice + }).ToList(); public async Task GetByIdAsync(Guid id) { @@ -69,6 +123,9 @@ public class InvoiceService( try { await repo.AddAsync(invoice); + var lines = BuildItemsFromShopPO(po, invoice.Id); + await repo.AddItemsAsync(lines); + await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy)); po.InvoiceId = invoice.Id; shopPoRepo.Update(po); await uow.CommitAsync(ct); @@ -79,6 +136,85 @@ public class InvoiceService( return ToResponse(saved!); } + public async Task UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default) + { + var invoice = await repo.GetByIdAsync(id) + ?? throw new KeyNotFoundException("Invoice not found"); + + if (invoice.Status != InvoiceStatus.Draft) + throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})"); + + var newLines = new List(); + + foreach (var edit in request.Items) + { + if (edit.UnitPrice is < 0) + throw new InvalidOperationException("Unit price cannot be negative"); + + if (edit.InvoiceItemId is Guid lineId) + { + // Existing line: edit quantity/price, or strike it (IsRemoved) / restore it. + var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive) + ?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice"); + + item.IsRemoved = edit.IsRemoved; + if (!edit.IsRemoved) + { + ValidateQuantity(item, edit.Quantity); + item.Quantity = edit.Quantity; + } + if (edit.UnitPrice is decimal price) item.UnitPrice = price; + } + else if (edit.ItemId is Guid itemId) + { + // New line for an item that was not on the PO. + if (edit.IsRemoved) continue; + if (edit.Quantity <= 0) + throw new InvalidOperationException("Quantity for an added item must be greater than zero"); + if (edit.UnitPrice is null) + throw new InvalidOperationException("Unit price is required for an added item"); + if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId)) + throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead"); + + var catalogItem = await repo.GetItemAsync(itemId) + ?? throw new InvalidOperationException($"Item {itemId} not found"); + + newLines.Add(new InvoiceItem + { + FK_InvoiceId = invoice.Id, + FK_ItemId = catalogItem.Id, + Item = catalogItem, + Quantity = edit.Quantity, + UnitPrice = edit.UnitPrice.Value + }); + } + else + throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)"); + } + + invoice.TotalAmount = BillableTotal(invoice.Items.Concat(newLines)); + + await uow.BeginAsync(ct); + try + { + repo.Update(invoice); + if (newLines.Count > 0) await repo.AddItemsAsync(newLines); + await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse((await repo.GetByIdAsync(id))!); + } + + private static void ValidateQuantity(InvoiceItem item, int quantity) + { + var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue; + if (quantity < 0 || quantity > ordered) + throw new InvalidOperationException( + $"Quantity for item {item.Id} must be between 0 and {ordered}"); + } + public async Task PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default) { var invoice = await repo.GetByIdAsync(id) @@ -92,7 +228,12 @@ public class InvoiceService( invoice.PrintedBy = printedBy; await uow.BeginAsync(ct); - try { repo.Update(invoice); await uow.CommitAsync(ct); } + try + { + repo.Update(invoice); + await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy); + await uow.CommitAsync(ct); + } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(invoice); @@ -106,31 +247,37 @@ public class InvoiceService( if (invoice.Status != InvoiceStatus.Printed) throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status"); + var items = invoice.Items.Where(i => i.IsActive).ToList(); var adjustments = request.Adjustments ?? []; - if (adjustments.Count > 0) + + // Agent's handwritten edits from the printed copy are written onto the invoice lines. + foreach (var adjustment in adjustments) { - var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList(); - decimal total = 0; - foreach (var item in poItems) + var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId) + ?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice"); + + item.IsRemoved = adjustment.IsRemoved; + if (!adjustment.IsRemoved) { - var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id); - var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity; - - if (billedQuantity < 0 || billedQuantity > item.Quantity) - throw new InvalidOperationException( - $"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}"); - - total += billedQuantity * item.UnitPrice; + ValidateQuantity(item, adjustment.AdjustedQuantity); + item.Quantity = adjustment.AdjustedQuantity; } - invoice.TotalAmount = total; } + if (items.Count > 0) + invoice.TotalAmount = BillableTotal(items); + invoice.Status = InvoiceStatus.Finalized; invoice.FinalizedAt = DateTime.Now; invoice.FinalizedBy = finalizedBy; await uow.BeginAsync(ct); - try { repo.Update(invoice); await uow.CommitAsync(ct); } + try + { + repo.Update(invoice); + await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy); + await uow.CommitAsync(ct); + } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(invoice);