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:
@@ -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 — 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’s name, address</b><br/>" +
|
||||
$"{Encode(church.Name)}<br/>{payerAddress}" +
|
||||
"</td>" +
|
||||
"<td width=\"50%\" valign=\"top\">" +
|
||||
"<b>PAYER’s TIN (EIN)</b><br/> " +
|
||||
"</td></tr>" +
|
||||
|
||||
"<tr><td valign=\"top\">" +
|
||||
"<b>RECIPIENT’s name, address</b><br/>" +
|
||||
$"{Encode(payee.LegalName)}<br/>{recipientAddress}" +
|
||||
"</td>" +
|
||||
"<td valign=\"top\">" +
|
||||
$"<b>RECIPIENT’s TIN</b><br/>{Encode(maskedTin)}" +
|
||||
"</td></tr>" +
|
||||
|
||||
"<tr><td colspan=\"2\">" +
|
||||
"<b>Box 1 — 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’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);
|
||||
}
|
||||
Reference in New Issue
Block a user