d29de83116
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>
163 lines
6.1 KiB
C#
163 lines
6.1 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
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;
|
|
|
|
public class Payee1099Service : IPayee1099Service
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly ITinProtector _tin;
|
|
private readonly IFileStorage _storage;
|
|
|
|
public Payee1099Service(AppDbContext db, ITinProtector tin, IFileStorage storage)
|
|
{
|
|
_db = db;
|
|
_tin = tin;
|
|
_storage = storage;
|
|
}
|
|
|
|
public async Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive)
|
|
{
|
|
var q = _db.Payee1099s.AsNoTracking().Include(p => p.Member).AsQueryable();
|
|
if (!includeInactive) q = q.Where(p => p.IsActive);
|
|
return await q.OrderBy(p => p.LegalName).Select(p => new Payee1099ListItemDto
|
|
{
|
|
Id = p.Id,
|
|
LegalName = p.LegalName,
|
|
DisplayName = p.DisplayName,
|
|
MemberId = p.MemberId,
|
|
MemberName = p.Member != null ? p.Member.FirstName_en + " " + p.Member.LastName_en : null,
|
|
TaxClassification = p.TaxClassification,
|
|
Is1099Tracked = p.Is1099Tracked,
|
|
TinType = p.TinType,
|
|
TinLast4 = p.TinLast4,
|
|
W9Status = p.W9Status,
|
|
IsActive = p.IsActive,
|
|
}).ToListAsync();
|
|
}
|
|
|
|
public async Task<Payee1099Dto?> GetByIdAsync(int id)
|
|
{
|
|
var p = await _db.Payee1099s.AsNoTracking().Include(x => x.Member).FirstOrDefaultAsync(x => x.Id == id);
|
|
if (p is null) return null;
|
|
return new Payee1099Dto
|
|
{
|
|
Id = p.Id,
|
|
LegalName = p.LegalName,
|
|
DisplayName = p.DisplayName,
|
|
MemberId = p.MemberId,
|
|
MemberName = p.Member != null ? $"{p.Member.FirstName_en} {p.Member.LastName_en}" : null,
|
|
TaxClassification = p.TaxClassification,
|
|
Is1099Tracked = p.Is1099Tracked,
|
|
TinType = p.TinType,
|
|
TinLast4 = p.TinLast4,
|
|
W9Status = p.W9Status,
|
|
IsActive = p.IsActive,
|
|
AddressLine1 = p.AddressLine1,
|
|
AddressLine2 = p.AddressLine2,
|
|
City = p.City,
|
|
State = p.State,
|
|
Zip = p.Zip,
|
|
Email = p.Email,
|
|
Phone = p.Phone,
|
|
W9ReceivedDate = p.W9ReceivedDate?.ToString("yyyy-MM-dd"),
|
|
HasW9Document = p.W9BlobPath != null,
|
|
Notes = p.Notes,
|
|
};
|
|
}
|
|
|
|
public async Task<int> CreateAsync(SavePayee1099Request r)
|
|
{
|
|
var p = new Payee1099();
|
|
Apply(p, r);
|
|
_db.Payee1099s.Add(p);
|
|
await _db.SaveChangesAsync();
|
|
return p.Id;
|
|
}
|
|
|
|
public async Task UpdateAsync(int id, SavePayee1099Request r)
|
|
{
|
|
var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
|
|
?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
|
|
Apply(p, r);
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task DeleteAsync(int id)
|
|
{
|
|
var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
|
|
?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
|
|
p.IsDeleted = true;
|
|
p.DeletedAt = DateTimeOffset.UtcNow;
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<string?> RevealTinAsync(int id)
|
|
{
|
|
var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
|
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)
|
|
{
|
|
p.LegalName = r.LegalName;
|
|
p.DisplayName = r.DisplayName;
|
|
p.MemberId = r.MemberId;
|
|
p.TaxClassification = r.TaxClassification;
|
|
p.Is1099Tracked = r.Is1099Tracked;
|
|
p.TinType = r.TinType;
|
|
p.AddressLine1 = r.AddressLine1;
|
|
p.AddressLine2 = r.AddressLine2;
|
|
p.City = r.City;
|
|
p.State = r.State;
|
|
p.Zip = r.Zip;
|
|
p.Email = r.Email;
|
|
p.Phone = r.Phone;
|
|
p.W9Status = r.W9Status;
|
|
p.W9ReceivedDate = r.W9ReceivedDate;
|
|
p.IsActive = r.IsActive;
|
|
p.Notes = r.Notes;
|
|
if (!string.IsNullOrWhiteSpace(r.Tin))
|
|
{
|
|
p.TinEncrypted = _tin.Protect(r.Tin);
|
|
p.TinLast4 = TinProtector.Last4(r.Tin);
|
|
}
|
|
}
|
|
}
|