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>
This commit is contained in:
Chris Chen
2026-06-25 18:11:11 -07:00
parent ad276c01f3
commit d29de83116
7 changed files with 182 additions and 4 deletions
+37 -3
View File
@@ -3,6 +3,7 @@ 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;
@@ -10,11 +11,13 @@ public class Payee1099Service : IPayee1099Service
{
private readonly AppDbContext _db;
private readonly ITinProtector _tin;
private readonly IFileStorage _storage;
public Payee1099Service(AppDbContext db, ITinProtector tin)
public Payee1099Service(AppDbContext db, ITinProtector tin, IFileStorage storage)
{
_db = db;
_tin = tin;
_db = db;
_tin = tin;
_storage = storage;
}
public async Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive)
@@ -99,6 +102,37 @@ public class Payee1099Service : IPayee1099Service
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)
{