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); } }