Phase F: Customer/Vendor Statements, Payment Terms Parser, Tax Rates

F1: GetCustomerStatementAsync/GetVendorStatementAsync on IFinancialReportService;
    StatementLineDto; CustomerStatementDto/VendorStatementDto; Statement action on
    CustomersController + VendorsController; Statement views + PDF download via
    StatementPdfHelper (QuestPDF); Statement button on Customer/Vendor Details pages.

F2: PaymentTermsParser static helper (CalculateDueDate, ParseEarlyPaymentDiscount);
    EarlyPaymentDiscountPercent/Days on Invoice entity; GetCustomerPaymentTerms AJAX
    endpoint on InvoicesController auto-populates Terms + due date on customer select;
    early payment discount notice on Invoice Create.

F3: TaxRate entity (Name/Rate/State/IsDefault/IsActive, tenant-filtered);
    IUnitOfWork.TaxRates + UnitOfWork + ApplicationDbContext; TaxRatesController
    (Index/Create/Edit/Delete/ToggleActive, CompanyAdminOnly); GetTaxRateForCustomer
    AJAX endpoint; Tax Rates in Settings gear menu.

Also fixes AddVendorCredits migration: VendorCreditApplications FKs changed from
CASCADE to NoAction to resolve SQL Server error 1785 (multiple cascade paths).
Migration: AddPaymentTermsAndTaxRates applied locally; 200/200 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 10:55:22 -04:00
parent 1229081436
commit d3a5d827f9
27 changed files with 11492 additions and 12 deletions
@@ -713,6 +713,218 @@ public class FinancialReportService : IFinancialReportService
return method ?? AccountingMethod.Accrual;
}
/// <inheritdoc/>
public async Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to)
{
var toEnd = to.AddDays(1).AddTicks(-1);
var fromEnd = from.AddTicks(-1); // exclusive upper bound for pre-period queries
var companyName = await GetCompanyNameAsync(companyId);
var customer = await _context.Customers
.Where(c => c.Id == customerId && c.CompanyId == companyId)
.AsNoTracking().FirstOrDefaultAsync();
if (customer == null) return new CustomerStatementDto { CompanyName = companyName, From = from, To = to };
var customerName = customer.IsCommercial
? customer.CompanyName ?? string.Empty
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
var address = string.Join(", ", new[] { customer.Address, customer.City, customer.State, customer.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s)));
// Opening balance: invoiced paid before period start
var preInvoiced = await _context.Invoices
.Where(i => i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < from)
.SumAsync(i => (decimal?)i.Total) ?? 0;
var prePaid = await _context.Payments
.Where(p => p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate < from)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
var preCredits = await _context.CreditMemoApplications
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
var openingBalance = preInvoiced - prePaid - preCredits;
// In-period activity — gather, then sort, then compute running balance
var lines = new List<StatementLineDto>();
var periodInvoices = await _context.Invoices
.Where(i => i.CustomerId == customerId
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var inv in periodInvoices)
lines.Add(new StatementLineDto
{
Date = inv.InvoiceDate,
Type = "Invoice",
Reference = inv.InvoiceNumber,
Description = "Invoice",
Debit = inv.Total,
});
var periodPayments = await _context.Payments
.Include(p => p.Invoice)
.Where(p => p.Invoice.CustomerId == customerId
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var pay in periodPayments)
lines.Add(new StatementLineDto
{
Date = pay.PaymentDate,
Type = "Payment",
Reference = pay.Invoice.InvoiceNumber,
Description = pay.Notes ?? "Payment received",
Credit = pay.Amount,
});
var periodCredits = await _context.CreditMemoApplications
.Include(a => a.Invoice)
.Include(a => a.CreditMemo)
.Where(a => a.Invoice.CustomerId == customerId
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var cr in periodCredits)
lines.Add(new StatementLineDto
{
Date = cr.AppliedDate,
Type = "Credit Applied",
Reference = cr.Invoice?.InvoiceNumber ?? string.Empty,
Description = $"Credit memo applied",
Credit = cr.AmountApplied,
});
// Sort by date then compute running balance
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
var running = openingBalance;
foreach (var line in lines)
{
running += (line.Debit ?? 0) - (line.Credit ?? 0);
line.RunningBalance = running;
}
return new CustomerStatementDto
{
CustomerId = customerId,
CustomerName = customerName,
CustomerAddress = address,
CompanyName = companyName,
From = from,
To = to,
OpeningBalance = openingBalance,
Lines = lines,
ClosingBalance = running,
};
}
/// <inheritdoc/>
public async Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to)
{
var toEnd = to.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var vendor = await _context.Vendors
.Where(v => v.Id == vendorId && v.CompanyId == companyId)
.AsNoTracking().FirstOrDefaultAsync();
if (vendor == null) return new VendorStatementDto { CompanyName = companyName, From = from, To = to };
// Opening balance: bills payments credits before period start
var preBills = await _context.Bills
.Where(b => b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate < from)
.SumAsync(b => (decimal?)b.Total) ?? 0;
var prePayments = await _context.BillPayments
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
var preVcApplied = await _context.VendorCreditApplications
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
var openingBalance = preBills - prePayments - preVcApplied;
var lines = new List<StatementLineDto>();
var periodBills = await _context.Bills
.Where(b => b.VendorId == vendorId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate >= from && b.BillDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var bill in periodBills)
lines.Add(new StatementLineDto
{
Date = bill.BillDate,
Type = "Bill",
Reference = bill.BillNumber,
Description = bill.Memo ?? "Vendor bill",
Debit = bill.Total,
});
var periodPayments = await _context.BillPayments
.Include(bp => bp.Bill)
.Where(bp => bp.Bill.VendorId == vendorId
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var pay in periodPayments)
lines.Add(new StatementLineDto
{
Date = pay.PaymentDate,
Type = "Payment",
Reference = pay.Bill.BillNumber,
Description = pay.Memo ?? "Bill payment",
Credit = pay.Amount,
});
var periodVcApplied = await _context.VendorCreditApplications
.Include(vca => vca.VendorCredit)
.Include(vca => vca.Bill)
.Where(vca => vca.Bill.VendorId == vendorId
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync();
foreach (var vca in periodVcApplied)
lines.Add(new StatementLineDto
{
Date = vca.AppliedDate,
Type = "Credit Applied",
Reference = vca.VendorCredit.CreditNumber,
Description = $"Vendor credit applied to {vca.Bill.BillNumber}",
Credit = vca.Amount,
});
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
var running = openingBalance;
foreach (var line in lines)
{
running += (line.Debit ?? 0) - (line.Credit ?? 0);
line.RunningBalance = running;
}
return new VendorStatementDto
{
VendorId = vendorId,
VendorName = vendor.CompanyName,
CompanyName = companyName,
From = from,
To = to,
OpeningBalance = openingBalance,
Lines = lines,
ClosingBalance = running,
};
}
/// <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.