feat(1099): add Form1099ReportService cash-basis annual aggregation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-25 16:53:28 -07:00
parent 9aa64b5f4c
commit 0754ed8d69
3 changed files with 215 additions and 0 deletions
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using System.Security.Claims;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form1099ReportServiceTests
{
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
var accessorMock = new Mock<IHttpContextAccessor>();
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options);
}
private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId)
{
var db = NewDb();
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" });
var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 };
var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 };
db.Form1099Boxes.AddRange(nec, rent);
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null });
db.SaveChanges();
necSubId = 1; rentSubId = 2; salarySubId = 3;
return db;
}
private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn)
{
var e = new Expense
{
MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId,
Amount = amount, Description = "x", ExpenseDate = paidOn,
PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ],
};
db.Expenses.Add(e);
db.SaveChanges();
}
[Fact]
public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9()
{
var db = Seeded(out var necSub, out var rentSub, out _);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" });
db.SaveChanges();
AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1));
AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1));
var svc = new Form1099ReportService(db);
var sum = await svc.GetAnnualSummaryAsync(2026);
var row = Assert.Single(sum.Rows);
Assert.Equal(700m, row.NecTotal);
Assert.Equal(500m, row.RentsTotal);
Assert.Equal(1200m, row.GrandTotal);
Assert.True(row.MeetsThreshold);
Assert.True(row.W9Missing);
Assert.Equal(1, sum.RecipientsAtThreshold);
Assert.Equal(1, sum.RecipientsMissingW9);
}
[Fact]
public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year()
{
var db = Seeded(out var necSub, out _, out var salarySub);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" });
db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" });
db.SaveChanges();
AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked
AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box
AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
Assert.Empty(sum.Rows);
}
[Fact]
public async Task Threshold_flag_is_false_below_600()
{
var db = Seeded(out var necSub, out _, out _);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" });
db.SaveChanges();
AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1));
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
var row = Assert.Single(sum.Rows);
Assert.False(row.MeetsThreshold);
Assert.False(row.W9Missing);
Assert.Equal(0, sum.RecipientsAtThreshold);
}
}
@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Read-only aggregation producing the year-end 1099 recipient summary. CASH BASIS:
/// only Paid expenses whose PaidAt falls in the tax year, attributed to a tracked payee,
/// on a line whose category maps to a 1099 box (sub ?? group). Unmapped lines are excluded.
/// </summary>
public class Form1099ReportService : IForm1099ReportService
{
private readonly AppDbContext _db;
public Form1099ReportService(AppDbContext db) => _db = db;
public async Task<List<Form1099BoxDto>> GetBoxesAsync() =>
await _db.Form1099Boxes.AsNoTracking().Where(b => b.IsActive)
.OrderBy(b => b.SortOrder)
.Select(b => new Form1099BoxDto
{
Id = b.Id, BoxCode = b.BoxCode, Name_en = b.Name_en,
Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder,
}).ToListAsync();
private IQueryable<PaidLine> ReportableLines(int taxYear) =>
from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null
&& e.PaidAt.Value.Year == taxYear && e.PayeeId != null)
join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id
join l in _db.ExpenseLines on e.Id equals l.ExpenseId
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
select new PaidLine
{
PayeeId = p.Id,
LegalName = p.LegalName,
TinLast4 = p.TinLast4,
W9Status = p.W9Status,
PaidAt = e.PaidAt!.Value,
Description = e.Description,
CategoryName = grp.Name_en + " / " + sub.Name_en,
Amount = l.Amount,
BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId,
};
public async Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear)
{
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
var lines = await ReportableLines(taxYear).Where(x => x.BoxId != null).ToListAsync();
var dto = new Form1099SummaryDto { TaxYear = taxYear };
foreach (var g in lines.GroupBy(x => x.PayeeId))
{
var first = g.First();
var nec = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxNec1).Sum(x => x.Amount);
var rents = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxMisc1).Sum(x => x.Amount);
var w9Missing = first.W9Status != Form1099.W9Status.OnFile;
var meets = nec >= Form1099.ReportingThreshold || rents >= Form1099.ReportingThreshold;
dto.Rows.Add(new Form1099RecipientRowDto
{
PayeeId = first.PayeeId, LegalName = first.LegalName, TinLast4 = first.TinLast4,
W9Status = first.W9Status, NecTotal = nec, RentsTotal = rents,
GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing,
});
}
dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ToList();
dto.TotalReportable = dto.Rows.Sum(r => r.GrandTotal);
dto.RecipientsAtThreshold = dto.Rows.Count(r => r.MeetsThreshold);
dto.RecipientsMissingW9 = dto.Rows.Count(r => r.W9Missing);
return dto;
}
public async Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
{
var payee = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(p => p.Id == payeeId);
if (payee is null) return null;
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
var lines = await ReportableLines(taxYear).Where(x => x.PayeeId == payeeId && x.BoxId != null).ToListAsync();
return new Form1099RecipientDetailDto
{
PayeeId = payee.Id, LegalName = payee.LegalName, TinLast4 = payee.TinLast4,
W9Status = payee.W9Status, TaxYear = taxYear,
Payments = lines.OrderBy(x => x.PaidAt).Select(x => new Form1099PaymentDto
{
PaidDate = DateOnly.FromDateTime(x.PaidAt.Date).ToString("yyyy-MM-dd"),
Description = x.Description, CategoryName = x.CategoryName,
BoxCode = boxes.GetValueOrDefault(x.BoxId!.Value) ?? "", Amount = x.Amount,
}).ToList(),
};
}
private sealed class PaidLine
{
public int PayeeId; public string LegalName = ""; public string? TinLast4; public string W9Status = "";
public DateTimeOffset PaidAt; public string Description = ""; public string CategoryName = "";
public decimal Amount; public int? BoxId;
}
}
@@ -0,0 +1,9 @@
using ROLAC.API.DTOs.Finance;
namespace ROLAC.API.Services;
public interface IForm1099ReportService
{
Task<List<Form1099BoxDto>> GetBoxesAsync();
Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear);
Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear);
}