1108 lines
53 KiB
C#
1108 lines
53 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="IFormFile"/>, which is an ASP.NET Core abstraction not suitable for
|
|
/// a pure infrastructure library.
|
|
/// </summary>
|
|
public class QuickBooksOnlineService
|
|
{
|
|
private readonly ApplicationDbContext _db;
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ILogger<QuickBooksOnlineService> _logger;
|
|
|
|
public QuickBooksOnlineService(
|
|
ApplicationDbContext db,
|
|
ITenantContext tenantContext,
|
|
ILogger<QuickBooksOnlineService> logger)
|
|
{
|
|
_db = db;
|
|
_tenantContext = tenantContext;
|
|
_logger = logger;
|
|
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
|
}
|
|
|
|
// ── Public entry points ───────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="Customer.CurrentBalance"/> to avoid overwriting live AR data.
|
|
/// </summary>
|
|
public Task<ImportResultDto> ImportCustomersAsync(IFormFile file, int companyId, string userId)
|
|
=> RunImport(file, companyId, userId, ProcessCustomers);
|
|
|
|
/// <summary>
|
|
/// Imports vendors from a QBO Vendor Contact List export (.xlsx).
|
|
/// Matches by normalized company display name; updates contact fields on existing records.
|
|
/// </summary>
|
|
public Task<ImportResultDto> ImportVendorsAsync(IFormFile file, int companyId, string userId)
|
|
=> RunImport(file, companyId, userId, ProcessVendors);
|
|
|
|
/// <summary>
|
|
/// Imports products and services from a QBO Products and Services export (.xlsx).
|
|
/// QBO "Inventory" type items are mapped to <see cref="InventoryItem"/>; all other
|
|
/// service/non-inventory types are mapped to <see cref="CatalogItem"/>. Uses a
|
|
/// two-pass save to ensure category rows receive real database IDs before item rows
|
|
/// reference them via foreign key.
|
|
/// </summary>
|
|
public Task<ImportResultDto> ImportCatalogItemsAsync(IFormFile file, int companyId, string userId)
|
|
=> RunImport(file, companyId, userId, ProcessCatalogItems);
|
|
|
|
/// <summary>
|
|
/// 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 <c>ParentAccountId</c> relationships.
|
|
/// This avoids self-referential FK violations when both a parent and its sub-account are new.
|
|
/// </summary>
|
|
public Task<ImportResultDto> ImportChartOfAccountsAsync(IFormFile file, int companyId, string userId)
|
|
=> RunImport(file, companyId, userId, ProcessChartOfAccounts);
|
|
|
|
/// <summary>
|
|
/// Imports invoices from a QBO Invoice List export (.xlsx).
|
|
/// De-duplicates by <c>ExternalReference</c> (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 <see cref="InvoiceItem"/> is synthesized for each invoice.
|
|
/// Existing payments implied by a non-zero "Open Balance" are also imported as
|
|
/// <see cref="Payment"/> records using <see cref="PaymentMethod.BankTransferACH"/> as a
|
|
/// neutral placeholder — the actual method is unknown from QBO exports.
|
|
/// </summary>
|
|
public Task<ImportResultDto> ImportInvoicesAsync(IFormFile file, int companyId, string userId)
|
|
=> RunImportAsync(file, companyId, userId, ProcessInvoicesAsync);
|
|
|
|
/// <summary>
|
|
/// 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 <c>ExternalReference</c> 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 <see cref="ImportResultDto.Errors"/>.
|
|
/// </summary>
|
|
public Task<ImportResultDto> ImportTransactionsAsync(IFormFile file, int companyId, string userId)
|
|
=> RunImport(file, companyId, userId, ProcessTransactions);
|
|
|
|
// ── Generic runner ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Reads the uploaded file into memory, opens it with EPPlus, and delegates processing
|
|
/// to the supplied synchronous <paramref name="processor"/> delegate.
|
|
/// All exceptions are caught and returned as a failed <see cref="ImportResultDto"/> so
|
|
/// the controller never receives an unhandled 500 during an import operation.
|
|
/// </summary>
|
|
private async Task<ImportResultDto> RunImport(
|
|
IFormFile file, int companyId, string userId,
|
|
Func<ExcelWorksheet, int, string, ImportResultDto> 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Async variant of <see cref="RunImport"/> for processors that require awaitable
|
|
/// operations inside their row loops (e.g., invoice number generation via DB query).
|
|
/// </summary>
|
|
private async Task<ImportResultDto> RunImportAsync(
|
|
IFormFile file, int companyId, string userId,
|
|
Func<ExcelWorksheet, int, string, Task<ImportResultDto>> 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 ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Parses a QBO Customer Contact List worksheet and upserts <see cref="Customer"/> records.
|
|
/// Uses <c>IgnoreQueryFilters()</c> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a QBO Vendor Contact List worksheet and upserts <see cref="Vendor"/> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>SaveChanges()</c> is required: pass 1 persists any new <see cref="CatalogCategory"/>
|
|
/// rows so they have valid database IDs, and pass 2 uses those IDs when inserting
|
|
/// <see cref="CatalogItem"/> records. QBO "Inventory" type items are routed to
|
|
/// <see cref="InventoryItem"/> instead of <see cref="CatalogItem"/>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a QBO Chart of Accounts worksheet and upserts <see cref="Account"/> 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 <c>ParentAccountId</c> links by looking up the parent segment by name.
|
|
/// QBO account type/detail-type strings are mapped to <see cref="AccountType"/> and
|
|
/// <see cref="AccountSubType"/> enums via <see cref="MapQboAccountType"/> and
|
|
/// <see cref="MapQboDetailType"/>; unrecognized types default to <c>Expense</c>/<c>Other</c>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a QBO Invoice List worksheet and inserts new <see cref="Invoice"/> records.
|
|
/// De-duplication is based on <c>ExternalReference</c> (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 <see cref="InvoiceItem"/> covering the full invoice total is created.
|
|
/// If "Open Balance" is less than the invoice total, the difference is recorded as a
|
|
/// <see cref="Payment"/> using <see cref="PaymentMethod.BankTransferACH"/> as a neutral
|
|
/// placeholder — the actual payment method is not available in the export.
|
|
/// Invoice numbers use the app's own <c>INV-YYMM-####</c> scheme; the QBO number is
|
|
/// preserved in <c>ExternalReference</c> for cross-reference.
|
|
/// </summary>
|
|
private async Task<ImportResultDto> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>ExternalReference</c> 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.
|
|
/// </summary>
|
|
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 ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static (int headerRow, Dictionary<string, int> colMap) FindHeaderRow(
|
|
ExcelWorksheet ws, string[] knownHeaders)
|
|
{
|
|
int lastCol = ws.Dimension?.End.Column ?? 0;
|
|
int bestRow = -1;
|
|
int bestScore = 0;
|
|
var bestMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
for (int row = 1; row <= Math.Min(10, ws.Dimension?.End.Row ?? 0); row++)
|
|
{
|
|
var map = new Dictionary<string, int>(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<string, int>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a cell value by trying multiple possible column header names.
|
|
/// </summary>
|
|
private static string? Cell(ExcelWorksheet ws, int row, Dictionary<string, int> 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"];
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Translates a QBO account type string (as it appears in the export) to the app's
|
|
/// <see cref="AccountType"/> enum. QBO uses long, human-readable labels like
|
|
/// "Accounts Receivable (A/R)" which must be lowercased and matched literally.
|
|
/// Unrecognized types default to <see cref="AccountType.Expense"/> to avoid import failures.
|
|
/// </summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Translates a QBO "Detail Type" string to the app's <see cref="AccountSubType"/> enum.
|
|
/// Only the most common subtypes used in powder-coating businesses are mapped; everything
|
|
/// else collapses to <see cref="AccountSubType.Other"/> so the import does not fail on
|
|
/// exotic or custom QBO account detail types.
|
|
/// </summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Pass-through helper retained for symmetry with other entity processors.
|
|
/// <see cref="InvoiceStatus"/> is a plain enum (not a lookup table entity), so no
|
|
/// database query is needed — the value is returned as-is.
|
|
/// </summary>
|
|
private InvoiceStatus GetOrCreateInvoiceStatus(InvoiceStatus status) => status;
|
|
|
|
/// <summary>
|
|
/// Strips a leading "Label: " prefix that QBO sometimes embeds in cell values
|
|
/// (e.g. "Phone: (555) 123-4567" → "(555) 123-4567").
|
|
/// </summary>
|
|
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 ───────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Generates the next sequential invoice number in the format <c>INV-YYMM-####</c>.
|
|
/// Uses <c>IgnoreQueryFilters()</c> 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.
|
|
/// </summary>
|
|
private async Task<string> 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}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static string Normalize(string? s) =>
|
|
(s ?? "").Trim().ToLowerInvariant().Replace(" ", " ");
|
|
|
|
/// <summary>
|
|
/// Constructs a failed <see cref="ImportResultDto"/> with a single descriptive error.
|
|
/// Used for early-exit conditions such as an unreadable file or missing header row.
|
|
/// </summary>
|
|
private static ImportResultDto Fail(string message) =>
|
|
new ImportResultDto { Success = false, Errors = { new ImportErrorDto { ErrorMessage = message } } };
|
|
}
|