using Distribution.Features.ShopPO; using Distribution.Features.Stock; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.Invoice; public class InvoiceService( IInvoiceRepository repo, IShopPORepository shopPoRepo, IUnitOfWork uow, ICurrentUserService currentUser) { private static InvoiceResponse ToResponse(Models.Invoice i) => new(i.Id, i.FK_ShopPOId, i.ShopPO?.FK_StoreId ?? Guid.Empty, i.ShopPO?.Store?.StoreName ?? string.Empty, i.Status, i.PaymentStatus, i.CreatedBy, i.PrintedAt, i.PrintedBy, i.FinalizedAt, i.FinalizedBy, i.TotalAmount, i.DiscountAmount, i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m, 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); // TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change // and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks. private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable items) { var billable = BillableTotal(items); invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable); invoice.TotalAmount = billable - invoice.DiscountAmount; } // 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) { var invoice = await repo.GetByIdAsync(id); return invoice is null ? null : ToResponse(invoice); } public async Task> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) { var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus); var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize); return new PagedResponse(page, pageSize, total, items.Select(ToResponse).ToList()); } public async Task GetOutstandingSummaryAsync() { var invoices = await repo.GetOutstandingAsync(); var count = invoices.Count; var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount)); return new OutstandingPaymentsResponse(count, outstanding); } public async Task CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default) { var po = await shopPoRepo.GetByIdAsync(request.ShopPOId) ?? throw new KeyNotFoundException("Shop PO not found"); if (po.ConfirmedAt is null) throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created"); if (po.Status == ShopPOStatus.Cancelled) throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO"); if (po.InvoiceId is not null) throw new InvalidOperationException("An invoice already exists for this Shop PO"); var invoice = new Models.Invoice { FK_ShopPOId = po.Id, Status = InvoiceStatus.Draft, PaymentStatus = PaymentStatus.Pending, CreatedBy = createdBy }; var lines = BuildItemsFromShopPO(po, invoice.Id); await ApplyTotalsAsync(invoice, lines); await uow.BeginAsync(ct); try { await repo.AddAsync(invoice); 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); } catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(invoice.Id); 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)"); } await ApplyTotalsAsync(invoice, 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) ?? throw new KeyNotFoundException("Invoice not found"); if (invoice.Status != InvoiceStatus.Draft) throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status"); // Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit. var lines = invoice.Items.Where(i => i.IsActive).ToList(); if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines); invoice.Status = InvoiceStatus.Printed; invoice.PrintedAt = DateTime.Now; invoice.PrintedBy = printedBy; await uow.BeginAsync(ct); try { repo.Update(invoice); await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(invoice); } public async Task FinalizeAsync(Guid id, FinalizeInvoiceRequest request, Guid finalizedBy, CancellationToken ct = default) { var invoice = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Invoice not found"); 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 ?? []; // Agent's handwritten edits from the printed copy are written onto the invoice lines. foreach (var adjustment in adjustments) { 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) { ValidateQuantity(item, adjustment.AdjustedQuantity); item.Quantity = adjustment.AdjustedQuantity; } } if (items.Count > 0) await ApplyTotalsAsync(invoice, items); invoice.Status = InvoiceStatus.Finalized; invoice.FinalizedAt = DateTime.Now; invoice.FinalizedBy = finalizedBy; await uow.BeginAsync(ct); try { repo.Update(invoice); await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(invoice); } }