5034 lines
229 KiB
C#
5034 lines
229 KiB
C#
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 1000–1999,
|
||
/// Liabilities 2000–2999, 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
|
||
}
|