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 } } };
}