Invoice handling with versioning and item updates

This commit is contained in:
2026-10-21 12:41:20 +05:30
parent 82be251878
commit 48f607d623
5 changed files with 242 additions and 18 deletions
@@ -10,5 +10,10 @@ public interface IInvoiceRepository
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus); Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync(); Task<List<Models.Invoice>> GetOutstandingAsync();
Task AddAsync(Models.Invoice invoice); Task AddAsync(Models.Invoice invoice);
Task<Models.Item?> GetItemAsync(Guid itemId);
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
Task AddVersionAsync(InvoiceVersion version);
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
void Update(Models.Invoice invoice); void Update(Models.Invoice invoice);
} }
@@ -52,6 +52,29 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
[HttpGet("{id:guid}/versions")]
public async Task<IActionResult> GetVersions(Guid id)
{
var result = await service.GetVersionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> 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")] [HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct) public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{ {
+28 -2
View File
@@ -3,10 +3,35 @@
namespace Distribution.Features.Invoice; namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null); 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<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null); public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = 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<InvoiceItemEdit> 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<InvoiceVersionItemResponse> Items);
public record InvoiceItemResponse(
Guid Id,
Guid? ShopPOItemId,
Guid ItemId,
string ItemName,
int? OrderedQuantity,
int Quantity,
decimal UnitPrice,
bool IsRemoved);
public record InvoiceResponse( public record InvoiceResponse(
Guid Id, Guid Id,
Guid ShopPOId, Guid ShopPOId,
@@ -22,6 +47,7 @@ public record InvoiceResponse(
decimal TotalAmount, decimal TotalAmount,
decimal PaidAmount, decimal PaidAmount,
bool IsActive, bool IsActive,
DateTime CreatedAt); DateTime CreatedAt,
List<InvoiceItemResponse> Items);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount); public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
await context.Invoices await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store) .Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive)) .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)) .Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.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) => public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice); await context.Invoices.AddAsync(invoice);
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
await context.InvoiceItems.AddRangeAsync(items);
public async Task AddVersionAsync(InvoiceVersion version) =>
await context.InvoiceVersions.AddAsync(version);
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
await context.InvoiceVersions
.Where(v => v.FK_InvoiceId == invoiceId)
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
public async Task<List<InvoiceVersion>> 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) => public void Update(Models.Invoice invoice) =>
context.Invoices.Update(invoice); context.Invoices.Update(invoice);
} }
+163 -16
View File
@@ -21,7 +21,61 @@ public class InvoiceService(
i.FinalizedAt, i.FinalizedBy, i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.TotalAmount,
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m, 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<InvoiceItem> 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<InvoiceItem> 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<List<InvoiceVersionResponse>?> 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<InvoiceItem>? 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<InvoiceItem> 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<InvoiceResponse?> GetByIdAsync(Guid id) public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{ {
@@ -69,6 +123,9 @@ public class InvoiceService(
try try
{ {
await repo.AddAsync(invoice); 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; po.InvoiceId = invoice.Id;
shopPoRepo.Update(po); shopPoRepo.Update(po);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
@@ -79,6 +136,85 @@ public class InvoiceService(
return ToResponse(saved!); return ToResponse(saved!);
} }
public async Task<InvoiceResponse> 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<InvoiceItem>();
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<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default) public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{ {
var invoice = await repo.GetByIdAsync(id) var invoice = await repo.GetByIdAsync(id)
@@ -92,7 +228,12 @@ public class InvoiceService(
invoice.PrintedBy = printedBy; invoice.PrintedBy = printedBy;
await uow.BeginAsync(ct); 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; } catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice); return ToResponse(invoice);
@@ -106,31 +247,37 @@ public class InvoiceService(
if (invoice.Status != InvoiceStatus.Printed) if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status"); throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
var items = invoice.Items.Where(i => i.IsActive).ToList();
var adjustments = request.Adjustments ?? []; 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(); var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
decimal total = 0; ?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
foreach (var item in poItems)
item.IsRemoved = adjustment.IsRemoved;
if (!adjustment.IsRemoved)
{ {
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id); ValidateQuantity(item, adjustment.AdjustedQuantity);
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity; item.Quantity = adjustment.AdjustedQuantity;
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;
} }
invoice.TotalAmount = total;
} }
if (items.Count > 0)
invoice.TotalAmount = BillableTotal(items);
invoice.Status = InvoiceStatus.Finalized; invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now; invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedBy = finalizedBy; invoice.FinalizedBy = finalizedBy;
await uow.BeginAsync(ct); 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; } catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice); return ToResponse(invoice);