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);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expense-categories")]
|
||||
public class ExpenseCategoryController(ExpenseCategoryService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> Tree() => Ok(await service.GetTreeAsync());
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Create(CreateExpenseCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public record ExpenseCategoryResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Code,
|
||||
Guid? ParentCategoryId,
|
||||
bool IsActive,
|
||||
List<ExpenseCategoryResponse> Children);
|
||||
|
||||
public record CreateExpenseCategoryRequest(
|
||||
string Name,
|
||||
string? Code,
|
||||
Guid? ParentCategoryId);
|
||||
|
||||
public record UpdateExpenseCategoryRequest(
|
||||
string Name,
|
||||
string? Code,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,19 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public class ExpenseCategoryRepository(AppDBContext context) : IExpenseCategoryRepository
|
||||
{
|
||||
public async Task<Models.ExpenseCategory?> GetByIdAsync(Guid id) =>
|
||||
await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<List<Models.ExpenseCategory>> GetAllAsync() =>
|
||||
await context.ExpenseCategories.OrderBy(c => c.FK_ParentCategoryId).ThenBy(c => c.Name).ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.ExpenseCategory entity) =>
|
||||
await context.ExpenseCategories.AddAsync(entity);
|
||||
|
||||
public void Update(Models.ExpenseCategory entity) =>
|
||||
context.ExpenseCategories.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public class ExpenseCategoryService(IExpenseCategoryRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
public async Task<List<ExpenseCategoryResponse>> GetTreeAsync()
|
||||
{
|
||||
var all = await repo.GetAllAsync();
|
||||
var byParent = all.ToLookup(c => c.FK_ParentCategoryId);
|
||||
|
||||
return all
|
||||
.Where(c => c.FK_ParentCategoryId is null)
|
||||
.Select(c => Map(c, byParent))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static ExpenseCategoryResponse Map(Models.ExpenseCategory c, ILookup<Guid?, Models.ExpenseCategory> byParent) =>
|
||||
new(c.Id, c.Name, c.Code, c.FK_ParentCategoryId, c.IsActive,
|
||||
byParent[c.Id].Select(child => Map(child, byParent)).ToList());
|
||||
|
||||
public async Task<ExpenseCategoryResponse> CreateAsync(CreateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
throw new InvalidOperationException("Name is required");
|
||||
|
||||
if (request.ParentCategoryId is { } parentId)
|
||||
{
|
||||
var parent = await repo.GetByIdAsync(parentId)
|
||||
?? throw new InvalidOperationException("Parent category not found");
|
||||
if (parent.FK_ParentCategoryId is not null)
|
||||
throw new InvalidOperationException("Only two levels are allowed — cannot nest under a title");
|
||||
}
|
||||
|
||||
var entity = new Models.ExpenseCategory
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim(),
|
||||
FK_ParentCategoryId = request.ParentCategoryId
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||
}
|
||||
|
||||
public async Task<ExpenseCategoryResponse?> UpdateAsync(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim();
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public interface IExpenseCategoryRepository
|
||||
{
|
||||
Task<Models.ExpenseCategory?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.ExpenseCategory>> GetAllAsync();
|
||||
Task AddAsync(Models.ExpenseCategory entity);
|
||||
void Update(Models.ExpenseCategory entity);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expense-vendors")]
|
||||
public class ExpenseVendorController(ExpenseVendorService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50) =>
|
||||
Ok(await service.ListAsync(search, page, pageSize));
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Create(CreateExpenseVendorRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public record ExpenseVendorResponse(Guid Id, string Name, string? ContactInfo, bool IsActive);
|
||||
|
||||
public record CreateExpenseVendorRequest(string Name, string? ContactInfo);
|
||||
|
||||
public record UpdateExpenseVendorRequest(string Name, string? ContactInfo, bool IsActive);
|
||||
@@ -0,0 +1,31 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public class ExpenseVendorRepository(AppDBContext context) : IExpenseVendorRepository
|
||||
{
|
||||
public async Task<Models.ExpenseVendor?> GetByIdAsync(Guid id) =>
|
||||
await context.ExpenseVendors.FirstOrDefaultAsync(v => v.Id == id);
|
||||
|
||||
public async Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize) =>
|
||||
await context.ExpenseVendors
|
||||
.Where(v => v.IsActive && (search == null || v.Name.Contains(search)))
|
||||
.OrderBy(v => v.Name)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> CountAsync(string? search) =>
|
||||
await context.ExpenseVendors
|
||||
.CountAsync(v => v.IsActive && (search == null || v.Name.Contains(search)));
|
||||
|
||||
public async Task<bool> NameExistsAsync(string name, Guid? excludeId) =>
|
||||
await context.ExpenseVendors.AnyAsync(v => v.Name == name && (excludeId == null || v.Id != excludeId));
|
||||
|
||||
public async Task AddAsync(Models.ExpenseVendor entity) =>
|
||||
await context.ExpenseVendors.AddAsync(entity);
|
||||
|
||||
public void Update(Models.ExpenseVendor entity) =>
|
||||
context.ExpenseVendors.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public record PagedExpenseVendorsResponse(List<ExpenseVendorResponse> Items, int Page, int PageSize, int Total);
|
||||
|
||||
public class ExpenseVendorService(IExpenseVendorRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private static ExpenseVendorResponse Map(Models.ExpenseVendor v) =>
|
||||
new(v.Id, v.Name, v.ContactInfo, v.IsActive);
|
||||
|
||||
public async Task<PagedExpenseVendorsResponse> ListAsync(string? search, int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 50;
|
||||
|
||||
search = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
|
||||
var items = await repo.ListAsync(search, page, pageSize);
|
||||
var total = await repo.CountAsync(search);
|
||||
return new PagedExpenseVendorsResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ExpenseVendorResponse> CreateAsync(CreateExpenseVendorRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = (request.Name ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||
if (await repo.NameExistsAsync(name, null))
|
||||
throw new InvalidOperationException("A vendor with this name already exists");
|
||||
|
||||
var entity = new Models.ExpenseVendor { Name = name, ContactInfo = request.ContactInfo };
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
public async Task<ExpenseVendorResponse?> UpdateAsync(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
|
||||
var name = (request.Name ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||
if (await repo.NameExistsAsync(name, id))
|
||||
throw new InvalidOperationException("A vendor with this name already exists");
|
||||
|
||||
entity.Name = name;
|
||||
entity.ContactInfo = request.ContactInfo;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public interface IExpenseVendorRepository
|
||||
{
|
||||
Task<Models.ExpenseVendor?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize);
|
||||
Task<int> CountAsync(string? search);
|
||||
Task<bool> NameExistsAsync(string name, Guid? excludeId);
|
||||
Task AddAsync(Models.ExpenseVendor entity);
|
||||
void Update(Models.ExpenseVendor entity);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public interface IRecurringExpenseRepository
|
||||
{
|
||||
Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize);
|
||||
Task<int> CountAsync();
|
||||
Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf);
|
||||
Task AddAsync(Models.RecurringExpenseTemplate entity);
|
||||
void Update(Models.RecurringExpenseTemplate entity);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public record ToggleRequest(bool Paused);
|
||||
|
||||
[ApiController]
|
||||
[Route("api/recurring-expenses")]
|
||||
public class RecurringExpenseController(RecurringExpenseService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20) =>
|
||||
Ok(await service.ListAsync(page, pageSize));
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> Create(CreateRecurringExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/toggle")]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> Toggle(Guid id, ToggleRequest body, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ToggleAsync(id, body.Paused, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("run-due")]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> RunDue(CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.RunDueAsync(ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public record CreateRecurringExpenseRequest(
|
||||
string Name,
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
Guid? VendorId,
|
||||
decimal Amount,
|
||||
string? Payee,
|
||||
string? Description,
|
||||
RecurringFrequency Frequency,
|
||||
int? DayOfMonth,
|
||||
int? DayOfWeek,
|
||||
DateTime StartDate,
|
||||
DateTime? EndDate,
|
||||
bool AutoPost);
|
||||
|
||||
public record UpdateRecurringExpenseRequest(
|
||||
string Name,
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
Guid? VendorId,
|
||||
decimal Amount,
|
||||
string? Payee,
|
||||
string? Description,
|
||||
RecurringFrequency Frequency,
|
||||
int? DayOfMonth,
|
||||
int? DayOfWeek,
|
||||
DateTime StartDate,
|
||||
DateTime? EndDate,
|
||||
bool AutoPost);
|
||||
|
||||
public record RecurringExpenseResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
Guid CategoryId,
|
||||
string CategoryName,
|
||||
Guid? WarehouseId,
|
||||
string? WarehouseName,
|
||||
Guid? VendorId,
|
||||
string? VendorName,
|
||||
decimal Amount,
|
||||
string? Payee,
|
||||
string? Description,
|
||||
RecurringFrequency Frequency,
|
||||
int? DayOfMonth,
|
||||
int? DayOfWeek,
|
||||
DateTime StartDate,
|
||||
DateTime? EndDate,
|
||||
DateTime NextRunDate,
|
||||
bool AutoPost,
|
||||
bool IsPaused,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record PagedRecurringExpensesResponse(List<RecurringExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||
|
||||
public record RunDueResponse(int Generated, List<Guid> ExpenseIds);
|
||||
@@ -0,0 +1,39 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public class RecurringExpenseRepository(AppDBContext context) : IRecurringExpenseRepository
|
||||
{
|
||||
private IQueryable<Models.RecurringExpenseTemplate> Base() =>
|
||||
context.RecurringExpenseTemplates
|
||||
.Include(t => t.Category)
|
||||
.Include(t => t.Warehouse)
|
||||
.Include(t => t.Vendor);
|
||||
|
||||
public async Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id) =>
|
||||
await Base().FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
public async Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize) =>
|
||||
await Base()
|
||||
.Where(t => t.IsActive)
|
||||
.OrderBy(t => t.IsPaused).ThenBy(t => t.NextRunDate)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> CountAsync() =>
|
||||
await context.RecurringExpenseTemplates.CountAsync(t => t.IsActive);
|
||||
|
||||
public async Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf) =>
|
||||
await Base()
|
||||
.Where(t => t.IsActive && !t.IsPaused && t.NextRunDate <= asOf
|
||||
&& (t.EndDate == null || t.EndDate >= asOf))
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.RecurringExpenseTemplate entity) =>
|
||||
await context.RecurringExpenseTemplates.AddAsync(entity);
|
||||
|
||||
public void Update(Models.RecurringExpenseTemplate entity) =>
|
||||
context.RecurringExpenseTemplates.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public class RecurringExpenseService(
|
||||
AppDBContext context,
|
||||
IRecurringExpenseRepository repo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static RecurringExpenseResponse Map(Models.RecurringExpenseTemplate t) =>
|
||||
new(t.Id, t.Name,
|
||||
t.FK_CategoryId, t.Category?.Name ?? string.Empty,
|
||||
t.FK_WarehouseId, t.Warehouse?.Name,
|
||||
t.FK_VendorId, t.Vendor?.Name,
|
||||
t.Amount, t.Payee, t.Description,
|
||||
t.Frequency, t.DayOfMonth, t.DayOfWeek,
|
||||
t.StartDate, t.EndDate, t.NextRunDate, t.AutoPost, t.IsPaused, t.IsActive, t.CreatedAt);
|
||||
|
||||
public async Task<PagedRecurringExpensesResponse> ListAsync(int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||
var items = await repo.ListAsync(page, pageSize);
|
||||
var total = await repo.CountAsync();
|
||||
return new PagedRecurringExpensesResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<RecurringExpenseResponse> CreateAsync(CreateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
var category = await context.ExpenseCategories.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");
|
||||
|
||||
var entity = new Models.RecurringExpenseTemplate
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
FK_CategoryId = request.CategoryId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
FK_VendorId = request.VendorId,
|
||||
Amount = request.Amount,
|
||||
Payee = request.Payee,
|
||||
Description = request.Description,
|
||||
Frequency = request.Frequency,
|
||||
DayOfMonth = request.DayOfMonth,
|
||||
DayOfWeek = request.DayOfWeek,
|
||||
StartDate = request.StartDate.Date,
|
||||
EndDate = request.EndDate?.Date,
|
||||
NextRunDate = request.StartDate.Date,
|
||||
AutoPost = request.AutoPost
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map((await repo.GetByIdAsync(entity.Id))!);
|
||||
}
|
||||
|
||||
public async Task<RecurringExpenseResponse?> UpdateAsync(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
|
||||
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.FK_CategoryId = request.CategoryId;
|
||||
entity.FK_WarehouseId = request.WarehouseId;
|
||||
entity.FK_VendorId = request.VendorId;
|
||||
entity.Amount = request.Amount;
|
||||
entity.Payee = request.Payee;
|
||||
entity.Description = request.Description;
|
||||
entity.Frequency = request.Frequency;
|
||||
entity.DayOfMonth = request.DayOfMonth;
|
||||
entity.DayOfWeek = request.DayOfWeek;
|
||||
entity.StartDate = request.StartDate.Date;
|
||||
entity.EndDate = request.EndDate?.Date;
|
||||
entity.AutoPost = request.AutoPost;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<RecurringExpenseResponse?> ToggleAsync(Guid id, bool paused, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
entity.IsPaused = paused;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes every active, non-paused template whose NextRunDate ≤ today
|
||||
/// into an Expense row, then advances NextRunDate by one period. Idempotent
|
||||
/// per call — advances only after a successful insert.
|
||||
/// </summary>
|
||||
public async Task<RunDueResponse> RunDueAsync(CancellationToken ct = default)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var due = await repo.ListDueAsync(today);
|
||||
if (due.Count == 0) return new RunDueResponse(0, []);
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
var createdIds = new List<Guid>();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var t in due)
|
||||
{
|
||||
var expense = new Models.Expense
|
||||
{
|
||||
FK_CategoryId = t.FK_CategoryId,
|
||||
FK_WarehouseId = t.FK_WarehouseId,
|
||||
FK_VendorId = t.FK_VendorId,
|
||||
Amount = t.Amount,
|
||||
BillDate = t.NextRunDate,
|
||||
PeriodCovered = new DateTime(t.NextRunDate.Year, t.NextRunDate.Month, 1),
|
||||
Payee = t.Payee,
|
||||
Description = t.Description,
|
||||
Status = t.AutoPost ? ExpenseStatus.Posted : ExpenseStatus.Draft,
|
||||
RecordedByUserId = userId,
|
||||
PostedByUserId = t.AutoPost ? userId : null,
|
||||
PostedAt = t.AutoPost ? DateTime.Now : null,
|
||||
FK_RecurringTemplateId = t.Id
|
||||
};
|
||||
await context.Expenses.AddAsync(expense, ct);
|
||||
createdIds.Add(expense.Id);
|
||||
|
||||
t.NextRunDate = AdvanceNext(t.NextRunDate, t.Frequency);
|
||||
repo.Update(t);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return new RunDueResponse(createdIds.Count, createdIds);
|
||||
}
|
||||
|
||||
private static DateTime AdvanceNext(DateTime from, RecurringFrequency freq) => freq switch
|
||||
{
|
||||
RecurringFrequency.Weekly => from.AddDays(7),
|
||||
RecurringFrequency.Monthly => from.AddMonths(1),
|
||||
RecurringFrequency.Quarterly => from.AddMonths(3),
|
||||
RecurringFrequency.Yearly => from.AddYears(1),
|
||||
_ => throw new InvalidOperationException($"Unknown frequency {freq}")
|
||||
};
|
||||
|
||||
private static void ValidateSchedule(RecurringFrequency freq, int? dayOfMonth, int? dayOfWeek)
|
||||
{
|
||||
if (freq == RecurringFrequency.Weekly)
|
||||
{
|
||||
if (dayOfWeek is null or < 0 or > 6)
|
||||
throw new InvalidOperationException("DayOfWeek (0-6) is required for Weekly");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (dayOfMonth is null or < 1 or > 31)
|
||||
throw new InvalidOperationException("DayOfMonth (1-31) is required for Monthly/Quarterly/Yearly");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user