Files
ERP_Distribution/Distribution/Features/Expense/ExpenseService.cs
T
Kalana B. Thilakarathna c775146b64
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m39s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m1s
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.
2026-10-22 17:47:49 +05:30

270 lines
11 KiB
C#

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)
}
}