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="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
</ItemGroup> </ItemGroup>
<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 Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard"; public const string FinanceDashboard = "FinanceDashboard";
public const string Form990Report = "Form990Report"; public const string Form990Report = "Form990Report";
public const string Form1099 = "Form1099";
public const string MonthlyStatements = "MonthlyStatements"; public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile"; public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements"; public const string Disbursements = "Disbursements";
@@ -39,6 +40,7 @@ public static class Modules
Ministries, Ministries,
FinanceDashboard, FinanceDashboard,
Form990Report, Form990Report,
Form1099,
MonthlyStatements, MonthlyStatements,
ChurchProfile, ChurchProfile,
Disbursements, 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? BankName { get; set; }
public string? BankAccountNumber { get; set; } public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; } public string? BankRoutingNumber { get; set; }
public string? PayerEin { get; set; }
public int NextCheckNumber { get; set; } public int NextCheckNumber { get; set; }
public string AiProvider { get; set; } = "Claude"; public string AiProvider { get; set; } = "Claude";
public string? ClaudeModel { get; set; } public string? ClaudeModel { get; set; }
@@ -38,6 +39,7 @@ public class UpdateChurchProfileRequest
[MaxLength(200)] public string? BankName { get; set; } [MaxLength(200)] public string? BankName { get; set; }
[MaxLength(50)] public string? BankAccountNumber { get; set; } [MaxLength(50)] public string? BankAccountNumber { get; set; }
[MaxLength(50)] public string? BankRoutingNumber { 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; } [Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
[MaxLength(20)] public string AiProvider { get; set; } = "Claude"; [MaxLength(20)] public string AiProvider { get; set; } = "Claude";
[MaxLength(100)] public string? ClaudeModel { get; set; } [MaxLength(100)] public string? ClaudeModel { get; set; }
@@ -11,6 +11,8 @@ public class ExpenseSubCategoryDto
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int? Form990LineId { get; set; } public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; } public string? Form990LineCode { get; set; }
public int? Form1099BoxId { get; set; }
public string? Form1099BoxCode { get; set; }
} }
public class ExpenseCategoryGroupDto public class ExpenseCategoryGroupDto
@@ -22,6 +24,8 @@ public class ExpenseCategoryGroupDto
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int? Form990LineId { get; set; } public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; } public string? Form990LineCode { get; set; }
public int? Form1099BoxId { get; set; }
public string? Form1099BoxCode { get; set; }
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = []; public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
} }
@@ -31,6 +35,7 @@ public class CreateExpenseGroupRequest
[MaxLength(200)] public string? Name_zh { get; set; } [MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public int? Form990LineId { get; set; } public int? Form990LineId { get; set; }
public int? Form1099BoxId { get; set; }
} }
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
{ {
@@ -44,6 +49,7 @@ public class CreateExpenseSubCategoryRequest
[MaxLength(200)] public string? Name_zh { get; set; } [MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public int? Form990LineId { get; set; } public int? Form990LineId { get; set; }
public int? Form1099BoxId { get; set; }
} }
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
{ {
@@ -35,6 +35,7 @@ public class ExpenseListItemDto
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
public DateTimeOffset? ReviewedAt { get; set; } public DateTimeOffset? ReviewedAt { get; set; }
public string? ReviewNotes { get; set; } // reject reason (or approval note) public string? ReviewNotes { get; set; } // reject reason (or approval note)
public int? PayeeId { get; set; }
} }
public class ExpenseDto : ExpenseListItemDto public class ExpenseDto : ExpenseListItemDto
@@ -66,6 +67,7 @@ public class CreateExpenseRequest
[MaxLength(50)] public string? CheckNumber { get; set; } [MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; } [Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? PayeeId { get; set; }
} }
public class UpdateExpenseRequest : CreateExpenseRequest { } 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<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>(); public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>(); 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<Expense> Expenses => Set<Expense>();
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>(); public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>(); public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
@@ -218,6 +220,45 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.HasIndex(e => e.LineCode).IsUnique(); 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 ───────────────────────────────────────────── // ── ExpenseCategoryGroup ─────────────────────────────────────────────
builder.Entity<ExpenseCategoryGroup>(entity => builder.Entity<ExpenseCategoryGroup>(entity =>
{ {
@@ -227,6 +268,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasOne(e => e.Form990Line).WithMany() entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Form1099Box).WithMany()
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── ExpenseSubCategory ─────────────────────────────────────────────── // ── ExpenseSubCategory ───────────────────────────────────────────────
@@ -240,6 +283,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Form990Line).WithMany() entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Form1099Box).WithMany()
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── Expense ────────────────────────────────────────────────────────── // ── Expense ──────────────────────────────────────────────────────────
@@ -270,6 +315,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany() entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); .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) ────────────────── // ── 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.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50); entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).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.NameZh).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(50); entity.Property(e => e.Phone).HasMaxLength(50);
entity.Property(e => e.Email).HasMaxLength(200); entity.Property(e => e.Email).HasMaxLength(200);
+42
View File
@@ -137,6 +137,23 @@ public static class DbSeeder
("Other", "Gifts", "24"), ("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 // 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 // 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 // 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.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true), ("finance", Modules.Disbursements, true, true, true, true),
("finance", Modules.Form990Report, true, false, false, false), ("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 // Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too. // governance value, so finance and board members can read them too.
@@ -375,6 +397,25 @@ public static class DbSeeder
await db.SaveChangesAsync(); 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) public static async Task SeedChurchProfileAsync(AppDbContext db)
{ {
// Singleton row used by the disbursement module (issuer info + check counter). // Singleton row used by the disbursement module (issuer info + check counter).
@@ -454,6 +495,7 @@ public static class DbSeeder
await SeedMinistriesAsync(db); await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db); await SeedExpenseCategoriesAsync(db);
await SeedForm990ExpenseLinesAsync(db); await SeedForm990ExpenseLinesAsync(db);
await SeedForm1099BoxesAsync(db);
await SeedChurchProfileAsync(db); await SeedChurchProfileAsync(db);
await SeedSiteSettingAsync(db); await SeedSiteSettingAsync(db);
await SeedNotificationSettingAsync(db, config); await SeedNotificationSettingAsync(db, config);
+3
View File
@@ -21,6 +21,9 @@ public class ChurchProfile : AuditableEntity, IAuditable
public string? BankAccountNumber { get; set; } public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { 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) ── // ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini" public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001"; 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 Description { get; set; } = null!;
public string? VendorName { get; set; } public string? VendorName { get; set; }
public int? MemberId { get; set; } public int? MemberId { get; set; }
public int? PayeeId { get; set; } // 1099 recipient attribution (header-level)
public string? CheckNumber { get; set; } public string? CheckNumber { get; set; }
public DateOnly ExpenseDate { get; set; } public DateOnly ExpenseDate { get; set; }
public string? ReceiptBlobPath { get; set; } public string? ReceiptBlobPath { get; set; }
@@ -25,5 +26,6 @@ public class Expense : SoftDeleteEntity, IAuditable
public Ministry? Ministry { get; set; } public Ministry? Ministry { get; set; }
public Member? Member { get; set; } public Member? Member { get; set; }
public Payee1099? Payee { get; set; }
public List<ExpenseLine> Lines { get; set; } = new(); public List<ExpenseLine> Lines { get; set; } = new();
} }
@@ -12,5 +12,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
public int? Form990LineId { get; set; } public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { 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; } = []; public List<ExpenseSubCategory> SubCategories { get; set; } = [];
} }
@@ -13,5 +13,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
public int? Form990LineId { get; set; } public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { 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; } 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") b.Property<int>("NextCheckNumber")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("PayerEin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Phone") b.Property<string>("Phone")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
@@ -598,6 +602,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("PayeeId")
.HasColumnType("integer");
b.Property<string>("ReceiptBlobPath") b.Property<string>("ReceiptBlobPath")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
@@ -652,6 +659,8 @@ namespace ROLAC.API.Migrations
b.HasIndex("MinistryId"); b.HasIndex("MinistryId");
b.HasIndex("PayeeId");
b.HasIndex("Status") b.HasIndex("Status")
.HasFilter("\"IsDeleted\" = false"); .HasFilter("\"IsDeleted\" = false");
@@ -674,6 +683,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("Form1099BoxId")
.HasColumnType("integer");
b.Property<int?>("Form990LineId") b.Property<int?>("Form990LineId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -702,6 +714,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Form1099BoxId");
b.HasIndex("Form990LineId"); b.HasIndex("Form990LineId");
b.ToTable("ExpenseCategoryGroups"); b.ToTable("ExpenseCategoryGroups");
@@ -900,6 +914,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("Form1099BoxId")
.HasColumnType("integer");
b.Property<int?>("Form990LineId") b.Property<int?>("Form990LineId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -931,6 +948,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Form1099BoxId");
b.HasIndex("Form990LineId"); b.HasIndex("Form990LineId");
b.HasIndex("GroupId"); b.HasIndex("GroupId");
@@ -976,6 +995,63 @@ namespace ROLAC.API.Migrations
b.ToTable("FamilyUnits"); 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 => modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1925,6 +2001,128 @@ namespace ROLAC.API.Migrations
b.ToTable("OfferingSessions"); 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 => modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2208,18 +2406,32 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("ROLAC.API.Entities.Payee1099", "Payee")
.WithMany()
.HasForeignKey("PayeeId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member"); b.Navigation("Member");
b.Navigation("Ministry"); b.Navigation("Ministry");
b.Navigation("Payee");
}); });
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => 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") b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany() .WithMany()
.HasForeignKey("Form990LineId") .HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.Navigation("Form1099Box");
b.Navigation("Form990Line"); b.Navigation("Form990Line");
}); });
@@ -2290,6 +2502,11 @@ namespace ROLAC.API.Migrations
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => 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") b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany() .WithMany()
.HasForeignKey("Form990LineId") .HasForeignKey("Form990LineId")
@@ -2301,6 +2518,8 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Form1099Box");
b.Navigation("Form990Line"); b.Navigation("Form990Line");
b.Navigation("Group"); b.Navigation("Group");
@@ -2380,6 +2599,16 @@ namespace ROLAC.API.Migrations
b.Navigation("MessagingGroup"); 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 => modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{ {
b.HasOne("ROLAC.API.Entities.AppUser", "User") 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.Middleware;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Logging; using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Security;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration; var config = builder.Configuration;
@@ -157,6 +158,11 @@ builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>(); builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>(); builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>(); 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<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<ISettingsService, SettingsService>(); builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>(); 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, 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, Website = p.Website, Address = p.Address, City = p.City, State = p.State,
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber, 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, AiProvider = p.AiProvider,
ClaudeModel = p.ClaudeModel, ClaudeModel = p.ClaudeModel,
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey), 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.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.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.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.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
p.ClaudeModel = r.ClaudeModel; p.ClaudeModel = r.ClaudeModel;
p.GeminiModel = r.GeminiModel; p.GeminiModel = r.GeminiModel;
@@ -25,25 +25,32 @@ public class ExpenseCategoryService : IExpenseCategoryService
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking() var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
.ToDictionaryAsync(l => l.Id, l => l.LineCode); .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 return groups.Select(g => new ExpenseCategoryGroupDto
{ {
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh, Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
SortOrder = g.SortOrder, IsActive = g.IsActive, SortOrder = g.SortOrder, IsActive = g.IsActive,
Form990LineId = g.Form990LineId, Form990LineId = g.Form990LineId,
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null, 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 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, Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
SortOrder = s.SortOrder, IsActive = s.IsActive, SortOrder = s.SortOrder, IsActive = s.IsActive,
Form990LineId = s.Form990LineId, Form990LineId = s.Form990LineId,
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null, 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(),
}).ToList(); }).ToList();
} }
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r) 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); _db.ExpenseCategoryGroups.Add(g);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return g.Id; return g.Id;
@@ -53,7 +60,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var g = await _db.ExpenseCategoryGroups.FindAsync(id) var g = await _db.ExpenseCategoryGroups.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); ?? 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(); await _db.SaveChangesAsync();
} }
@@ -69,7 +76,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId); var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found."); 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); _db.ExpenseSubCategories.Add(s);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return s.Id; return s.Id;
@@ -79,7 +86,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var s = await _db.ExpenseSubCategories.FindAsync(id) var s = await _db.ExpenseSubCategories.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); ?? 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(); await _db.SaveChangesAsync();
} }
+4 -1
View File
@@ -120,6 +120,7 @@ public class ExpenseService : IExpenseService
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null, ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
ReviewedAt = e.ReviewedAt, ReviewedAt = e.ReviewedAt,
ReviewNotes = e.ReviewNotes, ReviewNotes = e.ReviewNotes,
PayeeId = e.PayeeId,
}; };
}).ToList(); }).ToList();
@@ -211,6 +212,7 @@ public class ExpenseService : IExpenseService
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt, ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt, SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
PayeeId = e.PayeeId,
Lines = lineDtos, Lines = lineDtos,
}; };
} }
@@ -273,6 +275,7 @@ public class ExpenseService : IExpenseService
e.VendorName = null; e.VendorName = null;
} }
e.PayeeId = r.PayeeId;
_db.Expenses.Add(e); _db.Expenses.Add(e);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return e.Id; 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."); 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.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; if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
// Replace the line set wholesale (lines are owned by the header), recompute the total. // 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 { 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 { 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 { 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 { 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 { 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'; 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', 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', SystemLogs: 'SystemLogs',
AuditLogs: 'AuditLogs', AuditLogs: 'AuditLogs',
Settings: 'Settings', Settings: 'Settings',
Form1099: 'Form1099',
} as const; } as const;
/** A required permission, used in route data and the *appHasPermission directive. */ /** 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; id: number; name: string; nameZh: string | null; phone: string | null;
email: string | null; website: string | null; address: string | null; city: string | null; email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: 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; aiProvider: string;
claudeModel: string | null; claudeApiKeyMasked: string | null; claudeModel: string | null; claudeApiKeyMasked: string | null;
geminiModel: string | null; geminiApiKeyMasked: string | null; geminiModel: string | null; geminiApiKeyMasked: string | null;
@@ -55,6 +55,10 @@
Routing # / 路由號碼 Routing # / 路由號碼
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox> <kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
</label> </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"> <label class="flex flex-col gap-1">
Next Check # / 下一張支票號碼 Next Check # / 下一張支票號碼
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox> <kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
@@ -158,7 +158,7 @@
</div> </div>
<!-- Vendor mode: vendor name + check number --> <!-- Vendor mode: vendor name + check number + optional 1099 recipient -->
<ng-container *ngIf="mode === 'vendor'"> <ng-container *ngIf="mode === 'vendor'">
<label class="flex flex-col gap-1">Vendor Name <label class="flex flex-col gap-1">Vendor Name
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox> <kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
@@ -166,6 +166,12 @@
<label class="flex flex-col gap-1">Check # <label class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox> <kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
</label> </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> </ng-container>
<!-- Reimbursement mode: receipt file input --> <!-- Reimbursement mode: receipt file input -->
@@ -15,6 +15,8 @@ import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/e
import { ExpenseAiService } from '../../services/expense-ai.service'; import { ExpenseAiService } from '../../services/expense-ai.service';
import { MemberApiService } from '../../../members/services/member-api.service'; import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import { Payee1099ApiService } from '../../../payee1099/services/payee1099-api.service';
import { Payee1099ListItem } from '../../../payee1099/models/payee1099.model';
import { import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest, MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseDto, FunctionalClass, ExpenseAiSuggestion, ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
@@ -66,6 +68,8 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
ministries: MinistryDto[] = []; ministries: MinistryDto[] = [];
groups: ExpenseCategoryGroupDto[] = []; groups: ExpenseCategoryGroupDto[] = [];
payees: Payee1099ListItem[] = [];
/** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */ /** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */
snapshots: ExpenseSnapshotDto[] = []; snapshots: ExpenseSnapshotDto[] = [];
/** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */ /** 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: '', vendorName: '',
checkNumber: '', checkNumber: '',
memberId: null as number | null, memberId: null as number | null,
payeeId: null as number | null,
expenseDate: new Date(), expenseDate: new Date(),
}; };
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */ /** 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 expenseApi: ExpenseApiService,
private snapshotApi: ExpenseSnapshotApiService, private snapshotApi: ExpenseSnapshotApiService,
private aiApi: ExpenseAiService, private aiApi: ExpenseAiService,
private payeeApi: Payee1099ApiService,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m)); this.ministryApi.getAll().subscribe(m => (this.ministries = m));
this.payeeApi.getAll(false).subscribe(list => (this.payees = list));
if (this.showSnapshotTools) this.loadSnapshots(); if (this.showSnapshotTools) this.loadSnapshots();
this.catApi.getAll(false).subscribe(groups => { this.catApi.getAll(false).subscribe(groups => {
this.groups = groups; this.groups = groups;
@@ -169,6 +176,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
vendorName: expense.vendorName ?? '', vendorName: expense.vendorName ?? '',
checkNumber: expense.checkNumber ?? '', checkNumber: expense.checkNumber ?? '',
memberId: expense.memberId, memberId: expense.memberId,
payeeId: expense.payeeId ?? null,
expenseDate: new Date(year, month - 1, day), expenseDate: new Date(year, month - 1, day),
}; };
this.lines = (expense.lines ?? []).map(l => ({ 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, checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
expenseDate, expenseDate,
notes: null, notes: null,
payeeId: this.form.payeeId,
}; };
// The request and receipt are snapshotted here, so resetting the form right // The request and receipt are snapshotted here, so resetting the form right
// after emitting is safe even though the parent saves asynchronously. // 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 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 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; } 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; } 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 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 UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
export interface ExpenseLineItemDto { export interface ExpenseLineItemDto {
@@ -28,6 +28,7 @@ export interface ExpenseListItemDto {
expenseDate: string; hasReceipt: boolean; expenseDate: string; hasReceipt: boolean;
checkNumber: string | null; checkNumber: string | null;
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null; reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
payeeId: number | null;
} }
export interface ExpenseDto extends ExpenseListItemDto { export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null; notes: string | null;
@@ -70,6 +71,7 @@ export interface CreateExpenseRequest {
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[]; type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
description: string; vendorName: string | null; memberId: number | null; description: string; vendorName: string | null; memberId: number | null;
checkNumber: string | null; expenseDate: string; notes: string | null; checkNumber: string | null; expenseDate: string; notes: string | null;
payeeId: number | null;
} }
export type UpdateExpenseRequest = CreateExpenseRequest; export type UpdateExpenseRequest = CreateExpenseRequest;
export interface RejectExpenseRequest { reviewNotes: string | null; } export interface RejectExpenseRequest { reviewNotes: string | null; }
@@ -91,7 +91,7 @@
Sort order Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox> <kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label> </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> <span>Form 990 Line / 990 行</span>
<kendo-dropdownlist <kendo-dropdownlist
[data]="form990Lines" [data]="form990Lines"
@@ -100,6 +100,15 @@
[(ngModel)]="groupForm.form990LineId"> [(ngModel)]="groupForm.form990LineId">
</kendo-dropdownlist> </kendo-dropdownlist>
</label> </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"> <label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active <input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
</label> </label>
@@ -158,7 +167,7 @@
Sort order Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox> <kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label> </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> <span>Form 990 Line / 990 行</span>
<kendo-dropdownlist <kendo-dropdownlist
[data]="form990Lines" [data]="form990Lines"
@@ -167,6 +176,15 @@
[(ngModel)]="subForm.form990LineId"> [(ngModel)]="subForm.form990LineId">
</kendo-dropdownlist> </kendo-dropdownlist>
</label> </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"> <label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active <input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
</label> </label>
@@ -10,6 +10,7 @@ import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service'; import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model'; import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model'; import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
import { Form1099Box } from '../../../payee1099/models/payee1099.model';
@Component({ @Component({
selector: 'app-expense-categories-page', selector: 'app-expense-categories-page',
@@ -23,6 +24,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
selectedGroup: ExpenseCategoryGroupDto | null = null; selectedGroup: ExpenseCategoryGroupDto | null = null;
loading = false; loading = false;
form990Lines: Form990ExpenseLineDto[] = []; form990Lines: Form990ExpenseLineDto[] = [];
form1099Boxes: (Form1099Box & { label: string })[] = [];
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent; @ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
@ViewChild('subMenu') subMenu!: ContextMenuComponent; @ViewChild('subMenu') subMenu!: ContextMenuComponent;
@@ -33,13 +35,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groupDialogOpen = false; groupDialogOpen = false;
editingGroupId: number | null = null; 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; groupAiLoading = false;
groupAiSuggestion: CategoryAiSuggestion | null = null; groupAiSuggestion: CategoryAiSuggestion | null = null;
subDialogOpen = false; subDialogOpen = false;
editingSubId: number | null = null; 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; subAiLoading = false;
subAiSuggestion: CategoryAiSuggestion | null = null; subAiSuggestion: CategoryAiSuggestion | null = null;
@@ -48,6 +50,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.load(); this.load();
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; }); this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
this.api.getForm1099Boxes().subscribe(boxes => { this.form1099Boxes = boxes; });
} }
load(): void { load(): void {
@@ -111,13 +114,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewGroup(): void { openNewGroup(): void {
this.editingGroupId = null; 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.resetGroupAi();
this.groupDialogOpen = true; this.groupDialogOpen = true;
} }
openEditGroup(g: ExpenseCategoryGroupDto): void { openEditGroup(g: ExpenseCategoryGroupDto): void {
this.editingGroupId = g.id; 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.resetGroupAi();
this.groupDialogOpen = true; this.groupDialogOpen = true;
} }
@@ -143,7 +146,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
} }
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; } dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
saveGroup(): void { 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(); }; const done = () => { this.groupDialogOpen = false; this.load(); };
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done); if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).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 { openNewSub(): void {
if (!this.selectedGroup) return; if (!this.selectedGroup) return;
this.editingSubId = null; 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.resetSubAi();
this.subDialogOpen = true; this.subDialogOpen = true;
} }
openEditSub(s: ExpenseSubCategoryDto): void { openEditSub(s: ExpenseSubCategoryDto): void {
this.editingSubId = s.id; 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.resetSubAi();
this.subDialogOpen = true; this.subDialogOpen = true;
} }
@@ -195,7 +198,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; } dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
saveSub(): void { saveSub(): void {
if (!this.selectedGroup) return; 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(); }; const done = () => { this.subDialogOpen = false; this.load(); };
if (this.editingSubId == null) this.api.createSub(body).subscribe(done); if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done); else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
@@ -9,6 +9,7 @@ import {
ExpenseCategoryAiRequest, CategoryAiSuggestion, ExpenseCategoryAiRequest, CategoryAiSuggestion,
} from '../models/expense.model'; } from '../models/expense.model';
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model'; import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
import { Form1099Box } from '../../payee1099/models/payee1099.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ExpenseCategoryApiService { export class ExpenseCategoryApiService {
@@ -38,4 +39,9 @@ export class ExpenseCategoryApiService {
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines') 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 : ''}` })))); .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' } }, permission: { module: PermissionModules.Disbursements, action: 'read' } },
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register', { text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
permission: { module: PermissionModules.Disbursements, action: 'read' } }, 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) 6. [Phase 1 — CMS](#6-cms)
7. [Phase 1 — Giving & Donations(奉獻)](#7-giving--donations-奉獻) 7. [Phase 1 — Giving & Donations(奉獻)](#7-giving--donations-奉獻)
8. [Phase 1 — Expense Tracking(支出)](#8-expense-tracking-支出) 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-代禱) 9. [Phase 1 — Prayer Requests(代禱)](#9-prayer-requests-代禱)
10. [Phase 1 — Audit Log](#10-audit-log) 10. [Phase 1 — Audit Log](#10-audit-log)
11. [Phase 1 — Notifications](#11-notifications) 11. [Phase 1 — Notifications](#11-notifications)
@@ -704,6 +707,91 @@ Table: MonthlyStatements
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id | | UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| **UNIQUE** | (Year, Month) | 每個月只有一份月結報表 | | **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(代禱) ## 9. Prayer Requests(代禱)
@@ -1033,6 +1121,14 @@ super_admin, pastor, board_member, coworker_chair, ministry_leader, district_lea
Form990Report — 唯讀報表權限,授予角色:finance、pastor、board_member Form990Report — 唯讀報表權限,授予角色:finance、pastor、board_member
``` ```
### Form1099 權限模組
```
Form1099 — 1099 收款人管理與申報,授予角色:
finance — Read / Write / Delete(完整管理)
pastor — Read(唯讀總覽)
board_member — Read(唯讀總覽)
```
### CmsPages(靜態頁面 Slug ### CmsPages(靜態頁面 Slug
``` ```
about, vision, service-times, contact about, vision, service-times, contact