using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using OfficeOpenXml; using PowderCoating.Application.DTOs.QuickBooks; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Web.Services; /// /// Imports data exported from QuickBooks Online (.xlsx or .csv files). /// Handles the flexible column/header structure QBO produces across its various report exports. /// The service lives in the Web project (not Infrastructure) because it depends on /// , which is an ASP.NET Core abstraction not suitable for /// a pure infrastructure library. /// public class QuickBooksOnlineService { private readonly ApplicationDbContext _db; private readonly ITenantContext _tenantContext; private readonly ILogger _logger; public QuickBooksOnlineService( ApplicationDbContext db, ITenantContext tenantContext, ILogger logger) { _db = db; _tenantContext = tenantContext; _logger = logger; ExcelPackage.LicenseContext = LicenseContext.NonCommercial; } // ── Public entry points ─────────────────────────────────────────────────── /// /// Imports customers from a QBO Customer Contact List export (.xlsx). /// Matches existing customers by normalized display name; updates contact fields for /// existing records and inserts new ones. Balance is imported read-only and not used /// to update to avoid overwriting live AR data. /// public Task ImportCustomersAsync(IFormFile file, int companyId, string userId) => RunImport(file, companyId, userId, ProcessCustomers); /// /// Imports vendors from a QBO Vendor Contact List export (.xlsx). /// Matches by normalized company display name; updates contact fields on existing records. /// public Task ImportVendorsAsync(IFormFile file, int companyId, string userId) => RunImport(file, companyId, userId, ProcessVendors); /// /// Imports products and services from a QBO Products and Services export (.xlsx). /// QBO "Inventory" type items are mapped to ; all other /// service/non-inventory types are mapped to . Uses a /// two-pass save to ensure category rows receive real database IDs before item rows /// reference them via foreign key. /// public Task ImportCatalogItemsAsync(IFormFile file, int companyId, string userId) => RunImport(file, companyId, userId, ProcessCatalogItems); /// /// Imports the chart of accounts from a QBO Chart of Accounts export (.xlsx). /// Uses a two-pass save strategy: pass 1 upserts all accounts without parent links so /// every row gets a database ID, then pass 2 wires up ParentAccountId relationships. /// This avoids self-referential FK violations when both a parent and its sub-account are new. /// public Task ImportChartOfAccountsAsync(IFormFile file, int companyId, string userId) => RunImport(file, companyId, userId, ProcessChartOfAccounts); /// /// Imports invoices from a QBO Invoice List export (.xlsx). /// De-duplicates by ExternalReference (the original QBO invoice number) so the same /// file can be re-uploaded safely. Because the QBO Invoice List is summary-only (no line /// items), a single catch-all is synthesized for each invoice. /// Existing payments implied by a non-zero "Open Balance" are also imported as /// records using as a /// neutral placeholder — the actual method is unknown from QBO exports. /// public Task ImportInvoicesAsync(IFormFile file, int companyId, string userId) => RunImportAsync(file, companyId, userId, ProcessInvoicesAsync); /// /// Imports payment transactions from a QBO Transaction List by Date export (.xlsx). /// Filters rows to Payment/Receipt types only, then attempts to match each payment to /// an existing invoice using two strategies: (1) exact ExternalReference match on /// the transaction number, then (2) customer name + outstanding balance/total amount. /// Duplicate payments (same date + amount already recorded) are silently skipped. /// Unmatched payments are recorded as warnings in . /// public Task ImportTransactionsAsync(IFormFile file, int companyId, string userId) => RunImport(file, companyId, userId, ProcessTransactions); // ── Generic runner ──────────────────────────────────────────────────────── /// /// Reads the uploaded file into memory, opens it with EPPlus, and delegates processing /// to the supplied synchronous delegate. /// All exceptions are caught and returned as a failed so /// the controller never receives an unhandled 500 during an import operation. /// private async Task RunImport( IFormFile file, int companyId, string userId, Func processor) { try { using var stream = new MemoryStream(); await file.CopyToAsync(stream); stream.Position = 0; using var package = new ExcelPackage(stream); var ws = package.Workbook.Worksheets.FirstOrDefault(); if (ws == null) return Fail("The file appears to be empty or could not be parsed."); return processor(ws, companyId, userId); } catch (Exception ex) { _logger.LogError(ex, "QBO import error for company {CompanyId}", companyId); var msg = ex.InnerException?.Message ?? ex.Message; return Fail($"Import failed: {msg}"); } } /// /// Async variant of for processors that require awaitable /// operations inside their row loops (e.g., invoice number generation via DB query). /// private async Task RunImportAsync( IFormFile file, int companyId, string userId, Func> processor) { try { using var stream = new MemoryStream(); await file.CopyToAsync(stream); stream.Position = 0; using var package = new ExcelPackage(stream); var ws = package.Workbook.Worksheets.FirstOrDefault(); if (ws == null) return Fail("The file appears to be empty or could not be parsed."); return await processor(ws, companyId, userId); } catch (Exception ex) { _logger.LogError(ex, "QBO import error for company {CompanyId}", companyId); var msg = ex.InnerException?.Message ?? ex.Message; return Fail($"Import failed: {msg}"); } } // ── Processors ──────────────────────────────────────────────────────────── /// /// Parses a QBO Customer Contact List worksheet and upserts records. /// Uses IgnoreQueryFilters() so that soft-deleted customers from a previous import /// are included in the duplicate check — this prevents ghost re-inserts when a record was /// soft-deleted and the same customer appears in a fresh export. /// The "Customer" column in QBO contains the display name (which may be a company name or /// a personal name), while separate "First Name"/"Last Name" columns carry the contact name. /// private ImportResultDto ProcessCustomers(ExcelWorksheet ws, int companyId, string userId) { var result = new ImportResultDto(); var (headerRow, colMap) = FindHeaderRow(ws, new[] { "customer", "name", "company", "first name", "last name", "email", "phone", "billing street", "city", "state", "zip", "balance", "terms" }); if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Customer Contact List → Export to Excel."); var existing = _db.Customers .IgnoreQueryFilters() .Where(c => c.CompanyId == companyId) .ToList() .ToDictionary(c => Normalize(c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}"), c => c); int lastRow = ws.Dimension?.End.Row ?? 0; for (int row = headerRow + 1; row <= lastRow; row++) { var rawName = Cell(ws, row, colMap, "customer", "name"); var company = Cell(ws, row, colMap, "company", "company name"); var firstName = Cell(ws, row, colMap, "first name"); var lastName = Cell(ws, row, colMap, "last name"); var email = StripLabel(Cell(ws, row, colMap, "email", "email address")); var phone = StripLabel(Cell(ws, row, colMap, "phone", "phone number", "mobile")); var street = Cell(ws, row, colMap, "billing street", "address"); var city = Cell(ws, row, colMap, "city", "billing city"); var state = Cell(ws, row, colMap, "state", "billing state"); var zip = Cell(ws, row, colMap, "zip", "postal code", "billing zip", "billing postal code"); var country = Cell(ws, row, colMap, "country", "billing country"); var terms = Cell(ws, row, colMap, "terms", "payment terms"); var balanceRaw = Cell(ws, row, colMap, "balance", "open balance"); // Display name — QBO uses "Customer" column for the full display name var displayName = string.IsNullOrWhiteSpace(rawName) ? company : rawName; if (string.IsNullOrWhiteSpace(displayName)) { displayName = $"{firstName} {lastName}".Trim(); } if (string.IsNullOrWhiteSpace(displayName) || IsMetadataRow(displayName)) { result.SkippedCount++; continue; } // For company field: if no dedicated company column, use display name if (string.IsNullOrWhiteSpace(company)) company = displayName; result.TotalRecords++; decimal.TryParse(balanceRaw?.Replace("$", "").Replace(",", ""), out var balance); var key = Normalize(displayName); if (existing.TryGetValue(key, out var cust)) { // Update if (!string.IsNullOrWhiteSpace(email)) cust.Email = email; if (!string.IsNullOrWhiteSpace(phone)) cust.Phone = phone; if (!string.IsNullOrWhiteSpace(street)) cust.Address = street; if (!string.IsNullOrWhiteSpace(city)) cust.City = city; if (!string.IsNullOrWhiteSpace(state)) cust.State = state; if (!string.IsNullOrWhiteSpace(zip)) cust.ZipCode = zip; if (!string.IsNullOrWhiteSpace(country)) cust.Country = country; if (!string.IsNullOrWhiteSpace(terms)) cust.PaymentTerms = terms; cust.UpdatedAt = DateTime.UtcNow; result.UpdatedCount++; } else { // Insert var newCust = new Customer { CompanyId = companyId, CompanyName = company, ContactFirstName = firstName ?? "", ContactLastName = lastName ?? "", Email = email ?? "", Phone = phone ?? "", Address = street ?? "", City = city ?? "", State = state ?? "", ZipCode = zip ?? "", Country = country ?? "", PaymentTerms = terms ?? "", IsCommercial = !string.IsNullOrWhiteSpace(company) && company != displayName, IsActive = true, CurrentBalance = balance, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }; _db.Customers.Add(newCust); existing[key] = newCust; result.ImportedCount++; } } _db.SaveChanges(); result.Success = result.TotalRecords > 0; return result; } /// /// Parses a QBO Vendor Contact List worksheet and upserts records. /// Deduplication key is the normalized company display name. Only non-blank contact fields /// from the import overwrite existing values, preserving any enrichment done inside the app. /// private ImportResultDto ProcessVendors(ExcelWorksheet ws, int companyId, string userId) { var result = new ImportResultDto(); var (headerRow, colMap) = FindHeaderRow(ws, new[] { "company display name", "vendor", "name", "company", "email", "phone", "city", "state", "zip", "terms" }); if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Vendor Contact List → Export to Excel."); var existing = _db.Vendors .IgnoreQueryFilters() .Where(v => v.CompanyId == companyId) .ToList() .ToDictionary(v => Normalize(v.CompanyName ?? ""), v => v); int lastRow = ws.Dimension?.End.Row ?? 0; for (int row = headerRow + 1; row <= lastRow; row++) { var company = Cell(ws, row, colMap, "company display name", "vendor", "company", "name"); if (string.IsNullOrWhiteSpace(company) || IsMetadataRow(company)) { result.SkippedCount++; continue; } var contact = Cell(ws, row, colMap, "contact", "contact name"); var email = Cell(ws, row, colMap, "email", "email address"); var phone = Cell(ws, row, colMap, "phone", "phone number"); var street = Cell(ws, row, colMap, "billing street", "address"); var city = Cell(ws, row, colMap, "city", "billing city"); var state = Cell(ws, row, colMap, "state", "billing state"); var zip = Cell(ws, row, colMap, "zip", "postal code", "billing zip"); var country = Cell(ws, row, colMap, "country", "billing country"); var terms = Cell(ws, row, colMap, "terms", "payment terms"); result.TotalRecords++; var key = Normalize(company); if (existing.TryGetValue(key, out var vendor)) { if (!string.IsNullOrWhiteSpace(email)) vendor.Email = email; if (!string.IsNullOrWhiteSpace(phone)) vendor.Phone = phone; if (!string.IsNullOrWhiteSpace(street)) vendor.Address = street; if (!string.IsNullOrWhiteSpace(city)) vendor.City = city; if (!string.IsNullOrWhiteSpace(state)) vendor.State = state; if (!string.IsNullOrWhiteSpace(zip)) vendor.ZipCode = zip; if (!string.IsNullOrWhiteSpace(country)) vendor.Country = country; if (!string.IsNullOrWhiteSpace(terms)) vendor.PaymentTerms = terms; vendor.UpdatedAt = DateTime.UtcNow; result.UpdatedCount++; } else { _db.Vendors.Add(new Vendor { CompanyId = companyId, CompanyName = company, ContactName = contact ?? "", Email = email ?? "", Phone = phone ?? "", Address = street ?? "", City = city ?? "", State = state ?? "", ZipCode = zip ?? "", Country = country ?? "", PaymentTerms = terms ?? "", IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }); result.ImportedCount++; } } _db.SaveChanges(); result.Success = result.TotalRecords > 0; return result; } /// /// Parses a QBO Products and Services worksheet and upserts catalog and inventory items. /// QBO exports one row per category header (type = "Category") and one row per item, both /// in the same sheet. Category rows are identified by either the "Category" type column /// or by the absence of a colon-delimited sub-item suffix and skipped from item processing. /// A two-pass SaveChanges() is required: pass 1 persists any new /// rows so they have valid database IDs, and pass 2 uses those IDs when inserting /// records. QBO "Inventory" type items are routed to /// instead of . /// private ImportResultDto ProcessCatalogItems(ExcelWorksheet ws, int companyId, string userId) { var result = new ImportResultDto(); var (headerRow, colMap) = FindHeaderRow(ws, new[] { "name", "type", "sku", "sales price", "rate", "price", "cost", "qty on hand", "quantity on hand", "income account" }); if (headerRow < 0) return Fail("Could not find column headers. Please export from Sales → Products and Services → Export to Excel."); // Load existing categories so we can get-or-create them. var existingCategories = _db.CatalogCategories .IgnoreQueryFilters() .Where(c => c.CompanyId == companyId) .ToList() .ToDictionary(c => Normalize(c.Name), c => c); var existingCatalog = _db.CatalogItems .IgnoreQueryFilters() .Where(c => c.CompanyId == companyId) .ToList() .ToDictionary(c => Normalize(c.Name), c => c); var existingInventory = _db.InventoryItems .IgnoreQueryFilters() .Where(i => i.CompanyId == companyId) .ToList() .ToDictionary(i => Normalize(i.Name), i => i); // Helper: get existing category or create a new one (without saving yet). CatalogCategory GetOrCreateCategory(string categoryName) { var key = Normalize(categoryName); if (existingCategories.TryGetValue(key, out var existing)) return existing; var newCat = new CatalogCategory { CompanyId = companyId, Name = categoryName.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }; _db.CatalogCategories.Add(newCat); existingCategories[key] = newCat; return newCat; } // Ensure there is always a fallback "Imported" category for items with no prefix. var defaultCategory = GetOrCreateCategory("Imported"); // Collect rows for processing. var rows = new List<(string name, string? categoryName, string type, string? sku, decimal price, decimal cost, decimal qty, string? desc)>(); int lastRow = ws.Dimension?.End.Row ?? 0; for (int row = headerRow + 1; row <= lastRow; row++) { var rawName = Cell(ws, row, colMap, "name"); if (string.IsNullOrWhiteSpace(rawName) || IsMetadataRow(rawName)) { result.SkippedCount++; continue; } // Extract category prefix: "Category:Item Name" var name = rawName.Contains(':') ? rawName.Substring(rawName.LastIndexOf(':') + 1).Trim() : rawName.Trim(); var categoryName = rawName.Contains(':') ? rawName.Substring(0, rawName.LastIndexOf(':')).Trim() : null; var type = Cell(ws, row, colMap, "type") ?? "Service"; var sku = Cell(ws, row, colMap, "sku"); var priceRaw = Cell(ws, row, colMap, "sales price/rate", "sales price", "price/rate", "rate", "price"); var costRaw = Cell(ws, row, colMap, "cost", "purchase cost"); var qtyRaw = Cell(ws, row, colMap, "qty on hand", "quantity on hand", "qty"); var desc = Cell(ws, row, colMap, "description", "sales description"); decimal.TryParse(priceRaw?.Replace("$", "").Replace(",", ""), out var price); decimal.TryParse(costRaw?.Replace("$", "").Replace(",", ""), out var cost); decimal.TryParse(qtyRaw?.Replace(",", ""), out var qty); // Ensure the category entity exists in the change tracker (but not saved yet). if (categoryName != null) GetOrCreateCategory(categoryName); result.TotalRecords++; rows.Add((name, categoryName, type, sku, price, cost, qty, desc)); } // Pass 1: save any new categories so they get real IDs before items reference them. _db.SaveChanges(); // Pass 2: upsert items now that every category has a valid Id. foreach (var (name, categoryName, type, sku, price, cost, qty, desc) in rows) { // Skip rows that ARE a category definition (QBO exports one row per category // with just the category name and no sub-item suffix). if (categoryName == null && existingCategories.ContainsKey(Normalize(name))) { result.SkippedCount++; result.TotalRecords--; continue; } // Also skip rows explicitly typed as "Category". if (type.Equals("Category", StringComparison.OrdinalIgnoreCase)) { result.SkippedCount++; result.TotalRecords--; continue; } if (type.Equals("Inventory", StringComparison.OrdinalIgnoreCase)) { var key = Normalize(name); if (existingInventory.TryGetValue(key, out var inv)) { inv.UnitCost = cost > 0 ? cost : inv.UnitCost; inv.QuantityOnHand = qty; inv.UpdatedAt = DateTime.UtcNow; result.UpdatedCount++; } else { _db.InventoryItems.Add(new InventoryItem { CompanyId = companyId, Name = name, SKU = sku ?? "", Description = desc ?? "", UnitCost = cost, QuantityOnHand = qty, IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }); result.ImportedCount++; } } else { var key = Normalize(name); var catKey = categoryName != null ? Normalize(categoryName) : Normalize("Imported"); var category = existingCategories.TryGetValue(catKey, out var c) ? c : defaultCategory; if (existingCatalog.TryGetValue(key, out var cat)) { cat.DefaultPrice = price > 0 ? price : cat.DefaultPrice; cat.UpdatedAt = DateTime.UtcNow; result.UpdatedCount++; } else { _db.CatalogItems.Add(new CatalogItem { CompanyId = companyId, Name = name, SKU = sku ?? "", Description = desc ?? "", DefaultPrice = price, CategoryId = category.Id, IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }); result.ImportedCount++; } } } _db.SaveChanges(); result.Success = result.TotalRecords > 0; return result; } /// /// Parses a QBO Chart of Accounts worksheet and upserts records. /// Sub-accounts are expressed in QBO as colon-delimited names (e.g., "Expenses:Utilities"). /// Pass 1 saves all accounts without parent links so every row has a real ID. /// Pass 2 resolves ParentAccountId links by looking up the parent segment by name. /// QBO account type/detail-type strings are mapped to and /// enums via and /// ; unrecognized types default to Expense/Other. /// private ImportResultDto ProcessChartOfAccounts(ExcelWorksheet ws, int companyId, string userId) { var result = new ImportResultDto(); var (headerRow, colMap) = FindHeaderRow(ws, new[] { "account name", "name", "type", "detail type", "number", "description" }); if (headerRow < 0) return Fail("Could not find column headers. Please export from Accounting → Chart of Accounts → Export."); var existing = _db.Accounts .IgnoreQueryFilters() .Where(a => a.CompanyId == companyId) .ToList() .ToDictionary(a => Normalize(a.Name), a => a); // Collect rows so we can do a two-pass save: parents first, then sub-accounts. // This avoids a self-referential FK violation when both parent and child are new. var rows = new List<(string displayName, string? parentName, string? number, string? desc, AccountType accountType, AccountSubType accountSubType)>(); int lastRow = ws.Dimension?.End.Row ?? 0; for (int row = headerRow + 1; row <= lastRow; row++) { var name = Cell(ws, row, colMap, "account name", "name"); var typeStr = Cell(ws, row, colMap, "type", "account type"); var detailType = Cell(ws, row, colMap, "detail type", "detail account type"); var number = Cell(ws, row, colMap, "number", "account number", "#"); var desc = Cell(ws, row, colMap, "description"); if (string.IsNullOrWhiteSpace(name) || IsMetadataRow(name)) { result.SkippedCount++; continue; } // Strip sub-account prefix if present (e.g., "Sales:Powder Coating") var displayName = name.Contains(':') ? name.Substring(name.LastIndexOf(':') + 1).Trim() : name.Trim(); var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null; result.TotalRecords++; rows.Add((displayName, parentName, number, desc, MapQboAccountType(typeStr), MapQboDetailType(detailType))); } // Pass 1: upsert every account WITHOUT parent links so they all get IDs. foreach (var (displayName, _, number, desc, accountType, accountSubType) in rows) { var key = Normalize(displayName); if (existing.TryGetValue(key, out var acct)) { if (!string.IsNullOrWhiteSpace(number)) acct.AccountNumber = number; if (!string.IsNullOrWhiteSpace(desc)) acct.Description = desc; acct.AccountType = accountType; acct.AccountSubType = accountSubType; acct.UpdatedAt = DateTime.UtcNow; result.UpdatedCount++; } else { var newAcct = new Account { CompanyId = companyId, Name = displayName, AccountNumber = number ?? "", Description = desc ?? "", AccountType = accountType, AccountSubType = accountSubType, IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }; _db.Accounts.Add(newAcct); existing[key] = newAcct; result.ImportedCount++; } } // Save so all new accounts have real IDs before we link parents. _db.SaveChanges(); // Pass 2: wire up ParentAccountId now that every account has an Id. foreach (var (displayName, parentName, _, _, _, _) in rows) { if (parentName == null) continue; var childKey = Normalize(displayName); var parentKey = Normalize(parentName.Contains(':') ? parentName.Substring(parentName.LastIndexOf(':') + 1) : parentName); if (existing.TryGetValue(childKey, out var child) && existing.TryGetValue(parentKey, out var parent) && child.ParentAccountId != parent.Id) { child.ParentAccountId = parent.Id; } } _db.SaveChanges(); result.Success = result.TotalRecords > 0; return result; } /// /// Parses a QBO Invoice List worksheet and inserts new records. /// De-duplication is based on ExternalReference (stored as the original QBO invoice /// number) so the same file can be re-uploaded without creating duplicates. /// Because the QBO Invoice List export is summary-level only (no individual line items), /// a single synthetic covering the full invoice total is created. /// If "Open Balance" is less than the invoice total, the difference is recorded as a /// using as a neutral /// placeholder — the actual payment method is not available in the export. /// Invoice numbers use the app's own INV-YYMM-#### scheme; the QBO number is /// preserved in ExternalReference for cross-reference. /// private async Task ProcessInvoicesAsync(ExcelWorksheet ws, int companyId, string userId) { var result = new ImportResultDto(); var (headerRow, colMap) = FindHeaderRow(ws, new[] { "date", "invoice no", "num", "customer", "name", "amount", "total", "open balance", "due date" }); if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Invoice List → Export to Excel."); var customers = _db.Customers .IgnoreQueryFilters() .Where(c => c.CompanyId == companyId && !c.IsDeleted) .ToList() .ToDictionary(c => Normalize(c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}"), c => c); var existingInvoices = _db.Invoices .IgnoreQueryFilters() .Where(i => i.CompanyId == companyId) .Select(i => i.ExternalReference) .Where(r => r != null) .ToHashSet(); // Load status lookups var paidStatus = GetOrCreateInvoiceStatus(InvoiceStatus.Paid); var unpaidStatus = GetOrCreateInvoiceStatus(InvoiceStatus.Sent); var partialStatus = GetOrCreateInvoiceStatus(InvoiceStatus.PartiallyPaid); var overdueStatus = GetOrCreateInvoiceStatus(InvoiceStatus.Overdue); int lastRow = ws.Dimension?.End.Row ?? 0; for (int row = headerRow + 1; row <= lastRow; row++) { var invoiceNoRaw = Cell(ws, row, colMap, "invoice no", "num", "invoice number", "#"); var customerName = Cell(ws, row, colMap, "customer", "name"); var dateRaw = Cell(ws, row, colMap, "date", "invoice date"); var dueDateRaw = Cell(ws, row, colMap, "due date"); var totalRaw = Cell(ws, row, colMap, "amount", "total"); var balanceRaw = Cell(ws, row, colMap, "open balance", "balance"); var statusRaw = Cell(ws, row, colMap, "status"); if (string.IsNullOrWhiteSpace(invoiceNoRaw) || string.IsNullOrWhiteSpace(customerName)) { result.SkippedCount++; continue; } if (existingInvoices.Contains(invoiceNoRaw)) { result.SkippedCount++; continue; } if (!DateTime.TryParse(dateRaw, out var invoiceDate)) invoiceDate = DateTime.UtcNow; DateTime.TryParse(dueDateRaw, out var dueDate); decimal.TryParse(totalRaw?.Replace("$", "").Replace(",", ""), out var total); decimal.TryParse(balanceRaw?.Replace("$", "").Replace(",", ""), out var balanceDue); var amountPaid = total - balanceDue; var custKey = Normalize(customerName); customers.TryGetValue(custKey, out var customer); result.TotalRecords++; var status = (statusRaw?.ToLower()) switch { "paid" => paidStatus, "partial" => partialStatus, "overdue" => overdueStatus, _ => unpaidStatus }; if (amountPaid >= total && total > 0) status = paidStatus; else if (amountPaid > 0) status = partialStatus; var invoice = new Invoice { CompanyId = companyId, CustomerId = customer?.Id ?? 0, InvoiceNumber = await GenerateInvoiceNumber(companyId), ExternalReference = invoiceNoRaw, InvoiceDate = invoiceDate, DueDate = dueDate == default ? (DateTime?)null : dueDate, SubTotal = total, TaxAmount = 0, Total = total, AmountPaid = amountPaid, Status = status, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }; _db.Invoices.Add(invoice); // QBO Invoice List is summary-only — create one catch-all line item for the total. var lineDesc = Cell(ws, row, colMap, "memo", "description", "product/service") ?? $"QB Invoice #{invoiceNoRaw}"; _db.InvoiceItems.Add(new InvoiceItem { Invoice = invoice, // navigation property — EF resolves Id after save CompanyId = companyId, Description = lineDesc, Quantity = 1, UnitPrice = total, TotalPrice = total, DisplayOrder = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }); existingInvoices.Add(invoiceNoRaw); result.ImportedCount++; if (amountPaid > 0) { _db.Payments.Add(new Payment { Invoice = invoice, CompanyId = companyId, Amount = amountPaid, PaymentDate = invoiceDate, PaymentMethod = PaymentMethod.BankTransferACH, Notes = "Imported from QuickBooks Online", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }); } } _db.SaveChanges(); result.Success = result.TotalRecords > 0; return result; } /// /// Parses a QBO Transaction List by Date worksheet and posts payment records against /// previously-imported invoices. /// Only rows whose Transaction Type contains "Payment" or "Receipt" are processed. /// Two invoice-matching strategies are attempted in order: /// 1. Exact match on ExternalReference using the Num column (works when the QBO /// transaction number equals the invoice number — common for invoice payments). /// 2. Customer-name + amount match against open invoices (fallback for payment rows where /// the Num column holds a check/ACH reference number rather than an invoice number). /// Payments already recorded for the same date and amount are silently skipped to prevent /// duplicates when the same transaction file is uploaded more than once. /// Unmatched payments are recorded as warnings rather than errors so the rest of the batch /// still completes successfully. /// private ImportResultDto ProcessTransactions(ExcelWorksheet ws, int companyId, string userId) { var result = new ImportResultDto(); var (headerRow, colMap) = FindHeaderRow(ws, new[] { "date", "transaction type", "type", "num", "name", "customer", "amount", "memo", "description" }); if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Transaction List by Date → Export to Excel."); var allInvoices = _db.Invoices .IgnoreQueryFilters() .Where(i => i.CompanyId == companyId) .Include(i => i.Payments) .Include(i => i.Customer) .ToList(); // Primary lookup: by ExternalReference (invoice number as exported from QBO). var invoicesByRef = allInvoices .Where(i => i.ExternalReference != null) .ToDictionary(i => i.ExternalReference!, i => i); // Fallback lookup: by customer display name → list of their open invoices. // QBO payment rows carry the customer name in the "Name" column but NOT the invoice number. var invoicesByCustomer = allInvoices .GroupBy(i => { var c = i.Customer; return Normalize(c?.CompanyName ?? $"{c?.ContactFirstName} {c?.ContactLastName}".Trim()); }) .ToDictionary(g => g.Key, g => g.ToList()); var paidStatus = GetOrCreateInvoiceStatus(InvoiceStatus.Paid); var partialStatus = GetOrCreateInvoiceStatus(InvoiceStatus.PartiallyPaid); int lastRow = ws.Dimension?.End.Row ?? 0; for (int row = headerRow + 1; row <= lastRow; row++) { var txType = Cell(ws, row, colMap, "transaction type", "type") ?? ""; var dateRaw = Cell(ws, row, colMap, "date"); var num = Cell(ws, row, colMap, "num", "#"); var customerRaw = Cell(ws, row, colMap, "name", "customer"); var amtRaw = Cell(ws, row, colMap, "amount"); var memo = Cell(ws, row, colMap, "memo", "description", "memo/description"); // Only process payments/receipts if (!txType.Contains("Payment", StringComparison.OrdinalIgnoreCase) && !txType.Contains("Receipt", StringComparison.OrdinalIgnoreCase)) { result.SkippedCount++; continue; } if (!DateTime.TryParse(dateRaw, out var payDate)) payDate = DateTime.UtcNow; decimal.TryParse(amtRaw?.Replace("$", "").Replace(",", "").Replace("(", "-").Replace(")", ""), out var amount); if (amount < 0) amount = Math.Abs(amount); if (amount == 0) { result.SkippedCount++; continue; } result.TotalRecords++; // Match strategy 1: num matches an invoice's ExternalReference directly. Invoice? invoice = null; if (!string.IsNullOrWhiteSpace(num)) invoicesByRef.TryGetValue(num, out invoice); // Match strategy 2: QBO payment rows use the payment/check number in Num, not the // invoice number. Fall back to customer name + exact amount against open invoices. if (invoice == null && !string.IsNullOrWhiteSpace(customerRaw)) { var custKey = Normalize(customerRaw); if (invoicesByCustomer.TryGetValue(custKey, out var custInvoices)) { // Prefer an invoice whose outstanding balance equals the payment amount. invoice = custInvoices.FirstOrDefault(i => i.BalanceDue > 0 && i.BalanceDue == amount && !i.Payments.Any(p => p.PaymentDate.Date == payDate.Date && p.Amount == amount)); // Broader fallback: any open invoice for this customer with a matching total. invoice ??= custInvoices.FirstOrDefault(i => i.BalanceDue > 0 && i.Total == amount && !i.Payments.Any(p => p.PaymentDate.Date == payDate.Date && p.Amount == amount)); } } if (invoice != null) { // Avoid duplicate payments if (invoice.Payments.Any(p => p.PaymentDate.Date == payDate.Date && p.Amount == amount)) { result.SkippedCount++; result.TotalRecords--; continue; } invoice.AmountPaid += amount; invoice.Status = invoice.BalanceDue <= 0 ? paidStatus : partialStatus; invoice.UpdatedAt = DateTime.UtcNow; _db.Payments.Add(new Payment { CompanyId = companyId, InvoiceId = invoice.Id, Amount = amount, PaymentDate = payDate, PaymentMethod = PaymentMethod.BankTransferACH, Notes = memo ?? "Imported from QuickBooks Online", Reference = num ?? "", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, CreatedBy = userId, UpdatedBy = userId, }); result.ImportedCount++; } else { result.Errors.Add(new ImportErrorDto { Severity = "Warning", LineNumber = row, RecordName = num, ErrorMessage = $"No matching invoice found for payment '{num}' (customer: {customerRaw ?? "unknown"}, amount: {amount:C}). Payment skipped." }); result.SkippedCount++; } } _db.SaveChanges(); result.Success = result.TotalRecords > 0; return result; } // ── Header detection ────────────────────────────────────────────────────── /// /// Scans the first 10 rows looking for the row that contains the most known column keywords. /// Returns (headerRowIndex, columnMap) where columnMap maps lowercase header name → column index. /// private static (int headerRow, Dictionary colMap) FindHeaderRow( ExcelWorksheet ws, string[] knownHeaders) { int lastCol = ws.Dimension?.End.Column ?? 0; int bestRow = -1; int bestScore = 0; var bestMap = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int row = 1; row <= Math.Min(10, ws.Dimension?.End.Row ?? 0); row++) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); int score = 0; for (int col = 1; col <= lastCol; col++) { var val = ws.Cells[row, col].Text?.Trim(); if (string.IsNullOrEmpty(val)) continue; var lower = val.ToLowerInvariant(); map[lower] = col; if (knownHeaders.Any(h => lower.Contains(h) || h.Contains(lower))) score++; } if (score > bestScore) { bestScore = score; bestRow = row; bestMap = map; } } return bestScore >= 2 ? (bestRow, bestMap) : (-1, new Dictionary()); } /// /// Reads a cell value by trying multiple possible column header names. /// private static string? Cell(ExcelWorksheet ws, int row, Dictionary colMap, params string[] names) { foreach (var name in names) { // Exact match if (colMap.TryGetValue(name, out var col)) { var val = ws.Cells[row, col].Text?.Trim(); if (!string.IsNullOrEmpty(val)) return val; } // Partial match var partial = colMap.Keys.FirstOrDefault(k => k.Contains(name) || name.Contains(k)); if (partial != null) { var val = ws.Cells[row, colMap[partial]].Text?.Trim(); if (!string.IsNullOrEmpty(val)) return val; } } return null; } // ── Metadata-row guard ─────────────────────────────────────────────────── private static readonly string[] _daysOfWeek = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; /// /// Returns true if the value looks like a QBO report footer/header line rather than real data. /// QBO appends a timestamp footer such as "Saturday, April 04, 2026 02:06 PM GMTZ" and sometimes /// a company-name header before the column headers. /// private static bool IsMetadataRow(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; var lower = value.Trim().ToLowerInvariant(); // Starts with a day-of-week name (timestamp footer) if (_daysOfWeek.Any(d => lower.StartsWith(d + ","))) return true; // Contains AM/PM + a timezone abbreviation (another timestamp pattern) if ((lower.Contains(" am ") || lower.Contains(" pm ")) && (lower.EndsWith("gmt") || lower.EndsWith("gmtz") || lower.EndsWith("utc") || lower.EndsWith("est") || lower.EndsWith("cst") || lower.EndsWith("mst") || lower.EndsWith("pst"))) return true; // "Total" / "Grand Total" summary rows if (lower.StartsWith("total") || lower.StartsWith("grand total")) return true; return false; } // ── Enum mapping ────────────────────────────────────────────────────────── /// /// Translates a QBO account type string (as it appears in the export) to the app's /// enum. QBO uses long, human-readable labels like /// "Accounts Receivable (A/R)" which must be lowercased and matched literally. /// Unrecognized types default to to avoid import failures. /// private static AccountType MapQboAccountType(string? type) => (type?.ToLower().Trim()) switch { "bank" => AccountType.Asset, "accounts receivable (a/r)" => AccountType.Asset, "accounts receivable" => AccountType.Asset, "other current assets" => AccountType.Asset, "fixed assets" => AccountType.Asset, "other assets" => AccountType.Asset, "accounts payable (a/p)" => AccountType.Liability, "accounts payable" => AccountType.Liability, "credit card" => AccountType.Liability, "other current liabilities" => AccountType.Liability, "long-term liabilities" => AccountType.Liability, "equity" => AccountType.Equity, "income" => AccountType.Revenue, "other income" => AccountType.Revenue, "cost of goods sold" => AccountType.CostOfGoods, "expenses" => AccountType.Expense, "other expense" => AccountType.Expense, _ => AccountType.Expense }; /// /// Translates a QBO "Detail Type" string to the app's enum. /// Only the most common subtypes used in powder-coating businesses are mapped; everything /// else collapses to so the import does not fail on /// exotic or custom QBO account detail types. /// private static AccountSubType MapQboDetailType(string? detail) => (detail?.ToLower().Trim()) switch { "checking" => AccountSubType.Checking, "savings" => AccountSubType.Savings, "inventory" => AccountSubType.Inventory, "accounts receivable" => AccountSubType.AccountsReceivable, "accounts payable" => AccountSubType.AccountsPayable, _ => AccountSubType.Other }; /// /// Pass-through helper retained for symmetry with other entity processors. /// is a plain enum (not a lookup table entity), so no /// database query is needed — the value is returned as-is. /// private InvoiceStatus GetOrCreateInvoiceStatus(InvoiceStatus status) => status; /// /// Strips a leading "Label: " prefix that QBO sometimes embeds in cell values /// (e.g. "Phone: (555) 123-4567" → "(555) 123-4567"). /// private static string? StripLabel(string? value) { if (string.IsNullOrWhiteSpace(value)) return value; var idx = value.IndexOf(':'); if (idx > 0 && idx < 20) // colon within the first 20 chars → likely a label { var candidate = value.Substring(idx + 1).Trim(); if (!string.IsNullOrWhiteSpace(candidate)) return candidate; } return value; } // ── Helpers ─────────────────────────────────────────────────────────────── /// /// Generates the next sequential invoice number in the format INV-YYMM-####. /// Uses IgnoreQueryFilters() to scan across all tenants' invoices with the same /// month prefix, which guarantees uniqueness even if the same company previously had /// soft-deleted invoices from the same month. /// Called once per invoice row during the import loop, so it performs one DB round-trip /// per invoice — acceptable because invoice imports are typically small batches. /// private async Task GenerateInvoiceNumber(int companyId) { var prefix = $"INV-{DateTime.UtcNow:yyMM}-"; var last = await _db.Invoices .IgnoreQueryFilters() .Where(i => i.InvoiceNumber.StartsWith(prefix)) .OrderByDescending(i => i.InvoiceNumber) .Select(i => i.InvoiceNumber) .FirstOrDefaultAsync(); int seq = 1; if (last != null && int.TryParse(last.Substring(prefix.Length), out var n)) seq = n + 1; return $"{prefix}{seq:D4}"; } /// /// Normalizes a string for case- and whitespace-insensitive deduplication matching /// (trim, lowercase, collapse double spaces). Used as the dictionary key for all /// upsert lookups so that "ABC Corp" and "abc corp " are treated as the same entity. /// private static string Normalize(string? s) => (s ?? "").Trim().ToLowerInvariant().Replace(" ", " "); /// /// Constructs a failed with a single descriptive error. /// Used for early-exit conditions such as an unreadable file or missing header row. /// private static ImportResultDto Fail(string message) => new ImportResultDto { Success = false, Errors = { new ImportErrorDto { ErrorMessage = message } } }; }