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,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