Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/AccountingExportController.cs
T
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00

571 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Interfaces;
using System.IO.Compression;
using System.Text;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class AccountingExportController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly PowderCoating.Application.Interfaces.IAuditService _auditService;
public AccountingExportController(IUnitOfWork unitOfWork, ITenantContext tenantContext,
PowderCoating.Application.Interfaces.IAuditService auditService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_auditService = auditService;
}
/// <summary>
/// Displays the accounting data export page. Pre-fills the start date to the first day of
/// the current month and the end date to today so the user sees a sensible default range
/// without having to type dates.
/// </summary>
public IActionResult Index()
{
ViewBag.DefaultStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).ToString("yyyy-MM-dd");
ViewBag.DefaultEnd = DateTime.Now.ToString("yyyy-MM-dd");
return View();
}
/// <summary>
/// Produces a ZIP archive containing the company's financial data for the requested date
/// range in either QuickBooks Desktop IIF format or generic CSV format.
/// <para>
/// <b>QuickBooks IIF</b>: Generates three files that must be imported into QuickBooks
/// Desktop in order (customers → invoices/payments → expenses/bills). IIF is a tab-delimited
/// format with header rows (<c>!CUST</c>, <c>!TRNS</c>, <c>!SPL</c>, <c>!ENDTRNS</c>) that
/// QuickBooks parses as transaction records. Account names in the IIF files must exactly match
/// the company's QuickBooks chart of accounts, so users are advised to review account names
/// before importing.
/// </para>
/// <para>
/// <b>CSV</b>: Generates seven files covering customers, invoice headers, invoice line items,
/// payments received, expenses, vendor bills, and bill payments. Suitable for import into any
/// spreadsheet or accounting tool that accepts CSV.
/// </para>
/// The end date is extended to end-of-day (<c>AddDays(1).AddTicks(-1)</c>) so that records
/// created at any time on the end date are included. The ZIP is streamed directly from a
/// <c>MemoryStream</c> (not written to disk) to avoid temporary file management on the server.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Export(DateTime startDate, DateTime endDate, string format)
{
var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1);
// ── Load data ─────────────────────────────────────────────────────────
var invoices = (await _unitOfWork.Invoices.FindAsync(
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
false,
i => i.InvoiceItems,
i => i.Payments,
i => i.Customer))
.OrderBy(i => i.InvoiceDate)
.ToList();
var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.Date >= start && e.Date <= end,
false,
e => e.Vendor,
e => e.ExpenseAccount,
e => e.PaymentAccount))
.OrderBy(e => e.Date)
.ToList();
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
var customers = (await _unitOfWork.Customers.GetAllAsync())
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToList();
// ── Build ZIP ─────────────────────────────────────────────────────────
using var ms = new MemoryStream();
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
if (format == "quickbooks")
{
AddEntry(zip, "1_customers.iif", BuildIifCustomers(customers));
AddEntry(zip, "2_invoices_payments.iif", BuildIifInvoicesAndPayments(invoices));
AddEntry(zip, "3_expenses_bills.iif", BuildIifExpensesAndBills(expenses, bills));
AddEntry(zip, "README.txt", BuildReadme("QuickBooks Desktop (IIF)", startDate, endDate));
}
else
{
AddEntry(zip, "customers.csv", BuildCsvCustomers(customers));
AddEntry(zip, "invoices.csv", BuildCsvInvoices(invoices));
AddEntry(zip, "invoice_line_items.csv", BuildCsvInvoiceLineItems(invoices));
AddEntry(zip, "payments_received.csv", BuildCsvPayments(invoices));
AddEntry(zip, "expenses.csv", BuildCsvExpenses(expenses));
AddEntry(zip, "bills.csv", BuildCsvBills(bills));
AddEntry(zip, "bill_payments.csv", BuildCsvBillPayments(bills));
AddEntry(zip, "README.txt", BuildReadme("CSV", startDate, endDate));
}
}
var fileName = $"accounting-export-{startDate:yyyy-MM-dd}-to-{endDate:yyyy-MM-dd}.zip";
await _auditService.LogAsync("Exported", "AccountingExport",
$"{format} export {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}",
new { format, startDate = startDate.ToString("yyyy-MM-dd"), endDate = endDate.ToString("yyyy-MM-dd"),
invoiceCount = invoices.Count, billCount = bills.Count, expenseCount = expenses.Count });
return File(ms.ToArray(), "application/zip", fileName);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Writes a UTF-8 text file entry into a <see cref="ZipArchive"/>. Using
/// <c>CompressionLevel.Optimal</c> yields significant size reductions on repetitive CSV/IIF
/// text without meaningful latency for export files of this scale.
/// </summary>
private static void AddEntry(ZipArchive zip, string name, string content)
{
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
/// <summary>
/// Formats a single value for inclusion in a CSV field. Values containing commas, double
/// quotes, or newlines are wrapped in double quotes and internal double quotes are escaped by
/// doubling them (RFC 4180 compliance).
/// </summary>
private static string CsvVal(object? v)
{
if (v == null) return "";
var s = v.ToString() ?? "";
if (s.Contains(',') || s.Contains('"') || s.Contains('\n'))
return $"\"{s.Replace("\"", "\"\"")}\"";
return s;
}
/// <summary>
/// Joins a set of field values into a single RFC 4180-compliant CSV row by applying
/// <see cref="CsvVal"/> to each field and joining with commas.
/// </summary>
private static string CsvRow(params object?[] fields)
=> string.Join(",", fields.Select(CsvVal));
/// <summary>
/// Formats a date in the <c>MM/dd/yyyy</c> format required by QuickBooks IIF transaction
/// records. QuickBooks Desktop does not accept ISO 8601 dates in IIF imports.
/// </summary>
private static string IifDate(DateTime d) => d.ToString("MM/dd/yyyy");
/// <summary>
/// Returns the display name for a customer: company name when available, otherwise
/// first + last name concatenated. This mirrors the display logic in the UI and ensures
/// IIF/CSV records identify the customer consistently across all export files.
/// </summary>
private static string CustomerName(PowderCoating.Core.Entities.Customer? c)
{
if (c == null) return "Unknown";
return !string.IsNullOrWhiteSpace(c.CompanyName)
? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
}
// ── IIF Builders ──────────────────────────────────────────────────────────
/// <summary>
/// Builds the QuickBooks IIF customer list file. Each customer is emitted as a <c>CUST</c>
/// record row with the <c>!CUST</c> header. The <c>TAXABLE</c> field is the inverse of
/// <c>IsTaxExempt</c>: a tax-exempt customer is "N" (not taxable), consistent with how
/// QuickBooks represents taxability.
/// </summary>
private static string BuildIifCustomers(List<PowderCoating.Core.Entities.Customer> customers)
{
var sb = new StringBuilder();
sb.AppendLine("!CUST\tNAME\tCOMPANYNAME\tFIRSTNAME\tLASTNAME\tBILLADDR1\tBILLADDR2\tPHONE1\tEMAIL\tTERMS\tTAXABLE\tLIMIT");
foreach (var c in customers)
{
var name = CustomerName(c);
var addr2 = !string.IsNullOrWhiteSpace(c.City)
? $"{c.City}, {c.State} {c.ZipCode}".Trim()
: "";
sb.Append("CUST\t");
sb.Append($"{name}\t");
sb.Append($"{c.CompanyName ?? ""}\t");
sb.Append($"{c.ContactFirstName ?? ""}\t");
sb.Append($"{c.ContactLastName ?? ""}\t");
sb.Append($"{c.Address ?? ""}\t");
sb.Append($"{addr2}\t");
sb.Append($"{c.Phone ?? ""}\t");
sb.Append($"{c.Email ?? ""}\t");
sb.Append($"{c.PaymentTerms ?? ""}\t");
sb.Append(c.IsTaxExempt ? "N\t" : "Y\t");
sb.AppendLine($"{c.CreditLimit}");
}
return sb.ToString();
}
/// <summary>
/// Builds the IIF file for invoices and their associated payments. Each invoice is emitted
/// as an <c>INVOICE</c> transaction: a <c>TRNS</c> header row debiting Accounts Receivable,
/// followed by <c>SPL</c> lines crediting the Sales account for each line item (amounts are
/// negated on split lines because IIF splits use the opposite sign from the transaction
/// header). Tax and discount lines are appended as additional split rows when applicable.
/// Each payment is emitted as a separate <c>PAYMENT</c> transaction immediately after its
/// invoice block. The bank account is determined by
/// <see cref="MapPaymentMethodToAccount"/> which maps payment method to a QB account name.
/// </summary>
private static string BuildIifInvoicesAndPayments(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO\tDUEDATE\tTERMS");
sb.AppendLine("!SPL\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO\tQNTY\tPRICE\tINVITEM");
sb.AppendLine("!ENDTRNS");
foreach (var inv in invoices)
{
var cust = CustomerName(inv.Customer);
var date = IifDate(inv.InvoiceDate);
var due = inv.DueDate.HasValue ? IifDate(inv.DueDate.Value) : date;
var terms = inv.Terms ?? "";
sb.AppendLine($"TRNS\tINVOICE\t{date}\tAccounts Receivable\t{cust}\t{inv.Total}\t{inv.InvoiceNumber}\t{inv.Notes ?? ""}\t{due}\t{terms}");
foreach (var item in inv.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
sb.AppendLine($"SPL\tINVOICE\t{date}\tSales\t{cust}\t{-item.TotalPrice}\t{inv.InvoiceNumber}\t{item.Description}\t{item.Quantity}\t{item.UnitPrice}\t{item.Description}");
}
if (inv.TaxAmount > 0)
sb.AppendLine($"SPL\tINVOICE\t{date}\tSales Tax Payable\t{cust}\t{-inv.TaxAmount}\t{inv.InvoiceNumber}\tSales Tax\t1\t{inv.TaxAmount}\tSales Tax");
if (inv.DiscountAmount > 0)
sb.AppendLine($"SPL\tINVOICE\t{date}\tDiscounts Given\t{cust}\t{inv.DiscountAmount}\t{inv.InvoiceNumber}\tDiscount\t1\t{-inv.DiscountAmount}\tDiscount");
sb.AppendLine("ENDTRNS");
// Payments against this invoice
foreach (var pmt in inv.Payments)
{
var pmtDate = IifDate(pmt.PaymentDate);
var pmtMethod = MapPaymentMethodToAccount(pmt.PaymentMethod);
var memo = $"Payment for {inv.InvoiceNumber}";
sb.AppendLine($"TRNS\tPAYMENT\t{pmtDate}\t{pmtMethod}\t{cust}\t{pmt.Amount}\t{pmt.Reference ?? ""}\t{memo}");
sb.AppendLine($"SPL\tPAYMENT\t{pmtDate}\tAccounts Receivable\t{cust}\t{-pmt.Amount}\t{pmt.Reference ?? ""}\t{memo}");
sb.AppendLine("ENDTRNS");
}
}
return sb.ToString();
}
/// <summary>
/// Builds the IIF file for direct expenses and vendor bills. Direct expenses are emitted as
/// <c>CHECK</c> transactions (payment account → expense account), while vendor bills are
/// emitted as <c>BILL</c> transactions (Accounts Payable → per-line expense accounts). Each
/// bill payment is appended as a <c>BILLPMT</c> transaction after its parent bill, referencing
/// the actual bank account stored on the <c>BillPayment</c> record (or "Checking" as a
/// fallback when no bank account is linked).
/// </summary>
private static string BuildIifExpensesAndBills(
List<PowderCoating.Core.Entities.Expense> expenses,
List<PowderCoating.Core.Entities.Bill> bills)
{
var sb = new StringBuilder();
sb.AppendLine("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO");
sb.AppendLine("!SPL\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO");
sb.AppendLine("!ENDTRNS");
foreach (var exp in expenses)
{
var date = IifDate(exp.Date);
var vendor = exp.Vendor?.CompanyName ?? "Unknown Vendor";
var payAcct = exp.PaymentAccount?.Name ?? "Checking";
var expAcct = exp.ExpenseAccount?.Name ?? "General Expenses";
sb.AppendLine($"TRNS\tCHECK\t{date}\t{payAcct}\t{vendor}\t{-exp.Amount}\t{exp.ExpenseNumber}\t{exp.Memo ?? ""}");
sb.AppendLine($"SPL\tCHECK\t{date}\t{expAcct}\t{vendor}\t{exp.Amount}\t{exp.ExpenseNumber}\t{exp.Memo ?? ""}");
sb.AppendLine("ENDTRNS");
}
foreach (var bill in bills)
{
var date = IifDate(bill.BillDate);
var vendor = bill.Vendor?.CompanyName ?? "Unknown Vendor";
sb.AppendLine($"TRNS\tBILL\t{date}\tAccounts Payable\t{vendor}\t{-bill.Total}\t{bill.BillNumber}\t{bill.Memo ?? ""}");
foreach (var line in bill.LineItems.OrderBy(l => l.DisplayOrder))
{
var acct = line.Account?.Name ?? "General Expenses";
sb.AppendLine($"SPL\tBILL\t{date}\t{acct}\t{vendor}\t{line.Amount}\t{bill.BillNumber}\t{line.Description}");
}
sb.AppendLine("ENDTRNS");
foreach (var pmt in bill.Payments)
{
var pmtDate = IifDate(pmt.PaymentDate);
sb.AppendLine($"TRNS\tBILLPMT\t{pmtDate}\t{pmt.BankAccount?.Name ?? "Checking"}\t{vendor}\t{-pmt.Amount}\t{pmt.PaymentNumber}\t{pmt.Memo ?? ""}");
sb.AppendLine($"SPL\tBILLPMT\t{pmtDate}\tAccounts Payable\t{vendor}\t{pmt.Amount}\t{pmt.PaymentNumber}\t{pmt.Memo ?? ""}");
sb.AppendLine("ENDTRNS");
}
}
return sb.ToString();
}
// ── CSV Builders ──────────────────────────────────────────────────────────
/// <summary>
/// Builds a CSV file of all customers in the company with contact info, tax status, credit
/// limit, and current balance. Suitable for import into any CRM or accounting system.
/// </summary>
private static string BuildCsvCustomers(List<PowderCoating.Core.Entities.Customer> customers)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Name", "Company", "First Name", "Last Name", "Email", "Phone",
"Address", "City", "State", "Zip", "Country", "Payment Terms",
"Tax Exempt", "Credit Limit", "Current Balance"));
foreach (var c in customers)
{
sb.AppendLine(CsvRow(
CustomerName(c), c.CompanyName, c.ContactFirstName, c.ContactLastName,
c.Email, c.Phone, c.Address, c.City, c.State, c.ZipCode, c.Country,
c.PaymentTerms, c.IsTaxExempt ? "Yes" : "No",
c.CreditLimit.ToString("F2"), c.CurrentBalance.ToString("F2")));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of invoice headers with subtotals, tax, discounts, and balance due. Line
/// items are exported separately in <see cref="BuildCsvInvoiceLineItems"/> to keep the header
/// CSV clean and allow joining by invoice number in the target system.
/// </summary>
private static string BuildCsvInvoices(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Invoice Number", "Customer", "Invoice Date", "Due Date",
"Terms", "Status", "Sub Total", "Tax %", "Tax Amount",
"Discount", "Total", "Amount Paid", "Balance Due", "Notes", "Customer PO"));
foreach (var inv in invoices)
{
sb.AppendLine(CsvRow(
inv.InvoiceNumber, CustomerName(inv.Customer),
inv.InvoiceDate.ToString("yyyy-MM-dd"),
inv.DueDate?.ToString("yyyy-MM-dd"),
inv.Terms, inv.Status.ToString(),
inv.SubTotal.ToString("F2"), inv.TaxPercent.ToString("F2"),
inv.TaxAmount.ToString("F2"), inv.DiscountAmount.ToString("F2"),
inv.Total.ToString("F2"), inv.AmountPaid.ToString("F2"),
inv.BalanceDue.ToString("F2"), inv.Notes, inv.CustomerPO));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of invoice line items with invoice number and customer name repeated on each
/// row so the file can be used standalone without requiring a join to the invoice header CSV.
/// Items are ordered by <c>DisplayOrder</c> to preserve the sequence from the original invoice.
/// </summary>
private static string BuildCsvInvoiceLineItems(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Invoice Number", "Customer", "Invoice Date", "Description",
"Quantity", "Unit Price", "Total Price", "Notes"));
foreach (var inv in invoices)
{
var cust = CustomerName(inv.Customer);
var date = inv.InvoiceDate.ToString("yyyy-MM-dd");
foreach (var item in inv.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
sb.AppendLine(CsvRow(
inv.InvoiceNumber, cust, date,
item.Description, item.Quantity.ToString("F2"),
item.UnitPrice.ToString("F2"), item.TotalPrice.ToString("F2"),
item.Notes));
}
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of customer payments received, one row per payment. Derived from the same
/// invoice collection as other invoice CSVs so the data is consistent for the export date
/// range. Payments are ordered by date within each invoice.
/// </summary>
private static string BuildCsvPayments(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Invoice Number", "Customer", "Payment Date",
"Amount", "Payment Method", "Reference", "Notes"));
foreach (var inv in invoices)
{
var cust = CustomerName(inv.Customer);
foreach (var pmt in inv.Payments.OrderBy(p => p.PaymentDate))
{
sb.AppendLine(CsvRow(
inv.InvoiceNumber, cust,
pmt.PaymentDate.ToString("yyyy-MM-dd"),
pmt.Amount.ToString("F2"),
FormatPaymentMethod(pmt.PaymentMethod),
pmt.Reference, pmt.Notes));
}
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of direct expenses with vendor, account categorisation, and payment method.
/// These represent cash/card purchases that do not go through the AP workflow.
/// </summary>
private static string BuildCsvExpenses(List<PowderCoating.Core.Entities.Expense> expenses)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Expense Number", "Date", "Vendor", "Expense Account",
"Payment Account", "Payment Method", "Amount", "Memo"));
foreach (var e in expenses)
{
sb.AppendLine(CsvRow(
e.ExpenseNumber, e.Date.ToString("yyyy-MM-dd"),
e.Vendor?.CompanyName ?? "",
e.ExpenseAccount?.Name ?? "",
e.PaymentAccount?.Name ?? "",
FormatPaymentMethod(e.PaymentMethod),
e.Amount.ToString("F2"), e.Memo));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of vendor bill headers (AP entries) with status, amounts, and balance due.
/// Line item detail is not included here to keep the file flat; use the IIF export for
/// per-line categorisation data or query the database directly.
/// </summary>
private static string BuildCsvBills(List<PowderCoating.Core.Entities.Bill> bills)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Bill Number", "Vendor Invoice #", "Vendor", "Bill Date",
"Due Date", "Status", "Sub Total", "Tax Amount", "Total",
"Amount Paid", "Balance Due", "Memo"));
foreach (var b in bills)
{
sb.AppendLine(CsvRow(
b.BillNumber, b.VendorInvoiceNumber, b.Vendor?.CompanyName ?? "",
b.BillDate.ToString("yyyy-MM-dd"), b.DueDate?.ToString("yyyy-MM-dd"),
b.Status.ToString(),
b.SubTotal.ToString("F2"), b.TaxAmount.ToString("F2"),
b.Total.ToString("F2"), b.AmountPaid.ToString("F2"),
b.BalanceDue.ToString("F2"), b.Memo));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of vendor bill payments, one row per payment. The bill number and vendor name
/// are repeated on each row so the file is self-contained for AP payment reconciliation
/// without requiring a join to the bills CSV.
/// </summary>
private static string BuildCsvBillPayments(List<PowderCoating.Core.Entities.Bill> bills)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Payment Number", "Bill Number", "Vendor", "Payment Date",
"Amount", "Payment Method", "Check Number", "Memo"));
foreach (var b in bills)
{
foreach (var pmt in b.Payments.OrderBy(p => p.PaymentDate))
{
sb.AppendLine(CsvRow(
pmt.PaymentNumber, b.BillNumber, b.Vendor?.CompanyName ?? "",
pmt.PaymentDate.ToString("yyyy-MM-dd"),
pmt.Amount.ToString("F2"),
FormatPaymentMethod(pmt.PaymentMethod),
pmt.CheckNumber, pmt.Memo));
}
}
return sb.ToString();
}
// ── README ────────────────────────────────────────────────────────────────
/// <summary>
/// Generates a human-readable README.txt file included in the export ZIP. For QuickBooks
/// exports it documents the required import order (customers → invoices → expenses/bills)
/// and warns that IIF account names must match the company's QB chart of accounts exactly.
/// For CSV exports it lists all files and their contents.
/// </summary>
private static string BuildReadme(string format, DateTime start, DateTime end)
{
var sb = new StringBuilder();
sb.AppendLine("ACCOUNTING DATA EXPORT");
sb.AppendLine("======================");
sb.AppendLine($"Format: {format}");
sb.AppendLine($"Period: {start:MMMM d, yyyy} {end:MMMM d, yyyy}");
sb.AppendLine($"Generated: {DateTime.Now:MMMM d, yyyy h:mm tt}");
sb.AppendLine();
if (format.Contains("QuickBooks"))
{
sb.AppendLine("IMPORT ORDER (QuickBooks Desktop)");
sb.AppendLine("---------------------------------");
sb.AppendLine("1. File > Utilities > Import > IIF Files");
sb.AppendLine("2. Import in this order:");
sb.AppendLine(" a. 1_customers.iif");
sb.AppendLine(" b. 2_invoices_payments.iif");
sb.AppendLine(" c. 3_expenses_bills.iif");
sb.AppendLine();
sb.AppendLine("NOTE: Review your Chart of Accounts in QuickBooks before importing.");
sb.AppendLine(" Account names in the IIF files must match your QB accounts exactly.");
}
else
{
sb.AppendLine("FILES INCLUDED");
sb.AppendLine("--------------");
sb.AppendLine("customers.csv Customer list with balances");
sb.AppendLine("invoices.csv Invoice headers");
sb.AppendLine("invoice_line_items.csv Invoice line item detail");
sb.AppendLine("payments_received.csv Customer payments");
sb.AppendLine("expenses.csv Direct expenses");
sb.AppendLine("bills.csv Vendor bills (AP)");
sb.AppendLine("bill_payments.csv Payments made to vendors");
}
return sb.ToString();
}
// ── Enum helpers ──────────────────────────────────────────────────────────
/// <summary>
/// Maps a <see cref="PaymentMethod"/> enum value to the standard QuickBooks account name
/// used in IIF transaction records. QuickBooks uses account names (not IDs) in IIF imports,
/// so these strings must match the company's QB chart of accounts exactly. Cash maps to
/// "Petty Cash" and card/digital payments map to "Merchant Account" per common QB conventions.
/// </summary>
private static string MapPaymentMethodToAccount(PowderCoating.Core.Enums.PaymentMethod method) => method switch
{
PowderCoating.Core.Enums.PaymentMethod.Cash => "Petty Cash",
PowderCoating.Core.Enums.PaymentMethod.Check => "Checking",
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Merchant Account",
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "Checking",
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Merchant Account",
_ => "Undeposited Funds"
};
/// <summary>
/// Formats a <see cref="PaymentMethod"/> enum value as a human-readable string for CSV
/// output. Unlike <see cref="MapPaymentMethodToAccount"/>, this returns a descriptive label
/// (e.g. "ACH / Bank Transfer") rather than a QB account name, suitable for display in
/// spreadsheets and non-QB accounting tools.
/// </summary>
private static string FormatPaymentMethod(PowderCoating.Core.Enums.PaymentMethod method) => method switch
{
PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Credit/Debit Card",
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH / Bank Transfer",
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital Payment",
_ => method.ToString()
};
}