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;
}
///
/// 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.
///
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();
}
///
/// 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.
///
/// QuickBooks IIF: 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 (!CUST, !TRNS, !SPL, !ENDTRNS) 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.
///
///
/// CSV: 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.
///
/// The end date is extended to end-of-day (AddDays(1).AddTicks(-1)) so that records
/// created at any time on the end date are included. The ZIP is streamed directly from a
/// MemoryStream (not written to disk) to avoid temporary file management on the server.
///
[HttpPost]
[ValidateAntiForgeryToken]
public async Task 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 ───────────────────────────────────────────────────────────────
///
/// Writes a UTF-8 text file entry into a . Using
/// CompressionLevel.Optimal yields significant size reductions on repetitive CSV/IIF
/// text without meaningful latency for export files of this scale.
///
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);
}
///
/// 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).
///
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;
}
///
/// Joins a set of field values into a single RFC 4180-compliant CSV row by applying
/// to each field and joining with commas.
///
private static string CsvRow(params object?[] fields)
=> string.Join(",", fields.Select(CsvVal));
///
/// Formats a date in the MM/dd/yyyy format required by QuickBooks IIF transaction
/// records. QuickBooks Desktop does not accept ISO 8601 dates in IIF imports.
///
private static string IifDate(DateTime d) => d.ToString("MM/dd/yyyy");
///
/// 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.
///
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 ──────────────────────────────────────────────────────────
///
/// Builds the QuickBooks IIF customer list file. Each customer is emitted as a CUST
/// record row with the !CUST header. The TAXABLE field is the inverse of
/// IsTaxExempt: a tax-exempt customer is "N" (not taxable), consistent with how
/// QuickBooks represents taxability.
///
private static string BuildIifCustomers(List 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();
}
///
/// Builds the IIF file for invoices and their associated payments. Each invoice is emitted
/// as an INVOICE transaction: a TRNS header row debiting Accounts Receivable,
/// followed by SPL 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 PAYMENT transaction immediately after its
/// invoice block. The bank account is determined by
/// which maps payment method to a QB account name.
///
private static string BuildIifInvoicesAndPayments(List 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();
}
///
/// Builds the IIF file for direct expenses and vendor bills. Direct expenses are emitted as
/// CHECK transactions (payment account → expense account), while vendor bills are
/// emitted as BILL transactions (Accounts Payable → per-line expense accounts). Each
/// bill payment is appended as a BILLPMT transaction after its parent bill, referencing
/// the actual bank account stored on the BillPayment record (or "Checking" as a
/// fallback when no bank account is linked).
///
private static string BuildIifExpensesAndBills(
List expenses,
List 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 ──────────────────────────────────────────────────────────
///
/// 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.
///
private static string BuildCsvCustomers(List 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();
}
///
/// Builds a CSV of invoice headers with subtotals, tax, discounts, and balance due. Line
/// items are exported separately in to keep the header
/// CSV clean and allow joining by invoice number in the target system.
///
private static string BuildCsvInvoices(List 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();
}
///
/// 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 DisplayOrder to preserve the sequence from the original invoice.
///
private static string BuildCsvInvoiceLineItems(List 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();
}
///
/// 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.
///
private static string BuildCsvPayments(List 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();
}
///
/// 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.
///
private static string BuildCsvExpenses(List 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();
}
///
/// 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.
///
private static string BuildCsvBills(List 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();
}
///
/// 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.
///
private static string BuildCsvBillPayments(List 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 ────────────────────────────────────────────────────────────────
///
/// 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.
///
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 ──────────────────────────────────────────────────────────
///
/// Maps a 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.
///
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"
};
///
/// Formats a enum value as a human-readable string for CSV
/// output. Unlike , 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.
///
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()
};
}