diff --git a/API/ROLAC.API.Tests/Services/Form1099FormServiceTests.cs b/API/ROLAC.API.Tests/Services/Form1099FormServiceTests.cs new file mode 100644 index 0000000..b4eaf40 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Form1099FormServiceTests.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text; +using ROLAC.API.DTOs.Finance; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class Form1099FormServiceTests +{ + /// Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export. + private sealed class StubReportService : IForm1099ReportService + { + private readonly Form1099SummaryDto _summary; + public StubReportService(Form1099SummaryDto summary) => _summary = summary; + + public Task GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary); + public Task> GetBoxesAsync() => throw new NotImplementedException(); + public Task GetRecipientDetailAsync(int payeeId, int taxYear) + => throw new NotImplementedException(); + } + + private static Form1099FormService BuildService(Form1099SummaryDto summary) => + // IPayee1099Service and AppDbContext are only used by RenderCopyBAsync, not by the CSV path. + new Form1099FormService(new StubReportService(summary), payees: null!, db: null!); + + [Fact] + public async Task ExportFilingCsvAsync_WritesHeaderRowPerRecipientAndInvariantNumbers() + { + var summary = new Form1099SummaryDto + { + TaxYear = 2026, + Rows = + { + new Form1099RecipientRowDto + { + PayeeId = 1, LegalName = "Acme, LLC", TinLast4 = "1234", W9Status = "OnFile", + NecTotal = 1234.50m, RentsTotal = 0m, GrandTotal = 1234.50m, MeetsThreshold = true + }, + new Form1099RecipientRowDto + { + PayeeId = 2, LegalName = "Bob Smith", TinLast4 = "9876", W9Status = "Missing", + NecTotal = 100m, RentsTotal = 50m, GrandTotal = 150m, MeetsThreshold = false + }, + } + }; + + var service = BuildService(summary); + var (stream, contentType, fileName) = await service.ExportFilingCsvAsync(2026); + + Assert.Equal("text/csv", contentType); + Assert.Equal("1099-filing-2026.csv", fileName); + + using var reader = new StreamReader(stream, Encoding.UTF8); + var text = await reader.ReadToEndAsync(); + var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + + // Header + one data line per row. + Assert.Equal(3, lines.Length); + Assert.Equal("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold", lines[0]); + + // A value containing a comma is quoted. + Assert.StartsWith("\"Acme, LLC\",1234,OnFile,", lines[1]); + + // Invariant numeric formatting (period decimal separator) and Y/N threshold flag. + Assert.Contains("1234.50", lines[1]); + Assert.EndsWith(",Y", lines[1]); + Assert.EndsWith(",N", lines[2]); + + // Sanity: the period really is the invariant separator regardless of current culture. + Assert.Equal("1234.50", 1234.50m.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/API/ROLAC.API/Services/Form1099/Form1099FormService.cs b/API/ROLAC.API/Services/Form1099/Form1099FormService.cs new file mode 100644 index 0000000..7cc90e0 --- /dev/null +++ b/API/ROLAC.API/Services/Form1099/Form1099FormService.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Text; +using DevExpress.Office; +using DevExpress.XtraRichEdit; +using DevExpress.XtraRichEdit.API.Native; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Payee; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +/// +/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the +/// DevExpress RichEdit/Office API, mirroring CheckPrintService) and a filing-data CSV. +/// +public class Form1099FormService : I1099FormService +{ + private readonly IForm1099ReportService _report; + private readonly IPayee1099Service _payees; + private readonly AppDbContext _db; + + public Form1099FormService(IForm1099ReportService report, IPayee1099Service payees, AppDbContext db) + { + _report = report; + _payees = payees; + _db = db; + } + + public async Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear) + { + var payee = await _payees.GetByIdAsync(payeeId) + ?? throw new InvalidOperationException($"Payee {payeeId} not found."); + + var church = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync() + ?? new ChurchProfile { Name = "Church" }; + + // Box 1 (Nonemployee compensation) = sum of this payee's NEC-1 payments for the year. + var detail = await _report.GetRecipientDetailAsync(payeeId, taxYear); + var box1Nec = detail?.Payments + .Where(payment => payment.BoxCode == Entities.Form1099.BoxNec1) + .Sum(payment => payment.Amount) ?? 0m; + + using var server = new RichEditDocumentServer(); + var document = server.Document; + document.BeginUpdate(); + try + { + document.Unit = DocumentUnit.Inch; + var section = document.Sections[0]; + section.Page.Width = 8.5f; + section.Page.Height = 11f; + section.Margins.Left = section.Margins.Right = 0.8f; + section.Margins.Top = section.Margins.Bottom = 0.8f; + + document.AppendHtmlText(BuildCopyBHtml(church, payee, taxYear, box1Nec)); + } + finally + { + document.EndUpdate(); + } + + var stream = new MemoryStream(); + server.ExportToPdf(stream); + stream.Position = 0; + return (stream, "application/pdf", $"1099-NEC-{payeeId}-{taxYear}.pdf"); + } + + public async Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear) + { + var summary = await _report.GetAnnualSummaryAsync(taxYear); + var builder = new StringBuilder(); + builder.AppendLine("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold"); + foreach (var row in summary.Rows) + { + builder.AppendLine(string.Join(",", + Csv(row.LegalName), Csv(row.TinLast4 ?? ""), Csv(row.W9Status), + row.NecTotal.ToString(CultureInfo.InvariantCulture), + row.RentsTotal.ToString(CultureInfo.InvariantCulture), + row.GrandTotal.ToString(CultureInfo.InvariantCulture), + row.MeetsThreshold ? "Y" : "N")); + } + + var bytes = Encoding.UTF8.GetBytes(builder.ToString()); + return (new MemoryStream(bytes), "text/csv", $"1099-filing-{taxYear}.csv"); + + static string Csv(string value) => value.Contains(',') || value.Contains('"') + ? "\"" + value.Replace("\"", "\"\"") + "\"" : value; + } + + private static string BuildCopyBHtml(ChurchProfile church, Payee1099Dto payee, int taxYear, decimal box1Nec) + { + var payerAddress = JoinAddress(church.Address, church.City, church.State, church.ZipCode); + var recipientAddress = JoinAddress( + JoinLines(payee.AddressLine1, payee.AddressLine2), payee.City, payee.State, payee.Zip); + + // ChurchProfile has no payer EIN/TIN field, so the payer-TIN box is labelled but left blank. + var maskedTin = string.IsNullOrWhiteSpace(payee.TinLast4) ? "" : $"***-**-{payee.TinLast4}"; + + return + "
" + + $"

Form 1099-NEC — Copy B (For Recipient)

" + + $"

Tax Year {taxYear}
Nonemployee Compensation

" + + + "" + + + "" + + "" + + + "" + + "" + + + "" + + + "
" + + "PAYER’s name, address
" + + $"{Encode(church.Name)}
{payerAddress}" + + "
" + + "PAYER’s TIN (EIN)
 " + + "
" + + "RECIPIENT’s name, address
" + + $"{Encode(payee.LegalName)}
{recipientAddress}" + + "
" + + $"RECIPIENT’s TIN
{Encode(maskedTin)}" + + "
" + + "Box 1 — Nonemployee compensation
" + + $"{Encode(FormatCurrency(box1Nec))}" + + "
" + + + "

" + + "This is important tax information and is being furnished to the recipient. " + + "Recipient’s taxpayer identification number is shown masked for security." + + "

" + + "
"; + } + + private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? ""); + + private static string FormatCurrency(decimal amount) => + amount.ToString("C2", CultureInfo.GetCultureInfo("en-US")); + + private static string? JoinLines(string? line1, string? line2) + { + var parts = new[] { line1, line2 }.Where(part => !string.IsNullOrWhiteSpace(part)); + var joined = string.Join(", ", parts); + return string.IsNullOrWhiteSpace(joined) ? null : joined; + } + + // Builds an HTML address block; each text part is HTML-encoded and the line break (
) is literal. + private static string JoinAddress(string? address, string? city, string? state, string? zip) + { + var cityLine = string.Join(", ", + new[] { city, string.Join(" ", new[] { state, zip }.Where(part => !string.IsNullOrWhiteSpace(part))) } + .Where(part => !string.IsNullOrWhiteSpace(part))); + var lines = new[] { address, cityLine } + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(Encode); + return string.Join("
", lines); + } +} diff --git a/API/ROLAC.API/Services/Form1099/I1099FormService.cs b/API/ROLAC.API/Services/Form1099/I1099FormService.cs new file mode 100644 index 0000000..91e96b9 --- /dev/null +++ b/API/ROLAC.API/Services/Form1099/I1099FormService.cs @@ -0,0 +1,10 @@ +namespace ROLAC.API.Services; + +public interface I1099FormService +{ + /// Recipient Copy B 1099-NEC PDF for one payee/year (plain paper). + Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear); + + /// Filing-data CSV (one row per reportable recipient) for IRIS/accountant. + Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear); +}