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