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:
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.
|
||||
|
||||
Reference in New Issue
Block a user