Merge dev into feat-notification
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -36,6 +36,22 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest 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.CreatedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/print")]
|
||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Distribution.Features.Invoice;
|
||||
|
||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
||||
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
@@ -10,6 +10,7 @@ public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
public record InvoiceResponse(
|
||||
Guid Id,
|
||||
Guid ShopPOId,
|
||||
Guid StoreId,
|
||||
string StoreName,
|
||||
InvoiceStatus Status,
|
||||
PaymentStatus PaymentStatus,
|
||||
@@ -19,6 +20,7 @@ public record InvoiceResponse(
|
||||
DateTime? FinalizedAt,
|
||||
Guid? FinalizedBy,
|
||||
decimal TotalAmount,
|
||||
decimal PaidAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||
.Where(i => i.IsActive
|
||||
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
||||
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -7,16 +8,20 @@ namespace Distribution.Features.Invoice;
|
||||
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IShopPORepository shopPoRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||
new(i.Id, i.FK_ShopPOId,
|
||||
i.ShopPO?.FK_StoreId ?? Guid.Empty,
|
||||
i.ShopPO?.Store?.StoreName ?? string.Empty,
|
||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||
i.PrintedAt, i.PrintedBy,
|
||||
i.FinalizedAt, i.FinalizedBy,
|
||||
i.TotalAmount, i.IsActive, i.CreatedAt);
|
||||
i.TotalAmount,
|
||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
||||
i.IsActive, i.CreatedAt);
|
||||
|
||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
@@ -39,6 +44,41 @@ public class InvoiceService(
|
||||
return new OutstandingPaymentsResponse(count, outstanding);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
|
||||
{
|
||||
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.ConfirmedAt is null)
|
||||
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
|
||||
if (po.InvoiceId is not null)
|
||||
throw new InvalidOperationException("An invoice already exists for this Shop PO");
|
||||
|
||||
var invoice = new Models.Invoice
|
||||
{
|
||||
FK_ShopPOId = po.Id,
|
||||
Status = InvoiceStatus.Draft,
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
CreatedBy = createdBy,
|
||||
TotalAmount = po.TotalAmount
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(invoice);
|
||||
po.InvoiceId = invoice.Id;
|
||||
shopPoRepo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(invoice.Id);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.StoreCredit;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -12,6 +13,7 @@ public class PaymentService(
|
||||
IInvoiceRepository invoiceRepo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IReceivedChequeRepository receivedChequeRepo,
|
||||
IStoreCreditMovementRepository creditRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
@@ -34,12 +36,16 @@ public class PaymentService(
|
||||
{
|
||||
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
|
||||
if (invoice is null || !invoice.IsActive) return null;
|
||||
if (invoice.Status == InvoiceStatus.Finalized)
|
||||
throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
|
||||
|
||||
if (request.Amount <= 0)
|
||||
throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
var existingPaid = (await repo.GetByInvoiceIdAsync(invoiceId)).Sum(p => p.Amount);
|
||||
var remaining = invoice.TotalAmount - existingPaid;
|
||||
if (request.Amount > remaining)
|
||||
throw new InvalidOperationException(
|
||||
$"Amount exceeds the outstanding balance of {remaining:0.##}");
|
||||
|
||||
if (request.Method == PaymentMethod.Cheque &&
|
||||
(string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
|
||||
throw new InvalidOperationException("ChequeNumber and ChequeDate are required when Method is Cheque");
|
||||
@@ -47,6 +53,19 @@ public class PaymentService(
|
||||
if (request.Method == PaymentMethod.Cash && request.BankAccountId is null)
|
||||
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||
|
||||
if (request.Method == PaymentMethod.Credit)
|
||||
{
|
||||
var store = invoice.ShopPO?.Store
|
||||
?? throw new InvalidOperationException("Invoice is missing the owning store; cannot record a credit payment");
|
||||
var currentOutstanding = await creditRepo.GetOutstandingForStoreAsync(store.Id);
|
||||
if (currentOutstanding + request.Amount > store.CreditLimit)
|
||||
{
|
||||
var available = store.CreditLimit - currentOutstanding;
|
||||
throw new InvalidOperationException(
|
||||
$"Credit limit exceeded. Available credit: {available:0.##}");
|
||||
}
|
||||
}
|
||||
|
||||
Models.BankAccount? bankAccount = null;
|
||||
if (request.BankAccountId is not null)
|
||||
{
|
||||
@@ -107,9 +126,22 @@ public class PaymentService(
|
||||
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
|
||||
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
||||
if (request.Method == PaymentMethod.Credit)
|
||||
{
|
||||
var movement = new StoreCreditMovement
|
||||
{
|
||||
FK_StoreId = invoice.ShopPO!.Store!.Id,
|
||||
Direction = StoreCreditMovementDirection.Usage,
|
||||
Amount = request.Amount,
|
||||
FK_InvoiceId = invoice.Id,
|
||||
FK_PaymentId = payment.Id,
|
||||
OccurredAt = DateTime.Now,
|
||||
RecordedBy = recorderId
|
||||
};
|
||||
await creditRepo.AddAsync(movement);
|
||||
}
|
||||
|
||||
var total = existingPaid + request.Amount;
|
||||
invoice.PaymentStatus = total == 0
|
||||
? PaymentStatus.Pending
|
||||
: total < invoice.TotalAmount
|
||||
|
||||
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("confirmed-without-invoice")]
|
||||
public async Task<IActionResult> GetConfirmedWithoutInvoice(
|
||||
[FromQuery] Guid? agentId,
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? shopId)
|
||||
{
|
||||
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -65,6 +65,13 @@ public class ShopPOService(
|
||||
return ToResponse(po, users);
|
||||
}
|
||||
|
||||
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||
{
|
||||
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
||||
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
||||
return items.Select(p => ToResponse(p, users)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
||||
{
|
||||
if (request.Items.Count == 0)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.StoreCredit;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications)
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
private const int CreditApproverRoleLevel = 20;
|
||||
@@ -181,7 +182,8 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
|
||||
var outstanding = await creditRepo.GetOutstandingForStoreAsync(id);
|
||||
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, outstanding, store.CreditLimit - outstanding);
|
||||
}
|
||||
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.StoreCredit;
|
||||
|
||||
public interface IStoreCreditMovementRepository
|
||||
{
|
||||
Task<decimal> GetOutstandingForStoreAsync(Guid storeId);
|
||||
Task<List<StoreCreditMovement>> GetByStoreAsync(Guid storeId, int page, int pageSize);
|
||||
Task<int> GetCountByStoreAsync(Guid storeId);
|
||||
Task AddAsync(StoreCreditMovement movement);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.StoreCredit;
|
||||
|
||||
public class StoreCreditMovementRepository(AppDBContext context) : IStoreCreditMovementRepository
|
||||
{
|
||||
public async Task<decimal> GetOutstandingForStoreAsync(Guid storeId)
|
||||
{
|
||||
var usage = await context.StoreCreditMovements
|
||||
.Where(m => m.IsActive && m.FK_StoreId == storeId && m.Direction == StoreCreditMovementDirection.Usage)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||
|
||||
var settlements = await context.StoreCreditMovements
|
||||
.Where(m => m.IsActive && m.FK_StoreId == storeId && m.Direction == StoreCreditMovementDirection.Settlement)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||
|
||||
return usage - settlements;
|
||||
}
|
||||
|
||||
public async Task<List<StoreCreditMovement>> GetByStoreAsync(Guid storeId, int page, int pageSize) =>
|
||||
await context.StoreCreditMovements
|
||||
.Where(m => m.IsActive && m.FK_StoreId == storeId)
|
||||
.OrderByDescending(m => m.OccurredAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountByStoreAsync(Guid storeId) =>
|
||||
await context.StoreCreditMovements
|
||||
.CountAsync(m => m.IsActive && m.FK_StoreId == storeId);
|
||||
|
||||
public async Task AddAsync(StoreCreditMovement movement) =>
|
||||
await context.StoreCreditMovements.AddAsync(movement);
|
||||
}
|
||||
Reference in New Issue
Block a user