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