Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/FixedAssetsController.cs
T
spouliot fde24b09c9 Phase F: Add Invoice Write-Off, Fixed Assets, Period Locking, and 1099 Tracking
- Invoice Write-Off: WriteOff POST action in InvoicesController posts bad-debt JE
  (DR bad debt expense / CR AR), reduces customer balance, marks invoice WrittenOff;
  write-off modal added to Invoice Details view with expense account selector
- Fixed Assets: FixedAsset + FixedAssetDepreciationEntry entities with straight-line
  depreciation; FixedAssetsController (Index/Create/Edit/Details/PostDepreciation/Delete);
  PostDepreciation auto-generates one JE per asset per period, skips already-posted,
  fully-depreciated, and disposed assets; full CRUD views + nav link
- Period Locking: Company.BookLockedThrough field; AccountingPeriodValidator static helper;
  lock check added to JE Post and Bill Create (blocks backdating into closed periods);
  SetPeriodLock action + date picker UI in Company Settings Accounting section
- 1099 Tracking: Is1099Vendor flag on Vendor entity + DTOs; checkbox in Create/Edit views;
  TaxReporting1099 report action + view lists payments by year, flags vendors >= $600;
  report card added to Reports Landing
- Migration AddFixedAssetsLockAnd1099 applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:19:32 -04:00

382 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class FixedAssetsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountBalanceService _accountBalanceService;
private readonly ILogger<FixedAssetsController> _logger;
public FixedAssetsController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
IAccountBalanceService accountBalanceService,
ILogger<FixedAssetsController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_accountBalanceService = accountBalanceService;
_logger = logger;
}
/// <summary>Lists all fixed assets for the current company with depreciation summary.</summary>
[HttpGet]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Create()
{
await PopulateAccountsAsync();
return View(new FixedAssetVm { PurchaseDate = DateTime.Today });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<string>();
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<JournalEntryLine>
{
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<IActionResult> 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();
}
/// <summary>Generates next JE number in JE-YYMM-#### format, ignoring soft-deleted entries.</summary>
private async Task<string> 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; }
}