1cb7a8ca4a
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>
571 lines
28 KiB
C#
571 lines
28 KiB
C#
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()
|
||
};
|
||
}
|