feat(1099): add I1099FormService with filing CSV export + Copy B PDF

Adds I1099FormService and Form1099FormService: an IRIS/accountant filing-data
CSV (one row per reportable recipient) and a plain-paper recipient Copy B
1099-NEC PDF rendered via the DevExpress RichEdit/Office API (mirroring
CheckPrintService). Includes a CSV-export unit test over a stub report service.

Service lives in namespace ROLAC.API.Services (not ...Services.Form1099) to
avoid shadowing the ROLAC.API.Entities.Form1099 constants class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-25 17:20:52 -07:00
parent 556abba687
commit b7eb95056d
3 changed files with 243 additions and 0 deletions
@@ -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
{
/// <summary>Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export.</summary>
private sealed class StubReportService : IForm1099ReportService
{
private readonly Form1099SummaryDto _summary;
public StubReportService(Form1099SummaryDto summary) => _summary = summary;
public Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary);
public Task<List<Form1099BoxDto>> GetBoxesAsync() => throw new NotImplementedException();
public Task<Form1099RecipientDetailDto?> 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));
}
}
@@ -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;
/// <summary>
/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the
/// DevExpress RichEdit/Office API, mirroring <c>CheckPrintService</c>) and a filing-data CSV.
/// </summary>
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
"<div style=\"font-family:Arial;font-size:11pt;color:#111;\">" +
$"<h2 style=\"text-align:center;margin:0;\">Form 1099-NEC &mdash; Copy B (For Recipient)</h2>" +
$"<p style=\"text-align:center;margin:4px 0 16px 0;\"><b>Tax Year {taxYear}</b><br/>Nonemployee Compensation</p>" +
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"6\" width=\"100%\" style=\"border-collapse:collapse;\">" +
"<tr><td width=\"50%\" valign=\"top\">" +
"<b>PAYER&rsquo;s name, address</b><br/>" +
$"{Encode(church.Name)}<br/>{payerAddress}" +
"</td>" +
"<td width=\"50%\" valign=\"top\">" +
"<b>PAYER&rsquo;s TIN (EIN)</b><br/>&nbsp;" +
"</td></tr>" +
"<tr><td valign=\"top\">" +
"<b>RECIPIENT&rsquo;s name, address</b><br/>" +
$"{Encode(payee.LegalName)}<br/>{recipientAddress}" +
"</td>" +
"<td valign=\"top\">" +
$"<b>RECIPIENT&rsquo;s TIN</b><br/>{Encode(maskedTin)}" +
"</td></tr>" +
"<tr><td colspan=\"2\">" +
"<b>Box 1 &mdash; Nonemployee compensation</b><br/>" +
$"<span style=\"font-size:14pt;\"><b>{Encode(FormatCurrency(box1Nec))}</b></span>" +
"</td></tr>" +
"</table>" +
"<p style=\"font-size:8pt;color:#555;margin-top:12px;\">" +
"This is important tax information and is being furnished to the recipient. " +
"Recipient&rsquo;s taxpayer identification number is shown masked for security." +
"</p>" +
"</div>";
}
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 (<br/>) 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("<br/>", lines);
}
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.Services;
public interface I1099FormService
{
/// <summary>Recipient Copy B 1099-NEC PDF for one payee/year (plain paper).</summary>
Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear);
/// <summary>Filing-data CSV (one row per reportable recipient) for IRIS/accountant.</summary>
Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear);
}