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:
/// COMPANYNAME → NAME → FIRSTNAME + 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:
/// COMPANYNAME → PRINTAS → NAME → FIRSTNAME + 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): COMPANYNAME → NAME → FIRSTNAME + 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
/// HIDDEN → IsActive 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
}