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:
@@ -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));
|
||||
|
||||
Generated
+10366
File diff suppressed because it is too large
Load Diff
+199
@@ -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&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';
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user