Phase C: Add Manual Journal Entries (double-entry GL)

- JournalEntry + JournalEntryLine entities with Draft/Posted/Reversed lifecycle
- JournalEntryStatus enum (Draft, Posted, Reversed)
- Migration AddJournalEntries: two new tables with self-referencing reversal FK
- IUnitOfWork/UnitOfWork wired with JournalEntries + JournalEntryLines repos
- ApplicationDbContext: DbSets, tenant query filters, reversal FK config
- LedgerService: JE lines added as 10th source in GetAccountLedgerAsync and ComputePriorBalanceAsync
- JournalEntriesController: Index (All/Draft/Posted tabs), Create, Details, Post, Reverse, Delete
- Views: Index, Create (dynamic balanced line grid with running debit/credit totals), Details
- journal-entry-create.js: dynamic line management with balance indicator
- Nav: Journal Entries added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 23:56:03 -04:00
parent 0afb474c3e
commit a33687f7bd
15 changed files with 11017 additions and 3 deletions
@@ -0,0 +1,372 @@
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 });
}
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();
}
}