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
@@ -2,6 +2,72 @@ using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Accounting;
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
// without needing a separate round-trip to the company settings.
// ── AP Aging ──────────────────────────────────────────────────────────────────
public class ApAgingReportDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<ApAgingVendorDto> Vendors { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingVendorDto
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public List<ApAgingBillDto> Bills { get; set; } = new();
public decimal TotalCurrent { get; set; }
public decimal Total1to30 { get; set; }
public decimal Total31to60 { get; set; }
public decimal Total61to90 { get; set; }
public decimal TotalOver90 { get; set; }
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
}
public class ApAgingBillDto
{
public int BillId { get; set; }
public string BillNumber { get; set; } = string.Empty;
public DateTime BillDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal BalanceDue { get; set; }
public int DaysOverdue { get; set; }
}
// ── Trial Balance ─────────────────────────────────────────────────────────────
public class TrialBalanceDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<TrialBalanceLine> Lines { get; set; } = new();
public decimal TotalDebits { get; set; }
public decimal TotalCredits { get; set; }
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
}
public class TrialBalanceLine
{
public int AccountId { get; set; }
public string AccountNumber { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
public decimal DebitBalance { get; set; }
public decimal CreditBalance { get; set; }
}
// ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto
@@ -9,6 +75,7 @@ public class ProfitAndLossDto
public DateTime From { get; set; }
public DateTime To { get; set; }
public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public List<FinancialReportLine> RevenueLines { get; set; } = new();
public decimal TotalRevenue { get; set; }
@@ -40,6 +107,7 @@ public class BalanceSheetDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
// Assets
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? TimeZone { get; set; }
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
public bool HasLogo { get; set; }
public CompanyOperatingCostsDto? OperatingCosts { get; set; }
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
public string? TimeZone { get; set; }
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
}
/// <summary>
@@ -1,4 +1,5 @@
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.Interfaces;
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
/// Read-only service for financial aggregate reports. All methods query the database
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
/// The <paramref name="method"/> parameter overrides the company's stored preference when
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
/// </summary>
public interface IFinancialReportService
{
/// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary>
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
@@ -23,4 +26,13 @@ public interface IFinancialReportService
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
}
@@ -42,6 +42,8 @@ public interface IPdfService
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
Task<byte[]> GenerateGiftCertificatePdfAsync(
GiftCertificateDto cert,
@@ -2357,4 +2357,240 @@ public class PdfService : IPdfService
return document.GeneratePdf();
});
}
/// <summary>
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
/// </summary>
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#b91c1c";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
KpiCell(row, "130 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
KpiCell(row, "3160 Days", dto.Total31to60.ToString("C0"), "#ea580c");
KpiCell(row, "6190 Days", dto.Total61to90.ToString("C0"), "#dc2626");
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
});
if (!dto.Vendors.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("All bills are paid — no outstanding balances.")
.FontSize(11).FontColor("#16a34a");
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(3);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Vendor", "Current", "130", "3160", "6190", "Over 90", "Total" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var vend in dto.Vendors)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
alt = !alt;
}
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
});
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
foreach (var vend in dto.Vendors)
{
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
{
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
vendCol.Item().Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
});
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
{
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
: bill.DaysOverdue <= 30 ? "#ca8a04"
: bill.DaysOverdue <= 60 ? "#ea580c"
: bill.DaysOverdue <= 90 ? "#dc2626"
: "#7f1d1d";
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
}
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
table.Cell().Background("#f1f5f9");
});
});
}
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
/// totals and a balanced/unbalanced indicator.
/// </summary>
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#1a56db";
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.6f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
page.Content().PaddingTop(12).Column(col =>
{
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
{
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
dto.IsBalanced ? "#16a34a" : "#dc2626");
});
if (!dto.Lines.Any())
{
col.Item().PaddingTop(20).AlignCenter()
.Text("No active accounts with balances found.")
.FontSize(11).FontColor(Colors.Grey.Darken1);
return;
}
col.Item().PaddingTop(14).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.ConstantColumn(70);
cols.RelativeColumn(4);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
cols.RelativeColumn(2);
});
table.Header(h =>
{
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
});
var alt = false;
foreach (var line in dto.Lines)
{
var bg = alt ? "#f8fafc" : "#ffffff";
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
alt = !alt;
}
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
});
});
page.Footer().AlignCenter().Text(text =>
{
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
}
@@ -105,6 +105,13 @@ public class Company : BaseEntity
public bool MarketingEmailOptOut { get; set; } = false;
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Determines whether financial reports (P&amp;L, Balance Sheet, Cash Flow) use
/// cash-basis or accrual-basis presentation. Switchable at any time — no GL
/// re-posting occurs. Default is Accrual (standard for most businesses).
/// </summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
// Settings
public string? TimeZone { get; set; } = "America/New_York";
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
@@ -66,3 +66,16 @@ public enum BillStatus
Paid = 3,
Voided = 4
}
/// <summary>
/// Company-level accounting method preference. Affects how financial reports
/// (P&amp;L, Balance Sheet, Cash Flow) query and present data. Switching this
/// setting never re-posts historical GL entries — it is a report-time choice only.
/// </summary>
public enum AccountingMethod
{
/// <summary>Revenue and expenses recognised when cash changes hands.</summary>
Cash = 0,
/// <summary>Revenue and expenses recognised when earned/incurred (default).</summary>
Accrual = 1
}
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.
@@ -1192,6 +1192,69 @@ public class ReportsController : Controller
return File(bytes, "text/csv", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.csv");
}
/// <summary>
/// Accounts Payable Aging report — mirrors the AR Aging but groups open bills by vendor
/// and buckets them by days past due date. Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/ApAging
public async Task<IActionResult> ApAging(DateTime? asOf)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetApAgingAsync(companyId, asOfDate);
return View(dto);
}
/// <summary>
/// PDF export of the AP Aging report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/ApAgingPdf
public async Task<IActionResult> ApAgingPdf(DateTime? asOf, bool inline = false)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetApAgingAsync(companyId, asOfDate);
var pdfBytes = await _pdfService.GenerateApAgingPdfAsync(dto);
return inline
? File(pdfBytes, "application/pdf")
: File(pdfBytes, "application/pdf", $"AP-Aging-{asOfDate:yyyyMMdd}.pdf");
}
/// <summary>
/// Trial Balance report — lists all active accounts with debit and credit balances using
/// <c>Account.CurrentBalance</c> (live, not point-in-time). Validates that debits equal
/// credits. Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/TrialBalance
public async Task<IActionResult> TrialBalance(DateTime? asOf)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetTrialBalanceAsync(companyId, asOfDate);
return View(dto);
}
/// <summary>
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/TrialBalancePdf
public async Task<IActionResult> TrialBalancePdf(DateTime? asOf, bool inline = false)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetTrialBalanceAsync(companyId, asOfDate);
var pdfBytes = await _pdfService.GenerateTrialBalancePdfAsync(dto);
return inline
? File(pdfBytes, "application/pdf")
: File(pdfBytes, "application/pdf", $"TrialBalance-{asOfDate:yyyyMMdd}.pdf");
}
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
/// <summary>
@@ -199,6 +199,16 @@
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="accountingMethod" class="form-label">Accounting Method</label>
<select class="form-select" id="accountingMethod" name="AccountingMethod">
<option value="1" selected="@(Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Accrual ? "selected" : null)">Accrual (default)</option>
<option value="0" selected="@(Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Cash ? "selected" : null)">Cash Basis</option>
</select>
<div class="form-text">Affects how financial reports (P&amp;L, Balance Sheet, Cash Flow) present data. Switching does not re-post historical transactions.</div>
</div>
</div>
</div>
<div class="mb-3">
@@ -2166,7 +2176,8 @@
City: $('#city').val(),
State: $('#state').val(),
ZipCode: $('#zipCode').val(),
TimeZone: $('#timeZone').val()
TimeZone: $('#timeZone').val(),
AccountingMethod: parseInt($('#accountingMethod').val())
};
const btn = $('#btnSaveCompanyInfo');
@@ -0,0 +1,242 @@
@model PowderCoating.Application.DTOs.Accounting.ApAgingReportDto
@{
ViewData["Title"] = "AP Aging";
ViewData["PageIcon"] = "bi-hourglass-split";
var today = DateTime.Today;
}
<style>
@@media print {
.no-print { display: none !important; }
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
body { font-size: 11px; }
.table { font-size: 11px; }
}
.aging-current { color: #198754; }
.aging-1-30 { color: #fd7e14; }
.aging-31-60 { color: #dc6c02; }
.aging-61-90 { color: #dc3545; }
.aging-over90 { color: #842029; font-weight: 700; }
.vendor-row { background: #f8f9fa; font-weight: 600; }
</style>
<!-- Header -->
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">As of @Model.AsOf.ToString("MMMM d, yyyy") · @Model.Vendors.Sum(v => v.Bills.Count) open bills</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("ApAgingPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
<i class="bi bi-file-pdf me-1"></i>Download PDF
</a>
<a href="@Url.Action("ApAgingPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd"), inline = true })"
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
<i class="bi bi-printer me-1"></i>Print
</a>
</div>
</div>
<!-- Date filter -->
<div class="card shadow-sm mb-4 no-print">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label form-label-sm mb-1">As of Date</label>
<input type="date" name="asOf" class="form-control form-control-sm" value="@Model.AsOf.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-funnel me-1"></i>Run Report</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm">
<a href="@Url.Action("ApAging", new { asOf = today.ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">Today</a>
<a href="@Url.Action("ApAging", new { asOf = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">End of Last Month</a>
</div>
</div>
</form>
</div>
</div>
<!-- Print header -->
<div class="text-center mb-4 d-none d-print-block">
<h4 class="fw-bold">@Model.CompanyName</h4>
<h5>Accounts Payable Aging</h5>
<p class="text-muted">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
</div>
<!-- Aging summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 text-success mb-1">@Model.TotalCurrent.ToString("C0")</div>
<div class="text-muted small">Current</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-1-30 mb-1">@Model.Total1to30.ToString("C0")</div>
<div class="text-muted small">130 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-31-60 mb-1">@Model.Total31to60.ToString("C0")</div>
<div class="text-muted small">3160 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-61-90 mb-1">@Model.Total61to90.ToString("C0")</div>
<div class="text-muted small">6190 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-over90 mb-1">@Model.TotalOver90.ToString("C0")</div>
<div class="text-muted small">Over 90 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100 border-danger border-opacity-25">
<div class="card-body py-3">
<div class="h6 text-danger fw-bold mb-1">@Model.TotalOutstanding.ToString("C0")</div>
<div class="text-muted small">Total Owed</div>
</div>
</div>
</div>
</div>
@if (!Model.Vendors.Any())
{
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-check-circle text-success fs-1 d-block mb-2"></i>
<p class="mb-0 fw-semibold">All bills are paid!</p>
<p class="small mb-0">No outstanding balances as of @Model.AsOf.ToString("MMMM d, yyyy").</p>
</div>
</div>
}
else
{
<!-- Summary table -->
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold">
<i class="bi bi-table me-1"></i>Aging Summary by Vendor
</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Vendor</th>
<th class="text-end">Current</th>
<th class="text-end">130 Days</th>
<th class="text-end">3160 Days</th>
<th class="text-end">6190 Days</th>
<th class="text-end">Over 90</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
@foreach (var vend in Model.Vendors)
{
<tr>
<td>
<a asp-controller="Vendors" asp-action="Details" asp-route-id="@vend.VendorId" class="text-decoration-none fw-medium">
@vend.VendorName
</a>
<span class="badge bg-secondary ms-1">@vend.Bills.Count bill@(vend.Bills.Count == 1 ? "" : "s")</span>
</td>
<td class="text-end aging-current">@(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—")</td>
<td class="text-end aging-1-30">@(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—")</td>
<td class="text-end aging-31-60">@(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—")</td>
<td class="text-end aging-61-90">@(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—")</td>
<td class="text-end aging-over90">@(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—")</td>
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td>Total</td>
<td class="text-end aging-current">@Model.TotalCurrent.ToString("C")</td>
<td class="text-end aging-1-30">@Model.Total1to30.ToString("C")</td>
<td class="text-end aging-31-60">@Model.Total31to60.ToString("C")</td>
<td class="text-end aging-61-90">@Model.Total61to90.ToString("C")</td>
<td class="text-end aging-over90">@Model.TotalOver90.ToString("C")</td>
<td class="text-end">@Model.TotalOutstanding.ToString("C")</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Detail by vendor -->
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-list-ul me-1"></i>Bill Detail
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Bill #</th>
<th>Bill Date</th>
<th>Due Date</th>
<th class="text-end">Balance Due</th>
<th class="text-end">Age</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var vend in Model.Vendors)
{
<tr class="vendor-row">
<td colspan="6" class="py-2">@vend.VendorName</td>
</tr>
@foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
{
string ageBadge = bill.DaysOverdue <= 0 ? "bg-success-subtle text-success"
: bill.DaysOverdue <= 30 ? "bg-warning-subtle text-warning"
: bill.DaysOverdue <= 60 ? "bg-orange-subtle text-warning"
: bill.DaysOverdue <= 90 ? "bg-danger-subtle text-danger"
: "bg-danger text-white";
string ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
<tr>
<td class="ps-4">
<a asp-controller="Bills" asp-action="Details" asp-route-id="@bill.BillId" class="text-decoration-none fw-medium">
@bill.BillNumber
</a>
</td>
<td class="text-muted small">@bill.BillDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
<td class="text-end fw-semibold @(bill.DaysOverdue > 30 ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</td>
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
<td></td>
</tr>
}
<tr class="table-light">
<td colspan="3" class="ps-4 fw-semibold text-end small">@vend.VendorName subtotal</td>
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
<td colspan="2"></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="text-muted small mt-2 no-print">
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Includes all open bills (excluding Draft and Voided). Age calculated from due date.
</div>
@@ -22,6 +22,14 @@
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
@if (Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Cash)
{
<span class="badge bg-warning text-dark">Cash Basis</span>
}
else
{
<span class="badge bg-info text-dark">Accrual Basis</span>
}
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("BalanceSheetPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
@@ -188,6 +188,22 @@
<p>Snapshot of assets, liabilities, and equity as of any date.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="ApAging" class="report-card">
<div class="report-card-icon" style="background:#fff1f2;color:#b91c1c;">
<i class="bi bi-hourglass-split"></i>
</div>
<h5>AP Aging</h5>
<p>Outstanding vendor bills by age — current, 30, 60, and 90+ days past due. Exportable to PDF.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="TrialBalance" class="report-card">
<div class="report-card-icon" style="background:#eef2ff;color:#4338ca;">
<i class="bi bi-list-columns-reverse"></i>
</div>
<h5>Trial Balance</h5>
<p>All active accounts with debit and credit balances — validates that your books are in balance.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
}
@@ -30,6 +30,14 @@
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">Income Statement — @Model.From.ToString("MMM d") @Model.To.ToString("MMM d, yyyy")</p>
@if (Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Cash)
{
<span class="badge bg-warning text-dark">Cash Basis</span>
}
else
{
<span class="badge bg-info text-dark">Accrual Basis</span>
}
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("ProfitAndLossPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
@@ -0,0 +1,183 @@
@model PowderCoating.Application.DTOs.Accounting.TrialBalanceDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Trial Balance";
ViewData["PageIcon"] = "bi-list-columns-reverse";
var today = DateTime.Today;
var grouped = Model.Lines.GroupBy(l => l.AccountType).OrderBy(g => g.Key.ToString());
}
<style>
@@media print {
.no-print { display: none !important; }
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
body { font-size: 11px; }
.table { font-size: 11px; }
}
.type-header { background: #f1f5f9; font-weight: 600; }
</style>
<!-- Header -->
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">
As of @Model.AsOf.ToString("MMMM d, yyyy") ·
@if (Model.IsBalanced)
{
<span class="text-success fw-semibold"><i class="bi bi-check-circle me-1"></i>Balanced</span>
}
else
{
<span class="text-danger fw-semibold"><i class="bi bi-exclamation-triangle me-1"></i>Out of Balance by @((Model.TotalDebits - Model.TotalCredits).ToString("C"))</span>
}
</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("TrialBalancePdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
<i class="bi bi-file-pdf me-1"></i>Download PDF
</a>
<a href="@Url.Action("TrialBalancePdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd"), inline = true })"
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
<i class="bi bi-printer me-1"></i>Print
</a>
</div>
</div>
<!-- Date filter -->
<div class="card shadow-sm mb-4 no-print">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label form-label-sm mb-1">As of Date</label>
<input type="date" name="asOf" class="form-control form-control-sm" value="@Model.AsOf.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-funnel me-1"></i>Run Report</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm">
<a href="@Url.Action("TrialBalance", new { asOf = today.ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">Today</a>
<a href="@Url.Action("TrialBalance", new { asOf = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">End of Last Month</a>
</div>
</div>
</form>
</div>
</div>
<!-- Print header -->
<div class="text-center mb-4 d-none d-print-block">
<h4 class="fw-bold">@Model.CompanyName</h4>
<h5>Trial Balance</h5>
<p class="text-muted">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
</div>
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 text-primary fw-bold mb-1">@Model.TotalDebits.ToString("C0")</div>
<div class="text-muted small">Total Debits</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 text-primary fw-bold mb-1">@Model.TotalCredits.ToString("C0")</div>
<div class="text-muted small">Total Credits</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm text-center h-100 @(Model.IsBalanced ? "border-success border-opacity-50" : "border-danger border-opacity-50")">
<div class="card-body py-3">
@if (Model.IsBalanced)
{
<div class="h5 text-success fw-bold mb-1"><i class="bi bi-check-circle me-1"></i>Balanced</div>
<div class="text-muted small">Debits = Credits</div>
}
else
{
<div class="h5 text-danger fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Unbalanced</div>
<div class="text-muted small">Difference: @((Model.TotalDebits - Model.TotalCredits).ToString("C"))</div>
}
</div>
</div>
</div>
</div>
@if (!Model.Lines.Any())
{
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-journal-x fs-1 d-block mb-2"></i>
<p class="mb-0 fw-semibold">No active accounts with balances found.</p>
</div>
</div>
}
else
{
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-list-columns-reverse me-1"></i>Account Balances
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:90px">Acct #</th>
<th>Account Name</th>
<th class="text-end" style="width:140px">Debit</th>
<th class="text-end" style="width:140px">Credit</th>
</tr>
</thead>
<tbody>
@foreach (var grp in grouped)
{
<tr class="type-header">
<td colspan="4" class="py-2 text-uppercase small tracking-wide">@grp.Key</td>
</tr>
@foreach (var line in grp.OrderBy(l => l.AccountNumber))
{
<tr>
<td class="ps-4 text-muted small">@line.AccountNumber</td>
<td>@line.AccountName</td>
<td class="text-end @(line.DebitBalance > 0 ? "fw-medium" : "text-muted")">
@(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "")
</td>
<td class="text-end @(line.CreditBalance > 0 ? "fw-medium" : "text-muted")">
@(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "")
</td>
</tr>
}
<tr class="table-light">
<td colspan="2" class="text-end pe-3 small fw-semibold text-muted">@grp.Key subtotal</td>
<td class="text-end fw-semibold">@grp.Sum(l => l.DebitBalance).ToString("C")</td>
<td class="text-end fw-semibold">@grp.Sum(l => l.CreditBalance).ToString("C")</td>
</tr>
}
</tbody>
<tfoot class="table-dark fw-bold">
<tr>
<td colspan="2" class="text-end pe-3">Total</td>
<td class="text-end">@Model.TotalDebits.ToString("C")</td>
<td class="text-end">@Model.TotalCredits.ToString("C")</td>
</tr>
@if (!Model.IsBalanced)
{
<tr class="table-danger">
<td colspan="2" class="text-end pe-3 text-danger">Difference (out of balance)</td>
<td class="text-end text-danger" colspan="2">@((Model.TotalDebits - Model.TotalCredits).ToString("C"))</td>
</tr>
}
</tfoot>
</table>
</div>
</div>
}
<div class="text-muted small mt-2 no-print">
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Uses current account balances (live, not point-in-time). Accounts with zero balance are excluded.
</div>