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

1612 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```csharp
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**
```bash
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)**
```csharp
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**
```bash
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**
```csharp
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**
```bash
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:
```csharp
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:
```csharp
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**
```bash
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:
```csharp
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:
```csharp
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**
```csharp
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:
```csharp
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:
```csharp
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**
```bash
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**
```csharp
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`:
```csharp
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`:
```csharp
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:
```csharp
builder.Services.AddDataProtection();
builder.Services.AddScoped<ITinProtector, TinProtector>();
```
(Add `using ROLAC.API.Services.Security;` if needed.)
- [ ] **Step 6: Build + commit**
```bash
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`:
```csharp
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`:
```csharp
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**
```bash
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**
```csharp
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**
```csharp
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**
```csharp
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**
```bash
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**
```csharp
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**
```csharp
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**
```csharp
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**
```bash
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`:
```csharp
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>()`:
```csharp
builder.Services.AddScoped<IForm1099ReportService, Form1099ReportService>();
builder.Services.AddScoped<IPayee1099Service, Payee1099Service>();
```
- [ ] **Step 3: Build + commit**
```bash
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**
```csharp
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**
```csharp
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**
```bash
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**
```bash
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:
```csharp
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)**
```csharp
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:
```csharp
await SeedForm1099BoxesAsync(db);
```
- [ ] **Step 4: Build + commit**
```bash
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):
```bash
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**
```bash
dotnet ef database update --project API/ROLAC.API/ROLAC.API.csproj
```
Expected: migration applies cleanly; seeding runs on next API start.
- [ ] **Step 4: Commit**
```bash
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**
```csharp
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)**
```csharp
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):
```csharp
[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**
```bash
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**
```typescript
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**
```bash
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`**
```typescript
Form1099: 'Form1099',
```
- [ ] **Step 2: Add finance nav items** (in the Expenses finance group, mirroring the Form990/Disbursements items)
```typescript
{ 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)
```typescript
{
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)
```bash
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:
- Kendo Grid (desktop, `hidden md:block`) columns: LegalName, MemberName, TaxClassification, `tinLast4` shown as `***-**-{{last4}}`, W9Status badge, Is1099Tracked (Yes/No), IsActive. Right-click context menu → Edit / Deactivate ([[feedback-kendo-table-actions-context-menu]]). Primary column click opens edit ([[feedback-kendo-table-select-via-row-click]]).
- Mobile card list (`md:hidden flex flex-col gap-3`) — never set `display` in SCSS ([[feedback-scss-display-defeats-md-hidden]]).
- Edit dialog: Tailwind `grid grid-cols-1 md:grid-cols-2` ([[feedback-form-layout-tailwind]]) with LegalName, DisplayName, Member picker (DropdownList, `[valuePrimitive]="true"` — [[feedback-kendo-value-primitive]]), TaxClassification dropdown, Is1099Tracked switch, TinType + Tin (entry field; on edit show placeholder `***-**-{{last4}}`, blank = unchanged), address fields, W9Status dropdown, W9ReceivedDate datepicker (local Y/M/D, never `toISOString()` — [[feedback-date-only-local-format]]), Notes.
- Save calls `create` or `update`; header "New Recipient" action via `appPageHeaderActions` ([[project-unified-system-header]]).
- [ ] **Step 2: Verify build**
Run: `npm --prefix APP run build`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
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**
```bash
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**
```bash
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**
```bash
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`.
- [ ] **Step 2: API smoke** (run API standalone per [[project-build-run-env]])
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).
```