diff --git a/API/ROLAC.API/Services/Form1099ReportService.cs b/API/ROLAC.API/Services/Form1099ReportService.cs index 51d3e4d..e237e8e 100644 --- a/API/ROLAC.API/Services/Form1099ReportService.cs +++ b/API/ROLAC.API/Services/Form1099ReportService.cs @@ -24,30 +24,58 @@ public class Form1099ReportService : IForm1099ReportService Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder, }).ToListAsync(); - private IQueryable 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, - }; + /// + /// Pulls the reportable expense lines for the tax year and materializes them (anonymous + /// projection -> ToListAsync -> in-memory map), mirroring Form990ReportService so the SQL + /// translation stays simple on Npgsql. The tax year is a half-open UTC range + /// [Jan 1 taxYear, Jan 1 taxYear+1), deterministic regardless of server timezone and matching + /// how Expense.PaidAt is written (midnight UTC). Unmapped lines (no 1099 box) are dropped here + /// so callers always receive reportable lines. + /// + private async Task> LoadReportableLinesAsync(int taxYear) + { + var start = new DateTimeOffset(new DateTime(taxYear, 1, 1), TimeSpan.Zero); + var end = start.AddYears(1); + var raw = await ( + from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null + && e.PaidAt >= start && e.PaidAt < end && 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 + { + PayeeId = p.Id, + p.LegalName, + p.TinLast4, + p.W9Status, + PaidAt = e.PaidAt!.Value, + e.Description, + GroupName = grp.Name_en, + SubName = sub.Name_en, + l.Amount, + BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId, + }).ToListAsync(); + + return raw.Where(x => x.BoxId != null) + .Select(x => new PaidLine + { + PayeeId = x.PayeeId, + LegalName = x.LegalName, + TinLast4 = x.TinLast4, + W9Status = x.W9Status, + PaidAt = x.PaidAt, + Description = x.Description, + CategoryName = x.GroupName + " / " + x.SubName, + Amount = x.Amount, + BoxId = x.BoxId, + }).ToList(); + } public async Task 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 lines = await LoadReportableLinesAsync(taxYear); var dto = new Form1099SummaryDto { TaxYear = taxYear }; foreach (var g in lines.GroupBy(x => x.PayeeId)) @@ -64,7 +92,7 @@ public class Form1099ReportService : IForm1099ReportService GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing, }); } - dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ToList(); + dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ThenBy(r => r.LegalName).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); @@ -76,7 +104,7 @@ public class Form1099ReportService : IForm1099ReportService 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(); + var lines = (await LoadReportableLinesAsync(taxYear)).Where(x => x.PayeeId == payeeId).ToList(); return new Form1099RecipientDetailDto { @@ -93,8 +121,14 @@ public class Form1099ReportService : IForm1099ReportService 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; + public int PayeeId { get; set; } + public string LegalName { get; set; } = ""; + public string? TinLast4 { get; set; } + public string W9Status { get; set; } = ""; + public DateTimeOffset PaidAt { get; set; } + public string Description { get; set; } = ""; + public string CategoryName { get; set; } = ""; + public decimal Amount { get; set; } + public int? BoxId { get; set; } } }