feat(1099): add Form1099ReportService cash-basis annual aggregation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user