Initial commit
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds chronological ledger reports for individual accounts by querying every transaction
|
||||
/// table that can touch a given account (Payments, Expenses, BillPayments, InvoiceItems, Bills,
|
||||
/// AR, AP) and computing a running balance. Also drives <see cref="AccountBalanceService.RecalculateAllAsync"/>
|
||||
/// by returning the authoritative closing balance.
|
||||
/// </summary>
|
||||
public class LedgerService : ILedgerService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ILogger<LedgerService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the service with direct <see cref="ApplicationDbContext"/> access.
|
||||
/// Direct context access (rather than repositories) is intentional here: the ledger
|
||||
/// must query multiple tables with heterogeneous filters and EF navigation properties,
|
||||
/// which would require many separate repository calls that would be slower and less readable.
|
||||
/// </summary>
|
||||
public LedgerService(ApplicationDbContext context, ILogger<LedgerService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles a full ledger report for an account over the given date range, including
|
||||
/// nine distinct transaction sources: customer payments deposited, expenses paid from,
|
||||
/// bill payments paid from, invoice revenue line items, sales tax, direct expense
|
||||
/// categorizations, bill line-item categorizations, AR movements, and AP movements.
|
||||
/// Returns <c>null</c> when the account does not exist or is filtered out by the global
|
||||
/// query filters (company isolation / soft delete).
|
||||
/// The <c>OpeningBalance</c> in the returned DTO represents the account balance on the day
|
||||
/// before <paramref name="from"/>, computed by <see cref="ComputePriorBalanceAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<AccountLedgerDto?> GetAccountLedgerAsync(int accountId, DateTime from, DateTime to)
|
||||
{
|
||||
// Use FirstOrDefaultAsync so global query filters (company isolation, soft delete) apply
|
||||
var account = await _context.Accounts.FirstOrDefaultAsync(a => a.Id == accountId);
|
||||
if (account == null) return null;
|
||||
|
||||
var fromDate = from.Date;
|
||||
var toDate = to.Date.AddDays(1).AddTicks(-1); // inclusive end of day
|
||||
|
||||
var entries = new List<LedgerEntryDto>();
|
||||
|
||||
// ── 1. Customer payments deposited INTO this account (DEBIT) ──────────
|
||||
// e.g. Checking/Savings account receives a customer payment
|
||||
var depositedPayments = await _context.Payments
|
||||
.Include(p => p.Invoice)
|
||||
.Where(p => p.DepositAccountId == accountId
|
||||
&& p.PaymentDate >= fromDate && p.PaymentDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var p in depositedPayments)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = p.PaymentDate,
|
||||
Reference = p.Invoice?.InvoiceNumber ?? $"PMT-{p.Id}",
|
||||
Source = "Customer Payment",
|
||||
Description = p.Notes ?? p.Reference,
|
||||
Debit = p.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Invoices",
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
|
||||
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||
// e.g. Checking account used to pay an expense
|
||||
var expensesPaidFrom = await _context.Expenses
|
||||
.Include(e => e.Vendor)
|
||||
.Where(e => e.PaymentAccountId == accountId
|
||||
&& e.Date >= fromDate && e.Date <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var e in expensesPaidFrom)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = e.Date,
|
||||
Reference = e.ExpenseNumber,
|
||||
Source = "Expense",
|
||||
Description = e.Memo ?? e.Vendor?.CompanyName,
|
||||
Debit = 0,
|
||||
Credit = e.Amount,
|
||||
LinkController = "Expenses",
|
||||
LinkId = e.Id
|
||||
});
|
||||
|
||||
// ── 3. Bill payments made FROM this account (CREDIT) ──────────────────
|
||||
// e.g. Checking account used to pay a vendor bill
|
||||
var billPaymentsPaidFrom = await _context.BillPayments
|
||||
.Include(bp => bp.Bill)
|
||||
.Where(bp => bp.BankAccountId == accountId
|
||||
&& bp.PaymentDate >= fromDate && bp.PaymentDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var bp in billPaymentsPaidFrom)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = bp.PaymentDate,
|
||||
Reference = bp.PaymentNumber,
|
||||
Source = "Bill Payment",
|
||||
Description = bp.Memo ?? bp.Bill?.BillNumber,
|
||||
Debit = 0,
|
||||
Credit = bp.Amount,
|
||||
LinkController = "Bills",
|
||||
LinkId = bp.BillId
|
||||
});
|
||||
|
||||
// ── 4. Invoice line items that post revenue to this account (CREDIT) ──
|
||||
// e.g. Revenue account 4000 receives line-item revenue
|
||||
var revenueItems = await _context.InvoiceItems
|
||||
.Include(ii => ii.Invoice)
|
||||
.Where(ii => ii.RevenueAccountId == accountId
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var item in revenueItems)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = item.Invoice.InvoiceDate,
|
||||
Reference = item.Invoice.InvoiceNumber,
|
||||
Source = "Invoice",
|
||||
Description = item.Description,
|
||||
Debit = 0,
|
||||
Credit = item.TotalPrice,
|
||||
LinkController = "Invoices",
|
||||
LinkId = item.InvoiceId
|
||||
});
|
||||
|
||||
// ── 5. Sales tax collected to this account (CREDIT) ───────────────────
|
||||
// e.g. Liability account 2200 receives sales tax from invoices
|
||||
var taxInvoices = await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId == accountId
|
||||
&& i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var inv in taxInvoices)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = inv.InvoiceDate,
|
||||
Reference = inv.InvoiceNumber,
|
||||
Source = "Sales Tax",
|
||||
Description = $"Tax on {inv.InvoiceNumber}",
|
||||
Debit = 0,
|
||||
Credit = inv.TaxAmount,
|
||||
LinkController = "Invoices",
|
||||
LinkId = inv.Id
|
||||
});
|
||||
|
||||
// ── 6. Direct expenses categorized to this account (DEBIT) ────────────
|
||||
// e.g. Expense account 6200 receives direct expense entries
|
||||
var expensesTo = await _context.Expenses
|
||||
.Include(e => e.Vendor)
|
||||
.Where(e => e.ExpenseAccountId == accountId
|
||||
&& e.Date >= fromDate && e.Date <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var e in expensesTo)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = e.Date,
|
||||
Reference = e.ExpenseNumber,
|
||||
Source = "Expense",
|
||||
Description = e.Memo ?? e.Vendor?.CompanyName,
|
||||
Debit = e.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Expenses",
|
||||
LinkId = e.Id
|
||||
});
|
||||
|
||||
// ── 7. Bill line items categorized to this account (DEBIT) ────────────
|
||||
// e.g. Expense/COGS account receives costs from vendor bill line items
|
||||
var billLineItems = await _context.BillLineItems
|
||||
.Include(bli => bli.Bill)
|
||||
.Where(bli => bli.AccountId == accountId
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var bli in billLineItems)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = bli.Bill.BillDate,
|
||||
Reference = bli.Bill.BillNumber,
|
||||
Source = "Bill",
|
||||
Description = bli.Description,
|
||||
Debit = bli.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Bills",
|
||||
LinkId = bli.BillId
|
||||
});
|
||||
|
||||
// ── 8. Accounts Receivable ─────────────────────────────────────────────
|
||||
// Invoice creation increases AR (DEBIT); customer payments reduce AR (CREDIT)
|
||||
if (account.AccountSubType == AccountSubType.AccountsReceivable)
|
||||
{
|
||||
var arInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var inv in arInvoices)
|
||||
{
|
||||
var customerName = inv.Customer?.IsCommercial == true
|
||||
? inv.Customer.CompanyName
|
||||
: $"{inv.Customer?.ContactFirstName} {inv.Customer?.ContactLastName}".Trim();
|
||||
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = inv.InvoiceDate,
|
||||
Reference = inv.InvoiceNumber,
|
||||
Source = "Invoice",
|
||||
Description = customerName,
|
||||
Debit = inv.Total,
|
||||
Credit = 0,
|
||||
LinkController = "Invoices",
|
||||
LinkId = inv.Id
|
||||
});
|
||||
}
|
||||
|
||||
var arPayments = await _context.Payments
|
||||
.Include(p => p.Invoice)
|
||||
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var p in arPayments)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = p.PaymentDate,
|
||||
Reference = p.Invoice?.InvoiceNumber ?? $"PMT-{p.Id}",
|
||||
Source = "Invoice Payment",
|
||||
Description = $"Payment — {p.Invoice?.InvoiceNumber}",
|
||||
Debit = 0,
|
||||
Credit = p.Amount,
|
||||
LinkController = "Invoices",
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||
// Bill creation increases AP (CREDIT); bill payments reduce AP (DEBIT)
|
||||
if (account.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
var apBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => b.APAccountId == accountId
|
||||
&& b.Status != BillStatus.Draft
|
||||
&& b.Status != BillStatus.Voided
|
||||
&& b.BillDate >= fromDate && b.BillDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var b in apBills)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = b.BillDate,
|
||||
Reference = b.BillNumber,
|
||||
Source = "Bill",
|
||||
Description = b.Vendor?.CompanyName ?? b.Memo,
|
||||
Debit = 0,
|
||||
Credit = b.Total,
|
||||
LinkController = "Bills",
|
||||
LinkId = b.Id
|
||||
});
|
||||
|
||||
var apPayments = await _context.BillPayments
|
||||
.Include(bp => bp.Bill)
|
||||
.Where(bp => bp.Bill.APAccountId == accountId
|
||||
&& bp.PaymentDate >= fromDate && bp.PaymentDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var bp in apPayments)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = bp.PaymentDate,
|
||||
Reference = bp.PaymentNumber,
|
||||
Source = "Bill Payment",
|
||||
Description = bp.Memo ?? bp.Bill?.BillNumber,
|
||||
Debit = bp.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Bills",
|
||||
LinkId = bp.BillId
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort and compute running balance ──────────────────────────────────
|
||||
entries = entries
|
||||
.OrderBy(e => e.Date)
|
||||
.ThenBy(e => e.Reference)
|
||||
.ToList();
|
||||
|
||||
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
|
||||
// since users could misconfigure AccountType while SubType is picked from a constrained list).
|
||||
bool normalDebitBalance = IsNormalDebitBalance(account.AccountSubType);
|
||||
|
||||
// Compute the balance before the selected period
|
||||
decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance);
|
||||
|
||||
|
||||
decimal runningBalance = priorBalance;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
runningBalance += normalDebitBalance
|
||||
? entry.Debit - entry.Credit
|
||||
: entry.Credit - entry.Debit;
|
||||
entry.RunningBalance = runningBalance;
|
||||
}
|
||||
|
||||
return new AccountLedgerDto
|
||||
{
|
||||
Id = account.Id,
|
||||
AccountNumber = account.AccountNumber,
|
||||
Name = account.Name,
|
||||
AccountType = account.AccountType,
|
||||
AccountSubType = account.AccountSubType,
|
||||
From = fromDate,
|
||||
To = to.Date,
|
||||
OpeningBalance = priorBalance,
|
||||
PeriodDebits = entries.Sum(e => e.Debit),
|
||||
PeriodCredits = entries.Sum(e => e.Credit),
|
||||
ClosingBalance = runningBalance,
|
||||
Entries = entries
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
|
||||
/// <c>false</c> for normal credit balance (Liabilities, Equity, Revenue).
|
||||
/// <see cref="AccountSubType"/> is used rather than <see cref="PowderCoating.Core.Enums.AccountType"/>
|
||||
/// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
|
||||
/// whereas <c>AccountType</c> is a broader category that a user might set incorrectly.
|
||||
/// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
|
||||
/// </summary>
|
||||
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
|
||||
{
|
||||
// Asset subtypes → normal debit balance
|
||||
AccountSubType.Checking
|
||||
or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory
|
||||
or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset
|
||||
or AccountSubType.OtherAsset => true,
|
||||
|
||||
// COGS → normal debit balance
|
||||
AccountSubType.CostOfGoodsSold => true,
|
||||
|
||||
// Expense subtypes (enum values ≥ 50) → normal debit balance
|
||||
var st when (int)st >= 50 => true,
|
||||
|
||||
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
|
||||
/// by summing all activity prior to that date across every transaction source and adding
|
||||
/// the stored <c>OpeningBalance</c>. The opening balance is only included when its recorded
|
||||
/// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g.
|
||||
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
|
||||
/// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies.
|
||||
/// The sign convention follows <see cref="IsNormalDebitBalance"/>: debits increase debit-normal
|
||||
/// accounts and credits increase credit-normal accounts.
|
||||
/// </summary>
|
||||
private async Task<decimal> ComputePriorBalanceAsync(
|
||||
PowderCoating.Core.Entities.Account account, DateTime beforeDate, DateTime periodEnd, bool normalDebitBalance)
|
||||
{
|
||||
var accountId = account.Id;
|
||||
decimal debits = 0;
|
||||
decimal credits = 0;
|
||||
|
||||
// 1. Customer payments deposited INTO this account (DEBIT)
|
||||
debits += await _context.Payments
|
||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||
credits += await _context.Expenses
|
||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
|
||||
// 3. Bill payments made FROM this account (CREDIT)
|
||||
credits += await _context.BillPayments
|
||||
.Where(bp => bp.BankAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
|
||||
// 4. Invoice line items posting revenue to this account (CREDIT)
|
||||
credits += await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == accountId
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate < beforeDate)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
// 5. Sales tax collected to this account (CREDIT)
|
||||
credits += await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId == accountId
|
||||
&& i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate < beforeDate)
|
||||
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
|
||||
|
||||
// 6. Direct expenses categorized to this account (DEBIT)
|
||||
debits += await _context.Expenses
|
||||
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
|
||||
// 7. Bill line items categorized to this account (DEBIT)
|
||||
debits += await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId == accountId
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate < beforeDate)
|
||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
|
||||
// 8. Accounts Receivable
|
||||
if (account.AccountSubType == AccountSubType.AccountsReceivable)
|
||||
{
|
||||
debits += await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate < beforeDate)
|
||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
|
||||
credits += await _context.Payments
|
||||
.Where(p => p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 9. Accounts Payable
|
||||
if (account.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
credits += await _context.Bills
|
||||
.Where(b => b.APAccountId == accountId
|
||||
&& b.Status != BillStatus.Draft
|
||||
&& b.Status != BillStatus.Voided
|
||||
&& b.BillDate < beforeDate)
|
||||
.SumAsync(b => (decimal?)b.Total) ?? 0;
|
||||
|
||||
debits += await _context.BillPayments
|
||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
}
|
||||
|
||||
decimal netActivity = normalDebitBalance ? debits - credits : credits - debits;
|
||||
|
||||
// Apply the opening balance if it was established on or before the end of the viewed period.
|
||||
// A null date means it predates all transactions and always applies.
|
||||
decimal openingBalance = (account.OpeningBalanceDate == null || account.OpeningBalanceDate.Value.Date <= periodEnd)
|
||||
? account.OpeningBalance
|
||||
: 0;
|
||||
|
||||
return openingBalance + netActivity;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user