Add invoice service
This commit is contained in:
@@ -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<string>("OrderInvoice:InvoiceCreationMode") != "A";
|
||||
|
||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
return invoice is null || !invoice.IsActive ? null : ToResponse(invoice);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<InvoiceResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>Draft → Printed. The agent takes the physical copy to the field and may make
|
||||
/// handwritten edits during the ride.</summary>
|
||||
public async Task<InvoiceResponse?> 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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<InvoiceResponse?> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user