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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:19:32 -04:00
parent a255893ada
commit fde24b09c9
29 changed files with 12520 additions and 3 deletions
@@ -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; }
}