a0b96b056a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1612 lines
63 KiB
Markdown
1612 lines
63 KiB
Markdown
# 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 (T2–T4), 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 (T16–T20), 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).
|
||
```
|