using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using System.ComponentModel.DataAnnotations; namespace PowderCoating.Web.Controllers; /// /// Manages the fixed asset register. Tracks depreciable assets (ovens, blast cabinets, /// vehicles, etc.) using straight-line depreciation. PostDepreciation auto-generates /// Journal Entries for a selected month, crediting Accumulated Depreciation and debiting /// Depreciation Expense. /// [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public class FixedAssetsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; private readonly IAccountBalanceService _accountBalanceService; private readonly ILogger _logger; public FixedAssetsController( IUnitOfWork unitOfWork, ITenantContext tenantContext, UserManager userManager, IAccountBalanceService accountBalanceService, ILogger logger) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; _userManager = userManager; _accountBalanceService = accountBalanceService; _logger = logger; } /// Lists all fixed assets for the current company with depreciation summary. [HttpGet] public async Task Index() { var assets = await _unitOfWork.FixedAssets.FindAsync( fa => true, false, fa => fa.AssetAccount, fa => fa.DepreciationExpenseAccount, fa => fa.AccumDepreciationAccount); ViewBag.TotalCost = assets.Sum(a => a.PurchaseCost); ViewBag.TotalAccumDeprec = assets.Sum(a => a.AccumulatedDepreciation); ViewBag.TotalBookValue = assets.Sum(a => a.BookValue); ViewBag.ActiveCount = assets.Count(a => !a.IsDisposed); return View(assets.OrderBy(a => a.PurchaseDate).ToList()); } [HttpGet] public async Task Details(int id) { var asset = await _unitOfWork.FixedAssets.GetByIdAsync( id, false, fa => fa.AssetAccount, fa => fa.DepreciationExpenseAccount, fa => fa.AccumDepreciationAccount); if (asset == null) return NotFound(); var entries = await _unitOfWork.FixedAssetDepreciationEntries.FindAsync( e => e.FixedAssetId == id, false, e => e.JournalEntry); ViewBag.Entries = entries.OrderByDescending(e => e.PeriodYear).ThenByDescending(e => e.PeriodMonth).ToList(); ViewBag.MonthsRemaining = Math.Max(0, asset.UsefulLifeMonths - entries.Count(e => e.Amount > 0)); ViewBag.FullyDepreciated = asset.AccumulatedDepreciation >= (asset.PurchaseCost - asset.SalvageValue); return View(asset); } [HttpGet] public async Task Create() { await PopulateAccountsAsync(); return View(new FixedAssetVm { PurchaseDate = DateTime.Today }); } [HttpPost, ValidateAntiForgeryToken] public async Task Create(FixedAssetVm vm) { if (!ModelState.IsValid) { await PopulateAccountsAsync(); return View(vm); } var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var asset = new FixedAsset { Name = vm.Name, Description = vm.Description, PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc), PurchaseCost = vm.PurchaseCost, SalvageValue = vm.SalvageValue, UsefulLifeMonths = vm.UsefulLifeMonths, AccumulatedDepreciation = vm.AccumulatedDepreciation, AssetAccountId = vm.AssetAccountId, DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId, AccumDepreciationAccountId = vm.AccumDepreciationAccountId, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.FixedAssets.AddAsync(asset); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Fixed asset \"{asset.Name}\" added."; return RedirectToAction(nameof(Details), new { id = asset.Id }); } [HttpGet] public async Task Edit(int id) { var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id); if (asset == null) return NotFound(); await PopulateAccountsAsync(); return View(new FixedAssetVm { Id = asset.Id, Name = asset.Name, Description = asset.Description, PurchaseDate = asset.PurchaseDate.ToLocalTime(), PurchaseCost = asset.PurchaseCost, SalvageValue = asset.SalvageValue, UsefulLifeMonths = asset.UsefulLifeMonths, AccumulatedDepreciation = asset.AccumulatedDepreciation, AssetAccountId = asset.AssetAccountId, DepreciationExpenseAccountId = asset.DepreciationExpenseAccountId, AccumDepreciationAccountId = asset.AccumDepreciationAccountId, IsDisposed = asset.IsDisposed, DisposalDate = asset.DisposalDate?.ToLocalTime() }); } [HttpPost, ValidateAntiForgeryToken] public async Task Edit(int id, FixedAssetVm vm) { if (id != vm.Id) return BadRequest(); if (!ModelState.IsValid) { await PopulateAccountsAsync(); return View(vm); } var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id); if (asset == null) return NotFound(); asset.Name = vm.Name; asset.Description = vm.Description; asset.PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc); asset.PurchaseCost = vm.PurchaseCost; asset.SalvageValue = vm.SalvageValue; asset.UsefulLifeMonths = vm.UsefulLifeMonths; asset.AccumulatedDepreciation = vm.AccumulatedDepreciation; asset.AssetAccountId = vm.AssetAccountId; asset.DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId; asset.AccumDepreciationAccountId = vm.AccumDepreciationAccountId; asset.IsDisposed = vm.IsDisposed; asset.DisposalDate = vm.IsDisposed && vm.DisposalDate.HasValue ? DateTime.SpecifyKind(vm.DisposalDate.Value, DateTimeKind.Utc) : null; asset.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Fixed asset \"{asset.Name}\" updated."; return RedirectToAction(nameof(Details), new { id }); } /// /// Posts straight-line depreciation for all active assets for the specified year/month. /// Skips assets that have already been depreciated for the period, assets without GL accounts, /// and fully-depreciated assets (BookValue ≤ SalvageValue). Creates one JE per asset. /// [HttpPost, ValidateAntiForgeryToken] public async Task PostDepreciation(int year, int month) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var currentUser = await _userManager.GetUserAsync(User); var assets = await _unitOfWork.FixedAssets.FindAsync( fa => !fa.IsDisposed, false, fa => fa.DepreciationEntries); int posted = 0, skipped = 0; var errors = new List(); foreach (var asset in assets) { // Skip assets missing required GL accounts if (!asset.DepreciationExpenseAccountId.HasValue || !asset.AccumDepreciationAccountId.HasValue) { skipped++; continue; } // Skip already posted for this period if (asset.DepreciationEntries.Any(e => e.PeriodYear == year && e.PeriodMonth == month && !e.IsDeleted)) { skipped++; continue; } var depreciableBase = asset.PurchaseCost - asset.SalvageValue; var remaining = depreciableBase - asset.AccumulatedDepreciation; // Skip fully depreciated assets if (remaining <= 0) { skipped++; continue; } // Don't over-depreciate in the final period var amount = Math.Min(asset.MonthlyDepreciation, remaining); try { await _unitOfWork.ExecuteInTransactionAsync(async () => { // GL: DR Depreciation Expense / CR Accumulated Depreciation await _accountBalanceService.DebitAsync(asset.DepreciationExpenseAccountId, amount); await _accountBalanceService.CreditAsync(asset.AccumDepreciationAccountId, amount); // Post JE var je = new JournalEntry { EntryNumber = await GenerateJeNumberAsync(companyId), EntryDate = new DateTime(year, month, DateTime.DaysInMonth(year, month), 0, 0, 0, DateTimeKind.Utc), Description = $"Depreciation — {asset.Name} ({month:D2}/{year})", Reference = asset.Name, Status = JournalEntryStatus.Posted, PostedBy = currentUser?.Email, PostedAt = DateTime.UtcNow, CompanyId = companyId, CreatedAt = DateTime.UtcNow, Lines = new List { new() { AccountId = asset.DepreciationExpenseAccountId!.Value, DebitAmount = amount, CreditAmount = 0, Description = $"Depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow }, new() { AccountId = asset.AccumDepreciationAccountId!.Value, DebitAmount = 0, CreditAmount = amount, Description = $"Accum. depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow } } }; await _unitOfWork.JournalEntries.AddAsync(je); await _unitOfWork.CompleteAsync(); // Record the depreciation entry var entry = new FixedAssetDepreciationEntry { FixedAssetId = asset.Id, PeriodYear = year, PeriodMonth = month, Amount = amount, JournalEntryId = je.Id, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.FixedAssetDepreciationEntries.AddAsync(entry); asset.AccumulatedDepreciation += amount; asset.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); }); posted++; } catch (Exception ex) { _logger.LogError(ex, "Error posting depreciation for asset {AssetId}", asset.Id); errors.Add($"{asset.Name}: {ex.Message}"); } } if (errors.Any()) TempData["Error"] = $"Posted {posted}, skipped {skipped}. Errors: {string.Join("; ", errors)}"; else TempData["Success"] = $"Depreciation posted: {posted} asset(s) for {new DateTime(year, month, 1):MMMM yyyy}. {skipped} skipped (already posted, no GL accounts, or fully depreciated)."; return RedirectToAction(nameof(Index)); } [HttpPost, ValidateAntiForgeryToken] public async Task Delete(int id) { var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id); if (asset == null) return NotFound(); var hasEntries = (await _unitOfWork.FixedAssetDepreciationEntries.FindAsync(e => e.FixedAssetId == id)).Any(); if (hasEntries) { TempData["Error"] = "Cannot delete an asset with depreciation history. Mark it as disposed instead."; return RedirectToAction(nameof(Details), new { id }); } await _unitOfWork.FixedAssets.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Fixed asset \"{asset.Name}\" deleted."; return RedirectToAction(nameof(Index)); } private async Task PopulateAccountsAsync() { var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive); var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList(); ViewBag.AssetAccounts = list .Where(a => a.AccountType == AccountType.Asset) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); ViewBag.ExpenseAccounts = list .Where(a => a.AccountType == AccountType.Expense) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); // Accumulated depreciation typically lives as a negative Asset (contra-asset) ViewBag.AccumDeprecAccounts = list .Where(a => a.AccountType is AccountType.Asset or AccountType.Expense) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); } /// Generates next JE number in JE-YYMM-#### format, ignoring soft-deleted entries. private async Task GenerateJeNumberAsync(int companyId) { var prefix = $"JE-{DateTime.Now:yyMM}-"; var all = await _unitOfWork.JournalEntries.FindAsync( je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix), ignoreQueryFilters: true); int next = all.Any() ? all.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1 : 1; return $"{prefix}{next:D4}"; } } public class FixedAssetVm { public int Id { get; set; } [Required, MaxLength(200)] public string Name { get; set; } = string.Empty; [MaxLength(1000)] public string? Description { get; set; } [Required] public DateTime PurchaseDate { get; set; } = DateTime.Today; [Required, Range(0.01, double.MaxValue, ErrorMessage = "Purchase cost must be greater than zero.")] public decimal PurchaseCost { get; set; } [Range(0, double.MaxValue)] public decimal SalvageValue { get; set; } = 0; [Required, Range(1, 600, ErrorMessage = "Useful life must be between 1 and 600 months.")] public int UsefulLifeMonths { get; set; } = 60; [Range(0, double.MaxValue)] public decimal AccumulatedDepreciation { get; set; } = 0; public int? AssetAccountId { get; set; } public int? DepreciationExpenseAccountId { get; set; } public int? AccumDepreciationAccountId { get; set; } public bool IsDisposed { get; set; } = false; public DateTime? DisposalDate { get; set; } }