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
@@ -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);
}
}