feat(1099): add Payee1099Service recipient CRUD with TIN protection
Implement IPayee1099Service and Payee1099Service: list/get/create/update/ soft-delete and RevealTin. TIN is encrypted via ITinProtector on write; TinLast4 is the only clear-text fragment stored. Null Tin on update preserves the existing ciphertext. Four xUnit tests cover encrypt-on-create, null-tin-keeps-ciphertext, list-masks-to-last4, and soft-delete hides from list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using System.Security.Claims;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Payee;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class Payee1099ServiceTests
|
||||
{
|
||||
private static (Payee1099Service svc, AppDbContext db) Build()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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.FirstName_en + " " + p.Member.LastName_en : 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.FirstName_en} {p.Member.LastName_en}" : 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 fields onto the entity. A null/blank 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user