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
@@ -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);
}
+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)
{
@@ -146,6 +146,17 @@
<kendo-datepicker [(value)]="form.w9ReceivedDate"></kendo-datepicker>
</label>
<!-- W-9 document upload/view: edit mode only (a new record is saved first, then re-opened to attach). -->
<div *ngIf="editingId != null" class="flex flex-col gap-1 md:col-span-2">
<span>W-9 Document / W-9 文件</span>
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf"
*appHasPermission="{ module: 'Form1099', action: 'write' }"
(change)="onW9FileSelected($event)" />
<span class="hint-text-sm">Upload W-9 / 上傳 W-9</span>
<button *ngIf="editingHasW9" kendoButton type="button" fillMode="link" class="self-start"
(click)="viewW9()">View W-9 / 檢視 W-9</button>
</div>
<label class="flex flex-col gap-1 md:col-span-2">
Notes / 備註
<kendo-textarea [(ngModel)]="form.notes" [rows]="3"></kendo-textarea>
@@ -71,6 +71,12 @@ export class Payee1099PageComponent implements OnInit {
editingId: number | null = null;
/** Last-4 of the existing TIN (edit mode), so the TIN box can show a masked placeholder. */
editingTinLast4: string | null = null;
/** True when the record being edited already has a W-9 document attached. */
editingHasW9 = false;
/** Full TIN revealed on demand (write-gated); shown read-only, never logged or persisted. */
revealedTin: string | null = null;
/** Whether the user has manually toggled "1099 Tracked" in this dialog session (suppresses the classification default). */
private trackedTouched = false;
form: Payee1099Form = this.blankForm();
constructor(
@@ -144,16 +150,23 @@ export class Payee1099PageComponent implements OnInit {
openNew(): void {
this.editingId = null;
this.editingTinLast4 = null;
this.editingHasW9 = false;
this.revealedTin = null;
this.trackedTouched = false;
this.form = this.blankForm();
this.dialogOpen = true;
}
openEdit(row: Payee1099ListItem): void {
this.editingId = row.id;
this.editingHasW9 = false;
this.revealedTin = null;
this.trackedTouched = false;
this.dialogOpen = true;
// Load the full record so the dialog can prefill the address/contact/notes fields.
this.api.getById(row.id).subscribe((payee: Payee1099) => {
this.editingTinLast4 = payee.tinLast4 ?? null;
this.editingHasW9 = payee.hasW9Document;
this.form = {
legalName: payee.legalName,
displayName: payee.displayName ?? '',
@@ -217,6 +230,47 @@ export class Payee1099PageComponent implements OnInit {
this.api.delete(row.id).subscribe(() => this.load());
}
// ── Tax classification drives the 1099-tracked default (spec §2.1/§2.3) ────────
// Corporations default to NOT tracked; everyone else defaults to tracked. Only applies
// to NEW records and only until the user manually flips the toggle (no override of an
// explicit choice or an existing saved value on edit).
onTaxClassificationChange(classification: string): void {
if (this.editingId != null || this.trackedTouched) return;
const isCorporation = classification === 'CCorp' || classification === 'SCorp';
this.form.is1099Tracked = !isCorporation;
}
onTrackedToggle(): void { this.trackedTouched = true; }
// ── W-9 document upload/view (edit mode only; a new record is saved first) ─────
onW9FileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
if (!file || this.editingId == null) return;
this.api.uploadW9(this.editingId, file).subscribe(() => {
this.editingHasW9 = true;
input.value = '';
});
}
/** Fetch the stored W-9 via HttpClient (auth interceptor attaches the JWT) and open it in a new tab. */
viewW9(): void {
if (this.editingId == null) return;
this.api.downloadW9(this.editingId).subscribe((blob) => {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
});
}
// ── Full TIN reveal (write-gated; acceptance criterion #11.4) ──────────────────
revealTin(): void {
if (this.editingId == null) return;
this.api.revealTin(this.editingId).subscribe((result) => {
this.revealedTin = result.tin;
});
}
// ── Date-only helpers: build/parse "yyyy-MM-dd" from LOCAL components ─────────
private parseDateOnly(value: string | undefined | null): Date | null {
if (!value) return null;
@@ -39,4 +39,19 @@ export class Payee1099ApiService {
revealTin(id: number): Observable<{ tin: string | null }> {
return this.http.get<{ tin: string | null }>(`${this.endpoint}/${id}/tin`);
}
uploadW9(id: number, file: File): Observable<void> {
const form = new FormData();
form.append('file', file);
return this.http.post<void>(`${this.endpoint}/${id}/w9`, form);
}
/**
* Fetches the stored W-9 as a Blob via HttpClient so the auth interceptor attaches
* the JWT. A plain window.open on the API URL would be an unauthenticated browser
* navigation and the API's permission gate would reject it.
*/
downloadW9(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/${id}/w9`, { responseType: 'blob' });
}
}