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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user