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>
This commit is contained in:
2026-05-10 12:19:32 -04:00
parent a255893ada
commit fde24b09c9
29 changed files with 12520 additions and 3 deletions
@@ -0,0 +1,381 @@
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; }
}