feat: add preview totals endpoint and DTOs for invoice calculations
This commit is contained in:
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user