Files
ROLAC/API/ROLAC.API/Services/Payee1099Service.cs
T
Chris Chen d29de83116 feat(1099): wire W-9 document upload/view for recipients
Adds POST/GET payee-1099/{id}/w9, mirroring the expense-receipt upload:
IFileStorage saves to finance/w9/{id}{ext}, content-type derived from the
blob extension. Frontend dialog (edit mode) gains a W-9 file input and an
auth-correct blob "View W-9" link. Payee1099Service ctor now takes
IFileStorage; tests updated with an in-memory FakeStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:11:11 -07:00

163 lines
6.1 KiB
C#

using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Entities;
using ROLAC.API.Services.Security;
using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services;
public class Payee1099Service : IPayee1099Service
{
private readonly AppDbContext _db;
private readonly ITinProtector _tin;
private readonly IFileStorage _storage;
public Payee1099Service(AppDbContext db, ITinProtector tin, IFileStorage storage)
{
_db = db;
_tin = tin;
_storage = storage;
}
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);
}
public async Task SaveW9Async(int id, Stream content, string fileName)
{
var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
// Mirror the expense-receipt blob convention: a stable per-record path under a feature folder,
// preserving the original extension. Re-uploads overwrite the prior blob.
var ext = Path.GetExtension(fileName);
var path = $"finance/w9/{p.Id}{ext}";
if (p.W9BlobPath != null && p.W9BlobPath != path)
await _storage.DeleteAsync(p.W9BlobPath);
var saved = await _storage.SaveAsync(content, path);
p.W9BlobPath = saved;
await _db.SaveChangesAsync();
}
public async Task<(Stream stream, string contentType)?> OpenW9Async(int id)
{
var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (p?.W9BlobPath is null) return null;
var stream = await _storage.OpenReadAsync(p.W9BlobPath);
if (stream is null) return null;
var ext = Path.GetExtension(p.W9BlobPath).ToLowerInvariant();
var contentType = ext switch
{
".png" => "image/png", ".webp" => "image/webp", ".pdf" => "application/pdf",
_ => "image/jpeg",
};
return (stream, contentType);
}
// 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);
}
}
}