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:
@@ -6,6 +6,47 @@ namespace PowderCoating.Application.DTOs.Accounting;
|
||||
// without needing a separate round-trip to the company settings.
|
||||
|
||||
|
||||
// ── Customer / Vendor Statements ─────────────────────────────────────────────
|
||||
|
||||
public class CustomerStatementDto
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CustomerAddress { get; set; }
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class VendorStatementDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class StatementLineDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
|
||||
public decimal? Debit { get; set; }
|
||||
/// <summary>Amount reducing the balance (payment, credit).</summary>
|
||||
public decimal? Credit { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ApAgingReportDto
|
||||
|
||||
@@ -35,4 +35,10 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
@@ -285,3 +285,20 @@ public class VendorCreditApplication : BaseEntity
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
|
||||
/// invoices when a taxable customer is selected. Companies can define multiple rates for
|
||||
/// different jurisdictions and mark one as default.
|
||||
/// </summary>
|
||||
public class TaxRate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,19 @@ public class Invoice : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
/// Parsed from the customer's payment terms when the invoice is created (e.g., "2/10 Net 30").
|
||||
/// Informational only — does not automatically reduce the amount due.
|
||||
/// </summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of days after invoice date within which the early payment discount applies.
|
||||
/// Parsed from the customer's payment terms (e.g., "2/10 Net 30" → 10 days).
|
||||
/// </summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
||||
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
||||
|
||||
@@ -103,6 +103,9 @@ public interface IUnitOfWork : IDisposable
|
||||
// Bank Reconciliation
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Tax Rates
|
||||
IRepository<TaxRate> TaxRates { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,6 +26,7 @@ public class CustomersController : Controller
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
|
||||
public CustomersController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -34,7 +35,8 @@ public class CustomersController : Controller
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IFinancialReportService financialReports)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -43,6 +45,7 @@ public class CustomersController : Controller
|
||||
_subscriptionService = subscriptionService;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_financialReports = financialReports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -935,6 +938,30 @@ public class CustomersController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays or downloads a dated activity statement for a customer.
|
||||
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
var toDate = to ?? DateTime.Today;
|
||||
|
||||
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
|
||||
|
||||
if (pdf)
|
||||
{
|
||||
var bytes = StatementPdfHelper.Generate(
|
||||
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
|
||||
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
|
||||
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
||||
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
||||
|
||||
@@ -1851,6 +1851,54 @@ public class InvoicesController : Controller
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the customer's payment terms, derived due date, and early-payment discount info
|
||||
/// for the Invoice Create form so JavaScript can auto-populate those fields on customer selection.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomerPaymentTerms(int customerId)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
var invoiceDate = DateTime.Today;
|
||||
var dueDate = PaymentTermsParser.CalculateDueDate(customer.PaymentTerms, invoiceDate);
|
||||
var (discountPercent, discountDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(customer.PaymentTerms);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
paymentTerms = customer.PaymentTerms,
|
||||
dueDate = dueDate?.ToString("yyyy-MM-dd"),
|
||||
earlyPaymentDiscountPercent = discountPercent,
|
||||
earlyPaymentDiscountDays = discountDays,
|
||||
isTaxExempt = customer.IsTaxExempt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the default active tax rate for the current company, or zero for tax-exempt customers.
|
||||
/// Called by the Invoice Create form when the customer selection changes.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTaxRateForCustomer(int customerId)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
if (customer.IsTaxExempt)
|
||||
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
|
||||
|
||||
var defaultRate = await _unitOfWork.TaxRates
|
||||
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
taxPercent = defaultRate?.Rate ?? 0m,
|
||||
taxRateName = defaultRate?.Name
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates ViewBag data used by both Create GET and Create POST (on validation failure re-display):
|
||||
/// — Active customer list for the customer dropdown.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages named tax rates used to pre-fill the tax percent field on invoices when a taxable
|
||||
/// customer is selected. Only one rate may be marked as default at a time; that default is
|
||||
/// auto-applied via the GetTaxRateForCustomer AJAX endpoint on the Invoice Create form.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class TaxRatesController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<TaxRatesController> _logger;
|
||||
|
||||
public TaxRatesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<TaxRatesController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Lists all tax rates for the current company.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var rates = await _unitOfWork.TaxRates.GetAllAsync();
|
||||
return View(rates.OrderBy(r => r.Name).ToList());
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Create() => View(new TaxRate());
|
||||
|
||||
/// <summary>Creates a new tax rate. Enforces that only one rate is the default.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(TaxRate model)
|
||||
{
|
||||
if (!ModelState.IsValid) return View(model);
|
||||
|
||||
if (model.IsDefault)
|
||||
await ClearOtherDefaultsAsync(0);
|
||||
|
||||
await _unitOfWork.TaxRates.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Tax rate \"{model.Name}\" created.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
return View(rate);
|
||||
}
|
||||
|
||||
/// <summary>Updates an existing tax rate. Clears default flag on other rates when IsDefault is set.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, TaxRate model)
|
||||
{
|
||||
if (id != model.Id) return BadRequest();
|
||||
if (!ModelState.IsValid) return View(model);
|
||||
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
|
||||
if (model.IsDefault && !rate.IsDefault)
|
||||
await ClearOtherDefaultsAsync(id);
|
||||
|
||||
rate.Name = model.Name;
|
||||
rate.Rate = model.Rate;
|
||||
rate.State = model.State;
|
||||
rate.Description = model.Description;
|
||||
rate.IsDefault = model.IsDefault;
|
||||
rate.IsActive = model.IsActive;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Tax rate \"{rate.Name}\" updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Toggles IsActive without a full page reload.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleActive(int id)
|
||||
{
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
|
||||
rate.IsActive = !rate.IsActive;
|
||||
if (!rate.IsActive) rate.IsDefault = false;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes a tax rate. Blocked when the rate is currently the default.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
|
||||
if (rate.IsDefault)
|
||||
{
|
||||
TempData["Error"] = "Cannot delete the default tax rate. Set another rate as default first.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
await _unitOfWork.TaxRates.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Tax rate \"{rate.Name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears IsDefault on all rates except the one with <paramref name="exceptId"/>.
|
||||
/// Called before saving a newly-designated default to enforce the single-default invariant.
|
||||
/// </summary>
|
||||
private async Task ClearOtherDefaultsAsync(int exceptId)
|
||||
{
|
||||
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
|
||||
foreach (var r in others)
|
||||
r.IsDefault = false;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Vendor;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -27,17 +29,23 @@ public class VendorsController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<VendorsController> _logger;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public VendorsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<VendorsController> logger)
|
||||
ILogger<VendorsController> logger,
|
||||
IFinancialReportService financialReports,
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_financialReports = financialReports;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -377,6 +385,29 @@ public class VendorsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Populates <c>ViewBag.ExpenseAccounts</c> with active Expense, Cost of Goods, and Asset accounts
|
||||
/// <summary>
|
||||
/// Displays or downloads a dated activity statement for a vendor.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
var toDate = to ?? DateTime.Today;
|
||||
|
||||
var dto = await _financialReports.GetVendorStatementAsync(companyId, id, fromDate, toDate);
|
||||
|
||||
if (pdf)
|
||||
{
|
||||
var bytes = StatementPdfHelper.Generate(
|
||||
dto.VendorName, dto.CompanyName, null,
|
||||
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: true);
|
||||
return File(bytes, "application/pdf", $"Statement-{dto.VendorName}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// for the vendor's default expense account dropdown. All three account types are included because
|
||||
/// vendor bills can legitimately be coded to COGS (powder, materials) or asset accounts (equipment
|
||||
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses payment terms strings (e.g., "Net 30", "2/10 Net 30", "Due on Receipt")
|
||||
/// to compute due dates and extract early-payment discount terms.
|
||||
/// </summary>
|
||||
public static class PaymentTermsParser
|
||||
{
|
||||
private static readonly Regex NetDaysRegex = new(@"\bnet\s+(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex EarlyDiscountRegex = new(@"(\d+(?:\.\d+)?)/(\d+)\s+net", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the due date calculated from <paramref name="invoiceDate"/> and the supplied terms.
|
||||
/// Returns null when the terms string cannot be parsed.
|
||||
/// </summary>
|
||||
public static DateTime? CalculateDueDate(string? terms, DateTime invoiceDate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(terms)) return null;
|
||||
|
||||
var normalized = terms.Trim().ToLowerInvariant();
|
||||
if (Regex.IsMatch(normalized, @"\b(receipt|due\s*now|cod|immediate)\b"))
|
||||
return invoiceDate;
|
||||
|
||||
var match = NetDaysRegex.Match(terms);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var days))
|
||||
return invoiceDate.AddDays(days);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts early-payment discount terms from a string like "2/10 Net 30".
|
||||
/// Returns (percent: 2, days: 10) for that example, or (0, 0) if not present.
|
||||
/// </summary>
|
||||
public static (decimal Percent, int Days) ParseEarlyPaymentDiscount(string? terms)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(terms)) return (0, 0);
|
||||
|
||||
var match = EarlyDiscountRegex.Match(terms);
|
||||
if (match.Success
|
||||
&& decimal.TryParse(match.Groups[1].Value, out var percent)
|
||||
&& int.TryParse(match.Groups[2].Value, out var days))
|
||||
return (percent, days);
|
||||
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a QuestPDF account-activity statement. Shared by customer and vendor statement actions.
|
||||
/// </summary>
|
||||
public static class StatementPdfHelper
|
||||
{
|
||||
/// <summary>Generates and returns the raw PDF bytes for a statement.</summary>
|
||||
public static byte[] Generate(
|
||||
string entityName,
|
||||
string companyName,
|
||||
string? address,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
decimal openingBalance,
|
||||
List<StatementLineDto> lines,
|
||||
decimal closingBalance,
|
||||
bool isVendor)
|
||||
{
|
||||
var doc = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(40);
|
||||
page.Size(PageSizes.Letter);
|
||||
page.DefaultTextStyle(t => t.FontSize(10));
|
||||
|
||||
page.Header().Element(h =>
|
||||
{
|
||||
h.Column(col =>
|
||||
{
|
||||
col.Item().Text(companyName).Bold().FontSize(16);
|
||||
col.Item().Text($"{(isVendor ? "Vendor" : "Customer")} Statement").FontSize(12).FontColor("#555555");
|
||||
col.Item().Text($"Period: {from:MMM d, yyyy} – {to:MMM d, yyyy}");
|
||||
col.Item().PaddingTop(4).Text(entityName).Bold();
|
||||
if (!string.IsNullOrWhiteSpace(address))
|
||||
col.Item().Text(address).FontColor("#555555");
|
||||
});
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(16).Column(col =>
|
||||
{
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c =>
|
||||
{
|
||||
c.ConstantColumn(72); // Date
|
||||
c.RelativeColumn(2); // Type / Ref
|
||||
c.RelativeColumn(3); // Description
|
||||
c.ConstantColumn(80); // Debit
|
||||
c.ConstantColumn(80); // Credit
|
||||
c.ConstantColumn(90); // Balance
|
||||
});
|
||||
|
||||
static IContainer HeaderCell(IContainer c) =>
|
||||
c.Background("#2c3e50").PaddingVertical(4).PaddingHorizontal(6);
|
||||
|
||||
t.Header(h =>
|
||||
{
|
||||
h.Cell().Element(HeaderCell).Text("Date").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).Text("Type / Ref").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).Text("Description").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).AlignRight().Text("Debit").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).AlignRight().Text("Credit").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).AlignRight().Text("Balance").FontColor("white").Bold();
|
||||
});
|
||||
|
||||
static IContainer DataCell(IContainer c) =>
|
||||
c.BorderBottom(0.5f).BorderColor("#dddddd").PaddingVertical(3).PaddingHorizontal(6);
|
||||
|
||||
// Opening balance row
|
||||
t.Cell().Element(DataCell).Text(from.AddDays(-1).ToString("MM/dd/yy")).FontColor("#888888");
|
||||
t.Cell().Element(DataCell).Text("Opening Balance").Bold();
|
||||
t.Cell().Element(DataCell).Text("");
|
||||
t.Cell().Element(DataCell).Text("");
|
||||
t.Cell().Element(DataCell).Text("");
|
||||
t.Cell().Element(DataCell).AlignRight().Text(openingBalance.ToString("C"));
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
t.Cell().Element(DataCell).Text(line.Date.ToString("MM/dd/yy"));
|
||||
t.Cell().Element(DataCell).Text($"{line.Type}\n{line.Reference}");
|
||||
t.Cell().Element(DataCell).Text(line.Description);
|
||||
t.Cell().Element(DataCell).AlignRight().Text(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "");
|
||||
t.Cell().Element(DataCell).AlignRight().Text(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "");
|
||||
t.Cell().Element(DataCell).AlignRight().Text(line.RunningBalance.ToString("C"));
|
||||
}
|
||||
|
||||
static IContainer TotalCell(IContainer c) =>
|
||||
c.Background("#f0f4f8").PaddingVertical(4).PaddingHorizontal(6);
|
||||
|
||||
t.Cell().ColumnSpan(5).Element(TotalCell).Text("Closing Balance").Bold();
|
||||
t.Cell().Element(TotalCell).AlignRight().Text(closingBalance.ToString("C")).Bold();
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(t =>
|
||||
{
|
||||
t.Span($"Generated {DateTime.Now:MM/dd/yyyy HH:mm}").FontSize(8).FontColor("#888888");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return doc.GeneratePdf();
|
||||
}
|
||||
}
|
||||
@@ -459,6 +459,9 @@
|
||||
<a asp-action="Invoices" asp-route-id="@Model.Id" class="btn btn-outline-warning">
|
||||
<i class="bi bi-receipt me-2"></i>View Invoices
|
||||
</a>
|
||||
<a asp-action="Statement" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-journal-text me-2"></i>Statement
|
||||
</a>
|
||||
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Job
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CustomerStatementDto
|
||||
@{
|
||||
ViewData["Title"] = $"Statement – {Model.CustomerName}";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0">Customer Statement</h4>
|
||||
<p class="text-muted mb-0">@Model.CustomerName · @Model.From.ToString("MMM d, yyyy") – @Model.To.ToString("MMM d, yyyy")</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="get" class="d-flex gap-2 align-items-center">
|
||||
<input type="hidden" name="id" value="@(ViewContext.RouteData.Values["id"])" />
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
||||
</form>
|
||||
<a asp-action="Statement" asp-route-id="@(ViewContext.RouteData.Values["id"])"
|
||||
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
|
||||
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
|
||||
asp-route-pdf="true"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
|
||||
</a>
|
||||
<a asp-action="Details" asp-route-id="@(ViewContext.RouteData.Values["id"])" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="fw-semibold">@Model.CustomerName</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerAddress))
|
||||
{
|
||||
<span class="text-muted small ms-2">@Model.CustomerAddress</span>
|
||||
}
|
||||
</div>
|
||||
<div class="text-muted small">@Model.CompanyName</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th style="width:100px">Date</th>
|
||||
<th style="width:120px">Type</th>
|
||||
<th style="width:130px">Reference</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end" style="width:110px">Debit</th>
|
||||
<th class="text-end" style="width:110px">Credit</th>
|
||||
<th class="text-end" style="width:120px">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Opening balance -->
|
||||
<tr class="table-light fw-semibold">
|
||||
<td class="text-muted">@Model.From.AddDays(-1).ToString("MM/dd/yy")</td>
|
||||
<td colspan="5">Opening Balance</td>
|
||||
<td class="text-end">@Model.OpeningBalance.ToString("C")</td>
|
||||
</tr>
|
||||
|
||||
@if (!Model.Lines.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">No activity in this period.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var line in Model.Lines)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-muted small">@line.Date.ToString("MM/dd/yy")</td>
|
||||
<td>
|
||||
<span class="badge @(line.Type == "Invoice" ? "bg-primary" : line.Type == "Payment" ? "bg-success" : "bg-secondary") text-white">
|
||||
@line.Type
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">@line.Reference</td>
|
||||
<td class="small text-muted">@line.Description</td>
|
||||
<td class="text-end small">@(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small">@(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small @(line.RunningBalance > 0 ? "text-danger" : "text-success") fw-semibold">
|
||||
@line.RunningBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Closing balance -->
|
||||
<tr class="table-secondary fw-bold">
|
||||
<td colspan="6">Closing Balance</td>
|
||||
<td class="text-end @(Model.ClosingBalance > 0 ? "text-danger" : "text-success")">
|
||||
@Model.ClosingBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,6 +180,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
|
||||
<div id="earlyPaymentDiscountNotice" class="form-text text-success d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,11 +457,52 @@
|
||||
function onCustomerChanged(select) {
|
||||
document.getElementById('hiddenCustomerId').value = select.value;
|
||||
const customerId = parseInt(select.value) || 0;
|
||||
const taxField = document.getElementById('TaxPercent');
|
||||
if (taxField) {
|
||||
taxField.value = taxExemptCustomerIds.has(customerId) ? 0 : companyTaxPercent;
|
||||
recalcTotals();
|
||||
}
|
||||
if (!customerId) return;
|
||||
|
||||
// Fetch payment terms + tax rate for the selected customer
|
||||
fetch(`/Invoices/GetCustomerPaymentTerms?customerId=${customerId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
// Auto-fill Terms dropdown
|
||||
const termsSelect = document.getElementById('Terms');
|
||||
if (termsSelect && data.paymentTerms) {
|
||||
// Set the matching option, or fall back to the raw value
|
||||
const opt = Array.from(termsSelect.options).find(o => o.value === data.paymentTerms);
|
||||
if (opt) termsSelect.value = data.paymentTerms;
|
||||
// Trigger due date recalculation (invoice-due-date.js listens to 'change')
|
||||
termsSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
// Show/hide early payment discount notice
|
||||
const discountEl = document.getElementById('earlyPaymentDiscountNotice');
|
||||
if (discountEl) {
|
||||
if (data.earlyPaymentDiscountPercent > 0) {
|
||||
discountEl.textContent = `${data.earlyPaymentDiscountPercent}% discount if paid within ${data.earlyPaymentDiscountDays} days`;
|
||||
discountEl.classList.remove('d-none');
|
||||
} else {
|
||||
discountEl.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch tax rate for the selected customer
|
||||
fetch(`/Invoices/GetTaxRateForCustomer?customerId=${customerId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
const taxField = document.getElementById('TaxPercent');
|
||||
if (taxField) {
|
||||
taxField.value = data.taxPercent ?? 0;
|
||||
recalcTotals();
|
||||
}
|
||||
}).catch(() => {
|
||||
// Fall back to client-side tax exempt check
|
||||
const taxField = document.getElementById('TaxPercent');
|
||||
if (taxField) {
|
||||
taxField.value = taxExemptCustomerIds.has(customerId) ? 0 : companyTaxPercent;
|
||||
recalcTotals();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Merchandise combobox ────────────────────────────────────────────────
|
||||
|
||||
@@ -1516,6 +1516,7 @@
|
||||
{
|
||||
<li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li>
|
||||
<li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li>
|
||||
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@if (gearIsAdmin)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
@model PowderCoating.Core.Entities.TaxRate
|
||||
@{
|
||||
ViewData["Title"] = "Add Tax Rate";
|
||||
}
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-0">Add Tax Rate</h4>
|
||||
<p class="text-muted small mb-0">Define a named tax rate to auto-fill invoice tax percent by jurisdiction.</p>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm" style="max-width:600px">
|
||||
<div class="card-body">
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Name" class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
|
||||
<input asp-for="Name" class="form-control" placeholder="e.g., CA Sales Tax" />
|
||||
<span asp-validation-for="Name" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Rate" class="form-label fw-semibold">Rate (%) <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="Rate" type="number" step="0.0001" min="0" max="100" class="form-control" placeholder="8.25" />
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
<span asp-validation-for="Rate" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="State" class="form-label fw-semibold">State</label>
|
||||
<input asp-for="State" class="form-control" placeholder="e.g., CA" maxlength="2" />
|
||||
<span asp-validation-for="State" class="text-danger small"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Description" class="form-label fw-semibold">Description</label>
|
||||
<input asp-for="Description" class="form-control" placeholder="Optional notes" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsDefault" class="form-check-label">
|
||||
Default rate
|
||||
<span class="text-muted small d-block">Automatically applied to new invoices for taxable customers.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check">
|
||||
<input asp-for="IsActive" class="form-check-input" type="checkbox" checked />
|
||||
<label asp-for="IsActive" class="form-check-label">Active</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Save Tax Rate</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
@model PowderCoating.Core.Entities.TaxRate
|
||||
@{
|
||||
ViewData["Title"] = "Edit Tax Rate";
|
||||
}
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-0">Edit Tax Rate</h4>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm" style="max-width:600px">
|
||||
<div class="card-body">
|
||||
<form asp-action="Edit" asp-route-id="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" asp-for="Id" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Name" class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
|
||||
<input asp-for="Name" class="form-control" />
|
||||
<span asp-validation-for="Name" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Rate" class="form-label fw-semibold">Rate (%) <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="Rate" type="number" step="0.0001" min="0" max="100" class="form-control" />
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
<span asp-validation-for="Rate" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="State" class="form-label fw-semibold">State</label>
|
||||
<input asp-for="State" class="form-control" maxlength="2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Description" class="form-label fw-semibold">Description</label>
|
||||
<input asp-for="Description" class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsDefault" class="form-check-label">
|
||||
Default rate
|
||||
<span class="text-muted small d-block">Automatically applied to new invoices for taxable customers.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check">
|
||||
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsActive" class="form-check-label">Active</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
@model List<PowderCoating.Core.Entities.TaxRate>
|
||||
@{
|
||||
ViewData["Title"] = "Tax Rates";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4 class="mb-0">Tax Rates</h4>
|
||||
<p class="text-muted mb-0 small">Named rates used to auto-fill invoice tax percent when a taxable customer is selected.</p>
|
||||
</div>
|
||||
<a asp-action="Create" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Tax Rate
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show" role="alert">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-percent fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-1">No tax rates defined yet.</p>
|
||||
<p class="small">Add a rate and mark it as default to auto-populate tax on invoices.</p>
|
||||
<a asp-action="Create" class="btn btn-primary btn-sm mt-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add First Tax Rate
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Rate</th>
|
||||
<th>State</th>
|
||||
<th>Description</th>
|
||||
<th class="text-center">Default</th>
|
||||
<th class="text-center">Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rate in Model)
|
||||
{
|
||||
<tr class="@(!rate.IsActive ? "opacity-50" : "")">
|
||||
<td>
|
||||
@rate.Name
|
||||
@if (rate.IsDefault)
|
||||
{
|
||||
<span class="badge bg-success ms-1">Default</span>
|
||||
}
|
||||
</td>
|
||||
<td>@rate.Rate.ToString("0.##")%</td>
|
||||
<td>@(rate.State ?? "—")</td>
|
||||
<td class="text-muted small">@(rate.Description ?? "—")</td>
|
||||
<td class="text-center">
|
||||
@if (rate.IsDefault)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<form asp-action="ToggleActive" asp-route-id="@rate.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-link p-0 border-0"
|
||||
title="@(rate.IsActive ? "Deactivate" : "Activate")">
|
||||
<i class="bi @(rate.IsActive ? "bi-toggle-on text-success fs-5" : "bi-toggle-off text-muted fs-5")"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a asp-action="Edit" asp-route-id="@rate.Id" class="btn btn-sm btn-outline-secondary me-1">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
@if (!rate.IsDefault)
|
||||
{
|
||||
<form asp-action="Delete" asp-route-id="@rate.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete this tax rate?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,6 +10,9 @@
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="d-flex justify-content-end gap-2 mb-4">
|
||||
<a asp-action="Statement" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-journal-text me-2"></i>Statement
|
||||
</a>
|
||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
||||
<i class="bi bi-pencil me-2"></i>Edit
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.VendorStatementDto
|
||||
@{
|
||||
ViewData["Title"] = $"Statement – {Model.VendorName}";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0">Vendor Statement</h4>
|
||||
<p class="text-muted mb-0">@Model.VendorName · @Model.From.ToString("MMM d, yyyy") – @Model.To.ToString("MMM d, yyyy")</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="get" class="d-flex gap-2 align-items-center">
|
||||
<input type="hidden" name="id" value="@(ViewContext.RouteData.Values["id"])" />
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
||||
</form>
|
||||
<a asp-action="Statement" asp-route-id="@(ViewContext.RouteData.Values["id"])"
|
||||
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
|
||||
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
|
||||
asp-route-pdf="true"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
|
||||
</a>
|
||||
<a asp-action="Details" asp-route-id="@(ViewContext.RouteData.Values["id"])" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-white d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">@Model.VendorName</span>
|
||||
<div class="text-muted small">@Model.CompanyName</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th style="width:100px">Date</th>
|
||||
<th style="width:120px">Type</th>
|
||||
<th style="width:130px">Reference</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end" style="width:110px">Debit</th>
|
||||
<th class="text-end" style="width:110px">Credit</th>
|
||||
<th class="text-end" style="width:120px">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="table-light fw-semibold">
|
||||
<td class="text-muted">@Model.From.AddDays(-1).ToString("MM/dd/yy")</td>
|
||||
<td colspan="5">Opening Balance</td>
|
||||
<td class="text-end">@Model.OpeningBalance.ToString("C")</td>
|
||||
</tr>
|
||||
|
||||
@if (!Model.Lines.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">No activity in this period.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var line in Model.Lines)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-muted small">@line.Date.ToString("MM/dd/yy")</td>
|
||||
<td>
|
||||
<span class="badge @(line.Type == "Bill" ? "bg-danger" : line.Type == "Payment" ? "bg-success" : "bg-secondary") text-white">
|
||||
@line.Type
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">@line.Reference</td>
|
||||
<td class="small text-muted">@line.Description</td>
|
||||
<td class="text-end small">@(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small">@(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small @(line.RunningBalance > 0 ? "text-danger" : "text-success") fw-semibold">
|
||||
@line.RunningBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
<tr class="table-secondary fw-bold">
|
||||
<td colspan="6">Closing Balance</td>
|
||||
<td class="text-end @(Model.ClosingBalance > 0 ? "text-danger" : "text-success")">
|
||||
@Model.ClosingBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user