From f54b9450537f029ec83fd03ff513f284ec231bce Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Thu, 18 Jun 2026 18:49:39 -0400 Subject: [PATCH] Preserve accounting linkages through CSV export/import round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../DTOs/Import/BillImportDto.cs | 55 ++ .../DTOs/Import/BillLineItemImportDto.cs | 37 + .../DTOs/Import/DepositImportDto.cs | 46 ++ .../DTOs/Import/InvoiceItemImportDto.cs | 44 ++ .../DTOs/Import/JournalEntryImportDto.cs | 27 + .../DTOs/Import/JournalEntryLineImportDto.cs | 31 + .../DTOs/Import/PaymentImportDto.cs | 8 + .../Interfaces/ICsvImportService.cs | 28 +- .../Services/CsvImportService.cs | 720 +++++++++++++++++- .../Controllers/ToolsController.cs | 478 +++++++++++- .../wwwroot/js/tools-import.js | 89 ++- 11 files changed, 1543 insertions(+), 20 deletions(-) create mode 100644 src/PowderCoating.Application/DTOs/Import/BillImportDto.cs create mode 100644 src/PowderCoating.Application/DTOs/Import/BillLineItemImportDto.cs create mode 100644 src/PowderCoating.Application/DTOs/Import/DepositImportDto.cs create mode 100644 src/PowderCoating.Application/DTOs/Import/InvoiceItemImportDto.cs create mode 100644 src/PowderCoating.Application/DTOs/Import/JournalEntryImportDto.cs create mode 100644 src/PowderCoating.Application/DTOs/Import/JournalEntryLineImportDto.cs diff --git a/src/PowderCoating.Application/DTOs/Import/BillImportDto.cs b/src/PowderCoating.Application/DTOs/Import/BillImportDto.cs new file mode 100644 index 0000000..e7d7d10 --- /dev/null +++ b/src/PowderCoating.Application/DTOs/Import/BillImportDto.cs @@ -0,0 +1,55 @@ +using CsvHelper.Configuration.Attributes; + +namespace PowderCoating.Application.DTOs.Import; + +/// +/// DTO for importing vendor bill headers from CSV. Column names match the native bills export +/// (ExportBillsCsv) for round-trip compatibility. The vendor is resolved by name and the AP account +/// by number so accounting linkages survive. Line items import separately via BillLineItemImportDto. +/// +public class BillImportDto +{ + [Name("BillNumber")] + public string? BillNumber { get; set; } + + [Name("VendorInvoiceNumber")] + public string? VendorInvoiceNumber { get; set; } + + /// Vendor company name, matched against Vendor.CompanyName. + [Name("VendorName")] + public string? VendorName { get; set; } + + /// AP account number (Chart of Accounts) this bill posts to. + [Name("APAccountNumber")] + public string? APAccountNumber { get; set; } + + [Name("BillDate")] + public DateTime BillDate { get; set; } + + [Name("DueDate")] + public DateTime? DueDate { get; set; } + + [Name("Status")] + public string Status { get; set; } = "Open"; + + [Name("Terms")] + public string? Terms { get; set; } + + [Name("Memo")] + public string? Memo { get; set; } + + [Name("SubTotal")] + public decimal SubTotal { get; set; } + + [Name("TaxPercent")] + public decimal TaxPercent { get; set; } + + [Name("TaxAmount")] + public decimal TaxAmount { get; set; } + + [Name("Total")] + public decimal Total { get; set; } + + [Name("AmountPaid")] + public decimal AmountPaid { get; set; } +} diff --git a/src/PowderCoating.Application/DTOs/Import/BillLineItemImportDto.cs b/src/PowderCoating.Application/DTOs/Import/BillLineItemImportDto.cs new file mode 100644 index 0000000..5847211 --- /dev/null +++ b/src/PowderCoating.Application/DTOs/Import/BillLineItemImportDto.cs @@ -0,0 +1,37 @@ +using CsvHelper.Configuration.Attributes; + +namespace PowderCoating.Application.DTOs.Import; + +/// +/// DTO for importing vendor bill line items from CSV. Column names match the native bill-items export +/// (ExportBillLineItemsCsv). Lines are matched to their parent bill by BillNumber; the expense/asset +/// account is resolved (optional) from AccountNumber so each line's GL attribution round-trips. +/// +public class BillLineItemImportDto +{ + [Name("BillNumber")] + public string? BillNumber { get; set; } + + /// Expense/asset account number this line is categorized under. Optional. + [Name("AccountNumber")] + public string? AccountNumber { get; set; } + + /// Optional job-costing link, matched against Job.JobNumber. + [Name("JobNumber")] + public string? JobNumber { get; set; } + + [Name("Description")] + public string? Description { get; set; } + + [Name("Quantity")] + public decimal Quantity { get; set; } + + [Name("UnitPrice")] + public decimal UnitPrice { get; set; } + + [Name("Amount")] + public decimal Amount { get; set; } + + [Name("DisplayOrder")] + public int DisplayOrder { get; set; } +} diff --git a/src/PowderCoating.Application/DTOs/Import/DepositImportDto.cs b/src/PowderCoating.Application/DTOs/Import/DepositImportDto.cs new file mode 100644 index 0000000..65b3303 --- /dev/null +++ b/src/PowderCoating.Application/DTOs/Import/DepositImportDto.cs @@ -0,0 +1,46 @@ +using CsvHelper.Configuration.Attributes; + +namespace PowderCoating.Application.DTOs.Import; + +/// +/// DTO for importing customer deposits from CSV. Column names match the native deposits export +/// (ExportDepositsCsv). The customer is resolved by name, the bank account by number +/// (DepositAccountNumber), and the optional applied invoice by number so the deposit's linkages +/// survive an export/import round-trip. +/// +public class DepositImportDto +{ + [Name("ReceiptNumber")] + public string? ReceiptNumber { get; set; } + + /// Customer name (company name, or contact full name), matched against the customer record. + [Name("CustomerName")] + public string? CustomerName { get; set; } + + [Name("Amount")] + public decimal Amount { get; set; } + + /// Valid values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment + [Name("PaymentMethod")] + public string PaymentMethod { get; set; } = "Cash"; + + [Name("ReceivedDate")] + public DateTime ReceivedDate { get; set; } + + /// Bank/cash account number (Chart of Accounts) the deposit landed in. Optional. + [Name("DepositAccountNumber")] + public string? DepositAccountNumber { get; set; } + + /// Invoice number this deposit has been applied to, if any. Optional. + [Name("AppliedToInvoiceNumber")] + public string? AppliedToInvoiceNumber { get; set; } + + [Name("AppliedDate")] + public DateTime? AppliedDate { get; set; } + + [Name("Reference")] + public string? Reference { get; set; } + + [Name("Notes")] + public string? Notes { get; set; } +} diff --git a/src/PowderCoating.Application/DTOs/Import/InvoiceItemImportDto.cs b/src/PowderCoating.Application/DTOs/Import/InvoiceItemImportDto.cs new file mode 100644 index 0000000..63e4f05 --- /dev/null +++ b/src/PowderCoating.Application/DTOs/Import/InvoiceItemImportDto.cs @@ -0,0 +1,44 @@ +using CsvHelper.Configuration.Attributes; + +namespace PowderCoating.Application.DTOs.Import; + +/// +/// DTO for importing invoice line items from CSV. Column names match the native +/// invoice-items export (ExportInvoiceItemsCsv) for round-trip compatibility. +/// Line items are matched to their parent invoice by InvoiceNumber; the revenue +/// account is resolved from RevenueAccountNumber against Account.AccountNumber so the +/// invoice's revenue attribution survives an export/import round-trip. +/// +public class InvoiceItemImportDto +{ + [Name("InvoiceNumber")] + public string? InvoiceNumber { get; set; } + + [Name("Description")] + public string? Description { get; set; } + + [Name("Quantity")] + public decimal Quantity { get; set; } + + [Name("UnitPrice")] + public decimal UnitPrice { get; set; } + + [Name("TotalPrice")] + public decimal TotalPrice { get; set; } + + [Name("ColorName")] + public string? ColorName { get; set; } + + /// + /// Account number (Chart of Accounts) of the revenue account this line posts to. Optional — + /// a blank value means the line falls back to the company's default revenue account. + /// + [Name("RevenueAccountNumber")] + public string? RevenueAccountNumber { get; set; } + + [Name("DisplayOrder")] + public int DisplayOrder { get; set; } + + [Name("Notes")] + public string? Notes { get; set; } +} diff --git a/src/PowderCoating.Application/DTOs/Import/JournalEntryImportDto.cs b/src/PowderCoating.Application/DTOs/Import/JournalEntryImportDto.cs new file mode 100644 index 0000000..d009a77 --- /dev/null +++ b/src/PowderCoating.Application/DTOs/Import/JournalEntryImportDto.cs @@ -0,0 +1,27 @@ +using CsvHelper.Configuration.Attributes; + +namespace PowderCoating.Application.DTOs.Import; + +/// +/// DTO for importing journal entry headers from CSV. Column names match the native journal-entries +/// export (ExportJournalEntriesCsv). The debit/credit lines import separately via +/// JournalEntryLineImportDto and must balance per entry. +/// +public class JournalEntryImportDto +{ + [Name("EntryNumber")] + public string? EntryNumber { get; set; } + + [Name("EntryDate")] + public DateTime EntryDate { get; set; } + + [Name("Reference")] + public string? Reference { get; set; } + + [Name("Description")] + public string? Description { get; set; } + + /// Valid values: Draft, Posted, Reversed + [Name("Status")] + public string Status { get; set; } = "Draft"; +} diff --git a/src/PowderCoating.Application/DTOs/Import/JournalEntryLineImportDto.cs b/src/PowderCoating.Application/DTOs/Import/JournalEntryLineImportDto.cs new file mode 100644 index 0000000..0fee52a --- /dev/null +++ b/src/PowderCoating.Application/DTOs/Import/JournalEntryLineImportDto.cs @@ -0,0 +1,31 @@ +using CsvHelper.Configuration.Attributes; + +namespace PowderCoating.Application.DTOs.Import; + +/// +/// DTO for importing journal entry lines from CSV. Column names match the native journal-entry-lines +/// export (ExportJournalEntryLinesCsv). Lines are matched to their parent entry by EntryNumber and the +/// account is resolved from AccountNumber (required — a JE line is meaningless without its account). +/// Either DebitAmount or CreditAmount is non-zero per line, not both. +/// +public class JournalEntryLineImportDto +{ + [Name("EntryNumber")] + public string? EntryNumber { get; set; } + + /// Account number (Chart of Accounts) this line debits or credits. Required. + [Name("AccountNumber")] + public string? AccountNumber { get; set; } + + [Name("DebitAmount")] + public decimal DebitAmount { get; set; } + + [Name("CreditAmount")] + public decimal CreditAmount { get; set; } + + [Name("Description")] + public string? Description { get; set; } + + [Name("LineOrder")] + public int LineOrder { get; set; } +} diff --git a/src/PowderCoating.Application/DTOs/Import/PaymentImportDto.cs b/src/PowderCoating.Application/DTOs/Import/PaymentImportDto.cs index 7effc03..c1e5c62 100644 --- a/src/PowderCoating.Application/DTOs/Import/PaymentImportDto.cs +++ b/src/PowderCoating.Application/DTOs/Import/PaymentImportDto.cs @@ -24,6 +24,14 @@ public class PaymentImportDto [Name("PaymentMethod")] public string PaymentMethod { get; set; } = "Cash"; + /// + /// Account number (Chart of Accounts) of the bank/cash account the payment was deposited into. + /// Resolved back to DepositAccountId on import so the balance recalc can post it to the + /// right bank account. Optional — a blank value means no deposit account was recorded. + /// + [Name("DepositAccountNumber")] + public string? DepositAccountNumber { get; set; } + [Name("Reference")] public string? Reference { get; set; } diff --git a/src/PowderCoating.Application/Interfaces/ICsvImportService.cs b/src/PowderCoating.Application/Interfaces/ICsvImportService.cs index 0fc3eb6..6d96e0a 100644 --- a/src/PowderCoating.Application/Interfaces/ICsvImportService.cs +++ b/src/PowderCoating.Application/Interfaces/ICsvImportService.cs @@ -179,10 +179,36 @@ public interface ICsvImportService /// /// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then /// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices - /// are updated; new ones are created. Line items are not part of the CSV format. + /// are updated; new ones are created. Line items are imported separately via + /// . /// Task ImportInvoicesAsync(Stream csvStream, int companyId); + /// + /// Import invoice line items from a CSV stream. Each line is matched to its parent invoice by + /// InvoiceNumber and its revenue account resolved (optional) from RevenueAccountNumber. Idempotent + /// by description + total + display order. Run after invoices have been imported. + /// + Task ImportInvoiceItemsAsync(Stream csvStream, int companyId); + + /// Import vendor bill headers. Vendor by name, AP account by number. Dedup by BillNumber. + Task ImportBillsAsync(Stream csvStream, int companyId); + + /// Import vendor bill line items. Matched to bills by BillNumber; account/job by number. + Task ImportBillLineItemsAsync(Stream csvStream, int companyId); + + /// Import customer deposits. Customer by name, bank account by number, applied invoice by number. + Task ImportDepositsAsync(Stream csvStream, int companyId); + + /// Import journal entry headers. Dedup by EntryNumber. Lines import separately. + Task ImportJournalEntriesAsync(Stream csvStream, int companyId); + + /// Import journal entry lines. Matched to entries by EntryNumber; account by number (required). + Task ImportJournalEntryLinesAsync(Stream csvStream, int companyId); + + /// Generate a CSV template file for invoice line-item imports. + byte[] GenerateInvoiceItemTemplate(); + /// Generate a CSV template file for payment imports. byte[] GeneratePaymentTemplate(); diff --git a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs index c6208fd..84cae98 100644 --- a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs +++ b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs @@ -3208,6 +3208,33 @@ public class CsvImportService : ICsvImportService } } + public byte[] GenerateInvoiceItemTemplate() + { + using var memoryStream = new MemoryStream(); + using var writer = new StreamWriter(memoryStream); + using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)); + + csv.WriteHeader(); + csv.NextRecord(); + + csv.WriteRecord(new InvoiceItemImportDto + { + InvoiceNumber = "INV-2601-0001", + Description = "Powder coating - 4 wheels", + Quantity = 4, + UnitPrice = 75.00m, + TotalPrice = 300.00m, + ColorName = "Gloss Black", + RevenueAccountNumber = "47905", + DisplayOrder = 0, + Notes = "" + }); + csv.NextRecord(); + + writer.Flush(); + return memoryStream.ToArray(); + } + public byte[] GeneratePaymentTemplate() { using var memoryStream = new MemoryStream(); @@ -3219,12 +3246,13 @@ public class CsvImportService : ICsvImportService csv.WriteRecord(new PaymentImportDto { - InvoiceNumber = "INV-2601-0001", - Amount = 250.00m, - PaymentDate = DateTime.Today, - PaymentMethod = "Check", - Reference = "CHK-1234", - Notes = "" + InvoiceNumber = "INV-2601-0001", + Amount = 250.00m, + PaymentDate = DateTime.Today, + PaymentMethod = "Check", + DepositAccountNumber = "10100", + Reference = "CHK-1234", + Notes = "" }); csv.NextRecord(); @@ -3232,6 +3260,651 @@ public class CsvImportService : ICsvImportService return memoryStream.ToArray(); } + /// + /// Imports invoice line items from CSV. Each row is matched to its parent invoice by InvoiceNumber; + /// the revenue account is resolved (optional) from RevenueAccountNumber against the Chart of Accounts. + /// Idempotent — an invoice line with the same description + total + display order is skipped, so the + /// import can be safely re-run. Run AFTER invoices have been imported (the parents must exist). + /// + public async Task ImportInvoiceItemsAsync(Stream csvStream, int companyId) + { + var result = new CsvImportResultDto(); + var rowNumber = 0; + + try + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HeaderValidated = null, + MissingFieldFound = null + }); + + var records = csv.GetRecords().ToList(); + result.TotalRows = records.Count; + + _logger.LogInformation("Starting import of {Count} invoice line items for company {CompanyId}", records.Count, companyId); + + var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.InvoiceItems); + var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber)) + .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase); + + var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accountByNumber = accounts + .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) + .GroupBy(a => a.AccountNumber.Trim()) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var record in records) + { + rowNumber++; + try + { + if (string.IsNullOrWhiteSpace(record.InvoiceNumber)) + { + result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required."); + result.ErrorCount++; + continue; + } + + if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice)) + { + result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found."); + result.ErrorCount++; + continue; + } + + var description = StripQuotes(record.Description)?.Trim() ?? ""; + + // Idempotency: skip a line that already exists on this invoice. + var isDuplicate = invoice.InvoiceItems.Any(it => + string.Equals(it.Description, description, StringComparison.OrdinalIgnoreCase) + && it.TotalPrice == record.TotalPrice + && it.DisplayOrder == record.DisplayOrder); + if (isDuplicate) + { + result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on invoice '{record.InvoiceNumber}' — skipped."); + result.SkippedCount++; + continue; + } + + // Resolve the optional revenue account by number so revenue attribution is preserved. + int? revenueAccountId = null; + var cleanRevenueAccount = StripQuotes(record.RevenueAccountNumber)?.Trim(); + if (!string.IsNullOrWhiteSpace(cleanRevenueAccount)) + { + if (accountByNumber.TryGetValue(cleanRevenueAccount, out var revenueAccount)) + revenueAccountId = revenueAccount.Id; + else + result.Warnings.Add($"Row {rowNumber}: Revenue account '{cleanRevenueAccount}' not found in Chart of Accounts — line imported without a revenue account."); + } + + var item = new Core.Entities.InvoiceItem + { + InvoiceId = invoice.Id, + CompanyId = companyId, + Description = description, + Quantity = record.Quantity, + UnitPrice = record.UnitPrice, + TotalPrice = record.TotalPrice, + ColorName = string.IsNullOrWhiteSpace(record.ColorName) ? null : record.ColorName.Trim(), + RevenueAccountId = revenueAccountId, + DisplayOrder = record.DisplayOrder, + Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() + }; + + await _unitOfWork.InvoiceItems.AddAsync(item); + await _unitOfWork.CompleteAsync(); + + // Keep the in-memory invoice current so later rows dedup against it correctly. + invoice.InvoiceItems.Add(item); + + result.SuccessCount++; + } + catch (Exception ex) + { + result.Errors.Add($"Row {rowNumber}: {ex.Message}"); + result.ErrorCount++; + } + } + + result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error during invoice item CSV import for company {CompanyId}", companyId); + result.Errors.Add($"Fatal error: {ex.Message}"); + return result; + } + } + + /// + /// Imports vendor bill headers from CSV. Vendor is resolved by name and the AP account by number. + /// Dedup by BillNumber. Line items import separately via . + /// + public async Task ImportBillsAsync(Stream csvStream, int companyId) + { + var result = new CsvImportResultDto(); + var rowNumber = 0; + + try + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HeaderValidated = null, + MissingFieldFound = null + }); + + var records = csv.GetRecords().ToList(); + result.TotalRows = records.Count; + + var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber)) + .GroupBy(a => a.AccountNumber.Trim()) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var vendors = await _unitOfWork.Vendors.GetAllAsync(); + var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName)) + .GroupBy(v => v.CompanyName.Trim()) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var existingBills = await _unitOfWork.Bills.GetAllAsync(); + var existingBillNumbers = existingBills.Where(b => !string.IsNullOrEmpty(b.BillNumber)) + .Select(b => b.BillNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var record in records) + { + rowNumber++; + try + { + var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(billNumber)) + { + result.Errors.Add($"Row {rowNumber}: BillNumber is required."); + result.ErrorCount++; + continue; + } + if (existingBillNumbers.Contains(billNumber)) + { + result.Warnings.Add($"Row {rowNumber}: Bill '{billNumber}' already exists — skipped."); + result.SkippedCount++; + continue; + } + + var cleanVendor = StripQuotes(record.VendorName)?.Trim() ?? ""; + if (!vendorByName.TryGetValue(cleanVendor, out var vendor)) + { + result.Errors.Add($"Row {rowNumber}: Vendor '{cleanVendor}' not found."); + result.ErrorCount++; + continue; + } + + var cleanApAccount = StripQuotes(record.APAccountNumber)?.Trim() ?? ""; + if (!accountByNumber.TryGetValue(cleanApAccount, out var apAccount)) + { + result.Errors.Add($"Row {rowNumber}: AP account '{cleanApAccount}' not found in Chart of Accounts."); + result.ErrorCount++; + continue; + } + + if (!Enum.TryParse(record.Status?.Trim(), true, out var status)) + status = BillStatus.Open; + + var bill = new Core.Entities.Bill + { + CompanyId = companyId, + BillNumber = billNumber, + VendorInvoiceNumber = string.IsNullOrWhiteSpace(record.VendorInvoiceNumber) ? null : record.VendorInvoiceNumber.Trim(), + VendorId = vendor.Id, + APAccountId = apAccount.Id, + BillDate = record.BillDate == default ? DateTime.UtcNow.Date : record.BillDate, + DueDate = record.DueDate, + Status = status, + Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(), + Memo = string.IsNullOrWhiteSpace(record.Memo) ? null : record.Memo.Trim(), + SubTotal = record.SubTotal, + TaxPercent = record.TaxPercent, + TaxAmount = record.TaxAmount, + Total = record.Total, + AmountPaid = record.AmountPaid + }; + + await _unitOfWork.Bills.AddAsync(bill); + await _unitOfWork.CompleteAsync(); + existingBillNumbers.Add(billNumber); + result.SuccessCount++; + } + catch (Exception ex) + { + result.Errors.Add($"Row {rowNumber}: {ex.Message}"); + result.ErrorCount++; + } + } + + result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error during bill CSV import for company {CompanyId}", companyId); + result.Errors.Add($"Fatal error: {ex.Message}"); + return result; + } + } + + /// + /// Imports vendor bill line items from CSV. Each line is matched to its parent bill by BillNumber; + /// the expense/asset account (optional) and job (optional) are resolved by number. Idempotent by + /// bill + description + amount + display order. Run after bills have been imported. + /// + public async Task ImportBillLineItemsAsync(Stream csvStream, int companyId) + { + var result = new CsvImportResultDto(); + var rowNumber = 0; + + try + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HeaderValidated = null, + MissingFieldFound = null + }); + + var records = csv.GetRecords().ToList(); + result.TotalRows = records.Count; + + var bills = await _unitOfWork.Bills.GetAllAsync(false, b => b.LineItems); + var billByNumber = bills.Where(b => !string.IsNullOrEmpty(b.BillNumber)) + .ToDictionary(b => b.BillNumber.Trim(), b => b, StringComparer.OrdinalIgnoreCase); + + var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber)) + .GroupBy(a => a.AccountNumber.Trim()) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var jobs = await _unitOfWork.Jobs.GetAllAsync(); + var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)) + .ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase); + + foreach (var record in records) + { + rowNumber++; + try + { + var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? ""; + if (!billByNumber.TryGetValue(billNumber, out var bill)) + { + result.Errors.Add($"Row {rowNumber}: Bill '{record.BillNumber}' not found."); + result.ErrorCount++; + continue; + } + + var description = StripQuotes(record.Description)?.Trim() ?? ""; + var isDuplicate = bill.LineItems.Any(li => + string.Equals(li.Description, description, StringComparison.OrdinalIgnoreCase) + && li.Amount == record.Amount && li.DisplayOrder == record.DisplayOrder); + if (isDuplicate) + { + result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on bill '{billNumber}' — skipped."); + result.SkippedCount++; + continue; + } + + int? accountId = null; + var cleanAccount = StripQuotes(record.AccountNumber)?.Trim(); + if (!string.IsNullOrWhiteSpace(cleanAccount)) + { + if (accountByNumber.TryGetValue(cleanAccount, out var account)) + accountId = account.Id; + else + result.Warnings.Add($"Row {rowNumber}: Account '{cleanAccount}' not found — line imported without an account."); + } + + int? jobId = null; + var cleanJob = StripQuotes(record.JobNumber)?.Trim(); + if (!string.IsNullOrWhiteSpace(cleanJob) && jobByNumber.TryGetValue(cleanJob, out var job)) + jobId = job.Id; + + var lineItem = new Core.Entities.BillLineItem + { + CompanyId = companyId, + BillId = bill.Id, + AccountId = accountId, + JobId = jobId, + Description = description, + Quantity = record.Quantity, + UnitPrice = record.UnitPrice, + Amount = record.Amount, + DisplayOrder = record.DisplayOrder + }; + + await _unitOfWork.BillLineItems.AddAsync(lineItem); + await _unitOfWork.CompleteAsync(); + bill.LineItems.Add(lineItem); + result.SuccessCount++; + } + catch (Exception ex) + { + result.Errors.Add($"Row {rowNumber}: {ex.Message}"); + result.ErrorCount++; + } + } + + result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error during bill line item CSV import for company {CompanyId}", companyId); + result.Errors.Add($"Fatal error: {ex.Message}"); + return result; + } + } + + /// + /// Imports customer deposits from CSV. Customer is resolved by name, the bank account by number, + /// and the optional applied invoice by number. Dedup by ReceiptNumber. + /// + public async Task ImportDepositsAsync(Stream csvStream, int companyId) + { + var result = new CsvImportResultDto(); + var rowNumber = 0; + + try + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HeaderValidated = null, + MissingFieldFound = null + }); + + var records = csv.GetRecords().ToList(); + result.TotalRows = records.Count; + + var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber)) + .GroupBy(a => a.AccountNumber.Trim()) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var customers = await _unitOfWork.Customers.GetAllAsync(); + var customerByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var c in customers) + { + var name = !string.IsNullOrWhiteSpace(c.CompanyName) + ? c.CompanyName.Trim() + : $"{c.ContactFirstName} {c.ContactLastName}".Trim(); + if (!string.IsNullOrWhiteSpace(name)) customerByName[name] = c; + } + + var invoices = await _unitOfWork.Invoices.GetAllAsync(); + var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber)) + .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase); + + var validMethods = Enum.GetNames() + .ToDictionary(n => n.ToLower(), n => Enum.Parse(n), StringComparer.OrdinalIgnoreCase); + + var existingReceipts = (await _unitOfWork.Deposits.GetAllAsync()) + .Where(d => !string.IsNullOrEmpty(d.ReceiptNumber)) + .Select(d => d.ReceiptNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var record in records) + { + rowNumber++; + try + { + var receiptNumber = StripQuotes(record.ReceiptNumber)?.Trim() ?? ""; + if (!string.IsNullOrWhiteSpace(receiptNumber) && existingReceipts.Contains(receiptNumber)) + { + result.Warnings.Add($"Row {rowNumber}: Deposit '{receiptNumber}' already exists — skipped."); + result.SkippedCount++; + continue; + } + + var cleanCustomer = StripQuotes(record.CustomerName)?.Trim() ?? ""; + if (!customerByName.TryGetValue(cleanCustomer, out var customer)) + { + result.Errors.Add($"Row {rowNumber}: Customer '{cleanCustomer}' not found."); + result.ErrorCount++; + continue; + } + + if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method)) + method = PaymentMethod.Cash; + + int? depositAccountId = null; + var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim(); + if (!string.IsNullOrWhiteSpace(cleanDepositAccount)) + { + if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount)) + depositAccountId = depositAccount.Id; + else + result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found — deposit imported without a bank account."); + } + + int? appliedInvoiceId = null; + var cleanInvoice = StripQuotes(record.AppliedToInvoiceNumber)?.Trim(); + if (!string.IsNullOrWhiteSpace(cleanInvoice) && invoiceByNumber.TryGetValue(cleanInvoice, out var appliedInvoice)) + appliedInvoiceId = appliedInvoice.Id; + + var deposit = new Core.Entities.Deposit + { + CompanyId = companyId, + ReceiptNumber = receiptNumber, + CustomerId = customer.Id, + Amount = record.Amount, + PaymentMethod = method, + ReceivedDate = record.ReceivedDate == default ? DateTime.UtcNow.Date : record.ReceivedDate, + DepositAccountId = depositAccountId, + AppliedToInvoiceId = appliedInvoiceId, + AppliedDate = record.AppliedDate, + Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(), + Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() + }; + + await _unitOfWork.Deposits.AddAsync(deposit); + await _unitOfWork.CompleteAsync(); + if (!string.IsNullOrWhiteSpace(receiptNumber)) existingReceipts.Add(receiptNumber); + result.SuccessCount++; + } + catch (Exception ex) + { + result.Errors.Add($"Row {rowNumber}: {ex.Message}"); + result.ErrorCount++; + } + } + + result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error during deposit CSV import for company {CompanyId}", companyId); + result.Errors.Add($"Fatal error: {ex.Message}"); + return result; + } + } + + /// + /// Imports journal entry headers from CSV. Dedup by EntryNumber. The debit/credit lines import + /// separately via . + /// + public async Task ImportJournalEntriesAsync(Stream csvStream, int companyId) + { + var result = new CsvImportResultDto(); + var rowNumber = 0; + + try + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HeaderValidated = null, + MissingFieldFound = null + }); + + var records = csv.GetRecords().ToList(); + result.TotalRows = records.Count; + + var existingNumbers = (await _unitOfWork.JournalEntries.GetAllAsync()) + .Where(j => !string.IsNullOrEmpty(j.EntryNumber)) + .Select(j => j.EntryNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var record in records) + { + rowNumber++; + try + { + var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(entryNumber)) + { + result.Errors.Add($"Row {rowNumber}: EntryNumber is required."); + result.ErrorCount++; + continue; + } + if (existingNumbers.Contains(entryNumber)) + { + result.Warnings.Add($"Row {rowNumber}: Journal entry '{entryNumber}' already exists — skipped."); + result.SkippedCount++; + continue; + } + + if (!Enum.TryParse(record.Status?.Trim(), true, out var status)) + status = JournalEntryStatus.Draft; + + var entry = new Core.Entities.JournalEntry + { + CompanyId = companyId, + EntryNumber = entryNumber, + EntryDate = record.EntryDate == default ? DateTime.UtcNow.Date : record.EntryDate, + Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(), + Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(), + Status = status, + PostedAt = status == JournalEntryStatus.Posted ? DateTime.UtcNow : null + }; + + await _unitOfWork.JournalEntries.AddAsync(entry); + await _unitOfWork.CompleteAsync(); + existingNumbers.Add(entryNumber); + result.SuccessCount++; + } + catch (Exception ex) + { + result.Errors.Add($"Row {rowNumber}: {ex.Message}"); + result.ErrorCount++; + } + } + + result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error during journal entry CSV import for company {CompanyId}", companyId); + result.Errors.Add($"Fatal error: {ex.Message}"); + return result; + } + } + + /// + /// Imports journal entry lines from CSV. Each line is matched to its parent entry by EntryNumber + /// and the account resolved (required) from AccountNumber. Idempotent by entry + account + amounts + /// + line order. Run after journal entry headers have been imported. + /// + public async Task ImportJournalEntryLinesAsync(Stream csvStream, int companyId) + { + var result = new CsvImportResultDto(); + var rowNumber = 0; + + try + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HeaderValidated = null, + MissingFieldFound = null + }); + + var records = csv.GetRecords().ToList(); + result.TotalRows = records.Count; + + var entries = await _unitOfWork.JournalEntries.GetAllAsync(false, j => j.Lines); + var entryByNumber = entries.Where(j => !string.IsNullOrEmpty(j.EntryNumber)) + .ToDictionary(j => j.EntryNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase); + + var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber)) + .GroupBy(a => a.AccountNumber.Trim()) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var record in records) + { + rowNumber++; + try + { + var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? ""; + if (!entryByNumber.TryGetValue(entryNumber, out var entry)) + { + result.Errors.Add($"Row {rowNumber}: Journal entry '{record.EntryNumber}' not found."); + result.ErrorCount++; + continue; + } + + var cleanAccount = StripQuotes(record.AccountNumber)?.Trim() ?? ""; + if (!accountByNumber.TryGetValue(cleanAccount, out var account)) + { + result.Errors.Add($"Row {rowNumber}: Account '{cleanAccount}' not found in Chart of Accounts."); + result.ErrorCount++; + continue; + } + + var isDuplicate = entry.Lines.Any(l => + l.AccountId == account.Id && l.DebitAmount == record.DebitAmount + && l.CreditAmount == record.CreditAmount && l.LineOrder == record.LineOrder); + if (isDuplicate) + { + result.Warnings.Add($"Row {rowNumber}: Line for account '{cleanAccount}' already exists on entry '{entryNumber}' — skipped."); + result.SkippedCount++; + continue; + } + + var line = new Core.Entities.JournalEntryLine + { + CompanyId = companyId, + JournalEntryId = entry.Id, + AccountId = account.Id, + DebitAmount = record.DebitAmount, + CreditAmount = record.CreditAmount, + Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(), + LineOrder = record.LineOrder + }; + + await _unitOfWork.JournalEntryLines.AddAsync(line); + await _unitOfWork.CompleteAsync(); + entry.Lines.Add(line); + result.SuccessCount++; + } + catch (Exception ex) + { + result.Errors.Add($"Row {rowNumber}: {ex.Message}"); + result.ErrorCount++; + } + } + + result.Success = result.ErrorCount == 0 || result.SuccessCount > 0; + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error during journal entry line CSV import for company {CompanyId}", companyId); + result.Errors.Add($"Fatal error: {ex.Message}"); + return result; + } + } + public async Task ImportPaymentsAsync(Stream csvStream, int companyId) { var result = new CsvImportResultDto(); @@ -3256,6 +3929,14 @@ public class CsvImportService : ICsvImportService var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber)) .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase); + // Account lookup for resolving the deposit (bank) account by number — optional per row. + // Mirrors the expense import so payments round-trip with their bank-account linkage intact. + var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accountByNumber = accounts + .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) + .GroupBy(a => a.AccountNumber.Trim()) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var validMethods = Enum.GetNames() .ToDictionary(n => n.ToLower(), n => Enum.Parse(n), StringComparer.OrdinalIgnoreCase); @@ -3301,15 +3982,28 @@ public class CsvImportService : ICsvImportService method = PaymentMethod.Cash; } + // Resolve the optional deposit (bank) account by number so the balance recalc can + // post this payment. A blank value is fine; an unknown number warns but still imports. + int? depositAccountId = null; + var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim(); + if (!string.IsNullOrWhiteSpace(cleanDepositAccount)) + { + if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount)) + depositAccountId = depositAccount.Id; + else + result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found in Chart of Accounts — payment imported without a deposit account."); + } + var payment = new Core.Entities.Payment { - InvoiceId = invoice.Id, - CompanyId = companyId, - Amount = record.Amount, - PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc), - PaymentMethod = method, - Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(), - Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() + InvoiceId = invoice.Id, + CompanyId = companyId, + Amount = record.Amount, + PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc), + PaymentMethod = method, + DepositAccountId = depositAccountId, + Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(), + Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() }; await _unitOfWork.Payments.AddAsync(payment); diff --git a/src/PowderCoating.Web/Controllers/ToolsController.cs b/src/PowderCoating.Web/Controllers/ToolsController.cs index a9a2104..d4e16c2 100644 --- a/src/PowderCoating.Web/Controllers/ToolsController.cs +++ b/src/PowderCoating.Web/Controllers/ToolsController.cs @@ -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 } } + /// + /// 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. + /// + // POST: Tools/CsvImportInvoiceItems + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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}" }); + } + } + /// /// 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"); } + /// Downloads a blank CSV template for the invoice line-item bulk import. + // 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 CsvImportBills(IFormFile file) + => RunCsvImport(file, "Bills", _csvImportService.ImportBillsAsync); + + // POST: Tools/CsvImportBillLineItems — bill lines (run after bills). + [HttpPost] + [ValidateAntiForgeryToken] + public Task CsvImportBillLineItems(IFormFile file) + => RunCsvImport(file, "BillLineItems", _csvImportService.ImportBillLineItemsAsync); + + // POST: Tools/CsvImportDeposits — customer deposits (customer by name, bank account by number). + [HttpPost] + [ValidateAntiForgeryToken] + public Task CsvImportDeposits(IFormFile file) + => RunCsvImport(file, "Deposits", _csvImportService.ImportDepositsAsync); + + // POST: Tools/CsvImportJournalEntries — journal entry headers. + [HttpPost] + [ValidateAntiForgeryToken] + public Task CsvImportJournalEntries(IFormFile file) + => RunCsvImport(file, "JournalEntries", _csvImportService.ImportJournalEntriesAsync); + + // POST: Tools/CsvImportJournalEntryLines — journal entry lines (run after journal entries). + [HttpPost] + [ValidateAntiForgeryToken] + public Task CsvImportJournalEntryLines(IFormFile file) + => RunCsvImport(file, "JournalEntryLines", _csvImportService.ImportJournalEntryLinesAsync); + + /// + /// 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. + /// + private async Task RunCsvImport(IFormFile file, string label, + Func> 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}" }); + } + } + /// /// 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 } } + /// + /// 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 + /// (which is header-only) so invoice detail and revenue attribution round-trip on re-import. + /// + // GET: Tools/ExportInvoiceItemsCsv + [HttpGet] + public async Task 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)); + } + } + + /// Exports vendor bill headers (vendor by name, AP account by number) as CSV. + // GET: Tools/ExportBillsCsv + [HttpGet] + public async Task 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)); + } + } + + /// Exports vendor bill line items (account/job by number) as CSV. + // GET: Tools/ExportBillLineItemsCsv + [HttpGet] + public async Task 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)); + } + } + + /// Exports customer deposits (customer by name, bank account + applied invoice by number) as CSV. + // GET: Tools/ExportDepositsCsv + [HttpGet] + public async Task 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)); + } + } + + /// Exports journal entry headers as CSV. Lines export separately. + // GET: Tools/ExportJournalEntriesCsv + [HttpGet] + public async Task 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)); + } + } + + /// Exports journal entry lines (account by number, debit/credit) as CSV. + // GET: Tools/ExportJournalEntryLinesCsv + [HttpGet] + public async Task 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)); + } + } + /// /// 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(); } + /// + /// 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 ) + /// are written so revenue attribution survives an export/import round-trip. Rows are emitted in + /// DisplayOrder within each invoice. + /// + private string GenerateInvoiceItemsCsv(IEnumerable invoices, IReadOnlyDictionary 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(); + } + /// /// Builds a CSV string for the given invoice payment records. The parent invoice number is /// resolved from the eagerly loaded Invoice navigation property. PaymentMethod is @@ -3981,13 +4351,111 @@ public class ToolsController : Controller private string GeneratePaymentsCsv(IEnumerable 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(); + } + + /// Builds the vendor bill header CSV — vendor by name, AP account by number. + private string GenerateBillsCsv(IEnumerable 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(); + } + + /// Builds the bill line-item CSV — one row per line, account/job resolved by number. + private string GenerateBillLineItemsCsv(IEnumerable bills, + IReadOnlyDictionary accountNumberById, IReadOnlyDictionary 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(); + } + + /// Builds the customer deposit CSV — customer by name, bank account + applied invoice resolved. + private string GenerateDepositsCsv(IEnumerable deposits, IReadOnlyDictionary 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(); + } + + /// Builds the journal entry header CSV. Lines are exported separately. + private string GenerateJournalEntriesCsv(IEnumerable 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(); + } + + /// Builds the journal entry line CSV — one row per debit/credit line, account by number. + private string GenerateJournalEntryLinesCsv(IEnumerable entries, IReadOnlyDictionary 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(); diff --git a/src/PowderCoating.Web/wwwroot/js/tools-import.js b/src/PowderCoating.Web/wwwroot/js/tools-import.js index 9615794..7cf47a5 100644 --- a/src/PowderCoating.Web/wwwroot/js/tools-import.js +++ b/src/PowderCoating.Web/wwwroot/js/tools-import.js @@ -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',