Merge: 1099 Recipient Tracking feature (sub-project B)
ci-cd-vm / ci-cd (push) Successful in 2m57s

Adds a Payee1099 recipient master (encrypted TIN, W-9, optional Member
link), Form1099Box catalog + category->box mapping, a cash-basis year-end
1099 report (per-recipient x box, $600 + missing-W9 flags), recipient
Copy B 1099-NEC PDF + filing CSV, W-9 upload, write-gated TIN reveal, and
a ChurchProfile payer EIN. New Form1099 permission module + admin pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-25 18:29:29 -07:00
60 changed files with 8278 additions and 23 deletions
@@ -22,6 +22,7 @@
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,73 @@
using System.Globalization;
using System.Text;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form1099FormServiceTests
{
/// <summary>Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export.</summary>
private sealed class StubReportService : IForm1099ReportService
{
private readonly Form1099SummaryDto _summary;
public StubReportService(Form1099SummaryDto summary) => _summary = summary;
public Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary);
public Task<List<Form1099BoxDto>> GetBoxesAsync() => throw new NotImplementedException();
public Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
=> throw new NotImplementedException();
}
private static Form1099FormService BuildService(Form1099SummaryDto summary) =>
// IPayee1099Service and AppDbContext are only used by RenderCopyBAsync, not by the CSV path.
new Form1099FormService(new StubReportService(summary), payees: null!, db: null!);
[Fact]
public async Task ExportFilingCsvAsync_WritesHeaderRowPerRecipientAndInvariantNumbers()
{
var summary = new Form1099SummaryDto
{
TaxYear = 2026,
Rows =
{
new Form1099RecipientRowDto
{
PayeeId = 1, LegalName = "Acme, LLC", TinLast4 = "1234", W9Status = "OnFile",
NecTotal = 1234.50m, RentsTotal = 0m, GrandTotal = 1234.50m, MeetsThreshold = true
},
new Form1099RecipientRowDto
{
PayeeId = 2, LegalName = "Bob Smith", TinLast4 = "9876", W9Status = "Missing",
NecTotal = 100m, RentsTotal = 50m, GrandTotal = 150m, MeetsThreshold = false
},
}
};
var service = BuildService(summary);
var (stream, contentType, fileName) = await service.ExportFilingCsvAsync(2026);
Assert.Equal("text/csv", contentType);
Assert.Equal("1099-filing-2026.csv", fileName);
using var reader = new StreamReader(stream, Encoding.UTF8);
var text = await reader.ReadToEndAsync();
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
// Header + one data line per row.
Assert.Equal(3, lines.Length);
Assert.Equal("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold", lines[0]);
// A value containing a comma is quoted.
Assert.StartsWith("\"Acme, LLC\",1234,OnFile,", lines[1]);
// Invariant numeric formatting (period decimal separator) and Y/N threshold flag.
Assert.Contains("1234.50", lines[1]);
Assert.EndsWith(",Y", lines[1]);
Assert.EndsWith(",N", lines[2]);
// Sanity: the period really is the invariant separator regardless of current culture.
Assert.Equal("1234.50", 1234.50m.ToString(CultureInfo.InvariantCulture));
}
}
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using System.Security.Claims;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form1099ReportServiceTests
{
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
var accessorMock = new Mock<IHttpContextAccessor>();
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options);
}
private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId)
{
var db = NewDb();
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" });
var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 };
var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 };
db.Form1099Boxes.AddRange(nec, rent);
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null });
db.SaveChanges();
necSubId = 1; rentSubId = 2; salarySubId = 3;
return db;
}
private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn)
{
var e = new Expense
{
MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId,
Amount = amount, Description = "x", ExpenseDate = paidOn,
PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ],
};
db.Expenses.Add(e);
db.SaveChanges();
}
[Fact]
public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9()
{
var db = Seeded(out var necSub, out var rentSub, out _);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" });
db.SaveChanges();
AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1));
AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1));
var svc = new Form1099ReportService(db);
var sum = await svc.GetAnnualSummaryAsync(2026);
var row = Assert.Single(sum.Rows);
Assert.Equal(700m, row.NecTotal);
Assert.Equal(500m, row.RentsTotal);
Assert.Equal(1200m, row.GrandTotal);
Assert.True(row.MeetsThreshold);
Assert.True(row.W9Missing);
Assert.Equal(1, sum.RecipientsAtThreshold);
Assert.Equal(1, sum.RecipientsMissingW9);
}
[Fact]
public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year()
{
var db = Seeded(out var necSub, out _, out var salarySub);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" });
db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" });
db.SaveChanges();
AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked
AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box
AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
Assert.Empty(sum.Rows);
}
[Fact]
public async Task Threshold_flag_is_false_below_600()
{
var db = Seeded(out var necSub, out _, out _);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" });
db.SaveChanges();
AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1));
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
var row = Assert.Single(sum.Rows);
Assert.False(row.MeetsThreshold);
Assert.False(row.W9Missing);
Assert.Equal(0, sum.RecipientsAtThreshold);
}
}
@@ -0,0 +1,112 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using System.Security.Claims;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Payee;
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") })) };
var accessorMock = new Mock<IHttpContextAccessor>();
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.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, new FakeStorage()), db);
}
[Fact]
public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
{
var (svc, db) = Build();
var id = await svc.CreateAsync(new SavePayee1099Request
{ LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });
var saved = await db.Payee1099s.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal("6789", saved!.TinLast4);
Assert.NotNull(saved.TinEncrypted);
Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
}
[Fact]
public async Task Update_with_null_tin_keeps_existing_ciphertext()
{
var (svc, db) = Build();
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;
await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });
var after = await db.Payee1099s.FindAsync(id);
Assert.Equal("X renamed", after!.LegalName);
Assert.Equal(before, after.TinEncrypted);
Assert.Equal("3333", after.TinLast4);
}
[Fact]
public async Task List_dto_masks_tin_to_last4()
{
var (svc, _) = Build();
await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
var list = await svc.GetAllAsync(includeInactive: true);
var item = Assert.Single(list);
Assert.Equal("7777", item.TinLast4);
}
[Fact]
public async Task Delete_is_soft_and_hides_from_list()
{
var (svc, _) = Build();
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
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());
}
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.DataProtection;
using ROLAC.API.Services.Security;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class TinProtectorTests
{
private static TinProtector Build() =>
new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
[Fact]
public void Protect_then_Unprotect_round_trips()
{
var p = Build();
var cipher = p.Protect("123-45-6789");
Assert.NotEqual("123-45-6789", cipher);
Assert.Equal("123-45-6789", p.Unprotect(cipher));
}
[Theory]
[InlineData("123-45-6789", "6789")]
[InlineData("12-3456789", "6789")]
[InlineData("7", "7")]
public void Last4_keeps_only_trailing_digits(string raw, string expected)
=> Assert.Equal(expected, TinProtector.Last4(raw));
[Fact]
public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null));
}
+2
View File
@@ -17,6 +17,7 @@ public static class Modules
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string Form990Report = "Form990Report";
public const string Form1099 = "Form1099";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
@@ -39,6 +40,7 @@ public static class Modules
Ministries,
FinanceDashboard,
Form990Report,
Form1099,
MonthlyStatements,
ChurchProfile,
Disbursements,
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/form1099-report")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Form1099ReportController : ControllerBase
{
private readonly IForm1099ReportService _svc;
private readonly I1099FormService _form;
public Form1099ReportController(IForm1099ReportService svc, I1099FormService form)
{
_svc = svc;
_form = form;
}
[HttpGet("boxes")]
public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());
[HttpGet("summary")]
public async Task<IActionResult> Summary([FromQuery] int taxYear)
=> Ok(await _svc.GetAnnualSummaryAsync(taxYear));
[HttpGet("recipient/{payeeId:int}")]
public async Task<IActionResult> Recipient(int payeeId, [FromQuery] int taxYear)
=> await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound();
[HttpGet("recipient/{payeeId:int}/copy-b")]
public async Task<IActionResult> CopyB(int payeeId, [FromQuery] int taxYear)
{
var (stream, contentType, fileName) = await _form.RenderCopyBAsync(payeeId, taxYear);
return File(stream, contentType, fileName);
}
[HttpGet("export-csv")]
public async Task<IActionResult> ExportCsv([FromQuery] int taxYear)
{
var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear);
return File(stream, contentType, fileName);
}
}
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/payee-1099")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Payee1099Controller : ControllerBase
{
private readonly IPayee1099Service _svc;
public Payee1099Controller(IPayee1099Service svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
=> await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();
[HttpPost]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
=> Ok(new { id = await _svc.CreateAsync(r) });
[HttpPut("{id:int}")]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
{ await _svc.UpdateAsync(id, r); return NoContent(); }
[HttpDelete("{id:int}")]
[HasPermission(Modules.Form1099, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{ await _svc.DeleteAsync(id); return NoContent(); }
// Full TIN reveal is gated on Write (a stronger right than Read).
[HttpGet("{id:int}/tin")]
[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);
}
}
@@ -16,6 +16,7 @@ public class ChurchProfileDto
public string? BankName { get; set; }
public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; }
public string? PayerEin { get; set; }
public int NextCheckNumber { get; set; }
public string AiProvider { get; set; } = "Claude";
public string? ClaudeModel { get; set; }
@@ -38,6 +39,7 @@ public class UpdateChurchProfileRequest
[MaxLength(200)] public string? BankName { get; set; }
[MaxLength(50)] public string? BankAccountNumber { get; set; }
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
[MaxLength(20)] public string? PayerEin { get; set; }
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
[MaxLength(100)] public string? ClaudeModel { get; set; }
@@ -11,6 +11,8 @@ public class ExpenseSubCategoryDto
public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
public int? Form1099BoxId { get; set; }
public string? Form1099BoxCode { get; set; }
}
public class ExpenseCategoryGroupDto
@@ -22,6 +24,8 @@ public class ExpenseCategoryGroupDto
public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
public int? Form1099BoxId { get; set; }
public string? Form1099BoxCode { get; set; }
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
}
@@ -31,6 +35,7 @@ public class CreateExpenseGroupRequest
[MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public int? Form990LineId { get; set; }
public int? Form1099BoxId { get; set; }
}
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
{
@@ -44,6 +49,7 @@ public class CreateExpenseSubCategoryRequest
[MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public int? Form990LineId { get; set; }
public int? Form1099BoxId { get; set; }
}
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
{
@@ -35,6 +35,7 @@ public class ExpenseListItemDto
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
public DateTimeOffset? ReviewedAt { get; set; }
public string? ReviewNotes { get; set; } // reject reason (or approval note)
public int? PayeeId { get; set; }
}
public class ExpenseDto : ExpenseListItemDto
@@ -66,6 +67,7 @@ public class CreateExpenseRequest
[MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; }
public int? PayeeId { get; set; }
}
public class UpdateExpenseRequest : CreateExpenseRequest { }
@@ -0,0 +1,52 @@
namespace ROLAC.API.DTOs.Finance;
public class Form1099BoxDto
{
public int Id { get; set; }
public string BoxCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public string FormType { get; set; } = "";
public int SortOrder { get; set; }
}
public class Form1099RecipientRowDto
{
public int PayeeId { get; set; }
public string LegalName { get; set; } = "";
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public decimal NecTotal { get; set; }
public decimal RentsTotal { get; set; }
public decimal GrandTotal { get; set; }
public bool MeetsThreshold { get; set; }
public bool W9Missing { get; set; }
}
public class Form1099SummaryDto
{
public int TaxYear { get; set; }
public List<Form1099RecipientRowDto> Rows { get; set; } = [];
public decimal TotalReportable { get; set; }
public int RecipientsAtThreshold { get; set; }
public int RecipientsMissingW9 { get; set; }
}
public class Form1099PaymentDto
{
public string PaidDate { get; set; } = "";
public string Description { get; set; } = "";
public string CategoryName { get; set; } = "";
public string BoxCode { get; set; } = "";
public decimal Amount { get; set; }
}
public class Form1099RecipientDetailDto
{
public int PayeeId { get; set; }
public string LegalName { get; set; } = "";
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public int TaxYear { get; set; }
public List<Form1099PaymentDto> Payments { get; set; } = [];
}
+54
View File
@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Payee;
public class Payee1099ListItemDto
{
public int Id { get; set; }
public string LegalName { get; set; } = "";
public string? DisplayName { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public string TaxClassification { get; set; } = "";
public bool Is1099Tracked { get; set; }
public string? TinType { get; set; }
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public bool IsActive { get; set; }
}
public class Payee1099Dto : Payee1099ListItemDto
{
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? W9ReceivedDate { get; set; }
public bool HasW9Document { get; set; }
public string? Notes { get; set; }
}
public class SavePayee1099Request
{
[Required, MaxLength(200)] public string LegalName { get; set; } = "";
[MaxLength(200)] public string? DisplayName { get; set; }
public int? MemberId { get; set; }
[Required, MaxLength(40)] public string TaxClassification { get; set; } = "Individual";
public bool Is1099Tracked { get; set; } = true;
[MaxLength(10)] public string? TinType { get; set; }
/// <summary>Plain TIN; null = leave unchanged on update. Encrypted server-side.</summary>
public string? Tin { get; set; }
[MaxLength(100)] public string? AddressLine1 { get; set; }
[MaxLength(100)] public string? AddressLine2 { get; set; }
[MaxLength(60)] public string? City { get; set; }
[MaxLength(2)] public string? State { get; set; }
[MaxLength(10)] public string? Zip { get; set; }
[MaxLength(120)] public string? Email { get; set; }
[MaxLength(40)] public string? Phone { get; set; }
[MaxLength(20)] public string W9Status { get; set; } = "Missing";
public DateOnly? W9ReceivedDate { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
}
+48
View File
@@ -21,6 +21,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
public DbSet<Payee1099> Payee1099s => Set<Payee1099>();
public DbSet<Form1099Box> Form1099Boxes => Set<Form1099Box>();
public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
@@ -218,6 +220,45 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.HasIndex(e => e.LineCode).IsUnique();
});
// ── Form1099Box (1099 reporting box catalog) ──────────────────────────
builder.Entity<Form1099Box>(entity =>
{
entity.Property(e => e.BoxCode).HasMaxLength(10).IsRequired();
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.FormType).HasMaxLength(20).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.BoxCode).IsUnique();
});
// ── Payee1099 (1099 recipient master) ────────────────────────────────
builder.Entity<Payee1099>(entity =>
{
entity.HasQueryFilter(p => !p.IsDeleted);
entity.Property(e => e.LegalName).HasMaxLength(200).IsRequired();
entity.Property(e => e.DisplayName).HasMaxLength(200);
entity.Property(e => e.TaxClassification).HasMaxLength(40).IsRequired();
entity.Property(e => e.TinType).HasMaxLength(10);
entity.Property(e => e.TinLast4).HasMaxLength(4);
entity.Property(e => e.State).HasMaxLength(2);
entity.Property(e => e.Zip).HasMaxLength(10);
entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing);
entity.Property(e => e.AddressLine1).HasMaxLength(200);
entity.Property(e => e.AddressLine2).HasMaxLength(200);
entity.Property(e => e.City).HasMaxLength(100);
entity.Property(e => e.Email).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(30);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.W9BlobPath).HasMaxLength(500);
entity.Property(e => e.TinEncrypted).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.DeletedBy).HasMaxLength(450);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
builder.Entity<ExpenseCategoryGroup>(entity =>
{
@@ -227,6 +268,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Form1099Box).WithMany()
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
});
// ── ExpenseSubCategory ───────────────────────────────────────────────
@@ -240,6 +283,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Form1099Box).WithMany()
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
});
// ── Expense ──────────────────────────────────────────────────────────
@@ -270,6 +315,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Payee).WithMany()
.HasForeignKey(e => e.PayeeId).OnDelete(DeleteBehavior.SetNull);
});
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
@@ -346,6 +393,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
entity.Property(e => e.PayerEin).HasMaxLength(20);
entity.Property(e => e.NameZh).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(50);
entity.Property(e => e.Email).HasMaxLength(200);
+42
View File
@@ -137,6 +137,23 @@ public static class DbSeeder
("Other", "Gifts", "24"),
];
private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed =
[
(Form1099.BoxNec1, "Nonemployee compensation", "非員工報酬", "1099-NEC", 1),
(Form1099.BoxMisc1, "Rents", "租金", "1099-MISC", 2),
];
// Only service/rent subcategories get a box. Everything else stays unmapped (not reportable).
private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed =
[
("Personnel", "Honorarium", Form1099.BoxNec1),
("Personnel", "Contract Labor", Form1099.BoxNec1),
("Professional Services", "Legal", Form1099.BoxNec1),
("Professional Services", "Accounting & Audit", Form1099.BoxNec1),
("Professional Services", "Other Professional", Form1099.BoxNec1),
("Facility", "Rent", Form1099.BoxMisc1),
];
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
// value — this block does. Idempotent: each row fires only while the subcategory still holds
@@ -190,6 +207,11 @@ public static class DbSeeder
("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true),
("finance", Modules.Form990Report, true, false, false, false),
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
("finance", Modules.Form1099, true, true, true, false),
("pastor", Modules.Form1099, true, false, false, false),
("board_member", Modules.Form1099, true, false, false, false),
// Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too.
@@ -375,6 +397,25 @@ public static class DbSeeder
await db.SaveChangesAsync();
}
public static async Task SeedForm1099BoxesAsync(AppDbContext db)
{
foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed)
if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code))
db.Form1099Boxes.Add(new Form1099Box
{ BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true });
await db.SaveChangesAsync();
var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id);
var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed)
{
var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId))
sub.Form1099BoxId = boxId;
}
await db.SaveChangesAsync();
}
public static async Task SeedChurchProfileAsync(AppDbContext db)
{
// Singleton row used by the disbursement module (issuer info + check counter).
@@ -454,6 +495,7 @@ public static class DbSeeder
await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db);
await SeedForm990ExpenseLinesAsync(db);
await SeedForm1099BoxesAsync(db);
await SeedChurchProfileAsync(db);
await SeedSiteSettingAsync(db);
await SeedNotificationSettingAsync(db, config);
+3
View File
@@ -21,6 +21,9 @@ public class ChurchProfile : AuditableEntity, IAuditable
public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; }
/// <summary>Payer EIN printed on Form 1099-NEC Copy B; the church's own public business identifier.</summary>
public string? PayerEin { get; set; }
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
+2
View File
@@ -11,6 +11,7 @@ public class Expense : SoftDeleteEntity, IAuditable
public string Description { get; set; } = null!;
public string? VendorName { get; set; }
public int? MemberId { get; set; }
public int? PayeeId { get; set; } // 1099 recipient attribution (header-level)
public string? CheckNumber { get; set; }
public DateOnly ExpenseDate { get; set; }
public string? ReceiptBlobPath { get; set; }
@@ -25,5 +26,6 @@ public class Expense : SoftDeleteEntity, IAuditable
public Ministry? Ministry { get; set; }
public Member? Member { get; set; }
public Payee1099? Payee { get; set; }
public List<ExpenseLine> Lines { get; set; } = new();
}
@@ -12,5 +12,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
public Form1099Box? Form1099Box { get; set; }
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
}
@@ -13,5 +13,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
public Form1099Box? Form1099Box { get; set; }
public ExpenseCategoryGroup? Group { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
namespace ROLAC.API.Entities;
/// <summary>Shared 1099 constants. Box codes match Form1099Box.BoxCode seed values.</summary>
public static class Form1099
{
/// <summary>IRS reporting threshold (USD) per box, per recipient, per calendar year.</summary>
public const decimal ReportingThreshold = 600m;
public const string BoxNec1 = "NEC-1"; // Nonemployee compensation
public const string BoxMisc1 = "MISC-1"; // Rents
public static class W9Status
{
public const string Missing = "Missing";
public const string Requested = "Requested";
public const string OnFile = "OnFile";
public const string Expired = "Expired";
public static readonly IReadOnlyList<string> All = [Missing, Requested, OnFile, Expired];
}
}
+14
View File
@@ -0,0 +1,14 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>A 1099 reporting box, e.g. "NEC-1 — Nonemployee compensation".</summary>
public class Form1099Box : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string BoxCode { get; set; } = null!; // "NEC-1", "MISC-1"
public string Name_en { get; set; } = null!;
public string? Name_zh { get; set; }
public string FormType { get; set; } = "1099-NEC"; // "1099-NEC" | "1099-MISC"
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
+32
View File
@@ -0,0 +1,32 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// A 1099 recipient (independent contractor / vendor). Holds W-9 data and an encrypted TIN.
/// Optionally linked to a Member (e.g. a part-time co-worker paid as a contractor).
/// </summary>
public class Payee1099 : SoftDeleteEntity, IAuditable
{
public int Id { get; set; }
public string LegalName { get; set; } = null!; // name on the W-9
public string? DisplayName { get; set; } // friendly / DBA
public int? MemberId { get; set; }
public Member? Member { get; set; }
public string TaxClassification { get; set; } = "Individual"; // drives Is1099Tracked default
public bool Is1099Tracked { get; set; } = true;
public string? TinType { get; set; } // "SSN" | "EIN"
public string? TinEncrypted { get; set; } // Data-Protection ciphertext
public string? TinLast4 { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string W9Status { get; set; } = Form1099.W9Status.Missing;
public DateOnly? W9ReceivedDate { get; set; }
public string? W9BlobPath { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddForm1099RecipientTracking : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Form1099BoxId",
table: "ExpenseSubCategories",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PayeeId",
table: "Expenses",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Form1099BoxId",
table: "ExpenseCategoryGroups",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "Form1099Boxes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BoxCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
FormType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Form1099Boxes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Payee1099s",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
LegalName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
MemberId = table.Column<int>(type: "integer", nullable: true),
TaxClassification = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Is1099Tracked = table.Column<bool>(type: "boolean", nullable: false),
TinType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
TinEncrypted = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
TinLast4 = table.Column<string>(type: "character varying(4)", maxLength: 4, nullable: true),
AddressLine1 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
AddressLine2 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
State = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: true),
Zip = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Phone = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
W9Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Missing"),
W9ReceivedDate = table.Column<DateOnly>(type: "date", nullable: true),
W9BlobPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Payee1099s", x => x.Id);
table.ForeignKey(
name: "FK_Payee1099s_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_ExpenseSubCategories_Form1099BoxId",
table: "ExpenseSubCategories",
column: "Form1099BoxId");
migrationBuilder.CreateIndex(
name: "IX_Expenses_PayeeId",
table: "Expenses",
column: "PayeeId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseCategoryGroups_Form1099BoxId",
table: "ExpenseCategoryGroups",
column: "Form1099BoxId");
migrationBuilder.CreateIndex(
name: "IX_Form1099Boxes_BoxCode",
table: "Form1099Boxes",
column: "BoxCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Payee1099s_MemberId",
table: "Payee1099s",
column: "MemberId");
migrationBuilder.AddForeignKey(
name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId",
table: "ExpenseCategoryGroups",
column: "Form1099BoxId",
principalTable: "Form1099Boxes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_Expenses_Payee1099s_PayeeId",
table: "Expenses",
column: "PayeeId",
principalTable: "Payee1099s",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId",
table: "ExpenseSubCategories",
column: "Form1099BoxId",
principalTable: "Form1099Boxes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropForeignKey(
name: "FK_Expenses_Payee1099s_PayeeId",
table: "Expenses");
migrationBuilder.DropForeignKey(
name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId",
table: "ExpenseSubCategories");
migrationBuilder.DropTable(
name: "Form1099Boxes");
migrationBuilder.DropTable(
name: "Payee1099s");
migrationBuilder.DropIndex(
name: "IX_ExpenseSubCategories_Form1099BoxId",
table: "ExpenseSubCategories");
migrationBuilder.DropIndex(
name: "IX_Expenses_PayeeId",
table: "Expenses");
migrationBuilder.DropIndex(
name: "IX_ExpenseCategoryGroups_Form1099BoxId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropColumn(
name: "Form1099BoxId",
table: "ExpenseSubCategories");
migrationBuilder.DropColumn(
name: "PayeeId",
table: "Expenses");
migrationBuilder.DropColumn(
name: "Form1099BoxId",
table: "ExpenseCategoryGroups");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddPayerEinToChurchProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PayerEin",
table: "ChurchProfiles",
type: "character varying(20)",
maxLength: 20,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PayerEin",
table: "ChurchProfiles");
}
}
}
@@ -506,6 +506,10 @@ namespace ROLAC.API.Migrations
b.Property<int>("NextCheckNumber")
.HasColumnType("integer");
b.Property<string>("PayerEin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Phone")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -598,6 +602,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("PayeeId")
.HasColumnType("integer");
b.Property<string>("ReceiptBlobPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
@@ -652,6 +659,8 @@ namespace ROLAC.API.Migrations
b.HasIndex("MinistryId");
b.HasIndex("PayeeId");
b.HasIndex("Status")
.HasFilter("\"IsDeleted\" = false");
@@ -674,6 +683,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("Form1099BoxId")
.HasColumnType("integer");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
@@ -702,6 +714,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("Form1099BoxId");
b.HasIndex("Form990LineId");
b.ToTable("ExpenseCategoryGroups");
@@ -900,6 +914,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("Form1099BoxId")
.HasColumnType("integer");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
@@ -931,6 +948,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("Form1099BoxId");
b.HasIndex("Form990LineId");
b.HasIndex("GroupId");
@@ -976,6 +995,63 @@ namespace ROLAC.API.Migrations
b.ToTable("FamilyUnits");
});
modelBuilder.Entity("ROLAC.API.Entities.Form1099Box", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("BoxCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("FormType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name_en")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("BoxCode")
.IsUnique();
b.ToTable("Form1099Boxes");
});
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
{
b.Property<int>("Id")
@@ -1925,6 +2001,128 @@ namespace ROLAC.API.Migrations
b.ToTable("OfferingSessions");
});
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AddressLine1")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("AddressLine2")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("City")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeletedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("Is1099Tracked")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("LegalName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("State")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<string>("TaxClassification")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("TinEncrypted")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("TinLast4")
.HasMaxLength(4)
.HasColumnType("character varying(4)");
b.Property<string>("TinType")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("W9BlobPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateOnly?>("W9ReceivedDate")
.HasColumnType("date");
b.Property<string>("W9Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Missing");
b.Property<string>("Zip")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.HasKey("Id");
b.HasIndex("MemberId");
b.ToTable("Payee1099s");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
@@ -2208,18 +2406,32 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Payee1099", "Payee")
.WithMany()
.HasForeignKey("PayeeId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member");
b.Navigation("Ministry");
b.Navigation("Payee");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
.WithMany()
.HasForeignKey("Form1099BoxId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Form1099Box");
b.Navigation("Form990Line");
});
@@ -2290,6 +2502,11 @@ namespace ROLAC.API.Migrations
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
.WithMany()
.HasForeignKey("Form1099BoxId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
@@ -2301,6 +2518,8 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Form1099Box");
b.Navigation("Form990Line");
b.Navigation("Group");
@@ -2380,6 +2599,16 @@ namespace ROLAC.API.Migrations
b.Navigation("MessagingGroup");
});
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
+6
View File
@@ -15,6 +15,7 @@ using ROLAC.API.Json;
using ROLAC.API.Middleware;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Security;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
@@ -157,6 +158,11 @@ builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
builder.Services.AddScoped<IForm1099ReportService, Form1099ReportService>();
builder.Services.AddScoped<IPayee1099Service, Payee1099Service>();
builder.Services.AddScoped<I1099FormService, Form1099FormService>();
builder.Services.AddDataProtection();
builder.Services.AddScoped<ITinProtector, TinProtector>();
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
@@ -18,7 +18,7 @@ public class ChurchProfileService : IChurchProfileService
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
BankRoutingNumber = p.BankRoutingNumber, PayerEin = p.PayerEin, NextCheckNumber = p.NextCheckNumber,
AiProvider = p.AiProvider,
ClaudeModel = p.ClaudeModel,
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
@@ -33,7 +33,7 @@ public class ChurchProfileService : IChurchProfileService
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
p.BankRoutingNumber = r.BankRoutingNumber; p.PayerEin = r.PayerEin; p.NextCheckNumber = r.NextCheckNumber;
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
p.ClaudeModel = r.ClaudeModel;
p.GeminiModel = r.GeminiModel;
@@ -25,25 +25,32 @@ public class ExpenseCategoryService : IExpenseCategoryService
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
var boxCodes = await _db.Form1099Boxes.AsNoTracking()
.ToDictionaryAsync(b => b.Id, b => b.BoxCode);
return groups.Select(g => new ExpenseCategoryGroupDto
{
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
SortOrder = g.SortOrder, IsActive = g.IsActive,
Form990LineId = g.Form990LineId,
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
Form1099BoxId = g.Form1099BoxId,
Form1099BoxCode = g.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(g.Form1099BoxId.Value) : null,
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
{
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
SortOrder = s.SortOrder, IsActive = s.IsActive,
Form990LineId = s.Form990LineId,
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
Form1099BoxId = s.Form1099BoxId,
Form1099BoxCode = s.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(s.Form1099BoxId.Value) : null,
}).ToList(),
}).ToList();
}
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
{
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
_db.ExpenseCategoryGroups.Add(g);
await _db.SaveChangesAsync();
return g.Id;
@@ -53,7 +60,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId;
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId; g.Form1099BoxId = r.Form1099BoxId;
await _db.SaveChangesAsync();
}
@@ -69,7 +76,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
_db.ExpenseSubCategories.Add(s);
await _db.SaveChangesAsync();
return s.Id;
@@ -79,7 +86,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{
var s = await _db.ExpenseSubCategories.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId;
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId; s.Form1099BoxId = r.Form1099BoxId;
await _db.SaveChangesAsync();
}
+4 -1
View File
@@ -120,6 +120,7 @@ public class ExpenseService : IExpenseService
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
ReviewedAt = e.ReviewedAt,
ReviewNotes = e.ReviewNotes,
PayeeId = e.PayeeId,
};
}).ToList();
@@ -211,6 +212,7 @@ public class ExpenseService : IExpenseService
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
PayeeId = e.PayeeId,
Lines = lineDtos,
};
}
@@ -273,6 +275,7 @@ public class ExpenseService : IExpenseService
e.VendorName = null;
}
e.PayeeId = r.PayeeId;
_db.Expenses.Add(e);
await _db.SaveChangesAsync();
return e.Id;
@@ -294,7 +297,7 @@ public class ExpenseService : IExpenseService
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.PayeeId = r.PayeeId;
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
// Replace the line set wholesale (lines are owned by the header), recompute the total.
@@ -0,0 +1,160 @@
using System.Globalization;
using System.Text;
using DevExpress.Office;
using DevExpress.XtraRichEdit;
using DevExpress.XtraRichEdit.API.Native;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the
/// DevExpress RichEdit/Office API, mirroring <c>CheckPrintService</c>) and a filing-data CSV.
/// </summary>
public class Form1099FormService : I1099FormService
{
private readonly IForm1099ReportService _report;
private readonly IPayee1099Service _payees;
private readonly AppDbContext _db;
public Form1099FormService(IForm1099ReportService report, IPayee1099Service payees, AppDbContext db)
{
_report = report;
_payees = payees;
_db = db;
}
public async Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear)
{
var payee = await _payees.GetByIdAsync(payeeId)
?? throw new InvalidOperationException($"Payee {payeeId} not found.");
var church = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
?? new ChurchProfile { Name = "Church" };
// Box 1 (Nonemployee compensation) = sum of this payee's NEC-1 payments for the year.
var detail = await _report.GetRecipientDetailAsync(payeeId, taxYear);
var box1Nec = detail?.Payments
.Where(payment => payment.BoxCode == Entities.Form1099.BoxNec1)
.Sum(payment => payment.Amount) ?? 0m;
using var server = new RichEditDocumentServer();
var document = server.Document;
document.BeginUpdate();
try
{
document.Unit = DocumentUnit.Inch;
var section = document.Sections[0];
section.Page.Width = 8.5f;
section.Page.Height = 11f;
section.Margins.Left = section.Margins.Right = 0.8f;
section.Margins.Top = section.Margins.Bottom = 0.8f;
document.AppendHtmlText(BuildCopyBHtml(church, payee, taxYear, box1Nec));
}
finally
{
document.EndUpdate();
}
var stream = new MemoryStream();
server.ExportToPdf(stream);
stream.Position = 0;
return (stream, "application/pdf", $"1099-NEC-{payeeId}-{taxYear}.pdf");
}
public async Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear)
{
var summary = await _report.GetAnnualSummaryAsync(taxYear);
var builder = new StringBuilder();
builder.AppendLine("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold");
foreach (var row in summary.Rows)
{
builder.AppendLine(string.Join(",",
Csv(row.LegalName), Csv(row.TinLast4 ?? ""), Csv(row.W9Status),
row.NecTotal.ToString(CultureInfo.InvariantCulture),
row.RentsTotal.ToString(CultureInfo.InvariantCulture),
row.GrandTotal.ToString(CultureInfo.InvariantCulture),
row.MeetsThreshold ? "Y" : "N"));
}
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
return (new MemoryStream(bytes), "text/csv", $"1099-filing-{taxYear}.csv");
static string Csv(string value) => value.Contains(',') || value.Contains('"')
? "\"" + value.Replace("\"", "\"\"") + "\"" : value;
}
private static string BuildCopyBHtml(ChurchProfile church, Payee1099Dto payee, int taxYear, decimal box1Nec)
{
var payerAddress = JoinAddress(church.Address, church.City, church.State, church.ZipCode);
var recipientAddress = JoinAddress(
JoinLines(payee.AddressLine1, payee.AddressLine2), payee.City, payee.State, payee.Zip);
var payerEin = string.IsNullOrWhiteSpace(church.PayerEin) ? "" : church.PayerEin;
var maskedTin = string.IsNullOrWhiteSpace(payee.TinLast4) ? "" : $"***-**-{payee.TinLast4}";
return
"<div style=\"font-family:Arial;font-size:11pt;color:#111;\">" +
$"<h2 style=\"text-align:center;margin:0;\">Form 1099-NEC &mdash; Copy B (For Recipient)</h2>" +
$"<p style=\"text-align:center;margin:4px 0 16px 0;\"><b>Tax Year {taxYear}</b><br/>Nonemployee Compensation</p>" +
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"6\" width=\"100%\" style=\"border-collapse:collapse;\">" +
"<tr><td width=\"50%\" valign=\"top\">" +
"<b>PAYER&rsquo;s name, address</b><br/>" +
$"{Encode(church.Name)}<br/>{payerAddress}" +
"</td>" +
"<td width=\"50%\" valign=\"top\">" +
$"<b>PAYER&rsquo;s TIN (EIN)</b><br/>{Encode(payerEin)}" +
"</td></tr>" +
"<tr><td valign=\"top\">" +
"<b>RECIPIENT&rsquo;s name, address</b><br/>" +
$"{Encode(payee.LegalName)}<br/>{recipientAddress}" +
"</td>" +
"<td valign=\"top\">" +
$"<b>RECIPIENT&rsquo;s TIN</b><br/>{Encode(maskedTin)}" +
"</td></tr>" +
"<tr><td colspan=\"2\">" +
"<b>Box 1 &mdash; Nonemployee compensation</b><br/>" +
$"<span style=\"font-size:14pt;\"><b>{Encode(FormatCurrency(box1Nec))}</b></span>" +
"</td></tr>" +
"</table>" +
"<p style=\"font-size:8pt;color:#555;margin-top:12px;\">" +
"This is important tax information and is being furnished to the recipient. " +
"Recipient&rsquo;s taxpayer identification number is shown masked for security." +
"</p>" +
"</div>";
}
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
private static string FormatCurrency(decimal amount) =>
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
private static string? JoinLines(string? line1, string? line2)
{
var parts = new[] { line1, line2 }.Where(part => !string.IsNullOrWhiteSpace(part));
var joined = string.Join(", ", parts);
return string.IsNullOrWhiteSpace(joined) ? null : joined;
}
// Builds an HTML address block; each text part is HTML-encoded and the line break (<br/>) is literal.
private static string JoinAddress(string? address, string? city, string? state, string? zip)
{
var cityLine = string.Join(", ",
new[] { city, string.Join(" ", new[] { state, zip }.Where(part => !string.IsNullOrWhiteSpace(part))) }
.Where(part => !string.IsNullOrWhiteSpace(part)));
var lines = new[] { address, cityLine }
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(Encode);
return string.Join("<br/>", lines);
}
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.Services;
public interface I1099FormService
{
/// <summary>Recipient Copy B 1099-NEC PDF for one payee/year (plain paper).</summary>
Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear);
/// <summary>Filing-data CSV (one row per reportable recipient) for IRIS/accountant.</summary>
Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear);
}
@@ -0,0 +1,134 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Read-only aggregation producing the year-end 1099 recipient summary. CASH BASIS:
/// only Paid expenses whose PaidAt falls in the tax year, attributed to a tracked payee,
/// on a line whose category maps to a 1099 box (sub ?? group). Unmapped lines are excluded.
/// </summary>
public class Form1099ReportService : IForm1099ReportService
{
private readonly AppDbContext _db;
public Form1099ReportService(AppDbContext db) => _db = db;
public async Task<List<Form1099BoxDto>> GetBoxesAsync() =>
await _db.Form1099Boxes.AsNoTracking().Where(b => b.IsActive)
.OrderBy(b => b.SortOrder)
.Select(b => new Form1099BoxDto
{
Id = b.Id, BoxCode = b.BoxCode, Name_en = b.Name_en,
Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder,
}).ToListAsync();
/// <summary>
/// Pulls the reportable expense lines for the tax year and materializes them (anonymous
/// projection -&gt; ToListAsync -&gt; in-memory map), mirroring Form990ReportService so the SQL
/// translation stays simple on Npgsql. The tax year is a half-open UTC range
/// [Jan 1 taxYear, Jan 1 taxYear+1), deterministic regardless of server timezone and matching
/// how Expense.PaidAt is written (midnight UTC). Unmapped lines (no 1099 box) are dropped here
/// so callers always receive reportable lines.
/// </summary>
private async Task<List<PaidLine>> LoadReportableLinesAsync(int taxYear)
{
var start = new DateTimeOffset(new DateTime(taxYear, 1, 1), TimeSpan.Zero);
var end = start.AddYears(1);
var raw = await (
from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null
&& e.PaidAt >= start && e.PaidAt < end && e.PayeeId != null)
join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id
join l in _db.ExpenseLines on e.Id equals l.ExpenseId
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
select new
{
PayeeId = p.Id,
p.LegalName,
p.TinLast4,
p.W9Status,
PaidAt = e.PaidAt!.Value,
e.Description,
GroupName = grp.Name_en,
SubName = sub.Name_en,
l.Amount,
BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId,
}).ToListAsync();
return raw.Where(x => x.BoxId != null)
.Select(x => new PaidLine
{
PayeeId = x.PayeeId,
LegalName = x.LegalName,
TinLast4 = x.TinLast4,
W9Status = x.W9Status,
PaidAt = x.PaidAt,
Description = x.Description,
CategoryName = x.GroupName + " / " + x.SubName,
Amount = x.Amount,
BoxId = x.BoxId,
}).ToList();
}
public async Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear)
{
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
var lines = await LoadReportableLinesAsync(taxYear);
var dto = new Form1099SummaryDto { TaxYear = taxYear };
foreach (var g in lines.GroupBy(x => x.PayeeId))
{
var first = g.First();
var nec = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxNec1).Sum(x => x.Amount);
var rents = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxMisc1).Sum(x => x.Amount);
var w9Missing = first.W9Status != Form1099.W9Status.OnFile;
var meets = nec >= Form1099.ReportingThreshold || rents >= Form1099.ReportingThreshold;
dto.Rows.Add(new Form1099RecipientRowDto
{
PayeeId = first.PayeeId, LegalName = first.LegalName, TinLast4 = first.TinLast4,
W9Status = first.W9Status, NecTotal = nec, RentsTotal = rents,
GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing,
});
}
dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ThenBy(r => r.LegalName).ToList();
dto.TotalReportable = dto.Rows.Sum(r => r.GrandTotal);
dto.RecipientsAtThreshold = dto.Rows.Count(r => r.MeetsThreshold);
dto.RecipientsMissingW9 = dto.Rows.Count(r => r.W9Missing);
return dto;
}
public async Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
{
var payee = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(p => p.Id == payeeId);
if (payee is null) return null;
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
var lines = (await LoadReportableLinesAsync(taxYear)).Where(x => x.PayeeId == payeeId).ToList();
return new Form1099RecipientDetailDto
{
PayeeId = payee.Id, LegalName = payee.LegalName, TinLast4 = payee.TinLast4,
W9Status = payee.W9Status, TaxYear = taxYear,
Payments = lines.OrderBy(x => x.PaidAt).Select(x => new Form1099PaymentDto
{
PaidDate = DateOnly.FromDateTime(x.PaidAt.Date).ToString("yyyy-MM-dd"),
Description = x.Description, CategoryName = x.CategoryName,
BoxCode = boxes.GetValueOrDefault(x.BoxId!.Value) ?? "", Amount = x.Amount,
}).ToList(),
};
}
private sealed class PaidLine
{
public int PayeeId { get; set; }
public string LegalName { get; set; } = "";
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public DateTimeOffset PaidAt { get; set; }
public string Description { get; set; } = "";
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public int? BoxId { get; set; }
}
}
@@ -0,0 +1,9 @@
using ROLAC.API.DTOs.Finance;
namespace ROLAC.API.Services;
public interface IForm1099ReportService
{
Task<List<Form1099BoxDto>> GetBoxesAsync();
Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear);
Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear);
}
@@ -0,0 +1,17 @@
using ROLAC.API.DTOs.Payee;
namespace ROLAC.API.Services;
public interface IPayee1099Service
{
Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive);
Task<Payee1099Dto?> GetByIdAsync(int id);
Task<int> CreateAsync(SavePayee1099Request r);
Task UpdateAsync(int id, SavePayee1099Request r);
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);
}
+162
View File
@@ -0,0 +1,162 @@
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);
}
}
}
@@ -0,0 +1,8 @@
namespace ROLAC.API.Services.Security;
/// <summary>Reversible protection for taxpayer identification numbers (SSN/EIN).</summary>
public interface ITinProtector
{
string Protect(string plaintext);
string Unprotect(string ciphertext);
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.DataProtection;
namespace ROLAC.API.Services.Security;
public class TinProtector : ITinProtector
{
private readonly IDataProtector _protector;
public TinProtector(IDataProtectionProvider provider)
=> _protector = provider.CreateProtector("Payee1099.Tin");
public string Protect(string plaintext) => _protector.Protect(plaintext);
public string Unprotect(string ciphertext) => _protector.Unprotect(ciphertext);
/// <summary>Last four digits of a TIN (ignoring dashes/spaces); null/empty in => null.</summary>
public static string? Last4(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var digits = new string(raw.Where(char.IsDigit).ToArray());
return digits.Length <= 4 ? digits : digits[^4..];
}
}
+20
View File
@@ -22,6 +22,8 @@ import { DisbursementPageComponent } from './features/disbursement/pages/disburs
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
import { Form1099ReportPageComponent } from './features/finance-report/pages/form1099-report-page/form1099-report-page.component';
import { Payee1099PageComponent } from './features/payee1099/pages/payee-1099-page/payee-1099-page.component';
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
@@ -228,6 +230,24 @@ export const routes: Routes = [
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
},
},
{
path: 'finance/payee-1099',
component: Payee1099PageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Form1099, action: 'read' },
title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance',
},
},
{
path: 'finance/form1099-report',
component: Form1099ReportPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Form1099, action: 'read' },
title: '1099 Year-End Report', titleZh: '1099 年度報表', section: 'Finance',
},
},
]
},
@@ -33,6 +33,7 @@ export const PermissionModules = {
SystemLogs: 'SystemLogs',
AuditLogs: 'AuditLogs',
Settings: 'Settings',
Form1099: 'Form1099',
} as const;
/** A required permission, used in route data and the *appHasPermission directive. */
@@ -48,7 +48,7 @@ export interface ChurchProfileDto {
id: number; name: string; nameZh: string | null; phone: string | null;
email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
bankAccountNumber: string | null; bankRoutingNumber: string | null; payerEin: string | null; nextCheckNumber: number;
aiProvider: string;
claudeModel: string | null; claudeApiKeyMasked: string | null;
geminiModel: string | null; geminiApiKeyMasked: string | null;
@@ -55,6 +55,10 @@
Routing # / 路由號碼
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Payer EIN / 雇主識別號 (EIN)
<kendo-textbox [(ngModel)]="model.payerEin" placeholder="XX-XXXXXXX"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Next Check # / 下一張支票號碼
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
@@ -158,7 +158,7 @@
</div>
<!-- Vendor mode: vendor name + check number -->
<!-- Vendor mode: vendor name + check number + optional 1099 recipient -->
<ng-container *ngIf="mode === 'vendor'">
<label class="flex flex-col gap-1">Vendor Name
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
@@ -166,6 +166,12 @@
<label class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">1099 Recipient / 1099 收款人 <span class="text-gray-400 font-normal">(optional)</span>
<kendo-dropdownlist [data]="payees" textField="legalName" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, legalName: ' none ' }"
[(ngModel)]="form.payeeId">
</kendo-dropdownlist>
</label>
</ng-container>
<!-- Reimbursement mode: receipt file input -->
@@ -15,6 +15,8 @@ import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/e
import { ExpenseAiService } from '../../services/expense-ai.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import { Payee1099ApiService } from '../../../payee1099/services/payee1099-api.service';
import { Payee1099ListItem } from '../../../payee1099/models/payee1099.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
@@ -66,6 +68,8 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
ministries: MinistryDto[] = [];
groups: ExpenseCategoryGroupDto[] = [];
payees: Payee1099ListItem[] = [];
/** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */
snapshots: ExpenseSnapshotDto[] = [];
/** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */
@@ -95,6 +99,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
vendorName: '',
checkNumber: '',
memberId: null as number | null,
payeeId: null as number | null,
expenseDate: new Date(),
};
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
@@ -133,11 +138,13 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
private expenseApi: ExpenseApiService,
private snapshotApi: ExpenseSnapshotApiService,
private aiApi: ExpenseAiService,
private payeeApi: Payee1099ApiService,
private sanitizer: DomSanitizer,
) {}
ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
this.payeeApi.getAll(false).subscribe(list => (this.payees = list));
if (this.showSnapshotTools) this.loadSnapshots();
this.catApi.getAll(false).subscribe(groups => {
this.groups = groups;
@@ -169,6 +176,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
vendorName: expense.vendorName ?? '',
checkNumber: expense.checkNumber ?? '',
memberId: expense.memberId,
payeeId: expense.payeeId ?? null,
expenseDate: new Date(year, month - 1, day),
};
this.lines = (expense.lines ?? []).map(l => ({
@@ -424,6 +432,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
expenseDate,
notes: null,
payeeId: this.form.payeeId,
};
// The request and receipt are snapshotted here, so resetting the form right
// after emitting is safe even though the parent saves asynchronously.
@@ -8,11 +8,11 @@ export interface PagedResult<T> {
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; }
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; }
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; }
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; }
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; }
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; }
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
export interface ExpenseLineItemDto {
@@ -28,6 +28,7 @@ export interface ExpenseListItemDto {
expenseDate: string; hasReceipt: boolean;
checkNumber: string | null;
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
payeeId: number | null;
}
export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null;
@@ -70,6 +71,7 @@ export interface CreateExpenseRequest {
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
description: string; vendorName: string | null; memberId: number | null;
checkNumber: string | null; expenseDate: string; notes: string | null;
payeeId: number | null;
}
export type UpdateExpenseRequest = CreateExpenseRequest;
export interface RejectExpenseRequest { reviewNotes: string | null; }
@@ -91,7 +91,7 @@
Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<label class="flex flex-col gap-1">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
@@ -100,6 +100,15 @@
[(ngModel)]="groupForm.form990LineId">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
<span>1099 Box / 1099 框</span>
<kendo-dropdownlist
[data]="form1099Boxes"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: ' none ' }"
[(ngModel)]="groupForm.form1099BoxId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
</label>
@@ -158,7 +167,7 @@
Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<label class="flex flex-col gap-1">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
@@ -167,6 +176,15 @@
[(ngModel)]="subForm.form990LineId">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
<span>1099 Box / 1099 框</span>
<kendo-dropdownlist
[data]="form1099Boxes"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: ' none ' }"
[(ngModel)]="subForm.form1099BoxId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
</label>
@@ -10,6 +10,7 @@ import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
import { Form1099Box } from '../../../payee1099/models/payee1099.model';
@Component({
selector: 'app-expense-categories-page',
@@ -23,6 +24,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
selectedGroup: ExpenseCategoryGroupDto | null = null;
loading = false;
form990Lines: Form990ExpenseLineDto[] = [];
form1099Boxes: (Form1099Box & { label: string })[] = [];
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
@@ -33,13 +35,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groupDialogOpen = false;
editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
groupAiLoading = false;
groupAiSuggestion: CategoryAiSuggestion | null = null;
subDialogOpen = false;
editingSubId: number | null = null;
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
subAiLoading = false;
subAiSuggestion: CategoryAiSuggestion | null = null;
@@ -48,6 +50,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
ngOnInit(): void {
this.load();
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
this.api.getForm1099Boxes().subscribe(boxes => { this.form1099Boxes = boxes; });
}
load(): void {
@@ -111,13 +114,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewGroup(): void {
this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
this.resetGroupAi();
this.groupDialogOpen = true;
}
openEditGroup(g: ExpenseCategoryGroupDto): void {
this.editingGroupId = g.id;
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId, form1099BoxId: g.form1099BoxId };
this.resetGroupAi();
this.groupDialogOpen = true;
}
@@ -143,7 +146,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
}
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
saveGroup(): void {
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId, form1099BoxId: this.groupForm.form1099BoxId };
const done = () => { this.groupDialogOpen = false; this.load(); };
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
@@ -156,13 +159,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewSub(): void {
if (!this.selectedGroup) return;
this.editingSubId = null;
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
this.resetSubAi();
this.subDialogOpen = true;
}
openEditSub(s: ExpenseSubCategoryDto): void {
this.editingSubId = s.id;
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId, form1099BoxId: s.form1099BoxId };
this.resetSubAi();
this.subDialogOpen = true;
}
@@ -195,7 +198,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
saveSub(): void {
if (!this.selectedGroup) return;
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId, form1099BoxId: this.subForm.form1099BoxId };
const done = () => { this.subDialogOpen = false; this.load(); };
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
@@ -9,6 +9,7 @@ import {
ExpenseCategoryAiRequest, CategoryAiSuggestion,
} from '../models/expense.model';
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
import { Form1099Box } from '../../payee1099/models/payee1099.model';
@Injectable({ providedIn: 'root' })
export class ExpenseCategoryApiService {
@@ -38,4 +39,9 @@ export class ExpenseCategoryApiService {
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode}${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
}
getForm1099Boxes(): Observable<(Form1099Box & { label: string })[]> {
return this.http.get<Form1099Box[]>(this.apiConfig.getApiUrl('form1099-report') + '/boxes')
.pipe(map(rows => rows.map(b => ({ ...b, label: `${b.boxCode}${b.name_en}${b.name_zh ? ' / ' + b.name_zh : ''}` }))));
}
}
@@ -0,0 +1,119 @@
<div class="page">
<ng-template appPageHeaderActions>
<button kendoButton themeColor="primary" (click)="exportCsv()">
Export filing CSV / 匯出申報資料
</button>
</ng-template>
<!-- Year selector -->
<div class="flex flex-wrap items-end gap-3 mb-4">
<label class="flex flex-col gap-1">
<span>Tax Year / 稅務年度</span>
<kendo-dropdownlist [data]="years" [(ngModel)]="taxYear" [style.width.px]="140"></kendo-dropdownlist>
</label>
<button kendoButton themeColor="primary" (click)="load()">Load / 載入</button>
</div>
<!-- Summary chips -->
<div *ngIf="summary" class="flex flex-wrap gap-3 mb-4">
<div class="summary-chip">
<div class="summary-label">Total Reportable / 應申報總額</div>
<div class="summary-value">{{ summary.totalReportable | currency }}</div>
</div>
<div class="summary-chip">
<div class="summary-label">Recipients ≥ $600 / 達門檻收款人</div>
<div class="summary-value">{{ summary.recipientsAtThreshold }}</div>
</div>
<div class="summary-chip" [class.summary-chip-flag]="summary.recipientsMissingW9 > 0">
<div class="summary-label">Missing W-9 / 缺少 W-9</div>
<div class="summary-value">{{ summary.recipientsMissingW9 }}</div>
</div>
</div>
<div class="hint-text-sm">Click a name for payment detail · right-click a row for Copy B / 點選名稱檢視明細 · 右鍵下載 Copy B</div>
<!-- Desktop grid -->
<div class="hidden md:block">
<kendo-grid class="clickable-rows" [data]="summary?.rows ?? []" [loading]="loading"
(cellClick)="onCellClick($event)">
<kendo-grid-column field="legalName" title="Legal Name / 法定名稱">
<ng-template kendoGridCellTemplate let-r>
<span class="legal-name">{{ r.legalName }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="TIN" [width]="120">
<ng-template kendoGridCellTemplate let-r>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="w9Status" title="W-9" [width]="130">
<ng-template kendoGridCellTemplate let-r>
<span class="badge" [ngClass]="r.w9Missing ? 'badge-missing' : 'badge-' + r.w9Status.toLowerCase()">
{{ r.w9Status }}
</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="necTotal" title="NEC / 非雇員報酬" format="{0:c2}" [width]="150"></kendo-grid-column>
<kendo-grid-column field="rentsTotal" title="Rents / 租金" format="{0:c2}" [width]="140"></kendo-grid-column>
<kendo-grid-column field="grandTotal" title="Total / 總計" format="{0:c2}" [width]="150"></kendo-grid-column>
<kendo-grid-column title="Threshold / 門檻" [width]="130">
<ng-template kendoGridCellTemplate let-r>
<span *ngIf="r.meetsThreshold" class="badge badge-threshold">≥ $600</span>
<span *ngIf="!r.meetsThreshold"></span>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
</div>
<!-- Mobile cards -->
<div class="md:hidden flex flex-col gap-3">
<div *ngFor="let r of summary?.rows ?? []" class="rounded border p-3" (click)="openDetail(r)">
<div class="flex justify-between items-start gap-2">
<div class="font-semibold">{{ r.legalName }}</div>
<span class="badge" [ngClass]="r.w9Missing ? 'badge-missing' : 'badge-' + r.w9Status.toLowerCase()">
{{ r.w9Status }}
</span>
</div>
<div class="text-sm flex justify-between"><span>TIN</span><span>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</span></div>
<div class="text-sm flex justify-between"><span>NEC / 非雇員報酬</span><span>{{ r.necTotal | currency }}</span></div>
<div class="text-sm flex justify-between"><span>Rents / 租金</span><span>{{ r.rentsTotal | currency }}</span></div>
<div class="text-sm flex justify-between font-semibold"><span>Total / 總計</span><span>{{ r.grandTotal | currency }}</span></div>
<div class="text-sm flex justify-between">
<span>Threshold / 門檻</span>
<span><span *ngIf="r.meetsThreshold" class="badge badge-threshold">≥ $600</span><span *ngIf="!r.meetsThreshold"></span></span>
</div>
</div>
</div>
<!-- Recipient detail dialog -->
<kendo-dialog *ngIf="detail || detailLoading"
[title]="'Recipient Detail / 收款人明細'"
(close)="closeDetail()"
[width]="760" [maxWidth]="'95vw'">
<div *ngIf="detailLoading" class="p-3">Loading… / 載入中…</div>
<ng-container *ngIf="detail">
<div class="detail-header">
<div class="detail-name">{{ detail.legalName }}</div>
<div class="detail-meta">
<span>TIN {{ detail.tinLast4 ? '***-**-' + detail.tinLast4 : '—' }}</span>
<span class="badge" [ngClass]="'badge-' + detail.w9Status.toLowerCase()">{{ detail.w9Status }}</span>
<span>Year / 年度 {{ detail.taxYear }}</span>
</div>
</div>
<kendo-grid [data]="detail.payments">
<kendo-grid-column field="paidDate" title="Date / 日期" [width]="120"></kendo-grid-column>
<kendo-grid-column field="description" title="Description / 說明"></kendo-grid-column>
<kendo-grid-column field="categoryName" title="Category / 類別" [width]="170"></kendo-grid-column>
<kendo-grid-column field="boxCode" title="Box" [width]="90"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount / 金額" format="{0:c2}" [width]="140"></kendo-grid-column>
</kendo-grid>
</ng-container>
<kendo-dialog-actions>
<button kendoButton (click)="closeDetail()">Close / 關閉</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,100 @@
.hint-text-sm {
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #999;
}
.legal-name {
font-weight: 600;
}
// Grid rows are clickable to open the recipient detail.
.clickable-rows ::ng-deep .k-grid-content tr {
cursor: pointer;
}
// Summary chips.
.summary-chip {
flex: 1 1 200px;
min-width: 180px;
padding: 0.75rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #f9fafb;
}
.summary-label {
font-size: 0.75rem;
color: #6b7280;
}
.summary-value {
font-size: 1.5rem;
font-weight: 700;
color: #111827;
}
// Missing-W-9 chip is a governance flag — make it stand out.
.summary-chip-flag {
border-color: #fca5a5;
background-color: #fef2f2;
.summary-value {
color: #991b1b;
}
}
// Recipient detail header.
.detail-header {
margin-bottom: 0.75rem;
}
.detail-name {
font-size: 1.1rem;
font-weight: 700;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.85rem;
color: #555;
}
// Status / threshold badges.
.badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
}
.badge-onfile {
background-color: #dcfce7;
color: #166534;
}
.badge-requested {
background-color: #fef9c3;
color: #854d0e;
}
.badge-missing {
background-color: #fee2e2;
color: #991b1b;
}
.badge-expired {
background-color: #fed7aa;
color: #9a3412;
}
.badge-threshold {
background-color: #dbeafe;
color: #1e40af;
}
@@ -0,0 +1,122 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { Form1099ReportApiService } from '../../../payee1099/services/form1099-report-api.service';
import {
Form1099Summary, Form1099RecipientRow, Form1099RecipientDetail,
} from '../../../payee1099/models/payee1099.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({
selector: 'app-form1099-report-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
DropDownsModule, ContextMenuModule, PageHeaderActionsDirective,
],
templateUrl: './form1099-report-page.component.html',
styleUrls: ['./form1099-report-page.component.scss'],
})
export class Form1099ReportPageComponent implements OnInit {
/** Recent years offered in the selector: current year and the prior four. */
readonly years: number[] = [];
taxYear: number = new Date().getFullYear();
summary: Form1099Summary | null = null;
loading = false;
// Per-row "Copy B" action, surfaced through a right-click context menu (matches
// the recipients page convention of putting row actions in a context menu).
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
rowMenuItems: { text: string }[] = [];
private contextRow: Form1099RecipientRow | null = null;
detail: Form1099RecipientDetail | null = null;
detailLoading = false;
constructor(private api: Form1099ReportApiService) {
const currentYear = new Date().getFullYear();
for (let offset = 0; offset < 5; offset++) {
this.years.push(currentYear - offset);
}
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.api.getSummary(this.taxYear).subscribe({
next: (summary) => {
this.summary = summary;
this.loading = false;
},
error: () => { this.loading = false; },
});
}
// ── Row interaction: primary click opens the detail; right-click shows actions ──
onCellClick(event: CellClickEvent): void {
if (event.type === 'contextmenu') {
event.originalEvent.preventDefault();
this.contextRow = event.dataItem;
this.rowMenuItems = [{ text: 'Copy B PDF' }];
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
} else {
this.openDetail(event.dataItem);
}
}
onRowMenuSelect(event: ContextMenuSelectEvent): void {
if (!this.contextRow) return;
if (event.item.text === 'Copy B PDF') this.copyB(this.contextRow);
}
openDetail(row: Form1099RecipientRow): void {
this.detail = null;
this.detailLoading = true;
this.api.getRecipient(row.payeeId, this.taxYear).subscribe({
next: (detail) => {
this.detail = detail;
this.detailLoading = false;
},
error: () => { this.detailLoading = false; },
});
}
closeDetail(): void {
this.detail = null;
this.detailLoading = false;
}
// ── Downloads: fetched as blobs so the auth interceptor attaches the token ──────
exportCsv(): void {
this.api.downloadCsv(this.taxYear).subscribe((blob) => {
this.saveBlob(blob, `1099-filing-${this.taxYear}.csv`);
});
}
copyB(row: Form1099RecipientRow): void {
this.api.downloadCopyB(row.payeeId, this.taxYear).subscribe((blob) => {
this.saveBlob(blob, `1099-NEC-${row.payeeId}-${this.taxYear}.pdf`);
});
}
/** Trigger a browser save of a downloaded blob via a temporary anchor. */
private saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
}
@@ -0,0 +1,43 @@
export interface Payee1099ListItem {
id: number; legalName: string; displayName?: string;
memberId?: number; memberName?: string; taxClassification: string;
is1099Tracked: boolean; tinType?: string; tinLast4?: string;
w9Status: string; isActive: boolean;
}
export interface Payee1099 extends Payee1099ListItem {
addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
email?: string; phone?: string; w9ReceivedDate?: string; hasW9Document: boolean; notes?: string;
}
export interface SavePayee1099Request {
legalName: string; displayName?: string; memberId?: number | null;
taxClassification: string; is1099Tracked: boolean;
tinType?: string; tin?: string | null;
addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
email?: string; phone?: string; w9Status: string; w9ReceivedDate?: string | null;
isActive: boolean; notes?: string;
}
export interface Form1099Box {
id: number; boxCode: string; name_en: string; name_zh?: string; formType: string; sortOrder: number;
}
export interface Form1099RecipientRow {
payeeId: number; legalName: string; tinLast4?: string; w9Status: string;
necTotal: number; rentsTotal: number; grandTotal: number; meetsThreshold: boolean; w9Missing: boolean;
}
export interface Form1099Summary {
taxYear: number; rows: Form1099RecipientRow[];
totalReportable: number; recipientsAtThreshold: number; recipientsMissingW9: number;
}
export interface Form1099Payment {
paidDate: string; description: string; categoryName: string; boxCode: string; amount: number;
}
export interface Form1099RecipientDetail {
payeeId: number; legalName: string; tinLast4?: string; w9Status: string;
taxYear: number; payments: Form1099Payment[];
}
@@ -0,0 +1,183 @@
<div class="page">
<ng-template appPageHeaderActions>
<label class="inactive-toggle">
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive / 顯示停用
</label>
<button kendoButton themeColor="primary"
*appHasPermission="{ module: 'Form1099', action: 'write' }"
(click)="openNew()">+ New Recipient / 新增收款人</button>
</ng-template>
<div class="hint-text-sm">Click a name to edit · right-click a row for actions / 點選名稱編輯 · 右鍵顯示動作</div>
<!-- Desktop grid -->
<div class="hidden md:block">
<kendo-grid class="clickable-rows" [data]="recipients" [loading]="loading"
(cellClick)="onCellClick($event)">
<kendo-grid-column field="legalName" title="Legal Name / 法定名稱">
<ng-template kendoGridCellTemplate let-r>
<span class="legal-name">{{ r.legalName }}</span>
<span *ngIf="r.displayName" class="display-name"> ({{ r.displayName }})</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="memberName" title="Member / 會友">
<ng-template kendoGridCellTemplate let-r>{{ r.memberName || '—' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="taxClassification" title="Tax Class / 稅務分類" [width]="150"></kendo-grid-column>
<kendo-grid-column title="TIN" [width]="120">
<ng-template kendoGridCellTemplate let-r>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="w9Status" title="W-9" [width]="120">
<ng-template kendoGridCellTemplate let-r>
<span class="badge" [ngClass]="'badge-' + r.w9Status.toLowerCase()">{{ r.w9Status }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="is1099Tracked" title="1099 Tracked" [width]="120">
<ng-template kendoGridCellTemplate let-r>{{ r.is1099Tracked ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="90">
<ng-template kendoGridCellTemplate let-r>{{ r.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
</div>
<!-- Mobile cards -->
<div class="md:hidden flex flex-col gap-3">
<div *ngFor="let r of recipients" class="rounded border p-3" (click)="openEdit(r)">
<div class="flex justify-between items-start gap-2">
<div class="font-semibold">{{ r.legalName }}</div>
<span class="badge" [ngClass]="'badge-' + r.w9Status.toLowerCase()">{{ r.w9Status }}</span>
</div>
<div *ngIf="r.displayName" class="text-sm text-gray-500">{{ r.displayName }}</div>
<div class="text-sm flex justify-between"><span>Member / 會友</span><span>{{ r.memberName || '—' }}</span></div>
<div class="text-sm flex justify-between"><span>Tax Class</span><span>{{ r.taxClassification }}</span></div>
<div class="text-sm flex justify-between"><span>TIN</span><span>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</span></div>
<div class="text-sm flex justify-between"><span>1099 Tracked</span><span>{{ r.is1099Tracked ? 'Yes' : 'No' }}</span></div>
<div class="text-sm flex justify-between"><span>Active</span><span>{{ r.isActive ? 'Yes' : 'No' }}</span></div>
</div>
</div>
<!-- New / Edit dialog -->
<kendo-dialog *ngIf="dialogOpen"
[title]="editingId != null ? 'Edit Recipient / 編輯收款人' : 'New Recipient / 新增收款人'"
(close)="dialogOpen = false"
[width]="720" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Legal Name / 法定名稱 *
<kendo-textbox [(ngModel)]="form.legalName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Display Name / 顯示名稱
<kendo-textbox [(ngModel)]="form.displayName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Linked Member / 連結會友
<kendo-dropdownlist
[data]="memberResults"
textField="displayName" valueField="id" [valuePrimitive]="true"
[filterable]="true" (filterChange)="onMemberFilter($event)"
[defaultItem]="{ id: null, displayName: '(None / )' }"
[(ngModel)]="form.memberId">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Tax Classification / 稅務分類
<kendo-dropdownlist [data]="taxClassifications" [(ngModel)]="form.taxClassification"
(valueChange)="onTaxClassificationChange($event)"></kendo-dropdownlist>
</label>
<label class="flex items-center gap-2 md:mt-6">
<kendo-switch [(ngModel)]="form.is1099Tracked" (valueChange)="onTrackedToggle()"></kendo-switch>
<span>1099 Tracked / 列入 1099</span>
</label>
<label class="flex flex-col gap-1">
TIN Type / 稅號類型
<kendo-dropdownlist [data]="tinTypes" [(ngModel)]="form.tinType"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
TIN / 稅號
<kendo-textbox [(ngModel)]="form.tin"
[placeholder]="editingId != null && editingTinLast4 ? '***-**-' + editingTinLast4 : ''"></kendo-textbox>
<span *ngIf="editingId != null" class="hint-text-sm">Leave blank to keep the existing TIN / 留空則保留現有稅號</span>
<div *ngIf="editingId != null" class="flex flex-col gap-1">
<button kendoButton type="button" fillMode="link" class="self-start"
*appHasPermission="{ module: 'Form1099', action: 'write' }"
(click)="revealTin()">Reveal full TIN / 顯示完整 TIN</button>
<span *ngIf="revealedTin" class="font-mono">{{ revealedTin }}</span>
</div>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address Line 1 / 地址 1
<kendo-textbox [(ngModel)]="form.addressLine1"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address Line 2 / 地址 2
<kendo-textbox [(ngModel)]="form.addressLine2"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
City / 城市
<kendo-textbox [(ngModel)]="form.city"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-x-4">
<label class="flex flex-col gap-1">
State / 州
<kendo-textbox [(ngModel)]="form.state"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Zip / 郵遞區號
<kendo-textbox [(ngModel)]="form.zip"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Email / 電郵
<kendo-textbox [(ngModel)]="form.email"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Phone / 電話
<kendo-textbox [(ngModel)]="form.phone"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
W-9 Status / W-9 狀態
<kendo-dropdownlist [data]="w9Statuses" [(ngModel)]="form.w9Status"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
W-9 Received / W-9 收到日期
<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>
</label>
<label *ngIf="editingId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="form.isActive" /> Active / 啟用
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="dialogOpen = false">Cancel / 取消</button>
<button kendoButton themeColor="primary" [disabled]="!form.legalName" (click)="save()">Save / 儲存</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,55 @@
.hint-text-sm {
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #999;
}
.inactive-toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.85rem;
}
.legal-name {
font-weight: 600;
}
.display-name {
color: #777;
}
// Grid rows are clickable to open the editor.
.clickable-rows ::ng-deep .k-grid-content tr {
cursor: pointer;
}
// W-9 status badges.
.badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
}
.badge-onfile {
background-color: #dcfce7;
color: #166534;
}
.badge-requested {
background-color: #fef9c3;
color: #854d0e;
}
.badge-missing {
background-color: #fee2e2;
color: #991b1b;
}
.badge-expired {
background-color: #fed7aa;
color: #9a3412;
}
@@ -0,0 +1,288 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { Payee1099ApiService } from '../../services/payee1099-api.service';
import { Payee1099ListItem, Payee1099, SavePayee1099Request } from '../../models/payee1099.model';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
/** Flattened member item with a single displayName field for the picker. */
interface MemberOption { id: number; displayName: string; }
/** Editable form model for the New/Edit dialog. */
interface Payee1099Form {
legalName: string;
displayName: string;
memberId: number | null;
taxClassification: string;
is1099Tracked: boolean;
tinType: string;
tin: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
zip: string;
email: string;
phone: string;
w9Status: string;
w9ReceivedDate: Date | null;
isActive: boolean;
notes: string;
}
@Component({
selector: 'app-payee-1099-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
InputsModule, DropDownsModule, DateInputsModule, ContextMenuModule,
PageHeaderActionsDirective, HasPermissionDirective,
],
templateUrl: './payee-1099-page.component.html',
styleUrls: ['./payee-1099-page.component.scss'],
})
export class Payee1099PageComponent implements OnInit {
recipients: Payee1099ListItem[] = [];
loading = false;
includeInactive = false;
readonly taxClassifications = ['Individual', 'SoleProprietor', 'Partnership', 'CCorp', 'SCorp', 'LLC', 'Other'];
readonly tinTypes = ['SSN', 'EIN'];
readonly w9Statuses = ['Missing', 'Requested', 'OnFile', 'Expired'];
/** Member picker options, filled on demand from the members search. */
memberResults: MemberOption[] = [];
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
rowMenuItems: { text: string }[] = [];
private contextRow: Payee1099ListItem | null = null;
dialogOpen = false;
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(
private api: Payee1099ApiService,
private memberApi: MemberApiService,
) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.api.getAll(this.includeInactive).subscribe({
next: (rows) => {
this.recipients = rows;
this.loading = false;
},
error: () => { this.loading = false; },
});
}
private blankForm(): Payee1099Form {
return {
legalName: '', displayName: '', memberId: null,
taxClassification: 'Individual', is1099Tracked: true,
tinType: 'SSN', tin: '',
addressLine1: '', addressLine2: '', city: '', state: '', zip: '',
email: '', phone: '',
w9Status: 'Missing', w9ReceivedDate: null,
isActive: true, notes: '',
};
}
// ── Member picker (server-side search, same source as the expense form) ──────
onMemberFilter(term: string): void {
if (!term || term.length < 1) { this.memberResults = []; return; }
this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe((result) => {
this.memberResults = result.items.map((member: MemberListItemDto) => ({
id: member.id,
displayName: memberDisplayName(member),
}));
});
}
// ── Row interaction: primary click opens the editor; right-click shows actions ──
onCellClick(event: CellClickEvent): void {
if (event.type === 'contextmenu') {
event.originalEvent.preventDefault();
this.contextRow = event.dataItem;
this.rowMenuItems = this.buildMenuItems(event.dataItem.isActive);
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
} else {
this.openEdit(event.dataItem);
}
}
onRowMenuSelect(event: ContextMenuSelectEvent): void {
if (!this.contextRow) return;
if (event.item.text === 'Edit') this.openEdit(this.contextRow);
else if (event.item.text === 'Deactivate') this.deactivate(this.contextRow);
}
private buildMenuItems(isActive: boolean): { text: string }[] {
const items: { text: string }[] = [{ text: 'Edit' }];
if (isActive) items.push({ text: 'Deactivate' });
return items;
}
// ── Dialog open ──────────────────────────────────────────────────────────────
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 ?? '',
memberId: payee.memberId ?? null,
taxClassification: payee.taxClassification,
is1099Tracked: payee.is1099Tracked,
tinType: payee.tinType ?? 'SSN',
tin: '',
addressLine1: payee.addressLine1 ?? '',
addressLine2: payee.addressLine2 ?? '',
city: payee.city ?? '',
state: payee.state ?? '',
zip: payee.zip ?? '',
email: payee.email ?? '',
phone: payee.phone ?? '',
w9Status: payee.w9Status,
w9ReceivedDate: this.parseDateOnly(payee.w9ReceivedDate),
isActive: payee.isActive,
notes: payee.notes ?? '',
};
// Seed the picker with the linked member so its name shows even before a search.
if (payee.memberId != null && payee.memberName) {
this.memberResults = [{ id: payee.memberId, displayName: payee.memberName }];
}
});
}
// ── Save ─────────────────────────────────────────────────────────────────────
save(): void {
if (!this.form.legalName.trim()) return;
const typedTin = this.form.tin.trim();
const request: SavePayee1099Request = {
legalName: this.form.legalName.trim(),
displayName: this.form.displayName.trim() || undefined,
memberId: this.form.memberId ?? null,
taxClassification: this.form.taxClassification,
is1099Tracked: this.form.is1099Tracked,
tinType: this.form.tinType,
// Send the typed TIN when present. On edit a blank leaves the stored value
// unchanged (null = no change); on new a blank simply means no TIN yet.
tin: typedTin || null,
addressLine1: this.form.addressLine1.trim() || undefined,
addressLine2: this.form.addressLine2.trim() || undefined,
city: this.form.city.trim() || undefined,
state: this.form.state.trim() || undefined,
zip: this.form.zip.trim() || undefined,
email: this.form.email.trim() || undefined,
phone: this.form.phone.trim() || undefined,
w9Status: this.form.w9Status,
w9ReceivedDate: this.toDateOnly(this.form.w9ReceivedDate),
isActive: this.form.isActive,
notes: this.form.notes.trim() || undefined,
};
const done = () => { this.dialogOpen = false; this.load(); };
if (this.editingId == null) this.api.create(request).subscribe(done);
else this.api.update(this.editingId, request).subscribe(done);
}
deactivate(row: Payee1099ListItem): void {
if (!confirm(`Deactivate "${row.legalName}"?`)) return;
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;
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
private toDateOnly(date: Date | null): string | null {
if (!date) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
Form1099Box, Form1099Summary, Form1099RecipientDetail,
} from '../models/payee1099.model';
@Injectable({ providedIn: 'root' })
export class Form1099ReportApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('form1099-report');
}
getBoxes(): Observable<Form1099Box[]> {
return this.http.get<Form1099Box[]>(`${this.endpoint}/boxes`);
}
getSummary(taxYear: number): Observable<Form1099Summary> {
return this.http.get<Form1099Summary>(`${this.endpoint}/summary`, {
params: { taxYear: String(taxYear) },
});
}
getRecipient(payeeId: number, taxYear: number): Observable<Form1099RecipientDetail> {
return this.http.get<Form1099RecipientDetail>(`${this.endpoint}/recipient/${payeeId}`, {
params: { taxYear: String(taxYear) },
});
}
// Authenticated blob downloads: routed through HttpClient so the auth
// interceptor attaches the bearer token (a raw window.open would 401).
downloadCsv(taxYear: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/export-csv`, {
params: { taxYear: String(taxYear) },
responseType: 'blob',
});
}
downloadCopyB(payeeId: number, taxYear: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/recipient/${payeeId}/copy-b`, {
params: { taxYear: String(taxYear) },
responseType: 'blob',
});
}
}
@@ -0,0 +1,57 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
Payee1099ListItem, Payee1099, SavePayee1099Request,
} from '../models/payee1099.model';
@Injectable({ providedIn: 'root' })
export class Payee1099ApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('payee-1099');
}
getAll(includeInactive = false): Observable<Payee1099ListItem[]> {
return this.http.get<Payee1099ListItem[]>(this.endpoint, {
params: { includeInactive: String(includeInactive) },
});
}
getById(id: number): Observable<Payee1099> {
return this.http.get<Payee1099>(`${this.endpoint}/${id}`);
}
create(req: SavePayee1099Request): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, req);
}
update(id: number, req: SavePayee1099Request): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, req);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
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' });
}
}
@@ -138,6 +138,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
permission: { module: PermissionModules.Disbursements, action: 'read' } },
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
permission: { module: PermissionModules.Disbursements, action: 'read' } },
{ text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099',
permission: { module: PermissionModules.Form1099, action: 'read' } },
{ text: '1099 Report', icon: fileReportIcon, path: '/user-portal/finance/form1099-report',
permission: { module: PermissionModules.Form1099, action: 'read' } },
],
},
{
+96
View File
@@ -17,6 +17,9 @@
6. [Phase 1 — CMS](#6-cms)
7. [Phase 1 — Giving & Donations(奉獻)](#7-giving--donations-奉獻)
8. [Phase 1 — Expense Tracking(支出)](#8-expense-tracking-支出)
- [Form1099Box1099 欄位目錄)](#form1099box-irs-1099-報告欄位目錄)
- [Payee1099(收款人主檔)](#payee1099-1099-申報收款人主檔)
- [現有表新增欄位(1099 歸屬)](#現有表新增欄位1099-歸屬)
9. [Phase 1 — Prayer Requests(代禱)](#9-prayer-requests-代禱)
10. [Phase 1 — Audit Log](#10-audit-log)
11. [Phase 1 — Notifications](#11-notifications)
@@ -704,6 +707,91 @@ Table: MonthlyStatements
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| **UNIQUE** | (Year, Month) | 每個月只有一份月結報表 |
### Form1099BoxIRS 1099 報告欄位目錄)
```
Table: Form1099Boxes
```
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| BoxCode | varchar(20) NOT NULL UNIQUE | 欄位代碼,如 "NEC-1"、"MISC-1" |
| Name_en | varchar(200) NOT NULL | 英文欄位名稱 |
| Name_zh | varchar(200)? | 中文欄位名稱 |
| FormType | varchar(20) NOT NULL | '1099-NEC' \| '1099-MISC' |
| SortOrder | int NOT NULL DEFAULT 0 | 顯示排序 |
| IsActive | bool NOT NULL DEFAULT true | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
> **說明:** IRS 1099 申報欄位目錄(catalog)。Seed 預設兩個欄位:`NEC-1`Nonemployee compensation — 非員工報酬,1099-NEC 第 1 欄)與 `MISC-1`Rents — 租金,1099-MISC 第 1 欄)。此表為唯讀參考資料,僅透過 seed 管理;新增欄位須更新 seed 並重新執行 migration。
### Payee10991099 申報收款人主檔)
```
Table: Payee1099s
```
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| LegalName | varchar(200) NOT NULL | IRS 法定全名(個人或公司)|
| DisplayName | varchar(200)? | 顯示用簡稱(選填)|
| MemberId | int? | FK → Members.IdON DELETE SET NULL。收款人同時為教友時可選填關聯 |
| TaxClassification | varchar(50) NOT NULL | 稅務分類,如 'Individual'、'SoleProprietor'、'Corporation'、'Partnership' 等 |
| Is1099Tracked | bool NOT NULL DEFAULT true | 是否需要申報 1099 |
| TinType | varchar(10)? | 'SSN' \| 'EIN'null = 尚未收到 W-9 |
| **TinEncrypted** | varchar(MAX)? | **TIN 加密密文(使用 ASP.NET Data Protection API 加密靜態儲存,明文永不入庫)** |
| **TinLast4** | varchar(4)? | **TIN 末四碼明文(僅供遮罩顯示用,如 \*\*\*-\*\*-1234** |
| AddressLine1 | varchar(200)? | |
| AddressLine2 | varchar(200)? | |
| City | varchar(100)? | |
| State | varchar(50)? | |
| Zip | varchar(20)? | |
| Email | varchar(200)? | |
| Phone | varchar(30)? | |
| W9Status | varchar(20) NOT NULL DEFAULT 'Missing' | 'Missing' \| 'Requested' \| 'OnFile' \| 'Expired' |
| W9ReceivedDate | date? | W-9 文件收到日期 |
| W9BlobPath | varchar(500)? | 上傳的 W-9 文件 Azure Blob 路徑 |
| IsActive | bool NOT NULL DEFAULT true | |
| Notes | text? | 內部備注 |
| IsDeleted | bool NOT NULL DEFAULT false | 軟刪除 |
| DeletedAt | timestamp? | |
| DeletedBy | varchar(450)? | FK → AspNetUsers.Id |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
> **TIN 靜態加密(Encryption at Rest):** 納稅識別碼(SSN / EIN)屬高敏感個人資料。`TinEncrypted` 欄位儲存使用 ASP.NET Data Protection API`IDataProtector`)加密後的密文;`TinLast4` 僅儲存末四碼明文供前端遮罩顯示(\*\*\*-\*\*-XXXX)。明文 TIN 永遠不寫入資料庫,也不出現在 Audit Log 快照中。
### 現有表新增欄位(1099 歸屬)
以下欄位由 1099 功能新增至現有表,透過 EF Core Migration 套用:
**`Expenses`(新增欄位)**
| 欄位 | 型別 | 說明 |
|------|------|------|
| **PayeeId** | int? | FK → Payee1099s.IdON DELETE SET NULL。費用標題層級 1099 收款人歸屬;null = 不申報 1099 |
**`ExpenseSubCategories`(新增欄位)**
| 欄位 | 型別 | 說明 |
|------|------|------|
| **Form1099BoxId** | int? | FK → Form1099Boxes.IdON DELETE SET NULL。子項目層級 1099 申報欄位映射(優先於大類值)|
**`ExpenseCategoryGroups`(新增欄位)**
| 欄位 | 型別 | 說明 |
|------|------|------|
| **Form1099BoxId** | int? | FK → Form1099Boxes.IdON DELETE SET NULL。大類層級 1099 申報欄位備援映射 |
> **有效 1099 欄位解析順序:** `SubCategory.Form1099BoxId ?? Group.Form1099BoxId ?? null`(先取子項目欄位;若為 null 則取大類欄位;仍為 null = 該費用不需申報 1099)。此解析邏輯與 Form 990 行號解析(`SubCategory.Form990LineId ?? Group.Form990LineId ?? "24"`)平行,但語意不同:1099 的 null 代表「不申報」,而 990 的 null 會回退至行 "24"(其他費用)。
---
## 9. Prayer Requests(代禱)
@@ -1033,6 +1121,14 @@ super_admin, pastor, board_member, coworker_chair, ministry_leader, district_lea
Form990Report — 唯讀報表權限,授予角色:finance、pastor、board_member
```
### Form1099 權限模組
```
Form1099 — 1099 收款人管理與申報,授予角色:
finance — Read / Write / Delete(完整管理)
pastor — Read(唯讀總覽)
board_member — Read(唯讀總覽)
```
### CmsPages(靜態頁面 Slug
```
about, vision, service-times, contact