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:
@@ -332,6 +332,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<TaxRate> TaxRates { get; set; }
|
||||
|
||||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||
@@ -638,12 +641,29 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Tax Rates: tenant-filtered
|
||||
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
|
||||
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.Bill)
|
||||
.WithMany()
|
||||
.HasForeignKey(vca => vca.BillId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.VendorCredit)
|
||||
.WithMany()
|
||||
.HasForeignKey(vca => vca.VendorCreditId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Purchase Orders
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
@@ -78,13 +78,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
column: x => x.BillId,
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
|
||||
Generated
+10105
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPaymentTermsAndTaxRates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TaxRates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Rate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TaxRates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TaxRates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3586,6 +3586,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("DueDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("EarlyPaymentDiscountDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("EarlyPaymentDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ExternalReference")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
@@ -6287,7 +6293,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6298,7 +6304,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6309,7 +6315,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7742,6 +7748,62 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("SubscriptionPlanConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TaxRate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Rate")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaxRates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -154,6 +154,9 @@ public class UnitOfWork : IUnitOfWork
|
||||
// Bank Reconciliation
|
||||
private IRepository<BankReconciliation>? _bankReconciliations;
|
||||
|
||||
// Tax Rates
|
||||
private IRepository<TaxRate>? _taxRates;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the unit of work with the scoped <paramref name="context"/>.
|
||||
/// The context is shared across all repositories created by this instance so that
|
||||
@@ -552,6 +555,11 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<BankReconciliation> BankReconciliations =>
|
||||
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
|
||||
|
||||
// Tax Rates
|
||||
/// <summary>Repository for <see cref="TaxRate"/> named tax rates used to pre-fill invoice tax percent by jurisdiction.</summary>
|
||||
public IRepository<TaxRate> TaxRates =>
|
||||
_taxRates ??= new Repository<TaxRate>(_context);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all pending changes in the EF Core change tracker to the database.
|
||||
/// Returns the number of state entries written.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user