Phase F: Add Invoice Write-Off, Fixed Assets, Period Locking, and 1099 Tracking

- Invoice Write-Off: WriteOff POST action in InvoicesController posts bad-debt JE
  (DR bad debt expense / CR AR), reduces customer balance, marks invoice WrittenOff;
  write-off modal added to Invoice Details view with expense account selector
- Fixed Assets: FixedAsset + FixedAssetDepreciationEntry entities with straight-line
  depreciation; FixedAssetsController (Index/Create/Edit/Details/PostDepreciation/Delete);
  PostDepreciation auto-generates one JE per asset per period, skips already-posted,
  fully-depreciated, and disposed assets; full CRUD views + nav link
- Period Locking: Company.BookLockedThrough field; AccountingPeriodValidator static helper;
  lock check added to JE Post and Bill Create (blocks backdating into closed periods);
  SetPeriodLock action + date picker UI in Company Settings Accounting section
- 1099 Tracking: Is1099Vendor flag on Vendor entity + DTOs; checkbox in Create/Edit views;
  TaxReporting1099 report action + view lists payments by year, flags vendors >= $600;
  report card added to Reports Landing
- Migration AddFixedAssetsLockAnd1099 applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:19:32 -04:00
parent a255893ada
commit fde24b09c9
29 changed files with 12520 additions and 3 deletions
@@ -120,6 +120,9 @@ public class CreateVendorDto
[Display(Name = "Preferred Vendor")]
public bool IsPreferred { get; set; } = false;
[Display(Name = "1099 Vendor")]
public bool Is1099Vendor { get; set; } = false;
[Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; }
}
@@ -201,6 +204,9 @@ public class UpdateVendorDto
[Display(Name = "Preferred Vendor")]
public bool IsPreferred { get; set; }
[Display(Name = "1099 Vendor")]
public bool Is1099Vendor { get; set; }
[Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; }
}
@@ -334,3 +334,64 @@ public class TaxRate : BaseEntity
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
/// to auto-post monthly depreciation journal entries.
/// </summary>
public class FixedAsset : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime PurchaseDate { get; set; }
public decimal PurchaseCost { get; set; }
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
public decimal SalvageValue { get; set; } = 0;
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
public int UsefulLifeMonths { get; set; }
/// <summary>Running total of depreciation posted so far.</summary>
public decimal AccumulatedDepreciation { get; set; } = 0;
public bool IsDisposed { get; set; } = false;
public DateTime? DisposalDate { get; set; }
// Computed — not persisted
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
/// <summary>Straight-line monthly depreciation amount.</summary>
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
public int? AssetAccountId { get; set; }
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
public int? DepreciationExpenseAccountId { get; set; }
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
public int? AccumDepreciationAccountId { get; set; }
// Navigation
public virtual Account? AssetAccount { get; set; }
public virtual Account? DepreciationExpenseAccount { get; set; }
public virtual Account? AccumDepreciationAccount { get; set; }
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
}
/// <summary>
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
/// month/year combination; linked to the JournalEntry that was created so the posting
/// can be traced back through the GL.
/// </summary>
public class FixedAssetDepreciationEntry : BaseEntity
{
public int FixedAssetId { get; set; }
public int PeriodYear { get; set; }
public int PeriodMonth { get; set; }
public decimal Amount { get; set; }
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
public int? JournalEntryId { get; set; }
// Navigation
public virtual FixedAsset FixedAsset { get; set; } = null!;
public virtual JournalEntry? JournalEntry { get; set; }
}
@@ -112,6 +112,12 @@ public class Company : BaseEntity
/// </summary>
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
/// <summary>
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
/// </summary>
public DateTime? BookLockedThrough { get; set; }
// Settings
public string? TimeZone { get; set; } = "America/New_York";
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
@@ -35,6 +35,10 @@ public class Vendor : BaseEntity
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
public int? DefaultExpenseAccountId { get; set; }
// 1099 Contractor tracking
/// <summary>When true, this vendor is an independent contractor subject to 1099-NEC reporting.</summary>
public bool Is1099Vendor { get; set; } = false;
// Navigation
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
@@ -109,6 +109,10 @@ public interface IUnitOfWork : IDisposable
// Recurring Transactions
IRepository<RecurringTemplate> RecurringTemplates { get; }
// Fixed Assets
IRepository<FixedAsset> FixedAssets { get; }
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; }
@@ -338,6 +338,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
public DbSet<FixedAsset> FixedAssets { get; set; }
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { 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>
@@ -652,6 +657,36 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
// reverse collection for FixedAssets so WithMany() is anonymous for each.
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.AssetAccount)
.WithMany()
.HasForeignKey(fa => fa.AssetAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.DepreciationExpenseAccount)
.WithMany()
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FixedAsset>()
.HasOne(fa => fa.AccumDepreciationAccount)
.WithMany()
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
.OnDelete(DeleteBehavior.Restrict);
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
modelBuilder.Entity<FixedAssetDepreciationEntry>()
.HasOne(e => e.JournalEntry)
.WithMany()
.HasForeignKey(e => e.JournalEntryId)
.OnDelete(DeleteBehavior.NoAction);
// Vendor Credits: tenant-filtered; child rows soft-delete only
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFixedAssetsLockAnd1099 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Is1099Vendor",
table: "Vendors",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "BookLockedThrough",
table: "Companies",
type: "datetime2",
nullable: true);
migrationBuilder.CreateTable(
name: "FixedAssets",
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),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
PurchaseDate = table.Column<DateTime>(type: "datetime2", nullable: false),
PurchaseCost = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
SalvageValue = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
UsefulLifeMonths = table.Column<int>(type: "int", nullable: false),
AccumulatedDepreciation = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
IsDisposed = table.Column<bool>(type: "bit", nullable: false),
DisposalDate = table.Column<DateTime>(type: "datetime2", nullable: true),
AssetAccountId = table.Column<int>(type: "int", nullable: true),
DepreciationExpenseAccountId = table.Column<int>(type: "int", nullable: true),
AccumDepreciationAccountId = table.Column<int>(type: "int", nullable: true),
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_FixedAssets", x => x.Id);
table.ForeignKey(
name: "FK_FixedAssets_Accounts_AccumDepreciationAccountId",
column: x => x.AccumDepreciationAccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_FixedAssets_Accounts_AssetAccountId",
column: x => x.AssetAccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_FixedAssets_Accounts_DepreciationExpenseAccountId",
column: x => x.DepreciationExpenseAccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "FixedAssetDepreciationEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FixedAssetId = table.Column<int>(type: "int", nullable: false),
PeriodYear = table.Column<int>(type: "int", nullable: false),
PeriodMonth = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
JournalEntryId = table.Column<int>(type: "int", nullable: true),
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_FixedAssetDepreciationEntries", x => x.Id);
table.ForeignKey(
name: "FK_FixedAssetDepreciationEntries_FixedAssets_FixedAssetId",
column: x => x.FixedAssetId,
principalTable: "FixedAssets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_FixedAssetDepreciationEntries_JournalEntries_JournalEntryId",
column: x => x.JournalEntryId,
principalTable: "JournalEntries",
principalColumn: "Id");
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
migrationBuilder.CreateIndex(
name: "IX_FixedAssetDepreciationEntries_FixedAssetId",
table: "FixedAssetDepreciationEntries",
column: "FixedAssetId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssetDepreciationEntries_JournalEntryId",
table: "FixedAssetDepreciationEntries",
column: "JournalEntryId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssets_AccumDepreciationAccountId",
table: "FixedAssets",
column: "AccumDepreciationAccountId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssets_AssetAccountId",
table: "FixedAssets",
column: "AssetAccountId");
migrationBuilder.CreateIndex(
name: "IX_FixedAssets_DepreciationExpenseAccountId",
table: "FixedAssets",
column: "DepreciationExpenseAccountId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FixedAssetDepreciationEntries");
migrationBuilder.DropTable(
name: "FixedAssets");
migrationBuilder.DropColumn(
name: "Is1099Vendor",
table: "Vendors");
migrationBuilder.DropColumn(
name: "BookLockedThrough",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
}
}
}
@@ -1633,6 +1633,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AiPhotoQuotesEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("BookLockedThrough")
.HasColumnType("datetime2");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
@@ -2994,6 +2997,142 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Expenses");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccumDepreciationAccountId")
.HasColumnType("int");
b.Property<decimal>("AccumulatedDepreciation")
.HasColumnType("decimal(18,2)");
b.Property<int?>("AssetAccountId")
.HasColumnType("int");
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<int?>("DepreciationExpenseAccountId")
.HasColumnType("int");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DisposalDate")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsDisposed")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("PurchaseCost")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("PurchaseDate")
.HasColumnType("datetime2");
b.Property<decimal>("SalvageValue")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("UsefulLifeMonths")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AccumDepreciationAccountId");
b.HasIndex("AssetAccountId");
b.HasIndex("DepreciationExpenseAccountId");
b.ToTable("FixedAssets");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
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<int>("FixedAssetId")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int?>("JournalEntryId")
.HasColumnType("int");
b.Property<int>("PeriodMonth")
.HasColumnType("int");
b.Property<int>("PeriodYear")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("FixedAssetId");
b.HasIndex("JournalEntryId");
b.ToTable("FixedAssetDepreciationEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.Property<int>("Id")
@@ -6293,7 +6432,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199),
CreatedAt = new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6304,7 +6443,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205),
CreatedAt = new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6315,7 +6454,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206),
CreatedAt = new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -8010,6 +8149,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Is1099Vendor")
.HasColumnType("bit");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@@ -8703,6 +8845,48 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
{
b.HasOne("PowderCoating.Core.Entities.Account", "AccumDepreciationAccount")
.WithMany()
.HasForeignKey("AccumDepreciationAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("PowderCoating.Core.Entities.Account", "AssetAccount")
.WithMany()
.HasForeignKey("AssetAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("PowderCoating.Core.Entities.Account", "DepreciationExpenseAccount")
.WithMany()
.HasForeignKey("DepreciationExpenseAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("AccumDepreciationAccount");
b.Navigation("AssetAccount");
b.Navigation("DepreciationExpenseAccount");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
{
b.HasOne("PowderCoating.Core.Entities.FixedAsset", "FixedAsset")
.WithMany("DepreciationEntries")
.HasForeignKey("FixedAssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
.WithMany()
.HasForeignKey("JournalEntryId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("FixedAsset");
b.Navigation("JournalEntry");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
@@ -10012,6 +10196,11 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("OvenBatches");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
{
b.Navigation("DepreciationEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.Navigation("Redemptions");
@@ -159,6 +159,8 @@ public class UnitOfWork : IUnitOfWork
// Recurring Transactions
private IRepository<RecurringTemplate>? _recurringTemplates;
private IRepository<FixedAsset>? _fixedAssets;
private IRepository<FixedAssetDepreciationEntry>? _fixedAssetDepreciationEntries;
/// <summary>
/// Initialises the unit of work with the scoped <paramref name="context"/>.
@@ -567,6 +569,10 @@ public class UnitOfWork : IUnitOfWork
/// <summary>Repository for <see cref="RecurringTemplate"/> — saved recipes that auto-generate bills or expenses on a schedule.</summary>
public IRepository<RecurringTemplate> RecurringTemplates =>
_recurringTemplates ??= new Repository<RecurringTemplate>(_context);
public IRepository<FixedAsset> FixedAssets =>
_fixedAssets ??= new Repository<FixedAsset>(_context);
public IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries =>
_fixedAssetDepreciationEntries ??= new Repository<FixedAssetDepreciationEntry>(_context);
/// <summary>
/// Flushes all pending changes in the EF Core change tracker to the database.
@@ -321,6 +321,19 @@ public class BillsController : Controller
try
{
var currentUser = await _userManager.GetUserAsync(User);
// Period lock check — block if the bill date is in a locked period
if (currentUser != null)
{
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
{
ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
await PopulateDropdownsAsync();
return View(dto);
}
}
Bill? bill = null;
// Bill entity, PO back-reference, and optional immediate payment all commit
@@ -160,6 +160,10 @@ public class CompanySettingsController : Controller
UpdatedAt = t.UpdatedAt
}).ToList();
ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
: null;
return View(dto);
}
catch (FormatException fex)
@@ -227,6 +231,34 @@ public class CompanySettingsController : Controller
}
}
/// <summary>
/// Locks the books through the given date, preventing new or edited accounting entries
/// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SetPeriodLock(DateTime? lockThrough)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return BadRequest();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null) return NotFound();
company.BookLockedThrough = lockThrough.HasValue
? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc)
: null;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
TempData["Success"] = lockThrough.HasValue
? $"Books locked through {lockThrough.Value:MMMM d, yyyy}."
: "Period lock cleared — all periods are now open.";
return RedirectToAction(nameof(Index), null, "company-info");
}
/// <summary>
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
@@ -0,0 +1,381 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages the fixed asset register. Tracks depreciable assets (ovens, blast cabinets,
/// vehicles, etc.) using straight-line depreciation. PostDepreciation auto-generates
/// Journal Entries for a selected month, crediting Accumulated Depreciation and debiting
/// Depreciation Expense.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class FixedAssetsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountBalanceService _accountBalanceService;
private readonly ILogger<FixedAssetsController> _logger;
public FixedAssetsController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
IAccountBalanceService accountBalanceService,
ILogger<FixedAssetsController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_accountBalanceService = accountBalanceService;
_logger = logger;
}
/// <summary>Lists all fixed assets for the current company with depreciation summary.</summary>
[HttpGet]
public async Task<IActionResult> Index()
{
var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => true, false,
fa => fa.AssetAccount,
fa => fa.DepreciationExpenseAccount,
fa => fa.AccumDepreciationAccount);
ViewBag.TotalCost = assets.Sum(a => a.PurchaseCost);
ViewBag.TotalAccumDeprec = assets.Sum(a => a.AccumulatedDepreciation);
ViewBag.TotalBookValue = assets.Sum(a => a.BookValue);
ViewBag.ActiveCount = assets.Count(a => !a.IsDisposed);
return View(assets.OrderBy(a => a.PurchaseDate).ToList());
}
[HttpGet]
public async Task<IActionResult> Details(int id)
{
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(
id, false,
fa => fa.AssetAccount,
fa => fa.DepreciationExpenseAccount,
fa => fa.AccumDepreciationAccount);
if (asset == null) return NotFound();
var entries = await _unitOfWork.FixedAssetDepreciationEntries.FindAsync(
e => e.FixedAssetId == id, false,
e => e.JournalEntry);
ViewBag.Entries = entries.OrderByDescending(e => e.PeriodYear).ThenByDescending(e => e.PeriodMonth).ToList();
ViewBag.MonthsRemaining = Math.Max(0, asset.UsefulLifeMonths - entries.Count(e => e.Amount > 0));
ViewBag.FullyDepreciated = asset.AccumulatedDepreciation >= (asset.PurchaseCost - asset.SalvageValue);
return View(asset);
}
[HttpGet]
public async Task<IActionResult> Create()
{
await PopulateAccountsAsync();
return View(new FixedAssetVm { PurchaseDate = DateTime.Today });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(FixedAssetVm vm)
{
if (!ModelState.IsValid)
{
await PopulateAccountsAsync();
return View(vm);
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var asset = new FixedAsset
{
Name = vm.Name,
Description = vm.Description,
PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc),
PurchaseCost = vm.PurchaseCost,
SalvageValue = vm.SalvageValue,
UsefulLifeMonths = vm.UsefulLifeMonths,
AccumulatedDepreciation = vm.AccumulatedDepreciation,
AssetAccountId = vm.AssetAccountId,
DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId,
AccumDepreciationAccountId = vm.AccumDepreciationAccountId,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.FixedAssets.AddAsync(asset);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Fixed asset \"{asset.Name}\" added.";
return RedirectToAction(nameof(Details), new { id = asset.Id });
}
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
if (asset == null) return NotFound();
await PopulateAccountsAsync();
return View(new FixedAssetVm
{
Id = asset.Id,
Name = asset.Name,
Description = asset.Description,
PurchaseDate = asset.PurchaseDate.ToLocalTime(),
PurchaseCost = asset.PurchaseCost,
SalvageValue = asset.SalvageValue,
UsefulLifeMonths = asset.UsefulLifeMonths,
AccumulatedDepreciation = asset.AccumulatedDepreciation,
AssetAccountId = asset.AssetAccountId,
DepreciationExpenseAccountId = asset.DepreciationExpenseAccountId,
AccumDepreciationAccountId = asset.AccumDepreciationAccountId,
IsDisposed = asset.IsDisposed,
DisposalDate = asset.DisposalDate?.ToLocalTime()
});
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, FixedAssetVm vm)
{
if (id != vm.Id) return BadRequest();
if (!ModelState.IsValid)
{
await PopulateAccountsAsync();
return View(vm);
}
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
if (asset == null) return NotFound();
asset.Name = vm.Name;
asset.Description = vm.Description;
asset.PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc);
asset.PurchaseCost = vm.PurchaseCost;
asset.SalvageValue = vm.SalvageValue;
asset.UsefulLifeMonths = vm.UsefulLifeMonths;
asset.AccumulatedDepreciation = vm.AccumulatedDepreciation;
asset.AssetAccountId = vm.AssetAccountId;
asset.DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId;
asset.AccumDepreciationAccountId = vm.AccumDepreciationAccountId;
asset.IsDisposed = vm.IsDisposed;
asset.DisposalDate = vm.IsDisposed && vm.DisposalDate.HasValue
? DateTime.SpecifyKind(vm.DisposalDate.Value, DateTimeKind.Utc)
: null;
asset.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Fixed asset \"{asset.Name}\" updated.";
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Posts straight-line depreciation for all active assets for the specified year/month.
/// Skips assets that have already been depreciated for the period, assets without GL accounts,
/// and fully-depreciated assets (BookValue ≤ SalvageValue). Creates one JE per asset.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostDepreciation(int year, int month)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var currentUser = await _userManager.GetUserAsync(User);
var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => !fa.IsDisposed, false,
fa => fa.DepreciationEntries);
int posted = 0, skipped = 0;
var errors = new List<string>();
foreach (var asset in assets)
{
// Skip assets missing required GL accounts
if (!asset.DepreciationExpenseAccountId.HasValue || !asset.AccumDepreciationAccountId.HasValue)
{
skipped++;
continue;
}
// Skip already posted for this period
if (asset.DepreciationEntries.Any(e => e.PeriodYear == year && e.PeriodMonth == month && !e.IsDeleted))
{
skipped++;
continue;
}
var depreciableBase = asset.PurchaseCost - asset.SalvageValue;
var remaining = depreciableBase - asset.AccumulatedDepreciation;
// Skip fully depreciated assets
if (remaining <= 0)
{
skipped++;
continue;
}
// Don't over-depreciate in the final period
var amount = Math.Min(asset.MonthlyDepreciation, remaining);
try
{
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
// GL: DR Depreciation Expense / CR Accumulated Depreciation
await _accountBalanceService.DebitAsync(asset.DepreciationExpenseAccountId, amount);
await _accountBalanceService.CreditAsync(asset.AccumDepreciationAccountId, amount);
// Post JE
var je = new JournalEntry
{
EntryNumber = await GenerateJeNumberAsync(companyId),
EntryDate = new DateTime(year, month, DateTime.DaysInMonth(year, month), 0, 0, 0, DateTimeKind.Utc),
Description = $"Depreciation — {asset.Name} ({month:D2}/{year})",
Reference = asset.Name,
Status = JournalEntryStatus.Posted,
PostedBy = currentUser?.Email,
PostedAt = DateTime.UtcNow,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
Lines = new List<JournalEntryLine>
{
new() { AccountId = asset.DepreciationExpenseAccountId!.Value, DebitAmount = amount, CreditAmount = 0, Description = $"Depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow },
new() { AccountId = asset.AccumDepreciationAccountId!.Value, DebitAmount = 0, CreditAmount = amount, Description = $"Accum. depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow }
}
};
await _unitOfWork.JournalEntries.AddAsync(je);
await _unitOfWork.CompleteAsync();
// Record the depreciation entry
var entry = new FixedAssetDepreciationEntry
{
FixedAssetId = asset.Id,
PeriodYear = year,
PeriodMonth = month,
Amount = amount,
JournalEntryId = je.Id,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.FixedAssetDepreciationEntries.AddAsync(entry);
asset.AccumulatedDepreciation += amount;
asset.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
});
posted++;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error posting depreciation for asset {AssetId}", asset.Id);
errors.Add($"{asset.Name}: {ex.Message}");
}
}
if (errors.Any())
TempData["Error"] = $"Posted {posted}, skipped {skipped}. Errors: {string.Join("; ", errors)}";
else
TempData["Success"] = $"Depreciation posted: {posted} asset(s) for {new DateTime(year, month, 1):MMMM yyyy}. {skipped} skipped (already posted, no GL accounts, or fully depreciated).";
return RedirectToAction(nameof(Index));
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
if (asset == null) return NotFound();
var hasEntries = (await _unitOfWork.FixedAssetDepreciationEntries.FindAsync(e => e.FixedAssetId == id)).Any();
if (hasEntries)
{
TempData["Error"] = "Cannot delete an asset with depreciation history. Mark it as disposed instead.";
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.FixedAssets.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Fixed asset \"{asset.Name}\" deleted.";
return RedirectToAction(nameof(Index));
}
private async Task PopulateAccountsAsync()
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
ViewBag.AssetAccounts = list
.Where(a => a.AccountType == AccountType.Asset)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.ExpenseAccounts = list
.Where(a => a.AccountType == AccountType.Expense)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
// Accumulated depreciation typically lives as a negative Asset (contra-asset)
ViewBag.AccumDeprecAccounts = list
.Where(a => a.AccountType is AccountType.Asset or AccountType.Expense)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
}
/// <summary>Generates next JE number in JE-YYMM-#### format, ignoring soft-deleted entries.</summary>
private async Task<string> GenerateJeNumberAsync(int companyId)
{
var prefix = $"JE-{DateTime.Now:yyMM}-";
var all = await _unitOfWork.JournalEntries.FindAsync(
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
ignoreQueryFilters: true);
int next = all.Any()
? all.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
: 1;
return $"{prefix}{next:D4}";
}
}
public class FixedAssetVm
{
public int Id { get; set; }
[Required, MaxLength(200)]
public string Name { get; set; } = string.Empty;
[MaxLength(1000)]
public string? Description { get; set; }
[Required]
public DateTime PurchaseDate { get; set; } = DateTime.Today;
[Required, Range(0.01, double.MaxValue, ErrorMessage = "Purchase cost must be greater than zero.")]
public decimal PurchaseCost { get; set; }
[Range(0, double.MaxValue)]
public decimal SalvageValue { get; set; } = 0;
[Required, Range(1, 600, ErrorMessage = "Useful life must be between 1 and 600 months.")]
public int UsefulLifeMonths { get; set; } = 60;
[Range(0, double.MaxValue)]
public decimal AccumulatedDepreciation { get; set; } = 0;
public int? AssetAccountId { get; set; }
public int? DepreciationExpenseAccountId { get; set; }
public int? AccumDepreciationAccountId { get; set; }
public bool IsDisposed { get; set; } = false;
public DateTime? DisposalDate { get; set; }
}
@@ -276,6 +276,14 @@ public class InvoicesController : Controller
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
// Expense accounts for the write-off bad-debt modal
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense);
ViewBag.ExpenseAccounts = expenseAccounts
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep)
{
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
@@ -1366,6 +1374,113 @@ public class InvoicesController : Controller
}
}
// -----------------------------------------------------------------------
// POST: /Invoices/WriteOff/5
// -----------------------------------------------------------------------
/// <summary>
/// Writes off an uncollectible invoice. Posts a GL journal entry:
/// DR Bad Debt Expense (user-selected account) for the remaining BalanceDue
/// CR Accounts Receivable for the same amount
/// Then marks the invoice WrittenOff and reduces customer.CurrentBalance.
/// Only the outstanding BalanceDue is written off; amounts already collected are unaffected.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> WriteOff(int id, int? expenseAccountId, string? notes)
{
try
{
var invoice = await LoadInvoiceForViewAsync(id);
if (invoice == null) return NotFound();
if (invoice.Status is InvoiceStatus.Paid or InvoiceStatus.Voided or InvoiceStatus.WrittenOff)
{
TempData["Error"] = "Invoice cannot be written off in its current status.";
return RedirectToAction(nameof(Details), new { id });
}
var balanceDue = invoice.BalanceDue;
if (balanceDue <= 0)
{
TempData["Error"] = "Invoice has no outstanding balance to write off.";
return RedirectToAction(nameof(Details), new { id });
}
var currentUser = await _userManager.GetUserAsync(User);
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
var badDebtAccountId = expenseAccountId > 0
? expenseAccountId
: await GetBadDebtAccountIdAsync(invoice.CompanyId);
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
// GL: DR Bad Debt Expense / CR AR
await _accountBalanceService.DebitAsync(badDebtAccountId, balanceDue);
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
// Post a supporting JE for the audit trail
var je = new JournalEntry
{
EntryNumber = await GenerateJournalEntryNumberAsync(invoice.CompanyId),
EntryDate = DateTime.UtcNow,
Description = $"Write-off of invoice {invoice.InvoiceNumber}{(string.IsNullOrWhiteSpace(notes) ? "" : $" {notes}")}",
Reference = invoice.InvoiceNumber,
Status = JournalEntryStatus.Posted,
PostedBy = currentUser?.Email,
PostedAt = DateTime.UtcNow,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow,
Lines = new List<JournalEntryLine>
{
new JournalEntryLine
{
AccountId = badDebtAccountId ?? 0,
Description = $"Bad debt — invoice {invoice.InvoiceNumber}",
DebitAmount = balanceDue,
CreditAmount = 0,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
},
new JournalEntryLine
{
AccountId = arAccountId ?? 0,
Description = $"Write-off AR — invoice {invoice.InvoiceNumber}",
DebitAmount = 0,
CreditAmount = balanceDue,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
}
}
};
await _unitOfWork.JournalEntries.AddAsync(je);
// Reduce customer running balance
var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId);
if (customer != null)
{
customer.CurrentBalance = Math.Max(0, customer.CurrentBalance - balanceDue);
await _unitOfWork.Customers.UpdateAsync(customer);
}
invoice.Status = InvoiceStatus.WrittenOff;
invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email;
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} written off ({balanceDue:C} posted to Bad Debt Expense).";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error writing off invoice {Id}", id);
TempData["Error"] = "An error occurred while writing off the invoice.";
}
return RedirectToAction(nameof(Details), new { id });
}
// -----------------------------------------------------------------------
// GET: /Invoices/DownloadPdf/5
// -----------------------------------------------------------------------
@@ -1961,6 +2076,40 @@ public class InvoicesController : Controller
return accounts.FirstOrDefault()?.Id;
}
/// <summary>
/// Returns the Bad Debt Expense account for write-offs — prefers an account whose name
/// contains "bad debt", falls back to the first active Expense-type account.
/// </summary>
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
{
var expenses = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense);
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
?? expenses.FirstOrDefault()?.Id;
}
/// <summary>
/// Generates the next sequential JE number in JE-YYMM-#### format.
/// Queries across soft-deleted entries to prevent reuse after deletion.
/// </summary>
private async Task<string> GenerateJournalEntryNumberAsync(int companyId)
{
var prefix = $"JE-{DateTime.Now:yyMM}-";
var all = await _unitOfWork.JournalEntries.FindAsync(
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
ignoreQueryFilters: true);
int next = 1;
if (all.Any())
{
var nums = all.Select(je => je.EntryNumber[prefix.Length..])
.Select(s => int.TryParse(s, out int n) ? n : 0);
next = nums.Max() + 1;
}
return $"{prefix}{next:D4}";
}
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{
@@ -167,6 +167,14 @@ public class JournalEntriesController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// Period lock check — block posting if the entry date falls in a locked period
var company = await _unitOfWork.Companies.GetByIdAsync(entry.CompanyId);
if (Web.Helpers.AccountingPeriodValidator.IsLocked(entry.EntryDate, company?.BookLockedThrough))
{
TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough);
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
entry.Status = JournalEntryStatus.Posted;
@@ -2118,6 +2118,68 @@ public class ReportsController : Controller
}
}
// GET: /Reports/TaxReporting1099
/// <summary>
/// 1099-NEC report: sums all bill payments + expenses paid to vendors marked Is1099Vendor=true
/// for the selected calendar year. Flags vendors that exceed the $600 reporting threshold.
/// </summary>
public async Task<IActionResult> TaxReporting1099(int? year)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _rcid) ? _rcid : 0;
var reportYear = year ?? DateTime.Now.Year;
var periodStart = new DateTime(reportYear, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
// Load 1099-eligible vendors
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.Is1099Vendor)).ToList();
var rows = new List<Vendor1099Row>();
foreach (var vendor in vendors)
{
// Sum bills paid (using bill payment records) within the year
var bills = await _unitOfWork.Bills.FindAsync(
b => b.VendorId == vendor.Id,
false,
b => b.Payments);
decimal billPaid = bills
.SelectMany(b => b.Payments)
.Where(p => p.PaymentDate >= periodStart && p.PaymentDate <= periodEnd)
.Sum(p => p.Amount);
// Sum direct expenses for this vendor within the year
var expenses = await _unitOfWork.Expenses.FindAsync(
e => e.VendorId == vendor.Id && e.Date >= periodStart && e.Date <= periodEnd);
decimal expensePaid = expenses.Sum(e => e.Amount);
var total = billPaid + expensePaid;
rows.Add(new Vendor1099Row
{
VendorId = vendor.Id,
VendorName = vendor.CompanyName,
TaxId = vendor.TaxId,
Address = string.Join(", ", new[] { vendor.Address, vendor.City, vendor.State, vendor.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s))),
BillsPaid = billPaid,
ExpensesPaid = expensePaid,
TotalPaid = total,
NeedsForm = total >= 600m
});
}
ViewBag.ReportYear = reportYear;
ViewBag.AvailableYears = Enumerable.Range(DateTime.Now.Year - 5, 6).OrderDescending().ToList();
ViewBag.VendorsOver600 = rows.Count(r => r.NeedsForm);
return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
}
/// <summary>
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
/// company name into AI prompts so the generated text refers to the actual business, not a
@@ -2245,3 +2307,15 @@ public class AnalyticsDashboardViewModel
public int SelectedMonths { get; set; } = 6;
}
public class Vendor1099Row
{
public int VendorId { get; set; }
public string VendorName { get; set; } = string.Empty;
public string? TaxId { get; set; }
public string? Address { get; set; }
public decimal BillsPaid { get; set; }
public decimal ExpensesPaid { get; set; }
public decimal TotalPaid { get; set; }
public bool NeedsForm { get; set; }
}
@@ -0,0 +1,18 @@
namespace PowderCoating.Web.Helpers;
/// <summary>
/// Checks whether an accounting entry date falls within a locked period.
/// The lock date is stored on the Company as BookLockedThrough and is set by CompanyAdmin
/// users to prevent backdating entries into periods that have already been closed.
/// </summary>
public static class AccountingPeriodValidator
{
/// <summary>Returns true when the entry date is on or before the lock date (period is closed).</summary>
public static bool IsLocked(DateTime entryDate, DateTime? lockedThrough) =>
lockedThrough.HasValue && entryDate.Date <= lockedThrough.Value.Date;
/// <summary>User-facing message to display when a write is blocked by period locking.</summary>
public static string LockedMessage(DateTime? lockedThrough) =>
$"This period is locked — books are closed through {lockedThrough!.Value:MMMM d, yyyy}. " +
"Unlock the period in Company Settings → Accounting to make changes.";
}
@@ -211,6 +211,48 @@
</div>
</div>
<!-- Period Lock -->
<div class="row mb-4">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3 text-muted">Period Locking</h6>
</div>
<div class="col-md-4">
@{
var lockThrough = ViewBag.BookLockedThrough as DateTime?;
}
<label class="form-label">Books Locked Through</label>
@if (lockThrough.HasValue)
{
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock-fill text-warning"></i></span>
<input type="text" class="form-control" value="@lockThrough.Value.ToString("MMMM d, yyyy")" readonly />
<form asp-action="SetPeriodLock" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-secondary" title="Clear lock">
<i class="bi bi-unlock"></i> Clear
</button>
</form>
</div>
<div class="form-text text-warning">Entries dated on or before this date are blocked.</div>
}
else
{
<div class="text-muted small mb-2">No period lock set — all dates are open.</div>
}
</div>
<div class="col-md-4">
<label class="form-label">Set New Lock Date</label>
<form asp-action="SetPeriodLock" method="post" class="d-flex gap-2">
@Html.AntiForgeryToken()
<input type="date" name="lockThrough" class="form-control" />
<button type="submit" class="btn btn-warning" onclick="return confirm('Lock all accounting periods on or before this date? Users will not be able to post or edit entries in those periods.')">
<i class="bi bi-lock me-1"></i>Lock
</button>
</form>
<div class="form-text">Prevents backdating any GL entry (bills, JEs) into closed periods.</div>
</div>
</div>
<div class="mb-3">
<label for="address" class="form-label">Address</label>
<input type="text" class="form-control" id="address" name="Address" value="@Model.Address" maxlength="200">
@@ -0,0 +1,125 @@
@{
ViewData["Title"] = "Add Fixed Asset";
ViewData["PageIcon"] = "bi-plus-circle";
}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Asset Register
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-building-gear me-2 text-primary"></i>New Fixed Asset</h5>
</div>
<div class="card-body">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<!-- Asset Info -->
<h6 class="border-bottom pb-2 mb-3 text-muted">Asset Information</h6>
<div class="row g-3 mb-4">
<div class="col-12">
<label class="form-label">Asset Name <span class="text-danger">*</span></label>
<input type="text" name="Name" class="form-control" maxlength="200" required placeholder="e.g., Blast Cabinet #2, Paint Oven A" />
</div>
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" name="Description" class="form-control" maxlength="1000" placeholder="Optional notes about this asset" />
</div>
<div class="col-md-4">
<label class="form-label">Purchase Date <span class="text-danger">*</span></label>
<input type="date" name="PurchaseDate" class="form-control" value="@DateTime.Today.ToString("yyyy-MM-dd")" required />
</div>
<div class="col-md-4">
<label class="form-label">Purchase Cost <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="PurchaseCost" class="form-control" step="0.01" min="0.01" required placeholder="0.00" />
</div>
</div>
<div class="col-md-4">
<label class="form-label">Salvage Value</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="SalvageValue" class="form-control" step="0.01" min="0" value="0" placeholder="0.00" />
</div>
<div class="form-text">Estimated residual value at end of useful life.</div>
</div>
<div class="col-md-4">
<label class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
<input type="number" name="UsefulLifeMonths" class="form-control" min="1" max="600" value="60" required />
<div class="form-text">60 = 5 years, 120 = 10 years, etc.</div>
</div>
<div class="col-md-4">
<label class="form-label">Prior Accumulated Depreciation</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="AccumulatedDepreciation" class="form-control" step="0.01" min="0" value="0" placeholder="0.00" />
</div>
<div class="form-text">Set if the asset was partially depreciated before being added here.</div>
</div>
</div>
<!-- GL Accounts -->
<h6 class="border-bottom pb-2 mb-3 text-muted">GL Account Mapping</h6>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label class="form-label">Asset Account</label>
<select name="AssetAccountId" class="form-select">
<option value="">— None —</option>
@if (ViewBag.AssetAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AssetAccounts)
{
<option value="@item.Value">@item.Text</option>
}
}
</select>
<div class="form-text">Balance sheet asset account (e.g., 1500 Equipment).</div>
</div>
<div class="col-md-4">
<label class="form-label">Depreciation Expense Account</label>
<select name="DepreciationExpenseAccountId" class="form-select">
<option value="">— None —</option>
@if (ViewBag.ExpenseAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{
<option value="@item.Value">@item.Text</option>
}
}
</select>
<div class="form-text">P&amp;L expense account (e.g., 6200 Depreciation Expense).</div>
</div>
<div class="col-md-4">
<label class="form-label">Accumulated Depreciation Account</label>
<select name="AccumDepreciationAccountId" class="form-select">
<option value="">— None —</option>
@if (ViewBag.AccumDeprecAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AccumDeprecAccounts)
{
<option value="@item.Value">@item.Text</option>
}
}
</select>
<div class="form-text">Contra-asset account (e.g., 1510 Accum. Depreciation — Equipment).</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-2"></i>Add Asset
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,222 @@
@using PowderCoating.Core.Entities
@model FixedAsset
@{
ViewData["Title"] = Model.Name;
ViewData["PageIcon"] = "bi-building-gear";
var fullyDeprec = Model.AccumulatedDepreciation >= (Model.PurchaseCost - Model.SalvageValue);
var depreciableBase = Model.PurchaseCost - Model.SalvageValue;
var progress = depreciableBase > 0
? (double)(Model.AccumulatedDepreciation / depreciableBase) * 100
: 100;
var entries = ViewBag.Entries as List<FixedAssetDepreciationEntry> ?? new();
var monthsRemaining = (int)(ViewBag.MonthsRemaining ?? 0);
}
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Asset Register
</a>
<div class="d-flex gap-2">
@if (!Model.IsDisposed)
{
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil me-1"></i>Edit
</a>
}
<form asp-action="Delete" asp-route-id="@Model.Id" method="post"
onsubmit="return confirm('Delete this asset? This cannot be undone. Assets with depreciation history cannot be deleted.')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash me-1"></i>Delete
</button>
</form>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@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">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-4">
<!-- Asset Details Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-building-gear me-2 text-primary"></i>@Model.Name
</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<p class="text-muted">@Model.Description</p>
}
<div class="mb-3">
@if (Model.IsDisposed)
{
<span class="badge bg-secondary fs-6">Disposed</span>
@if (Model.DisposalDate.HasValue)
{
<div class="text-muted small mt-1">Disposed @Model.DisposalDate.Value.ToLocalTime().ToString("MM/dd/yyyy")</div>
}
}
else if (fullyDeprec)
{
<span class="badge bg-light text-dark border fs-6">Fully Depreciated</span>
}
else
{
<span class="badge bg-success fs-6">Active</span>
<div class="text-muted small mt-1">@monthsRemaining month@(monthsRemaining == 1 ? "" : "s") remaining</div>
}
</div>
<table class="table table-sm table-borderless mb-0">
<tr>
<td class="text-muted ps-0">Purchase Date</td>
<td class="text-end fw-semibold">@Model.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
</tr>
<tr>
<td class="text-muted ps-0">Purchase Cost</td>
<td class="text-end fw-semibold">@Model.PurchaseCost.ToString("C")</td>
</tr>
<tr>
<td class="text-muted ps-0">Salvage Value</td>
<td class="text-end">@Model.SalvageValue.ToString("C")</td>
</tr>
<tr>
<td class="text-muted ps-0">Useful Life</td>
<td class="text-end">@Model.UsefulLifeMonths months</td>
</tr>
<tr>
<td class="text-muted ps-0">Monthly Depreciation</td>
<td class="text-end">@Model.MonthlyDepreciation.ToString("C")</td>
</tr>
</table>
</div>
</div>
<!-- Book Value Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-bar-chart me-2 text-primary"></i>Book Value</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Accumulated Depreciation</span>
<span class="text-danger fw-semibold">@Model.AccumulatedDepreciation.ToString("C")</span>
</div>
<div class="progress mb-3" style="height:8px;">
<div class="progress-bar bg-danger" style="width: @progress.ToString("F1")%"></div>
</div>
<div class="d-flex justify-content-between border-top pt-2">
<span class="fw-semibold">Book Value</span>
<span class="fw-bold fs-5 @(Model.BookValue <= 0 ? "text-muted" : "text-success")">@Model.BookValue.ToString("C")</span>
</div>
</div>
</div>
<!-- GL Accounts Card -->
@if (Model.AssetAccount != null || Model.DepreciationExpenseAccount != null || Model.AccumDepreciationAccount != null)
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-diagram-3 me-2 text-primary"></i>GL Accounts</h5>
</div>
<div class="card-body">
@if (Model.AssetAccount != null)
{
<div class="mb-2">
<div class="text-muted small">Asset Account</div>
<div class="fw-semibold">@Model.AssetAccount.AccountNumber @Model.AssetAccount.Name</div>
</div>
}
@if (Model.DepreciationExpenseAccount != null)
{
<div class="mb-2">
<div class="text-muted small">Depreciation Expense</div>
<div class="fw-semibold">@Model.DepreciationExpenseAccount.AccountNumber @Model.DepreciationExpenseAccount.Name</div>
</div>
}
@if (Model.AccumDepreciationAccount != null)
{
<div>
<div class="text-muted small">Accumulated Depreciation</div>
<div class="fw-semibold">@Model.AccumDepreciationAccount.AccountNumber @Model.AccumDepreciationAccount.Name</div>
</div>
}
</div>
</div>
}
</div>
<!-- Right Column: Depreciation History -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold"><i class="bi bi-clock-history me-2 text-primary"></i>Depreciation History</h5>
<span class="badge bg-light text-dark border">@entries.Count period@(entries.Count == 1 ? "" : "s") posted</span>
</div>
<div class="card-body p-0">
@if (!entries.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-calendar-x display-4 d-block mb-3 opacity-25"></i>
<p>No depreciation posted yet. Use the <strong>Post Monthly Depreciation</strong> button on the <a asp-action="Index">Asset Register</a>.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Period</th>
<th class="text-end">Amount</th>
<th>Journal Entry</th>
<th>Posted</th>
</tr>
</thead>
<tbody>
@foreach (var e in entries)
{
<tr>
<td>@(new DateTime(e.PeriodYear, e.PeriodMonth, 1).ToString("MMMM yyyy"))</td>
<td class="text-end text-danger">@e.Amount.ToString("C")</td>
<td>
@if (e.JournalEntry != null)
{
<a asp-controller="JournalEntries" asp-action="Details" asp-route-id="@e.JournalEntryId">
@e.JournalEntry.EntryNumber
</a>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-muted small">@e.CreatedAt.ToLocalTime().ToString("MM/dd/yyyy")</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
@@ -0,0 +1,145 @@
@model PowderCoating.Web.Controllers.FixedAssetVm
@{
ViewData["Title"] = "Edit Fixed Asset";
ViewData["PageIcon"] = "bi-pencil";
}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Asset
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-pencil me-2 text-primary"></i>Edit @Model.Name</h5>
</div>
<div class="card-body">
<form asp-action="Edit" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<!-- Asset Info -->
<h6 class="border-bottom pb-2 mb-3 text-muted">Asset Information</h6>
<div class="row g-3 mb-4">
<div class="col-12">
<label asp-for="Name" class="form-label">Asset Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" maxlength="200" required />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label">Description</label>
<input asp-for="Description" class="form-control" maxlength="1000" />
</div>
<div class="col-md-4">
<label asp-for="PurchaseDate" class="form-label">Purchase Date <span class="text-danger">*</span></label>
<input asp-for="PurchaseDate" type="date" class="form-control" required />
<span asp-validation-for="PurchaseDate" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="PurchaseCost" class="form-label">Purchase Cost <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="PurchaseCost" type="number" step="0.01" min="0.01" class="form-control" />
</div>
<span asp-validation-for="PurchaseCost" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="SalvageValue" class="form-label">Salvage Value</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="SalvageValue" type="number" step="0.01" min="0" class="form-control" />
</div>
</div>
<div class="col-md-4">
<label asp-for="UsefulLifeMonths" class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
<input asp-for="UsefulLifeMonths" type="number" min="1" max="600" class="form-control" />
<span asp-validation-for="UsefulLifeMonths" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="AccumulatedDepreciation" class="form-label">Accumulated Depreciation</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="AccumulatedDepreciation" type="number" step="0.01" min="0" class="form-control" />
</div>
</div>
</div>
<!-- GL Accounts -->
<h6 class="border-bottom pb-2 mb-3 text-muted">GL Account Mapping</h6>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label asp-for="AssetAccountId" class="form-label">Asset Account</label>
<select asp-for="AssetAccountId" class="form-select">
<option value="">— None —</option>
@if (ViewBag.AssetAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AssetAccounts)
{
<option value="@item.Value" selected="@(item.Value == Model.AssetAccountId?.ToString() ? "selected" : null)">@item.Text</option>
}
}
</select>
</div>
<div class="col-md-4">
<label asp-for="DepreciationExpenseAccountId" class="form-label">Depreciation Expense Account</label>
<select asp-for="DepreciationExpenseAccountId" class="form-select">
<option value="">— None —</option>
@if (ViewBag.ExpenseAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{
<option value="@item.Value" selected="@(item.Value == Model.DepreciationExpenseAccountId?.ToString() ? "selected" : null)">@item.Text</option>
}
}
</select>
</div>
<div class="col-md-4">
<label asp-for="AccumDepreciationAccountId" class="form-label">Accumulated Depreciation Account</label>
<select asp-for="AccumDepreciationAccountId" class="form-select">
<option value="">— None —</option>
@if (ViewBag.AccumDeprecAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AccumDeprecAccounts)
{
<option value="@item.Value" selected="@(item.Value == Model.AccumDepreciationAccountId?.ToString() ? "selected" : null)">@item.Text</option>
}
}
</select>
</div>
</div>
<!-- Disposal -->
<h6 class="border-bottom pb-2 mb-3 text-muted">Disposal</h6>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<input asp-for="IsDisposed" class="form-check-input" type="checkbox" id="isDisposed" />
<label asp-for="IsDisposed" class="form-check-label">Mark as Disposed</label>
</div>
</div>
<div class="col-md-4" id="disposalDateField" style="@(Model.IsDisposed ? "" : "display:none")">
<label asp-for="DisposalDate" class="form-label">Disposal Date</label>
<input asp-for="DisposalDate" type="date" class="form-control" />
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-2"></i>Save Changes
</button>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/fixed-asset-edit.js" asp-append-version="true"></script>
}
@@ -0,0 +1,186 @@
@using PowderCoating.Core.Entities
@model List<FixedAsset>
@{
ViewData["Title"] = "Fixed Assets";
ViewData["PageIcon"] = "bi-building-gear";
var now = DateTime.Now;
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div></div>
<div class="d-flex gap-2">
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Asset
</a>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@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">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Active Assets</div>
<div class="fs-3 fw-bold text-primary">@ViewBag.ActiveCount</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Total Cost</div>
<div class="fs-3 fw-bold">@((ViewBag.TotalCost as decimal? ?? 0).ToString("C"))</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Accum. Depreciation</div>
<div class="fs-3 fw-bold text-danger">@((ViewBag.TotalAccumDeprec as decimal? ?? 0).ToString("C"))</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Total Book Value</div>
<div class="fs-3 fw-bold text-success">@((ViewBag.TotalBookValue as decimal? ?? 0).ToString("C"))</div>
</div>
</div>
</div>
</div>
<!-- Post Depreciation -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold"><i class="bi bi-calendar-check me-2 text-primary"></i>Post Monthly Depreciation</h5>
</div>
<div class="card-body">
<form asp-action="PostDepreciation" method="post" class="row g-3 align-items-end">
@Html.AntiForgeryToken()
<div class="col-md-3">
<label class="form-label">Year</label>
<input type="number" name="year" class="form-control" value="@now.Year" min="2000" max="2099" required />
</div>
<div class="col-md-3">
<label class="form-label">Month</label>
<select name="month" class="form-select">
@for (int m = 1; m <= 12; m++)
{
<option value="@m" selected="@(m == now.Month ? "selected" : null)">
@(new DateTime(now.Year, m, 1).ToString("MMMM"))
</option>
}
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary"
onclick="return confirm('Post straight-line depreciation for all active assets for this period? Assets already posted for the period will be skipped.')">
<i class="bi bi-send me-2"></i>Post Depreciation
</button>
</div>
<div class="col-md-3 text-muted small">
Creates one Journal Entry per asset. Fully-depreciated and disposed assets are skipped.
</div>
</form>
</div>
</div>
<!-- Asset Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Asset Register</h5>
</div>
<div class="card-body p-0">
@if (!Model.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-building-gear display-4 d-block mb-3 opacity-25"></i>
<p>No fixed assets yet. <a asp-action="Create">Add your first asset</a> to start tracking depreciation.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Asset</th>
<th>Purchase Date</th>
<th class="text-end">Cost</th>
<th class="text-end">Salvage Value</th>
<th class="text-center">Life (mo.)</th>
<th class="text-end">Monthly Depr.</th>
<th class="text-end">Accum. Depr.</th>
<th class="text-end">Book Value</th>
<th class="text-center">Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var a in Model)
{
var fullyDeprec = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
<tr>
<td>
<a asp-action="Details" asp-route-id="@a.Id" class="fw-semibold text-decoration-none">
@a.Name
</a>
@if (!string.IsNullOrWhiteSpace(a.Description))
{
<div class="text-muted small">@a.Description</div>
}
</td>
<td>@a.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
<td class="text-end">@a.PurchaseCost.ToString("C")</td>
<td class="text-end">@a.SalvageValue.ToString("C")</td>
<td class="text-center">@a.UsefulLifeMonths</td>
<td class="text-end">@a.MonthlyDepreciation.ToString("C")</td>
<td class="text-end text-danger">@a.AccumulatedDepreciation.ToString("C")</td>
<td class="text-end @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
@a.BookValue.ToString("C")
</td>
<td class="text-center">
@if (a.IsDisposed)
{
<span class="badge bg-secondary">Disposed</span>
}
else if (fullyDeprec)
{
<span class="badge bg-light text-dark border">Fully Depreciated</span>
}
else
{
<span class="badge bg-success">Active</span>
}
</td>
<td>
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
@@ -679,6 +679,13 @@
</button>
</form>
}
@if (!isVoided && Model.BalanceDue > 0)
{
<button type="button" class="btn btn-outline-danger w-100"
data-bs-toggle="modal" data-bs-target="#writeOffModal">
<i class="bi bi-journal-x me-2"></i>Write Off
</button>
}
@if (isDraft)
{
<form asp-action="Delete" asp-route-id="@Model.Id" method="post"
@@ -1308,6 +1315,55 @@
</div>
}
<!-- Write-Off Modal -->
@if (!isVoided && Model.BalanceDue > 0)
{
<div class="modal fade" id="writeOffModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-journal-x me-2 text-danger"></i>Write Off Invoice</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form asp-action="WriteOff" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<div class="modal-body">
<div class="alert alert-warning py-2 mb-3">
<i class="bi bi-exclamation-triangle me-2"></i>
This will write off the remaining balance of <strong>@Model.BalanceDue.ToString("C")</strong>
as bad debt. A GL journal entry will be posted. This action cannot be undone.
</div>
<div class="mb-3">
<label class="form-label">Bad Debt Expense Account</label>
<select name="expenseAccountId" class="form-select">
<option value="">— Use default bad debt account —</option>
@if (ViewBag.ExpenseAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{
<option value="@item.Value">@item.Text</option>
}
}
</select>
<div class="form-text">If blank, the system selects the first account with "bad" or "debt" in the name, or falls back to the first expense account.</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea name="notes" class="form-control" rows="2" placeholder="Reason for write-off (optional)"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-journal-x me-2"></i>Write Off @Model.BalanceDue.ToString("C")
</button>
</div>
</form>
</div>
</div>
</div>
}
@section Scripts {
<script>
function openEditPaymentModal(paymentId, invoiceId, paymentDate, paymentMethod, reference, notes, depositAccountId) {
@@ -212,6 +212,14 @@
<p>Track actual cash in/out across operating, investing, and financing activities with beginning and ending cash balance.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="TaxReporting1099" class="report-card">
<div class="report-card-icon" style="background:#fdf2f8;color:#86198f;">
<i class="bi bi-file-earmark-text"></i>
</div>
<h5>1099-NEC Report</h5>
<p>Payments to 1099-eligible vendors by calendar year — flags those exceeding the $600 reporting threshold.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
}
@@ -0,0 +1,156 @@
@using PowderCoating.Web.Controllers
@model List<Vendor1099Row>
@{
ViewData["Title"] = "1099-NEC Report";
ViewData["PageIcon"] = "bi-file-earmark-text";
var reportYear = (int)ViewBag.ReportYear;
var availableYears = ViewBag.AvailableYears as List<int> ?? new List<int>();
var vendorsOver600 = (int)(ViewBag.VendorsOver600 ?? 0);
}
<!-- Year Selector -->
<div class="d-flex justify-content-between align-items-center mb-4">
<form method="get" asp-action="TaxReporting1099" class="d-flex align-items-center gap-2">
<label class="form-label mb-0 fw-semibold">Year:</label>
<select name="year" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
@foreach (var y in availableYears)
{
<option value="@y" selected="@(y == reportYear ? "selected" : null)">@y</option>
}
</select>
</form>
<a asp-action="Landing" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Reports
</a>
</div>
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">1099-Eligible Vendors</div>
<div class="fs-3 fw-bold text-primary">@Model.Count</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Need 1099-NEC (≥ $600)</div>
<div class="fs-3 fw-bold text-danger">@vendorsOver600</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm text-center">
<div class="card-body py-3">
<div class="text-muted small">Total Paid to 1099 Vendors</div>
<div class="fs-3 fw-bold">@Model.Sum(r => r.TotalPaid).ToString("C")</div>
</div>
</div>
</div>
</div>
<div class="alert alert-info py-2 mb-4">
<i class="bi bi-info-circle me-2"></i>
This report shows payments to vendors marked as <strong>1099 Vendor</strong> during <strong>@reportYear</strong>.
IRS rules require a 1099-NEC for non-incorporated contractors, attorneys, and service providers paid <strong>$600 or more</strong> in the calendar year.
To flag a vendor, edit them in <a asp-controller="Vendors" asp-action="Index">Vendors</a> and check the "1099 Vendor" box.
</div>
@if (!Model.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center text-muted py-5">
<i class="bi bi-file-earmark-text display-4 d-block mb-3 opacity-25"></i>
<p>No vendors are marked as 1099 vendors. <a asp-controller="Vendors" asp-action="Index">Edit a vendor</a> and check the "1099 Vendor" checkbox to include them here.</p>
</div>
</div>
}
else
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>1099-NEC Summary — @reportYear</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Vendor</th>
<th>Tax ID / EIN</th>
<th>Address</th>
<th class="text-end">Bills Paid</th>
<th class="text-end">Expenses Paid</th>
<th class="text-end">Total Paid</th>
<th class="text-center">1099-NEC Required?</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model)
{
<tr class="@(row.NeedsForm ? "" : "text-muted")">
<td>
<a asp-controller="Vendors" asp-action="Details" asp-route-id="@row.VendorId"
class="fw-semibold text-decoration-none">
@row.VendorName
</a>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(row.TaxId))
{
<span class="font-monospace">@row.TaxId</span>
}
else
{
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Missing</span>
}
</td>
<td class="small">@(row.Address ?? "—")</td>
<td class="text-end">@row.BillsPaid.ToString("C")</td>
<td class="text-end">@row.ExpensesPaid.ToString("C")</td>
<td class="text-end fw-bold @(row.NeedsForm ? "text-danger" : "")">@row.TotalPaid.ToString("C")</td>
<td class="text-center">
@if (row.NeedsForm)
{
<span class="badge bg-danger">Yes — File Required</span>
}
else
{
<span class="badge bg-light text-dark border">No (under $600)</span>
}
</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="3">Total</td>
<td class="text-end">@Model.Sum(r => r.BillsPaid).ToString("C")</td>
<td class="text-end">@Model.Sum(r => r.ExpensesPaid).ToString("C")</td>
<td class="text-end">@Model.Sum(r => r.TotalPaid).ToString("C")</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="mt-3 text-muted small">
<i class="bi bi-info-circle me-1"></i>
1099-NEC forms are due to recipients by <strong>January 31</strong> and to the IRS by <strong>January 31</strong> (paper or e-file).
Missing Tax IDs should be collected via <strong>IRS Form W-9</strong> before payments are made.
</div>
}
@@ -1146,6 +1146,10 @@
<i class="bi bi-bank2"></i>
<span>Bank Reconciliation</span>
</a>
<a asp-controller="FixedAssets" asp-action="Index" class="nav-link">
<i class="bi bi-building-gear"></i>
<span>Fixed Assets</span>
</a>
<a asp-controller="RecurringTemplates" asp-action="Index" class="nav-link">
<i class="bi bi-arrow-repeat"></i>
<span>Recurring Transactions</span>
@@ -186,6 +186,11 @@
<input asp-for="IsActive" class="form-check-input" type="checkbox" checked />
<label asp-for="IsActive" class="form-check-label">Active Vendor</label>
</div>
<div class="form-check form-switch mt-2">
<input asp-for="Is1099Vendor" class="form-check-input" type="checkbox" />
<label asp-for="Is1099Vendor" class="form-check-label">1099 Vendor</label>
</div>
<div class="form-text">Check if this vendor requires a 1099-NEC at year end (typically non-incorporated service providers paid ≥ $600).</div>
</div>
</div>
</div>
@@ -188,6 +188,13 @@
</div>
<span asp-validation-for="CreditLimit" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input asp-for="Is1099Vendor" class="form-check-input" type="checkbox" />
<label asp-for="Is1099Vendor" class="form-check-label">1099 Vendor</label>
</div>
<div class="form-text">Check if this vendor requires a 1099-NEC at year end (typically non-incorporated service providers paid ≥ $600).</div>
</div>
</div>
</div>
@@ -0,0 +1,10 @@
document.addEventListener('DOMContentLoaded', function () {
var isDisposed = document.getElementById('isDisposed');
var disposalDateField = document.getElementById('disposalDateField');
if (isDisposed && disposalDateField) {
isDisposed.addEventListener('change', function () {
disposalDateField.style.display = this.checked ? '' : 'none';
});
}
});