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:
@@ -10,12 +10,24 @@ using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Security;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class Payee1099ServiceTests
|
||||
{
|
||||
// Minimal in-memory IFileStorage (mirrors the ExpenseServiceTests fake).
|
||||
private sealed class FakeStorage : IFileStorage
|
||||
{
|
||||
public Dictionary<string, byte[]> Files = new();
|
||||
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
|
||||
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
|
||||
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
||||
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
|
||||
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private static (Payee1099Service svc, AppDbContext db) Build()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||
@@ -25,7 +37,7 @@ public class Payee1099ServiceTests
|
||||
.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);
|
||||
return (new Payee1099Service(db, tin, new FakeStorage()), db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -76,4 +88,25 @@ public class Payee1099ServiceTests
|
||||
await svc.DeleteAsync(id);
|
||||
Assert.Empty(await svc.GetAllAsync(includeInactive: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveW9_records_document_and_round_trips_bytes()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "W9 Payee" });
|
||||
|
||||
var bytes = new byte[] { 1, 2, 3, 4, 5 };
|
||||
await svc.SaveW9Async(id, new MemoryStream(bytes), "w9.pdf");
|
||||
|
||||
var dto = await svc.GetByIdAsync(id);
|
||||
Assert.NotNull(dto);
|
||||
Assert.True(dto!.HasW9Document);
|
||||
|
||||
var opened = await svc.OpenW9Async(id);
|
||||
Assert.NotNull(opened);
|
||||
Assert.Equal("application/pdf", opened!.Value.contentType);
|
||||
using var ms = new MemoryStream();
|
||||
await opened.Value.stream.CopyToAsync(ms);
|
||||
Assert.Equal(bytes, ms.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,31 @@ public class Payee1099Controller : ControllerBase
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||
public async Task<IActionResult> RevealTin(int id)
|
||||
=> Ok(new { tin = await _svc.RevealTinAsync(id) });
|
||||
|
||||
// Mirrors the expense-receipt upload: multipart form file, size-limited, type-checked.
|
||||
[HttpPost("{id:int}/w9")]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||
[RequestSizeLimit(10_485_760)]
|
||||
public async Task<IActionResult> UploadW9(int id, IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
|
||||
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _svc.SaveW9Async(id, stream, file.FileName);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
// Class-level Read gate covers viewing the stored W-9 (mirrors the receipt GET).
|
||||
[HttpGet("{id:int}/w9")]
|
||||
public async Task<IActionResult> GetW9(int id)
|
||||
{
|
||||
var result = await _svc.OpenW9Async(id);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,8 @@ public interface IPayee1099Service
|
||||
Task DeleteAsync(int id);
|
||||
/// <summary>Full decrypted TIN. Caller must be authorized (gated at controller).</summary>
|
||||
Task<string?> RevealTinAsync(int id);
|
||||
/// <summary>Stores the uploaded W-9 blob and records its path. Throws KeyNotFoundException if the payee is missing.</summary>
|
||||
Task SaveW9Async(int id, Stream content, string fileName);
|
||||
/// <summary>Opens the stored W-9 blob; null when none is attached.</summary>
|
||||
Task<(Stream stream, string contentType)?> OpenW9Async(int id);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user