From d29de83116d245c8191abc4385658888f44bee2e Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 18:11:11 -0700 Subject: [PATCH] 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 --- .../Services/Payee1099ServiceTests.cs | 35 +++++++++++- .../Controllers/Payee1099Controller.cs | 27 ++++++++++ API/ROLAC.API/Services/IPayee1099Service.cs | 4 ++ API/ROLAC.API/Services/Payee1099Service.cs | 40 ++++++++++++-- .../payee-1099-page.component.html | 11 ++++ .../payee-1099-page.component.ts | 54 +++++++++++++++++++ .../services/payee1099-api.service.ts | 15 ++++++ 7 files changed, 182 insertions(+), 4 deletions(-) diff --git a/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs b/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs index dc90fe2..f3bb20f 100644 --- a/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs @@ -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 Files = new(); + public Task 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 OpenReadAsync(string p, CancellationToken ct = default) + => Task.FromResult(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()); + } } diff --git a/API/ROLAC.API/Controllers/Payee1099Controller.cs b/API/ROLAC.API/Controllers/Payee1099Controller.cs index 8a4fdfc..dd6a93c 100644 --- a/API/ROLAC.API/Controllers/Payee1099Controller.cs +++ b/API/ROLAC.API/Controllers/Payee1099Controller.cs @@ -41,4 +41,31 @@ public class Payee1099Controller : ControllerBase [HasPermission(Modules.Form1099, PermissionActions.Write)] public async Task 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 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 GetW9(int id) + { + var result = await _svc.OpenW9Async(id); + if (result is null) return NotFound(); + return File(result.Value.stream, result.Value.contentType); + } } diff --git a/API/ROLAC.API/Services/IPayee1099Service.cs b/API/ROLAC.API/Services/IPayee1099Service.cs index f608d9f..63e5d3e 100644 --- a/API/ROLAC.API/Services/IPayee1099Service.cs +++ b/API/ROLAC.API/Services/IPayee1099Service.cs @@ -10,4 +10,8 @@ public interface IPayee1099Service Task DeleteAsync(int id); /// Full decrypted TIN. Caller must be authorized (gated at controller). Task RevealTinAsync(int id); + /// Stores the uploaded W-9 blob and records its path. Throws KeyNotFoundException if the payee is missing. + Task SaveW9Async(int id, Stream content, string fileName); + /// Opens the stored W-9 blob; null when none is attached. + Task<(Stream stream, string contentType)?> OpenW9Async(int id); } diff --git a/API/ROLAC.API/Services/Payee1099Service.cs b/API/ROLAC.API/Services/Payee1099Service.cs index dd92b1b..ae9fcc2 100644 --- a/API/ROLAC.API/Services/Payee1099Service.cs +++ b/API/ROLAC.API/Services/Payee1099Service.cs @@ -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> 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) { diff --git a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html index 4288e8f..214bcb8 100644 --- a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html +++ b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html @@ -146,6 +146,17 @@ + +
+ W-9 Document / W-9 文件 + + Upload W-9 / 上傳 W-9 + +
+