diff --git a/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs b/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs new file mode 100644 index 0000000..dc90fe2 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs @@ -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(); + accessorMock.Setup(x => x.HttpContext).Returns(httpContext); + var db = new AppDbContext(new DbContextOptionsBuilder() + .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)); + } +} diff --git a/API/ROLAC.API/Services/IPayee1099Service.cs b/API/ROLAC.API/Services/IPayee1099Service.cs new file mode 100644 index 0000000..f608d9f --- /dev/null +++ b/API/ROLAC.API/Services/IPayee1099Service.cs @@ -0,0 +1,13 @@ +using ROLAC.API.DTOs.Payee; +namespace ROLAC.API.Services; + +public interface IPayee1099Service +{ + Task> GetAllAsync(bool includeInactive); + Task GetByIdAsync(int id); + Task CreateAsync(SavePayee1099Request r); + Task UpdateAsync(int id, SavePayee1099Request r); + Task DeleteAsync(int id); + /// Full decrypted TIN. Caller must be authorized (gated at controller). + Task RevealTinAsync(int id); +} diff --git a/API/ROLAC.API/Services/Payee1099Service.cs b/API/ROLAC.API/Services/Payee1099Service.cs new file mode 100644 index 0000000..dd92b1b --- /dev/null +++ b/API/ROLAC.API/Services/Payee1099Service.cs @@ -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> 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 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 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 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); + } + } +}