diff --git a/Distribution/Features/Invoice/InvoiceController.cs b/Distribution/Features/Invoice/InvoiceController.cs index 1cc9d80..ac91186 100644 --- a/Distribution/Features/Invoice/InvoiceController.cs +++ b/Distribution/Features/Invoice/InvoiceController.cs @@ -52,6 +52,13 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } + [HttpPost("{id:guid}/preview-totals")] + public async Task PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request) + { + var result = await service.PreviewTotalsAsync(id, request); + return result is null ? NotFound() : Ok(result); + } + [HttpGet("{id:guid}/versions")] public async Task GetVersions(Guid id) { diff --git a/Distribution/Features/Invoice/InvoiceDTOs.cs b/Distribution/Features/Invoice/InvoiceDTOs.cs index 70673de..224c66e 100644 --- a/Distribution/Features/Invoice/InvoiceDTOs.cs +++ b/Distribution/Features/Invoice/InvoiceDTOs.cs @@ -1,4 +1,5 @@ -using Distribution.Models; +using Distribution.Features.ShopPO; +using Distribution.Models; 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. public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false); -public record UpdateInvoiceItemsRequest(List Items, Guid? EditedByUserId = null); +// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is. +public record UpdateInvoiceItemsRequest(List Items, Guid? EditedByUserId = null, List? DiscountSchemeIds = null); + +public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null); +public record PreviewInvoiceTotalsRequest(List Items, List? 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 FreeEntitlements); 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 Quantity, decimal UnitPrice, - bool IsRemoved); + bool IsRemoved, + bool IsFree = false, + Guid? SchemeId = null, + string? SchemeName = null); public record InvoiceResponse( Guid Id, @@ -49,6 +60,7 @@ public record InvoiceResponse( decimal PaidAmount, bool IsActive, DateTime CreatedAt, - List Items); + List Items, + List? Discounts = null); public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount); diff --git a/Distribution/Features/Invoice/InvoiceService.cs b/Distribution/Features/Invoice/InvoiceService.cs index a8ce1a8..07af3e8 100644 --- a/Distribution/Features/Invoice/InvoiceService.cs +++ b/Distribution/Features/Invoice/InvoiceService.cs @@ -1,4 +1,5 @@ -using Distribution.Features.ShopPO; +using Distribution.Features.Scheme; +using Distribution.Features.ShopPO; using Distribution.Features.Stock; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; @@ -9,6 +10,7 @@ namespace Distribution.Features.Invoice; public class InvoiceService( IInvoiceRepository repo, IShopPORepository shopPoRepo, + SchemeService schemeService, IUnitOfWork uow, ICurrentUserService currentUser) { @@ -25,20 +27,53 @@ public class InvoiceService( 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()); + x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList()); private static decimal BillableTotal(IEnumerable items) => 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 - // and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks. + // The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the + // scheme rules ignore them. + private static List DiscountableLines(IEnumerable 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 items) { - var billable = BillableTotal(items); - invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable); + var lines = items.ToList(); + // 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; } + // Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing. + public async Task 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. public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable items, int versionNumber, InvoiceVersionStage stage, Guid userId) => new() @@ -83,13 +118,17 @@ public class InvoiceService( FK_ShopPOItemId = i.Id, FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, - UnitPrice = i.UnitPrice + UnitPrice = i.UnitPrice, + IsFree = i.IsFree, + FK_SchemeId = i.FK_SchemeId, + SchemeName = i.SchemeName }).ToList(); public async Task GetByIdAsync(Guid 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> 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); 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) { @@ -201,7 +245,10 @@ public class InvoiceService( 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); try @@ -213,7 +260,7 @@ public class InvoiceService( } catch { await uow.RollbackAsync(ct); throw; } - return ToResponse((await repo.GetByIdAsync(id))!); + return (await GetByIdAsync(id))!; } private static void ValidateQuantity(InvoiceItem item, int quantity)