Preserve accounting linkages through CSV export/import round-trip
The data export was silently dropping account linkages, so an export->import (used to copy prod data) lost which bank account each payment hit, all invoice line items, and any bill/deposit/journal-entry detail. Diagnosed while cleaning up a company's books from a copied dataset. Now every accounting linkage travels by account/vendor/customer/invoice number — matching how expenses already worked — so a round-trip preserves GL attribution instead of dropping it. Payments: add DepositAccountNumber to the export + DTO; import resolves it back to DepositAccountId so payments post to the right bank account on recalc. Invoices: were header-only (re-imported invoices had 0 line items). Add a new invoice_items CSV (one row per line, carrying RevenueAccountNumber) with export, idempotent import, UI cards, and a template. Bills / Deposits / Journal Entries: were not exported at all. Add full-fidelity export + import including line-item children — bills + bill line items (vendor by name, AP account + per-line expense account by number), deposits (customer + bank account + applied invoice), and journal entries + JE lines (account by number, debit/credit). 5 DTOs, 5 importers, 5 exporters + actions, all added to the all_data export zip, plus 10 import/export UI cards. Shared RunCsvImport helper added for the new import endpoints. All linkages resolve by stable business keys (numbers/names), never internal IDs, so the files round-trip across databases. Build clean; 278 unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3208,6 +3208,33 @@ public class CsvImportService : ICsvImportService
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] GenerateInvoiceItemTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var writer = new StreamWriter(memoryStream);
|
||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
||||
|
||||
csv.WriteHeader<InvoiceItemImportDto>();
|
||||
csv.NextRecord();
|
||||
|
||||
csv.WriteRecord(new InvoiceItemImportDto
|
||||
{
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Description = "Powder coating - 4 wheels",
|
||||
Quantity = 4,
|
||||
UnitPrice = 75.00m,
|
||||
TotalPrice = 300.00m,
|
||||
ColorName = "Gloss Black",
|
||||
RevenueAccountNumber = "47905",
|
||||
DisplayOrder = 0,
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
writer.Flush();
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
public byte[] GeneratePaymentTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
@@ -3219,12 +3246,13 @@ public class CsvImportService : ICsvImportService
|
||||
|
||||
csv.WriteRecord(new PaymentImportDto
|
||||
{
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Amount = 250.00m,
|
||||
PaymentDate = DateTime.Today,
|
||||
PaymentMethod = "Check",
|
||||
Reference = "CHK-1234",
|
||||
Notes = ""
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Amount = 250.00m,
|
||||
PaymentDate = DateTime.Today,
|
||||
PaymentMethod = "Check",
|
||||
DepositAccountNumber = "10100",
|
||||
Reference = "CHK-1234",
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
@@ -3232,6 +3260,651 @@ public class CsvImportService : ICsvImportService
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports invoice line items from CSV. Each row is matched to its parent invoice by InvoiceNumber;
|
||||
/// the revenue account is resolved (optional) from RevenueAccountNumber against the Chart of Accounts.
|
||||
/// Idempotent — an invoice line with the same description + total + display order is skipped, so the
|
||||
/// import can be safely re-run. Run AFTER invoices have been imported (the parents must exist).
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<InvoiceItemImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
_logger.LogInformation("Starting import of {Count} invoice line items for company {CompanyId}", records.Count, companyId);
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.InvoiceItems);
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts
|
||||
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||
|
||||
// Idempotency: skip a line that already exists on this invoice.
|
||||
var isDuplicate = invoice.InvoiceItems.Any(it =>
|
||||
string.Equals(it.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||
&& it.TotalPrice == record.TotalPrice
|
||||
&& it.DisplayOrder == record.DisplayOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on invoice '{record.InvoiceNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the optional revenue account by number so revenue attribution is preserved.
|
||||
int? revenueAccountId = null;
|
||||
var cleanRevenueAccount = StripQuotes(record.RevenueAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanRevenueAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanRevenueAccount, out var revenueAccount))
|
||||
revenueAccountId = revenueAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Revenue account '{cleanRevenueAccount}' not found in Chart of Accounts — line imported without a revenue account.");
|
||||
}
|
||||
|
||||
var item = new Core.Entities.InvoiceItem
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Description = description,
|
||||
Quantity = record.Quantity,
|
||||
UnitPrice = record.UnitPrice,
|
||||
TotalPrice = record.TotalPrice,
|
||||
ColorName = string.IsNullOrWhiteSpace(record.ColorName) ? null : record.ColorName.Trim(),
|
||||
RevenueAccountId = revenueAccountId,
|
||||
DisplayOrder = record.DisplayOrder,
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.InvoiceItems.AddAsync(item);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Keep the in-memory invoice current so later rows dedup against it correctly.
|
||||
invoice.InvoiceItems.Add(item);
|
||||
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during invoice item CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports vendor bill headers from CSV. Vendor is resolved by name and the AP account by number.
|
||||
/// Dedup by BillNumber. Line items import separately via <see cref="ImportBillLineItemsAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<BillImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName))
|
||||
.GroupBy(v => v.CompanyName.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existingBills = await _unitOfWork.Bills.GetAllAsync();
|
||||
var existingBillNumbers = existingBills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||
.Select(b => b.BillNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(billNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: BillNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
if (existingBillNumbers.Contains(billNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Bill '{billNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanVendor = StripQuotes(record.VendorName)?.Trim() ?? "";
|
||||
if (!vendorByName.TryGetValue(cleanVendor, out var vendor))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Vendor '{cleanVendor}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanApAccount = StripQuotes(record.APAccountNumber)?.Trim() ?? "";
|
||||
if (!accountByNumber.TryGetValue(cleanApAccount, out var apAccount))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: AP account '{cleanApAccount}' not found in Chart of Accounts.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<BillStatus>(record.Status?.Trim(), true, out var status))
|
||||
status = BillStatus.Open;
|
||||
|
||||
var bill = new Core.Entities.Bill
|
||||
{
|
||||
CompanyId = companyId,
|
||||
BillNumber = billNumber,
|
||||
VendorInvoiceNumber = string.IsNullOrWhiteSpace(record.VendorInvoiceNumber) ? null : record.VendorInvoiceNumber.Trim(),
|
||||
VendorId = vendor.Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = record.BillDate == default ? DateTime.UtcNow.Date : record.BillDate,
|
||||
DueDate = record.DueDate,
|
||||
Status = status,
|
||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||
Memo = string.IsNullOrWhiteSpace(record.Memo) ? null : record.Memo.Trim(),
|
||||
SubTotal = record.SubTotal,
|
||||
TaxPercent = record.TaxPercent,
|
||||
TaxAmount = record.TaxAmount,
|
||||
Total = record.Total,
|
||||
AmountPaid = record.AmountPaid
|
||||
};
|
||||
|
||||
await _unitOfWork.Bills.AddAsync(bill);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
existingBillNumbers.Add(billNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during bill CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports vendor bill line items from CSV. Each line is matched to its parent bill by BillNumber;
|
||||
/// the expense/asset account (optional) and job (optional) are resolved by number. Idempotent by
|
||||
/// bill + description + amount + display order. Run after bills have been imported.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<BillLineItemImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var bills = await _unitOfWork.Bills.GetAllAsync(false, b => b.LineItems);
|
||||
var billByNumber = bills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||
.ToDictionary(b => b.BillNumber.Trim(), b => b, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var jobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||
var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||
.ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||
if (!billByNumber.TryGetValue(billNumber, out var bill))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Bill '{record.BillNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||
var isDuplicate = bill.LineItems.Any(li =>
|
||||
string.Equals(li.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||
&& li.Amount == record.Amount && li.DisplayOrder == record.DisplayOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on bill '{billNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int? accountId = null;
|
||||
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||
accountId = account.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Account '{cleanAccount}' not found — line imported without an account.");
|
||||
}
|
||||
|
||||
int? jobId = null;
|
||||
var cleanJob = StripQuotes(record.JobNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanJob) && jobByNumber.TryGetValue(cleanJob, out var job))
|
||||
jobId = job.Id;
|
||||
|
||||
var lineItem = new Core.Entities.BillLineItem
|
||||
{
|
||||
CompanyId = companyId,
|
||||
BillId = bill.Id,
|
||||
AccountId = accountId,
|
||||
JobId = jobId,
|
||||
Description = description,
|
||||
Quantity = record.Quantity,
|
||||
UnitPrice = record.UnitPrice,
|
||||
Amount = record.Amount,
|
||||
DisplayOrder = record.DisplayOrder
|
||||
};
|
||||
|
||||
await _unitOfWork.BillLineItems.AddAsync(lineItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
bill.LineItems.Add(lineItem);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during bill line item CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports customer deposits from CSV. Customer is resolved by name, the bank account by number,
|
||||
/// and the optional applied invoice by number. Dedup by ReceiptNumber.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<DepositImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
var customerByName = new Dictionary<string, Core.Entities.Customer>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var c in customers)
|
||||
{
|
||||
var name = !string.IsNullOrWhiteSpace(c.CompanyName)
|
||||
? c.CompanyName.Trim()
|
||||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(name)) customerByName[name] = c;
|
||||
}
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.GetAllAsync();
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existingReceipts = (await _unitOfWork.Deposits.GetAllAsync())
|
||||
.Where(d => !string.IsNullOrEmpty(d.ReceiptNumber))
|
||||
.Select(d => d.ReceiptNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var receiptNumber = StripQuotes(record.ReceiptNumber)?.Trim() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(receiptNumber) && existingReceipts.Contains(receiptNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit '{receiptNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanCustomer = StripQuotes(record.CustomerName)?.Trim() ?? "";
|
||||
if (!customerByName.TryGetValue(cleanCustomer, out var customer))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Customer '{cleanCustomer}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method))
|
||||
method = PaymentMethod.Cash;
|
||||
|
||||
int? depositAccountId = null;
|
||||
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||
depositAccountId = depositAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found — deposit imported without a bank account.");
|
||||
}
|
||||
|
||||
int? appliedInvoiceId = null;
|
||||
var cleanInvoice = StripQuotes(record.AppliedToInvoiceNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanInvoice) && invoiceByNumber.TryGetValue(cleanInvoice, out var appliedInvoice))
|
||||
appliedInvoiceId = appliedInvoice.Id;
|
||||
|
||||
var deposit = new Core.Entities.Deposit
|
||||
{
|
||||
CompanyId = companyId,
|
||||
ReceiptNumber = receiptNumber,
|
||||
CustomerId = customer.Id,
|
||||
Amount = record.Amount,
|
||||
PaymentMethod = method,
|
||||
ReceivedDate = record.ReceivedDate == default ? DateTime.UtcNow.Date : record.ReceivedDate,
|
||||
DepositAccountId = depositAccountId,
|
||||
AppliedToInvoiceId = appliedInvoiceId,
|
||||
AppliedDate = record.AppliedDate,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
if (!string.IsNullOrWhiteSpace(receiptNumber)) existingReceipts.Add(receiptNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during deposit CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports journal entry headers from CSV. Dedup by EntryNumber. The debit/credit lines import
|
||||
/// separately via <see cref="ImportJournalEntryLinesAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JournalEntryImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var existingNumbers = (await _unitOfWork.JournalEntries.GetAllAsync())
|
||||
.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||
.Select(j => j.EntryNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(entryNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: EntryNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
if (existingNumbers.Contains(entryNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Journal entry '{entryNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<JournalEntryStatus>(record.Status?.Trim(), true, out var status))
|
||||
status = JournalEntryStatus.Draft;
|
||||
|
||||
var entry = new Core.Entities.JournalEntry
|
||||
{
|
||||
CompanyId = companyId,
|
||||
EntryNumber = entryNumber,
|
||||
EntryDate = record.EntryDate == default ? DateTime.UtcNow.Date : record.EntryDate,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||
Status = status,
|
||||
PostedAt = status == JournalEntryStatus.Posted ? DateTime.UtcNow : null
|
||||
};
|
||||
|
||||
await _unitOfWork.JournalEntries.AddAsync(entry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
existingNumbers.Add(entryNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during journal entry CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports journal entry lines from CSV. Each line is matched to its parent entry by EntryNumber
|
||||
/// and the account resolved (required) from AccountNumber. Idempotent by entry + account + amounts
|
||||
/// + line order. Run after journal entry headers have been imported.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JournalEntryLineImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var entries = await _unitOfWork.JournalEntries.GetAllAsync(false, j => j.Lines);
|
||||
var entryByNumber = entries.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||
.ToDictionary(j => j.EntryNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||
if (!entryByNumber.TryGetValue(entryNumber, out var entry))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Journal entry '{record.EntryNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim() ?? "";
|
||||
if (!accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Account '{cleanAccount}' not found in Chart of Accounts.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDuplicate = entry.Lines.Any(l =>
|
||||
l.AccountId == account.Id && l.DebitAmount == record.DebitAmount
|
||||
&& l.CreditAmount == record.CreditAmount && l.LineOrder == record.LineOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line for account '{cleanAccount}' already exists on entry '{entryNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var line = new Core.Entities.JournalEntryLine
|
||||
{
|
||||
CompanyId = companyId,
|
||||
JournalEntryId = entry.Id,
|
||||
AccountId = account.Id,
|
||||
DebitAmount = record.DebitAmount,
|
||||
CreditAmount = record.CreditAmount,
|
||||
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||
LineOrder = record.LineOrder
|
||||
};
|
||||
|
||||
await _unitOfWork.JournalEntryLines.AddAsync(line);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
entry.Lines.Add(line);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during journal entry line CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
@@ -3256,6 +3929,14 @@ public class CsvImportService : ICsvImportService
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Account lookup for resolving the deposit (bank) account by number — optional per row.
|
||||
// Mirrors the expense import so payments round-trip with their bank-account linkage intact.
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts
|
||||
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -3301,15 +3982,28 @@ public class CsvImportService : ICsvImportService
|
||||
method = PaymentMethod.Cash;
|
||||
}
|
||||
|
||||
// Resolve the optional deposit (bank) account by number so the balance recalc can
|
||||
// post this payment. A blank value is fine; an unknown number warns but still imports.
|
||||
int? depositAccountId = null;
|
||||
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||
depositAccountId = depositAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found in Chart of Accounts — payment imported without a deposit account.");
|
||||
}
|
||||
|
||||
var payment = new Core.Entities.Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Amount = record.Amount,
|
||||
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
||||
PaymentMethod = method,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Amount = record.Amount,
|
||||
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
||||
PaymentMethod = method,
|
||||
DepositAccountId = depositAccountId,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.Payments.AddAsync(payment);
|
||||
|
||||
Reference in New Issue
Block a user