10.
Implementation A partner, not just software HexDive Technologies' local team handles setup, data migration, training and ongoing support — in Sinhala, Tamil and English. [confirm] 1 Discovery Map your network, products and processes. 2 Configuration Set up depots, pricing, schemes, routes and users. 3 Migration & training Move your data and train every team. 4 Go-live & support Dedicated account manager and local support desk.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
[ApiController]
|
||||
public class ExpenseAttachmentController(ExpenseAttachmentService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/expenses/{expenseId:guid}/attachments")]
|
||||
[Authorize("expense.create")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> Upload(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UploadAsync(expenseId, file, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("api/expense-attachments/{id:guid}/download")]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await service.DownloadAsync(id, ct);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.Stream, result.Value.ContentType, result.Value.FileName);
|
||||
}
|
||||
|
||||
[HttpDelete("api/expense-attachments/{id:guid}")]
|
||||
[Authorize("expense.create")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||
await service.DeleteAsync(id, ct) ? NoContent() : NotFound();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public class ExpenseAttachmentService(
|
||||
AppDBContext context,
|
||||
IConfiguration config,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private const long MaxBytes = 10 * 1024 * 1024;
|
||||
private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
|
||||
private static readonly string[] AllowedContentTypes =
|
||||
["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
|
||||
private string AttachmentsRoot =>
|
||||
config["Expense:AttachmentsRoot"]
|
||||
?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured");
|
||||
|
||||
public async Task<ExpenseAttachmentResponse?> UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct);
|
||||
if (expense is null) return null;
|
||||
|
||||
if (file is null || file.Length == 0)
|
||||
throw new InvalidOperationException("File is empty");
|
||||
if (file.Length > MaxBytes)
|
||||
throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
throw new InvalidOperationException($"File type '{ext}' is not allowed");
|
||||
if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
|
||||
throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
var now = DateTime.Now;
|
||||
var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2"));
|
||||
var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir);
|
||||
Directory.CreateDirectory(absoluteDir);
|
||||
|
||||
var storedName = $"{Guid.NewGuid()}{ext}";
|
||||
var absolutePath = Path.Combine(absoluteDir, storedName);
|
||||
|
||||
await using (var stream = File.Create(absolutePath))
|
||||
{
|
||||
await file.CopyToAsync(stream, ct);
|
||||
}
|
||||
|
||||
var attachment = new Models.ExpenseAttachment
|
||||
{
|
||||
FK_ExpenseId = expenseId,
|
||||
OriginalFileName = file.FileName,
|
||||
StoredPath = absolutePath,
|
||||
ContentType = file.ContentType,
|
||||
SizeBytes = file.Length,
|
||||
UploadedByUserId = userId
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await context.ExpenseAttachments.AddAsync(attachment, ct);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
try { File.Delete(absolutePath); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName,
|
||||
attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct)
|
||||
{
|
||||
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||
if (a is null) return null;
|
||||
if (!File.Exists(a.StoredPath)) return null;
|
||||
|
||||
var stream = File.OpenRead(a.StoredPath);
|
||||
return (stream, a.ContentType, a.OriginalFileName);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid attachmentId, CancellationToken ct)
|
||||
{
|
||||
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||
if (a is null) return false;
|
||||
|
||||
a.IsActive = false;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// Best-effort file removal — keep DB soft-delete even if the file is gone.
|
||||
try { File.Delete(a.StoredPath); } catch { }
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expenses")]
|
||||
public class ExpenseController(
|
||||
ExpenseService service,
|
||||
IPermissionService permissions,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
private async Task<bool> HasAsync(string code)
|
||||
{
|
||||
if (currentUser.RoleId is not { } roleId) return false;
|
||||
return await permissions.HasPermissionAsync(roleId, code);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] ExpenseStatus? status,
|
||||
[FromQuery] Guid? categoryId,
|
||||
[FromQuery] Guid? warehouseId,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20) =>
|
||||
Ok(await service.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var result = await service.GetAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create endpoint takes a required ?action= query to drive the 4 form buttons:
|
||||
/// draft → Save draft; post → Save & post (requires expense.post); pay → Save & pay
|
||||
/// (requires expense.post + expense.pay). Draft is the default.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize("expense.create")]
|
||||
public async Task<IActionResult> Create(
|
||||
CreateExpenseRequest request,
|
||||
[FromQuery] ExpenseAction action = ExpenseAction.Draft,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (action is ExpenseAction.Post or ExpenseAction.Pay && !await HasAsync("expense.post"))
|
||||
return Forbid();
|
||||
if (action == ExpenseAction.Pay && !await HasAsync("expense.pay"))
|
||||
return Forbid();
|
||||
|
||||
try { return Ok(await service.CreateAsync(request, action, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.create")]
|
||||
public async Task<IActionResult> UpdateDraft(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateDraftAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/post")]
|
||||
[Authorize("expense.post")]
|
||||
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.PostAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/pay")]
|
||||
[Authorize("expense.pay")]
|
||||
public async Task<IActionResult> Pay(Guid id, ExpensePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.PayAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/void")]
|
||||
[Authorize("expense.void")]
|
||||
public async Task<IActionResult> Void(Guid id, VoidExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.VoidAsync(id, request?.Reason, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public record ExpensePaymentRequest(
|
||||
PaymentMethod Method,
|
||||
Guid? BankAccountId,
|
||||
Guid? ChequePageId);
|
||||
|
||||
public record CreateExpenseRequest(
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
decimal Amount,
|
||||
DateTime BillDate,
|
||||
DateTime? DueDate,
|
||||
DateTime? PeriodCovered,
|
||||
Guid? VendorId,
|
||||
string? Payee,
|
||||
string? BillRef,
|
||||
string? Description,
|
||||
/// <summary>Required when action = Pay. Ignored for Draft / Post.</summary>
|
||||
ExpensePaymentRequest? Payment);
|
||||
|
||||
public record UpdateExpenseDraftRequest(
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
decimal Amount,
|
||||
DateTime BillDate,
|
||||
DateTime? DueDate,
|
||||
DateTime? PeriodCovered,
|
||||
Guid? VendorId,
|
||||
string? Payee,
|
||||
string? BillRef,
|
||||
string? Description);
|
||||
|
||||
public record VoidExpenseRequest(string? Reason);
|
||||
|
||||
public record ExpenseAttachmentResponse(
|
||||
Guid Id,
|
||||
string OriginalFileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record ExpenseResponse(
|
||||
Guid Id,
|
||||
Guid CategoryId,
|
||||
string CategoryName,
|
||||
Guid? ParentCategoryId,
|
||||
string? ParentCategoryName,
|
||||
Guid? WarehouseId,
|
||||
string? WarehouseName,
|
||||
decimal Amount,
|
||||
DateTime BillDate,
|
||||
DateTime? DueDate,
|
||||
DateTime? PeriodCovered,
|
||||
Guid? VendorId,
|
||||
string? VendorName,
|
||||
string? Payee,
|
||||
string? BillRef,
|
||||
string? Description,
|
||||
ExpenseStatus Status,
|
||||
PaymentMethod? Method,
|
||||
Guid? BankAccountId,
|
||||
Guid? ChequePageId,
|
||||
string? ChequeNo,
|
||||
DateTime? PaidAt,
|
||||
Guid RecordedByUserId,
|
||||
Guid? PostedByUserId,
|
||||
Guid? PaidByUserId,
|
||||
Guid? VoidedByUserId,
|
||||
string? VoidReason,
|
||||
Guid? RecurringTemplateId,
|
||||
List<ExpenseAttachmentResponse> Attachments,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record PagedExpensesResponse(List<ExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||
@@ -0,0 +1,50 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public class ExpenseRepository(AppDBContext context) : IExpenseRepository
|
||||
{
|
||||
private IQueryable<Models.Expense> Base() =>
|
||||
context.Expenses
|
||||
.Include(e => e.Category!).ThenInclude(c => c.Parent)
|
||||
.Include(e => e.Warehouse)
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ChequePage)
|
||||
.Include(e => e.Attachments.Where(a => a.IsActive));
|
||||
|
||||
public async Task<Models.Expense?> GetByIdAsync(Guid id) =>
|
||||
await Base().FirstOrDefaultAsync(e => e.Id == id && e.IsActive);
|
||||
|
||||
private IQueryable<Models.Expense> Filtered(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||
Base()
|
||||
.Where(e => e.IsActive)
|
||||
.Where(e => !status.HasValue || e.Status == status)
|
||||
.Where(e => !categoryId.HasValue
|
||||
|| e.FK_CategoryId == categoryId
|
||||
|| e.Category!.FK_ParentCategoryId == categoryId)
|
||||
.Where(e => !warehouseId.HasValue || e.FK_WarehouseId == warehouseId)
|
||||
.Where(e => !from.HasValue || e.BillDate >= from)
|
||||
.Where(e => !to.HasValue || e.BillDate <= to);
|
||||
|
||||
public async Task<List<Models.Expense>> ListAsync(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||
int page, int pageSize) =>
|
||||
await Filtered(status, categoryId, warehouseId, from, to)
|
||||
.OrderByDescending(e => e.BillDate)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> CountAsync(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||
await Filtered(status, categoryId, warehouseId, from, to).CountAsync();
|
||||
|
||||
public async Task AddAsync(Models.Expense entity) =>
|
||||
await context.Expenses.AddAsync(entity);
|
||||
|
||||
public void Update(Models.Expense entity) =>
|
||||
context.Expenses.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public class ExpenseService(
|
||||
AppDBContext context,
|
||||
IExpenseRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IChequeBookRepository chequeBookRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) =>
|
||||
new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt);
|
||||
|
||||
private static ExpenseResponse ToResponse(Models.Expense e) =>
|
||||
new(e.Id,
|
||||
e.FK_CategoryId, e.Category?.Name ?? string.Empty,
|
||||
e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name,
|
||||
e.FK_WarehouseId, e.Warehouse?.Name,
|
||||
e.Amount, e.BillDate, e.DueDate, e.PeriodCovered,
|
||||
e.FK_VendorId, e.Vendor?.Name,
|
||||
e.Payee, e.BillRef, e.Description,
|
||||
e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo,
|
||||
e.PaidAt,
|
||||
e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason,
|
||||
e.FK_RecurringTemplateId,
|
||||
e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(),
|
||||
e.CreatedAt);
|
||||
|
||||
public async Task<PagedExpensesResponse> ListAsync(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||
int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||
|
||||
var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize);
|
||||
var total = await repo.CountAsync(status, categoryId, warehouseId, from, to);
|
||||
return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> GetAsync(Guid id)
|
||||
{
|
||||
var e = await repo.GetByIdAsync(id);
|
||||
return e is null ? null : ToResponse(e);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse> CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default)
|
||||
{
|
||||
ValidateBase(request);
|
||||
|
||||
var category = await context.ExpenseCategories.Include(c => c.Parent)
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||
?? throw new InvalidOperationException("Category not found");
|
||||
if (category.FK_ParentCategoryId is null)
|
||||
throw new InvalidOperationException("Pick a specific title under a category, not the category itself");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
var entity = new Models.Expense
|
||||
{
|
||||
FK_CategoryId = request.CategoryId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
Amount = request.Amount,
|
||||
BillDate = request.BillDate.Date,
|
||||
DueDate = request.DueDate?.Date,
|
||||
PeriodCovered = request.PeriodCovered?.Date,
|
||||
FK_VendorId = request.VendorId,
|
||||
Payee = request.Payee,
|
||||
BillRef = request.BillRef,
|
||||
Description = request.Description,
|
||||
RecordedByUserId = userId,
|
||||
Status = ExpenseStatus.Draft
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(entity);
|
||||
|
||||
if (action is ExpenseAction.Post or ExpenseAction.Pay)
|
||||
{
|
||||
entity.Status = ExpenseStatus.Posted;
|
||||
entity.PostedByUserId = userId;
|
||||
entity.PostedAt = DateTime.Now;
|
||||
}
|
||||
|
||||
if (action == ExpenseAction.Pay)
|
||||
{
|
||||
if (request.Payment is null)
|
||||
throw new InvalidOperationException("Payment details are required when action = Pay");
|
||||
await ApplyPaymentAsync(entity, request.Payment, userId, ct);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(entity.Id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status != ExpenseStatus.Draft)
|
||||
throw new InvalidOperationException("Only Draft expenses can be edited");
|
||||
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
entity.FK_CategoryId = request.CategoryId;
|
||||
entity.FK_WarehouseId = request.WarehouseId;
|
||||
entity.Amount = request.Amount;
|
||||
entity.BillDate = request.BillDate.Date;
|
||||
entity.DueDate = request.DueDate?.Date;
|
||||
entity.PeriodCovered = request.PeriodCovered?.Date;
|
||||
entity.FK_VendorId = request.VendorId;
|
||||
entity.Payee = request.Payee;
|
||||
entity.BillRef = request.BillRef;
|
||||
entity.Description = request.Description;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> PostAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status != ExpenseStatus.Draft)
|
||||
throw new InvalidOperationException("Only Draft expenses can be posted");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
entity.Status = ExpenseStatus.Posted;
|
||||
entity.PostedByUserId = userId;
|
||||
entity.PostedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status != ExpenseStatus.Posted)
|
||||
throw new InvalidOperationException("Only Posted expenses can be paid");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await ApplyPaymentAsync(entity, payment, userId, ct);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> VoidAsync(Guid id, string? reason, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status == ExpenseStatus.Void)
|
||||
throw new InvalidOperationException("Already voided");
|
||||
if (entity.Status == ExpenseStatus.Paid)
|
||||
throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
entity.Status = ExpenseStatus.Void;
|
||||
entity.VoidedByUserId = userId;
|
||||
entity.VoidedAt = DateTime.Now;
|
||||
entity.VoidReason = reason;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static void ValidateBase(CreateExpenseRequest request)
|
||||
{
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date)
|
||||
throw new InvalidOperationException("Due date cannot be earlier than bill date");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account);
|
||||
/// Cheque → consume an Unused ChequePage from an Active cheque book.
|
||||
/// </summary>
|
||||
private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct)
|
||||
{
|
||||
if (payment.Method == PaymentMethod.Credit)
|
||||
throw new InvalidOperationException("Credit is not a valid method for expense payments");
|
||||
|
||||
if (payment.Method == PaymentMethod.Cash)
|
||||
{
|
||||
if (payment.BankAccountId is null)
|
||||
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||
|
||||
var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value)
|
||||
?? throw new InvalidOperationException("BankAccountId does not reference a valid account");
|
||||
if (account.IsActive is false)
|
||||
throw new InvalidOperationException("The selected account is inactive");
|
||||
|
||||
entity.FK_BankAccountId = account.Id;
|
||||
}
|
||||
else // Cheque
|
||||
{
|
||||
if (payment.ChequePageId is null)
|
||||
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
|
||||
|
||||
var page = await context.ChequePages
|
||||
.Include(p => p.ChequeBook)
|
||||
.FirstOrDefaultAsync(p => p.Id == payment.ChequePageId.Value, ct)
|
||||
?? throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Unused)
|
||||
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
|
||||
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
|
||||
throw new InvalidOperationException("The cheque book is not active");
|
||||
|
||||
page.IssueStatus = ChequePageIssueStatus.Issued;
|
||||
page.PayeeType = PayeeType.Other;
|
||||
page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense";
|
||||
page.IssueDate = DateTime.Now;
|
||||
page.Amount = entity.Amount;
|
||||
page.CurrencyCode = "LKR";
|
||||
page.PrintedAt = DateTime.Now;
|
||||
|
||||
chequeBookRepo.UpdatePage(page);
|
||||
entity.FK_ChequePageId = page.Id;
|
||||
entity.ChequePage = page;
|
||||
}
|
||||
|
||||
entity.Method = payment.Method;
|
||||
entity.Status = ExpenseStatus.Paid;
|
||||
entity.PaidByUserId = userId;
|
||||
entity.PaidAt = DateTime.Now;
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Expense (category)
|
||||
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public interface IExpenseRepository
|
||||
{
|
||||
Task<Models.Expense?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Expense>> ListAsync(
|
||||
ExpenseStatus? status,
|
||||
Guid? categoryId,
|
||||
Guid? warehouseId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int page,
|
||||
int pageSize);
|
||||
Task<int> CountAsync(
|
||||
ExpenseStatus? status,
|
||||
Guid? categoryId,
|
||||
Guid? warehouseId,
|
||||
DateTime? from,
|
||||
DateTime? to);
|
||||
Task AddAsync(Models.Expense entity);
|
||||
void Update(Models.Expense entity);
|
||||
}
|
||||
Reference in New Issue
Block a user