Files
ROLAC/docs/superpowers/plans/2026-06-25-1099-recipient-tracking.md
T
2026-06-25 16:21:17 -07:00

63 KiB
Raw Blame History

1099 Recipient Tracking (Sub-Project B) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let the church identify independent-contractor/vendor payees, store their W-9/TIN securely, map expense categories to 1099 boxes, and produce a cash-basis year-end 1099-NEC aggregation report plus a printable recipient Copy B PDF and filing CSV.

Architecture: Mirror the proven sub-project-A "data-driven mapping" approach — a Form1099Box catalog + nullable Form1099BoxId FKs on ExpenseSubCategory/ExpenseCategoryGroup (effective box = sub ?? group ?? null), a Payee1099 master with an optional MemberId link and a nullable Expense.PayeeId, and a read-only Form1099ReportService peer of Form990ReportService. TIN is encrypted with the ASP.NET Data Protection API behind a small ITinProtector wrapper. Frontend follows existing Kendo/Tailwind admin-page conventions.

Tech Stack: C# / .NET, EF Core + PostgreSQL (EF InMemory for tests), xUnit + Moq, Angular + Kendo UI v20 + Tailwind v4, DevExpress.Document.Processor for PDF.

Spec: docs/superpowers/specs/2026-06-25-1099-recipient-tracking-design.md

Build/test note (project-build-run-env): VS locks bin/Debug; run CLI builds/tests with -c Release. Backend tests live in API/ROLAC.API.Tests.


File Structure

Backend (create):

  • API/ROLAC.API/Entities/Payee1099.cs — recipient master entity
  • API/ROLAC.API/Entities/Form1099Box.cs — 1099 box catalog entity
  • API/ROLAC.API/Services/Security/ITinProtector.cs + TinProtector.cs — TIN encrypt/decrypt + last-4
  • API/ROLAC.API/Services/IPayee1099Service.cs + Payee1099Service.cs — recipient CRUD
  • API/ROLAC.API/Services/IForm1099ReportService.cs + Form1099ReportService.cs — aggregation
  • API/ROLAC.API/Services/Form1099/I1099FormService.cs + Form1099FormService.cs — Copy B PDF + CSV
  • API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs — report + box DTOs
  • API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs — recipient DTOs
  • API/ROLAC.API/Controllers/Payee1099Controller.cs, Controllers/Form1099ReportController.cs
  • API/ROLAC.API/Entities/Form1099.cs — static constants (threshold, box codes, W9 statuses)
  • Migration under API/ROLAC.API/Migrations/
  • Tests: API/ROLAC.API.Tests/Services/{TinProtectorTests,Payee1099ServiceTests,Form1099ReportServiceTests}.cs

Backend (modify):

  • API/ROLAC.API/Entities/Expense.cs — add PayeeId
  • API/ROLAC.API/Entities/ExpenseSubCategory.cs, ExpenseCategoryGroup.cs — add Form1099BoxId
  • API/ROLAC.API/Data/AppDbContext.cs — DbSets + entity config
  • API/ROLAC.API/Data/DbSeeder.cs — box catalog + mapping seed
  • API/ROLAC.API/Authorization/Modules.cs — add Form1099
  • API/ROLAC.API/Program.cs — DI registrations + AddDataProtection
  • API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs — add PayeeId to requests/DTOs; box id to category DTOs
  • API/ROLAC.API/Services/ExpenseService.cs — persist PayeeId
  • docs/DB_SCHEMA.md — document new tables/columns

Frontend (create/modify):

  • APP/src/app/features/payee1099/ — models, api service, recipients page
  • APP/src/app/features/finance-report/pages/form1099-report-page/ — report page
  • expense-categories-page — add 1099-box dropdowns
  • expense-form-dialog — add payee picker
  • app.routes.ts, user-portal.component.ts, core/models/permission.model.ts

Task 1: Form1099 constants

Files:

  • Create: API/ROLAC.API/Entities/Form1099.cs

  • Step 1: Create the constants file

namespace ROLAC.API.Entities;

/// <summary>Shared 1099 constants. Box codes match Form1099Box.BoxCode seed values.</summary>
public static class Form1099
{
    /// <summary>IRS reporting threshold (USD) per box, per recipient, per calendar year.</summary>
    public const decimal ReportingThreshold = 600m;

    public const string BoxNec1  = "NEC-1";   // Nonemployee compensation
    public const string BoxMisc1 = "MISC-1";  // Rents

    public static class W9Status
    {
        public const string Missing   = "Missing";
        public const string Requested = "Requested";
        public const string OnFile    = "OnFile";
        public const string Expired   = "Expired";
        public static readonly IReadOnlyList<string> All = [Missing, Requested, OnFile, Expired];
    }
}
  • Step 2: Build to verify it compiles

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded.

  • Step 3: Commit
git add API/ROLAC.API/Entities/Form1099.cs
git commit -m "feat(1099): add Form1099 constants (threshold, box codes, W9 statuses)"

Task 2: Form1099Box catalog entity

Files:

  • Create: API/ROLAC.API/Entities/Form1099Box.cs

  • Step 1: Create the entity (mirrors Form990ExpenseLine)

using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;

/// <summary>A 1099 reporting box, e.g. "NEC-1 — Nonemployee compensation".</summary>
public class Form1099Box : AuditableEntity, IAuditable
{
    public int     Id        { get; set; }
    public string  BoxCode   { get; set; } = null!;   // "NEC-1", "MISC-1"
    public string  Name_en   { get; set; } = null!;
    public string? Name_zh   { get; set; }
    public string  FormType  { get; set; } = "1099-NEC";  // "1099-NEC" | "1099-MISC"
    public int     SortOrder { get; set; }
    public bool    IsActive  { get; set; } = true;
}
  • Step 2: Build

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded.

  • Step 3: Commit
git add API/ROLAC.API/Entities/Form1099Box.cs
git commit -m "feat(1099): add Form1099Box catalog entity"

Task 3: Payee1099 recipient master entity

Files:

  • Create: API/ROLAC.API/Entities/Payee1099.cs

  • Step 1: Create the entity

using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;

/// <summary>
/// A 1099 recipient (independent contractor / vendor). Holds W-9 data and an encrypted TIN.
/// Optionally linked to a Member (e.g. a part-time co-worker paid as a contractor).
/// </summary>
public class Payee1099 : SoftDeleteEntity, IAuditable
{
    public int     Id                { get; set; }
    public string  LegalName         { get; set; } = null!;     // name on the W-9
    public string? DisplayName       { get; set; }              // friendly / DBA
    public int?    MemberId          { get; set; }
    public Member? Member            { get; set; }
    public string  TaxClassification { get; set; } = "Individual"; // drives Is1099Tracked default
    public bool    Is1099Tracked     { get; set; } = true;
    public string? TinType           { get; set; }              // "SSN" | "EIN"
    public string? TinEncrypted      { get; set; }              // Data-Protection ciphertext
    public string? TinLast4          { get; set; }
    public string? AddressLine1      { get; set; }
    public string? AddressLine2      { get; set; }
    public string? City              { get; set; }
    public string? State             { get; set; }
    public string? Zip               { get; set; }
    public string? Email             { get; set; }
    public string? Phone             { get; set; }
    public string  W9Status          { get; set; } = Form1099.W9Status.Missing;
    public DateOnly? W9ReceivedDate  { get; set; }
    public string? W9BlobPath        { get; set; }
    public bool    IsActive          { get; set; } = true;
    public string? Notes             { get; set; }
}
  • Step 2: Build

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded.

  • Step 3: Commit
git add API/ROLAC.API/Entities/Payee1099.cs
git commit -m "feat(1099): add Payee1099 recipient master entity"

Task 4: Mapping FK fields on category entities + Expense.PayeeId

Files:

  • Modify: API/ROLAC.API/Entities/ExpenseSubCategory.cs, ExpenseCategoryGroup.cs, Expense.cs

  • Step 1: Add Form1099BoxId to ExpenseSubCategory

Add these properties next to the existing Form990LineId/Form990Line pair:

    public int?         Form1099BoxId { get; set; }   // null = not 1099-reportable
    public Form1099Box? Form1099Box   { get; set; }
  • Step 2: Add Form1099BoxId to ExpenseCategoryGroup

Same two properties (group-level fallback), next to its Form990LineId/Form990Line pair.

  • Step 3: Add PayeeId to Expense

Add next to the existing MemberId/Member properties:

    public int?      PayeeId { get; set; }   // 1099 recipient attribution (header-level)
    public Payee1099? Payee  { get; set; }
  • Step 4: Build

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded.

  • Step 5: Commit
git add API/ROLAC.API/Entities/ExpenseSubCategory.cs API/ROLAC.API/Entities/ExpenseCategoryGroup.cs API/ROLAC.API/Entities/Expense.cs
git commit -m "feat(1099): add Form1099BoxId mapping FKs and Expense.PayeeId"

Task 5: DbContext configuration

Files:

  • Modify: API/ROLAC.API/Data/AppDbContext.cs

  • Step 1: Add DbSets

Near the other expense/Form990 DbSets:

    public DbSet<Payee1099>  Payee1099s   => Set<Payee1099>();
    public DbSet<Form1099Box> Form1099Boxes => Set<Form1099Box>();
  • Step 2: Configure Form1099Box (mirror Form990ExpenseLine config)

In OnModelCreating, beside the Form990ExpenseLine block:

        modelBuilder.Entity<Form1099Box>(entity =>
        {
            entity.Property(e => e.BoxCode).HasMaxLength(10).IsRequired();
            entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
            entity.Property(e => e.Name_zh).HasMaxLength(200);
            entity.Property(e => e.FormType).HasMaxLength(20).IsRequired();
            entity.HasIndex(e => e.BoxCode).IsUnique();
        });
  • Step 3: Configure Payee1099
        modelBuilder.Entity<Payee1099>(entity =>
        {
            entity.HasQueryFilter(p => !p.IsDeleted);
            entity.Property(e => e.LegalName).HasMaxLength(200).IsRequired();
            entity.Property(e => e.DisplayName).HasMaxLength(200);
            entity.Property(e => e.TaxClassification).HasMaxLength(40).IsRequired();
            entity.Property(e => e.TinType).HasMaxLength(10);
            entity.Property(e => e.TinLast4).HasMaxLength(4);
            entity.Property(e => e.State).HasMaxLength(2);
            entity.Property(e => e.Zip).HasMaxLength(10);
            entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing);
            entity.HasOne(e => e.Member).WithMany()
                .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
        });
  • Step 4: Add the mapping FK relationships + Expense.Payee

Inside the existing ExpenseSubCategory config block add:

            entity.HasOne(e => e.Form1099Box).WithMany()
                .HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);

Inside ExpenseCategoryGroup config add the same (for its Form1099Box). Inside the Expense config block add:

            entity.HasOne(e => e.Payee).WithMany()
                .HasForeignKey(e => e.PayeeId).OnDelete(DeleteBehavior.SetNull);
  • Step 5: Build

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded.

  • Step 6: Commit
git add API/ROLAC.API/Data/AppDbContext.cs
git commit -m "feat(1099): configure Payee1099, Form1099Box, and mapping FKs in DbContext"

Task 6: ITinProtector (TIN encryption) — TDD

Files:

  • Create: API/ROLAC.API/Services/Security/ITinProtector.cs, TinProtector.cs

  • Test: API/ROLAC.API.Tests/Services/TinProtectorTests.cs

  • Step 1: Write the failing test

using Microsoft.AspNetCore.DataProtection;
using ROLAC.API.Services.Security;
using Xunit;

namespace ROLAC.API.Tests.Services;

public class TinProtectorTests
{
    private static TinProtector Build() =>
        new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));

    [Fact]
    public void Protect_then_Unprotect_round_trips()
    {
        var p = Build();
        var cipher = p.Protect("123-45-6789");
        Assert.NotEqual("123-45-6789", cipher);
        Assert.Equal("123-45-6789", p.Unprotect(cipher));
    }

    [Theory]
    [InlineData("123-45-6789", "6789")]
    [InlineData("12-3456789",  "6789")]
    [InlineData("7", "7")]
    public void Last4_keeps_only_trailing_digits(string raw, string expected)
        => Assert.Equal(expected, TinProtector.Last4(raw));

    [Fact]
    public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null));
}
  • Step 2: Run test to verify it fails

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter TinProtectorTests Expected: FAIL — TinProtector does not exist.

  • Step 3: Implement the interface and class

ITinProtector.cs:

namespace ROLAC.API.Services.Security;

/// <summary>Reversible protection for taxpayer identification numbers (SSN/EIN).</summary>
public interface ITinProtector
{
    string Protect(string plaintext);
    string Unprotect(string ciphertext);
}

TinProtector.cs:

using Microsoft.AspNetCore.DataProtection;

namespace ROLAC.API.Services.Security;

public class TinProtector : ITinProtector
{
    private readonly IDataProtector _protector;
    public TinProtector(IDataProtectionProvider provider)
        => _protector = provider.CreateProtector("Payee1099.Tin");

    public string Protect(string plaintext)   => _protector.Protect(plaintext);
    public string Unprotect(string ciphertext) => _protector.Unprotect(ciphertext);

    /// <summary>Last four digits of a TIN (ignoring dashes/spaces); null/empty in → null.</summary>
    public static string? Last4(string? raw)
    {
        if (string.IsNullOrWhiteSpace(raw)) return null;
        var digits = new string(raw.Where(char.IsDigit).ToArray());
        return digits.Length <= 4 ? digits : digits[^4..];
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter TinProtectorTests Expected: PASS (4 tests).

  • Step 5: Register in DI

In API/ROLAC.API/Program.cs, near the other AddScoped finance services:

builder.Services.AddDataProtection();
builder.Services.AddScoped<ITinProtector, TinProtector>();

(Add using ROLAC.API.Services.Security; if needed.)

  • Step 6: Build + commit
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
git add API/ROLAC.API/Services/Security API/ROLAC.API.Tests/Services/TinProtectorTests.cs API/ROLAC.API/Program.cs
git commit -m "feat(1099): add ITinProtector with Data Protection encryption + last-4 helper"

Task 7: Report + recipient DTOs

Files:

  • Create: API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs, API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs

  • Step 1: Create report DTOs

Form1099ReportDtos.cs:

namespace ROLAC.API.DTOs.Finance;

public class Form1099BoxDto
{
    public int    Id        { get; set; }
    public string BoxCode   { get; set; } = "";
    public string Name_en   { get; set; } = "";
    public string? Name_zh  { get; set; }
    public string FormType  { get; set; } = "";
    public int    SortOrder { get; set; }
}

public class Form1099RecipientRowDto
{
    public int     PayeeId       { get; set; }
    public string  LegalName     { get; set; } = "";
    public string? TinLast4      { get; set; }
    public string  W9Status      { get; set; } = "";
    public decimal NecTotal      { get; set; }
    public decimal RentsTotal    { get; set; }
    public decimal GrandTotal    { get; set; }
    public bool    MeetsThreshold { get; set; }
    public bool    W9Missing     { get; set; }
}

public class Form1099SummaryDto
{
    public int TaxYear { get; set; }
    public List<Form1099RecipientRowDto> Rows { get; set; } = [];
    public decimal TotalReportable        { get; set; }
    public int     RecipientsAtThreshold  { get; set; }
    public int     RecipientsMissingW9    { get; set; }
}

public class Form1099PaymentDto
{
    public string  PaidDate     { get; set; } = "";
    public string  Description  { get; set; } = "";
    public string  CategoryName { get; set; } = "";
    public string  BoxCode      { get; set; } = "";
    public decimal Amount       { get; set; }
}

public class Form1099RecipientDetailDto
{
    public int    PayeeId   { get; set; }
    public string LegalName { get; set; } = "";
    public string? TinLast4 { get; set; }
    public string W9Status  { get; set; } = "";
    public int    TaxYear   { get; set; }
    public List<Form1099PaymentDto> Payments { get; set; } = [];
}
  • Step 2: Create recipient DTOs

Payee1099Dtos.cs:

using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Payee;

public class Payee1099ListItemDto
{
    public int     Id                { get; set; }
    public string  LegalName         { get; set; } = "";
    public string? DisplayName       { get; set; }
    public int?    MemberId          { get; set; }
    public string? MemberName        { get; set; }
    public string  TaxClassification { get; set; } = "";
    public bool    Is1099Tracked     { get; set; }
    public string? TinType           { get; set; }
    public string? TinLast4          { get; set; }
    public string  W9Status          { get; set; } = "";
    public bool    IsActive          { get; set; }
}

public class Payee1099Dto : Payee1099ListItemDto
{
    public string? AddressLine1 { get; set; }
    public string? AddressLine2 { get; set; }
    public string? City  { get; set; }
    public string? State { get; set; }
    public string? Zip   { get; set; }
    public string? Email { get; set; }
    public string? Phone { get; set; }
    public string? W9ReceivedDate { get; set; }
    public bool    HasW9Document  { get; set; }
    public string? Notes { get; set; }
}

public class SavePayee1099Request
{
    [Required, MaxLength(200)] public string LegalName { get; set; } = "";
    [MaxLength(200)] public string? DisplayName { get; set; }
    public int? MemberId { get; set; }
    [Required, MaxLength(40)] public string TaxClassification { get; set; } = "Individual";
    public bool Is1099Tracked { get; set; } = true;
    [MaxLength(10)] public string? TinType { get; set; }
    /// <summary>Plain TIN; null = leave unchanged on update. Encrypted server-side.</summary>
    public string? Tin { get; set; }
    [MaxLength(100)] public string? AddressLine1 { get; set; }
    [MaxLength(100)] public string? AddressLine2 { get; set; }
    [MaxLength(60)]  public string? City  { get; set; }
    [MaxLength(2)]   public string? State { get; set; }
    [MaxLength(10)]  public string? Zip   { get; set; }
    [MaxLength(120)] public string? Email { get; set; }
    [MaxLength(40)]  public string? Phone { get; set; }
    [MaxLength(20)]  public string  W9Status { get; set; } = "Missing";
    public DateOnly? W9ReceivedDate { get; set; }
    public bool      IsActive { get; set; } = true;
    public string?   Notes { get; set; }
}
  • Step 3: Build + commit
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
git add API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs
git commit -m "feat(1099): add report and recipient DTOs"

Task 8: Form1099ReportService aggregation — TDD

Files:

  • Create: API/ROLAC.API/Services/IForm1099ReportService.cs, Form1099ReportService.cs

  • Test: API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs

  • Step 1: Write the interface

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);
}
  • Step 2: Write the failing tests
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;

namespace ROLAC.API.Tests.Services;

public class Form1099ReportServiceTests
{
    private static AppDbContext NewDb() =>
        new(new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);

    // Seeds: ministry; a Personnel>Contract Labor sub mapped to NEC-1; a Facility>Rent sub
    // mapped to MISC-1; an unmapped Salary sub. Returns the db.
    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);   // NEC 700 >= 600
        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 -> excluded
        AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box -> excluded
        AddPaidExpense(db, 10, necSub,    1, 5000m, new DateOnly(2025, 6, 1)); // wrong year -> excluded

        var svc = new Form1099ReportService(db);
        var sum = await svc.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);
    }
}
  • Step 3: Run tests to verify they fail

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter Form1099ReportServiceTests Expected: FAIL — Form1099ReportService does not exist.

  • Step 4: Implement the service
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;
    }
}
  • Step 5: Run tests to verify they pass

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter Form1099ReportServiceTests Expected: PASS (3 tests).

  • Step 6: Commit
git add API/ROLAC.API/Services/IForm1099ReportService.cs API/ROLAC.API/Services/Form1099ReportService.cs API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs
git commit -m "feat(1099): add Form1099ReportService cash-basis annual aggregation"

Task 9: Payee1099Service (recipient CRUD) — TDD

Files:

  • Create: API/ROLAC.API/Services/IPayee1099Service.cs, Payee1099Service.cs

  • Test: API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs

  • Step 1: Write the interface

using ROLAC.API.DTOs.Payee;
namespace ROLAC.API.Services;

public interface IPayee1099Service
{
    Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive);
    Task<Payee1099Dto?> GetByIdAsync(int id);
    Task<int> CreateAsync(SavePayee1099Request r);
    Task UpdateAsync(int id, SavePayee1099Request r);
    Task DeleteAsync(int id);
    /// <summary>Full decrypted TIN. Caller must be authorized (gated at controller).</summary>
    Task<string?> RevealTinAsync(int id);
}
  • Step 2: Write the failing tests
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Security;
using Xunit;

namespace ROLAC.API.Tests.Services;

public class Payee1099ServiceTests
{
    private static (Payee1099Service svc, AppDbContext db) Build()
    {
        var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
        var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
        return (new Payee1099Service(db, tin), db);
    }

    [Fact]
    public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
    {
        var (svc, db) = Build();
        var id = await svc.CreateAsync(new SavePayee1099Request
        { LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });

        var saved = await db.Payee1099s.FindAsync(id);
        Assert.NotNull(saved);
        Assert.Equal("6789", saved!.TinLast4);
        Assert.NotNull(saved.TinEncrypted);
        Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
        Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
    }

    [Fact]
    public async Task Update_with_null_tin_keeps_existing_ciphertext()
    {
        var (svc, db) = Build();
        var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
        var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;

        await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });

        var after = await db.Payee1099s.FindAsync(id);
        Assert.Equal("X renamed", after!.LegalName);
        Assert.Equal(before, after.TinEncrypted);
        Assert.Equal("3333", after.TinLast4);
    }

    [Fact]
    public async Task List_dto_masks_tin_to_last4()
    {
        var (svc, _) = Build();
        await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
        var list = await svc.GetAllAsync(includeInactive: true);
        var item = Assert.Single(list);
        Assert.Equal("7777", item.TinLast4);
    }

    [Fact]
    public async Task Delete_is_soft_and_hides_from_list()
    {
        var (svc, _) = Build();
        var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
        await svc.DeleteAsync(id);
        Assert.Empty(await svc.GetAllAsync(includeInactive: true));
    }
}
  • Step 3: Run tests to verify they fail

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter Payee1099ServiceTests Expected: FAIL — Payee1099Service does not exist.

  • Step 4: Implement the service
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Entities;
using ROLAC.API.Services.Security;

namespace ROLAC.API.Services;

public class Payee1099Service : IPayee1099Service
{
    private readonly AppDbContext _db;
    private readonly ITinProtector _tin;
    public Payee1099Service(AppDbContext db, ITinProtector tin) { _db = db; _tin = tin; }

    public async Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive)
    {
        var q = _db.Payee1099s.AsNoTracking().Include(p => p.Member).AsQueryable();
        if (!includeInactive) q = q.Where(p => p.IsActive);
        return await q.OrderBy(p => p.LegalName).Select(p => new Payee1099ListItemDto
        {
            Id = p.Id, LegalName = p.LegalName, DisplayName = p.DisplayName,
            MemberId = p.MemberId,
            MemberName = p.Member != null ? p.Member.LastName_zh + p.Member.FirstName_zh : null,
            TaxClassification = p.TaxClassification, Is1099Tracked = p.Is1099Tracked,
            TinType = p.TinType, TinLast4 = p.TinLast4, W9Status = p.W9Status, IsActive = p.IsActive,
        }).ToListAsync();
    }

    public async Task<Payee1099Dto?> GetByIdAsync(int id)
    {
        var p = await _db.Payee1099s.AsNoTracking().Include(x => x.Member).FirstOrDefaultAsync(x => x.Id == id);
        if (p is null) return null;
        return new Payee1099Dto
        {
            Id = p.Id, LegalName = p.LegalName, DisplayName = p.DisplayName, MemberId = p.MemberId,
            MemberName = p.Member != null ? p.Member.LastName_zh + p.Member.FirstName_zh : null,
            TaxClassification = p.TaxClassification, Is1099Tracked = p.Is1099Tracked,
            TinType = p.TinType, TinLast4 = p.TinLast4, W9Status = p.W9Status, IsActive = p.IsActive,
            AddressLine1 = p.AddressLine1, AddressLine2 = p.AddressLine2, City = p.City,
            State = p.State, Zip = p.Zip, Email = p.Email, Phone = p.Phone,
            W9ReceivedDate = p.W9ReceivedDate?.ToString("yyyy-MM-dd"),
            HasW9Document = p.W9BlobPath != null, Notes = p.Notes,
        };
    }

    public async Task<int> CreateAsync(SavePayee1099Request r)
    {
        var p = new Payee1099();
        Apply(p, r);
        _db.Payee1099s.Add(p);
        await _db.SaveChangesAsync();
        return p.Id;
    }

    public async Task UpdateAsync(int id, SavePayee1099Request r)
    {
        var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
        Apply(p, r);
        await _db.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
        p.IsDeleted = true;
        p.DeletedAt = DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync();
    }

    public async Task<string?> RevealTinAsync(int id)
    {
        var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
        return p?.TinEncrypted is null ? null : _tin.Unprotect(p.TinEncrypted);
    }

    // Maps request -> entity. A null Tin leaves the existing ciphertext untouched (update case).
    private void Apply(Payee1099 p, SavePayee1099Request r)
    {
        p.LegalName = r.LegalName; p.DisplayName = r.DisplayName; p.MemberId = r.MemberId;
        p.TaxClassification = r.TaxClassification; p.Is1099Tracked = r.Is1099Tracked;
        p.TinType = r.TinType;
        p.AddressLine1 = r.AddressLine1; p.AddressLine2 = r.AddressLine2; p.City = r.City;
        p.State = r.State; p.Zip = r.Zip; p.Email = r.Email; p.Phone = r.Phone;
        p.W9Status = r.W9Status; p.W9ReceivedDate = r.W9ReceivedDate;
        p.IsActive = r.IsActive; p.Notes = r.Notes;
        if (!string.IsNullOrWhiteSpace(r.Tin))
        {
            p.TinEncrypted = _tin.Protect(r.Tin);
            p.TinLast4     = TinProtector.Last4(r.Tin);
        }
    }
}

NOTE: confirm the Member display-name properties (LastName_zh/FirstName_zh) match the real Member entity; adjust the two MemberName expressions if the fields differ.

  • Step 5: Run tests to verify they pass

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter Payee1099ServiceTests Expected: PASS (4 tests).

  • Step 6: Commit
git add API/ROLAC.API/Services/IPayee1099Service.cs API/ROLAC.API/Services/Payee1099Service.cs API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs
git commit -m "feat(1099): add Payee1099Service recipient CRUD with TIN protection"

Task 10: Permission module + DI registration

Files:

  • Modify: API/ROLAC.API/Authorization/Modules.cs, API/ROLAC.API/Program.cs

  • Step 1: Add the Form1099 module constant

In Modules.cs add the constant after Form990Report:

    public const string Form1099          = "Form1099";

And insert Form1099, into the All list immediately after Form990Report,.

  • Step 2: Register services in Program.cs

Beside AddScoped<IForm990ReportService, Form990ReportService>():

builder.Services.AddScoped<IForm1099ReportService, Form1099ReportService>();
builder.Services.AddScoped<IPayee1099Service, Payee1099Service>();
  • Step 3: Build + commit
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
git add API/ROLAC.API/Authorization/Modules.cs API/ROLAC.API/Program.cs
git commit -m "feat(1099): register Form1099 permission module and services"

Task 11: Controllers

Files:

  • Create: API/ROLAC.API/Controllers/Payee1099Controller.cs, Controllers/Form1099ReportController.cs

  • Step 1: Create the recipient controller

using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Services;

namespace ROLAC.API.Controllers;

[ApiController]
[Route("api/payee-1099")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Payee1099Controller : ControllerBase
{
    private readonly IPayee1099Service _svc;
    public Payee1099Controller(IPayee1099Service svc) => _svc = svc;

    [HttpGet]
    public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
        => Ok(await _svc.GetAllAsync(includeInactive));

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id)
        => await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();

    [HttpPost]
    [HasPermission(Modules.Form1099, PermissionActions.Write)]
    public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
        => Ok(new { id = await _svc.CreateAsync(r) });

    [HttpPut("{id:int}")]
    [HasPermission(Modules.Form1099, PermissionActions.Write)]
    public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
    { await _svc.UpdateAsync(id, r); return NoContent(); }

    [HttpDelete("{id:int}")]
    [HasPermission(Modules.Form1099, PermissionActions.Delete)]
    public async Task<IActionResult> Delete(int id)
    { await _svc.DeleteAsync(id); return NoContent(); }

    // Full TIN reveal is gated on Write (a stronger right than Read).
    [HttpGet("{id:int}/tin")]
    [HasPermission(Modules.Form1099, PermissionActions.Write)]
    public async Task<IActionResult> RevealTin(int id)
        => Ok(new { tin = await _svc.RevealTinAsync(id) });
}
  • Step 2: Create the report controller
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;

namespace ROLAC.API.Controllers;

[ApiController]
[Route("api/form1099-report")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Form1099ReportController : ControllerBase
{
    private readonly IForm1099ReportService _svc;
    public Form1099ReportController(IForm1099ReportService svc) => _svc = svc;

    [HttpGet("boxes")]
    public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());

    [HttpGet("summary")]
    public async Task<IActionResult> Summary([FromQuery] int taxYear)
        => Ok(await _svc.GetAnnualSummaryAsync(taxYear));

    [HttpGet("recipient/{payeeId:int}")]
    public async Task<IActionResult> Recipient(int payeeId, [FromQuery] int taxYear)
        => await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound();
}
  • Step 3: Build + commit
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
git add API/ROLAC.API/Controllers/Payee1099Controller.cs API/ROLAC.API/Controllers/Form1099ReportController.cs
git commit -m "feat(1099): add recipient and report controllers"

Task 12: Persist PayeeId through the expense flow

Files:

  • Modify: API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs, API/ROLAC.API/Services/ExpenseService.cs

  • Step 1: Add PayeeId to request + DTOs

In ExpenseDtos.cs: add public int? PayeeId { get; set; } to CreateExpenseRequest and to ExpenseListItemDto (so the form can round-trip the selection).

  • Step 2: Persist on create and update

In ExpenseService.CreateAsync, set PayeeId = r.PayeeId on the new Expense. In UpdateAsync, set e.PayeeId = r.PayeeId;. In the projection that builds ExpenseListItemDto/ExpenseDto, map PayeeId = e.PayeeId.

  • Step 3: Run the existing expense tests (regression)

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter ExpenseServiceTests Expected: PASS (unchanged count).

  • Step 4: Commit
git add API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs API/ROLAC.API/Services/ExpenseService.cs
git commit -m "feat(1099): carry PayeeId through expense create/update/read"

Task 13: Seed the box catalog + default mappings

Files:

  • Modify: API/ROLAC.API/Data/DbSeeder.cs

  • Step 1: Add seed data arrays

Near Form990LineSeed/Form990SubMappingSeed, add:

    private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed =
    [
        (Form1099.BoxNec1,  "Nonemployee compensation", "非員工報酬", "1099-NEC",  1),
        (Form1099.BoxMisc1, "Rents",                    "租金",       "1099-MISC", 2),
    ];

    // Only service/rent subcategories get a box. Everything else stays unmapped (not reportable).
    private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed =
    [
        ("Personnel",             "Honorarium",          Form1099.BoxNec1),
        ("Personnel",             "Contract Labor",      Form1099.BoxNec1),
        ("Professional Services", "Legal",               Form1099.BoxNec1),
        ("Professional Services", "Accounting & Audit",  Form1099.BoxNec1),
        ("Professional Services", "Other Professional",  Form1099.BoxNec1),
        ("Facility",              "Rent",                Form1099.BoxMisc1),
    ];
  • Step 2: Add the seeder method (mirrors SeedForm990ExpenseLinesAsync; null-fill only)
    public static async Task SeedForm1099BoxesAsync(AppDbContext db)
    {
        foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed)
            if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code))
                db.Form1099Boxes.Add(new Form1099Box
                    { BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true });
        await db.SaveChangesAsync();

        var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id);
        var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
        foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed)
        {
            var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
            if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId))
                sub.Form1099BoxId = boxId;
        }
        await db.SaveChangesAsync();
    }
  • Step 3: Call it from SeedAsync

After await SeedForm990ExpenseLinesAsync(db); add:

        await SeedForm1099BoxesAsync(db);
  • Step 4: Build + commit
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
git add API/ROLAC.API/Data/DbSeeder.cs
git commit -m "feat(1099): seed Form1099Box catalog and default subcategory mappings"

Task 14: EF migration

Files:

  • Create: migration under API/ROLAC.API/Migrations/

  • Step 1: Add the migration

Run (per project-build-run-env, use a separate output to avoid the VS bin lock):

dotnet ef migrations add AddForm1099RecipientTracking --project API/ROLAC.API/ROLAC.API.csproj
  • Step 2: Inspect the generated migration

Open the new *_AddForm1099RecipientTracking.cs. Confirm it: creates Form1099Boxes and Payee1099s tables; adds Form1099BoxId to ExpenseSubCategories and ExpenseCategoryGroups; adds PayeeId to Expenses; and creates the three FKs with ReferentialAction.SetNull. No data backfill is required (all new columns are nullable).

  • Step 3: Apply to the dev DB
dotnet ef database update --project API/ROLAC.API/ROLAC.API.csproj

Expected: migration applies cleanly; seeding runs on next API start.

  • Step 4: Commit
git add API/ROLAC.API/Migrations/
git commit -m "feat(1099): EF migration for Payee1099, Form1099Box, mapping columns"

Task 15: Copy B PDF + filing CSV service

Files:

  • Create: API/ROLAC.API/Services/Form1099/I1099FormService.cs, Form1099FormService.cs
  • Modify: Form1099ReportController.cs (two download endpoints)

Reuse the DevExpress document pipeline already used by check printing (project-devexpress-check-printing); read the church payer info from ChurchProfile. Inspect ICheckPrintService/Form1099's sibling CheckPrintService for the exact DevExpress API in use and mirror it.

  • Step 1: Define the interface
namespace ROLAC.API.Services.Form1099;

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);
}
  • Step 2: Implement the CSV first (no DevExpress dependency — easy to verify)
using System.Globalization;
using System.Text;
using ROLAC.API.Services; // IForm1099ReportService

namespace ROLAC.API.Services.Form1099;

public partial class Form1099FormService : I1099FormService
{
    private readonly IForm1099ReportService _report;
    public Form1099FormService(IForm1099ReportService report /*, + ChurchProfile + DevExpress deps */)
        => _report = report;

    public async Task<(Stream, string, string)> ExportFilingCsvAsync(int taxYear)
    {
        var sum = await _report.GetAnnualSummaryAsync(taxYear);
        var sb = new StringBuilder();
        sb.AppendLine("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold");
        foreach (var r in sum.Rows)
            sb.AppendLine(string.Join(",",
                Csv(r.LegalName), Csv(r.TinLast4 ?? ""), Csv(r.W9Status),
                r.NecTotal.ToString(CultureInfo.InvariantCulture),
                r.RentsTotal.ToString(CultureInfo.InvariantCulture),
                r.GrandTotal.ToString(CultureInfo.InvariantCulture),
                r.MeetsThreshold ? "Y" : "N"));
        var bytes = Encoding.UTF8.GetBytes(sb.ToString());
        return (new MemoryStream(bytes), "text/csv", $"1099-filing-{taxYear}.csv");

        static string Csv(string v) => v.Contains(',') || v.Contains('"')
            ? "\"" + v.Replace("\"", "\"\"") + "\"" : v;
    }

    // RenderCopyBAsync implemented in the DevExpress partial (Step 3).
}
  • Step 3: Implement Copy B PDF (DevExpress partial)

Create Form1099FormService.Pdf.cs mirroring CheckPrintService's DevExpress.Document.Processor usage: load/compose the 1099-NEC Copy B layout, fill payer (ChurchProfile), recipient (Payee1099 — legal name, address, masked TIN), and box 1 = NEC total for the year (from GetRecipientDetailAsync). Return the rendered PDF stream. (Copy B is plain-paper; no official red form needed.)

  • Step 4: Register + add endpoints

In Program.cs: builder.Services.AddScoped<I1099FormService, Form1099FormService>();

In Form1099ReportController add (Read-gated downloads):

    [HttpGet("recipient/{payeeId:int}/copy-b")]
    public async Task<IActionResult> CopyB(int payeeId, [FromQuery] int taxYear)
    { var (s, ct, fn) = await _form.RenderCopyBAsync(payeeId, taxYear); return File(s, ct, fn); }

    [HttpGet("export-csv")]
    public async Task<IActionResult> ExportCsv([FromQuery] int taxYear)
    { var (s, ct, fn) = await _form.ExportFilingCsvAsync(taxYear); return File(s, ct, fn); }

(Inject I1099FormService _form into the controller constructor.)

  • Step 5: Build + commit
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
git add API/ROLAC.API/Services/Form1099 API/ROLAC.API/Controllers/Form1099ReportController.cs API/ROLAC.API/Program.cs
git commit -m "feat(1099): Copy B PDF + filing CSV service and download endpoints"

Task 16: Frontend — models + API services

Files:

  • Create: APP/src/app/features/payee1099/models/payee1099.model.ts, services/payee1099-api.service.ts, services/form1099-report-api.service.ts

  • Step 1: Models

export interface Payee1099ListItem {
  id: number; legalName: string; displayName?: string;
  memberId?: number; memberName?: string; taxClassification: string;
  is1099Tracked: boolean; tinType?: string; tinLast4?: string;
  w9Status: string; isActive: boolean;
}
export interface Payee1099 extends Payee1099ListItem {
  addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
  email?: string; phone?: string; w9ReceivedDate?: string; hasW9Document: boolean; notes?: string;
}
export interface SavePayee1099Request {
  legalName: string; displayName?: string; memberId?: number | null;
  taxClassification: string; is1099Tracked: boolean;
  tinType?: string; tin?: string | null;
  addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
  email?: string; phone?: string; w9Status: string; w9ReceivedDate?: string | null;
  isActive: boolean; notes?: string;
}
export interface Form1099Box { id: number; boxCode: string; name_en: string; name_zh?: string; formType: string; sortOrder: number; }
export interface Form1099RecipientRow {
  payeeId: number; legalName: string; tinLast4?: string; w9Status: string;
  necTotal: number; rentsTotal: number; grandTotal: number; meetsThreshold: boolean; w9Missing: boolean;
}
export interface Form1099Summary {
  taxYear: number; rows: Form1099RecipientRow[];
  totalReportable: number; recipientsAtThreshold: number; recipientsMissingW9: number;
}
export interface Form1099Payment { paidDate: string; description: string; categoryName: string; boxCode: string; amount: number; }
export interface Form1099RecipientDetail { payeeId: number; legalName: string; tinLast4?: string; w9Status: string; taxYear: number; payments: Form1099Payment[]; }
  • Step 2: API services (mirror expense-snapshot-api.service.ts style)

payee1099-api.service.ts: getAll(includeInactive), getById(id), create(req), update(id, req), delete(id), revealTin(id) against api/payee-1099.

form1099-report-api.service.ts: getBoxes(), getSummary(taxYear), getRecipient(payeeId, taxYear), and URL builders copyBUrl(payeeId, taxYear) / exportCsvUrl(taxYear) against api/form1099-report.

  • Step 3: Build + commit
git add APP/src/app/features/payee1099/models APP/src/app/features/payee1099/services
git commit -m "feat(1099): frontend models and API services"

Task 17: Frontend — permission module + nav + routes

Files:

  • Modify: APP/src/app/core/models/permission.model.ts, APP/src/app/portals/user-portal/user-portal.component.ts, APP/src/app/app.routes.ts

  • Step 1: Add to PermissionModules

  Form1099: 'Form1099',
  • Step 2: Add finance nav items (in the Expenses finance group, mirroring the Form990/Disbursements items)
{ text: '1099 Recipients', icon: <existingOutlineIcon>, path: '/user-portal/finance/payee-1099',
  permission: { module: PermissionModules.Form1099, action: 'read' } },
{ text: '1099 Report', icon: <existingOutlineIcon>, path: '/user-portal/finance/form1099-report',
  permission: { module: PermissionModules.Form1099, action: 'read' } },
  • Step 3: Add routes (mirror the Form990 report route block)
{
  path: 'finance/payee-1099',
  loadComponent: () => import('./features/payee1099/pages/payee-1099-page/payee-1099-page.component').then(m => m.Payee1099PageComponent),
  canActivate: [PermissionGuard],
  data: { permission: { module: PermissionModules.Form1099, action: 'read' },
          title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance' },
},
{
  path: 'finance/form1099-report',
  loadComponent: () => import('./features/finance-report/pages/form1099-report-page/form1099-report-page.component').then(m => m.Form1099ReportPageComponent),
  canActivate: [PermissionGuard],
  data: { permission: { module: PermissionModules.Form1099, action: 'read' },
          title: '1099 Year-End Report', titleZh: '1099 年度報表', section: 'Finance' },
},
  • Step 4: Commit (won't build until Task 18/19 create the components — commit after those, or stub the components first)
git add APP/src/app/core/models/permission.model.ts APP/src/app/portals/user-portal/user-portal.component.ts APP/src/app/app.routes.ts
git commit -m "feat(1099): frontend permission module, nav items, routes"

Task 18: Frontend — 1099 Recipients master page

Files:

  • Create: APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts

  • Step 1: Build the component modeled on expense-categories-page

Standalone component (inline template per project-frontend-test-runner if unit-tested), with:

Run: npm --prefix APP run build Expected: build succeeds.

  • Step 3: Commit
git add APP/src/app/features/payee1099/pages/payee-1099-page
git commit -m "feat(1099): recipients master page"

Task 19: Frontend — 1099 Year-End Report page

Files:

  • Create: APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts

  • Step 1: Build the component modeled on form990-report-page

  • Year selector (NumericTextBox or dropdown of recent years), "Load" action.

  • Summary chips: TotalReportable, RecipientsAtThreshold, RecipientsMissingW9.

  • Desktop Kendo Grid (hidden md:block): LegalName, ***-**-{{tinLast4}}, W9Status (highlight when w9Missing), NecTotal, RentsTotal, GrandTotal (currency), MeetsThreshold badge. Row click → recipient detail (drill-in dialog listing payments).

  • Mobile cards (md:hidden).

  • Header actions (appPageHeaderActions): "Export filing CSV" → exportCsvUrl(year); per-recipient "Copy B PDF" → copyBUrl(payeeId, year) (open in new tab).

  • Step 2: Verify build

Run: npm --prefix APP run build Expected: build succeeds.

  • Step 3: Commit
git add APP/src/app/features/finance-report/pages/form1099-report-page
git commit -m "feat(1099): year-end 1099 report page with drill-in, CSV, Copy B"

Task 20: Frontend — category→box mapping + expense payee picker

Files:

  • Modify: expense-categories-page component, expense-form-dialog component (+ its category/expense models)

  • Step 1: Add form1099BoxId to category models + DTOs

Add form1099BoxId?: number | null to the group and subcategory frontend models, and (backend) to the category DTOs/save requests + ExpenseCategoryService mapping so the value round-trips. Load boxes via form1099-report-api.getBoxes().

  • Step 2: Add the "1099 Box" dropdown

In the group and subcategory edit dialogs of expense-categories-page, add a Kendo DropdownList bound to form1099BoxId (data = boxes, textField="name_en", valueField="id", [valuePrimitive]="true", a "— none —" default), beside the existing 990-line dropdown.

  • Step 3: Add the payee picker to the expense form

In expense-form-dialog, add an optional "1099 Recipient" DropdownList (data from payee1099-api.getAll(false), textField="legalName", valueField="id", [valuePrimitive]="true", nullable default), bound to the new payeeId field; include payeeId in the create/update payload.

  • Step 4: Verify build + commit
npm --prefix APP run build
git add APP/src/app/features/expense
git commit -m "feat(1099): category->box mapping dropdowns and expense payee picker"

Task 21: Docs + permission seeding

Files:

  • Modify: docs/DB_SCHEMA.md, API/ROLAC.API/Data/DbSeeder.cs (role permissions, if finance role is seeded there)

  • Step 1: Document new tables/columns in docs/DB_SCHEMA.md

Add Payee1099s and Form1099Boxes tables (fields + notes: TIN is encrypted, only last-4 in clear), and the new columns Expenses.PayeeId, ExpenseSubCategories.Form1099BoxId, ExpenseCategoryGroups.Form1099BoxId.

  • Step 2: Seed finance-role permissions for Form1099

If SeedRolePermissionsAsync grants finance roles per module, add Form1099 (Read/Write/Delete) to the finance role grants so the menu/pages appear without manual matrix edits. (super_admin auto-bypasses.)

  • Step 3: Commit
git add docs/DB_SCHEMA.md API/ROLAC.API/Data/DbSeeder.cs
git commit -m "docs(1099): document schema; seed Form1099 finance-role permissions"

Task 22: Full verification pass

  • Step 1: Backend — full test suite (Release)

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release Expected: all pass, including TinProtectorTests, Form1099ReportServiceTests, Payee1099ServiceTests, and unchanged ExpenseServiceTests.

  1. POST api/payee-1099 → create "Pat Player" (SSN, W9Status=OnFile, Is1099Tracked=true). Confirm GET list shows tinLast4 only.
  2. Create a VendorPayment expense (Personnel ▸ Contract Labor, $700, this year), set its payeeId, approve, and Pay it (sets PaidAt this year).
  3. GET api/form1099-report/summary?taxYear=<year> → Pat appears, NecTotal 700, MeetsThreshold true.
  4. GET api/form1099-report/recipient/{id}/copy-b?taxYear=<year> → PDF renders with no DevExpress watermark (project-devexpress-check-printing).
  5. GET api/form1099-report/export-csv?taxYear=<year> → CSV downloads with Pat's row.
  6. GET api/payee-1099/{id}/tin with Write permission → full TIN; with Read-only token → 403.
  • Step 3: Frontend

Run: npm --prefix APP run build (expect success). Scoped unit tests for any inline-template component via --include with Edge CHROME_BIN (project-frontend-test-runner). Manually load the recipients page and report page on desktop and a ~375px mobile width: grid on desktop, cards on mobile; drill-in works; CSV + Copy B download.

  • Step 4: Update the project memory

After merge, update project_form990_audit_readiness.md to mark sub-project B implemented (commits, what shipped) and note the known follow-ups (no VendorName→master backfill; Copy A/1096 + IRIS e-file deferred; threshold is a constant).


Self-Review notes (addressed)

  • Spec coverage: entities (T2T4), TIN encryption (T6), report cash-basis + flags (T8), recipient CRUD + masking (T9), permissions (T10), controllers + TIN reveal gate (T11), expense PayeeId (T12), seeding null-fill (T13), migration (T14), Copy B + CSV (T15), all four frontend surfaces (T16T20), docs/schema (T21), verification (T22) — every spec §2–§11 item maps to a task.
  • Type consistency: box codes flow from Form1099 constants (T1) through seed (T13), service (T8), and DTOs (T7); SavePayee1099Request.Tin null-means-unchanged is honored in Apply (T9) and tested (T9 Step 2).
  • Open confirm-on-implement: Member display-name fields in Payee1099Service (flagged in T9); exact DevExpress API mirrored from CheckPrintService (T15); whether finance roles are seeded per-module (T21).