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,78 @@
const JournalEntry = (() => {
let accounts = [];
let lineIndex = 0;
function init(accountList) {
accounts = accountList;
document.getElementById('addLineBtn').addEventListener('click', addLine);
addLine();
addLine();
}
function buildAccountOptions(selectedId) {
return accounts.map(a =>
`<option value="${a.value}" ${a.value == selectedId ? 'selected' : ''}>${escHtml(a.text)}</option>`
).join('');
}
function addLine(accountId, debit, credit, desc) {
const tbody = document.getElementById('linesBody');
const idx = lineIndex++;
const tr = document.createElement('tr');
tr.dataset.idx = idx;
tr.innerHTML = `
<td>
<select name="lineAccountIds" class="form-select form-select-sm line-account" required>
<option value="">— select account —</option>
${buildAccountOptions(accountId)}
</select>
</td>
<td><input name="lineDescriptions" type="text" class="form-control form-control-sm" placeholder="optional" value="${escHtml(desc || '')}" /></td>
<td>
<input name="lineDebits" type="number" step="0.01" min="0" class="form-control form-control-sm text-end line-debit" placeholder="0.00" value="${debit || ''}" />
</td>
<td>
<input name="lineCreditAmounts" type="number" step="0.01" min="0" class="form-control form-control-sm text-end line-credit" placeholder="0.00" value="${credit || ''}" />
</td>
<td>
<input name="lineOrders" type="hidden" value="${idx}" />
<button type="button" class="btn btn-sm btn-link text-danger p-0 remove-line-btn" title="Remove line">
<i class="bi bi-x-lg"></i>
</button>
</td>`;
tr.querySelector('.remove-line-btn').addEventListener('click', () => {
tr.remove();
updateTotals();
});
tr.querySelector('.line-debit').addEventListener('input', updateTotals);
tr.querySelector('.line-credit').addEventListener('input', updateTotals);
tbody.appendChild(tr);
updateTotals();
}
function updateTotals() {
let debits = 0, credits = 0;
document.querySelectorAll('.line-debit').forEach(el => {
debits += parseFloat(el.value) || 0;
});
document.querySelectorAll('.line-credit').forEach(el => {
credits += parseFloat(el.value) || 0;
});
document.getElementById('totalDebits').textContent = fmtCurrency(debits);
document.getElementById('totalCredits').textContent = fmtCurrency(credits);
const badge = document.getElementById('balanceStatus');
const balanced = Math.abs(debits - credits) < 0.001 && debits > 0;
badge.textContent = balanced ? 'Balanced' : 'Unbalanced';
badge.className = 'badge ' + (balanced ? 'bg-success' : 'bg-secondary');
}
function fmtCurrency(n) {
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
return { init };
})();