using System.Globalization; using System.Text; using PowderCoating.Core.Enums; using CsvHelper; using CsvHelper.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.QuickBooks; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; using ExcelDataReader; namespace PowderCoating.Application.Services; /// /// Implements QuickBooks IIF (Intuit Interchange Format) export and import for QuickBooks Desktop, /// plus CSV import support for QuickBooks Online exports. /// /// IIF format is tab-delimited text where header definition rows start with ! (e.g. !CUST) /// and corresponding data rows use the same prefix without the ! (e.g. CUST). /// Sign conventions must be observed: QuickBooks expects credit-normal accounts (Revenue, AP, Equity) /// with opposite-sign values to debit-normal accounts. /// /// /// Import flow: Customers → Vendors → Chart of Accounts → Invoices → Transactions → Bills → Vendor Payments. /// Each step requires reference data from prior steps to be present. /// /// public class QuickBooksIifService : IQuickBooksIifService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private const char TabDelimiter = '\t'; private const long MaxFileSize = 50 * 1024 * 1024; // 50 MB private const int MaxRecords = 10000; private static readonly string[] AllowedExtensions = { ".iif", ".txt", ".xls", ".xlsx", ".csv" }; /// /// Initializes the service with required infrastructure dependencies. /// public QuickBooksIifService(IUnitOfWork unitOfWork, ILogger logger) { _unitOfWork = unitOfWork; _logger = logger; } #region Export Methods /// /// Exports all active customers for a company as a QuickBooks Desktop IIF file (!CUST / CUST records). /// /// The TAXABLE field uses an inverted boolean: QuickBooks stores whether the customer IS taxable, /// while the application stores IsTaxExempt. Therefore TAXABLE = N when IsTaxExempt = true. /// /// /// Returns an empty result (success=false) with an error message when no active customers exist, /// so the caller can show a meaningful UI message rather than offering a 0-byte download. /// /// public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCustomersAsync(int companyId) { try { _logger.LogInformation("Exporting customers for company {CompanyId}", companyId); // Fetch active customers with pricing tier var customers = await _unitOfWork.Customers.FindAsync( c => c.CompanyId == companyId && c.IsActive && !c.IsDeleted); var customerList = customers.ToList(); _logger.LogInformation("Found {Count} active customers to export", customerList.Count); if (customerList.Count == 0) { return (false, Array.Empty(), string.Empty, "No active customers found to export."); } // Build IIF content var sb = new StringBuilder(); // Header row sb.AppendLine(string.Join(TabDelimiter, new[] { "!CUST", "NAME", "BADDR1", "CITY", "STATE", "ZIP", "COUNTRY", "PHONE1", "PHONE2", "EMAIL", "CONT1", "CONT2", "TERMS", "TAXABLE", "LIMIT", "NOTE" })); // Data rows foreach (var customer in customerList) { sb.AppendLine(string.Join(TabDelimiter, new[] { "CUST", EscapeIifField(customer.CompanyName), EscapeIifField(customer.Address), EscapeIifField(customer.City), EscapeIifField(customer.State), EscapeIifField(customer.ZipCode), EscapeIifField(customer.Country ?? "USA"), EscapeIifField(customer.Phone), EscapeIifField(customer.MobilePhone), EscapeIifField(customer.Email), EscapeIifField(customer.ContactFirstName), EscapeIifField(customer.ContactLastName), EscapeIifField(customer.PaymentTerms ?? "Net 30"), customer.IsTaxExempt ? "N" : "Y", // Inverted: QuickBooks TAXABLE = not exempt customer.CreditLimit.ToString("F2"), EscapeIifField(customer.GeneralNotes) })); } var fileContent = Encoding.UTF8.GetBytes(sb.ToString()); var fileName = $"customers_{companyId}_{DateTime.UtcNow:yyyyMMddHHmmss}.iif"; _logger.LogInformation("Successfully exported {Count} customers to {FileName}", customerList.Count, fileName); return (true, fileContent, fileName, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error exporting customers for company {CompanyId}", companyId); return (false, Array.Empty(), string.Empty, $"Error exporting customers: {ex.Message}"); } } /// /// Exports active catalog items for a company as a QuickBooks Desktop IIF file using the SERV /// (Service Item) record type, since powder coating line items are service-based, not physical inventory. /// /// The HIDDEN field is the inverse of IsActive: HIDDEN=Y means the item is suppressed /// in QuickBooks, so only inactive items get HIDDEN=Y. /// /// /// Category names are included in the EXTRA column as a reference comment; QuickBooks Desktop /// does not natively support service-item categories in IIF, so this is informational only. /// /// public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCatalogItemsAsync(int companyId) { try { _logger.LogInformation("Exporting catalog items for company {CompanyId}", companyId); // Fetch active catalog items with category var items = await _unitOfWork.CatalogItems.FindAsync( c => c.CompanyId == companyId && c.IsActive && !c.IsDeleted); var itemList = items.ToList(); _logger.LogInformation("Found {Count} active catalog items to export", itemList.Count); if (itemList.Count == 0) { return (false, Array.Empty(), string.Empty, "No active catalog items found to export."); } // Load categories for all items var categoryIds = itemList.Select(i => i.CategoryId).Distinct().ToList(); var categories = await _unitOfWork.CatalogCategories.FindAsync(c => categoryIds.Contains(c.Id)); var categoryDict = categories.ToDictionary(c => c.Id, c => c.Name); // Build IIF content var sb = new StringBuilder(); // Header row (SERV = Service item in QuickBooks) sb.AppendLine(string.Join(TabDelimiter, new[] { "!SERV", "NAME", "SALESDESC", "PRICE", "HIDDEN", "EXTRA" })); // Data rows foreach (var item in itemList) { var categoryName = categoryDict.TryGetValue(item.CategoryId, out var catName) ? catName : "Uncategorized"; var salesDesc = string.IsNullOrEmpty(item.Description) ? item.Name : $"{item.Name} - {item.Description}"; sb.AppendLine(string.Join(TabDelimiter, new[] { "SERV", EscapeIifField(item.SKU ?? $"ITEM-{item.Id}"), EscapeIifField(salesDesc), item.DefaultPrice.ToString("F2"), item.IsActive ? "N" : "Y", // Inverted: QuickBooks HIDDEN = not active EscapeIifField(categoryName) // Category as reference })); } var fileContent = Encoding.UTF8.GetBytes(sb.ToString()); var fileName = $"catalog_items_{companyId}_{DateTime.UtcNow:yyyyMMddHHmmss}.iif"; _logger.LogInformation("Successfully exported {Count} catalog items to {FileName}", itemList.Count, fileName); return (true, fileContent, fileName, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error exporting catalog items for company {CompanyId}", companyId); return (false, Array.Empty(), string.Empty, $"Error exporting catalog items: {ex.Message}"); } } /// /// Exports active customers as a QuickBooks Online (QBO) CSV file. /// /// QBO uses a different import format from QuickBooks Desktop IIF: comma-delimited with a /// *Customer marker column. Billing address is duplicated into shipping address columns /// because QBO requires both; powder coating customers typically pick up in person, so the /// shipping address is the same as billing. /// /// /// Customer display name priority: CompanyName (if set) → ContactFirstName ContactLastName. /// /// public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCustomersOnlineAsync(int companyId) { try { _logger.LogInformation("Exporting customers for QuickBooks Online (CSV) for company {CompanyId}", companyId); var customers = await _unitOfWork.Customers .FindAsync(c => c.CompanyId == companyId && c.IsActive); if (!customers.Any()) { return (false, Array.Empty(), string.Empty, "No active customers found to export."); } // Build CSV content for QuickBooks Online var csv = new System.Text.StringBuilder(); // Header row (QuickBooks Online format) csv.AppendLine("*Customer,Name,Company Name,Email,Phone,Mobile,Fax,Website,Billing Address,Billing City,Billing State,Billing ZIP,Billing Country,Shipping Address,Shipping City,Shipping State,Shipping ZIP,Shipping Country,Notes"); // Data rows foreach (var customer in customers.OrderBy(c => c.CompanyName ?? c.ContactLastName)) { var displayName = !string.IsNullOrWhiteSpace(customer.CompanyName) ? customer.CompanyName : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); var companyName = !string.IsNullOrWhiteSpace(customer.CompanyName) ? customer.CompanyName : ""; var email = customer.Email ?? ""; var phone = customer.Phone ?? ""; var mobile = customer.MobilePhone ?? ""; var address = customer.Address ?? ""; var city = customer.City ?? ""; var state = customer.State ?? ""; var zipCode = customer.ZipCode ?? ""; var notes = $"Credit Limit: {customer.CreditLimit:C}"; // Escape quotes and commas in CSV displayName = EscapeCsv(displayName); companyName = EscapeCsv(companyName); email = EscapeCsv(email); mobile = EscapeCsv(mobile); address = EscapeCsv(address); notes = EscapeCsv(notes); // Columns: *Customer,Name,Company Name,Email,Phone,Mobile,Fax,Website,Billing Address,Billing City,Billing State,Billing ZIP,Billing Country,Shipping Address,Shipping City,Shipping State,Shipping ZIP,Shipping Country,Notes csv.AppendLine($"Customer,{displayName},{companyName},{email},{phone},{mobile},,,{address},{city},{state},{zipCode},USA,{address},{city},{state},{zipCode},USA,{notes}"); } var fileName = $"Customers_QBO_{DateTime.Now:yyyyMMdd_HHmmss}.csv"; var fileContent = System.Text.Encoding.UTF8.GetBytes(csv.ToString()); _logger.LogInformation("Successfully exported {Count} customers to QuickBooks Online CSV", customers.Count()); return (true, fileContent, fileName, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error exporting customers for QuickBooks Online"); return (false, Array.Empty(), string.Empty, $"Error exporting customers: {ex.Message}"); } } /// /// Exports active catalog items as a QuickBooks Online (QBO) CSV file using the ServiceItem row marker. /// /// All items default to non-taxable (No) because powder coating services are subject to /// location-specific sales tax rules that QBO manages through its own tax settings; presetting /// taxable=Yes would cause double-taxation for customers who are already tax-exempt. /// /// /// Eager-loads Category and Category.ParentCategory to support QBO's hierarchical /// category display without additional per-item round-trips. /// /// public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCatalogItemsOnlineAsync(int companyId) { try { _logger.LogInformation("Exporting catalog items for QuickBooks Online (CSV) for company {CompanyId}", companyId); var catalogItems = await _unitOfWork.CatalogItems .GetAllAsync(false, ci => ci.Category, ci => ci.Category.ParentCategory); var activeItems = catalogItems .Where(ci => ci.CompanyId == companyId && ci.IsActive) .ToList(); if (!activeItems.Any()) { return (false, Array.Empty(), string.Empty, "No active catalog items found to export."); } // Build CSV content for QuickBooks Online var csv = new System.Text.StringBuilder(); // Header row (QuickBooks Online format for Service items) csv.AppendLine("*ServiceItem,Name,Description,Price,SKU,Category,Taxable,Income Account"); // Data rows foreach (var item in activeItems.OrderBy(i => i.SKU)) { var name = EscapeCsv(item.Name); var description = EscapeCsv(item.Description ?? ""); var price = item.DefaultPrice.ToString("F2"); var sku = EscapeCsv(item.SKU ?? ""); var category = item.Category != null ? EscapeCsv(item.Category.Name) : ""; var taxable = "No"; // Default to non-taxable csv.AppendLine($"ServiceItem,{name},{description},{price},{sku},{category},{taxable},Sales"); } var fileName = $"CatalogItems_QBO_{DateTime.Now:yyyyMMdd_HHmmss}.csv"; var fileContent = System.Text.Encoding.UTF8.GetBytes(csv.ToString()); _logger.LogInformation("Successfully exported {Count} catalog items to QuickBooks Online CSV", activeItems.Count); return (true, fileContent, fileName, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error exporting catalog items for QuickBooks Online"); return (false, Array.Empty(), string.Empty, $"Error exporting catalog items: {ex.Message}"); } } /// /// Escapes a field value for RFC 4180-compliant CSV output. /// Wraps the value in double-quotes and doubles any embedded double-quotes when the value /// contains a comma, double-quote, or newline — the three characters that would otherwise /// break CSV parsing. /// private string EscapeCsv(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; // If value contains comma, quote, or newline, wrap in quotes and escape quotes if (value.Contains(",") || value.Contains("\"") || value.Contains("\n")) { return $"\"{value.Replace("\"", "\"\"")}\""; } return value; } #endregion #region Import Methods /// /// Dispatches a customer import file to the appropriate parser based on file extension. /// /// Supported formats: /// /// .xls / .xlsx — QuickBooks Online customer export (Excel) /// .iif / .txt — QuickBooks Desktop IIF export /// /// Returns an error (not an exception) for unsupported extensions so the UI can render /// a user-friendly message without a 500 response. /// /// public async Task ImportCustomersAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { _logger.LogInformation("Importing customers from file {FileName} for company {CompanyId}", file.FileName, companyId); // Detect format based on file extension var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (extension == ".xls" || extension == ".xlsx") { return await ImportCustomersFromExcelAsync(file, companyId, userId); } else if (extension == ".iif" || extension == ".txt") { return await ImportCustomersFromIifAsync(file, companyId, userId); } else { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Unsupported file format: {extension}. Supported formats: .iif, .txt (QuickBooks Desktop), .xls, .xlsx (QuickBooks Online)" }); return result; } } catch (Exception ex) { _logger.LogError(ex, "Error importing customers from file {FileName}", file.FileName); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Parses a QuickBooks Desktop IIF file and upserts customers for the given company. /// /// IIF files may contain multiple sections (e.g. !HDR, !CUSTNAMEDICT) before the /// !CUST section. The parser scans for the first line that starts with exactly /// "!CUST\t" (tab after CUST) to avoid false-matching !CUSTNAMEDICT. /// /// /// Customer identity is matched by CompanyName within the tenant. On a match the /// existing record is updated; otherwise a new one is created. Both paths run inside a /// single database transaction via ExecuteInTransactionAsync so a mid-import failure /// rolls back all rows rather than leaving partial data. /// /// /// Company name is resolved with a three-tier fallback: /// COMPANYNAMENAMEFIRSTNAME + LASTNAME. /// /// private async Task ImportCustomersFromIifAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { // Validate file var validation = await ValidateCustomerFileAsync(file); if (!validation.IsValid) { result.Success = false; result.Errors = validation.Errors; return result; } // Read file content using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); var allLines = new List(); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (!string.IsNullOrWhiteSpace(line)) allLines.Add(line); } if (allLines.Count < 2) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "File must contain at least a header row and one data row" }); return result; } // Find the !CUST header line (skip QuickBooks metadata sections like !HDR, !CUSTNAMEDICT, etc.) int headerIndex = -1; for (int i = 0; i < allLines.Count; i++) { var line = allLines[i].Trim(); // Remove UTF-8 BOM if present if (line.Length > 0 && line[0] == '\uFEFF') { line = line.Substring(1); } // Match exactly "!CUST" followed by tab (not !CUSTNAMEDICT or other variants) if (line.StartsWith("!CUST" + TabDelimiter, StringComparison.OrdinalIgnoreCase)) { headerIndex = i; break; } } if (headerIndex == -1) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Could not find customer data section (!CUST) in file. This may not be a QuickBooks customer export." }); return result; } // Extract only the customer section (from !CUST onwards) var lines = allLines.Skip(headerIndex).ToList(); var headerLine = lines[0].Trim(); // Remove UTF-8 BOM if present if (headerLine.Length > 0 && headerLine[0] == '\uFEFF') { headerLine = headerLine.Substring(1); } var headers = headerLine.Split(TabDelimiter); result.TotalRecords = lines.Count - 1; // Exclude header _logger.LogInformation("Found customer header at line {HeaderIndex} with {FieldCount} fields", headerIndex + 1, headers.Length); await _unitOfWork.ExecuteInTransactionAsync(async () => { result.ImportedCount = 0; result.UpdatedCount = 0; result.SkippedCount = 0; result.Errors.Clear(); // Process data rows for (int i = 1; i < lines.Count; i++) { var lineNumber = i + 1; var line = lines[i]; try { var fields = line.Split(TabDelimiter); // Skip non-CUST records if (fields.Length > 0 && fields[0] != "CUST") { result.SkippedCount++; continue; } // Build company name using same logic as CreateCustomerFromIif var companyName = GetField(fields, headers, "COMPANYNAME"); if (string.IsNullOrWhiteSpace(companyName)) { companyName = GetField(fields, headers, "NAME"); if (string.IsNullOrWhiteSpace(companyName)) { var firstName = GetField(fields, headers, "FIRSTNAME"); var lastName = GetField(fields, headers, "LASTNAME"); companyName = $"{firstName} {lastName}".Trim(); } } if (string.IsNullOrWhiteSpace(companyName)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = GetField(fields, headers, "NAME"), FieldName = "COMPANYNAME/NAME", ErrorMessage = "Company name is required (COMPANYNAME, NAME, or FIRSTNAME+LASTNAME)" }); result.SkippedCount++; continue; } // Check if customer exists var existingCustomer = await _unitOfWork.Customers.FirstOrDefaultAsync( c => c.CompanyId == companyId && c.CompanyName == companyName); if (existingCustomer != null) { // Update existing customer UpdateCustomerFromIif(existingCustomer, fields, headers, userId); await _unitOfWork.Customers.UpdateAsync(existingCustomer); result.UpdatedCount++; _logger.LogDebug("Updated customer: {CompanyName}", companyName); } else { // Create new customer var newCustomer = CreateCustomerFromIif(fields, headers, companyId, userId); await _unitOfWork.Customers.AddAsync(newCustomer); result.ImportedCount++; _logger.LogDebug("Created new customer: {CompanyName}", companyName); } } catch (Exception ex) { _logger.LogWarning(ex, "Error processing line {LineNumber}", lineNumber); result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = ex.Message }); result.SkippedCount++; } } // Save changes await _unitOfWork.CompleteAsync(); result.Success = true; _logger.LogInformation("Successfully imported customers: {Summary}", result.Summary); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing customers from file {FileName}", file.FileName); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Parses a QuickBooks Online Excel export and upserts customers for the given company. /// /// QBO exports column headers like "Company name", "Street Address", "Open balance" — these differ /// from the Desktop IIF field names (COMPANYNAME, BADDR1, LIMIT). /// handles missing columns gracefully. /// /// /// Duplicate email detection is done in-memory (via a HashSet) before hitting the database /// so that two rows with the same email in the same file are caught without needing a DB round-trip /// for each row. The set is declared inside the transaction lambda so retries start clean. /// /// /// IsCommercial is inferred: customers with a non-empty Company name column are /// treated as commercial (B2B); individual-name-only rows are treated as non-commercial. /// /// private async Task ImportCustomersFromExcelAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); using var stream = file.OpenReadStream(); using var reader = ExcelReaderFactory.CreateReader(stream); // Read as DataSet var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration() { ConfigureDataTable = (_) => new ExcelDataTableConfiguration() { UseHeaderRow = true } }); if (dataSet.Tables.Count == 0) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Excel file contains no worksheets" }); return result; } var table = dataSet.Tables[0]; result.TotalRecords = table.Rows.Count; _logger.LogInformation("Found {RowCount} customer records in Excel file", table.Rows.Count); await _unitOfWork.ExecuteInTransactionAsync(async () => { // Track emails to detect duplicates within the file — declared inside so retries start clean var processedEmails = new HashSet(StringComparer.OrdinalIgnoreCase); result.ImportedCount = 0; result.UpdatedCount = 0; result.SkippedCount = 0; result.Errors.Clear(); for (int i = 0; i < table.Rows.Count; i++) { var row = table.Rows[i]; var lineNumber = i + 2; // +2 because row 1 is header, and we're 0-indexed try { // Extract fields from Excel columns (QuickBooks Online format) var name = GetColumnValue(row, "Name"); var companyName = GetColumnValue(row, "Company name"); var streetAddress = GetColumnValue(row, "Street Address"); var city = GetColumnValue(row, "City"); var state = GetColumnValue(row, "State"); var country = GetColumnValue(row, "Country"); var zip = GetColumnValue(row, "Zip"); var phone = GetColumnValue(row, "Phone"); var email = GetColumnValue(row, "Email"); var openBalance = GetColumnValue(row, "Open balance"); // Use Company name if provided, otherwise use Name var finalCompanyName = string.IsNullOrWhiteSpace(companyName) ? name : companyName; if (string.IsNullOrWhiteSpace(finalCompanyName)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = name, FieldName = "Name/Company name", ErrorMessage = "Name or Company name is required" }); result.SkippedCount++; continue; } // Convert empty email to null if (string.IsNullOrWhiteSpace(email)) email = null; // Check for duplicate email within this import if (!string.IsNullOrEmpty(email)) { if (processedEmails.Contains(email)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = finalCompanyName, FieldName = "Email", ErrorMessage = $"Duplicate email address '{email}' in import file. Only the first occurrence will be imported." }); result.SkippedCount++; continue; } processedEmails.Add(email); } // Check if customer already exists (by company name) var existing = await _unitOfWork.Customers.FindAsync( c => c.CompanyName == finalCompanyName && c.CompanyId == companyId ); var existingCustomer = existing.FirstOrDefault(); if (existingCustomer != null) { // Update existing customer existingCustomer.Address = streetAddress; existingCustomer.City = city; existingCustomer.State = state; existingCustomer.ZipCode = zip; existingCustomer.Country = string.IsNullOrWhiteSpace(country) ? "USA" : country; existingCustomer.Phone = phone; existingCustomer.Email = email; existingCustomer.CurrentBalance = ParseDecimal(openBalance); existingCustomer.UpdatedBy = userId; existingCustomer.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Customers.UpdateAsync(existingCustomer); result.UpdatedCount++; } else { // Create new customer var newCustomer = new Customer { CompanyId = companyId, CompanyName = finalCompanyName, Address = streetAddress, City = city, State = state, ZipCode = zip, Country = string.IsNullOrWhiteSpace(country) ? "USA" : country, Phone = phone, Email = email, ContactFirstName = string.Empty, ContactLastName = string.Empty, PaymentTerms = "Net 30", CurrentBalance = ParseDecimal(openBalance), CreditLimit = 0, IsCommercial = !string.IsNullOrWhiteSpace(companyName), IsActive = true, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.Customers.AddAsync(newCustomer); result.ImportedCount++; } } catch (Exception ex) { _logger.LogError(ex, "Error processing row {LineNumber}", lineNumber); result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = $"Error processing record: {ex.Message}" }); result.SkippedCount++; } } // Save all changes await _unitOfWork.CompleteAsync(); result.Success = true; _logger.LogInformation("Successfully imported customers from Excel: {Summary}", result.Summary); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing customers from Excel file {FileName}", file.FileName); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Safely reads a named column from an Excel . /// Returns rather than throwing when the column does not exist /// (QBO export layouts vary by account plan and settings), so callers can treat optional /// columns uniformly without per-column null checks. /// private string GetColumnValue(System.Data.DataRow row, string columnName) { try { if (row.Table.Columns.Contains(columnName) && row[columnName] != DBNull.Value) { return row[columnName]?.ToString() ?? string.Empty; } } catch { // Column doesn't exist or error reading } return string.Empty; } /// /// Dispatches a catalog-item import file to the appropriate parser based on file extension. /// /// Supported formats: /// /// .xls / .xlsx — QuickBooks Online "Products and Services" Excel export /// .csv — QuickBooks Online CSV export /// .iif / .txt — QuickBooks Desktop IIF export (INVITEM records) /// /// Validation (file size, extension) is run before routing so all three parsers can assume /// a valid, non-empty file. /// /// public async Task ImportCatalogItemsAsync(IFormFile file, int companyId, string userId) { _logger.LogInformation("Importing catalog items from file {FileName} for company {CompanyId}", file.FileName, companyId); // Validate file var validation = await ValidateCatalogItemFileAsync(file); if (!validation.IsValid) { return new ImportResultDto { Success = false, Errors = validation.Errors }; } // Route to appropriate import method based on file extension var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (extension == ".xls" || extension == ".xlsx") { return await ImportCatalogItemsFromExcelAsync(file, companyId, userId); } else if (extension == ".csv") { return await ImportCatalogItemsFromQboCsvAsync(file, companyId, userId); } else { return await ImportCatalogItemsFromIifAsync(file, companyId, userId); } } /// /// Parses a QuickBooks Desktop IIF file and upserts catalog items for the given company. /// /// QuickBooks exports all item types as INVITEM records with an INVITEMTYPE discriminator. /// Only SERV (service) items are imported because powder coating line items are services, /// not physical inventory. Types like DISC, PMT, GRP, and OTHC /// are QuickBooks-internal and have no equivalent in this system. /// /// /// QuickBooks uses a colon-delimited hierarchy in the NAME field (e.g. "Cerakote:Auto:Item") /// to represent item groups. The last segment is the item name; all preceding segments become the /// category path via . /// /// /// Items without a description (DESC empty) are silently skipped because they are /// QuickBooks group/header markers, not actual billable items. /// /// private async Task ImportCatalogItemsFromIifAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { // Read file content using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); var allLines = new List(); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (!string.IsNullOrWhiteSpace(line)) allLines.Add(line); } if (allLines.Count < 2) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "File must contain at least a header row and one data row" }); return result; } // Find the !INVITEM header line (QuickBooks exports service items as INVITEM with INVITEMTYPE=SERV) int headerIndex = -1; for (int i = 0; i < allLines.Count; i++) { var line = allLines[i].Trim(); // Remove UTF-8 BOM if present if (line.Length > 0 && line[0] == '\uFEFF') { line = line.Substring(1); } // Match exactly "!INVITEM" followed by tab if (line.StartsWith("!INVITEM" + TabDelimiter, StringComparison.OrdinalIgnoreCase)) { headerIndex = i; break; } } if (headerIndex == -1) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Could not find items data section (!INVITEM) in file. This may not be a QuickBooks items export." }); return result; } // Extract only the items section (from !INVITEM onwards) var lines = allLines.Skip(headerIndex).ToList(); var headerLine = lines[0].Trim(); // Remove UTF-8 BOM if present (again, just in case) if (headerLine.Length > 0 && headerLine[0] == '\uFEFF') { headerLine = headerLine.Substring(1); } var headers = headerLine.Split(TabDelimiter); result.TotalRecords = lines.Count - 1; // Exclude header await _unitOfWork.ExecuteInTransactionAsync(async () => { result.ImportedCount = 0; result.UpdatedCount = 0; result.SkippedCount = 0; result.Errors.Clear(); // Process data rows for (int i = 1; i < lines.Count; i++) { var lineNumber = i + 1; var line = lines[i]; try { var fields = line.Split(TabDelimiter); // Skip non-INVITEM records (ENDGRP, second header rows, etc.) — QB structural, not items if (fields.Length > 0 && fields[0] != "INVITEM") { result.TotalRecords--; continue; } // Only import service items (INVITEMTYPE=SERV) — DISC, OTHC, GRP, PMT, etc. are QB-only types var itemType = GetField(fields, headers, "INVITEMTYPE"); if (!string.Equals(itemType, "SERV", StringComparison.OrdinalIgnoreCase)) { result.TotalRecords--; continue; } var sku = GetField(fields, headers, "NAME"); if (string.IsNullOrWhiteSpace(sku)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, FieldName = "NAME", ErrorMessage = "SKU is required" }); result.SkippedCount++; continue; } // Category/group markers have a NAME but no DESC — silently exclude var desc = GetField(fields, headers, "DESC"); if (string.IsNullOrWhiteSpace(desc)) { result.TotalRecords--; continue; } // Parse SKU hierarchy and get/create the appropriate category var categoryId = await GetOrCreateCategoryFromSkuHierarchy(sku, companyId, userId); // Get the item name from DESC to check for duplicates var itemName = desc; if (!string.IsNullOrWhiteSpace(desc) && desc.Contains(" - ")) { var parts = desc.Split(new[] { " - " }, 2, StringSplitOptions.None); itemName = parts[0]; } // Check if item exists by Name + CategoryId (SKU is not used for QuickBooks imports) var existingItem = await _unitOfWork.CatalogItems.FirstOrDefaultAsync( c => c.CompanyId == companyId && c.Name == itemName && c.CategoryId == categoryId); if (existingItem != null) { // Update existing item (update category too in case hierarchy changed) existingItem.CategoryId = categoryId; UpdateCatalogItemFromIif(existingItem, fields, headers, userId); await _unitOfWork.CatalogItems.UpdateAsync(existingItem); result.UpdatedCount++; _logger.LogDebug("Updated catalog item: {SKU}", sku); } else { // Create new item with parsed category var newItem = CreateCatalogItemFromIif(fields, headers, companyId, categoryId, userId); await _unitOfWork.CatalogItems.AddAsync(newItem); result.ImportedCount++; _logger.LogDebug("Created new catalog item: {SKU}", sku); } } catch (Exception ex) { _logger.LogWarning(ex, "Error processing line {LineNumber}", lineNumber); result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = ex.Message }); result.SkippedCount++; } } // Save changes await _unitOfWork.CompleteAsync(); result.Success = true; _logger.LogInformation("Successfully imported catalog items: {Summary}", result.Summary); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing catalog items from file {FileName}", file.FileName); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Parses a QuickBooks Online "Products and Services" Excel export and upserts catalog items. /// /// QBO exports variant product hierarchies with a "Single,parent or variant?" column. /// Only Single and Parent rows are imported; child variant rows are skipped /// because they share a name with the parent but differ only in attributes that have no direct /// equivalent in the catalog item model. /// /// /// Supported item types: Service, Inventory, Non-inventory. Bundle and Group types are skipped /// because their composite pricing cannot be represented as a single catalog item price. /// /// /// In-file duplicate detection uses a HashSet keyed on itemName|categoryId /// (declared inside the transaction lambda so retries reset it). /// /// private async Task ImportCatalogItemsFromExcelAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); using var stream = file.OpenReadStream(); using var reader = ExcelReaderFactory.CreateReader(stream); var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration() { ConfigureDataTable = (_) => new ExcelDataTableConfiguration() { UseHeaderRow = true } }); if (dataSet.Tables.Count == 0) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Excel file contains no worksheets" }); return result; } var table = dataSet.Tables[0]; result.TotalRecords = table.Rows.Count; _logger.LogInformation("Found {RowCount} catalog item records in Excel file", table.Rows.Count); await _unitOfWork.ExecuteInTransactionAsync(async () => { // Declared inside so retries start clean var processedItems = new HashSet(StringComparer.OrdinalIgnoreCase); result.ImportedCount = 0; result.UpdatedCount = 0; result.SkippedCount = 0; result.Errors.Clear(); for (int i = 0; i < table.Rows.Count; i++) { var row = table.Rows[i]; var lineNumber = i + 2; try { // QuickBooks Online column names (QBO Excel export) var qbName = GetColumnValue(row, "Product/Service Name"); var itemType = GetColumnValue(row, "Item type"); var categoryName = GetColumnValue(row, "Category"); var salesDescription = GetColumnValue(row, "Sales Description"); var price = GetColumnValue(row, "Price"); var sku = GetColumnValue(row, "SKU"); var variantType = GetColumnValue(row, "Single,parent or variant?"); // Skip child variants — only import Singles and Parents if (string.Equals(variantType, "variant", StringComparison.OrdinalIgnoreCase)) { result.SkippedCount++; continue; } // Skip unsupported item types (bundles, groups, etc.) if (!string.IsNullOrWhiteSpace(itemType) && !string.Equals(itemType, "Service", StringComparison.OrdinalIgnoreCase) && !string.Equals(itemType, "Inventory", StringComparison.OrdinalIgnoreCase) && !string.Equals(itemType, "Non-inventory", StringComparison.OrdinalIgnoreCase)) { result.SkippedCount++; continue; } if (string.IsNullOrWhiteSpace(qbName)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = qbName, FieldName = "Product/Service Name", ErrorMessage = "Product/Service Name is required" }); result.SkippedCount++; continue; } // Get/create flat category from the Category column var categoryId = string.IsNullOrWhiteSpace(categoryName) ? await GetOrCreateDefaultCategory(companyId, userId) : await GetOrCreateCategoryByName(categoryName, companyId, userId); // Use Product/Service Name as the item name; Sales Description as description var itemName = qbName; var itemDescription = salesDescription; // Check for duplicates by name + category var duplicateKey = $"{itemName}|{categoryId}"; if (processedItems.Contains(duplicateKey)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = itemName, FieldName = "Product/Service Name", ErrorMessage = $"Duplicate item '{itemName}' in category" }); result.SkippedCount++; continue; } processedItems.Add(duplicateKey); var existingItem = await _unitOfWork.CatalogItems.FirstOrDefaultAsync(c => c.CompanyId == companyId && c.Name == itemName && c.CategoryId == categoryId); if (existingItem != null) { existingItem.CategoryId = categoryId; existingItem.Description = itemDescription; existingItem.DefaultPrice = ParseDecimal(price); if (!string.IsNullOrWhiteSpace(sku)) existingItem.SKU = sku; existingItem.UpdatedBy = userId; existingItem.UpdatedAt = DateTime.UtcNow; result.UpdatedCount++; } else { var newItem = new CatalogItem { CompanyId = companyId, CategoryId = categoryId, SKU = string.IsNullOrWhiteSpace(sku) ? null : sku, Name = itemName, Description = itemDescription, DefaultPrice = ParseDecimal(price), IsActive = true, DisplayOrder = 0, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CatalogItems.AddAsync(newItem); result.ImportedCount++; } } catch (Exception ex) { _logger.LogWarning(ex, "Error processing Excel row {LineNumber}", lineNumber); result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = ex.Message }); result.SkippedCount++; } } await _unitOfWork.CompleteAsync(); result.Success = true; _logger.LogInformation("Successfully imported catalog items from Excel: {Summary}", result.Summary); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing catalog items from Excel file {FileName}", file.FileName); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Parses a QuickBooks Online "Products and Services" CSV export and upserts catalog items. /// /// CSV rows are read entirely into memory before entering the transaction lambda so that the /// stream is not held open across async EF operations (EF async and /// CsvHelper are not async-safe to interleave). The in-memory list also allows clean retry /// semantics if the transaction is rolled back and re-attempted. /// /// /// Applies the same variant/type filtering as ImportCatalogItemsFromExcelAsync. /// /// private async Task ImportCatalogItemsFromQboCsvAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { using var stream = file.OpenReadStream(); using var reader = new System.IO.StreamReader(stream); using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); csv.Read(); csv.ReadHeader(); // Read all rows into memory before the lambda so retries can reprocess from the beginning var csvRows = new List>(); while (csv.Read()) { var csvRow = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var header in csv.HeaderRecord ?? []) csvRow[header] = csv.GetField(header) ?? string.Empty; csvRows.Add(csvRow); } result.TotalRecords = csvRows.Count; await _unitOfWork.ExecuteInTransactionAsync(async () => { // Declared inside so retries start clean var processedItems = new HashSet(StringComparer.OrdinalIgnoreCase); result.ImportedCount = 0; result.UpdatedCount = 0; result.SkippedCount = 0; result.TotalRecords = csvRows.Count; result.Errors.Clear(); int lineNumber = 1; foreach (var csvRow in csvRows) { lineNumber++; try { var itemName = csvRow.GetValueOrDefault("Product/Service Name", string.Empty); var itemType = csvRow.GetValueOrDefault("Item type", string.Empty); var categoryName = csvRow.GetValueOrDefault("Category", string.Empty); var sku = csvRow.GetValueOrDefault("SKU", string.Empty); var price = csvRow.GetValueOrDefault("Price", string.Empty); var description = csvRow.GetValueOrDefault("Sales Description", string.Empty); var variantType = csvRow.GetValueOrDefault("Single,parent or variant?", string.Empty); // Skip child variants if (string.Equals(variantType, "variant", StringComparison.OrdinalIgnoreCase)) { result.SkippedCount++; continue; } // Skip unsupported types if (!string.IsNullOrWhiteSpace(itemType) && !string.Equals(itemType, "Service", StringComparison.OrdinalIgnoreCase) && !string.Equals(itemType, "Inventory", StringComparison.OrdinalIgnoreCase) && !string.Equals(itemType, "Non-inventory", StringComparison.OrdinalIgnoreCase)) { result.SkippedCount++; continue; } if (string.IsNullOrWhiteSpace(itemName)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, FieldName = "Product/Service Name", ErrorMessage = "Product/Service Name is required" }); result.SkippedCount++; continue; } var categoryId = string.IsNullOrWhiteSpace(categoryName) ? await GetOrCreateDefaultCategory(companyId, userId) : await GetOrCreateCategoryByName(categoryName, companyId, userId); var duplicateKey = $"{itemName}|{categoryId}"; if (processedItems.Contains(duplicateKey)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = itemName, FieldName = "Product/Service Name", ErrorMessage = $"Duplicate item '{itemName}' in this import file" }); result.SkippedCount++; continue; } processedItems.Add(duplicateKey); result.TotalRecords++; var existingItem = await _unitOfWork.CatalogItems.FirstOrDefaultAsync( c => c.CompanyId == companyId && c.Name == itemName && c.CategoryId == categoryId); if (existingItem != null) { existingItem.Description = description; existingItem.DefaultPrice = ParseDecimal(price); if (!string.IsNullOrWhiteSpace(sku)) existingItem.SKU = sku; existingItem.UpdatedBy = userId; existingItem.UpdatedAt = DateTime.UtcNow; result.UpdatedCount++; } else { var newItem = new CatalogItem { CompanyId = companyId, CategoryId = categoryId, SKU = string.IsNullOrWhiteSpace(sku) ? null : sku, Name = itemName, Description = description, DefaultPrice = ParseDecimal(price), IsActive = true, DisplayOrder = 0, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CatalogItems.AddAsync(newItem); result.ImportedCount++; } } catch (Exception ex) { _logger.LogWarning(ex, "Error processing QBO CSV row {LineNumber}", lineNumber); result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = ex.Message }); result.SkippedCount++; } } await _unitOfWork.CompleteAsync(); result.Success = true; _logger.LogInformation("Successfully imported catalog items from QBO CSV: {Summary}", result.Summary); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing catalog items from QBO CSV file {FileName}", file.FileName); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Gets or creates a flat (top-level) category by name. /// Used for QBO exports where category is a simple name, not a hierarchy. /// private async Task GetOrCreateCategoryByName(string name, int companyId, string userId) { var existing = await _unitOfWork.CatalogCategories.FirstOrDefaultAsync( c => c.CompanyId == companyId && c.Name == name && c.ParentCategoryId == null); if (existing != null) return existing.Id; var newCategory = new CatalogCategory { CompanyId = companyId, ParentCategoryId = null, Name = name, Description = $"Imported from QuickBooks Online", DisplayOrder = 0, IsActive = true, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CatalogCategories.AddAsync(newCategory); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Created category: {Name}", name); return newCategory.Id; } #endregion #region Validation Methods /// /// Validates a customer import file before any data is written to the database. /// /// Checks performed (in order): /// /// File is non-null and non-empty. /// Extension is in the allowed list (.iif, .txt, .xls, .xlsx, .csv). /// File size does not exceed 50 MB. /// For IIF/TXT files only: the first 100 lines contain a !CUST\t header row. /// /// The 100-line scan limit avoids loading the entire file into memory just for format detection /// while still handling real QuickBooks exports that include multi-section preambles. /// /// public async Task ValidateCustomerFileAsync(IFormFile file) { var result = new ValidationResultDto { IsValid = true }; // Check file exists if (file == null || file.Length == 0) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "No file provided or file is empty" }); return result; } // Check file extension var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!AllowedExtensions.Contains(extension)) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Invalid file extension. Allowed: {string.Join(", ", AllowedExtensions)}" }); return result; } // Check file size if (file.Length > MaxFileSize) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB" }); return result; } // Basic format check - look for !CUST section (may be after QuickBooks headers) try { using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); bool foundCustomerSection = false; string line; int lineNumber = 0; while ((line = await reader.ReadLineAsync()) != null && lineNumber < 100) // Check first 100 lines { lineNumber++; line = line.Trim(); // Remove UTF-8 BOM if present if (line.Length > 0 && line[0] == '\uFEFF') { line = line.Substring(1); } // Match exactly "!CUST" followed by tab (not !CUSTNAMEDICT or other variants) if (line.StartsWith("!CUST" + TabDelimiter, StringComparison.OrdinalIgnoreCase)) { foundCustomerSection = true; break; } } if (!foundCustomerSection) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Could not find customer data section (!CUST) in file. This may not be a QuickBooks customer export." }); } } catch (Exception ex) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Error reading file: {ex.Message}" }); } return result; } /// /// Validates a catalog-item import file before any data is written to the database. /// /// Applies the same size/extension checks as . /// Excel (.xls, .xlsx) and CSV (.csv) files skip the content-format scan /// because their structure is validated during import (the header row lookup is format-specific). /// For IIF/TXT files, the first 100 lines are scanned for a !INVITEM\t header row. /// /// public async Task ValidateCatalogItemFileAsync(IFormFile file) { var result = new ValidationResultDto { IsValid = true }; // Check file exists if (file == null || file.Length == 0) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "No file provided or file is empty" }); return result; } // Check file extension var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!AllowedExtensions.Contains(extension)) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Invalid file extension. Allowed: {string.Join(", ", AllowedExtensions)}" }); return result; } // Check file size if (file.Length > MaxFileSize) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB" }); return result; } // Skip format validation for Excel and CSV files (will be validated during import) if (extension == ".xls" || extension == ".xlsx" || extension == ".csv") { return result; } // Basic format check for IIF files - look for !INVITEM section (QuickBooks exports service items as INVITEM) try { using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); bool foundItemSection = false; string line; int lineNumber = 0; while ((line = await reader.ReadLineAsync()) != null && lineNumber < 100) // Check first 100 lines { lineNumber++; line = line.Trim(); // Remove UTF-8 BOM if present if (line.Length > 0 && line[0] == '\uFEFF') { line = line.Substring(1); } // Match exactly "!INVITEM" followed by tab if (line.StartsWith("!INVITEM" + TabDelimiter, StringComparison.OrdinalIgnoreCase)) { foundItemSection = true; break; } } if (!foundItemSection) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Could not find items data section (!INVITEM) in file. This may not be a QuickBooks items export." }); } } catch (Exception ex) { result.IsValid = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Error reading file: {ex.Message}" }); } return result; } #endregion #region Helper Methods #region Vendor Export /// /// Exports all active vendors for a company as a QuickBooks Desktop IIF file (!VEND / VEND records). /// /// QuickBooks Desktop IIF uses ADDR1 for street address and ADDR2 for the /// city/state/zip line (formatted as "City, ST Zip"), which differs from the application's /// separate City, State, ZipCode fields. The export combines the three /// fields into the QB ADDR2 format. /// /// public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportVendorsAsync(int companyId) { try { _logger.LogInformation("Exporting vendors for company {CompanyId}", companyId); var vendors = await _unitOfWork.Vendors.FindAsync( v => v.CompanyId == companyId && v.IsActive && !v.IsDeleted); var list = vendors.OrderBy(v => v.CompanyName).ToList(); if (list.Count == 0) return (false, Array.Empty(), string.Empty, "No active vendors found to export."); var sb = new StringBuilder(); sb.AppendLine(string.Join(TabDelimiter, new[] { "!VEND", "NAME", "PRINTAS", "ADDR1", "ADDR2", "PHONE1", "EMAIL", "CONT1", "TERMS", "LIMIT", "TAXID", "NOTE" })); foreach (var vendor in list) { // Combine city/state/zip into ADDR2 matching QB format var addr2Parts = new List(); if (!string.IsNullOrWhiteSpace(vendor.City)) addr2Parts.Add(vendor.City); var stateZip = $"{vendor.State} {vendor.ZipCode}".Trim(); if (!string.IsNullOrWhiteSpace(stateZip)) addr2Parts.Add(stateZip); var addr2 = addr2Parts.Count > 0 ? string.Join(", ", addr2Parts) : string.Empty; sb.AppendLine(string.Join(TabDelimiter, new[] { "VEND", EscapeIifField(vendor.CompanyName), EscapeIifField(vendor.CompanyName), EscapeIifField(vendor.Address), EscapeIifField(addr2), EscapeIifField(vendor.Phone), EscapeIifField(vendor.Email), EscapeIifField(vendor.ContactName), EscapeIifField(vendor.PaymentTerms), vendor.CreditLimit.HasValue ? vendor.CreditLimit.Value.ToString("F2") : string.Empty, EscapeIifField(vendor.TaxId), EscapeIifField(vendor.Notes) })); } var fileContent = Encoding.UTF8.GetBytes(sb.ToString()); var fileName = $"vendors_{companyId}_{DateTime.UtcNow:yyyyMMddHHmmss}.iif"; _logger.LogInformation("Successfully exported {Count} vendors to {FileName}", list.Count, fileName); return (true, fileContent, fileName, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error exporting vendors for company {CompanyId}", companyId); return (false, Array.Empty(), string.Empty, $"Error exporting vendors: {ex.Message}"); } } #endregion #region Vendor Import /// /// Parses a QuickBooks Desktop IIF file and upserts vendors for the given company. /// /// Vendor name is resolved with a four-tier fallback: /// COMPANYNAMEPRINTASNAMEFIRSTNAME + LASTNAME. /// This mirrors how QuickBooks assigns vendor display names depending on whether the vendor is /// a business or an individual. /// /// /// Vendors whose HIDDEN field is Y (inactive in QuickBooks) are silently skipped /// rather than imported as inactive, because hidden QB vendors are typically merged/deleted /// duplicates that should not be brought into the new system. /// /// public async Task ImportVendorsAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { _logger.LogInformation("Importing vendors from file {FileName} for company {CompanyId}", file.FileName, companyId); using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); var allLines = new List(); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (!string.IsNullOrWhiteSpace(line)) allLines.Add(line); } // Find the !VEND header line int headerIndex = -1; for (int i = 0; i < allLines.Count; i++) { var line = allLines[i].Trim(); if (line.Length > 0 && line[0] == '\uFEFF') line = line.Substring(1); if (line.StartsWith("!VEND" + TabDelimiter, StringComparison.OrdinalIgnoreCase)) { headerIndex = i; break; } } if (headerIndex == -1) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Could not find vendor data section (!VEND) in file. This may not be a QuickBooks vendor export." }); return result; } var lines = allLines.Skip(headerIndex).ToList(); var headerLine = lines[0].Trim(); if (headerLine.Length > 0 && headerLine[0] == '\uFEFF') headerLine = headerLine.Substring(1); var headers = headerLine.Split(TabDelimiter); result.TotalRecords = lines.Count - 1; await _unitOfWork.ExecuteInTransactionAsync(async () => { result.ImportedCount = 0; result.UpdatedCount = 0; result.SkippedCount = 0; result.Errors.Clear(); for (int i = 1; i < lines.Count; i++) { var lineNumber = i + 1; var line = lines[i]; try { var fields = line.Split(TabDelimiter); // Skip non-VEND records if (fields.Length == 0 || fields[0] != "VEND") { result.SkippedCount++; continue; } // Skip hidden/deleted vendors var hidden = GetField(fields, headers, "HIDDEN"); if (hidden == "Y") { result.SkippedCount++; continue; } // Resolve company name: COMPANYNAME → NAME → FIRSTNAME + LASTNAME var companyName = GetField(fields, headers, "COMPANYNAME"); if (string.IsNullOrWhiteSpace(companyName)) companyName = GetField(fields, headers, "PRINTAS"); if (string.IsNullOrWhiteSpace(companyName)) companyName = GetField(fields, headers, "NAME"); if (string.IsNullOrWhiteSpace(companyName)) { var fn = GetField(fields, headers, "FIRSTNAME"); var ln = GetField(fields, headers, "LASTNAME"); companyName = $"{fn} {ln}".Trim(); } if (string.IsNullOrWhiteSpace(companyName)) { result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = GetField(fields, headers, "NAME"), FieldName = "COMPANYNAME/NAME", ErrorMessage = "Vendor name is required" }); result.SkippedCount++; continue; } var existing = await _unitOfWork.Vendors.FirstOrDefaultAsync( v => v.CompanyId == companyId && v.CompanyName == companyName); if (existing != null) { UpdateVendorFromIif(existing, fields, headers, userId); await _unitOfWork.Vendors.UpdateAsync(existing); result.UpdatedCount++; } else { var vendor = CreateVendorFromIif(fields, headers, companyId, userId, companyName); await _unitOfWork.Vendors.AddAsync(vendor); result.ImportedCount++; } } catch (Exception ex) { _logger.LogWarning(ex, "Error processing vendor line {LineNumber}", lineNumber); result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = ex.Message }); result.SkippedCount++; } } await _unitOfWork.CompleteAsync(); result.Success = true; _logger.LogInformation("Vendor import complete: {Summary}", result.Summary); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing vendors from file {FileName}", file.FileName); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Constructs a new entity from a parsed IIF data row. /// /// QuickBooks Desktop packs city/state/zip into ADDR2 as a single string in the format /// "City, ST Zip". This method splits on the first comma to extract city, then splits the /// remainder on whitespace to extract state (first token) and zip (last token), which handles /// both two-character state abbreviations and full state names. /// /// /// Empty email is stored as null rather than an empty string to avoid triggering the /// database's unique-email constraint when multiple vendors have no email address on file. /// /// /// Credit limit of 0 is stored as null because QB exports a 0 when no credit /// limit is configured, and the application distinguishes between "no limit" (null) and "limit /// is explicitly zero" which would block all purchases. /// /// private Vendor CreateVendorFromIif(string[] fields, string[] headers, int companyId, string userId, string companyName) { // Parse contact name: CONT1 is primary contact name in vendor exports var contactName = GetField(fields, headers, "CONT1"); if (string.IsNullOrWhiteSpace(contactName)) { var fn = GetField(fields, headers, "FIRSTNAME"); var ln = GetField(fields, headers, "LASTNAME"); contactName = $"{fn} {ln}".Trim(); } // Parse address: ADDR1=street, ADDR2 often "City, State Zip" var addr1 = GetField(fields, headers, "ADDR1"); var addr2 = GetField(fields, headers, "ADDR2"); var city = string.Empty; var state = string.Empty; var zip = string.Empty; if (!string.IsNullOrWhiteSpace(addr2)) { // Try "City, ST Zip" format var commaIdx = addr2.IndexOf(','); if (commaIdx > 0) { city = addr2.Substring(0, commaIdx).Trim(); var rest = addr2.Substring(commaIdx + 1).Trim(); // rest = "ST Zip" or "State Zip" var parts = rest.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2) { state = parts[0]; zip = parts[parts.Length - 1]; } else if (parts.Length == 1) { state = parts[0]; } } else { city = addr2.Trim(); } } var limitRaw = GetField(fields, headers, "LIMIT").Replace(",", "").Replace("\"", "").Trim(); var notes = GetField(fields, headers, "NOTEPAD"); if (string.IsNullOrWhiteSpace(notes)) notes = GetField(fields, headers, "NOTE"); var email = GetField(fields, headers, "EMAIL"); if (string.IsNullOrWhiteSpace(email)) email = null; return new Vendor { CompanyId = companyId, CompanyName = companyName, ContactName = string.IsNullOrWhiteSpace(contactName) ? null : contactName, Email = email, Phone = GetField(fields, headers, "PHONE1"), Address = string.IsNullOrWhiteSpace(addr1) ? null : addr1, City = string.IsNullOrWhiteSpace(city) ? null : city, State = string.IsNullOrWhiteSpace(state) ? null : state, ZipCode = string.IsNullOrWhiteSpace(zip) ? null : zip, Country = "USA", TaxId = GetField(fields, headers, "TAXID") is { Length: > 0 } tid ? tid : null, PaymentTerms = GetField(fields, headers, "TERMS") is { Length: > 0 } t ? t : null, CreditLimit = string.IsNullOrWhiteSpace(limitRaw) ? null : ParseDecimal(limitRaw) == 0 ? null : ParseDecimal(limitRaw), Notes = string.IsNullOrWhiteSpace(notes) ? null : notes, IsActive = true, IsPreferred = false, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; } /// /// Updates an existing entity in-place from a parsed IIF data row. /// /// Only non-empty values overwrite existing data, preserving any local edits made after the /// last QuickBooks export. This is intentional: IIF imports are not authoritative overwrites — /// they are merges that fill gaps without destroying local data. /// /// /// Uses the same ADDR2 city/state/zip parsing logic as . /// /// private void UpdateVendorFromIif(Vendor vendor, string[] fields, string[] headers, string userId) { var contactName = GetField(fields, headers, "CONT1"); if (string.IsNullOrWhiteSpace(contactName)) { var fn = GetField(fields, headers, "FIRSTNAME"); var ln = GetField(fields, headers, "LASTNAME"); contactName = $"{fn} {ln}".Trim(); } var addr1 = GetField(fields, headers, "ADDR1"); var addr2 = GetField(fields, headers, "ADDR2"); var city = string.Empty; var state = string.Empty; var zip = string.Empty; if (!string.IsNullOrWhiteSpace(addr2)) { var commaIdx = addr2.IndexOf(','); if (commaIdx > 0) { city = addr2.Substring(0, commaIdx).Trim(); var rest = addr2.Substring(commaIdx + 1).Trim(); var parts = rest.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2) { state = parts[0]; zip = parts[parts.Length - 1]; } else if (parts.Length == 1) state = parts[0]; } else city = addr2.Trim(); } var limitRaw = GetField(fields, headers, "LIMIT").Replace(",", "").Replace("\"", "").Trim(); var notes = GetField(fields, headers, "NOTEPAD"); if (string.IsNullOrWhiteSpace(notes)) notes = GetField(fields, headers, "NOTE"); var email = GetField(fields, headers, "EMAIL"); if (string.IsNullOrWhiteSpace(email)) email = null; if (!string.IsNullOrWhiteSpace(contactName)) vendor.ContactName = contactName; vendor.Email = email; if (!string.IsNullOrWhiteSpace(GetField(fields, headers, "PHONE1"))) vendor.Phone = GetField(fields, headers, "PHONE1"); if (!string.IsNullOrWhiteSpace(addr1)) vendor.Address = addr1; if (!string.IsNullOrWhiteSpace(city)) vendor.City = city; if (!string.IsNullOrWhiteSpace(state)) vendor.State = state; if (!string.IsNullOrWhiteSpace(zip)) vendor.ZipCode = zip; if (GetField(fields, headers, "TAXID") is { Length: > 0 } tid) vendor.TaxId = tid; if (GetField(fields, headers, "TERMS") is { Length: > 0 } terms) vendor.PaymentTerms = terms; if (!string.IsNullOrWhiteSpace(limitRaw) && ParseDecimal(limitRaw) != 0) vendor.CreditLimit = ParseDecimal(limitRaw); if (!string.IsNullOrWhiteSpace(notes)) vendor.Notes = notes; vendor.UpdatedBy = userId; vendor.UpdatedAt = DateTime.UtcNow; } #endregion /// /// Sanitises a string value for safe inclusion in a tab-delimited IIF field. /// /// IIF is strictly tab-delimited: embedded tabs would break column alignment and cause /// QuickBooks to misparse the record. Embedded carriage returns and newlines would /// create phantom extra rows. All three are replaced with a single space. /// /// /// QuickBooks Desktop has a hard limit of ~500 characters per IIF field; values exceeding /// this are silently truncated to avoid import errors on the QuickBooks side. /// /// private string EscapeIifField(string? value) { if (string.IsNullOrEmpty(value)) return string.Empty; // Remove tabs and newlines, truncate to 500 chars var escaped = value .Replace("\t", " ") .Replace("\r", " ") .Replace("\n", " "); return escaped.Length > 500 ? escaped.Substring(0, 500) : escaped; } /// /// Retrieves a named field from a parsed IIF data row by performing a positional lookup /// against the corresponding header row. /// /// The lookup is case-sensitive because QuickBooks IIF field names are always upper-case. /// Returns (not null) when the field is not present so callers /// can use string comparisons without null checks. /// /// /// Applies so callers receive clean values even when QuickBooks /// wrapped the field in double-quotes (e.g. addresses containing commas). /// /// private string GetField(string[] fields, string[] headers, string fieldName) { for (int i = 0; i < headers.Length && i < fields.Length; i++) { if (headers[i] == fieldName) return UnquoteIifField(fields[i]); } return string.Empty; } /// /// IIF format wraps fields that contain commas in double quotes (e.g. "D-Tech, Inc"). /// Strip the surrounding quotes so the value is stored cleanly. /// private static string UnquoteIifField(string value) { if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') return value[1..^1]; return value; } /// /// Attempts to parse a decimal value from a string, returning 0 on failure. /// Used for optional numeric fields (prices, credit limits, balances) where a missing /// or non-numeric value should default to zero rather than cause a parse exception. /// private decimal ParseDecimal(string value) { if (string.IsNullOrWhiteSpace(value)) return 0; if (decimal.TryParse(value, out var result)) return result; return 0; } /// /// Constructs a new entity from a parsed IIF CUST data row. /// /// Company name resolution (three-tier fallback): COMPANYNAMENAMEFIRSTNAME + LASTNAME. /// /// /// Contact name is read from CONT1/CONT2 when available (QB business contacts); /// falling back to FIRSTNAME/LASTNAME for individual customers where QB stores /// the person's name in those fields. /// /// /// IsTaxExempt is the logical inverse of the IIF TAXABLE field: TAXABLE=N /// means the customer is not taxable → is tax exempt. /// /// /// IsCommercial is inferred: customers with a non-empty COMPANYNAME field are /// commercial (B2B); individuals without a company name are non-commercial. /// /// /// Notes are read from NOTEPAD first (longer notes field), falling back to NOTE. /// /// private Customer CreateCustomerFromIif(string[] fields, string[] headers, int companyId, string userId) { var taxable = GetField(fields, headers, "TAXABLE"); // QuickBooks uses COMPANYNAME for businesses, otherwise uses FIRSTNAME + LASTNAME var companyName = GetField(fields, headers, "COMPANYNAME"); if (string.IsNullOrWhiteSpace(companyName)) { // Use NAME field, or combine FIRSTNAME + LASTNAME companyName = GetField(fields, headers, "NAME"); if (string.IsNullOrWhiteSpace(companyName)) { var firstName = GetField(fields, headers, "FIRSTNAME"); var lastName = GetField(fields, headers, "LASTNAME"); companyName = $"{firstName} {lastName}".Trim(); } } // Get contact name from CONT1/CONT2 if available, otherwise from FIRSTNAME/LASTNAME var contactFirstName = GetField(fields, headers, "CONT1"); var contactLastName = GetField(fields, headers, "CONT2"); if (string.IsNullOrWhiteSpace(contactFirstName)) { contactFirstName = GetField(fields, headers, "FIRSTNAME"); contactLastName = GetField(fields, headers, "LASTNAME"); } // Notes - try NOTEPAD first, then NOTE var notes = GetField(fields, headers, "NOTEPAD"); if (string.IsNullOrWhiteSpace(notes)) { notes = GetField(fields, headers, "NOTE"); } // Get email and convert empty to null to avoid unique constraint issues var email = GetField(fields, headers, "EMAIL"); if (string.IsNullOrWhiteSpace(email)) email = null; return new Customer { CompanyId = companyId, CompanyName = companyName, Address = GetField(fields, headers, "BADDR1"), City = GetField(fields, headers, "CITY"), State = GetField(fields, headers, "STATE"), ZipCode = GetField(fields, headers, "ZIP"), Country = GetField(fields, headers, "COUNTRY") is { Length: > 0 } c ? c : "USA", Phone = GetField(fields, headers, "PHONE1"), MobilePhone = GetField(fields, headers, "PHONE2"), Email = email, ContactFirstName = contactFirstName, ContactLastName = contactLastName, PaymentTerms = GetField(fields, headers, "TERMS"), IsTaxExempt = taxable == "N", // Inverted: TAXABLE=N means tax exempt CreditLimit = ParseDecimal(GetField(fields, headers, "LIMIT")), GeneralNotes = notes, IsCommercial = !string.IsNullOrWhiteSpace(GetField(fields, headers, "COMPANYNAME")), // Has company name = commercial IsActive = true, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; } /// /// Updates an existing entity in-place from a parsed IIF CUST data row. /// /// All fields are unconditionally overwritten on update (unlike the vendor update path), because /// customers are matched by company name and a full refresh is the expected behaviour for a /// re-import scenario (e.g. after correcting data in QuickBooks and re-exporting). /// /// /// Applies the same TAXABLE inversion and NOTEPAD → NOTE fallback as /// . /// /// private void UpdateCustomerFromIif(Customer customer, string[] fields, string[] headers, string userId) { // Get contact name from CONT1/CONT2 if available, otherwise from FIRSTNAME/LASTNAME var contactFirstName = GetField(fields, headers, "CONT1"); var contactLastName = GetField(fields, headers, "CONT2"); if (string.IsNullOrWhiteSpace(contactFirstName)) { contactFirstName = GetField(fields, headers, "FIRSTNAME"); contactLastName = GetField(fields, headers, "LASTNAME"); } // Notes - try NOTEPAD first, then NOTE var notes = GetField(fields, headers, "NOTEPAD"); if (string.IsNullOrWhiteSpace(notes)) { notes = GetField(fields, headers, "NOTE"); } var taxable = GetField(fields, headers, "TAXABLE"); // Get email and convert empty to null to avoid unique constraint issues var email = GetField(fields, headers, "EMAIL"); if (string.IsNullOrWhiteSpace(email)) email = null; customer.Address = GetField(fields, headers, "BADDR1"); customer.City = GetField(fields, headers, "CITY"); customer.State = GetField(fields, headers, "STATE"); customer.ZipCode = GetField(fields, headers, "ZIP"); var country = GetField(fields, headers, "COUNTRY"); if (!string.IsNullOrWhiteSpace(country)) customer.Country = country; customer.Phone = GetField(fields, headers, "PHONE1"); customer.MobilePhone = GetField(fields, headers, "PHONE2"); customer.Email = email; customer.ContactFirstName = contactFirstName; customer.ContactLastName = contactLastName; customer.PaymentTerms = GetField(fields, headers, "TERMS"); customer.IsTaxExempt = taxable == "N"; // Inverted: TAXABLE=N means tax exempt customer.CreditLimit = ParseDecimal(GetField(fields, headers, "LIMIT")); customer.GeneralNotes = notes; customer.UpdatedBy = userId; customer.UpdatedAt = DateTime.UtcNow; } /// /// Constructs a new entity from a parsed IIF INVITEM data row. /// /// The NAME field in QuickBooks represents the colon-delimited hierarchy path (e.g. /// "Cerakote:Auto:Exhaust"), not a human-readable item name. The human-readable name /// is stored in DESC. When DESC contains " - ", it is split into name and /// description (e.g. "Sandblast - Small parts sandblasting service" → name="Sandblast", /// description="Small parts sandblasting service"). /// /// /// SKU is intentionally set to null: the QB NAME field (hierarchy path) is /// used for category resolution only, not as a catalog SKU. /// /// /// IsActive is the inverse of HIDDEN: HIDDEN=Y → inactive in this system. /// /// private CatalogItem CreateCatalogItemFromIif(string[] fields, string[] headers, int companyId, int categoryId, string userId) { var desc = GetField(fields, headers, "DESC"); var hidden = GetField(fields, headers, "HIDDEN"); // Use DESC as the name (category markers are already filtered out) var name = desc; var description = string.Empty; // If the description contains " - ", split it into name and description if (!string.IsNullOrWhiteSpace(desc) && desc.Contains(" - ")) { var parts = desc.Split(new[] { " - " }, 2, StringSplitOptions.None); name = parts[0]; description = parts.Length > 1 ? parts[1] : string.Empty; } return new CatalogItem { CompanyId = companyId, CategoryId = categoryId, SKU = null, // SKU not used - QuickBooks NAME field is only for category hierarchy Name = name, Description = description, DefaultPrice = ParseDecimal(GetField(fields, headers, "PRICE")), IsActive = hidden != "Y", // Inverted: Y means hidden DisplayOrder = 0, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; } /// /// Updates an existing entity in-place from a parsed IIF INVITEM data row. /// /// Applies the same DESC splitting logic as and the same /// HIDDENIsActive inversion. Price and active state are always overwritten; /// category is updated by the caller (not this method) before it is invoked. /// /// private void UpdateCatalogItemFromIif(CatalogItem item, string[] fields, string[] headers, string userId) { var desc = GetField(fields, headers, "DESC"); var hidden = GetField(fields, headers, "HIDDEN"); // Use DESC as the name (category markers are already filtered out) var name = desc; var description = string.Empty; // If the description contains " - ", split it into name and description if (!string.IsNullOrWhiteSpace(desc) && desc.Contains(" - ")) { var parts = desc.Split(new[] { " - " }, 2, StringSplitOptions.None); name = parts[0]; description = parts.Length > 1 ? parts[1] : string.Empty; } item.Name = name; item.Description = description; item.DefaultPrice = ParseDecimal(GetField(fields, headers, "PRICE")); item.IsActive = hidden != "Y"; // Inverted: Y means hidden item.UpdatedBy = userId; item.UpdatedAt = DateTime.UtcNow; } /// /// Parses a QuickBooks colon-delimited item hierarchy (e.g. "Cerakote:Automotive:Exhaust:Item Name") /// and ensures the full category path exists in the database, creating any missing levels. /// Returns the ID of the deepest (leaf) category node. /// /// The last colon-segment is the item name, not a category: ["Cerakote", "Automotive", "Exhaust"] /// become categories; "Item Name" is handled by the caller. /// /// /// Each level is flushed immediately (CompleteAsync) after creation so that its database-assigned /// Id is available as the ParentCategoryId for the next level. /// /// /// Items with no colon hierarchy (flat names) fall back to the default "Imported Items" category /// via rather than creating spurious top-level categories. /// /// private async Task GetOrCreateCategoryFromSkuHierarchy(string sku, int companyId, string userId) { // Split SKU by colon to get hierarchy levels var parts = sku.Split(':'); // If no hierarchy (no colons), create/use a default "Imported Items" category if (parts.Length == 1) { return await GetOrCreateDefaultCategory(companyId, userId); } // The last part is the item name, everything before is the category path // e.g., "Cerakote:Automotive:Exhaust:Item" -> ["Cerakote", "Automotive", "Exhaust"] = category path var categoryPath = parts.Take(parts.Length - 1).ToArray(); int? parentId = null; int categoryId = 0; // Build/find category hierarchy from root to leaf for (int i = 0; i < categoryPath.Length; i++) { var categoryName = categoryPath[i].Trim(); if (string.IsNullOrWhiteSpace(categoryName)) continue; // Try to find existing category at this level var existingCategory = await _unitOfWork.CatalogCategories.FirstOrDefaultAsync( c => c.CompanyId == companyId && c.Name == categoryName && c.ParentCategoryId == parentId); if (existingCategory != null) { categoryId = existingCategory.Id; } else { // Create new category var newCategory = new CatalogCategory { CompanyId = companyId, ParentCategoryId = parentId, Name = categoryName, Description = $"Imported from QuickBooks: {string.Join(" > ", categoryPath.Take(i + 1))}", DisplayOrder = i, IsActive = true, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CatalogCategories.AddAsync(newCategory); await _unitOfWork.CompleteAsync(); categoryId = newCategory.Id; _logger.LogInformation("Created category: {Name} (Parent: {ParentId})", categoryName, parentId); } // This category becomes the parent for the next level parentId = categoryId; } return categoryId; } /// /// Gets or creates the catch-all top-level "Imported Items" category used for QuickBooks items /// that have no colon-delimited hierarchy in their NAME field. /// /// The category is lazily created on first use and reused for all subsequent flat-name items /// within the same import, so only one DB round-trip is needed per import (not per item). /// It is assigned a high DisplayOrder (999) to appear at the bottom of category lists. /// /// private async Task GetOrCreateDefaultCategory(int companyId, string userId) { var defaultCategory = await _unitOfWork.CatalogCategories.FirstOrDefaultAsync( c => c.CompanyId == companyId && c.Name == "Imported Items"); if (defaultCategory != null) { return defaultCategory.Id; } // Create default category var newCategory = new CatalogCategory { CompanyId = companyId, ParentCategoryId = null, Name = "Imported Items", Description = "Items imported from QuickBooks without category hierarchy", DisplayOrder = 999, IsActive = true, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CatalogCategories.AddAsync(newCategory); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Created default 'Imported Items' category"); return newCategory.Id; } #endregion #region QB Invoice CSV Import // ── Private types ──────────────────────────────────────────────────────── private sealed class QbInvoiceGroup { public string CustomerName { get; } public string QbInvoiceNum { get; } public DateTime InvoiceDate { get; } public List Lines { get; } = new(); /// Amount already paid as-of the QB export. 0 if unknown (detail format didn't track payments). public decimal AmountPaid { get; set; } /// Individual payment applications for Payment record creation on import. public List PaymentRecords { get; } = new(); public QbInvoiceGroup(string customerName, string qbNum, DateTime date) { CustomerName = customerName; QbInvoiceNum = qbNum; InvoiceDate = date; } } private sealed record QbPaymentRecord(DateTime PaymentDate, decimal Amount, string? CheckNum, string? BankAccountName); private sealed class QbInvoiceLine { public string Memo { get; init; } = ""; public string ItemPath { get; init; } = ""; public string QtyRaw { get; init; } = ""; public string SalesPriceRaw { get; init; } = ""; public decimal Amount { get; init; } } // ── Public method ──────────────────────────────────────────────────────── /// /// Imports historical QuickBooks invoices from a QB Desktop CSV export and creates corresponding /// records in the application database. /// /// Pre-flight requirement: Customers must be imported first; the importer matches invoices /// to customers by display name and fails with a descriptive error if no customers are found. /// Customer name matching uses exact lookup first, then a "starts-with" fallback to handle cases /// where the QuickBooks invoice name is shorter than the full company name stored in the DB /// (e.g. "Novo Nordisk" matching "Novo Nordisk Pharmaceutical Industries"). /// /// /// Duplicate prevention: QuickBooks invoice numbers are stored in . /// Re-running the import skips any invoice whose QB number is already present in the DB. /// /// /// Invoice numbering: Imported invoices receive sequential internal numbers (INV-YYMM-####) /// that are computed in a pre-import block to avoid per-invoice DB round-trips. The QB number is /// preserved separately in ExternalReference. /// /// /// Payment records: Payment history is intentionally NOT created here. The importer sets /// AmountPaid and Status on the invoice but leaves Payment record creation to /// , which has richer data (check numbers, exact /// dates, bank account names). /// /// /// Format auto-detection: Supports both the "Customer Balance Detail" format and the /// "Sales by Customer / Invoice Detail" format, detected by scanning the CSV header row for /// columns like "Item", "Qty", or "SalesPrice". /// /// public async Task ImportQbInvoicesFromCsvAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { // Pre-flight: customers must exist before invoices can be linked var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && !c.IsDeleted)).ToList(); if (!customers.Any()) { result.Errors.Add(new ImportErrorDto { RecordName = "Pre-flight", ErrorMessage = "No customers found. Please import customers from QuickBooks first, then re-run the invoice import." }); return result; } // Customer lookup: display name → Customer // Strip surrounding quotes that may have been stored during IIF import, // and register both the full name and any extra-word variants. var customerLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var c in customers) { var raw = (!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName : $"{c.ContactFirstName} {c.ContactLastName}").Trim(); // Strip surrounding double-quotes stored by the IIF importer var name = raw.Trim('"'); customerLookup.TryAdd(name, c); // Also index by the raw form in case it wasn't quoted customerLookup.TryAdd(raw, c); } // Build a list for fallback "starts-with" matching (handles cases where the // QB invoice has a shorter name than the full company name, e.g. // invoice says "Novo Nordisk" but customer is "Novo Nordisk Pharmaceutical Industries") var customerList2 = customerLookup.ToList(); // Catalog item lookup: name → CatalogItem (best-effort match) var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId && !ci.IsDeleted)).ToList(); var catalogLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var ci in catalogItems) catalogLookup.TryAdd(ci.Name.Trim(), ci); // Already-imported QB invoice numbers (ExternalReference) — skip duplicates var existingInvoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId && i.ExternalReference != null); var existingRefs = existingInvoices.Select(i => i.ExternalReference!).ToHashSet(StringComparer.OrdinalIgnoreCase); // Read CSV string content; using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) content = await reader.ReadToEndAsync(); // Parse into invoice groups List groups; try { groups = ParseQbInvoicesCsv(content); } catch (Exception ex) { _logger.LogError(ex, "Failed to parse QB invoices CSV"); result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Failed to parse CSV: {ex.Message}" }); return result; } result.TotalRecords = groups.Count; _logger.LogInformation("Parsed {Count} invoice groups from QB CSV for company {CompanyId}", groups.Count, companyId); // Pre-compute a block of sequential invoice numbers (today's prefix) var prefix = $"INV-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; var sameMonthInvoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix)); var maxNum = sameMonthInvoices .Select(i => i.InvoiceNumber.Length >= prefix.Length + 4 ? i.InvoiceNumber[prefix.Length..] : "") .Where(s => int.TryParse(s, out _)) .Select(s => int.Parse(s)) .DefaultIfEmpty(0) .Max(); var invoiceCounter = maxNum + 1; // Import each invoice group foreach (var group in groups) { // Skip already-imported QB invoices if (existingRefs.Contains(group.QbInvoiceNum)) { result.SkippedCount++; continue; } // Require customer match — exact first, then starts-with fallback var invoiceCustomerName = group.CustomerName.Trim().Trim('"'); if (!customerLookup.TryGetValue(invoiceCustomerName, out var customer)) { // Fallback: customer name in DB starts with the invoice name // e.g. invoice = "Novo Nordisk", DB = "Novo Nordisk Pharmaceutical Industries" var fallback = customerList2.FirstOrDefault(kvp => kvp.Key.StartsWith(invoiceCustomerName, StringComparison.OrdinalIgnoreCase)); customer = fallback.Value; } if (customer == null) { result.Errors.Add(new ImportErrorDto { RecordName = $"QB #{group.QbInvoiceNum}", ErrorMessage = $"Customer '{group.CustomerName}' not found in this company. Skipping invoice #{group.QbInvoiceNum}." }); result.SkippedCount++; continue; } var total = group.Lines.Sum(l => l.Amount); var invoiceNumber = $"{prefix}{invoiceCounter++:D4}"; var amountPaid = Math.Min(group.AmountPaid, total); var invoiceStatus = amountPaid >= total && total > 0 ? InvoiceStatus.Paid : amountPaid > 0 ? InvoiceStatus.PartiallyPaid : InvoiceStatus.Sent; var invoice = new Invoice { CompanyId = companyId, CustomerId = customer.Id, InvoiceNumber = invoiceNumber, ExternalReference = group.QbInvoiceNum, InvoiceDate = group.InvoiceDate, Status = invoiceStatus, SubTotal = total, TaxAmount = 0, TaxPercent = 0, DiscountAmount = 0, Total = total, AmountPaid = amountPaid, CreatedBy = userId, CreatedAt = DateTime.UtcNow }; int displayOrder = 1; foreach (var line in group.Lines) { var shortName = ExtractQbItemShortName(line.ItemPath); catalogLookup.TryGetValue(shortName, out var catalogItem); bool isPercent = line.SalesPriceRaw.Contains('%'); decimal qty = !isPercent && decimal.TryParse(line.QtyRaw, NumberStyles.Any, CultureInfo.InvariantCulture, out var q) ? q : 1m; decimal unitPrice = isPercent ? line.Amount : (decimal.TryParse(line.SalesPriceRaw, NumberStyles.Any, CultureInfo.InvariantCulture, out var sp) ? sp : line.Amount); invoice.InvoiceItems.Add(new InvoiceItem { CompanyId = companyId, Description = line.Memo, Quantity = qty, UnitPrice = unitPrice, TotalPrice = line.Amount, CatalogItemId = catalogItem?.Id, DisplayOrder = displayOrder++, CreatedBy = userId, CreatedAt = DateTime.UtcNow }); } await _unitOfWork.Invoices.AddAsync(invoice); // NOTE: We intentionally do NOT create Payment records here. // AmountPaid and Status are set correctly from the balance detail file. // Step 7 (ImportQbTransactionsFromCsvAsync) creates the actual Payment // records with richer data: check numbers, bank account names, exact dates. // Track within-file to prevent duplicates if QB number appears twice in the file existingRefs.Add(group.QbInvoiceNum); result.ImportedCount++; } if (result.ImportedCount > 0) await _unitOfWork.CompleteAsync(); result.Success = true; _logger.LogInformation("QB invoice import complete: {Imported} imported, {Skipped} skipped, {Errors} errors", result.ImportedCount, result.SkippedCount, result.Errors.Count); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error during QB invoice import for company {CompanyId}", companyId); result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Unexpected error: {ex.Message}" }); } return result; } // ── Parsing ────────────────────────────────────────────────────────────── /// /// Detects which QuickBooks Desktop CSV export format the content represents and dispatches to /// the appropriate parser. /// /// QB Desktop can export invoice data in at least two different layouts: /// /// Sales by Customer / Invoice Detail (10+ columns): includes individual line-item rows /// with Item, Qty, SalesPrice, and Amount columns. /// Customer Balance Detail (7 columns): summarises open invoices and payment rows /// without line-item detail. /// /// Detection scans the header row for the presence of "item", "qty", "quantity", or "salesprice" /// keywords (case-insensitive). The detail format is preferred when any of these are found. /// /// private static List ParseQbInvoicesCsv(string content) { // Auto-detect which QB Desktop CSV export format this is: // // (A) Sales by Customer / Invoice Detail — 10+ columns: // [0] Customer label or blank, [1] Type, [2] Date, [3] Num, [4] Memo, // [5] Name, [6] Item, [7] Qty, [8] SalesPrice, [9] Amount, [10] Balance // // (B) Customer Balance Detail — 7 columns: // [0] Customer name (header rows) or blank, [1] Type, [2] Date, // [3] Num, [4] Account, [5] Amount, [6] Balance // // We detect by scanning the header row: if it contains "item", "qty", // or "salesprice" → format A; otherwise assume format B. var lines = content.Split('\n'); if (lines.Length < 2) return new List(); var headerCols = ParseCsvLine(lines[0].TrimEnd('\r')); bool isDetailFormat = headerCols.Any(h => { var lower = h.Trim().ToLowerInvariant(); return lower == "item" || lower == "qty" || lower == "quantity" || lower.Contains("salesprice") || lower.Contains("sales price"); }); return isDetailFormat ? ParseQbInvoiceDetailCsv(lines) : ParseQbBalanceDetailCsv(lines); } /// /// Parser for QB Desktop "Customer Balance Detail" CSV export. /// Columns: [0] Customer name (on header rows) | blank, [1] Type, [2] Date, /// [3] Num, [4] Account, [5] Amount, [6] Balance /// Payments are matched to invoices FIFO per customer to set AmountPaid. /// private static List ParseQbBalanceDetailCsv(string[] lines) { var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); var orderedKeys = new List(); string currentCustomer = ""; // Open invoices for the current customer in order (for FIFO payment matching) var openInvoiceNums = new List(); // Payments that arrived before any invoice for this customer (prepayments / deposits) var pendingPayments = new List(); for (int i = 1; i < lines.Length; i++) { var rawLine = lines[i].TrimEnd('\r'); if (string.IsNullOrWhiteSpace(rawLine)) continue; var cols = ParseCsvLine(rawLine); if (cols.Count < 2) continue; var col0 = cols[0].Trim().Trim('"'); // Customer header row: col[0] is non-empty and not a "Total" row if (!string.IsNullOrEmpty(col0) && !col0.StartsWith("Total ", StringComparison.OrdinalIgnoreCase)) { currentCustomer = col0; openInvoiceNums.Clear(); pendingPayments.Clear(); continue; } // Total row or blank first column — transaction rows have blank col[0] if (cols.Count < 6) continue; var txType = cols.Count > 1 ? cols[1].Trim() : ""; var dateStr = cols.Count > 2 ? cols[2].Trim() : ""; var num = cols.Count > 3 ? cols[3].Trim() : ""; var acctName = cols.Count > 4 ? cols[4].Trim().Trim('"') : ""; // bank account name on payment rows var amountStr = cols.Count > 5 ? cols[5].Trim() : ""; if (!decimal.TryParse(amountStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue; if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var txDate) && !DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out txDate)) continue; if (string.Equals(txType, "Invoice", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(num)) { if (!groups.ContainsKey(num)) { var group = new QbInvoiceGroup(currentCustomer, num, txDate); group.Lines.Add(new QbInvoiceLine { Memo = $"QB Invoice #{num}", Amount = amount, QtyRaw = "1", SalesPriceRaw = amount.ToString(CultureInfo.InvariantCulture), }); groups[num] = group; orderedKeys.Add(num); // Drain any pending prepayments against this invoice first if (pendingPayments.Count > 0) { decimal invoiceTotal = amount; foreach (var pp in pendingPayments.ToList()) { if (group.AmountPaid >= invoiceTotal) break; var outstanding = invoiceTotal - group.AmountPaid; var apply = Math.Min(pp.Amount, outstanding); group.AmountPaid += apply; group.PaymentRecords.Add(pp with { Amount = apply }); if (apply < pp.Amount) { // Partial use of a pending payment — keep the remainder var idx = pendingPayments.IndexOf(pp); pendingPayments[idx] = pp with { Amount = pp.Amount - apply }; } else { pendingPayments.Remove(pp); } } } openInvoiceNums.Add(num); } } else if (string.Equals(txType, "Payment", StringComparison.OrdinalIgnoreCase) && amount < 0) { decimal remaining = Math.Abs(amount); if (openInvoiceNums.Count == 0) { // No open invoices yet — this is a prepayment; hold it for the next invoice pendingPayments.Add(new QbPaymentRecord(txDate, remaining, string.IsNullOrEmpty(num) ? null : num, string.IsNullOrEmpty(acctName) ? null : acctName)); } else { // Apply FIFO to open invoices foreach (var invNum in openInvoiceNums.ToList()) { if (remaining <= 0) break; if (!groups.TryGetValue(invNum, out var g)) continue; var invoiceTotal = g.Lines.Sum(l => l.Amount); var alreadyPaid = g.AmountPaid; var outstanding = invoiceTotal - alreadyPaid; if (outstanding <= 0) { openInvoiceNums.Remove(invNum); continue; } var apply = Math.Min(remaining, outstanding); g.AmountPaid += apply; g.PaymentRecords.Add(new QbPaymentRecord(txDate, apply, string.IsNullOrEmpty(num) ? null : num, string.IsNullOrEmpty(acctName) ? null : acctName)); remaining -= apply; if (g.AmountPaid >= invoiceTotal) openInvoiceNums.Remove(invNum); } } } } return orderedKeys.Select(k => groups[k]).ToList(); } /// /// Parser for QB Desktop "Sales by Customer" / Invoice Detail CSV export. /// Columns: [0] Customer label or blank, [1] Type, [2] Date, [3] Num, /// [4] Memo, [5] Name, [6] Item, [7] Qty, [8] SalesPrice, [9] Amount, [10] Balance /// /// /// Parses the "Sales by Customer" / Invoice Detail CSV export from QuickBooks Desktop. /// Groups individual line rows into objects keyed by QB invoice number. /// /// The format has one row per line item; the first Invoice row for a given QB number creates /// the group. Subsequent rows for the same QB number append additional /// entries. AmountPaid is left at 0 — this format does not include payment rows; /// payment reconciliation happens in or during the /// transaction import step. /// /// private static List ParseQbInvoiceDetailCsv(string[] lines) { var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); var orderedKeys = new List(); for (int i = 1; i < lines.Length; i++) { var rawLine = lines[i].TrimEnd('\r'); if (string.IsNullOrWhiteSpace(rawLine)) continue; var cols = ParseCsvLine(rawLine); if (cols.Count < 10) continue; if (!string.Equals(cols[1].Trim(), "Invoice", StringComparison.OrdinalIgnoreCase)) continue; var customerName = cols[5].Trim(); var dateStr = cols[2].Trim(); var qbNum = cols[3].Trim(); var memo = cols[4].Trim(); var itemPath = cols[6].Trim(); var qtyRaw = cols[7].Trim(); var priceRaw = cols[8].Trim(); var amountStr = cols[9].Trim(); if (string.IsNullOrEmpty(qbNum) || string.IsNullOrEmpty(customerName)) continue; if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var invoiceDate) && !DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out invoiceDate)) continue; if (!decimal.TryParse(amountStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue; if (!groups.ContainsKey(qbNum)) { groups[qbNum] = new QbInvoiceGroup(customerName, qbNum, invoiceDate); orderedKeys.Add(qbNum); } groups[qbNum].Lines.Add(new QbInvoiceLine { Memo = memo, ItemPath = itemPath, QtyRaw = qtyRaw, SalesPriceRaw = priceRaw, Amount = amount }); } return orderedKeys.Select(k => groups[k]).ToList(); } /// /// Extracts the short item name from a QB item path. /// QB format: "Category:SubCat:Item Name (Short Name)" — returns "Short Name". /// Handles truncated paths ending with "...". /// private static string ExtractQbItemShortName(string itemPath) { if (string.IsNullOrWhiteSpace(itemPath)) return itemPath; var clean = itemPath.TrimEnd(); // Drop trailing ellipsis from truncated paths if (clean.EndsWith("...", StringComparison.Ordinal)) clean = clean[..^3]; var lastClose = clean.LastIndexOf(')'); if (lastClose < 0) return clean.Split(':').Last().Trim(); var lastOpen = clean.LastIndexOf('(', lastClose); if (lastOpen < 0) return clean.Split(':').Last().Trim(); return clean.Substring(lastOpen + 1, lastClose - lastOpen - 1).Trim(); } /// /// Parses a single RFC 4180-compliant CSV line into a list of field values. /// Delegates to with ',' as the delimiter. /// private static List ParseCsvLine(string line) => ParseDelimitedLine(line, ','); /// /// Tokenises a delimited line respecting RFC 4180 quoting rules: /// fields wrapped in double-quotes may contain the delimiter character and embedded newlines; /// a doubled double-quote ("") inside a quoted field represents a literal double-quote. /// /// Used for both comma-delimited CSV and tab-delimited IIF lines (via the /// parameter) so that the same robust parsing logic covers all input formats handled by this service. /// /// private static List ParseDelimitedLine(string line, char delimiter) { var fields = new List(); var current = new StringBuilder(); bool inQuotes = false; for (int i = 0; i < line.Length; i++) { char c = line[i]; if (inQuotes) { if (c == '"') { if (i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; // skip escaped quote } else { inQuotes = false; } } else { current.Append(c); } } else { if (c == '"') inQuotes = true; else if (c == delimiter) { fields.Add(current.ToString()); current.Clear(); } else current.Append(c); } } fields.Add(current.ToString()); return fields; } #endregion #region QB Transactions CSV Import // ----------------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------------- private sealed class QbTransactionRow { public string CustomerName { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; // "Invoice", "Payment", "Estimate" public DateTime Date { get; set; } public string Num { get; set; } = string.Empty; // QB invoice # (on Invoice rows) or check # (on Payment rows) public string Memo { get; set; } = string.Empty; public string Account { get; set; } = string.Empty; // e.g. "10200 · Wells Fargo Checking" public string Split { get; set; } = string.Empty; public decimal Amount { get; set; } } // ----------------------------------------------------------------------- // Public implementation // ----------------------------------------------------------------------- /// /// Imports QuickBooks payment transactions from a "Customer Transaction Detail" CSV export /// and creates records linked to previously-imported invoices. /// /// Pre-flight requirement: QB-sourced invoices (those with a non-null /// ) must exist before this step can run. /// If none are found, the method returns an error directing the user to run the invoice /// import first. /// /// /// Matching strategy: Rows are grouped by customer name. Within each group the /// importer processes rows in file order: each Invoice row pushes the QB invoice number /// onto a pending queue; each Payment row pops the oldest invoice (FIFO) and applies the /// payment amount. Payments that arrive before their invoice row (prepayments) are held in /// a separate list and drained when the matching invoice is later encountered. /// /// /// Already-settled invoices: When an invoice was imported with AmountPaid /// pre-set from the balance-detail file, its balance is already zero. The importer still /// creates Payment records for historical audit trail (check numbers, exact dates, payment /// method) without changing AmountPaid or Status. /// /// /// Session payment tracking: Because EF does not immediately reflect newly-added /// Payment records in invoice.Payments within the same request, the importer /// maintains a sessionPaymentsAdded dictionary to track the running total of payments /// added during this import run, enabling correct duplicate detection. /// /// /// Item description enrichment: When an Invoice row has a non-generic Memo /// value, the first invoice line item's description is updated from the generic /// "QB Invoice #NNN" placeholder to the actual memo text. /// /// public async Task ImportQbTransactionsFromCsvAsync( IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { // ── 0. Pre-flight: make sure there are imported invoices to match against ── var existingInvoices = (await _unitOfWork.Invoices.FindAsync( i => i.CompanyId == companyId && i.ExternalReference != null, false, i => i.Payments, i => i.InvoiceItems)) .ToList(); if (existingInvoices.Count == 0) { result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "No QB-imported invoices found. Import QB invoices first, then import transactions." }); return result; } // Build lookup by ExternalReference (QB invoice #) — case-insensitive var invoiceByRef = existingInvoices .Where(i => !string.IsNullOrWhiteSpace(i.ExternalReference)) .ToDictionary(i => i.ExternalReference!.Trim(), i => i, StringComparer.OrdinalIgnoreCase); _logger.LogInformation("QB Txn Import: {DbCount} invoices loaded into lookup (ExternalReference != null), {TotalCount} total imported invoices", invoiceByRef.Count, existingInvoices.Count); // ── Load bank/deposit accounts for DepositAccountId resolution ── var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted); var acctByNumber = allAccounts .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) .ToDictionary(a => a.AccountNumber.Trim(), a => a, StringComparer.OrdinalIgnoreCase); var acctByName = allAccounts .GroupBy(a => a.Name.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); // ── 1. Read file ── string content; using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) content = await reader.ReadToEndAsync(); // ── 2. Parse rows ── var rows = ParseQbTransactionsCsv(content); // ── 3. Group into (customer → ordered list of rows) then process ── // For each customer group, iterate rows in order. // Each "Invoice" row pushes its QB Num onto a pending queue. // Each "Payment" row pops the oldest pending invoice and records a Payment. var grouped = rows .GroupBy(r => r.CustomerName, StringComparer.OrdinalIgnoreCase) .ToList(); int lineNum = 0; int totalPaymentRows = rows.Count(r => r.Type.Equals("Payment", StringComparison.OrdinalIgnoreCase)); result.TotalRecords = totalPaymentRows; _logger.LogInformation("QB Txn Import: CSV has {InvoiceRows} Invoice rows, {PaymentRows} Payment rows, {CustomerGroups} customer groups", rows.Count(r => r.Type.Equals("Invoice", StringComparison.OrdinalIgnoreCase)), totalPaymentRows, grouped.Count); // Skip-reason counters for diagnostics int skipNotInDb = 0; int skipAlreadyRecorded = 0; var firstNotFoundNums = new List(); // sample of invoice nums not found in DB // Tracks how much payment history has been added (in this session) for invoices // that were already settled when imported. Keyed by QB invoice num. // Needed because invoice.Payments is a DB snapshot and won't include records // added earlier in the same import run. var sessionPaymentsAdded = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var customerGroup in grouped) { // Queue entries: (QB invoice num, found-in-DB flag). // We track FoundInDb so that multiple payments for the same un-imported // invoice are all silently skipped rather than erroring after the first. var pendingInvoiceQueue = new Queue<(string Num, bool FoundInDb)>(); // Payments that arrive before their invoice (prepayments / deposits). // Drained FIFO when the matching invoice row is later encountered. var prepaymentRows = new List(); foreach (var row in customerGroup) { lineNum++; if (row.Type.Equals("Invoice", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(row.Num)) continue; var num = row.Num.Trim(); bool foundInDb = invoiceByRef.ContainsKey(num); pendingInvoiceQueue.Enqueue((num, foundInDb)); // Update invoice item description from transactions CSV memo if (foundInDb && !string.IsNullOrWhiteSpace(row.Memo)) { var inv = invoiceByRef[num]; var item = inv.InvoiceItems.FirstOrDefault(); if (item != null && (item.Description.StartsWith("QB Invoice #", StringComparison.OrdinalIgnoreCase) || item.Description.Equals("Imported from QuickBooks", StringComparison.OrdinalIgnoreCase))) { item.Description = row.Memo.Trim(); item.UpdatedAt = DateTime.UtcNow; item.UpdatedBy = userId; await _unitOfWork.InvoiceItems.UpdateAsync(item); } } // Drain any prepayments against this newly-seen invoice if (foundInDb && prepaymentRows.Count > 0) { var inv = invoiceByRef[num]; foreach (var pp in prepaymentRows.ToList()) { var bd = inv.Total - inv.AmountPaid - inv.CreditApplied - inv.GiftCertificateRedeemed; if (bd <= 0m) break; var apply = Math.Min(pp.Amount, bd); var ppDepositAcct = ResolveAccountFromQbString(pp.Account, acctByNumber, acctByName); await _unitOfWork.Payments.AddAsync(new Payment { InvoiceId = inv.Id, CompanyId = companyId, Amount = apply, PaymentDate = pp.Date, PaymentMethod = DetectPaymentMethod(pp.Account), DepositAccountId = ppDepositAcct?.Id, Reference = string.IsNullOrWhiteSpace(pp.Num) ? null : pp.Num.Trim(), Notes = string.IsNullOrWhiteSpace(pp.Memo) ? "Imported from QuickBooks (prepayment)" : pp.Memo.Trim(), RecordedById = userId, CreatedAt = DateTime.UtcNow, CreatedBy = userId }); inv.AmountPaid += apply; if (inv.Total - inv.AmountPaid - inv.CreditApplied - inv.GiftCertificateRedeemed <= 0m) { inv.Status = InvoiceStatus.Paid; inv.PaidDate = pp.Date; pendingInvoiceQueue.Dequeue(); // fully paid by prepayment(s) } else { inv.Status = InvoiceStatus.PartiallyPaid; } inv.UpdatedAt = DateTime.UtcNow; inv.UpdatedBy = userId; await _unitOfWork.Invoices.UpdateAsync(inv); prepaymentRows.Remove(pp); result.ImportedCount++; } } continue; } if (!row.Type.Equals("Payment", StringComparison.OrdinalIgnoreCase)) continue; // Skip Estimates and anything else // ── Match payment to oldest pending invoice ── // Empty queue → prepayment (payment before invoice row in CSV) if (pendingInvoiceQueue.Count == 0) { prepaymentRows.Add(row); continue; } var (qbInvoiceNum, qbFoundInDb) = pendingInvoiceQueue.Peek(); if (!qbFoundInDb) { // Invoice wasn't imported — silently skip all its payments. // Leave it in the queue so the next payment also skips; the // invoice is dequeued when the next Invoice row arrives. skipNotInDb++; result.SkippedCount++; if (firstNotFoundNums.Count < 10 && !firstNotFoundNums.Contains(qbInvoiceNum)) firstNotFoundNums.Add(qbInvoiceNum); continue; } var invoice = invoiceByRef[qbInvoiceNum]; var balanceDue = invoice.Total - invoice.AmountPaid - invoice.CreditApplied - invoice.GiftCertificateRedeemed; var alreadySettled = balanceDue <= 0m; if (alreadySettled) { // Invoice was imported with AmountPaid pre-set from the balance detail // file (Invoices.CSV). We still need to create Payment records for // history (check numbers, dates, payment methods). Don't change AmountPaid. // Guard: if enough payment history already exists (DB + this session), // treat as duplicate and move on. sessionPaymentsAdded.TryGetValue(qbInvoiceNum, out var sessionAdded); var totalRecorded = invoice.Payments.Sum(p => p.Amount) + sessionAdded; if (totalRecorded >= invoice.AmountPaid - 0.01m) { skipAlreadyRecorded++; result.AlreadyRecordedCount++; pendingInvoiceQueue.Dequeue(); continue; } var histDepositAcct = ResolveAccountFromQbString(row.Account, acctByNumber, acctByName); var histPayment = new Payment { InvoiceId = invoice.Id, CompanyId = companyId, Amount = row.Amount, PaymentDate = row.Date, PaymentMethod = DetectPaymentMethod(row.Account), DepositAccountId = histDepositAcct?.Id, Reference = string.IsNullOrWhiteSpace(row.Num) ? null : row.Num.Trim(), Notes = string.IsNullOrWhiteSpace(row.Memo) ? "Imported from QuickBooks" : row.Memo.Trim(), RecordedById = userId, CreatedAt = DateTime.UtcNow, CreatedBy = userId }; await _unitOfWork.Payments.AddAsync(histPayment); var newSessionAdded = sessionAdded + row.Amount; sessionPaymentsAdded[qbInvoiceNum] = newSessionAdded; // Dequeue once total recorded history matches the invoice AmountPaid if (invoice.Payments.Sum(p => p.Amount) + newSessionAdded >= invoice.AmountPaid - 0.01m) pendingInvoiceQueue.Dequeue(); invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = userId; await _unitOfWork.Invoices.UpdateAsync(invoice); result.ImportedCount++; continue; } // ── Normal case: invoice has an outstanding balance ── // Determine payment method and deposit account from Account name var paymentMethod = DetectPaymentMethod(row.Account); var depositAccount = ResolveAccountFromQbString(row.Account, acctByNumber, acctByName); // Apply payment (cap at balance due) var applyAmount = Math.Min(row.Amount, balanceDue); var payment = new Payment { InvoiceId = invoice.Id, CompanyId = companyId, Amount = applyAmount, PaymentDate = row.Date, PaymentMethod = paymentMethod, DepositAccountId = depositAccount?.Id, Reference = string.IsNullOrWhiteSpace(row.Num) ? null : row.Num.Trim(), Notes = string.IsNullOrWhiteSpace(row.Memo) ? "Imported from QuickBooks" : row.Memo.Trim(), RecordedById = userId, CreatedAt = DateTime.UtcNow, CreatedBy = userId }; await _unitOfWork.Payments.AddAsync(payment); invoice.AmountPaid += applyAmount; var newBalance = invoice.Total - invoice.AmountPaid - invoice.CreditApplied - invoice.GiftCertificateRedeemed; if (newBalance <= 0m) { invoice.Status = InvoiceStatus.Paid; invoice.PaidDate = row.Date; // Fully paid — dequeue so next payment (if any) hits the next invoice pendingInvoiceQueue.Dequeue(); } else { invoice.Status = InvoiceStatus.PartiallyPaid; // Keep invoice in queue; remaining payments may continue to apply } invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = userId; await _unitOfWork.Invoices.UpdateAsync(invoice); result.ImportedCount++; } // Drain stale "not found" invoice from the front before next customer. // (Any remaining prepaymentRows for this customer have no invoice — drop them.) } _logger.LogInformation( "QB Txn Import complete: {Imported} imported, {SkipNotInDb} skipped-not-in-db, {SkipDupe} skipped-already-recorded. " + "First not-found invoice nums: [{Nums}]", result.ImportedCount, skipNotInDb, skipAlreadyRecorded, string.Join(", ", firstNotFoundNums)); await _unitOfWork.CompleteAsync(); result.Success = true; } catch (Exception ex) { _logger.LogError(ex, "Error importing QB transactions CSV for company {CompanyId}", companyId); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } // ----------------------------------------------------------------------- // ParseQbTransactionsCsv // Format: ,"Type","Date","Num","Memo","Account","Clr","Split","Amount" // Customer header rows: col[0] = customer name, all other cols empty // ----------------------------------------------------------------------- /// /// Parses the QuickBooks Desktop "Customer Transaction Detail" CSV report into a flat list /// of objects. /// /// The QB report format has customer header rows (col[0] = customer name, col[1] empty) /// that establish the current customer for subsequent transaction rows (col[0] empty, col[1] = type). /// Column header rows (col[1] = "Type") are skipped. /// /// /// Only Invoice, Payment, and Estimate row types are emitted; all other types (Credit Memo, /// Journal Entry, etc.) are silently dropped since they have no direct equivalent in the /// import flow. /// /// /// The report columns are: blank, Type, Date, Num, Memo, Account, Clr, Split, Amount. /// /// private static List ParseQbTransactionsCsv(string content) { var rows = new List(); var lines = content.Split('\n'); string currentCustomer = string.Empty; foreach (var rawLine in lines) { var line = rawLine.TrimEnd('\r'); if (string.IsNullOrWhiteSpace(line)) continue; var cols = ParseCsvLine(line); while (cols.Count < 9) cols.Add(string.Empty); // Customer header: col[0] has a name, col[1] is empty if (!string.IsNullOrWhiteSpace(cols[0]) && string.IsNullOrWhiteSpace(cols[1])) { currentCustomer = cols[0].Trim(); continue; } // Column header row if (cols[1].Equals("Type", StringComparison.OrdinalIgnoreCase)) continue; // Transaction row: col[0] empty, col[1] = type var type = cols[1].Trim(); if (string.IsNullOrWhiteSpace(type)) continue; if (!type.Equals("Invoice", StringComparison.OrdinalIgnoreCase) && !type.Equals("Payment", StringComparison.OrdinalIgnoreCase) && !type.Equals("Estimate", StringComparison.OrdinalIgnoreCase)) continue; if (!DateTime.TryParseExact(cols[2].Trim(), "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) continue; _ = decimal.TryParse(cols[8].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount); rows.Add(new QbTransactionRow { CustomerName = currentCustomer, Type = type, Date = date, Num = cols[3].Trim(), Memo = cols[4].Trim(), Account = cols[5].Trim(), Split = cols[7].Trim(), Amount = amount }); } return rows; } /// /// Infers the enum value from a QuickBooks account name string. /// /// QuickBooks records the bank/clearing account name on payment rows rather than a payment type. /// This method performs a keyword scan to map account names to payment methods: /// /// "Undeposited Funds" → (QB uses this for un-deposited cash/checks) /// PayPal / Venmo / Zelle / CashApp / Square → /// Credit / Debit / Visa / Mastercard / Amex → /// All other bank/checking accounts → (default for bank-routed payments) /// /// Returns for null/empty account names as a safe fallback. /// /// private static PaymentMethod DetectPaymentMethod(string accountName) { if (string.IsNullOrWhiteSpace(accountName)) return PaymentMethod.Cash; var lower = accountName.ToLowerInvariant(); if (lower.Contains("undeposited")) return PaymentMethod.Cash; if (lower.Contains("paypal") || lower.Contains("venmo") || lower.Contains("zelle") || lower.Contains("cashapp") || lower.Contains("square")) return PaymentMethod.DigitalPayment; if (lower.Contains("credit") || lower.Contains("debit") || lower.Contains("visa") || lower.Contains("mastercard") || lower.Contains("amex")) return PaymentMethod.CreditDebitCard; // Bank/checking accounts → Check or ACH; default to Check return PaymentMethod.Check; } #endregion #region QB Chart of Accounts Import /// /// Imports a QuickBooks Desktop IIF Chart of Accounts export and upserts records. /// /// Two-pass processing: Top-level accounts (no colon in NAME) are processed first, /// then sub-accounts. This ensures parent accounts always exist before their children attempt /// to set ParentAccountId, avoiding foreign-key violations even though QuickBooks exports /// them in a single flat list. /// /// /// Opening balance sign convention: QuickBooks exports credit-normal accounts (Revenue, /// Liability, Equity) with negative OBAMOUNT values (debits are positive in QB's /// general ledger view). This system stores OpeningBalance as a positive magnitude for /// all account types (sign direction is implied by AccountType), so the import takes /// Math.Abs for those account types. /// /// /// Upsert matching: Accounts are matched by account number first (most reliable), then /// by display name within the same account type. The type guard on name-matching prevents a /// sub-account (e.g. "Sales:Shop Supplies") from overwriting a top-level account with /// the same display name but a different type. /// /// /// Auto-numbering: Accounts that QB exported without an account number receive /// auto-assigned numbers from standard chart-of-accounts ranges (Assets 1000–1999, /// Liabilities 2000–2999, etc.) in increments of 10, avoiding collisions with any /// numbers already present in the DB or the import file. /// /// /// Non-posting accounts (type NONPOSTING) are skipped — these are QB internal /// ledgers for Estimates and Purchase Orders with no accounting significance. /// /// public async Task ImportChartOfAccountsAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { if (file == null || file.Length == 0) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." }); return result; } // Read all lines (IIF is tab-delimited) using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var allLines = new List(); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (!string.IsNullOrWhiteSpace(line)) allLines.Add(line); } // Find the !ACCNT header line to get column positions string[]? headers = null; var dataRows = new List(); foreach (var line in allLines) { var clean = line.TrimStart('\uFEFF'); var fields = clean.Split(TabDelimiter); if (fields[0].Equals("!ACCNT", StringComparison.OrdinalIgnoreCase)) { headers = fields; } else if (fields[0].Equals("ACCNT", StringComparison.OrdinalIgnoreCase) && headers != null) { dataRows.Add(fields); } } if (headers == null || dataRows.Count == 0) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "No ACCNT records found. Make sure this is a QuickBooks IIF file exported from Lists → Chart of Accounts." }); return result; } int idxName = Array.FindIndex(headers, h => h.Equals("NAME", StringComparison.OrdinalIgnoreCase)); int idxType = Array.FindIndex(headers, h => h.Equals("ACCNTTYPE", StringComparison.OrdinalIgnoreCase)); int idxBalance = Array.FindIndex(headers, h => h.Equals("OBAMOUNT", StringComparison.OrdinalIgnoreCase)); int idxDesc = Array.FindIndex(headers, h => h.Equals("DESC", StringComparison.OrdinalIgnoreCase)); int idxAccNum = Array.FindIndex(headers, h => h.Equals("ACCNUM", StringComparison.OrdinalIgnoreCase)); int idxHidden = Array.FindIndex(headers, h => h.Equals("HIDDEN", StringComparison.OrdinalIgnoreCase)); if (idxName < 0 || idxType < 0) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "Could not find required NAME or ACCNTTYPE columns in the IIF file." }); return result; } // Load existing accounts for upsert var existing = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted); var byNumber = existing .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) .ToDictionary(a => a.AccountNumber.Trim(), a => a, StringComparer.OrdinalIgnoreCase); var byQbName = existing .ToDictionary(a => a.Name.Trim(), a => a, StringComparer.OrdinalIgnoreCase); // Declared outside so they're accessible after the lambda (assigned at end of lambda) var qbNameToAccount = new Dictionary(StringComparer.OrdinalIgnoreCase); int imported = 0, updated = 0, skipped = 0; await _unitOfWork.ExecuteInTransactionAsync(async () => { // Reset all state so retries start clean qbNameToAccount.Clear(); imported = 0; updated = 0; skipped = 0; result.TotalRecords = 0; result.Errors.Clear(); // ── Auto-numbering for accounts that QB exported without a number ────────── // Build a set of every account number already in use (DB + explicit imports) // so generated numbers never collide with either source. var usedNumbers = existing .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) .Select(a => a.AccountNumber!.Trim()) .Where(n => int.TryParse(n, out _)) .Select(int.Parse) .ToHashSet(); if (idxAccNum >= 0) { foreach (var r in dataRows) { var n = GetField(r, idxAccNum); if (!string.IsNullOrEmpty(n) && int.TryParse(n, out var parsed)) usedNumbers.Add(parsed); } } // Standard chart-of-accounts ranges, incremented by 10 var typeRangeStart = new Dictionary { [AccountType.Asset] = 1000, [AccountType.Liability] = 2000, [AccountType.Equity] = 3000, [AccountType.Revenue] = 4000, [AccountType.CostOfGoods] = 5000, [AccountType.Expense] = 6000, }; var nextNumberByType = new Dictionary(); string AssignAccountNumber(AccountType t) { if (!typeRangeStart.TryGetValue(t, out var start)) return string.Empty; var end = start + 999; if (!nextNumberByType.ContainsKey(t)) { var maxUsed = usedNumbers.Where(n => n >= start && n <= end) .DefaultIfEmpty(start - 10).Max(); var seed = (maxUsed / 10) * 10 + 10; nextNumberByType[t] = seed < start ? start : seed; } while (usedNumbers.Contains(nextNumberByType[t]) && nextNumberByType[t] <= end) nextNumberByType[t] += 10; if (nextNumberByType[t] > end) return string.Empty; // range exhausted (extremely unlikely) usedNumbers.Add(nextNumberByType[t]); var num = nextNumberByType[t].ToString(); nextNumberByType[t] += 10; return num; } // ───────────────────────────────────────────────────────────────────────────── // Two passes: first top-level accounts, then sub-accounts var topLevel = dataRows.Where(r => !GetField(r, idxName).Contains(':')).ToList(); var subLevel = dataRows.Where(r => GetField(r, idxName).Contains(':')).ToList(); foreach (var pass in new[] { topLevel, subLevel }) { foreach (var row in pass) { result.TotalRecords++; var qbFullName = GetField(row, idxName); var accntType = GetField(row, idxType); var accNum = idxAccNum >= 0 ? GetField(row, idxAccNum) : string.Empty; var desc = idxDesc >= 0 ? GetField(row, idxDesc) : string.Empty; var balanceStr = idxBalance >= 0 ? GetField(row, idxBalance) : string.Empty; var hiddenStr = idxHidden >= 0 ? GetField(row, idxHidden) : "N"; // Skip non-posting accounts (Estimates, Purchase Orders, etc.) — QB internal use only if (accntType.Equals("NONPOSTING", StringComparison.OrdinalIgnoreCase)) { result.TotalRecords--; continue; } // Parse opening balance (QB wraps comma-formatted numbers in quotes) balanceStr = balanceStr.Trim('"').Replace(",", ""); decimal.TryParse(balanceStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var openingBalance); // Determine display name and parent string displayName; int? parentId = null; if (qbFullName.Contains(':')) { var lastColon = qbFullName.LastIndexOf(':'); var parentQbName = qbFullName[..lastColon]; displayName = qbFullName[(lastColon + 1)..]; if (qbNameToAccount.TryGetValue(parentQbName, out var parentAccount)) parentId = parentAccount.Id; } else { displayName = qbFullName; } var (accountType, subType) = MapQbAccountType(accntType, displayName); var isActive = !hiddenStr.Equals("Y", StringComparison.OrdinalIgnoreCase); // Auto-assign account number if QB didn't provide one if (string.IsNullOrEmpty(accNum)) accNum = AssignAccountNumber(accountType); // QB IIF exports credit-normal accounts (Revenue, Liability, Equity) with // negative balance values. Our system stores OpeningBalance as a positive // number for all account types (the sign direction is implied by account type). if (accountType is AccountType.Revenue or AccountType.Liability or AccountType.Equity) openingBalance = Math.Abs(openingBalance); // Upsert: match by account number first, then by display name. // Name match is only used when account types are compatible — prevents a // sub-account (e.g. "Sales:Shop Supplies") from overwriting a top-level // account with the same display name but a different type (e.g. "Shop Supplies" EXP). Account? account = null; if (!string.IsNullOrEmpty(accNum) && byNumber.TryGetValue(accNum, out var byNum)) account = byNum; else if (byQbName.TryGetValue(displayName, out var byName) && byName.AccountType == accountType) account = byName; if (account != null) { account.Name = displayName; account.AccountNumber = accNum; account.AccountType = accountType; account.AccountSubType = subType; account.Description = string.IsNullOrEmpty(desc) ? account.Description : desc; account.OpeningBalance = openingBalance; account.ParentAccountId = parentId ?? account.ParentAccountId; account.IsActive = isActive; account.UpdatedBy = userId; account.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Accounts.UpdateAsync(account); qbNameToAccount[qbFullName] = account; updated++; } else { account = new Account { CompanyId = companyId, Name = displayName, AccountNumber = accNum, AccountType = accountType, AccountSubType = subType, Description = string.IsNullOrEmpty(desc) ? null : desc, OpeningBalance = openingBalance, ParentAccountId = parentId, IsActive = isActive, IsSystem = false, CreatedBy = userId, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.Accounts.AddAsync(account); await _unitOfWork.CompleteAsync(); // flush to get Id for sub-account parent linking qbNameToAccount[qbFullName] = account; byNumber[accNum] = account; byQbName[displayName] = account; imported++; } } } await _unitOfWork.CompleteAsync(); result.ImportedCount = imported; result.UpdatedCount = updated; result.SkippedCount = skipped; result.Success = true; _logger.LogInformation( "Chart of Accounts import complete for company {CompanyId}: {Created} created, {Updated} updated, {Skipped} skipped", companyId, imported, updated, skipped); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing Chart of Accounts for company {CompanyId}", companyId); result.Success = false; result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Retrieves a field by positional index from an IIF data row array. /// Strips surrounding double-quotes (QuickBooks wraps fields containing commas) and trims whitespace. /// Returns when the index is out of bounds, allowing callers to /// use the result directly without bounds checking. /// private static string GetField(string[] row, int index) => index >= 0 && index < row.Length ? row[index].Trim('"').Trim() : string.Empty; /// /// Maps a QuickBooks IIF ACCNTTYPE string to the application's /// and enum pair. /// /// QuickBooks account type codes are fixed strings (e.g. BANK, AR, COGS, /// EXP). The mapping is exhaustive for all types documented in the QuickBooks IIF /// specification; unrecognised types default to Expense / Other as a safe fallback. /// /// /// For EXP and EXEXP (other expense) types, the account name is used to /// infer a more specific via . /// /// private static (AccountType type, AccountSubType subType) MapQbAccountType(string qbType, string name) { var n = name.ToLowerInvariant(); return qbType.ToUpperInvariant() switch { "BANK" => (AccountType.Asset, AccountSubType.Checking), "AR" => (AccountType.Asset, AccountSubType.AccountsReceivable), "OCASSET" => (AccountType.Asset, AccountSubType.OtherCurrentAsset), "FIXASSET" => (AccountType.Asset, AccountSubType.FixedAsset), "OASSET" => (AccountType.Asset, AccountSubType.OtherAsset), "AP" => (AccountType.Liability, AccountSubType.AccountsPayable), "CCARD" => (AccountType.Liability, AccountSubType.CreditCard), "OCLIAB" => (AccountType.Liability, AccountSubType.OtherCurrentLiability), "LTLIAB" => (AccountType.Liability, AccountSubType.LongTermLiability), "EQUITY" => (AccountType.Equity, AccountSubType.OwnersEquity), "INC" => (AccountType.Revenue, AccountSubType.Sales), "OTHERINC" => (AccountType.Revenue, AccountSubType.OtherIncome), "COGS" => (AccountType.CostOfGoods, AccountSubType.CostOfGoodsSold), "EXP" or "EXEXP" => (AccountType.Expense, MapExpenseSubType(n)), _ => (AccountType.Expense, AccountSubType.Other), }; } /// /// Infers an expense from an account name using keyword matching. /// /// QuickBooks Desktop exports all expense accounts with type EXP, losing the sub-type /// distinction that the application maintains. This heuristic restores a reasonable sub-type /// by scanning common keywords (rent, utilities, insurance, payroll, etc.) in the lowercased /// account name. Accounts that match no keyword default to . /// /// private static AccountSubType MapExpenseSubType(string nameLower) { if (nameLower.Contains("rent")) return AccountSubType.Rent; if (nameLower.Contains("util")) return AccountSubType.Utilities; if (nameLower.Contains("insur")) return AccountSubType.Insurance; if (nameLower.Contains("advertis") || nameLower.Contains("promot")) return AccountSubType.Advertising; if (nameLower.Contains("payroll")) return AccountSubType.Payroll; if (nameLower.Contains("professional") || nameLower.Contains("legal")) return AccountSubType.ProfessionalFees; if (nameLower.Contains("office")) return AccountSubType.OfficeSupplies; if (nameLower.Contains("depreci")) return AccountSubType.Depreciation; if (nameLower.Contains("bank") || nameLower.Contains("service charge")) return AccountSubType.BankCharges; if (nameLower.Contains("travel")) return AccountSubType.Travel; if (nameLower.Contains("meal") || nameLower.Contains("entertainment")) return AccountSubType.Meals; if (nameLower.Contains("vehicle") || nameLower.Contains("auto")) return AccountSubType.Vehicle; if (nameLower.Contains("shop") || nameLower.Contains("supplies") || nameLower.Contains("material") || nameLower.Contains("powder")) return AccountSubType.SuppliesMaterials; if (nameLower.Contains("equipment")) return AccountSubType.Equipment; return AccountSubType.Other; } #endregion #region QB Inventory Valuation Import /// /// Imports inventory stock levels and average costs from a QuickBooks Desktop /// "Inventory Valuation Summary" report exported as CSV. /// /// Format detection: QB exports this report as either tab-delimited (Desktop) or /// comma-delimited (other paths). The first 10 non-empty lines are probed for tabs; /// if any contain a tab, tab-delimited parsing is used for the entire file. /// /// /// Header-row discovery: QB Desktop reports include several metadata rows at the top /// (company name, report title, date range). The importer scans up to the first 20 rows /// looking for a row containing "On Hand" (or "On-Hand") to identify the actual column headers /// and dynamically maps column positions for On Hand, Avg Cost, and /// Pref Vendor. This handles layout variation between QB versions. /// /// /// Upsert logic: Existing items are updated (quantity, average cost, preferred vendor); /// new items are created with category "Powder" and unit of measure "lbs" as defaults /// appropriate for powder coating inventory. An is written /// for each item with a non-zero quantity (Initial type for new items, /// Adjustment for existing) to maintain a complete audit trail. /// /// /// Total rows: Lines whose name starts with "Total" or equals "TOTAL" are silently /// skipped; they are QuickBooks subtotal/grand-total structural rows, not inventory items. /// /// public async Task ImportQbInventoryValuationAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { if (file == null || file.Length == 0) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." }); return result; } var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (ext != ".csv") { result.Errors.Add(new ImportErrorDto { ErrorMessage = "File must be a .csv export from QuickBooks." }); return result; } // Load vendors for name-based lookup var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && !v.IsDeleted); var vendorByName = vendors.ToDictionary( v => v.CompanyName.Trim().ToLowerInvariant(), v => v, StringComparer.OrdinalIgnoreCase); // Load existing inventory items for upsert var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId && !i.IsDeleted); var itemByName = existingItems.ToDictionary( i => i.Name.Trim().ToLowerInvariant(), i => i, StringComparer.OrdinalIgnoreCase); // Read all lines — QB reports have several metadata rows before the actual column headers using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var allLines = new List(); string? rawLine; while ((rawLine = await reader.ReadLineAsync()) != null) allLines.Add(rawLine); // QB Desktop exports Inventory Valuation Summary as tab-delimited; other exports are comma-delimited. // Auto-detect by checking the first non-empty lines for tabs. char delim = ','; foreach (var probe in allLines.Take(10).Where(l => !string.IsNullOrWhiteSpace(l))) { if (probe.Contains('\t')) { delim = '\t'; break; } if (probe.Contains(',')) break; } // Dynamically find the column-header row by scanning for "On Hand" within the first 20 rows. // This handles QB exports regardless of how many metadata rows precede the data. int colName = 0, colPrefVendor = -1, colOnHand = -1, colAvgCost = -1; int dataStartRow = 1; // fallback: start after row 0 for (int scanIdx = 0; scanIdx < Math.Min(allLines.Count, 20); scanIdx++) { var scanFields = ParseDelimitedLine(allLines[scanIdx], delim); int onHandCol = scanFields.FindIndex( f => f.Trim().Equals("On Hand", StringComparison.OrdinalIgnoreCase) || f.Trim().Equals("On-Hand", StringComparison.OrdinalIgnoreCase)); if (onHandCol < 0) continue; // Found the header row — map all relevant columns colOnHand = onHandCol; for (int j = 0; j < scanFields.Count; j++) { var h = scanFields[j].Trim(); if (h.Equals("Pref Vendor", StringComparison.OrdinalIgnoreCase) || h.Equals("Preferred Vendor", StringComparison.OrdinalIgnoreCase)) colPrefVendor = j; else if (h.Equals("Avg Cost", StringComparison.OrdinalIgnoreCase) || h.Equals("Average Cost", StringComparison.OrdinalIgnoreCase)) colAvgCost = j; } dataStartRow = scanIdx + 1; break; } // Fallback column layout when the header row is not found: // QB report without Pref Vendor → [0]=name, [1]=OnHand, [2]=AvgCost, [3]=AssetValue // QB report with Pref Vendor → [0]=name, [1]=PrefVendor, [2]=OnHand, [3]=AvgCost // We can't tell which layout is used without a header, so assume no Pref Vendor. if (colOnHand < 0) { colOnHand = 1; colAvgCost = 2; } // colAvgCost may still be -1 if the header had "On Hand" but no "Avg Cost" label if (colAvgCost < 0) colAvgCost = colOnHand + 1; int lineNumber = dataStartRow; int importedCount = 0; int updatedCount = 0; int skippedCount = 0; await _unitOfWork.ExecuteInTransactionAsync(async () => { // Reset counters so retries don't double-count importedCount = 0; updatedCount = 0; skippedCount = 0; result.Errors.Clear(); for (int lineIdx = dataStartRow; lineIdx < allLines.Count; lineIdx++) { lineNumber = lineIdx + 1; var fields = ParseDelimitedLine(allLines[lineIdx], delim); // Helper: safely read a field by index string F(int idx) => (idx >= 0 && idx < fields.Count) ? (fields[idx].Trim()) : string.Empty; var itemName = F(colName); if (string.IsNullOrWhiteSpace(itemName)) continue; // Skip subtotal / grand total rows — QB structural, not inventory items if (itemName.StartsWith("Total", StringComparison.OrdinalIgnoreCase) || itemName.Equals("TOTAL", StringComparison.OrdinalIgnoreCase)) continue; var prefVendor = F(colPrefVendor); var onHandStr = F(colOnHand); var avgCostStr = F(colAvgCost); // Skip category/group header rows — name has text but all numeric columns are empty if (string.IsNullOrEmpty(onHandStr) && string.IsNullOrEmpty(avgCostStr)) continue; result.TotalRecords++; if (!decimal.TryParse(onHandStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var onHand)) onHand = 0; if (!decimal.TryParse(avgCostStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var avgCost)) avgCost = 0; // Vendor lookup by company name int? vendorId = null; if (!string.IsNullOrEmpty(prefVendor) && vendorByName.TryGetValue(prefVendor.ToLowerInvariant(), out var matchedVendor)) { vendorId = matchedVendor.Id; } var nameKey = itemName.ToLowerInvariant(); if (itemByName.TryGetValue(nameKey, out var existing)) { // Update existing item's stock levels and cost existing.QuantityOnHand = onHand; existing.AverageCost = avgCost; existing.UnitCost = avgCost; if (avgCost > 0) existing.LastPurchasePrice = avgCost; if (!string.IsNullOrEmpty(prefVendor)) existing.Manufacturer = prefVendor; if (vendorId.HasValue) existing.PrimaryVendorId = vendorId; existing.UpdatedBy = userId; existing.UpdatedAt = DateTime.UtcNow; await _unitOfWork.InventoryItems.UpdateAsync(existing); if (onHand != 0) { var tx = new InventoryTransaction { CompanyId = companyId, InventoryItemId = existing.Id, TransactionType = InventoryTransactionType.Adjustment, Quantity = onHand, UnitCost = avgCost, TotalCost = onHand * avgCost, BalanceAfter = onHand, TransactionDate = DateTime.UtcNow, Reference = "QB Import", Notes = "Stock balance updated from QuickBooks Inventory Valuation Summary", CreatedBy = userId, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.InventoryTransactions.AddAsync(tx); } updatedCount++; } else { // Create new inventory item var newItem = new InventoryItem { CompanyId = companyId, Name = itemName, ColorName = itemName, SKU = BuildInventorySku(itemName), Description = null, Category = "Powder", Manufacturer = string.IsNullOrEmpty(prefVendor) ? null : prefVendor, PrimaryVendorId = vendorId, QuantityOnHand = onHand, AverageCost = avgCost, UnitCost = avgCost, LastPurchasePrice = avgCost > 0 ? avgCost : 0, UnitOfMeasure = "lbs", ReorderPoint = 0, ReorderQuantity = 0, MinimumStock = 0, MaximumStock = 0, IsActive = true, CreatedBy = userId, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.InventoryItems.AddAsync(newItem); await _unitOfWork.CompleteAsync(); // flush to get the new item's Id if (onHand != 0) { var tx = new InventoryTransaction { CompanyId = companyId, InventoryItemId = newItem.Id, TransactionType = InventoryTransactionType.Initial, Quantity = onHand, UnitCost = avgCost, TotalCost = onHand * avgCost, BalanceAfter = onHand, TransactionDate = DateTime.UtcNow, Reference = "QB Import", Notes = "Opening balance imported from QuickBooks Inventory Valuation Summary", CreatedBy = userId, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.InventoryTransactions.AddAsync(tx); } itemByName[nameKey] = newItem; // prevent duplicate on re-upload importedCount++; } } await _unitOfWork.CompleteAsync(); result.ImportedCount = importedCount; result.UpdatedCount = updatedCount; result.SkippedCount = skippedCount; result.Success = true; _logger.LogInformation( "QB inventory valuation import complete for company {CompanyId}: {Created} created, {Updated} updated, {Skipped} skipped", companyId, importedCount, updatedCount, skippedCount); }); } catch (Exception ex) { _logger.LogError(ex, "Error importing QB inventory valuation for company {CompanyId}", companyId); result.Success = false; result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Generates a simple URL-safe SKU from an inventory item name for QuickBooks-imported items. /// /// QuickBooks inventory items do not always have SKUs. This method produces a deterministic /// prefix-based SKU (e.g. "Wet Black""INV-WET-BLACK") so the new record /// has a non-null SKU while being recognisably QB-origin. Non-alphanumeric characters (except /// hyphens) are stripped to ensure the SKU is safe for use in URLs, barcodes, and labels. /// /// private static string BuildInventorySku(string name) { // e.g. "Wet Black" → "INV-WET-BLACK" var slug = name.ToUpperInvariant() .Replace(" - ", "-") .Replace(" ", "-"); // Strip any characters that aren't letters, digits or hyphens slug = new string(slug.Where(c => char.IsLetterOrDigit(c) || c == '-').ToArray()); return $"INV-{slug}"; } #endregion #region QB Bills Import /// /// Imports vendor bills from a QuickBooks Desktop "Vendor Balance Detail" or /// "Expense by Vendor Detail" CSV export and creates records. /// /// Pre-flight requirements: Vendors and Chart of Accounts must be imported first. /// Bills are linked to vendors by name (with a starts-with fallback for partial name matches) /// and to AP/expense accounts by account number or name via . /// /// /// Format auto-detection: Three CSV layouts are supported, detected from the header row: /// /// VBD no memo (≤7 columns): blank, Type, Date, Num, AP Account, Amount, Balance /// VBD with memo (8 columns, has "balance" and "memo" but no "split"): blank, Type, Date, Num, Memo, AP Account, Amount, Balance /// Expense by Vendor Detail (9+ columns): blank, Type, Date, Num, Memo, Expense Account, ?, Split/AP, Amount, ... /// /// /// /// Row filtering: Only rows with type "Bill" are processed; Bill Pmt, Item Receipt, /// Credit, and other QB row types are silently skipped (handled by other importers or irrelevant). /// Zero-amount bill rows are also skipped as they are QB artifacts. /// /// /// Bill numbering: Internal bill numbers are generated sequentially with the format /// BILL-YYMM-####. The QB vendor invoice number is preserved in VendorInvoiceNumber. /// /// public async Task ImportQbBillsAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { if (file == null || file.Length == 0) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." }); return result; } // Read all lines using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var lines = new List(); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (!string.IsNullOrWhiteSpace(line)) lines.Add(line); } if (lines.Count < 2) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "File is empty or has no data rows." }); return result; } // Load reference data var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && !v.IsDeleted); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted); var vendorByName = vendors.ToDictionary(v => v.CompanyName.Trim(), v => v, StringComparer.OrdinalIgnoreCase); // Account lookups: by number and by name var accountByNumber = accounts .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) .ToDictionary(a => a.AccountNumber.Trim(), a => a, StringComparer.OrdinalIgnoreCase); var accountByName = accounts .GroupBy(a => a.Name.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); // Default AP account fallback var defaultApAccount = accounts.FirstOrDefault(a => a.AccountSubType == Core.Enums.AccountSubType.AccountsPayable && a.IsActive); // Bill number generation counter var billPrefix = $"BILL-{DateTime.Now:yyMM}-"; var lastBill = await _unitOfWork.Bills.FindAsync( b => b.CompanyId == companyId && b.BillNumber.StartsWith(billPrefix), ignoreQueryFilters: true); int nextBillSeq = 1; if (lastBill.Any()) { var maxNum = lastBill .Select(b => b.BillNumber[billPrefix.Length..]) .Where(s => int.TryParse(s, out _)) .Select(int.Parse) .DefaultIfEmpty(0) .Max(); nextBillSeq = maxNum + 1; } string NextBillNumber() => $"{billPrefix}{nextBillSeq++:D4}"; await _unitOfWork.ExecuteInTransactionAsync(async () => { // Auto-detect format from header row // VBD no memo (7 cols): blank, Type, Date, Num, Account(AP), Amount, Balance // VBD + memo (8 cols): blank, Type, Date, Num, Memo, Account(AP), Amount, Balance // EVD (9+ cols): blank, Type, Date, Num, Memo, Expense Account, ?, Split/AP, Amount, ... var headerCols = ParseCsvLine(lines[0]).ToArray(); var headerLower = lines[0].ToLowerInvariant(); bool isVbdWithMemo = headerLower.Contains("balance") && headerLower.Contains("memo") && !headerLower.Contains("split"); bool isVendorBalanceDetail = !isVbdWithMemo && (headerCols.Length <= 8 || (headerLower.Contains("balance") && !headerLower.Contains("split"))); string? currentVendorName = null; int lineNum = 0; int imported = 0, skipped = 0; // Skip header row (line 0) foreach (var rawLine in lines.Skip(1)) { lineNum++; var colsList = ParseCsvLine(rawLine); var cols = colsList.ToArray(); int minCols = isVendorBalanceDetail ? 6 : isVbdWithMemo ? 7 : 9; if (cols.Length < minCols) continue; var col0 = cols[0].Trim().Trim('"'); var type = cols.Length > 1 ? cols[1].Trim().Trim('"') : string.Empty; var dateStr = cols.Length > 2 ? cols[2].Trim().Trim('"') : string.Empty; var numStr = cols.Length > 3 ? cols[3].Trim().Trim('"') : string.Empty; // Column mapping differs by format // VBD no memo: col[4]=AP Account, col[5]=Amount // VBD + memo: col[4]=Memo, col[5]=AP Account, col[6]=Amount // EVD: col[4]=Memo, col[5]=Expense Account, col[7]=Split/AP Account, col[8]=Amount string memo = isVendorBalanceDetail ? string.Empty : (cols.Length > 4 ? cols[4].Trim().Trim('"') : string.Empty); string acctRaw = (isVendorBalanceDetail || isVbdWithMemo) ? string.Empty : (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty); string splitRaw = isVendorBalanceDetail ? (cols.Length > 4 ? cols[4].Trim().Trim('"') : string.Empty) : isVbdWithMemo ? (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty) : (cols.Length > 7 ? cols[7].Trim().Trim('"') : string.Empty); string amtStr = isVendorBalanceDetail ? (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty) : isVbdWithMemo ? (cols.Length > 6 ? cols[6].Trim().Trim('"') : string.Empty) : (cols.Length > 8 ? cols[8].Trim().Trim('"') : string.Empty); // Vendor header row: col 0 has content, col 1 is empty if (!string.IsNullOrEmpty(col0) && string.IsNullOrEmpty(type)) { // Skip Total rows if (col0.StartsWith("Total ", StringComparison.OrdinalIgnoreCase)) continue; currentVendorName = col0; continue; } // Only process Bill rows — everything else (Bill Pmt, Item Receipt, Credit, etc.) // is either handled by another importer or QB-internal; skip all silently. if (!type.Equals("Bill", StringComparison.OrdinalIgnoreCase)) continue; result.TotalRecords++; // Parse date if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var billDate)) { result.Errors.Add(new ImportErrorDto { Severity = "Error", LineNumber = lineNum, RecordName = currentVendorName, ErrorMessage = $"Could not parse date '{dateStr}'." }); continue; } // Parse amount decimal.TryParse(amtStr.Replace(",", ""), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount); if (amount == 0) continue; // Zero-amount bill rows are QB artifacts — silently ignore // Resolve vendor if (currentVendorName == null || !vendorByName.TryGetValue(currentVendorName, out var vendor)) { // Try starts-with fallback vendor = currentVendorName != null ? vendorByName.Values.FirstOrDefault(v => v.CompanyName.StartsWith(currentVendorName, StringComparison.OrdinalIgnoreCase) || currentVendorName.StartsWith(v.CompanyName, StringComparison.OrdinalIgnoreCase)) : null; if (vendor == null) { result.Errors.Add(new ImportErrorDto { Severity = "Error", LineNumber = lineNum, RecordName = currentVendorName ?? "(unknown)", ErrorMessage = $"Vendor '{currentVendorName}' not found. Import vendors first." }); continue; } } // Resolve expense account from Account column (e.g. "67100 · Rent Expense") // Not available in Vendor Balance Detail format — that's OK, AccountId will be null on the line item. var expenseAccount = string.IsNullOrEmpty(acctRaw) ? null : ResolveAccountFromQbString(acctRaw, accountByNumber, accountByName); if (!isVendorBalanceDetail && expenseAccount == null && !string.IsNullOrEmpty(acctRaw)) { result.Errors.Add(new ImportErrorDto { Severity = "Error", LineNumber = lineNum, RecordName = currentVendorName, FieldName = "Account", ErrorMessage = $"Could not find account for '{acctRaw}'." }); continue; } // Resolve AP account from Split/Account column; fall back to default AP account var apAccount = ResolveAccountFromQbString(splitRaw, accountByNumber, accountByName) ?? defaultApAccount; if (apAccount == null) { result.Errors.Add(new ImportErrorDto { Severity = "Error", LineNumber = lineNum, RecordName = currentVendorName, FieldName = "Split (AP Account)", ErrorMessage = "No Accounts Payable account found. Import Chart of Accounts first." }); continue; } // Create Bill + BillLineItem in one shot (no mid-loop flush needed) var bill = new Core.Entities.Bill { CompanyId = companyId, BillNumber = NextBillNumber(), VendorInvoiceNumber = string.IsNullOrEmpty(numStr) ? null : numStr, VendorId = vendor.Id, APAccountId = apAccount.Id, BillDate = billDate, Status = Core.Enums.BillStatus.Open, Memo = string.IsNullOrEmpty(memo) ? null : memo, SubTotal = amount, TaxPercent = 0, TaxAmount = 0, Total = amount, AmountPaid = 0, CreatedBy = userId, CreatedAt = DateTime.UtcNow, }; bill.LineItems.Add(new Core.Entities.BillLineItem { CompanyId = companyId, AccountId = expenseAccount?.Id, Description = string.IsNullOrEmpty(memo) ? (expenseAccount?.Name ?? "Imported from QuickBooks") : memo, Quantity = 1, UnitPrice = amount, Amount = amount, DisplayOrder = 0, CreatedBy = userId, CreatedAt = DateTime.UtcNow, }); await _unitOfWork.Bills.AddAsync(bill); imported++; } await _unitOfWork.CompleteAsync(); result.ImportedCount = imported; result.SkippedCount = skipped; result.Success = true; }); // end ExecuteInTransactionAsync } catch (Exception ex) { result.Success = false; result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } /// /// Resolves an Account from a QB-style string. /// Handles formats: /// "67100 · Rent Expense" (number + middle-dot separator + name) /// "67100 - Rent Expense" (number + dash + name) /// "Rent Expense" (name only) /// "Expenses:Rent Expense" (QB sub-account path — tries last segment as name) /// "67100" (number only) /// private static Core.Entities.Account? ResolveAccountFromQbString( string raw, Dictionary byNumber, Dictionary byName) { if (string.IsNullOrWhiteSpace(raw)) return null; var trimmed = raw.Trim(); // Try account number: take the first token if it's all digits. // Format: "67100 · Rent Expense" or "67100 - Rent Expense" or "67100 Rent Expense" var firstSpace = trimmed.IndexOf(' '); if (firstSpace > 0) { var potentialNumber = trimmed[..firstSpace].Trim(); if (potentialNumber.All(char.IsDigit) && byNumber.TryGetValue(potentialNumber, out var byNum)) return byNum; } // Try the whole string as a number (e.g., "67100" with no name part) if (trimmed.All(char.IsDigit) && byNumber.TryGetValue(trimmed, out var numOnly)) return numOnly; // Strip leading "NNNNN" to isolate the account name. // \W+ handles any separator regardless of encoding (·, -, –, replacement chars, etc.) var namePart = System.Text.RegularExpressions.Regex.Replace(trimmed, @"^\d+\W+", "").Trim(); if (!string.IsNullOrEmpty(namePart) && namePart != trimmed) { if (byName.TryGetValue(namePart, out var byNm)) return byNm; } // Handle QB sub-account paths like "Expenses:Rent" or "Cost of Goods:Materials". // Try the last colon-segment as the account name, then try progressively shorter paths. if (trimmed.Contains(':')) { var segments = trimmed.Split(':'); // Try last segment first (most specific), then last two, etc. for (int i = segments.Length - 1; i >= 0; i--) { var candidate = string.Join(":", segments.Skip(i)).Trim(); // Strip any leading number prefix from the candidate var candidateName = System.Text.RegularExpressions.Regex.Replace(candidate, @"^\d+\W+", "").Trim(); if (!string.IsNullOrEmpty(candidateName) && byName.TryGetValue(candidateName, out var byPath)) return byPath; if (byName.TryGetValue(candidate, out var byPathRaw)) return byPathRaw; } } // Last resort: try the raw value as a name return byName.TryGetValue(trimmed, out var direct) ? direct : null; } #endregion #region QB Vendor Payments Import /// /// Imports vendor payment records from a QuickBooks Desktop "Vendor Balance Detail" CSV export /// and creates records linked to previously-imported bills. /// /// Pre-flight requirements: Vendors, Chart of Accounts, and Bills must be imported first. /// Payments are applied FIFO to open/partially-paid bills for each vendor. /// /// /// Smart bill matching: Rather than pure FIFO, the importer first checks for an exact /// balance-match within a 180-day date proximity window (most likely a 1:1 payment to a specific /// bill). If no exact match is found, it falls back to FIFO within the proximity window, then /// to global FIFO across all open bills. This heuristic significantly reduces mismatched payments /// on real-world data where QB payment rows lack an explicit bill reference number. /// /// /// Format auto-detection: Supports the same three VBD/EVD layouts as /// . VBD formats have no bank account column; the importer /// falls back to the company's primary checking account (sorted by account number for /// deterministic selection when multiple checking accounts exist). /// /// /// Payment amounts: QuickBooks exports payment amounts as negative values (debits to cash). /// The importer takes the absolute value for the field. /// Zero-amount rows and amounts that exceed remaining open bills are logged as warnings but /// do not fail the import. /// /// /// Payment number sequence: Internal payment numbers use the format BPMT-YYMM-####. /// /// public async Task ImportQbVendorPaymentsAsync(IFormFile file, int companyId, string userId) { var result = new ImportResultDto(); try { if (file == null || file.Length == 0) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." }); return result; } using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var lines = new List(); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (!string.IsNullOrWhiteSpace(line)) lines.Add(line); } if (lines.Count < 2) { result.Errors.Add(new ImportErrorDto { ErrorMessage = "File is empty or has no data rows." }); return result; } // Load reference data var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && !v.IsDeleted); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted); var vendorByName = vendors.ToDictionary(v => v.CompanyName.Trim(), v => v, StringComparer.OrdinalIgnoreCase); var accountByNumber = accounts .Where(a => !string.IsNullOrEmpty(a.AccountNumber)) .ToDictionary(a => a.AccountNumber.Trim(), a => a, StringComparer.OrdinalIgnoreCase); var accountByName = accounts .GroupBy(a => a.Name.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); // Fallback bank account: Vendor Balance Detail doesn't include the payment bank account column. // Prefer accounts that have an account number (established accounts) over unnumbered ones // to avoid non-deterministic FirstOrDefault results when multiple checking accounts exist. var defaultBankAccount = accounts .Where(a => a.IsActive && a.AccountSubType == Core.Enums.AccountSubType.Checking) .OrderBy(a => string.IsNullOrEmpty(a.AccountNumber) ? 1 : 0) .ThenBy(a => a.AccountNumber) .FirstOrDefault() ?? accounts .Where(a => a.IsActive && a.AccountSubType == Core.Enums.AccountSubType.Savings) .OrderBy(a => string.IsNullOrEmpty(a.AccountNumber) ? 1 : 0) .ThenBy(a => a.AccountNumber) .FirstOrDefault(); // Payment number sequence var pmtPrefix = $"BPMT-{DateTime.Now:yyMM}-"; var lastPmt = await _unitOfWork.BillPayments.FindAsync( p => p.CompanyId == companyId && p.PaymentNumber.StartsWith(pmtPrefix), ignoreQueryFilters: true); int nextPmtSeq = 1; if (lastPmt.Any()) { var maxNum = lastPmt .Select(p => p.PaymentNumber[pmtPrefix.Length..]) .Where(s => int.TryParse(s, out _)) .Select(int.Parse) .DefaultIfEmpty(0) .Max(); nextPmtSeq = maxNum + 1; } string NextPaymentNumber() => $"{pmtPrefix}{nextPmtSeq++:D4}"; // Auto-detect payment format from header // VBD no memo (7 cols): blank, Type, Date, Num, Account(AP), Amount, Balance // VBD + memo (8 cols): blank, Type, Date, Num, Memo, Account(AP), Amount, Balance // Old format (8+ cols): blank, Type, Date, Num, Memo, Account(bank), ..., Amount var pmtHeaderCols = ParseCsvLine(lines[0]).ToArray(); var pmtHeaderLower = lines[0].ToLowerInvariant(); bool pmtIsVbdWithMemo = pmtHeaderCols.Length == 8 && pmtHeaderLower.Contains("balance") && pmtHeaderLower.Contains("memo") && !pmtHeaderLower.Contains("split"); bool pmtIsVbdNoMemo = !pmtIsVbdWithMemo && (pmtHeaderCols.Length <= 7 || (pmtHeaderLower.Contains("balance") && !pmtHeaderLower.Contains("memo") && !pmtHeaderLower.Contains("split"))); await _unitOfWork.ExecuteInTransactionAsync(async () => { string? currentVendorName = null; Vendor? currentVendor = null; // Open bills for the current vendor, sorted FIFO List? vendorBills = null; int lineNum = 0; int imported = 0, skipped = 0; foreach (var rawLine in lines.Skip(1)) { lineNum++; var colsList = ParseCsvLine(rawLine); var cols = colsList.ToArray(); if (cols.Length < 6) continue; var col0 = cols[0].Trim().Trim('"'); var type = cols.Length > 1 ? cols[1].Trim().Trim('"') : string.Empty; var dateStr = cols.Length > 2 ? cols[2].Trim().Trim('"') : string.Empty; var numStr = cols.Length > 3 ? cols[3].Trim().Trim('"') : string.Empty; // VBD no memo: col[4]=Account (AP acct), col[5]=Amount — no memo, no bank account column // VBD + memo: col[4]=Memo, col[5]=Account(AP), col[6]=Amount — no bank account column // Old format: col[4]=Memo, col[5]=Account (bank acct), col[8]=Amount var memo = pmtIsVbdNoMemo ? string.Empty : (cols.Length > 4 ? cols[4].Trim().Trim('"') : string.Empty); var acctRaw = (pmtIsVbdNoMemo || pmtIsVbdWithMemo) ? string.Empty : (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty); var amtStr = pmtIsVbdNoMemo ? (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty) : pmtIsVbdWithMemo ? (cols.Length > 6 ? cols[6].Trim().Trim('"') : string.Empty) : (cols.Length > 8 ? cols[8].Trim().Trim('"') : cols.Length > 7 ? cols[7].Trim().Trim('"') : string.Empty); // Vendor header row if (!string.IsNullOrEmpty(col0) && string.IsNullOrEmpty(type)) { if (col0.StartsWith("Total ", StringComparison.OrdinalIgnoreCase)) continue; currentVendorName = col0; vendorByName.TryGetValue(currentVendorName, out currentVendor); if (currentVendor == null) { // Try starts-with fallback currentVendor = vendorByName.Values.FirstOrDefault(v => v.CompanyName.StartsWith(currentVendorName, StringComparison.OrdinalIgnoreCase) || currentVendorName.StartsWith(v.CompanyName, StringComparison.OrdinalIgnoreCase)); } // Load open/partially-paid bills for this vendor, oldest first if (currentVendor != null) { var bills = await _unitOfWork.Bills.FindAsync( b => b.CompanyId == companyId && b.VendorId == currentVendor.Id && (b.Status == Core.Enums.BillStatus.Open || b.Status == Core.Enums.BillStatus.PartiallyPaid)); vendorBills = bills.OrderBy(b => b.BillDate).ThenBy(b => b.Id).ToList(); } else { vendorBills = null; } continue; } // Only process Bill Pmt rows if (!type.StartsWith("Bill Pmt", StringComparison.OrdinalIgnoreCase)) { // Only Bill Pmt rows are relevant here; everything else (Bill, Item Receipt, Credit, etc.) // is handled by another importer or QB-internal — skip all silently. continue; } result.TotalRecords++; // Validate vendor if (currentVendor == null) { result.Errors.Add(new ImportErrorDto { Severity = "Error", LineNumber = lineNum, RecordName = currentVendorName ?? "(unknown)", ErrorMessage = $"Vendor '{currentVendorName}' not found — payment skipped." }); continue; } // Parse date if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var pmtDate)) { result.Errors.Add(new ImportErrorDto { Severity = "Error", LineNumber = lineNum, RecordName = currentVendorName, ErrorMessage = $"Could not parse date '{dateStr}'." }); continue; } // Amount is negative in QB export — take absolute value for payment amount decimal.TryParse(amtStr.Replace(",", ""), NumberStyles.Any, CultureInfo.InvariantCulture, out var pmtAmountRaw); var pmtAmount = Math.Abs(pmtAmountRaw); if (pmtAmount == 0) continue; // Zero-amount payments are QB credit memo artifacts — silently ignore // Resolve bank account; VBD format has no bank account column — fall back to default checking account var bankAccount = string.IsNullOrEmpty(acctRaw) ? defaultBankAccount : (ResolveAccountFromQbString(acctRaw, accountByNumber, accountByName) ?? defaultBankAccount); if (bankAccount == null) { result.Errors.Add(new ImportErrorDto { Severity = "Error", LineNumber = lineNum, RecordName = currentVendorName, FieldName = "Account", ErrorMessage = "No checking/bank account found. Please import Chart of Accounts first." }); continue; } // Determine payment method from type string var paymentMethod = type.Contains("CCard", StringComparison.OrdinalIgnoreCase) ? Core.Enums.PaymentMethod.CreditDebitCard : Core.Enums.PaymentMethod.Check; // Apply FIFO across open bills for this vendor if (vendorBills == null || vendorBills.Count == 0) { skipped++; result.Errors.Add(new ImportErrorDto { Severity = "Skipped", LineNumber = lineNum, RecordName = currentVendorName, ErrorMessage = $"No open bills found for vendor '{currentVendorName}' — payment of {pmtAmount:C} skipped." }); continue; } decimal remaining = pmtAmount; // Smart payment-to-bill matching: // 1. Exact remaining-balance match within date proximity window (payment date // >= bill date and <= bill date + 180 days) — most likely a 1:1 payment // 2. FIFO within the date window (covers partial / multi-bill payments) // 3. Global FIFO fallback for very old or pre-dated payments const int proximityDays = 180; var exactWindowMatch = vendorBills.FirstOrDefault(b => { var bal = b.Total - b.AmountPaid; var daysDiff = (pmtDate.Date - b.BillDate.Date).TotalDays; return bal > 0 && Math.Abs(bal - remaining) < 0.01m && daysDiff >= 0 && daysDiff <= proximityDays; }); IEnumerable matchQueue; if (exactWindowMatch != null) { // Single exact match — apply the whole payment here matchQueue = new[] { exactWindowMatch }; } else { // Bills whose date is within the proximity window, oldest first var windowedBills = vendorBills .Where(b => { var bal = b.Total - b.AmountPaid; var daysDiff = (pmtDate.Date - b.BillDate.Date).TotalDays; return bal > 0 && daysDiff >= 0 && daysDiff <= proximityDays; }) .OrderBy(b => b.BillDate) .ToList(); // Fall back to global FIFO only when no bills fall in the window matchQueue = windowedBills.Count > 0 ? (IEnumerable)windowedBills : vendorBills.Where(b => b.Total - b.AmountPaid > 0); } foreach (var bill in matchQueue.ToList()) { if (remaining <= 0) break; var billBalance = bill.Total - bill.AmountPaid; if (billBalance <= 0) { vendorBills.Remove(bill); continue; } var applyAmount = Math.Min(remaining, billBalance); var payment = new Core.Entities.BillPayment { CompanyId = companyId, PaymentNumber = NextPaymentNumber(), BillId = bill.Id, VendorId = currentVendor.Id, BankAccountId = bankAccount.Id, PaymentDate = pmtDate, Amount = applyAmount, PaymentMethod = paymentMethod, CheckNumber = string.IsNullOrEmpty(numStr) ? null : numStr, Memo = string.IsNullOrEmpty(memo) ? null : memo, CreatedBy = userId, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.BillPayments.AddAsync(payment); bill.AmountPaid += applyAmount; bill.Status = bill.AmountPaid >= bill.Total ? Core.Enums.BillStatus.Paid : Core.Enums.BillStatus.PartiallyPaid; await _unitOfWork.Bills.UpdateAsync(bill); remaining -= applyAmount; imported++; if (bill.Status == Core.Enums.BillStatus.Paid) vendorBills.Remove(bill); } if (remaining > 0.01m) { result.Errors.Add(new ImportErrorDto { Severity = "Warning", LineNumber = lineNum, RecordName = currentVendorName, ErrorMessage = $"Payment of {pmtAmount:C} on {pmtDate:MM/dd/yyyy} — {remaining:C} could not be applied (no more open bills). Excess ignored." }); } } await _unitOfWork.CompleteAsync(); result.ImportedCount = imported; result.SkippedCount = skipped; result.Success = true; }); // end ExecuteInTransactionAsync } catch (Exception ex) { result.Success = false; result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Import failed: {ex.Message}" }); } return result; } #endregion }