Preserve accounting linkages through CSV export/import round-trip

The data export was silently dropping account linkages, so an export->import
(used to copy prod data) lost which bank account each payment hit, all invoice
line items, and any bill/deposit/journal-entry detail. Diagnosed while cleaning
up a company's books from a copied dataset. Now every accounting linkage travels
by account/vendor/customer/invoice number — matching how expenses already worked
— so a round-trip preserves GL attribution instead of dropping it.

Payments: add DepositAccountNumber to the export + DTO; import resolves it back
to DepositAccountId so payments post to the right bank account on recalc.

Invoices: were header-only (re-imported invoices had 0 line items). Add a new
invoice_items CSV (one row per line, carrying RevenueAccountNumber) with export,
idempotent import, UI cards, and a template.

Bills / Deposits / Journal Entries: were not exported at all. Add full-fidelity
export + import including line-item children — bills + bill line items (vendor
by name, AP account + per-line expense account by number), deposits (customer +
bank account + applied invoice), and journal entries + JE lines (account by
number, debit/credit). 5 DTOs, 5 importers, 5 exporters + actions, all added to
the all_data export zip, plus 10 import/export UI cards.

Shared RunCsvImport helper added for the new import endpoints. All linkages
resolve by stable business keys (numbers/names), never internal IDs, so the
files round-trip across databases. Build clean; 278 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:49:39 -04:00
parent f752abad86
commit f54b945053
11 changed files with 1543 additions and 20 deletions
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Import;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
@@ -1394,6 +1395,53 @@ public class ToolsController : Controller
}
}
/// <summary>
/// Bulk-imports invoice line items from a native CSV file. Lines are matched to their parent
/// invoice by InvoiceNumber and revenue accounts resolved by number. Run after the invoice import.
/// </summary>
// POST: Tools/CsvImportInvoiceItems
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CsvImportInvoiceItems(IFormFile file)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing invoice items from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await _csvImportService.ImportInvoiceItemsAsync(stream, companyId.Value);
await LogCsvImportAsync("InvoiceItems", file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing invoice items from CSV");
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Downloads a blank CSV template for the native payment bulk import.
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
@@ -1406,6 +1454,90 @@ public class ToolsController : Controller
return File(csvBytes, "text/csv", "payment_import_template.csv");
}
/// <summary>Downloads a blank CSV template for the invoice line-item bulk import.</summary>
// GET: Tools/DownloadInvoiceItemTemplate
[HttpGet]
public IActionResult DownloadInvoiceItemTemplate()
{
var csvBytes = _csvImportService.GenerateInvoiceItemTemplate();
return File(csvBytes, "text/csv", "invoice_item_import_template.csv");
}
// POST: Tools/CsvImportBills — vendor bill headers (vendor by name, AP account by number).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportBills(IFormFile file)
=> RunCsvImport(file, "Bills", _csvImportService.ImportBillsAsync);
// POST: Tools/CsvImportBillLineItems — bill lines (run after bills).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportBillLineItems(IFormFile file)
=> RunCsvImport(file, "BillLineItems", _csvImportService.ImportBillLineItemsAsync);
// POST: Tools/CsvImportDeposits — customer deposits (customer by name, bank account by number).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportDeposits(IFormFile file)
=> RunCsvImport(file, "Deposits", _csvImportService.ImportDepositsAsync);
// POST: Tools/CsvImportJournalEntries — journal entry headers.
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportJournalEntries(IFormFile file)
=> RunCsvImport(file, "JournalEntries", _csvImportService.ImportJournalEntriesAsync);
// POST: Tools/CsvImportJournalEntryLines — journal entry lines (run after journal entries).
[HttpPost]
[ValidateAntiForgeryToken]
public Task<IActionResult> CsvImportJournalEntryLines(IFormFile file)
=> RunCsvImport(file, "JournalEntryLines", _csvImportService.ImportJournalEntryLinesAsync);
/// <summary>
/// Shared plumbing for the accounting CSV imports: validates the upload, resolves the company,
/// runs the given import function, logs it, and returns the standard JSON result shape.
/// </summary>
private async Task<IActionResult> RunCsvImport(IFormFile file, string label,
Func<Stream, int, Task<CsvImportResultDto>> import)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Your account is not associated with a company." });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file provided or file is empty." });
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "Only CSV files are allowed." });
_logger.LogInformation("User {UserName} importing {Label} from CSV {FileName} for company {CompanyId}",
User.Identity?.Name, label, file.FileName, companyId);
using var stream = file.OpenReadStream();
var result = await import(stream, companyId.Value);
await LogCsvImportAsync(label, file.FileName, result);
return Json(new
{
success = result.Success,
message = result.Summary,
successCount = result.SuccessCount,
skippedCount = result.SkippedCount,
errorCount = result.ErrorCount,
totalRows = result.TotalRows,
errors = result.Errors,
warnings = result.Warnings
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing {Label} from CSV", label);
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
}
}
/// <summary>
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber.
/// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
@@ -2044,7 +2176,7 @@ public class ToolsController : Controller
}
// 11. Invoices
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job, i => i.InvoiceItems);
var invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open())
@@ -2063,6 +2195,17 @@ public class ToolsController : Controller
await writer.WriteAsync(accountsCsv);
}
// 12b. Invoice line items — one row per line, carrying the revenue account number so
// the invoice's revenue attribution survives an export/import round-trip.
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var invoiceItemsCsv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
var invoiceItemsEntry = archive.CreateEntry($"invoice_items_{timestamp}.csv");
using (var entryStream = invoiceItemsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(invoiceItemsCsv);
}
// 13. Expenses
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expensesCsv = GenerateExpensesCsv(expenses);
@@ -2074,7 +2217,7 @@ public class ToolsController : Controller
}
// 14. Payments
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice, p => p.DepositAccount);
var paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open())
@@ -2083,6 +2226,46 @@ public class ToolsController : Controller
await writer.WriteAsync(paymentsCsv);
}
// 15. Bills + bill line items (account/job by number, AP account, vendor by name)
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount, b => b.LineItems);
var billsEntry = archive.CreateEntry($"bills_{timestamp}.csv");
using (var entryStream = billsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateBillsCsv(bills));
}
var billLineItemsEntry = archive.CreateEntry($"bill_line_items_{timestamp}.csv");
using (var entryStream = billLineItemsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById));
}
// 16. Deposits (customer by name, bank account + applied invoice by number)
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
var depositsEntry = archive.CreateEntry($"deposits_{timestamp}.csv");
using (var entryStream = depositsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateDepositsCsv(deposits, accountNumberById));
}
// 17. Journal entries + lines (account by number, debit/credit)
var journalEntries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
var journalEntriesEntry = archive.CreateEntry($"journal_entries_{timestamp}.csv");
using (var entryStream = journalEntriesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateJournalEntriesCsv(journalEntries));
}
var journalEntryLinesEntry = archive.CreateEntry($"journal_entry_lines_{timestamp}.csv");
using (var entryStream = journalEntryLinesEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(GenerateJournalEntryLinesCsv(journalEntries, accountNumberById));
}
// 15. Purchase Orders
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
@@ -2504,7 +2687,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice, p => p.DepositAccount);
var csv = GeneratePaymentsCsv(payments);
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2519,6 +2702,164 @@ public class ToolsController : Controller
}
}
/// <summary>
/// Exports all invoice line items for the current company as a CSV, keyed by parent invoice number
/// and carrying each line's revenue account number. Complements <see cref="ExportInvoicesCsv"/>
/// (which is header-only) so invoice detail and revenue attribution round-trip on re-import.
/// </summary>
// GET: Tools/ExportInvoiceItemsCsv
[HttpGet]
public async Task<IActionResult> ExportInvoiceItemsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company.";
return RedirectToAction(nameof(Index));
}
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.InvoiceItems);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var csv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
var fileName = $"invoice_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
await LogExportAsync("InvoiceItems", $"CSV export ({invoices.Sum(i => i.InvoiceItems.Count)} line items)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting invoice items to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting invoice items.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports vendor bill headers (vendor by name, AP account by number) as CSV.</summary>
// GET: Tools/ExportBillsCsv
[HttpGet]
public async Task<IActionResult> ExportBillsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount);
var csv = GenerateBillsCsv(bills);
await LogExportAsync("Bills", $"CSV export ({bills.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bills_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting bills to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting bills.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports vendor bill line items (account/job by number) as CSV.</summary>
// GET: Tools/ExportBillLineItemsCsv
[HttpGet]
public async Task<IActionResult> ExportBillLineItemsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.LineItems);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
var csv = GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById);
await LogExportAsync("BillLineItems", $"CSV export ({bills.Sum(b => b.LineItems.Count)} line items)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bill_line_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting bill line items to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting bill line items.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports customer deposits (customer by name, bank account + applied invoice by number) as CSV.</summary>
// GET: Tools/ExportDepositsCsv
[HttpGet]
public async Task<IActionResult> ExportDepositsCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var csv = GenerateDepositsCsv(deposits, accountNumberById);
await LogExportAsync("Deposits", $"CSV export ({deposits.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"deposits_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting deposits to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting deposits.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports journal entry headers as CSV. Lines export separately.</summary>
// GET: Tools/ExportJournalEntriesCsv
[HttpGet]
public async Task<IActionResult> ExportJournalEntriesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value);
var csv = GenerateJournalEntriesCsv(entries);
await LogExportAsync("JournalEntries", $"CSV export ({entries.Count()} records)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entries_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting journal entries to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting journal entries.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>Exports journal entry lines (account by number, debit/credit) as CSV.</summary>
// GET: Tools/ExportJournalEntryLinesCsv
[HttpGet]
public async Task<IActionResult> ExportJournalEntryLinesCsv()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
var csv = GenerateJournalEntryLinesCsv(entries, accountNumberById);
await LogExportAsync("JournalEntryLines", $"CSV export ({entries.Sum(e => e.Lines.Count)} lines)");
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entry_lines_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting journal entry lines to CSV");
TempData["ErrorMessage"] = "An error occurred while exporting journal entry lines.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Exports all purchase orders for the current company as a CSV file, including the vendor
/// company name resolved via eager loading. PO status is written as its enum name.
@@ -3973,6 +4314,35 @@ public class ToolsController : Controller
return sb.ToString();
}
/// <summary>
/// Builds a CSV of invoice line items — one row per item across all the given invoices. The parent
/// invoice number and the line's revenue account number (resolved from <paramref name="accountNumberById"/>)
/// are written so revenue attribution survives an export/import round-trip. Rows are emitted in
/// DisplayOrder within each invoice.
/// </summary>
private string GenerateInvoiceItemsCsv(IEnumerable<Core.Entities.Invoice> invoices, IReadOnlyDictionary<int, string> accountNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("InvoiceNumber,Description,Quantity,UnitPrice,TotalPrice,ColorName,RevenueAccountNumber,DisplayOrder,Notes");
foreach (var invoice in invoices)
{
foreach (var item in invoice.InvoiceItems.OrderBy(it => it.DisplayOrder))
{
var revenueAccountNumber = item.RevenueAccountId.HasValue
&& accountNumberById.TryGetValue(item.RevenueAccountId.Value, out var num)
? num : "";
sb.AppendLine($"{EscapeCsv(invoice.InvoiceNumber)},{EscapeCsv(item.Description)}," +
$"{item.Quantity},{item.UnitPrice},{item.TotalPrice}," +
$"{EscapeCsv(item.ColorName)},{EscapeCsv(revenueAccountNumber)}," +
$"{item.DisplayOrder},{EscapeCsv(item.Notes)}");
}
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV string for the given invoice payment records. The parent invoice number is
/// resolved from the eagerly loaded <c>Invoice</c> navigation property. PaymentMethod is
@@ -3981,13 +4351,111 @@ public class ToolsController : Controller
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes");
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,DepositAccountNumber,Reference,Notes");
foreach (var payment in payments)
{
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
$"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
$"{payment.PaymentMethod},{EscapeCsv(payment.DepositAccount?.AccountNumber)}," +
$"{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
}
return sb.ToString();
}
/// <summary>Builds the vendor bill header CSV — vendor by name, AP account by number.</summary>
private string GenerateBillsCsv(IEnumerable<Core.Entities.Bill> bills)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("BillNumber,VendorInvoiceNumber,VendorName,APAccountNumber,BillDate,DueDate,Status,Terms,Memo,SubTotal,TaxPercent,TaxAmount,Total,AmountPaid");
foreach (var bill in bills)
{
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(bill.VendorInvoiceNumber)}," +
$"{EscapeCsv(bill.Vendor?.CompanyName)},{EscapeCsv(bill.APAccount?.AccountNumber)}," +
$"{bill.BillDate:yyyy-MM-dd},{bill.DueDate?.ToString("yyyy-MM-dd")},{bill.Status}," +
$"{EscapeCsv(bill.Terms)},{EscapeCsv(bill.Memo)}," +
$"{bill.SubTotal},{bill.TaxPercent},{bill.TaxAmount},{bill.Total},{bill.AmountPaid}");
}
return sb.ToString();
}
/// <summary>Builds the bill line-item CSV — one row per line, account/job resolved by number.</summary>
private string GenerateBillLineItemsCsv(IEnumerable<Core.Entities.Bill> bills,
IReadOnlyDictionary<int, string> accountNumberById, IReadOnlyDictionary<int, string> jobNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("BillNumber,AccountNumber,JobNumber,Description,Quantity,UnitPrice,Amount,DisplayOrder");
foreach (var bill in bills)
{
foreach (var line in bill.LineItems.OrderBy(li => li.DisplayOrder))
{
var accountNumber = line.AccountId.HasValue && accountNumberById.TryGetValue(line.AccountId.Value, out var an) ? an : "";
var jobNumber = line.JobId.HasValue && jobNumberById.TryGetValue(line.JobId.Value, out var jn) ? jn : "";
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(accountNumber)},{EscapeCsv(jobNumber)}," +
$"{EscapeCsv(line.Description)},{line.Quantity},{line.UnitPrice},{line.Amount},{line.DisplayOrder}");
}
}
return sb.ToString();
}
/// <summary>Builds the customer deposit CSV — customer by name, bank account + applied invoice resolved.</summary>
private string GenerateDepositsCsv(IEnumerable<Core.Entities.Deposit> deposits, IReadOnlyDictionary<int, string> accountNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("ReceiptNumber,CustomerName,Amount,PaymentMethod,ReceivedDate,DepositAccountNumber,AppliedToInvoiceNumber,AppliedDate,Reference,Notes");
foreach (var deposit in deposits)
{
var customerName = deposit.Customer != null
? (!string.IsNullOrWhiteSpace(deposit.Customer.CompanyName)
? deposit.Customer.CompanyName
: $"{deposit.Customer.ContactFirstName} {deposit.Customer.ContactLastName}".Trim())
: "";
var depositAccountNumber = deposit.DepositAccountId.HasValue && accountNumberById.TryGetValue(deposit.DepositAccountId.Value, out var an) ? an : "";
sb.AppendLine($"{EscapeCsv(deposit.ReceiptNumber)},{EscapeCsv(customerName)},{deposit.Amount}," +
$"{deposit.PaymentMethod},{deposit.ReceivedDate:yyyy-MM-dd},{EscapeCsv(depositAccountNumber)}," +
$"{EscapeCsv(deposit.AppliedToInvoice?.InvoiceNumber)},{deposit.AppliedDate?.ToString("yyyy-MM-dd")}," +
$"{EscapeCsv(deposit.Reference)},{EscapeCsv(deposit.Notes)}");
}
return sb.ToString();
}
/// <summary>Builds the journal entry header CSV. Lines are exported separately.</summary>
private string GenerateJournalEntriesCsv(IEnumerable<Core.Entities.JournalEntry> entries)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("EntryNumber,EntryDate,Reference,Description,Status");
foreach (var entry in entries)
{
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{entry.EntryDate:yyyy-MM-dd}," +
$"{EscapeCsv(entry.Reference)},{EscapeCsv(entry.Description)},{entry.Status}");
}
return sb.ToString();
}
/// <summary>Builds the journal entry line CSV — one row per debit/credit line, account by number.</summary>
private string GenerateJournalEntryLinesCsv(IEnumerable<Core.Entities.JournalEntry> entries, IReadOnlyDictionary<int, string> accountNumberById)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("EntryNumber,AccountNumber,DebitAmount,CreditAmount,Description,LineOrder");
foreach (var entry in entries)
{
foreach (var line in entry.Lines.OrderBy(l => l.LineOrder))
{
var accountNumber = accountNumberById.TryGetValue(line.AccountId, out var an) ? an : "";
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{EscapeCsv(accountNumber)}," +
$"{line.DebitAmount},{line.CreditAmount},{EscapeCsv(line.Description)},{line.LineOrder}");
}
}
return sb.ToString();
@@ -88,7 +88,17 @@
tips: ['Download the CSV template to see the expected columns',
'Customers must exist before importing — matched by CustomerEmail then Customer name',
'Existing invoices matched by InvoiceNumber are updated; new ones are created',
'Line items are not part of the CSV — this imports invoice headers and totals only'] },
'Line items import separately — run the Invoice Line Items import after this one'] },
{ key: 'csv-invoiceitems',
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
desc: 'Invoice line items with revenue account attribution',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportInvoiceItems', accept: '.csv',
template: '/Tools/DownloadInvoiceItemTemplate',
tips: ['Import invoices first — each line is matched to its parent by InvoiceNumber',
'RevenueAccountNumber is optional and matched against your Chart of Accounts',
'Re-running is safe — duplicate lines (same description + total + order) are skipped'] },
{ key: 'csv-appointments',
label: 'Appointments', icon: 'bi-calendar-check', color: '#2563eb',
@@ -157,6 +167,53 @@
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment',
'Invoice AmountPaid and status are updated automatically after each payment'] },
{ key: 'csv-bills',
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
desc: 'Vendor bill headers — vendor by name, AP account by number',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportBills', accept: '.csv',
tips: ['Import Chart of Accounts and Vendors first',
'VendorName matches a vendor; APAccountNumber matches your Chart of Accounts',
'Existing bills matched by BillNumber are skipped',
'Import bill line items separately after this'] },
{ key: 'csv-billlineitems',
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
desc: 'Bill line items with expense account attribution',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportBillLineItems', accept: '.csv',
tips: ['Import bills first — lines are matched to their parent by BillNumber',
'AccountNumber (expense/asset) and JobNumber are optional',
'Re-running is safe — duplicate lines are skipped'] },
{ key: 'csv-deposits',
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
desc: 'Deposits with customer, bank account, and applied invoice',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportDeposits', accept: '.csv',
tips: ['Import Customers and Chart of Accounts first',
'CustomerName matches a customer; DepositAccountNumber matches your Chart of Accounts',
'AppliedToInvoiceNumber is optional — links the deposit to an invoice',
'Existing deposits matched by ReceiptNumber are skipped'] },
{ key: 'csv-journalentries',
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
desc: 'Journal entry headers — import lines separately after',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportJournalEntries', accept: '.csv',
tips: ['Existing entries matched by EntryNumber are skipped',
'Valid Status values: Draft, Posted, Reversed',
'Import the entry lines separately after this'] },
{ key: 'csv-journalentrylines',
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
desc: 'Debit/credit lines with account attribution',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportJournalEntryLines', accept: '.csv',
tips: ['Import journal entries and Chart of Accounts first',
'Lines are matched to their entry by EntryNumber; AccountNumber is required',
'Each line is a debit or a credit — entries should balance'] },
{ key: 'csv-purchaseorders',
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
desc: 'Purchase order headers with vendor, status, and totals',
@@ -204,11 +261,41 @@
desc: 'Invoice headers, amounts, status, and customer info',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoicesCsv' },
{ key: 'exp-invoiceitems',
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
desc: 'Line items with revenue account, keyed by invoice number',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoiceItemsCsv' },
{ key: 'exp-payments',
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
desc: 'Invoice payment records with method and reference',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPaymentsCsv' },
{ key: 'exp-bills',
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
desc: 'Vendor bill headers with vendor and AP account',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillsCsv' },
{ key: 'exp-billlineitems',
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
desc: 'Bill line items with expense account, keyed by bill number',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillLineItemsCsv' },
{ key: 'exp-deposits',
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
desc: 'Deposits with customer, bank account, and applied invoice',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportDepositsCsv' },
{ key: 'exp-journalentries',
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
desc: 'Journal entry headers with reference and status',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntriesCsv' },
{ key: 'exp-journalentrylines',
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
desc: 'Debit/credit lines with account, keyed by entry number',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntryLinesCsv' },
{ key: 'exp-appointments',
label: 'Appointments', icon: 'bi-calendar-check', color: '#d97706',
desc: 'Customer, type, status, and scheduling details',