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:
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user