c775146b64
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.
270 lines
11 KiB
C#
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)
|
|
}
|
|
}
|