fde24b09c9
- 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>
381 lines
15 KiB
C#
381 lines
15 KiB
C#
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();
|
||
}
|
||
}
|