feat: add preview totals endpoint and DTOs for invoice calculations

This commit is contained in:
2026-10-22 16:24:09 +05:30
parent 2c39b34c5f
commit 5b2b39b7bf
3 changed files with 81 additions and 15 deletions
@@ -52,6 +52,13 @@ 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 }); }
} }
[HttpPost("{id:guid}/preview-totals")]
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
{
var result = await service.PreviewTotalsAsync(id, request);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/versions")] [HttpGet("{id:guid}/versions")]
public async Task<IActionResult> GetVersions(Guid id) public async Task<IActionResult> GetVersions(Guid id)
{ {
+16 -4
View File
@@ -1,4 +1,5 @@
using Distribution.Models; using Distribution.Features.ShopPO;
using Distribution.Models;
namespace Distribution.Features.Invoice; namespace Distribution.Features.Invoice;
@@ -9,7 +10,14 @@ public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line. // 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 InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null); // DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
// Quantity = how many free units the scheme still entitles for the lines as typed.
public record FreeEntitlement(Guid SchemeId, int Quantity);
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved); public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
@@ -30,7 +38,10 @@ public record InvoiceItemResponse(
int? OrderedQuantity, int? OrderedQuantity,
int Quantity, int Quantity,
decimal UnitPrice, decimal UnitPrice,
bool IsRemoved); bool IsRemoved,
bool IsFree = false,
Guid? SchemeId = null,
string? SchemeName = null);
public record InvoiceResponse( public record InvoiceResponse(
Guid Id, Guid Id,
@@ -49,6 +60,7 @@ public record InvoiceResponse(
decimal PaidAmount, decimal PaidAmount,
bool IsActive, bool IsActive,
DateTime CreatedAt, DateTime CreatedAt,
List<InvoiceItemResponse> Items); List<InvoiceItemResponse> Items,
List<ShopPODiscountResponse>? Discounts = null);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount); public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
+58 -11
View File
@@ -1,4 +1,5 @@
using Distribution.Features.ShopPO; using Distribution.Features.Scheme;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -9,6 +10,7 @@ namespace Distribution.Features.Invoice;
public class InvoiceService( public class InvoiceService(
IInvoiceRepository repo, IInvoiceRepository repo,
IShopPORepository shopPoRepo, IShopPORepository shopPoRepo,
SchemeService schemeService,
IUnitOfWork uow, IUnitOfWork uow,
ICurrentUserService currentUser) ICurrentUserService currentUser)
{ {
@@ -25,20 +27,53 @@ public class InvoiceService(
i.Items.Where(x => x.IsActive).Select(x => i.Items.Where(x => x.IsActive).Select(x =>
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId, new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity, x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
x.Quantity, x.UnitPrice, x.IsRemoved)).ToList()); x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) => private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice); 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 // The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
// and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks. // scheme rules ignore them.
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved)
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
.ToList();
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
// and at print/finalize. The redemption amount is updated with it.
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items) private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
{ {
var billable = BillableTotal(items); var lines = items.ToList();
invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable); // Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
var billable = BillableTotal(lines);
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
invoice.DiscountAmount = Math.Min(discount, billable);
invoice.TotalAmount = billable - invoice.DiscountAmount; invoice.TotalAmount = billable - invoice.DiscountAmount;
} }
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null;
var lines = request.Items.Where(l => !l.IsRemoved)
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
.ToList();
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
var chosen = request.DiscountSchemeIds is null
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
var discount = Math.Min(chosen, billable);
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
}
// Immutable copy of the invoice lines as they stand right now. // 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) => public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
new() new()
@@ -83,13 +118,17 @@ public class InvoiceService(
FK_ShopPOItemId = i.Id, FK_ShopPOItemId = i.Id,
FK_ItemId = i.FK_ItemId, FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity, Quantity = i.Quantity,
UnitPrice = i.UnitPrice UnitPrice = i.UnitPrice,
IsFree = i.IsFree,
FK_SchemeId = i.FK_SchemeId,
SchemeName = i.SchemeName
}).ToList(); }).ToList();
public async Task<InvoiceResponse?> GetByIdAsync(Guid id) public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{ {
var invoice = await repo.GetByIdAsync(id); var invoice = await repo.GetByIdAsync(id);
return invoice is null ? null : ToResponse(invoice); if (invoice is null) return null;
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
} }
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
@@ -172,7 +211,12 @@ public class InvoiceService(
ValidateQuantity(item, edit.Quantity); ValidateQuantity(item, edit.Quantity);
item.Quantity = edit.Quantity; item.Quantity = edit.Quantity;
} }
if (edit.UnitPrice is decimal price) item.UnitPrice = price; if (edit.UnitPrice is decimal price)
{
if (item.IsFree && price != 0m)
throw new InvalidOperationException("A free-issue line must stay at zero price");
item.UnitPrice = price;
}
} }
else if (edit.ItemId is Guid itemId) else if (edit.ItemId is Guid itemId)
{ {
@@ -201,7 +245,10 @@ public class InvoiceService(
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)"); throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
} }
await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines)); var allLines = invoice.Items.Concat(newLines).ToList();
await schemeService.ApplyInvoiceDiscountSelectionAsync(
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
await ApplyTotalsAsync(invoice, allLines);
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
@@ -213,7 +260,7 @@ public class InvoiceService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!); return (await GetByIdAsync(id))!;
} }
private static void ValidateQuantity(InvoiceItem item, int quantity) private static void ValidateQuantity(InvoiceItem item, int quantity)