Add Phase A accounting features: AP Aging, Trial Balance, Cash vs Accrual

- AP Aging report (GetApAgingAsync, controller actions, view, PDF export)
  mirrors AR Aging — groups open bills by vendor, buckets by days past due date
- Trial Balance report (GetTrialBalanceAsync, view, PDF export)
  uses Account.CurrentBalance, groups by AccountType, validates debits == credits
- Cash vs Accrual accounting method setting on Company entity
  switchable at any time — report-time only, no GL re-posting on change
  P&L cash: revenue = payments received; expenses = bills/expenses paid in period
  Balance Sheet cash: omits AR and AP lines (no receivables/payables concept)
  AccountingMethod badge shown on P&L and Balance Sheet views
- Migration A (AddAccountingMethod) applied, default = Accrual for all existing companies
- AP Aging and Trial Balance added to Reports Landing page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 23:34:54 -04:00
parent 379b0de885
commit 7e1676cfd7
18 changed files with 10765 additions and 67 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAccountingMethod : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AccountingMethod",
table: "Companies",
type: "int",
nullable: false,
defaultValue: 1); // 1 = Accrual (default for new and existing companies)
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AccountingMethod",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
}
}
}
@@ -1546,6 +1546,9 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccountingMethod")
.HasColumnType("int");
b.Property<bool?>("AccountingOverride")
.HasColumnType("bit");
@@ -6077,7 +6080,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249),
CreatedAt = new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6088,7 +6091,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260),
CreatedAt = new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6099,7 +6102,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261),
CreatedAt = new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -25,68 +25,108 @@ public class FinancialReportService : IFinancialReportService
}
/// <inheritdoc/>
public async Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
public async Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null)
{
var toEnd = to.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
// Revenue: InvoiceItems posted to revenue accounts
var revenueByAccount = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.GroupBy(ii => ii.RevenueAccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
.ToListAsync();
var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var accountingMethod = method ?? await GetCompanyAccountingMethodAsync(companyId);
var isCash = accountingMethod == AccountingMethod.Cash;
var revenueAccounts = await _context.Accounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
.ToDictionaryAsync(a => a.Id);
var revenueLines = revenueByAccount
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
.Select(r => new FinancialReportLine
{
AccountId = r.AccountId,
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
AccountName = revenueAccounts[r.AccountId].Name,
Amount = r.Amount
})
.OrderBy(l => l.AccountNumber)
.ToList();
var revenueLines = new List<FinancialReportLine>();
if (unlinkedRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
if (isCash)
{
// Cash basis: total payments received in period (not split by revenue account)
var cashRevenue = await _context.Payments
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd
&& p.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
if (cashRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue });
}
else
{
// Accrual basis: revenue = invoice item amounts by invoice date
var accrualRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.GroupBy(ii => ii.RevenueAccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
.ToListAsync();
// COGS & Expenses: direct Expenses + BillLineItems merged per account
var directByAccount = await _context.Expenses
.Where(e => e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync();
revenueLines.AddRange(accrualRevenue
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
.Select(r => new FinancialReportLine
{
AccountId = r.AccountId,
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
AccountName = revenueAccounts[r.AccountId].Name,
Amount = r.Amount
})
.OrderBy(l => l.AccountNumber));
var billLinesByAccount = await _context.BillLineItems
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
.GroupBy(bli => bli.AccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
.ToListAsync();
var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
if (unlinkedRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
}
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
var expenseAmounts = new Dictionary<int, decimal>();
foreach (var e in directByAccount)
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
foreach (var b in billLinesByAccount)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
if (isCash)
{
var cashExpenses = await _context.Expenses
.Where(e => e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync();
foreach (var e in cashExpenses)
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
// Pro-rate paid bill line items by payment fraction (bill total may be partial)
var paidBillLines = await _context.BillPayments
.Where(bp => bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
.ToListAsync();
foreach (var bp in paidBillLines)
{
var fraction = bp.Bill.Total == 0 ? 1m : bp.Amount / bp.Bill.Total;
foreach (var li in bp.Bill.LineItems.Where(li => li.AccountId != null))
expenseAmounts[li.AccountId!.Value] = expenseAmounts.GetValueOrDefault(li.AccountId!.Value) + li.Amount * fraction;
}
}
else
{
var accrualExpenses = await _context.Expenses
.Where(e => e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync();
foreach (var e in accrualExpenses)
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
var accrualBillLines = await _context.BillLineItems
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
.GroupBy(bli => bli.AccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
.ToListAsync();
foreach (var b in accrualBillLines)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
}
var expAccounts = await _context.Accounts
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
@@ -105,23 +145,26 @@ public class FinancialReportService : IFinancialReportService
return new ProfitAndLossDto
{
From = from,
To = to,
CompanyName = companyName,
RevenueLines = revenueLines,
TotalRevenue = revenueLines.Sum(l => l.Amount),
CogsLines = cogsLines,
TotalCogs = cogsLines.Sum(l => l.Amount),
ExpenseLines = expenseLines,
TotalExpenses = expenseLines.Sum(l => l.Amount),
From = from,
To = to,
CompanyName = companyName,
AccountingMethod = accountingMethod,
RevenueLines = revenueLines,
TotalRevenue = revenueLines.Sum(l => l.Amount),
CogsLines = cogsLines,
TotalCogs = cogsLines.Sum(l => l.Amount),
ExpenseLines = expenseLines,
TotalExpenses = expenseLines.Sum(l => l.Amount),
};
}
/// <inheritdoc/>
public async Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
public async Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null)
{
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var accountingMethod = method ?? await GetCompanyAccountingMethodAsync(companyId);
var isCash = accountingMethod == AccountingMethod.Cash;
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
@@ -232,10 +275,17 @@ public class FinancialReportService : IFinancialReportService
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
// Cash basis: AR and AP have no meaning (no receivables/payables concept)
var currentAssets = assetAccts
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset
|| (!isCash && a.AccountSubType == AccountSubType.AccountsReceivable))
.Select(ToLine).ToList();
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
var currentLiabilities = liabilityAccts
.Where(a => a.AccountSubType is AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability
|| (!isCash && a.AccountSubType == AccountSubType.AccountsPayable))
.Select(ToLine).ToList();
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
var equityLines = equityAccts.Select(ToLine).ToList();
@@ -247,6 +297,7 @@ public class FinancialReportService : IFinancialReportService
{
AsOf = asOf,
CompanyName = companyName,
AccountingMethod = accountingMethod,
CurrentAssets = currentAssets,
FixedAssets = fixedAssets,
OtherAssets = otherAssets,
@@ -518,6 +569,150 @@ public class FinancialReportService : IFinancialReportService
};
}
/// <inheritdoc/>
public async Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf)
{
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var openBills = await _context.Bills
.Include(b => b.Vendor)
.Where(b => b.CompanyId == companyId
&& b.Status != BillStatus.Draft
&& b.Status != BillStatus.Voided
&& b.Status != BillStatus.Paid
&& b.BillDate <= asOfEnd
&& b.BalanceDue > 0)
.OrderBy(b => b.Vendor!.CompanyName)
.ThenBy(b => b.DueDate)
.ToListAsync();
static string AgingBucket(int d) => d switch
{
<= 0 => "current",
<= 30 => "1-30",
<= 60 => "31-60",
<= 90 => "61-90",
_ => "90+"
};
var vendorDtos = new List<ApAgingVendorDto>();
foreach (var grp in openBills.GroupBy(b => new { b.VendorId, b.Vendor!.CompanyName }))
{
var vendDto = new ApAgingVendorDto
{
VendorId = grp.Key.VendorId,
VendorName = grp.Key.CompanyName
};
foreach (var bill in grp)
{
var balance = bill.BalanceDue;
var daysOverdue = bill.DueDate.HasValue
? (int)(asOf - bill.DueDate.Value.Date).TotalDays
: 0;
vendDto.Bills.Add(new ApAgingBillDto
{
BillId = bill.Id,
BillNumber = bill.BillNumber,
BillDate = bill.BillDate,
DueDate = bill.DueDate,
BalanceDue = balance,
DaysOverdue = daysOverdue
});
switch (AgingBucket(daysOverdue))
{
case "current": vendDto.TotalCurrent += balance; break;
case "1-30": vendDto.Total1to30 += balance; break;
case "31-60": vendDto.Total31to60 += balance; break;
case "61-90": vendDto.Total61to90 += balance; break;
default: vendDto.TotalOver90 += balance; break;
}
}
vendorDtos.Add(vendDto);
}
var sorted = vendorDtos.OrderByDescending(v => v.TotalBalance).ToList();
return new ApAgingReportDto
{
AsOf = asOf,
CompanyName = companyName,
Vendors = sorted,
TotalCurrent = sorted.Sum(v => v.TotalCurrent),
Total1to30 = sorted.Sum(v => v.Total1to30),
Total31to60 = sorted.Sum(v => v.Total31to60),
Total61to90 = sorted.Sum(v => v.Total61to90),
TotalOver90 = sorted.Sum(v => v.TotalOver90),
};
}
/// <inheritdoc/>
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
{
var companyName = await GetCompanyNameAsync(companyId);
var accounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive)
.OrderBy(a => a.AccountNumber)
.ToListAsync();
var lines = new List<TrialBalanceLine>();
foreach (var acct in accounts)
{
if (acct.CurrentBalance == 0) continue;
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
var line = new TrialBalanceLine
{
AccountId = acct.Id,
AccountNumber = acct.AccountNumber,
AccountName = acct.Name,
AccountType = acct.AccountType
};
if (isDebitNormal)
{
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
else line.CreditBalance = -acct.CurrentBalance;
}
else
{
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
else line.DebitBalance = -acct.CurrentBalance;
}
lines.Add(line);
}
return new TrialBalanceDto
{
AsOf = asOf,
CompanyName = companyName,
Lines = lines,
TotalDebits = lines.Sum(l => l.DebitBalance),
TotalCredits = lines.Sum(l => l.CreditBalance),
};
}
/// <inheritdoc/>
public async Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId)
{
if (companyId <= 0) return AccountingMethod.Accrual;
var method = await _context.Companies
.Where(c => c.Id == companyId)
.Select(c => (AccountingMethod?)c.AccountingMethod)
.FirstOrDefaultAsync();
return method ?? AccountingMethod.Accrual;
}
/// <summary>
/// Looks up the company name by ID for report headers and AI prompt injection.
/// Falls back to "Your Company" if the record is not found.