Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/JournalEntriesController.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

381 lines
15 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.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;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class JournalEntriesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly IAccountBalanceService _accountBalanceService;
public JournalEntriesController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_accountBalanceService = accountBalanceService;
}
private bool AllowAccounting() =>
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
// ── Index ────────────────────────────────────────────────────────────────
public async Task<IActionResult> Index(string? status)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var all = (await _unitOfWork.JournalEntries.FindAsync(
je => je.CompanyId == companyId))
.OrderByDescending(je => je.EntryDate)
.ThenByDescending(je => je.Id)
.ToList();
var displayed = status switch
{
"Draft" => all.Where(je => je.Status == JournalEntryStatus.Draft).ToList(),
"Posted" => all.Where(je => je.Status == JournalEntryStatus.Posted).ToList(),
_ => all
};
ViewBag.StatusFilter = status ?? "All";
ViewBag.TotalCount = all.Count;
ViewBag.DraftCount = all.Count(je => je.Status == JournalEntryStatus.Draft);
ViewBag.PostedCount = all.Count(je => je.Status == JournalEntryStatus.Posted);
return View(displayed);
}
// ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public async Task<IActionResult> Create()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
await PopulateAccountDropdownAsync();
return View(new JournalEntry { EntryDate = DateTime.Today });
}
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(
JournalEntry model,
int[] lineAccountIds,
decimal[] lineDebits,
decimal[] lineCreditAmounts,
string?[] lineDescriptions,
int[] lineOrders)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var lines = BuildLines(lineAccountIds, lineDebits, lineCreditAmounts, lineDescriptions, lineOrders);
if (!ValidateLines(lines, out string? error))
{
TempData["Error"] = error;
await PopulateAccountDropdownAsync();
return View(model);
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
model.EntryNumber = await GenerateEntryNumberAsync(companyId);
model.Status = JournalEntryStatus.Draft;
model.Lines = lines;
await _unitOfWork.JournalEntries.AddAsync(model);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Journal entry {model.EntryNumber} created as draft.";
return RedirectToAction(nameof(Details), new { id = model.Id });
}
// ── Details ──────────────────────────────────────────────────────────────
public async Task<IActionResult> Details(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var je = (await _unitOfWork.JournalEntries.FindAsync(
e => e.Id == id,
false,
e => e.Lines))
.FirstOrDefault();
if (je == null) return NotFound();
// Load account names for lines
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList();
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Reversal metadata
if (je.ReversalOfId.HasValue)
{
var original = await _unitOfWork.JournalEntries.GetByIdAsync(je.ReversalOfId.Value);
ViewBag.ReversalOfNumber = original?.EntryNumber;
}
var reversal = (await _unitOfWork.JournalEntries.FindAsync(
r => r.ReversalOfId == je.Id && r.Status == JournalEntryStatus.Posted))
.FirstOrDefault();
ViewBag.ReversalEntryNumber = reversal?.EntryNumber;
ViewBag.ReversalEntryId = reversal?.Id;
return View(je);
}
// ── Post ─────────────────────────────────────────────────────────────────
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Post(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var entry = (await _unitOfWork.JournalEntries.FindAsync(
je => je.Id == id,
false,
je => je.Lines))
.FirstOrDefault();
if (entry == null) return NotFound();
if (entry.Status != JournalEntryStatus.Draft)
{
TempData["Error"] = "Only draft entries can be posted.";
return RedirectToAction(nameof(Details), new { id });
}
var totalDebits = entry.Lines.Sum(l => l.DebitAmount);
var totalCredits = entry.Lines.Sum(l => l.CreditAmount);
if (totalDebits != totalCredits)
{
TempData["Error"] = $"Entry does not balance — debits {totalDebits:C} ≠ credits {totalCredits:C}.";
return RedirectToAction(nameof(Details), new { id });
}
// Period lock check — block posting if the entry date falls in a locked period
var company = await _unitOfWork.Companies.GetByIdAsync(entry.CompanyId);
if (Web.Helpers.AccountingPeriodValidator.IsLocked(entry.EntryDate, company?.BookLockedThrough))
{
TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough);
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
entry.Status = JournalEntryStatus.Posted;
entry.PostedAt = DateTime.UtcNow;
entry.PostedBy = User.Identity?.Name;
foreach (var line in entry.Lines)
{
if (line.DebitAmount > 0)
await _accountBalanceService.DebitAsync(line.AccountId, line.DebitAmount);
if (line.CreditAmount > 0)
await _accountBalanceService.CreditAsync(line.AccountId, line.CreditAmount);
}
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Journal entry {entry.EntryNumber} posted successfully.";
return RedirectToAction(nameof(Details), new { id });
}
// ── Reverse ──────────────────────────────────────────────────────────────
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Reverse(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var original = (await _unitOfWork.JournalEntries.FindAsync(
je => je.Id == id,
false,
je => je.Lines))
.FirstOrDefault();
if (original == null) return NotFound();
if (original.Status != JournalEntryStatus.Posted)
{
TempData["Error"] = "Only posted entries can be reversed.";
return RedirectToAction(nameof(Details), new { id });
}
var existingReversal = (await _unitOfWork.JournalEntries.FindAsync(
je => je.ReversalOfId == id))
.FirstOrDefault();
if (existingReversal != null)
{
TempData["Error"] = $"This entry was already reversed by {existingReversal.EntryNumber}.";
return RedirectToAction(nameof(Details), new { id });
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
int newEntryId = 0;
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
var reversal = new JournalEntry
{
EntryNumber = await GenerateEntryNumberAsync(companyId),
EntryDate = DateTime.Today,
Reference = $"Reversal of {original.EntryNumber}",
Description = $"Reversal of {original.EntryNumber}: {original.Description}",
Status = JournalEntryStatus.Posted,
IsReversal = true,
ReversalOfId = original.Id,
PostedAt = DateTime.UtcNow,
PostedBy = User.Identity?.Name,
Lines = original.Lines.Select((l, i) => new JournalEntryLine
{
AccountId = l.AccountId,
DebitAmount = l.CreditAmount,
CreditAmount = l.DebitAmount,
Description = l.Description,
LineOrder = l.LineOrder
}).ToList()
};
await _unitOfWork.JournalEntries.AddAsync(reversal);
original.Status = JournalEntryStatus.Reversed;
foreach (var line in reversal.Lines)
{
if (line.DebitAmount > 0)
await _accountBalanceService.DebitAsync(line.AccountId, line.DebitAmount);
if (line.CreditAmount > 0)
await _accountBalanceService.CreditAsync(line.AccountId, line.CreditAmount);
}
await _unitOfWork.CompleteAsync();
newEntryId = reversal.Id;
});
TempData["Success"] = "Reversal entry created and posted.";
return RedirectToAction(nameof(Details), new { id = newEntryId });
}
// ── Delete ───────────────────────────────────────────────────────────────
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var entry = await _unitOfWork.JournalEntries.GetByIdAsync(id);
if (entry == null) return NotFound();
if (entry.Status != JournalEntryStatus.Draft)
{
TempData["Error"] = "Only draft entries can be deleted. Posted entries must be reversed.";
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.JournalEntries.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Journal entry {entry.EntryNumber} deleted.";
return RedirectToAction(nameof(Index));
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static List<JournalEntryLine> BuildLines(
int[] accountIds, decimal[] debits, decimal[] credits,
string?[] descriptions, int[] orders)
{
var lines = new List<JournalEntryLine>();
for (int i = 0; i < accountIds.Length; i++)
{
if (accountIds[i] == 0) continue;
lines.Add(new JournalEntryLine
{
AccountId = accountIds[i],
DebitAmount = i < debits.Length ? debits[i] : 0,
CreditAmount = i < credits.Length ? credits[i] : 0,
Description = i < descriptions.Length ? descriptions[i] : null,
LineOrder = i < orders.Length ? orders[i] : i
});
}
return lines;
}
private static bool ValidateLines(List<JournalEntryLine> lines, out string? error)
{
if (lines.Count < 2)
{
error = "A journal entry must have at least two lines.";
return false;
}
var totalDebits = lines.Sum(l => l.DebitAmount);
var totalCredits = lines.Sum(l => l.CreditAmount);
if (totalDebits == 0 && totalCredits == 0)
{
error = "At least one debit or credit amount must be non-zero.";
return false;
}
if (totalDebits != totalCredits)
{
error = $"Debits ({totalDebits:C}) must equal credits ({totalCredits:C}) before saving.";
return false;
}
error = null;
return true;
}
/// <summary>
/// Generates the next sequential entry number in the format JE-YYMM-####.
/// Queries across soft-deleted entries to prevent number reuse after deletion.
/// </summary>
private async Task<string> GenerateEntryNumberAsync(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 = 1;
if (all.Any())
{
var nums = all
.Select(je => je.EntryNumber[prefix.Length..])
.Select(s => int.TryParse(s, out int n) ? n : 0);
next = nums.Max() + 1;
}
return $"{prefix}{next:D4}";
}
private async Task PopulateAccountDropdownAsync()
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem
{
Value = a.Id.ToString(),
Text = $"{a.AccountNumber} {a.Name}"
})
.ToList();
}
}