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:
@@ -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 & 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, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||||
KpiCell(row, "61–90 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", "1–30", "31–60", "61–90", "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&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&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
|
||||
}
|
||||
|
||||
Generated
+9555
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&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">1–30 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">31–60 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">61–90 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">1–30 Days</th>
|
||||
<th class="text-end">31–60 Days</th>
|
||||
<th class="text-end">61–90 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>
|
||||
Reference in New Issue
Block a user