diff --git a/Distribution/Features/Invoice/InvoiceService.cs b/Distribution/Features/Invoice/InvoiceService.cs new file mode 100644 index 0000000..04cb088 --- /dev/null +++ b/Distribution/Features/Invoice/InvoiceService.cs @@ -0,0 +1,203 @@ +using Distribution.Features.Item; +using Distribution.Features.Order; +using Distribution.Features.Returns; +using Distribution.Infra.Authorization; +using Distribution.Infra.UoW; +using Distribution.Models; +using Microsoft.Extensions.Configuration; + +namespace Distribution.Features.Invoice; + +public class InvoiceService( + IInvoiceRepository repo, + IOrderRepository orderRepo, + IReturnRepository returnRepo, + IItemRepository itemRepo, + IUnitOfWork uow, + ICurrentUserService currentUser, + IConfiguration configuration) +{ + private static InvoiceResponse ToResponse(Models.Invoice i) => + new(i.Id, i.FK_OrderId, i.Status, i.CreatedByUserId, i.PrintedAt, i.PrintedByUserId, + i.FinalizedAt, i.FinalizedByUserId, i.TotalAmount, i.IsActive, i.CreatedAt); + + private bool IsModeB => + configuration.GetValue("OrderInvoice:InvoiceCreationMode") != "A"; + + public async Task GetByIdAsync(Guid id) + { + var invoice = await repo.GetByIdAsync(id); + return invoice is null || !invoice.IsActive ? null : ToResponse(invoice); + } + + /// Mode B only — creates the Invoice (Draft) for an already-Confirmed PO as a + /// separate action. Mode A creates the Invoice atomically via + /// OrderService.ConfirmAndCreateInvoiceAsync instead. + public async Task CreateAsync(CreateInvoiceRequest request, CancellationToken ct = default) + { + if (!IsModeB) + throw new InvalidOperationException("POST /api/invoices is not enabled — this system uses InvoiceCreationMode A, which creates the Invoice as part of PO confirmation."); + + var order = await orderRepo.GetByIdAsync(request.OrderId); + if (order is null || !order.IsActive) + throw new InvalidOperationException("Order not found."); + if (order.ConfirmedAt is null) + throw new InvalidOperationException("Only a Confirmed PO can have an Invoice created for it."); + if (order.InvoiceId is not null) + throw new InvalidOperationException("An Invoice already exists for this PO."); + + var invoice = new Models.Invoice + { + FK_OrderId = order.Id, + Status = InvoiceStatus.Draft, + CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."), + TotalAmount = order.TotalAmount, + }; + order.InvoiceId = invoice.Id; + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(invoice); + orderRepo.Update(order); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + + return ToResponse(invoice); + } + + /// Draft → Printed. The agent takes the physical copy to the field and may make + /// handwritten edits during the ride. + public async Task PrintAsync(Guid id, CancellationToken ct = default) + { + var invoice = await repo.GetByIdAsync(id); + if (invoice is null || !invoice.IsActive) return null; + if (invoice.Status != InvoiceStatus.Draft) + throw new InvalidOperationException("Only a Draft invoice can be printed."); + + invoice.Status = InvoiceStatus.Printed; + invoice.PrintedAt = DateTime.Now; + invoice.PrintedByUserId = currentUser.UserId; + + await uow.BeginAsync(ct); + try + { + repo.Update(invoice); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + + return ToResponse(invoice); + } + + /// Printed → Finalized. The office worker enters whatever the agent handwrote on the + /// returned printed copy — delivered/free quantities per line, returns/damage (reusing the + /// SaleReturn/CreditNote shape from 18_returns_claims.md), and an optional invoice-level + /// discount. Payload shape resolved in order_invoice_lifecycle.md Open Question 6. + public async Task FinalizeAsync(Guid id, FinalizeInvoiceRequest request, CancellationToken ct = default) + { + var invoice = await repo.GetByIdAsync(id); + if (invoice is null || !invoice.IsActive) return null; + if (invoice.Status != InvoiceStatus.Printed) + throw new InvalidOperationException("An invoice can only be finalized from Printed — Draft → Finalized directly is not a valid transition."); + if (request.Items is null || request.Items.Count == 0) + throw new InvalidOperationException("Finalize must include at least one line item."); + + var order = await orderRepo.GetByIdAsync(invoice.FK_OrderId) + ?? throw new InvalidOperationException("Order not found for this Invoice."); + var orderItems = (await orderRepo.GetItemsByOrderIdAsync(order.Id)).ToDictionary(oi => oi.Id); + + decimal subtotal = 0; + foreach (var line in request.Items) + { + if (!orderItems.TryGetValue(line.OrderItemId, out var orderItem)) + throw new InvalidOperationException($"OrderItem {line.OrderItemId} does not belong to this Invoice's PO."); + if (line.DeliveredQuantity < 0 || line.FreeQuantity < 0) + throw new InvalidOperationException("Delivered/free quantity cannot be negative."); + + subtotal += line.DeliveredQuantity * orderItem.UnitPrice; // freeQuantity never bills + orderItem.QuantityFulfilled = (int)(line.DeliveredQuantity + line.FreeQuantity); + } + + Models.SaleReturn? saleReturn = null; + decimal creditNoteAmount = 0; + if (request.Returns is { Count: > 0 }) + { + saleReturn = new Models.SaleReturn + { + FK_OrderId = order.Id, + Mode = ReturnMode.CreditDeduction, + CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."), + }; + + foreach (var line in request.Returns) + { + if (line.Quantity <= 0) + throw new InvalidOperationException("Return quantity must be greater than zero."); + + var matchingOrderItem = orderItems.Values.FirstOrDefault(oi => oi.FK_ItemId == line.ItemId); + var unitPrice = matchingOrderItem?.UnitPrice + ?? (await itemRepo.GetByIdAsync(line.ItemId))?.SalePrice + ?? 0; + + saleReturn.Items.Add(new SaleReturnItem + { + FK_ItemId = line.ItemId, + Quantity = line.Quantity, + ReasonCode = line.ReasonCode, + UnitPrice = unitPrice, + }); + } + + creditNoteAmount = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice); + saleReturn.TotalCreditAmount = creditNoteAmount; + } + + decimal discountAmount = request.Discount switch + { + { Type: DiscountType.Amount } d => d.Value, + { Type: DiscountType.Percentage } d => subtotal * d.Value / 100m, + null => 0, + _ => 0, + }; + + invoice.TotalAmount = Math.Max(0, subtotal - discountAmount - creditNoteAmount); + invoice.Status = InvoiceStatus.Finalized; + invoice.FinalizedAt = DateTime.Now; + invoice.FinalizedByUserId = currentUser.UserId; + order.TotalAmount = invoice.TotalAmount; + + await uow.BeginAsync(ct); + try + { + repo.Update(invoice); + orderRepo.Update(order); + if (saleReturn is not null) + { + await returnRepo.AddAsync(saleReturn); + await returnRepo.AddCreditNoteAsync(new CreditNote + { + FK_SaleReturnId = saleReturn.Id, + Amount = creditNoteAmount, + }); + } + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + + return ToResponse(invoice); + } +}