Files
PowderCoatingLogix/src/PowderCoating.Application/Services/QuickBooksIifService.cs
T
2026-04-23 21:38:24 -04:00

5034 lines
229 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Implements QuickBooks IIF (Intuit Interchange Format) export and import for QuickBooks Desktop,
/// plus CSV import support for QuickBooks Online exports.
/// <para>
/// IIF format is tab-delimited text where header definition rows start with <c>!</c> (e.g. <c>!CUST</c>)
/// and corresponding data rows use the same prefix without the <c>!</c> (e.g. <c>CUST</c>).
/// Sign conventions must be observed: QuickBooks expects credit-normal accounts (Revenue, AP, Equity)
/// with opposite-sign values to debit-normal accounts.
/// </para>
/// <para>
/// Import flow: Customers → Vendors → Chart of Accounts → Invoices → Transactions → Bills → Vendor Payments.
/// Each step requires reference data from prior steps to be present.
/// </para>
/// </summary>
public class QuickBooksIifService : IQuickBooksIifService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<QuickBooksIifService> _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" };
/// <summary>
/// Initializes the service with required infrastructure dependencies.
/// </summary>
public QuickBooksIifService(IUnitOfWork unitOfWork, ILogger<QuickBooksIifService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
#region Export Methods
/// <summary>
/// Exports all active customers for a company as a QuickBooks Desktop IIF file (<c>!CUST</c> / <c>CUST</c> records).
/// <para>
/// The <c>TAXABLE</c> field uses an inverted boolean: QuickBooks stores whether the customer IS taxable,
/// while the application stores <c>IsTaxExempt</c>. Therefore <c>TAXABLE = N</c> when <c>IsTaxExempt = true</c>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
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<byte>(), 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<byte>(), string.Empty, $"Error exporting customers: {ex.Message}");
}
}
/// <summary>
/// Exports active catalog items for a company as a QuickBooks Desktop IIF file using the <c>SERV</c>
/// (Service Item) record type, since powder coating line items are service-based, not physical inventory.
/// <para>
/// The <c>HIDDEN</c> field is the inverse of <c>IsActive</c>: <c>HIDDEN=Y</c> means the item is suppressed
/// in QuickBooks, so only inactive items get <c>HIDDEN=Y</c>.
/// </para>
/// <para>
/// Category names are included in the <c>EXTRA</c> column as a reference comment; QuickBooks Desktop
/// does not natively support service-item categories in IIF, so this is informational only.
/// </para>
/// </summary>
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<byte>(), 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<byte>(), string.Empty, $"Error exporting catalog items: {ex.Message}");
}
}
/// <summary>
/// Exports active customers as a QuickBooks Online (QBO) CSV file.
/// <para>
/// QBO uses a different import format from QuickBooks Desktop IIF: comma-delimited with a
/// <c>*Customer</c> 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.
/// </para>
/// <para>
/// Customer display name priority: <c>CompanyName</c> (if set) → <c>ContactFirstName ContactLastName</c>.
/// </para>
/// </summary>
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<byte>(), 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<byte>(), string.Empty, $"Error exporting customers: {ex.Message}");
}
}
/// <summary>
/// Exports active catalog items as a QuickBooks Online (QBO) CSV file using the <c>ServiceItem</c> row marker.
/// <para>
/// All items default to non-taxable (<c>No</c>) 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.
/// </para>
/// <para>
/// Eager-loads <c>Category</c> and <c>Category.ParentCategory</c> to support QBO's hierarchical
/// category display without additional per-item round-trips.
/// </para>
/// </summary>
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<byte>(), 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<byte>(), string.Empty, $"Error exporting catalog items: {ex.Message}");
}
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// Dispatches a customer import file to the appropriate parser based on file extension.
/// <para>
/// Supported formats:
/// <list type="bullet">
/// <item><description><c>.xls</c> / <c>.xlsx</c> — QuickBooks Online customer export (Excel)</description></item>
/// <item><description><c>.iif</c> / <c>.txt</c> — QuickBooks Desktop IIF export</description></item>
/// </list>
/// Returns an error (not an exception) for unsupported extensions so the UI can render
/// a user-friendly message without a 500 response.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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;
}
/// <summary>
/// Parses a QuickBooks Desktop IIF file and upserts customers for the given company.
/// <para>
/// IIF files may contain multiple sections (e.g. <c>!HDR</c>, <c>!CUSTNAMEDICT</c>) before the
/// <c>!CUST</c> section. The parser scans for the first line that starts with exactly
/// <c>"!CUST\t"</c> (tab after CUST) to avoid false-matching <c>!CUSTNAMEDICT</c>.
/// </para>
/// <para>
/// Customer identity is matched by <c>CompanyName</c> 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 <c>ExecuteInTransactionAsync</c> so a mid-import failure
/// rolls back all rows rather than leaving partial data.
/// </para>
/// <para>
/// Company name is resolved with a three-tier fallback:
/// <c>COMPANYNAME</c> → <c>NAME</c> → <c>FIRSTNAME + LASTNAME</c>.
/// </para>
/// </summary>
private async Task<ImportResultDto> 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<string>();
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;
}
/// <summary>
/// Parses a QuickBooks Online Excel export and upserts customers for the given company.
/// <para>
/// QBO exports column headers like "Company name", "Street Address", "Open balance" — these differ
/// from the Desktop IIF field names (<c>COMPANYNAME</c>, <c>BADDR1</c>, <c>LIMIT</c>).
/// <see cref="GetColumnValue"/> handles missing columns gracefully.
/// </para>
/// <para>
/// Duplicate email detection is done in-memory (via a <c>HashSet</c>) 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.
/// </para>
/// <para>
/// <c>IsCommercial</c> is inferred: customers with a non-empty <c>Company name</c> column are
/// treated as commercial (B2B); individual-name-only rows are treated as non-commercial.
/// </para>
/// </summary>
private async Task<ImportResultDto> 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<string>(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;
}
/// <summary>
/// Safely reads a named column from an Excel <see cref="System.Data.DataRow"/>.
/// Returns <see cref="string.Empty"/> 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.
/// </summary>
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;
}
/// <summary>
/// Dispatches a catalog-item import file to the appropriate parser based on file extension.
/// <para>
/// Supported formats:
/// <list type="bullet">
/// <item><description><c>.xls</c> / <c>.xlsx</c> — QuickBooks Online "Products and Services" Excel export</description></item>
/// <item><description><c>.csv</c> — QuickBooks Online CSV export</description></item>
/// <item><description><c>.iif</c> / <c>.txt</c> — QuickBooks Desktop IIF export (INVITEM records)</description></item>
/// </list>
/// Validation (file size, extension) is run before routing so all three parsers can assume
/// a valid, non-empty file.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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);
}
}
/// <summary>
/// Parses a QuickBooks Desktop IIF file and upserts catalog items for the given company.
/// <para>
/// QuickBooks exports all item types as <c>INVITEM</c> records with an <c>INVITEMTYPE</c> discriminator.
/// Only <c>SERV</c> (service) items are imported because powder coating line items are services,
/// not physical inventory. Types like <c>DISC</c>, <c>PMT</c>, <c>GRP</c>, and <c>OTHC</c>
/// are QuickBooks-internal and have no equivalent in this system.
/// </para>
/// <para>
/// QuickBooks uses a colon-delimited hierarchy in the <c>NAME</c> field (e.g. <c>"Cerakote:Auto:Item"</c>)
/// to represent item groups. The last segment is the item name; all preceding segments become the
/// category path via <see cref="GetOrCreateCategoryFromSkuHierarchy"/>.
/// </para>
/// <para>
/// Items without a description (<c>DESC</c> empty) are silently skipped because they are
/// QuickBooks group/header markers, not actual billable items.
/// </para>
/// </summary>
private async Task<ImportResultDto> 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<string>();
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;
}
/// <summary>
/// Parses a QuickBooks Online "Products and Services" Excel export and upserts catalog items.
/// <para>
/// QBO exports variant product hierarchies with a <c>"Single,parent or variant?"</c> column.
/// Only <c>Single</c> and <c>Parent</c> rows are imported; child <c>variant</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// In-file duplicate detection uses a <c>HashSet</c> keyed on <c>itemName|categoryId</c>
/// (declared inside the transaction lambda so retries reset it).
/// </para>
/// </summary>
private async Task<ImportResultDto> 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<string>(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;
}
/// <summary>
/// Parses a QuickBooks Online "Products and Services" CSV export and upserts catalog items.
/// <para>
/// CSV rows are read entirely into memory before entering the transaction lambda so that the
/// <see cref="CsvReader"/> 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.
/// </para>
/// <para>
/// Applies the same variant/type filtering as <c>ImportCatalogItemsFromExcelAsync</c>.
/// </para>
/// </summary>
private async Task<ImportResultDto> 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<Dictionary<string, string>>();
while (csv.Read())
{
var csvRow = new Dictionary<string, string>(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<string>(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;
}
/// <summary>
/// Gets or creates a flat (top-level) category by name.
/// Used for QBO exports where category is a simple name, not a hierarchy.
/// </summary>
private async Task<int> 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
/// <summary>
/// Validates a customer import file before any data is written to the database.
/// <para>
/// Checks performed (in order):
/// <list type="number">
/// <item><description>File is non-null and non-empty.</description></item>
/// <item><description>Extension is in the allowed list (<c>.iif</c>, <c>.txt</c>, <c>.xls</c>, <c>.xlsx</c>, <c>.csv</c>).</description></item>
/// <item><description>File size does not exceed 50 MB.</description></item>
/// <item><description>For IIF/TXT files only: the first 100 lines contain a <c>!CUST\t</c> header row.</description></item>
/// </list>
/// 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.
/// </para>
/// </summary>
public async Task<ValidationResultDto> 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;
}
/// <summary>
/// Validates a catalog-item import file before any data is written to the database.
/// <para>
/// Applies the same size/extension checks as <see cref="ValidateCustomerFileAsync"/>.
/// Excel (<c>.xls</c>, <c>.xlsx</c>) and CSV (<c>.csv</c>) 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 <c>!INVITEM\t</c> header row.
/// </para>
/// </summary>
public async Task<ValidationResultDto> 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
/// <summary>
/// Exports all active vendors for a company as a QuickBooks Desktop IIF file (<c>!VEND</c> / <c>VEND</c> records).
/// <para>
/// QuickBooks Desktop IIF uses <c>ADDR1</c> for street address and <c>ADDR2</c> for the
/// city/state/zip line (formatted as "City, ST Zip"), which differs from the application's
/// separate <c>City</c>, <c>State</c>, <c>ZipCode</c> fields. The export combines the three
/// fields into the QB <c>ADDR2</c> format.
/// </para>
/// </summary>
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<byte>(), 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<string>();
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<byte>(), string.Empty, $"Error exporting vendors: {ex.Message}");
}
}
#endregion
#region Vendor Import
/// <summary>
/// Parses a QuickBooks Desktop IIF file and upserts vendors for the given company.
/// <para>
/// Vendor name is resolved with a four-tier fallback:
/// <c>COMPANYNAME</c> → <c>PRINTAS</c> → <c>NAME</c> → <c>FIRSTNAME + LASTNAME</c>.
/// This mirrors how QuickBooks assigns vendor display names depending on whether the vendor is
/// a business or an individual.
/// </para>
/// <para>
/// Vendors whose <c>HIDDEN</c> field is <c>Y</c> (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.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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<string>();
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;
}
/// <summary>
/// Constructs a new <see cref="Vendor"/> entity from a parsed IIF data row.
/// <para>
/// QuickBooks Desktop packs city/state/zip into <c>ADDR2</c> as a single string in the format
/// <c>"City, ST Zip"</c>. 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.
/// </para>
/// <para>
/// Empty email is stored as <c>null</c> rather than an empty string to avoid triggering the
/// database's unique-email constraint when multiple vendors have no email address on file.
/// </para>
/// <para>
/// Credit limit of 0 is stored as <c>null</c> because QB exports a <c>0</c> when no credit
/// limit is configured, and the application distinguishes between "no limit" (null) and "limit
/// is explicitly zero" which would block all purchases.
/// </para>
/// </summary>
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
};
}
/// <summary>
/// Updates an existing <see cref="Vendor"/> entity in-place from a parsed IIF data row.
/// <para>
/// 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.
/// </para>
/// <para>
/// Uses the same ADDR2 city/state/zip parsing logic as <see cref="CreateVendorFromIif"/>.
/// </para>
/// </summary>
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
/// <summary>
/// Sanitises a string value for safe inclusion in a tab-delimited IIF field.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Retrieves a named field from a parsed IIF data row by performing a positional lookup
/// against the corresponding header row.
/// <para>
/// The lookup is case-sensitive because QuickBooks IIF field names are always upper-case.
/// Returns <see cref="string.Empty"/> (not null) when the field is not present so callers
/// can use string comparisons without null checks.
/// </para>
/// <para>
/// Applies <see cref="UnquoteIifField"/> so callers receive clean values even when QuickBooks
/// wrapped the field in double-quotes (e.g. addresses containing commas).
/// </para>
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private static string UnquoteIifField(string value)
{
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
return value[1..^1];
return value;
}
/// <summary>
/// Attempts to parse a decimal value from a string, returning <c>0</c> 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.
/// </summary>
private decimal ParseDecimal(string value)
{
if (string.IsNullOrWhiteSpace(value))
return 0;
if (decimal.TryParse(value, out var result))
return result;
return 0;
}
/// <summary>
/// Constructs a new <see cref="Customer"/> entity from a parsed IIF <c>CUST</c> data row.
/// <para>
/// Company name resolution (three-tier fallback): <c>COMPANYNAME</c> → <c>NAME</c> → <c>FIRSTNAME + LASTNAME</c>.
/// </para>
/// <para>
/// Contact name is read from <c>CONT1</c>/<c>CONT2</c> when available (QB business contacts);
/// falling back to <c>FIRSTNAME</c>/<c>LASTNAME</c> for individual customers where QB stores
/// the person's name in those fields.
/// </para>
/// <para>
/// <c>IsTaxExempt</c> is the logical inverse of the IIF <c>TAXABLE</c> field: <c>TAXABLE=N</c>
/// means the customer is not taxable → is tax exempt.
/// </para>
/// <para>
/// <c>IsCommercial</c> is inferred: customers with a non-empty <c>COMPANYNAME</c> field are
/// commercial (B2B); individuals without a company name are non-commercial.
/// </para>
/// <para>
/// Notes are read from <c>NOTEPAD</c> first (longer notes field), falling back to <c>NOTE</c>.
/// </para>
/// </summary>
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
};
}
/// <summary>
/// Updates an existing <see cref="Customer"/> entity in-place from a parsed IIF <c>CUST</c> data row.
/// <para>
/// 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).
/// </para>
/// <para>
/// Applies the same <c>TAXABLE</c> inversion and <c>NOTEPAD → NOTE</c> fallback as
/// <see cref="CreateCustomerFromIif"/>.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Constructs a new <see cref="CatalogItem"/> entity from a parsed IIF <c>INVITEM</c> data row.
/// <para>
/// The <c>NAME</c> field in QuickBooks represents the colon-delimited hierarchy path (e.g.
/// <c>"Cerakote:Auto:Exhaust"</c>), not a human-readable item name. The human-readable name
/// is stored in <c>DESC</c>. When DESC contains <c>" - "</c>, it is split into name and
/// description (e.g. <c>"Sandblast - Small parts sandblasting service"</c> → name="Sandblast",
/// description="Small parts sandblasting service").
/// </para>
/// <para>
/// SKU is intentionally set to <c>null</c>: the QB <c>NAME</c> field (hierarchy path) is
/// used for category resolution only, not as a catalog SKU.
/// </para>
/// <para>
/// <c>IsActive</c> is the inverse of <c>HIDDEN</c>: <c>HIDDEN=Y</c> → inactive in this system.
/// </para>
/// </summary>
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
};
}
/// <summary>
/// Updates an existing <see cref="CatalogItem"/> entity in-place from a parsed IIF <c>INVITEM</c> data row.
/// <para>
/// Applies the same DESC splitting logic as <see cref="CreateCatalogItemFromIif"/> and the same
/// <c>HIDDEN</c> → <c>IsActive</c> inversion. Price and active state are always overwritten;
/// category is updated by the caller (not this method) before it is invoked.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Parses a QuickBooks colon-delimited item hierarchy (e.g. <c>"Cerakote:Automotive:Exhaust:Item Name"</c>)
/// and ensures the full category path exists in the database, creating any missing levels.
/// Returns the ID of the deepest (leaf) category node.
/// <para>
/// The last colon-segment is the item name, not a category: <c>["Cerakote", "Automotive", "Exhaust"]</c>
/// become categories; <c>"Item Name"</c> is handled by the caller.
/// </para>
/// <para>
/// Each level is flushed immediately (<c>CompleteAsync</c>) after creation so that its database-assigned
/// <c>Id</c> is available as the <c>ParentCategoryId</c> for the next level.
/// </para>
/// <para>
/// Items with no colon hierarchy (flat names) fall back to the default "Imported Items" category
/// via <see cref="GetOrCreateDefaultCategory"/> rather than creating spurious top-level categories.
/// </para>
/// </summary>
private async Task<int> 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;
}
/// <summary>
/// Gets or creates the catch-all top-level "Imported Items" category used for QuickBooks items
/// that have no colon-delimited hierarchy in their <c>NAME</c> field.
/// <para>
/// 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 <c>DisplayOrder</c> (999) to appear at the bottom of category lists.
/// </para>
/// </summary>
private async Task<int> 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<QbInvoiceLine> Lines { get; } = new();
/// <summary>Amount already paid as-of the QB export. 0 if unknown (detail format didn't track payments).</summary>
public decimal AmountPaid { get; set; }
/// <summary>Individual payment applications for Payment record creation on import.</summary>
public List<QbPaymentRecord> 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 ────────────────────────────────────────────────────────
/// <summary>
/// Imports historical QuickBooks invoices from a QB Desktop CSV export and creates corresponding
/// <see cref="Invoice"/> records in the application database.
/// <para>
/// <b>Pre-flight requirement:</b> 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").
/// </para>
/// <para>
/// <b>Duplicate prevention:</b> QuickBooks invoice numbers are stored in <see cref="Invoice.ExternalReference"/>.
/// Re-running the import skips any invoice whose QB number is already present in the DB.
/// </para>
/// <para>
/// <b>Invoice numbering:</b> 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 <c>ExternalReference</c>.
/// </para>
/// <para>
/// <b>Payment records:</b> Payment history is intentionally NOT created here. The importer sets
/// <c>AmountPaid</c> and <c>Status</c> on the invoice but leaves Payment record creation to
/// <see cref="ImportQbTransactionsFromCsvAsync"/>, which has richer data (check numbers, exact
/// dates, bank account names).
/// </para>
/// <para>
/// <b>Format auto-detection:</b> 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".
/// </para>
/// </summary>
public async Task<ImportResultDto> 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<string, Customer>(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<string, CatalogItem>(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<QbInvoiceGroup> 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 ──────────────────────────────────────────────────────────────
/// <summary>
/// Detects which QuickBooks Desktop CSV export format the content represents and dispatches to
/// the appropriate parser.
/// <para>
/// QB Desktop can export invoice data in at least two different layouts:
/// <list type="bullet">
/// <item><description><b>Sales by Customer / Invoice Detail</b> (10+ columns): includes individual line-item rows
/// with Item, Qty, SalesPrice, and Amount columns.</description></item>
/// <item><description><b>Customer Balance Detail</b> (7 columns): summarises open invoices and payment rows
/// without line-item detail.</description></item>
/// </list>
/// 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.
/// </para>
/// </summary>
private static List<QbInvoiceGroup> 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<QbInvoiceGroup>();
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);
}
/// <summary>
/// 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.
/// </summary>
private static List<QbInvoiceGroup> ParseQbBalanceDetailCsv(string[] lines)
{
var groups = new Dictionary<string, QbInvoiceGroup>(StringComparer.OrdinalIgnoreCase);
var orderedKeys = new List<string>();
string currentCustomer = "";
// Open invoices for the current customer in order (for FIFO payment matching)
var openInvoiceNums = new List<string>();
// Payments that arrived before any invoice for this customer (prepayments / deposits)
var pendingPayments = new List<QbPaymentRecord>();
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();
}
/// <summary>
/// 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
/// </summary>
/// <summary>
/// Parses the "Sales by Customer" / Invoice Detail CSV export from QuickBooks Desktop.
/// Groups individual line rows into <see cref="QbInvoiceGroup"/> objects keyed by QB invoice number.
/// <para>
/// 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 <see cref="QbInvoiceLine"/>
/// entries. <c>AmountPaid</c> is left at 0 — this format does not include payment rows;
/// payment reconciliation happens in <see cref="ParseQbBalanceDetailCsv"/> or during the
/// transaction import step.
/// </para>
/// </summary>
private static List<QbInvoiceGroup> ParseQbInvoiceDetailCsv(string[] lines)
{
var groups = new Dictionary<string, QbInvoiceGroup>(StringComparer.OrdinalIgnoreCase);
var orderedKeys = new List<string>();
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();
}
/// <summary>
/// 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 "...".
/// </summary>
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();
}
/// <summary>
/// Parses a single RFC 4180-compliant CSV line into a list of field values.
/// Delegates to <see cref="ParseDelimitedLine"/> with <c>','</c> as the delimiter.
/// </summary>
private static List<string> ParseCsvLine(string line) => ParseDelimitedLine(line, ',');
/// <summary>
/// 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 (<c>""</c>) inside a quoted field represents a literal double-quote.
/// <para>
/// Used for both comma-delimited CSV and tab-delimited IIF lines (via the <paramref name="delimiter"/>
/// parameter) so that the same robust parsing logic covers all input formats handled by this service.
/// </para>
/// </summary>
private static List<string> ParseDelimitedLine(string line, char delimiter)
{
var fields = new List<string>();
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
// -----------------------------------------------------------------------
/// <summary>
/// Imports QuickBooks payment transactions from a "Customer Transaction Detail" CSV export
/// and creates <see cref="Payment"/> records linked to previously-imported invoices.
/// <para>
/// <b>Pre-flight requirement:</b> QB-sourced invoices (those with a non-null
/// <see cref="Invoice.ExternalReference"/>) 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.
/// </para>
/// <para>
/// <b>Matching strategy:</b> 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.
/// </para>
/// <para>
/// <b>Already-settled invoices:</b> When an invoice was imported with <c>AmountPaid</c>
/// 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 <c>AmountPaid</c> or <c>Status</c>.
/// </para>
/// <para>
/// <b>Session payment tracking:</b> Because EF does not immediately reflect newly-added
/// Payment records in <c>invoice.Payments</c> within the same request, the importer
/// maintains a <c>sessionPaymentsAdded</c> dictionary to track the running total of payments
/// added during this import run, enabling correct duplicate detection.
/// </para>
/// <para>
/// <b>Item description enrichment:</b> When an Invoice row has a non-generic <c>Memo</c>
/// value, the first invoice line item's description is updated from the generic
/// "QB Invoice #NNN" placeholder to the actual memo text.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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<string>(); // 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<string, decimal>(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<QbTransactionRow>();
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
// -----------------------------------------------------------------------
/// <summary>
/// Parses the QuickBooks Desktop "Customer Transaction Detail" CSV report into a flat list
/// of <see cref="QbTransactionRow"/> objects.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// The report columns are: blank, Type, Date, Num, Memo, Account, Clr, Split, Amount.
/// </para>
/// </summary>
private static List<QbTransactionRow> ParseQbTransactionsCsv(string content)
{
var rows = new List<QbTransactionRow>();
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;
}
/// <summary>
/// Infers the <see cref="PaymentMethod"/> enum value from a QuickBooks account name string.
/// <para>
/// 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:
/// <list type="bullet">
/// <item><description>"Undeposited Funds" → <see cref="PaymentMethod.Cash"/> (QB uses this for un-deposited cash/checks)</description></item>
/// <item><description>PayPal / Venmo / Zelle / CashApp / Square → <see cref="PaymentMethod.DigitalPayment"/></description></item>
/// <item><description>Credit / Debit / Visa / Mastercard / Amex → <see cref="PaymentMethod.CreditDebitCard"/></description></item>
/// <item><description>All other bank/checking accounts → <see cref="PaymentMethod.Check"/> (default for bank-routed payments)</description></item>
/// </list>
/// Returns <see cref="PaymentMethod.Cash"/> for null/empty account names as a safe fallback.
/// </para>
/// </summary>
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
/// <summary>
/// Imports a QuickBooks Desktop IIF Chart of Accounts export and upserts <see cref="Account"/> records.
/// <para>
/// <b>Two-pass processing:</b> Top-level accounts (no colon in <c>NAME</c>) are processed first,
/// then sub-accounts. This ensures parent accounts always exist before their children attempt
/// to set <c>ParentAccountId</c>, avoiding foreign-key violations even though QuickBooks exports
/// them in a single flat list.
/// </para>
/// <para>
/// <b>Opening balance sign convention:</b> QuickBooks exports credit-normal accounts (Revenue,
/// Liability, Equity) with negative <c>OBAMOUNT</c> values (debits are positive in QB's
/// general ledger view). This system stores <c>OpeningBalance</c> as a positive magnitude for
/// all account types (sign direction is implied by <c>AccountType</c>), so the import takes
/// <c>Math.Abs</c> for those account types.
/// </para>
/// <para>
/// <b>Upsert matching:</b> 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. <c>"Sales:Shop Supplies"</c>) from overwriting a top-level account with
/// the same display name but a different type.
/// </para>
/// <para>
/// <b>Auto-numbering:</b> Accounts that QB exported without an account number receive
/// auto-assigned numbers from standard chart-of-accounts ranges (Assets 10001999,
/// Liabilities 20002999, etc.) in increments of 10, avoiding collisions with any
/// numbers already present in the DB or the import file.
/// </para>
/// <para>
/// <b>Non-posting accounts</b> (type <c>NONPOSTING</c>) are skipped — these are QB internal
/// ledgers for Estimates and Purchase Orders with no accounting significance.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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<string>();
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<string[]>();
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<string, Account>(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, int>
{
[AccountType.Asset] = 1000,
[AccountType.Liability] = 2000,
[AccountType.Equity] = 3000,
[AccountType.Revenue] = 4000,
[AccountType.CostOfGoods] = 5000,
[AccountType.Expense] = 6000,
};
var nextNumberByType = new Dictionary<AccountType, int>();
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;
}
/// <summary>
/// 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 <see cref="string.Empty"/> when the index is out of bounds, allowing callers to
/// use the result directly without bounds checking.
/// </summary>
private static string GetField(string[] row, int index)
=> index >= 0 && index < row.Length ? row[index].Trim('"').Trim() : string.Empty;
/// <summary>
/// Maps a QuickBooks IIF <c>ACCNTTYPE</c> string to the application's <see cref="AccountType"/>
/// and <see cref="AccountSubType"/> enum pair.
/// <para>
/// QuickBooks account type codes are fixed strings (e.g. <c>BANK</c>, <c>AR</c>, <c>COGS</c>,
/// <c>EXP</c>). The mapping is exhaustive for all types documented in the QuickBooks IIF
/// specification; unrecognised types default to <c>Expense / Other</c> as a safe fallback.
/// </para>
/// <para>
/// For <c>EXP</c> and <c>EXEXP</c> (other expense) types, the account name is used to
/// infer a more specific <see cref="AccountSubType"/> via <see cref="MapExpenseSubType"/>.
/// </para>
/// </summary>
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),
};
}
/// <summary>
/// Infers an expense <see cref="AccountSubType"/> from an account name using keyword matching.
/// <para>
/// QuickBooks Desktop exports all expense accounts with type <c>EXP</c>, 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 <see cref="AccountSubType.Other"/>.
/// </para>
/// </summary>
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
/// <summary>
/// Imports inventory stock levels and average costs from a QuickBooks Desktop
/// "Inventory Valuation Summary" report exported as CSV.
/// <para>
/// <b>Format detection:</b> 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.
/// </para>
/// <para>
/// <b>Header-row discovery:</b> 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 <c>On Hand</c>, <c>Avg Cost</c>, and
/// <c>Pref Vendor</c>. This handles layout variation between QB versions.
/// </para>
/// <para>
/// <b>Upsert logic:</b> 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 <see cref="InventoryTransaction"/> is written
/// for each item with a non-zero quantity (<c>Initial</c> type for new items,
/// <c>Adjustment</c> for existing) to maintain a complete audit trail.
/// </para>
/// <para>
/// <b>Total rows:</b> Lines whose name starts with "Total" or equals "TOTAL" are silently
/// skipped; they are QuickBooks subtotal/grand-total structural rows, not inventory items.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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>();
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;
}
/// <summary>
/// Generates a simple URL-safe SKU from an inventory item name for QuickBooks-imported items.
/// <para>
/// QuickBooks inventory items do not always have SKUs. This method produces a deterministic
/// prefix-based SKU (e.g. <c>"Wet Black"</c> → <c>"INV-WET-BLACK"</c>) 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.
/// </para>
/// </summary>
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
/// <summary>
/// Imports vendor bills from a QuickBooks Desktop "Vendor Balance Detail" or
/// "Expense by Vendor Detail" CSV export and creates <see cref="Core.Entities.Bill"/> records.
/// <para>
/// <b>Pre-flight requirements:</b> 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 <see cref="ResolveAccountFromQbString"/>.
/// </para>
/// <para>
/// <b>Format auto-detection:</b> Three CSV layouts are supported, detected from the header row:
/// <list type="bullet">
/// <item><description><b>VBD no memo</b> (≤7 columns): blank, Type, Date, Num, AP Account, Amount, Balance</description></item>
/// <item><description><b>VBD with memo</b> (8 columns, has "balance" and "memo" but no "split"): blank, Type, Date, Num, Memo, AP Account, Amount, Balance</description></item>
/// <item><description><b>Expense by Vendor Detail</b> (9+ columns): blank, Type, Date, Num, Memo, Expense Account, ?, Split/AP, Amount, ...</description></item>
/// </list>
/// </para>
/// <para>
/// <b>Row filtering:</b> 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.
/// </para>
/// <para>
/// <b>Bill numbering:</b> Internal bill numbers are generated sequentially with the format
/// <c>BILL-YYMM-####</c>. The QB vendor invoice number is preserved in <c>VendorInvoiceNumber</c>.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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<string>();
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;
}
/// <summary>
/// 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)
/// </summary>
private static Core.Entities.Account? ResolveAccountFromQbString(
string raw,
Dictionary<string, Core.Entities.Account> byNumber,
Dictionary<string, Core.Entities.Account> 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<any non-letter chars>" 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
/// <summary>
/// Imports vendor payment records from a QuickBooks Desktop "Vendor Balance Detail" CSV export
/// and creates <see cref="Core.Entities.BillPayment"/> records linked to previously-imported bills.
/// <para>
/// <b>Pre-flight requirements:</b> Vendors, Chart of Accounts, and Bills must be imported first.
/// Payments are applied FIFO to open/partially-paid bills for each vendor.
/// </para>
/// <para>
/// <b>Smart bill matching:</b> 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.
/// </para>
/// <para>
/// <b>Format auto-detection:</b> Supports the same three VBD/EVD layouts as
/// <see cref="ImportQbBillsAsync"/>. 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).
/// </para>
/// <para>
/// <b>Payment amounts:</b> QuickBooks exports payment amounts as negative values (debits to cash).
/// The importer takes the absolute value for the <see cref="Core.Entities.BillPayment.Amount"/> field.
/// Zero-amount rows and amounts that exceed remaining open bills are logged as warnings but
/// do not fail the import.
/// </para>
/// <para>
/// <b>Payment number sequence:</b> Internal payment numbers use the format <c>BPMT-YYMM-####</c>.
/// </para>
/// </summary>
public async Task<ImportResultDto> 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<string>();
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<Core.Entities.Bill>? 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<Core.Entities.Bill> 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<Core.Entities.Bill>)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
}