Compare commits
35 Commits
55543af5e1
...
d987ddea0e
| Author | SHA1 | Date | |
|---|---|---|---|
| d987ddea0e | |||
| a4ded78442 | |||
| 831b868d9d | |||
| 771889a99a | |||
| 4d396601f7 | |||
| d29de83116 | |||
| ad276c01f3 | |||
| fb95bf0048 | |||
| d8e6f3ed61 | |||
| 402826ee3d | |||
| 82096e7e6f | |||
| 6ffaaf37ac | |||
| d1747b510e | |||
| bf247726e1 | |||
| 8cb6245560 | |||
| b7eb95056d | |||
| 556abba687 | |||
| 1a8002015a | |||
| 7c63f6c9ba | |||
| 7c5348969b | |||
| 0a9b82544d | |||
| 6080946e74 | |||
| 560fb79bf0 | |||
| 0767a3fe94 | |||
| 0754ed8d69 | |||
| 9aa64b5f4c | |||
| 5e2fbe800c | |||
| 89238bba99 | |||
| 225e64b992 | |||
| 7809ba9741 | |||
| 48ae014def | |||
| 89f02d020b | |||
| 3b76ff43fc | |||
| a0b96b056a | |||
| 93374c3c0a |
@@ -22,6 +22,7 @@
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ROLAC.API.DTOs.Finance;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class Form1099FormServiceTests
|
||||
{
|
||||
/// <summary>Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export.</summary>
|
||||
private sealed class StubReportService : IForm1099ReportService
|
||||
{
|
||||
private readonly Form1099SummaryDto _summary;
|
||||
public StubReportService(Form1099SummaryDto summary) => _summary = summary;
|
||||
|
||||
public Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary);
|
||||
public Task<List<Form1099BoxDto>> GetBoxesAsync() => throw new NotImplementedException();
|
||||
public Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static Form1099FormService BuildService(Form1099SummaryDto summary) =>
|
||||
// IPayee1099Service and AppDbContext are only used by RenderCopyBAsync, not by the CSV path.
|
||||
new Form1099FormService(new StubReportService(summary), payees: null!, db: null!);
|
||||
|
||||
[Fact]
|
||||
public async Task ExportFilingCsvAsync_WritesHeaderRowPerRecipientAndInvariantNumbers()
|
||||
{
|
||||
var summary = new Form1099SummaryDto
|
||||
{
|
||||
TaxYear = 2026,
|
||||
Rows =
|
||||
{
|
||||
new Form1099RecipientRowDto
|
||||
{
|
||||
PayeeId = 1, LegalName = "Acme, LLC", TinLast4 = "1234", W9Status = "OnFile",
|
||||
NecTotal = 1234.50m, RentsTotal = 0m, GrandTotal = 1234.50m, MeetsThreshold = true
|
||||
},
|
||||
new Form1099RecipientRowDto
|
||||
{
|
||||
PayeeId = 2, LegalName = "Bob Smith", TinLast4 = "9876", W9Status = "Missing",
|
||||
NecTotal = 100m, RentsTotal = 50m, GrandTotal = 150m, MeetsThreshold = false
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
var service = BuildService(summary);
|
||||
var (stream, contentType, fileName) = await service.ExportFilingCsvAsync(2026);
|
||||
|
||||
Assert.Equal("text/csv", contentType);
|
||||
Assert.Equal("1099-filing-2026.csv", fileName);
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
var text = await reader.ReadToEndAsync();
|
||||
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Header + one data line per row.
|
||||
Assert.Equal(3, lines.Length);
|
||||
Assert.Equal("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold", lines[0]);
|
||||
|
||||
// A value containing a comma is quoted.
|
||||
Assert.StartsWith("\"Acme, LLC\",1234,OnFile,", lines[1]);
|
||||
|
||||
// Invariant numeric formatting (period decimal separator) and Y/N threshold flag.
|
||||
Assert.Contains("1234.50", lines[1]);
|
||||
Assert.EndsWith(",Y", lines[1]);
|
||||
Assert.EndsWith(",N", lines[2]);
|
||||
|
||||
// Sanity: the period really is the invariant separator regardless of current culture.
|
||||
Assert.Equal("1234.50", 1234.50m.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using System.Security.Claims;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class Form1099ReportServiceTests
|
||||
{
|
||||
private static AppDbContext NewDb()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
|
||||
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options);
|
||||
}
|
||||
|
||||
private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId)
|
||||
{
|
||||
var db = NewDb();
|
||||
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" });
|
||||
var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 };
|
||||
var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 };
|
||||
db.Form1099Boxes.AddRange(nec, rent);
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null });
|
||||
db.SaveChanges();
|
||||
necSubId = 1; rentSubId = 2; salarySubId = 3;
|
||||
return db;
|
||||
}
|
||||
|
||||
private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn)
|
||||
{
|
||||
var e = new Expense
|
||||
{
|
||||
MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId,
|
||||
Amount = amount, Description = "x", ExpenseDate = paidOn,
|
||||
PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
|
||||
Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ],
|
||||
};
|
||||
db.Expenses.Add(e);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9()
|
||||
{
|
||||
var db = Seeded(out var necSub, out var rentSub, out _);
|
||||
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" });
|
||||
db.SaveChanges();
|
||||
AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1));
|
||||
AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1));
|
||||
|
||||
var svc = new Form1099ReportService(db);
|
||||
var sum = await svc.GetAnnualSummaryAsync(2026);
|
||||
|
||||
var row = Assert.Single(sum.Rows);
|
||||
Assert.Equal(700m, row.NecTotal);
|
||||
Assert.Equal(500m, row.RentsTotal);
|
||||
Assert.Equal(1200m, row.GrandTotal);
|
||||
Assert.True(row.MeetsThreshold);
|
||||
Assert.True(row.W9Missing);
|
||||
Assert.Equal(1, sum.RecipientsAtThreshold);
|
||||
Assert.Equal(1, sum.RecipientsMissingW9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year()
|
||||
{
|
||||
var db = Seeded(out var necSub, out _, out var salarySub);
|
||||
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" });
|
||||
db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" });
|
||||
db.SaveChanges();
|
||||
AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked
|
||||
AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box
|
||||
AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year
|
||||
|
||||
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
|
||||
Assert.Empty(sum.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Threshold_flag_is_false_below_600()
|
||||
{
|
||||
var db = Seeded(out var necSub, out _, out _);
|
||||
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" });
|
||||
db.SaveChanges();
|
||||
AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1));
|
||||
|
||||
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
|
||||
var row = Assert.Single(sum.Rows);
|
||||
Assert.False(row.MeetsThreshold);
|
||||
Assert.False(row.W9Missing);
|
||||
Assert.Equal(0, sum.RecipientsAtThreshold);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using System.Security.Claims;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Payee;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Security;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class Payee1099ServiceTests
|
||||
{
|
||||
// Minimal in-memory IFileStorage (mirrors the ExpenseServiceTests fake).
|
||||
private sealed class FakeStorage : IFileStorage
|
||||
{
|
||||
public Dictionary<string, byte[]> Files = new();
|
||||
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
|
||||
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
|
||||
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
||||
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
|
||||
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private static (Payee1099Service svc, AppDbContext db) Build()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).Options);
|
||||
var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
||||
return (new Payee1099Service(db, tin, new FakeStorage()), db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
|
||||
{
|
||||
var (svc, db) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request
|
||||
{ LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });
|
||||
|
||||
var saved = await db.Payee1099s.FindAsync(id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("6789", saved!.TinLast4);
|
||||
Assert.NotNull(saved.TinEncrypted);
|
||||
Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
|
||||
Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_with_null_tin_keeps_existing_ciphertext()
|
||||
{
|
||||
var (svc, db) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
|
||||
var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;
|
||||
|
||||
await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });
|
||||
|
||||
var after = await db.Payee1099s.FindAsync(id);
|
||||
Assert.Equal("X renamed", after!.LegalName);
|
||||
Assert.Equal(before, after.TinEncrypted);
|
||||
Assert.Equal("3333", after.TinLast4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_dto_masks_tin_to_last4()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
|
||||
var list = await svc.GetAllAsync(includeInactive: true);
|
||||
var item = Assert.Single(list);
|
||||
Assert.Equal("7777", item.TinLast4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_is_soft_and_hides_from_list()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
|
||||
await svc.DeleteAsync(id);
|
||||
Assert.Empty(await svc.GetAllAsync(includeInactive: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveW9_records_document_and_round_trips_bytes()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "W9 Payee" });
|
||||
|
||||
var bytes = new byte[] { 1, 2, 3, 4, 5 };
|
||||
await svc.SaveW9Async(id, new MemoryStream(bytes), "w9.pdf");
|
||||
|
||||
var dto = await svc.GetByIdAsync(id);
|
||||
Assert.NotNull(dto);
|
||||
Assert.True(dto!.HasW9Document);
|
||||
|
||||
var opened = await svc.OpenW9Async(id);
|
||||
Assert.NotNull(opened);
|
||||
Assert.Equal("application/pdf", opened!.Value.contentType);
|
||||
using var ms = new MemoryStream();
|
||||
await opened.Value.stream.CopyToAsync(ms);
|
||||
Assert.Equal(bytes, ms.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using ROLAC.API.Services.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class TinProtectorTests
|
||||
{
|
||||
private static TinProtector Build() =>
|
||||
new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
||||
|
||||
[Fact]
|
||||
public void Protect_then_Unprotect_round_trips()
|
||||
{
|
||||
var p = Build();
|
||||
var cipher = p.Protect("123-45-6789");
|
||||
Assert.NotEqual("123-45-6789", cipher);
|
||||
Assert.Equal("123-45-6789", p.Unprotect(cipher));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("123-45-6789", "6789")]
|
||||
[InlineData("12-3456789", "6789")]
|
||||
[InlineData("7", "7")]
|
||||
public void Last4_keeps_only_trailing_digits(string raw, string expected)
|
||||
=> Assert.Equal(expected, TinProtector.Last4(raw));
|
||||
|
||||
[Fact]
|
||||
public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null));
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public static class Modules
|
||||
public const string Ministries = "Ministries";
|
||||
public const string FinanceDashboard = "FinanceDashboard";
|
||||
public const string Form990Report = "Form990Report";
|
||||
public const string Form1099 = "Form1099";
|
||||
public const string MonthlyStatements = "MonthlyStatements";
|
||||
public const string ChurchProfile = "ChurchProfile";
|
||||
public const string Disbursements = "Disbursements";
|
||||
@@ -39,6 +40,7 @@ public static class Modules
|
||||
Ministries,
|
||||
FinanceDashboard,
|
||||
Form990Report,
|
||||
Form1099,
|
||||
MonthlyStatements,
|
||||
ChurchProfile,
|
||||
Disbursements,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/form1099-report")]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Read)]
|
||||
public class Form1099ReportController : ControllerBase
|
||||
{
|
||||
private readonly IForm1099ReportService _svc;
|
||||
private readonly I1099FormService _form;
|
||||
public Form1099ReportController(IForm1099ReportService svc, I1099FormService form)
|
||||
{
|
||||
_svc = svc;
|
||||
_form = form;
|
||||
}
|
||||
|
||||
[HttpGet("boxes")]
|
||||
public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> Summary([FromQuery] int taxYear)
|
||||
=> Ok(await _svc.GetAnnualSummaryAsync(taxYear));
|
||||
|
||||
[HttpGet("recipient/{payeeId:int}")]
|
||||
public async Task<IActionResult> Recipient(int payeeId, [FromQuery] int taxYear)
|
||||
=> await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound();
|
||||
|
||||
[HttpGet("recipient/{payeeId:int}/copy-b")]
|
||||
public async Task<IActionResult> CopyB(int payeeId, [FromQuery] int taxYear)
|
||||
{
|
||||
var (stream, contentType, fileName) = await _form.RenderCopyBAsync(payeeId, taxYear);
|
||||
return File(stream, contentType, fileName);
|
||||
}
|
||||
|
||||
[HttpGet("export-csv")]
|
||||
public async Task<IActionResult> ExportCsv([FromQuery] int taxYear)
|
||||
{
|
||||
var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear);
|
||||
return File(stream, contentType, fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Payee;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/payee-1099")]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Read)]
|
||||
public class Payee1099Controller : ControllerBase
|
||||
{
|
||||
private readonly IPayee1099Service _svc;
|
||||
public Payee1099Controller(IPayee1099Service svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
=> await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
|
||||
=> Ok(new { id = await _svc.CreateAsync(r) });
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
|
||||
{ await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{ await _svc.DeleteAsync(id); return NoContent(); }
|
||||
|
||||
// Full TIN reveal is gated on Write (a stronger right than Read).
|
||||
[HttpGet("{id:int}/tin")]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||
public async Task<IActionResult> RevealTin(int id)
|
||||
=> Ok(new { tin = await _svc.RevealTinAsync(id) });
|
||||
|
||||
// Mirrors the expense-receipt upload: multipart form file, size-limited, type-checked.
|
||||
[HttpPost("{id:int}/w9")]
|
||||
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||
[RequestSizeLimit(10_485_760)]
|
||||
public async Task<IActionResult> UploadW9(int id, IFormFile file)
|
||||
{
|
||||
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
|
||||
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _svc.SaveW9Async(id, stream, file.FileName);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
// Class-level Read gate covers viewing the stored W-9 (mirrors the receipt GET).
|
||||
[HttpGet("{id:int}/w9")]
|
||||
public async Task<IActionResult> GetW9(int id)
|
||||
{
|
||||
var result = await _svc.OpenW9Async(id);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class ChurchProfileDto
|
||||
public string? BankName { get; set; }
|
||||
public string? BankAccountNumber { get; set; }
|
||||
public string? BankRoutingNumber { get; set; }
|
||||
public string? PayerEin { get; set; }
|
||||
public int NextCheckNumber { get; set; }
|
||||
public string AiProvider { get; set; } = "Claude";
|
||||
public string? ClaudeModel { get; set; }
|
||||
@@ -38,6 +39,7 @@ public class UpdateChurchProfileRequest
|
||||
[MaxLength(200)] public string? BankName { get; set; }
|
||||
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
||||
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
||||
[MaxLength(20)] public string? PayerEin { get; set; }
|
||||
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
||||
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
|
||||
[MaxLength(100)] public string? ClaudeModel { get; set; }
|
||||
|
||||
@@ -11,6 +11,8 @@ public class ExpenseSubCategoryDto
|
||||
public bool IsActive { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public string? Form990LineCode { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
public string? Form1099BoxCode { get; set; }
|
||||
}
|
||||
|
||||
public class ExpenseCategoryGroupDto
|
||||
@@ -22,6 +24,8 @@ public class ExpenseCategoryGroupDto
|
||||
public bool IsActive { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public string? Form990LineCode { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
public string? Form1099BoxCode { get; set; }
|
||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -31,6 +35,7 @@ public class CreateExpenseGroupRequest
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
}
|
||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||
{
|
||||
@@ -44,6 +49,7 @@ public class CreateExpenseSubCategoryRequest
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
}
|
||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ public class ExpenseListItemDto
|
||||
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
public string? ReviewNotes { get; set; } // reject reason (or approval note)
|
||||
public int? PayeeId { get; set; }
|
||||
}
|
||||
|
||||
public class ExpenseDto : ExpenseListItemDto
|
||||
@@ -66,6 +67,7 @@ public class CreateExpenseRequest
|
||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||
[Required] public DateOnly ExpenseDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? PayeeId { get; set; }
|
||||
}
|
||||
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace ROLAC.API.DTOs.Finance;
|
||||
|
||||
public class Form1099BoxDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string BoxCode { get; set; } = "";
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public string FormType { get; set; } = "";
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public class Form1099RecipientRowDto
|
||||
{
|
||||
public int PayeeId { get; set; }
|
||||
public string LegalName { get; set; } = "";
|
||||
public string? TinLast4 { get; set; }
|
||||
public string W9Status { get; set; } = "";
|
||||
public decimal NecTotal { get; set; }
|
||||
public decimal RentsTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
public bool MeetsThreshold { get; set; }
|
||||
public bool W9Missing { get; set; }
|
||||
}
|
||||
|
||||
public class Form1099SummaryDto
|
||||
{
|
||||
public int TaxYear { get; set; }
|
||||
public List<Form1099RecipientRowDto> Rows { get; set; } = [];
|
||||
public decimal TotalReportable { get; set; }
|
||||
public int RecipientsAtThreshold { get; set; }
|
||||
public int RecipientsMissingW9 { get; set; }
|
||||
}
|
||||
|
||||
public class Form1099PaymentDto
|
||||
{
|
||||
public string PaidDate { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string CategoryName { get; set; } = "";
|
||||
public string BoxCode { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class Form1099RecipientDetailDto
|
||||
{
|
||||
public int PayeeId { get; set; }
|
||||
public string LegalName { get; set; } = "";
|
||||
public string? TinLast4 { get; set; }
|
||||
public string W9Status { get; set; } = "";
|
||||
public int TaxYear { get; set; }
|
||||
public List<Form1099PaymentDto> Payments { get; set; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -21,6 +21,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||||
public DbSet<Payee1099> Payee1099s => Set<Payee1099>();
|
||||
public DbSet<Form1099Box> Form1099Boxes => Set<Form1099Box>();
|
||||
public DbSet<Expense> Expenses => Set<Expense>();
|
||||
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
|
||||
@@ -218,6 +220,45 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.HasIndex(e => e.LineCode).IsUnique();
|
||||
});
|
||||
|
||||
// ── Form1099Box (1099 reporting box catalog) ──────────────────────────
|
||||
builder.Entity<Form1099Box>(entity =>
|
||||
{
|
||||
entity.Property(e => e.BoxCode).HasMaxLength(10).IsRequired();
|
||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
entity.Property(e => e.FormType).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasIndex(e => e.BoxCode).IsUnique();
|
||||
});
|
||||
|
||||
// ── Payee1099 (1099 recipient master) ────────────────────────────────
|
||||
builder.Entity<Payee1099>(entity =>
|
||||
{
|
||||
entity.HasQueryFilter(p => !p.IsDeleted);
|
||||
entity.Property(e => e.LegalName).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.DisplayName).HasMaxLength(200);
|
||||
entity.Property(e => e.TaxClassification).HasMaxLength(40).IsRequired();
|
||||
entity.Property(e => e.TinType).HasMaxLength(10);
|
||||
entity.Property(e => e.TinLast4).HasMaxLength(4);
|
||||
entity.Property(e => e.State).HasMaxLength(2);
|
||||
entity.Property(e => e.Zip).HasMaxLength(10);
|
||||
entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing);
|
||||
entity.Property(e => e.AddressLine1).HasMaxLength(200);
|
||||
entity.Property(e => e.AddressLine2).HasMaxLength(200);
|
||||
entity.Property(e => e.City).HasMaxLength(100);
|
||||
entity.Property(e => e.Email).HasMaxLength(200);
|
||||
entity.Property(e => e.Phone).HasMaxLength(30);
|
||||
entity.Property(e => e.Notes).HasMaxLength(500);
|
||||
entity.Property(e => e.W9BlobPath).HasMaxLength(500);
|
||||
entity.Property(e => e.TinEncrypted).HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
||||
builder.Entity<ExpenseCategoryGroup>(entity =>
|
||||
{
|
||||
@@ -227,6 +268,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasOne(e => e.Form990Line).WithMany()
|
||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||
@@ -240,6 +283,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.Form990Line).WithMany()
|
||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── Expense ──────────────────────────────────────────────────────────
|
||||
@@ -270,6 +315,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.Payee).WithMany()
|
||||
.HasForeignKey(e => e.PayeeId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
|
||||
@@ -346,6 +393,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||
entity.Property(e => e.PayerEin).HasMaxLength(20);
|
||||
entity.Property(e => e.NameZh).HasMaxLength(200);
|
||||
entity.Property(e => e.Phone).HasMaxLength(50);
|
||||
entity.Property(e => e.Email).HasMaxLength(200);
|
||||
|
||||
@@ -137,6 +137,23 @@ public static class DbSeeder
|
||||
("Other", "Gifts", "24"),
|
||||
];
|
||||
|
||||
private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed =
|
||||
[
|
||||
(Form1099.BoxNec1, "Nonemployee compensation", "非員工報酬", "1099-NEC", 1),
|
||||
(Form1099.BoxMisc1, "Rents", "租金", "1099-MISC", 2),
|
||||
];
|
||||
|
||||
// Only service/rent subcategories get a box. Everything else stays unmapped (not reportable).
|
||||
private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed =
|
||||
[
|
||||
("Personnel", "Honorarium", Form1099.BoxNec1),
|
||||
("Personnel", "Contract Labor", Form1099.BoxNec1),
|
||||
("Professional Services", "Legal", Form1099.BoxNec1),
|
||||
("Professional Services", "Accounting & Audit", Form1099.BoxNec1),
|
||||
("Professional Services", "Other Professional", Form1099.BoxNec1),
|
||||
("Facility", "Rent", Form1099.BoxMisc1),
|
||||
];
|
||||
|
||||
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
|
||||
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
|
||||
// value — this block does. Idempotent: each row fires only while the subcategory still holds
|
||||
@@ -190,6 +207,11 @@ public static class DbSeeder
|
||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||
("finance", Modules.Disbursements, true, true, true, true),
|
||||
("finance", Modules.Form990Report, true, false, false, false),
|
||||
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
|
||||
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
|
||||
("finance", Modules.Form1099, true, true, true, false),
|
||||
("pastor", Modules.Form1099, true, false, false, false),
|
||||
("board_member", Modules.Form1099, true, false, false, false),
|
||||
|
||||
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
||||
// governance value, so finance and board members can read them too.
|
||||
@@ -375,6 +397,25 @@ public static class DbSeeder
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedForm1099BoxesAsync(AppDbContext db)
|
||||
{
|
||||
foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed)
|
||||
if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code))
|
||||
db.Form1099Boxes.Add(new Form1099Box
|
||||
{ BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id);
|
||||
var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
|
||||
foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed)
|
||||
{
|
||||
var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||
if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId))
|
||||
sub.Form1099BoxId = boxId;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
||||
{
|
||||
// Singleton row used by the disbursement module (issuer info + check counter).
|
||||
@@ -454,6 +495,7 @@ public static class DbSeeder
|
||||
await SeedMinistriesAsync(db);
|
||||
await SeedExpenseCategoriesAsync(db);
|
||||
await SeedForm990ExpenseLinesAsync(db);
|
||||
await SeedForm1099BoxesAsync(db);
|
||||
await SeedChurchProfileAsync(db);
|
||||
await SeedSiteSettingAsync(db);
|
||||
await SeedNotificationSettingAsync(db, config);
|
||||
|
||||
@@ -21,6 +21,9 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
||||
public string? BankAccountNumber { get; set; }
|
||||
public string? BankRoutingNumber { get; set; }
|
||||
|
||||
/// <summary>Payer EIN printed on Form 1099-NEC Copy B; the church's own public business identifier.</summary>
|
||||
public string? PayerEin { get; set; }
|
||||
|
||||
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
|
||||
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
|
||||
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
|
||||
|
||||
@@ -11,6 +11,7 @@ public class Expense : SoftDeleteEntity, IAuditable
|
||||
public string Description { get; set; } = null!;
|
||||
public string? VendorName { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
public int? PayeeId { get; set; } // 1099 recipient attribution (header-level)
|
||||
public string? CheckNumber { get; set; }
|
||||
public DateOnly ExpenseDate { get; set; }
|
||||
public string? ReceiptBlobPath { get; set; }
|
||||
@@ -25,5 +26,6 @@ public class Expense : SoftDeleteEntity, IAuditable
|
||||
|
||||
public Ministry? Ministry { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public Payee1099? Payee { get; set; }
|
||||
public List<ExpenseLine> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
|
||||
public int? Form990LineId { get; set; }
|
||||
public Form990ExpenseLine? Form990Line { get; set; }
|
||||
|
||||
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
|
||||
public Form1099Box? Form1099Box { get; set; }
|
||||
|
||||
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
|
||||
public int? Form990LineId { get; set; }
|
||||
public Form990ExpenseLine? Form990Line { get; set; }
|
||||
|
||||
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
|
||||
public Form1099Box? Form1099Box { get; set; }
|
||||
|
||||
public ExpenseCategoryGroup? Group { get; set; }
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+2676
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2680
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ROLAC.API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPayerEinToChurchProfile : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayerEin",
|
||||
table: "ChurchProfiles",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayerEin",
|
||||
table: "ChurchProfiles");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,6 +506,10 @@ namespace ROLAC.API.Migrations
|
||||
b.Property<int>("NextCheckNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PayerEin")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
@@ -598,6 +602,9 @@ namespace ROLAC.API.Migrations
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<int?>("PayeeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ReceiptBlobPath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
@@ -652,6 +659,8 @@ namespace ROLAC.API.Migrations
|
||||
|
||||
b.HasIndex("MinistryId");
|
||||
|
||||
b.HasIndex("PayeeId");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasFilter("\"IsDeleted\" = false");
|
||||
|
||||
@@ -674,6 +683,9 @@ namespace ROLAC.API.Migrations
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<int?>("Form1099BoxId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("Form990LineId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -702,6 +714,8 @@ namespace ROLAC.API.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Form1099BoxId");
|
||||
|
||||
b.HasIndex("Form990LineId");
|
||||
|
||||
b.ToTable("ExpenseCategoryGroups");
|
||||
@@ -900,6 +914,9 @@ namespace ROLAC.API.Migrations
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<int?>("Form1099BoxId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("Form990LineId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -931,6 +948,8 @@ namespace ROLAC.API.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Form1099BoxId");
|
||||
|
||||
b.HasIndex("Form990LineId");
|
||||
|
||||
b.HasIndex("GroupId");
|
||||
@@ -976,6 +995,63 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("FamilyUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Form1099Box", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("BoxCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("FormType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name_en")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Name_zh")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BoxCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Form1099Boxes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1925,6 +2001,128 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("OfferingSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AddressLine1")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("AddressLine2")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("Is1099Tracked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LegalName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("MemberId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("TaxClassification")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)");
|
||||
|
||||
b.Property<string>("TinEncrypted")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("TinLast4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("character varying(4)");
|
||||
|
||||
b.Property<string>("TinType")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("W9BlobPath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateOnly?>("W9ReceivedDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("W9Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Missing");
|
||||
|
||||
b.Property<string>("Zip")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MemberId");
|
||||
|
||||
b.ToTable("Payee1099s");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2208,18 +2406,32 @@ namespace ROLAC.API.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ROLAC.API.Entities.Payee1099", "Payee")
|
||||
.WithMany()
|
||||
.HasForeignKey("PayeeId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Member");
|
||||
|
||||
b.Navigation("Ministry");
|
||||
|
||||
b.Navigation("Payee");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
|
||||
.WithMany()
|
||||
.HasForeignKey("Form1099BoxId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||
.WithMany()
|
||||
.HasForeignKey("Form990LineId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Form1099Box");
|
||||
|
||||
b.Navigation("Form990Line");
|
||||
});
|
||||
|
||||
@@ -2290,6 +2502,11 @@ namespace ROLAC.API.Migrations
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
|
||||
.WithMany()
|
||||
.HasForeignKey("Form1099BoxId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||
.WithMany()
|
||||
.HasForeignKey("Form990LineId")
|
||||
@@ -2301,6 +2518,8 @@ namespace ROLAC.API.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Form1099Box");
|
||||
|
||||
b.Navigation("Form990Line");
|
||||
|
||||
b.Navigation("Group");
|
||||
@@ -2380,6 +2599,16 @@ namespace ROLAC.API.Migrations
|
||||
b.Navigation("MessagingGroup");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||
.WithMany()
|
||||
.HasForeignKey("MemberId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Member");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||
|
||||
@@ -15,6 +15,7 @@ using ROLAC.API.Json;
|
||||
using ROLAC.API.Middleware;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var config = builder.Configuration;
|
||||
@@ -157,6 +158,11 @@ builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
|
||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
|
||||
builder.Services.AddScoped<IForm1099ReportService, Form1099ReportService>();
|
||||
builder.Services.AddScoped<IPayee1099Service, Payee1099Service>();
|
||||
builder.Services.AddScoped<I1099FormService, Form1099FormService>();
|
||||
builder.Services.AddDataProtection();
|
||||
builder.Services.AddScoped<ITinProtector, TinProtector>();
|
||||
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
||||
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||
|
||||
@@ -18,7 +18,7 @@ public class ChurchProfileService : IChurchProfileService
|
||||
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
||||
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
||||
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
||||
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
||||
BankRoutingNumber = p.BankRoutingNumber, PayerEin = p.PayerEin, NextCheckNumber = p.NextCheckNumber,
|
||||
AiProvider = p.AiProvider,
|
||||
ClaudeModel = p.ClaudeModel,
|
||||
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
|
||||
@@ -33,7 +33,7 @@ public class ChurchProfileService : IChurchProfileService
|
||||
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
||||
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
||||
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
||||
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
||||
p.BankRoutingNumber = r.BankRoutingNumber; p.PayerEin = r.PayerEin; p.NextCheckNumber = r.NextCheckNumber;
|
||||
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
|
||||
p.ClaudeModel = r.ClaudeModel;
|
||||
p.GeminiModel = r.GeminiModel;
|
||||
|
||||
@@ -25,25 +25,32 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
||||
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
|
||||
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
|
||||
|
||||
var boxCodes = await _db.Form1099Boxes.AsNoTracking()
|
||||
.ToDictionaryAsync(b => b.Id, b => b.BoxCode);
|
||||
|
||||
return groups.Select(g => new ExpenseCategoryGroupDto
|
||||
{
|
||||
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
||||
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
||||
Form990LineId = g.Form990LineId,
|
||||
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
|
||||
Form1099BoxId = g.Form1099BoxId,
|
||||
Form1099BoxCode = g.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(g.Form1099BoxId.Value) : null,
|
||||
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
|
||||
{
|
||||
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
||||
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
||||
Form990LineId = s.Form990LineId,
|
||||
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
|
||||
Form1099BoxId = s.Form1099BoxId,
|
||||
Form1099BoxCode = s.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(s.Form1099BoxId.Value) : null,
|
||||
}).ToList(),
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
||||
{
|
||||
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
||||
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
|
||||
_db.ExpenseCategoryGroups.Add(g);
|
||||
await _db.SaveChangesAsync();
|
||||
return g.Id;
|
||||
@@ -53,7 +60,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
||||
{
|
||||
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
||||
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId;
|
||||
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId; g.Form1099BoxId = r.Form1099BoxId;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -69,7 +76,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
||||
{
|
||||
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
||||
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
||||
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
||||
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
|
||||
_db.ExpenseSubCategories.Add(s);
|
||||
await _db.SaveChangesAsync();
|
||||
return s.Id;
|
||||
@@ -79,7 +86,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
||||
{
|
||||
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
||||
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId;
|
||||
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId; s.Form1099BoxId = r.Form1099BoxId;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ public class ExpenseService : IExpenseService
|
||||
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
|
||||
ReviewedAt = e.ReviewedAt,
|
||||
ReviewNotes = e.ReviewNotes,
|
||||
PayeeId = e.PayeeId,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -211,6 +212,7 @@ public class ExpenseService : IExpenseService
|
||||
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
||||
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
|
||||
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
|
||||
PayeeId = e.PayeeId,
|
||||
Lines = lineDtos,
|
||||
};
|
||||
}
|
||||
@@ -273,6 +275,7 @@ public class ExpenseService : IExpenseService
|
||||
e.VendorName = null;
|
||||
}
|
||||
|
||||
e.PayeeId = r.PayeeId;
|
||||
_db.Expenses.Add(e);
|
||||
await _db.SaveChangesAsync();
|
||||
return e.Id;
|
||||
@@ -294,7 +297,7 @@ public class ExpenseService : IExpenseService
|
||||
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
|
||||
|
||||
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
|
||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.PayeeId = r.PayeeId;
|
||||
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
|
||||
|
||||
// Replace the line set wholesale (lines are owned by the header), recompute the total.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using DevExpress.Office;
|
||||
using DevExpress.XtraRichEdit;
|
||||
using DevExpress.XtraRichEdit.API.Native;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Payee;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the
|
||||
/// DevExpress RichEdit/Office API, mirroring <c>CheckPrintService</c>) and a filing-data CSV.
|
||||
/// </summary>
|
||||
public class Form1099FormService : I1099FormService
|
||||
{
|
||||
private readonly IForm1099ReportService _report;
|
||||
private readonly IPayee1099Service _payees;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public Form1099FormService(IForm1099ReportService report, IPayee1099Service payees, AppDbContext db)
|
||||
{
|
||||
_report = report;
|
||||
_payees = payees;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear)
|
||||
{
|
||||
var payee = await _payees.GetByIdAsync(payeeId)
|
||||
?? throw new InvalidOperationException($"Payee {payeeId} not found.");
|
||||
|
||||
var church = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
|
||||
?? new ChurchProfile { Name = "Church" };
|
||||
|
||||
// Box 1 (Nonemployee compensation) = sum of this payee's NEC-1 payments for the year.
|
||||
var detail = await _report.GetRecipientDetailAsync(payeeId, taxYear);
|
||||
var box1Nec = detail?.Payments
|
||||
.Where(payment => payment.BoxCode == Entities.Form1099.BoxNec1)
|
||||
.Sum(payment => payment.Amount) ?? 0m;
|
||||
|
||||
using var server = new RichEditDocumentServer();
|
||||
var document = server.Document;
|
||||
document.BeginUpdate();
|
||||
try
|
||||
{
|
||||
document.Unit = DocumentUnit.Inch;
|
||||
var section = document.Sections[0];
|
||||
section.Page.Width = 8.5f;
|
||||
section.Page.Height = 11f;
|
||||
section.Margins.Left = section.Margins.Right = 0.8f;
|
||||
section.Margins.Top = section.Margins.Bottom = 0.8f;
|
||||
|
||||
document.AppendHtmlText(BuildCopyBHtml(church, payee, taxYear, box1Nec));
|
||||
}
|
||||
finally
|
||||
{
|
||||
document.EndUpdate();
|
||||
}
|
||||
|
||||
var stream = new MemoryStream();
|
||||
server.ExportToPdf(stream);
|
||||
stream.Position = 0;
|
||||
return (stream, "application/pdf", $"1099-NEC-{payeeId}-{taxYear}.pdf");
|
||||
}
|
||||
|
||||
public async Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear)
|
||||
{
|
||||
var summary = await _report.GetAnnualSummaryAsync(taxYear);
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold");
|
||||
foreach (var row in summary.Rows)
|
||||
{
|
||||
builder.AppendLine(string.Join(",",
|
||||
Csv(row.LegalName), Csv(row.TinLast4 ?? ""), Csv(row.W9Status),
|
||||
row.NecTotal.ToString(CultureInfo.InvariantCulture),
|
||||
row.RentsTotal.ToString(CultureInfo.InvariantCulture),
|
||||
row.GrandTotal.ToString(CultureInfo.InvariantCulture),
|
||||
row.MeetsThreshold ? "Y" : "N"));
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
return (new MemoryStream(bytes), "text/csv", $"1099-filing-{taxYear}.csv");
|
||||
|
||||
static string Csv(string value) => value.Contains(',') || value.Contains('"')
|
||||
? "\"" + value.Replace("\"", "\"\"") + "\"" : value;
|
||||
}
|
||||
|
||||
private static string BuildCopyBHtml(ChurchProfile church, Payee1099Dto payee, int taxYear, decimal box1Nec)
|
||||
{
|
||||
var payerAddress = JoinAddress(church.Address, church.City, church.State, church.ZipCode);
|
||||
var recipientAddress = JoinAddress(
|
||||
JoinLines(payee.AddressLine1, payee.AddressLine2), payee.City, payee.State, payee.Zip);
|
||||
|
||||
var payerEin = string.IsNullOrWhiteSpace(church.PayerEin) ? "" : church.PayerEin;
|
||||
var maskedTin = string.IsNullOrWhiteSpace(payee.TinLast4) ? "" : $"***-**-{payee.TinLast4}";
|
||||
|
||||
return
|
||||
"<div style=\"font-family:Arial;font-size:11pt;color:#111;\">" +
|
||||
$"<h2 style=\"text-align:center;margin:0;\">Form 1099-NEC — 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’s name, address</b><br/>" +
|
||||
$"{Encode(church.Name)}<br/>{payerAddress}" +
|
||||
"</td>" +
|
||||
"<td width=\"50%\" valign=\"top\">" +
|
||||
$"<b>PAYER’s TIN (EIN)</b><br/>{Encode(payerEin)}" +
|
||||
"</td></tr>" +
|
||||
|
||||
"<tr><td valign=\"top\">" +
|
||||
"<b>RECIPIENT’s name, address</b><br/>" +
|
||||
$"{Encode(payee.LegalName)}<br/>{recipientAddress}" +
|
||||
"</td>" +
|
||||
"<td valign=\"top\">" +
|
||||
$"<b>RECIPIENT’s TIN</b><br/>{Encode(maskedTin)}" +
|
||||
"</td></tr>" +
|
||||
|
||||
"<tr><td colspan=\"2\">" +
|
||||
"<b>Box 1 — 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’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 -> ToListAsync -> 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);
|
||||
}
|
||||
@@ -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..];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { DisbursementPageComponent } from './features/disbursement/pages/disburs
|
||||
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
|
||||
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
||||
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
|
||||
import { Form1099ReportPageComponent } from './features/finance-report/pages/form1099-report-page/form1099-report-page.component';
|
||||
import { Payee1099PageComponent } from './features/payee1099/pages/payee-1099-page/payee-1099-page.component';
|
||||
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
||||
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||
@@ -228,6 +230,24 @@ export const routes: Routes = [
|
||||
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/payee-1099',
|
||||
component: Payee1099PageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' },
|
||||
title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/form1099-report',
|
||||
component: Form1099ReportPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' },
|
||||
title: '1099 Year-End Report', titleZh: '1099 年度報表', section: 'Finance',
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const PermissionModules = {
|
||||
SystemLogs: 'SystemLogs',
|
||||
AuditLogs: 'AuditLogs',
|
||||
Settings: 'Settings',
|
||||
Form1099: 'Form1099',
|
||||
} as const;
|
||||
|
||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface ChurchProfileDto {
|
||||
id: number; name: string; nameZh: string | null; phone: string | null;
|
||||
email: string | null; website: string | null; address: string | null; city: string | null;
|
||||
state: string | null; zipCode: string | null; bankName: string | null;
|
||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; payerEin: string | null; nextCheckNumber: number;
|
||||
aiProvider: string;
|
||||
claudeModel: string | null; claudeApiKeyMasked: string | null;
|
||||
geminiModel: string | null; geminiApiKeyMasked: string | null;
|
||||
|
||||
+4
@@ -55,6 +55,10 @@
|
||||
Routing # / 路由號碼
|
||||
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Payer EIN / 雇主識別號 (EIN)
|
||||
<kendo-textbox [(ngModel)]="model.payerEin" placeholder="XX-XXXXXXX"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Next Check # / 下一張支票號碼
|
||||
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||
|
||||
+7
-1
@@ -158,7 +158,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Vendor mode: vendor name + check number -->
|
||||
<!-- Vendor mode: vendor name + check number + optional 1099 recipient -->
|
||||
<ng-container *ngIf="mode === 'vendor'">
|
||||
<label class="flex flex-col gap-1">Vendor Name
|
||||
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
|
||||
@@ -166,6 +166,12 @@
|
||||
<label class="flex flex-col gap-1">Check #
|
||||
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 md:col-span-2">1099 Recipient / 1099 收款人 <span class="text-gray-400 font-normal">(optional)</span>
|
||||
<kendo-dropdownlist [data]="payees" textField="legalName" valueField="id" [valuePrimitive]="true"
|
||||
[defaultItem]="{ id: null, legalName: '— none —' }"
|
||||
[(ngModel)]="form.payeeId">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
</ng-container>
|
||||
|
||||
<!-- Reimbursement mode: receipt file input -->
|
||||
|
||||
+9
@@ -15,6 +15,8 @@ import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/e
|
||||
import { ExpenseAiService } from '../../services/expense-ai.service';
|
||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||
import { Payee1099ApiService } from '../../../payee1099/services/payee1099-api.service';
|
||||
import { Payee1099ListItem } from '../../../payee1099/models/payee1099.model';
|
||||
import {
|
||||
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
||||
ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
|
||||
@@ -66,6 +68,8 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
ministries: MinistryDto[] = [];
|
||||
groups: ExpenseCategoryGroupDto[] = [];
|
||||
|
||||
payees: Payee1099ListItem[] = [];
|
||||
|
||||
/** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */
|
||||
snapshots: ExpenseSnapshotDto[] = [];
|
||||
/** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */
|
||||
@@ -95,6 +99,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
vendorName: '',
|
||||
checkNumber: '',
|
||||
memberId: null as number | null,
|
||||
payeeId: null as number | null,
|
||||
expenseDate: new Date(),
|
||||
};
|
||||
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
|
||||
@@ -133,11 +138,13 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
private expenseApi: ExpenseApiService,
|
||||
private snapshotApi: ExpenseSnapshotApiService,
|
||||
private aiApi: ExpenseAiService,
|
||||
private payeeApi: Payee1099ApiService,
|
||||
private sanitizer: DomSanitizer,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||
this.payeeApi.getAll(false).subscribe(list => (this.payees = list));
|
||||
if (this.showSnapshotTools) this.loadSnapshots();
|
||||
this.catApi.getAll(false).subscribe(groups => {
|
||||
this.groups = groups;
|
||||
@@ -169,6 +176,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
vendorName: expense.vendorName ?? '',
|
||||
checkNumber: expense.checkNumber ?? '',
|
||||
memberId: expense.memberId,
|
||||
payeeId: expense.payeeId ?? null,
|
||||
expenseDate: new Date(year, month - 1, day),
|
||||
};
|
||||
this.lines = (expense.lines ?? []).map(l => ({
|
||||
@@ -424,6 +432,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
|
||||
expenseDate,
|
||||
notes: null,
|
||||
payeeId: this.form.payeeId,
|
||||
};
|
||||
// The request and receipt are snapshotted here, so resetting the form right
|
||||
// after emitting is safe even though the parent saves asynchronously.
|
||||
|
||||
@@ -8,11 +8,11 @@ export interface PagedResult<T> {
|
||||
|
||||
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
|
||||
|
||||
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; }
|
||||
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; }
|
||||
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
|
||||
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; }
|
||||
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; }
|
||||
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; }
|
||||
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
|
||||
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
|
||||
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; }
|
||||
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
|
||||
|
||||
export interface ExpenseLineItemDto {
|
||||
@@ -28,6 +28,7 @@ export interface ExpenseListItemDto {
|
||||
expenseDate: string; hasReceipt: boolean;
|
||||
checkNumber: string | null;
|
||||
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
|
||||
payeeId: number | null;
|
||||
}
|
||||
export interface ExpenseDto extends ExpenseListItemDto {
|
||||
notes: string | null;
|
||||
@@ -70,6 +71,7 @@ export interface CreateExpenseRequest {
|
||||
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
|
||||
description: string; vendorName: string | null; memberId: number | null;
|
||||
checkNumber: string | null; expenseDate: string; notes: string | null;
|
||||
payeeId: number | null;
|
||||
}
|
||||
export type UpdateExpenseRequest = CreateExpenseRequest;
|
||||
export interface RejectExpenseRequest { reviewNotes: string | null; }
|
||||
|
||||
+20
-2
@@ -91,7 +91,7 @@
|
||||
Sort order
|
||||
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>Form 990 Line / 990 行</span>
|
||||
<kendo-dropdownlist
|
||||
[data]="form990Lines"
|
||||
@@ -100,6 +100,15 @@
|
||||
[(ngModel)]="groupForm.form990LineId">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>1099 Box / 1099 框</span>
|
||||
<kendo-dropdownlist
|
||||
[data]="form1099Boxes"
|
||||
textField="label" valueField="id" [valuePrimitive]="true"
|
||||
[defaultItem]="{ id: null, label: '— none —' }"
|
||||
[(ngModel)]="groupForm.form1099BoxId">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
|
||||
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
|
||||
</label>
|
||||
@@ -158,7 +167,7 @@
|
||||
Sort order
|
||||
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>Form 990 Line / 990 行</span>
|
||||
<kendo-dropdownlist
|
||||
[data]="form990Lines"
|
||||
@@ -167,6 +176,15 @@
|
||||
[(ngModel)]="subForm.form990LineId">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>1099 Box / 1099 框</span>
|
||||
<kendo-dropdownlist
|
||||
[data]="form1099Boxes"
|
||||
textField="label" valueField="id" [valuePrimitive]="true"
|
||||
[defaultItem]="{ id: null, label: '— none —' }"
|
||||
[(ngModel)]="subForm.form1099BoxId">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
|
||||
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
|
||||
</label>
|
||||
|
||||
+11
-8
@@ -10,6 +10,7 @@ import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from
|
||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
|
||||
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
||||
import { Form1099Box } from '../../../payee1099/models/payee1099.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-expense-categories-page',
|
||||
@@ -23,6 +24,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
||||
loading = false;
|
||||
form990Lines: Form990ExpenseLineDto[] = [];
|
||||
form1099Boxes: (Form1099Box & { label: string })[] = [];
|
||||
|
||||
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
||||
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
||||
@@ -33,13 +35,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
|
||||
groupDialogOpen = false;
|
||||
editingGroupId: number | null = null;
|
||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
|
||||
groupAiLoading = false;
|
||||
groupAiSuggestion: CategoryAiSuggestion | null = null;
|
||||
|
||||
subDialogOpen = false;
|
||||
editingSubId: number | null = null;
|
||||
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
|
||||
subAiLoading = false;
|
||||
subAiSuggestion: CategoryAiSuggestion | null = null;
|
||||
|
||||
@@ -48,6 +50,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
|
||||
this.api.getForm1099Boxes().subscribe(boxes => { this.form1099Boxes = boxes; });
|
||||
}
|
||||
|
||||
load(): void {
|
||||
@@ -111,13 +114,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
|
||||
openNewGroup(): void {
|
||||
this.editingGroupId = null;
|
||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
|
||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
|
||||
this.resetGroupAi();
|
||||
this.groupDialogOpen = true;
|
||||
}
|
||||
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
||||
this.editingGroupId = g.id;
|
||||
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
|
||||
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId, form1099BoxId: g.form1099BoxId };
|
||||
this.resetGroupAi();
|
||||
this.groupDialogOpen = true;
|
||||
}
|
||||
@@ -143,7 +146,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
}
|
||||
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
|
||||
saveGroup(): void {
|
||||
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
|
||||
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId, form1099BoxId: this.groupForm.form1099BoxId };
|
||||
const done = () => { this.groupDialogOpen = false; this.load(); };
|
||||
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
|
||||
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
|
||||
@@ -156,13 +159,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
openNewSub(): void {
|
||||
if (!this.selectedGroup) return;
|
||||
this.editingSubId = null;
|
||||
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
|
||||
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
|
||||
this.resetSubAi();
|
||||
this.subDialogOpen = true;
|
||||
}
|
||||
openEditSub(s: ExpenseSubCategoryDto): void {
|
||||
this.editingSubId = s.id;
|
||||
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
|
||||
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId, form1099BoxId: s.form1099BoxId };
|
||||
this.resetSubAi();
|
||||
this.subDialogOpen = true;
|
||||
}
|
||||
@@ -195,7 +198,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
|
||||
saveSub(): void {
|
||||
if (!this.selectedGroup) return;
|
||||
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
|
||||
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId, form1099BoxId: this.subForm.form1099BoxId };
|
||||
const done = () => { this.subDialogOpen = false; this.load(); };
|
||||
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
|
||||
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ExpenseCategoryAiRequest, CategoryAiSuggestion,
|
||||
} from '../models/expense.model';
|
||||
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
|
||||
import { Form1099Box } from '../../payee1099/models/payee1099.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExpenseCategoryApiService {
|
||||
@@ -38,4 +39,9 @@ export class ExpenseCategoryApiService {
|
||||
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
|
||||
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode} — ${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
|
||||
}
|
||||
|
||||
getForm1099Boxes(): Observable<(Form1099Box & { label: string })[]> {
|
||||
return this.http.get<Form1099Box[]>(this.apiConfig.getApiUrl('form1099-report') + '/boxes')
|
||||
.pipe(map(rows => rows.map(b => ({ ...b, label: `${b.boxCode} — ${b.name_en}${b.name_zh ? ' / ' + b.name_zh : ''}` }))));
|
||||
}
|
||||
}
|
||||
|
||||
+119
@@ -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>
|
||||
+100
@@ -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;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||
import { Form1099ReportApiService } from '../../../payee1099/services/form1099-report-api.service';
|
||||
import {
|
||||
Form1099Summary, Form1099RecipientRow, Form1099RecipientDetail,
|
||||
} from '../../../payee1099/models/payee1099.model';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-form1099-report-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
|
||||
DropDownsModule, ContextMenuModule, PageHeaderActionsDirective,
|
||||
],
|
||||
templateUrl: './form1099-report-page.component.html',
|
||||
styleUrls: ['./form1099-report-page.component.scss'],
|
||||
})
|
||||
export class Form1099ReportPageComponent implements OnInit {
|
||||
/** Recent years offered in the selector: current year and the prior four. */
|
||||
readonly years: number[] = [];
|
||||
taxYear: number = new Date().getFullYear();
|
||||
|
||||
summary: Form1099Summary | null = null;
|
||||
loading = false;
|
||||
|
||||
// Per-row "Copy B" action, surfaced through a right-click context menu (matches
|
||||
// the recipients page convention of putting row actions in a context menu).
|
||||
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
|
||||
rowMenuItems: { text: string }[] = [];
|
||||
private contextRow: Form1099RecipientRow | null = null;
|
||||
|
||||
detail: Form1099RecipientDetail | null = null;
|
||||
detailLoading = false;
|
||||
|
||||
constructor(private api: Form1099ReportApiService) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
for (let offset = 0; offset < 5; offset++) {
|
||||
this.years.push(currentYear - offset);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.api.getSummary(this.taxYear).subscribe({
|
||||
next: (summary) => {
|
||||
this.summary = summary;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => { this.loading = false; },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Row interaction: primary click opens the detail; right-click shows actions ──
|
||||
onCellClick(event: CellClickEvent): void {
|
||||
if (event.type === 'contextmenu') {
|
||||
event.originalEvent.preventDefault();
|
||||
this.contextRow = event.dataItem;
|
||||
this.rowMenuItems = [{ text: 'Copy B PDF' }];
|
||||
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||
} else {
|
||||
this.openDetail(event.dataItem);
|
||||
}
|
||||
}
|
||||
|
||||
onRowMenuSelect(event: ContextMenuSelectEvent): void {
|
||||
if (!this.contextRow) return;
|
||||
if (event.item.text === 'Copy B PDF') this.copyB(this.contextRow);
|
||||
}
|
||||
|
||||
openDetail(row: Form1099RecipientRow): void {
|
||||
this.detail = null;
|
||||
this.detailLoading = true;
|
||||
this.api.getRecipient(row.payeeId, this.taxYear).subscribe({
|
||||
next: (detail) => {
|
||||
this.detail = detail;
|
||||
this.detailLoading = false;
|
||||
},
|
||||
error: () => { this.detailLoading = false; },
|
||||
});
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.detail = null;
|
||||
this.detailLoading = false;
|
||||
}
|
||||
|
||||
// ── Downloads: fetched as blobs so the auth interceptor attaches the token ──────
|
||||
exportCsv(): void {
|
||||
this.api.downloadCsv(this.taxYear).subscribe((blob) => {
|
||||
this.saveBlob(blob, `1099-filing-${this.taxYear}.csv`);
|
||||
});
|
||||
}
|
||||
|
||||
copyB(row: Form1099RecipientRow): void {
|
||||
this.api.downloadCopyB(row.payeeId, this.taxYear).subscribe((blob) => {
|
||||
this.saveBlob(blob, `1099-NEC-${row.payeeId}-${this.taxYear}.pdf`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Trigger a browser save of a downloaded blob via a temporary anchor. */
|
||||
private saveBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface Payee1099ListItem {
|
||||
id: number; legalName: string; displayName?: string;
|
||||
memberId?: number; memberName?: string; taxClassification: string;
|
||||
is1099Tracked: boolean; tinType?: string; tinLast4?: string;
|
||||
w9Status: string; isActive: boolean;
|
||||
}
|
||||
|
||||
export interface Payee1099 extends Payee1099ListItem {
|
||||
addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
|
||||
email?: string; phone?: string; w9ReceivedDate?: string; hasW9Document: boolean; notes?: string;
|
||||
}
|
||||
|
||||
export interface SavePayee1099Request {
|
||||
legalName: string; displayName?: string; memberId?: number | null;
|
||||
taxClassification: string; is1099Tracked: boolean;
|
||||
tinType?: string; tin?: string | null;
|
||||
addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
|
||||
email?: string; phone?: string; w9Status: string; w9ReceivedDate?: string | null;
|
||||
isActive: boolean; notes?: string;
|
||||
}
|
||||
|
||||
export interface Form1099Box {
|
||||
id: number; boxCode: string; name_en: string; name_zh?: string; formType: string; sortOrder: number;
|
||||
}
|
||||
|
||||
export interface Form1099RecipientRow {
|
||||
payeeId: number; legalName: string; tinLast4?: string; w9Status: string;
|
||||
necTotal: number; rentsTotal: number; grandTotal: number; meetsThreshold: boolean; w9Missing: boolean;
|
||||
}
|
||||
|
||||
export interface Form1099Summary {
|
||||
taxYear: number; rows: Form1099RecipientRow[];
|
||||
totalReportable: number; recipientsAtThreshold: number; recipientsMissingW9: number;
|
||||
}
|
||||
|
||||
export interface Form1099Payment {
|
||||
paidDate: string; description: string; categoryName: string; boxCode: string; amount: number;
|
||||
}
|
||||
|
||||
export interface Form1099RecipientDetail {
|
||||
payeeId: number; legalName: string; tinLast4?: string; w9Status: string;
|
||||
taxYear: number; payments: Form1099Payment[];
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<div class="page">
|
||||
<ng-template appPageHeaderActions>
|
||||
<label class="inactive-toggle">
|
||||
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive / 顯示停用
|
||||
</label>
|
||||
<button kendoButton themeColor="primary"
|
||||
*appHasPermission="{ module: 'Form1099', action: 'write' }"
|
||||
(click)="openNew()">+ New Recipient / 新增收款人</button>
|
||||
</ng-template>
|
||||
|
||||
<div class="hint-text-sm">Click a name to edit · right-click a row for actions / 點選名稱編輯 · 右鍵顯示動作</div>
|
||||
|
||||
<!-- Desktop grid -->
|
||||
<div class="hidden md:block">
|
||||
<kendo-grid class="clickable-rows" [data]="recipients" [loading]="loading"
|
||||
(cellClick)="onCellClick($event)">
|
||||
<kendo-grid-column field="legalName" title="Legal Name / 法定名稱">
|
||||
<ng-template kendoGridCellTemplate let-r>
|
||||
<span class="legal-name">{{ r.legalName }}</span>
|
||||
<span *ngIf="r.displayName" class="display-name"> ({{ r.displayName }})</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="memberName" title="Member / 會友">
|
||||
<ng-template kendoGridCellTemplate let-r>{{ r.memberName || '—' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="taxClassification" title="Tax Class / 稅務分類" [width]="150"></kendo-grid-column>
|
||||
<kendo-grid-column title="TIN" [width]="120">
|
||||
<ng-template kendoGridCellTemplate let-r>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="w9Status" title="W-9" [width]="120">
|
||||
<ng-template kendoGridCellTemplate let-r>
|
||||
<span class="badge" [ngClass]="'badge-' + r.w9Status.toLowerCase()">{{ r.w9Status }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="is1099Tracked" title="1099 Tracked" [width]="120">
|
||||
<ng-template kendoGridCellTemplate let-r>{{ r.is1099Tracked ? 'Yes' : 'No' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="isActive" title="Active" [width]="90">
|
||||
<ng-template kendoGridCellTemplate let-r>{{ r.isActive ? 'Yes' : 'No' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
<div *ngFor="let r of recipients" class="rounded border p-3" (click)="openEdit(r)">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<div class="font-semibold">{{ r.legalName }}</div>
|
||||
<span class="badge" [ngClass]="'badge-' + r.w9Status.toLowerCase()">{{ r.w9Status }}</span>
|
||||
</div>
|
||||
<div *ngIf="r.displayName" class="text-sm text-gray-500">{{ r.displayName }}</div>
|
||||
<div class="text-sm flex justify-between"><span>Member / 會友</span><span>{{ r.memberName || '—' }}</span></div>
|
||||
<div class="text-sm flex justify-between"><span>Tax Class</span><span>{{ r.taxClassification }}</span></div>
|
||||
<div class="text-sm flex justify-between"><span>TIN</span><span>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</span></div>
|
||||
<div class="text-sm flex justify-between"><span>1099 Tracked</span><span>{{ r.is1099Tracked ? 'Yes' : 'No' }}</span></div>
|
||||
<div class="text-sm flex justify-between"><span>Active</span><span>{{ r.isActive ? 'Yes' : 'No' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New / Edit dialog -->
|
||||
<kendo-dialog *ngIf="dialogOpen"
|
||||
[title]="editingId != null ? 'Edit Recipient / 編輯收款人' : 'New Recipient / 新增收款人'"
|
||||
(close)="dialogOpen = false"
|
||||
[width]="720" [maxWidth]="'95vw'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
Legal Name / 法定名稱 *
|
||||
<kendo-textbox [(ngModel)]="form.legalName"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Display Name / 顯示名稱
|
||||
<kendo-textbox [(ngModel)]="form.displayName"></kendo-textbox>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Linked Member / 連結會友
|
||||
<kendo-dropdownlist
|
||||
[data]="memberResults"
|
||||
textField="displayName" valueField="id" [valuePrimitive]="true"
|
||||
[filterable]="true" (filterChange)="onMemberFilter($event)"
|
||||
[defaultItem]="{ id: null, displayName: '(None / 無)' }"
|
||||
[(ngModel)]="form.memberId">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
Tax Classification / 稅務分類
|
||||
<kendo-dropdownlist [data]="taxClassifications" [(ngModel)]="form.taxClassification"
|
||||
(valueChange)="onTaxClassificationChange($event)"></kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 md:mt-6">
|
||||
<kendo-switch [(ngModel)]="form.is1099Tracked" (valueChange)="onTrackedToggle()"></kendo-switch>
|
||||
<span>1099 Tracked / 列入 1099</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
TIN Type / 稅號類型
|
||||
<kendo-dropdownlist [data]="tinTypes" [(ngModel)]="form.tinType"></kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
TIN / 稅號
|
||||
<kendo-textbox [(ngModel)]="form.tin"
|
||||
[placeholder]="editingId != null && editingTinLast4 ? '***-**-' + editingTinLast4 : ''"></kendo-textbox>
|
||||
<span *ngIf="editingId != null" class="hint-text-sm">Leave blank to keep the existing TIN / 留空則保留現有稅號</span>
|
||||
<div *ngIf="editingId != null" class="flex flex-col gap-1">
|
||||
<button kendoButton type="button" fillMode="link" class="self-start"
|
||||
*appHasPermission="{ module: 'Form1099', action: 'write' }"
|
||||
(click)="revealTin()">Reveal full TIN / 顯示完整 TIN</button>
|
||||
<span *ngIf="revealedTin" class="font-mono">{{ revealedTin }}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Address Line 1 / 地址 1
|
||||
<kendo-textbox [(ngModel)]="form.addressLine1"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Address Line 2 / 地址 2
|
||||
<kendo-textbox [(ngModel)]="form.addressLine2"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
City / 城市
|
||||
<kendo-textbox [(ngModel)]="form.city"></kendo-textbox>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-x-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
State / 州
|
||||
<kendo-textbox [(ngModel)]="form.state"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Zip / 郵遞區號
|
||||
<kendo-textbox [(ngModel)]="form.zip"></kendo-textbox>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
Email / 電郵
|
||||
<kendo-textbox [(ngModel)]="form.email"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Phone / 電話
|
||||
<kendo-textbox [(ngModel)]="form.phone"></kendo-textbox>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
W-9 Status / W-9 狀態
|
||||
<kendo-dropdownlist [data]="w9Statuses" [(ngModel)]="form.w9Status"></kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
W-9 Received / W-9 收到日期
|
||||
<kendo-datepicker [(value)]="form.w9ReceivedDate"></kendo-datepicker>
|
||||
</label>
|
||||
|
||||
<!-- W-9 document upload/view: edit mode only (a new record is saved first, then re-opened to attach). -->
|
||||
<div *ngIf="editingId != null" class="flex flex-col gap-1 md:col-span-2">
|
||||
<span>W-9 Document / W-9 文件</span>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
*appHasPermission="{ module: 'Form1099', action: 'write' }"
|
||||
(change)="onW9FileSelected($event)" />
|
||||
<span class="hint-text-sm">Upload W-9 / 上傳 W-9</span>
|
||||
<button *ngIf="editingHasW9" kendoButton type="button" fillMode="link" class="self-start"
|
||||
(click)="viewW9()">View W-9 / 檢視 W-9</button>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Notes / 備註
|
||||
<kendo-textarea [(ngModel)]="form.notes" [rows]="3"></kendo-textarea>
|
||||
</label>
|
||||
|
||||
<label *ngIf="editingId != null" class="flex items-center gap-2 md:col-span-2">
|
||||
<input type="checkbox" [(ngModel)]="form.isActive" /> Active / 啟用
|
||||
</label>
|
||||
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="dialogOpen = false">Cancel / 取消</button>
|
||||
<button kendoButton themeColor="primary" [disabled]="!form.legalName" (click)="save()">Save / 儲存</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
.hint-text-sm {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.inactive-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.legal-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
// Grid rows are clickable to open the editor.
|
||||
.clickable-rows ::ng-deep .k-grid-content tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// W-9 status badges.
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.badge-onfile {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.badge-requested {
|
||||
background-color: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.badge-missing {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-expired {
|
||||
background-color: #fed7aa;
|
||||
color: #9a3412;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||
import { Payee1099ApiService } from '../../services/payee1099-api.service';
|
||||
import { Payee1099ListItem, Payee1099, SavePayee1099Request } from '../../models/payee1099.model';
|
||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||
|
||||
/** Flattened member item with a single displayName field for the picker. */
|
||||
interface MemberOption { id: number; displayName: string; }
|
||||
|
||||
/** Editable form model for the New/Edit dialog. */
|
||||
interface Payee1099Form {
|
||||
legalName: string;
|
||||
displayName: string;
|
||||
memberId: number | null;
|
||||
taxClassification: string;
|
||||
is1099Tracked: boolean;
|
||||
tinType: string;
|
||||
tin: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
w9Status: string;
|
||||
w9ReceivedDate: Date | null;
|
||||
isActive: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-payee-1099-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
|
||||
InputsModule, DropDownsModule, DateInputsModule, ContextMenuModule,
|
||||
PageHeaderActionsDirective, HasPermissionDirective,
|
||||
],
|
||||
templateUrl: './payee-1099-page.component.html',
|
||||
styleUrls: ['./payee-1099-page.component.scss'],
|
||||
})
|
||||
export class Payee1099PageComponent implements OnInit {
|
||||
recipients: Payee1099ListItem[] = [];
|
||||
loading = false;
|
||||
includeInactive = false;
|
||||
|
||||
readonly taxClassifications = ['Individual', 'SoleProprietor', 'Partnership', 'CCorp', 'SCorp', 'LLC', 'Other'];
|
||||
readonly tinTypes = ['SSN', 'EIN'];
|
||||
readonly w9Statuses = ['Missing', 'Requested', 'OnFile', 'Expired'];
|
||||
|
||||
/** Member picker options, filled on demand from the members search. */
|
||||
memberResults: MemberOption[] = [];
|
||||
|
||||
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
|
||||
rowMenuItems: { text: string }[] = [];
|
||||
private contextRow: Payee1099ListItem | null = null;
|
||||
|
||||
dialogOpen = false;
|
||||
editingId: number | null = null;
|
||||
/** Last-4 of the existing TIN (edit mode), so the TIN box can show a masked placeholder. */
|
||||
editingTinLast4: string | null = null;
|
||||
/** True when the record being edited already has a W-9 document attached. */
|
||||
editingHasW9 = false;
|
||||
/** Full TIN revealed on demand (write-gated); shown read-only, never logged or persisted. */
|
||||
revealedTin: string | null = null;
|
||||
/** Whether the user has manually toggled "1099 Tracked" in this dialog session (suppresses the classification default). */
|
||||
private trackedTouched = false;
|
||||
form: Payee1099Form = this.blankForm();
|
||||
|
||||
constructor(
|
||||
private api: Payee1099ApiService,
|
||||
private memberApi: MemberApiService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.api.getAll(this.includeInactive).subscribe({
|
||||
next: (rows) => {
|
||||
this.recipients = rows;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => { this.loading = false; },
|
||||
});
|
||||
}
|
||||
|
||||
private blankForm(): Payee1099Form {
|
||||
return {
|
||||
legalName: '', displayName: '', memberId: null,
|
||||
taxClassification: 'Individual', is1099Tracked: true,
|
||||
tinType: 'SSN', tin: '',
|
||||
addressLine1: '', addressLine2: '', city: '', state: '', zip: '',
|
||||
email: '', phone: '',
|
||||
w9Status: 'Missing', w9ReceivedDate: null,
|
||||
isActive: true, notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Member picker (server-side search, same source as the expense form) ──────
|
||||
onMemberFilter(term: string): void {
|
||||
if (!term || term.length < 1) { this.memberResults = []; return; }
|
||||
this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe((result) => {
|
||||
this.memberResults = result.items.map((member: MemberListItemDto) => ({
|
||||
id: member.id,
|
||||
displayName: memberDisplayName(member),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Row interaction: primary click opens the editor; right-click shows actions ──
|
||||
onCellClick(event: CellClickEvent): void {
|
||||
if (event.type === 'contextmenu') {
|
||||
event.originalEvent.preventDefault();
|
||||
this.contextRow = event.dataItem;
|
||||
this.rowMenuItems = this.buildMenuItems(event.dataItem.isActive);
|
||||
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||
} else {
|
||||
this.openEdit(event.dataItem);
|
||||
}
|
||||
}
|
||||
|
||||
onRowMenuSelect(event: ContextMenuSelectEvent): void {
|
||||
if (!this.contextRow) return;
|
||||
if (event.item.text === 'Edit') this.openEdit(this.contextRow);
|
||||
else if (event.item.text === 'Deactivate') this.deactivate(this.contextRow);
|
||||
}
|
||||
|
||||
private buildMenuItems(isActive: boolean): { text: string }[] {
|
||||
const items: { text: string }[] = [{ text: 'Edit' }];
|
||||
if (isActive) items.push({ text: 'Deactivate' });
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── Dialog open ──────────────────────────────────────────────────────────────
|
||||
openNew(): void {
|
||||
this.editingId = null;
|
||||
this.editingTinLast4 = null;
|
||||
this.editingHasW9 = false;
|
||||
this.revealedTin = null;
|
||||
this.trackedTouched = false;
|
||||
this.form = this.blankForm();
|
||||
this.dialogOpen = true;
|
||||
}
|
||||
|
||||
openEdit(row: Payee1099ListItem): void {
|
||||
this.editingId = row.id;
|
||||
this.editingHasW9 = false;
|
||||
this.revealedTin = null;
|
||||
this.trackedTouched = false;
|
||||
this.dialogOpen = true;
|
||||
// Load the full record so the dialog can prefill the address/contact/notes fields.
|
||||
this.api.getById(row.id).subscribe((payee: Payee1099) => {
|
||||
this.editingTinLast4 = payee.tinLast4 ?? null;
|
||||
this.editingHasW9 = payee.hasW9Document;
|
||||
this.form = {
|
||||
legalName: payee.legalName,
|
||||
displayName: payee.displayName ?? '',
|
||||
memberId: payee.memberId ?? null,
|
||||
taxClassification: payee.taxClassification,
|
||||
is1099Tracked: payee.is1099Tracked,
|
||||
tinType: payee.tinType ?? 'SSN',
|
||||
tin: '',
|
||||
addressLine1: payee.addressLine1 ?? '',
|
||||
addressLine2: payee.addressLine2 ?? '',
|
||||
city: payee.city ?? '',
|
||||
state: payee.state ?? '',
|
||||
zip: payee.zip ?? '',
|
||||
email: payee.email ?? '',
|
||||
phone: payee.phone ?? '',
|
||||
w9Status: payee.w9Status,
|
||||
w9ReceivedDate: this.parseDateOnly(payee.w9ReceivedDate),
|
||||
isActive: payee.isActive,
|
||||
notes: payee.notes ?? '',
|
||||
};
|
||||
// Seed the picker with the linked member so its name shows even before a search.
|
||||
if (payee.memberId != null && payee.memberName) {
|
||||
this.memberResults = [{ id: payee.memberId, displayName: payee.memberName }];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Save ─────────────────────────────────────────────────────────────────────
|
||||
save(): void {
|
||||
if (!this.form.legalName.trim()) return;
|
||||
const typedTin = this.form.tin.trim();
|
||||
const request: SavePayee1099Request = {
|
||||
legalName: this.form.legalName.trim(),
|
||||
displayName: this.form.displayName.trim() || undefined,
|
||||
memberId: this.form.memberId ?? null,
|
||||
taxClassification: this.form.taxClassification,
|
||||
is1099Tracked: this.form.is1099Tracked,
|
||||
tinType: this.form.tinType,
|
||||
// Send the typed TIN when present. On edit a blank leaves the stored value
|
||||
// unchanged (null = no change); on new a blank simply means no TIN yet.
|
||||
tin: typedTin || null,
|
||||
addressLine1: this.form.addressLine1.trim() || undefined,
|
||||
addressLine2: this.form.addressLine2.trim() || undefined,
|
||||
city: this.form.city.trim() || undefined,
|
||||
state: this.form.state.trim() || undefined,
|
||||
zip: this.form.zip.trim() || undefined,
|
||||
email: this.form.email.trim() || undefined,
|
||||
phone: this.form.phone.trim() || undefined,
|
||||
w9Status: this.form.w9Status,
|
||||
w9ReceivedDate: this.toDateOnly(this.form.w9ReceivedDate),
|
||||
isActive: this.form.isActive,
|
||||
notes: this.form.notes.trim() || undefined,
|
||||
};
|
||||
const done = () => { this.dialogOpen = false; this.load(); };
|
||||
if (this.editingId == null) this.api.create(request).subscribe(done);
|
||||
else this.api.update(this.editingId, request).subscribe(done);
|
||||
}
|
||||
|
||||
deactivate(row: Payee1099ListItem): void {
|
||||
if (!confirm(`Deactivate "${row.legalName}"?`)) return;
|
||||
this.api.delete(row.id).subscribe(() => this.load());
|
||||
}
|
||||
|
||||
// ── Tax classification drives the 1099-tracked default (spec §2.1/§2.3) ────────
|
||||
// Corporations default to NOT tracked; everyone else defaults to tracked. Only applies
|
||||
// to NEW records and only until the user manually flips the toggle (no override of an
|
||||
// explicit choice or an existing saved value on edit).
|
||||
onTaxClassificationChange(classification: string): void {
|
||||
if (this.editingId != null || this.trackedTouched) return;
|
||||
const isCorporation = classification === 'CCorp' || classification === 'SCorp';
|
||||
this.form.is1099Tracked = !isCorporation;
|
||||
}
|
||||
|
||||
onTrackedToggle(): void { this.trackedTouched = true; }
|
||||
|
||||
// ── W-9 document upload/view (edit mode only; a new record is saved first) ─────
|
||||
onW9FileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
if (!file || this.editingId == null) return;
|
||||
this.api.uploadW9(this.editingId, file).subscribe(() => {
|
||||
this.editingHasW9 = true;
|
||||
input.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch the stored W-9 via HttpClient (auth interceptor attaches the JWT) and open it in a new tab. */
|
||||
viewW9(): void {
|
||||
if (this.editingId == null) return;
|
||||
this.api.downloadW9(this.editingId).subscribe((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Full TIN reveal (write-gated; acceptance criterion #11.4) ──────────────────
|
||||
revealTin(): void {
|
||||
if (this.editingId == null) return;
|
||||
this.api.revealTin(this.editingId).subscribe((result) => {
|
||||
this.revealedTin = result.tin;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Date-only helpers: build/parse "yyyy-MM-dd" from LOCAL components ─────────
|
||||
private parseDateOnly(value: string | undefined | null): Date | null {
|
||||
if (!value) return null;
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
private toDateOnly(date: Date | null): string | null {
|
||||
if (!date) return null;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
import {
|
||||
Form1099Box, Form1099Summary, Form1099RecipientDetail,
|
||||
} from '../models/payee1099.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Form1099ReportApiService {
|
||||
private readonly endpoint: string;
|
||||
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.endpoint = apiConfig.getApiUrl('form1099-report');
|
||||
}
|
||||
|
||||
getBoxes(): Observable<Form1099Box[]> {
|
||||
return this.http.get<Form1099Box[]>(`${this.endpoint}/boxes`);
|
||||
}
|
||||
|
||||
getSummary(taxYear: number): Observable<Form1099Summary> {
|
||||
return this.http.get<Form1099Summary>(`${this.endpoint}/summary`, {
|
||||
params: { taxYear: String(taxYear) },
|
||||
});
|
||||
}
|
||||
|
||||
getRecipient(payeeId: number, taxYear: number): Observable<Form1099RecipientDetail> {
|
||||
return this.http.get<Form1099RecipientDetail>(`${this.endpoint}/recipient/${payeeId}`, {
|
||||
params: { taxYear: String(taxYear) },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticated blob downloads: routed through HttpClient so the auth
|
||||
// interceptor attaches the bearer token (a raw window.open would 401).
|
||||
downloadCsv(taxYear: number): Observable<Blob> {
|
||||
return this.http.get(`${this.endpoint}/export-csv`, {
|
||||
params: { taxYear: String(taxYear) },
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
downloadCopyB(payeeId: number, taxYear: number): Observable<Blob> {
|
||||
return this.http.get(`${this.endpoint}/recipient/${payeeId}/copy-b`, {
|
||||
params: { taxYear: String(taxYear) },
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
import {
|
||||
Payee1099ListItem, Payee1099, SavePayee1099Request,
|
||||
} from '../models/payee1099.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Payee1099ApiService {
|
||||
private readonly endpoint: string;
|
||||
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.endpoint = apiConfig.getApiUrl('payee-1099');
|
||||
}
|
||||
|
||||
getAll(includeInactive = false): Observable<Payee1099ListItem[]> {
|
||||
return this.http.get<Payee1099ListItem[]>(this.endpoint, {
|
||||
params: { includeInactive: String(includeInactive) },
|
||||
});
|
||||
}
|
||||
|
||||
getById(id: number): Observable<Payee1099> {
|
||||
return this.http.get<Payee1099>(`${this.endpoint}/${id}`);
|
||||
}
|
||||
|
||||
create(req: SavePayee1099Request): Observable<{ id: number }> {
|
||||
return this.http.post<{ id: number }>(this.endpoint, req);
|
||||
}
|
||||
|
||||
update(id: number, req: SavePayee1099Request): Observable<void> {
|
||||
return this.http.put<void>(`${this.endpoint}/${id}`, req);
|
||||
}
|
||||
|
||||
delete(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.endpoint}/${id}`);
|
||||
}
|
||||
|
||||
revealTin(id: number): Observable<{ tin: string | null }> {
|
||||
return this.http.get<{ tin: string | null }>(`${this.endpoint}/${id}/tin`);
|
||||
}
|
||||
|
||||
uploadW9(id: number, file: File): Observable<void> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return this.http.post<void>(`${this.endpoint}/${id}/w9`, form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the stored W-9 as a Blob via HttpClient so the auth interceptor attaches
|
||||
* the JWT. A plain window.open on the API URL would be an unauthenticated browser
|
||||
* navigation and the API's permission gate would reject it.
|
||||
*/
|
||||
downloadW9(id: number): Observable<Blob> {
|
||||
return this.http.get(`${this.endpoint}/${id}/w9`, { responseType: 'blob' });
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
{ text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099',
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' } },
|
||||
{ text: '1099 Report', icon: fileReportIcon, path: '/user-portal/finance/form1099-report',
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
6. [Phase 1 — CMS](#6-cms)
|
||||
7. [Phase 1 — Giving & Donations(奉獻)](#7-giving--donations-奉獻)
|
||||
8. [Phase 1 — Expense Tracking(支出)](#8-expense-tracking-支出)
|
||||
- [Form1099Box(1099 欄位目錄)](#form1099box-irs-1099-報告欄位目錄)
|
||||
- [Payee1099(收款人主檔)](#payee1099-1099-申報收款人主檔)
|
||||
- [現有表新增欄位(1099 歸屬)](#現有表新增欄位1099-歸屬)
|
||||
9. [Phase 1 — Prayer Requests(代禱)](#9-prayer-requests-代禱)
|
||||
10. [Phase 1 — Audit Log](#10-audit-log)
|
||||
11. [Phase 1 — Notifications](#11-notifications)
|
||||
@@ -704,6 +707,91 @@ Table: MonthlyStatements
|
||||
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
|
||||
| **UNIQUE** | (Year, Month) | 每個月只有一份月結報表 |
|
||||
|
||||
### Form1099Box(IRS 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。
|
||||
|
||||
### Payee1099(1099 申報收款人主檔)
|
||||
|
||||
```
|
||||
Table: Payee1099s
|
||||
```
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|------|------|------|
|
||||
| Id | int PK | |
|
||||
| LegalName | varchar(200) NOT NULL | IRS 法定全名(個人或公司)|
|
||||
| DisplayName | varchar(200)? | 顯示用簡稱(選填)|
|
||||
| MemberId | int? | FK → Members.Id,ON 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.Id,ON DELETE SET NULL。費用標題層級 1099 收款人歸屬;null = 不申報 1099 |
|
||||
|
||||
**`ExpenseSubCategories`(新增欄位)**
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|------|------|------|
|
||||
| **Form1099BoxId** | int? | FK → Form1099Boxes.Id,ON DELETE SET NULL。子項目層級 1099 申報欄位映射(優先於大類值)|
|
||||
|
||||
**`ExpenseCategoryGroups`(新增欄位)**
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|------|------|------|
|
||||
| **Form1099BoxId** | int? | FK → Form1099Boxes.Id,ON DELETE SET NULL。大類層級 1099 申報欄位備援映射 |
|
||||
|
||||
> **有效 1099 欄位解析順序:** `SubCategory.Form1099BoxId ?? Group.Form1099BoxId ?? null`(先取子項目欄位;若為 null 則取大類欄位;仍為 null = 該費用不需申報 1099)。此解析邏輯與 Form 990 行號解析(`SubCategory.Form990LineId ?? Group.Form990LineId ?? "24"`)平行,但語意不同:1099 的 null 代表「不申報」,而 990 的 null 會回退至行 "24"(其他費用)。
|
||||
|
||||
---
|
||||
|
||||
## 9. Prayer Requests(代禱)
|
||||
@@ -1033,6 +1121,14 @@ super_admin, pastor, board_member, coworker_chair, ministry_leader, district_lea
|
||||
Form990Report — 唯讀報表權限,授予角色:finance、pastor、board_member
|
||||
```
|
||||
|
||||
### Form1099 權限模組
|
||||
```
|
||||
Form1099 — 1099 收款人管理與申報,授予角色:
|
||||
finance — Read / Write / Delete(完整管理)
|
||||
pastor — Read(唯讀總覽)
|
||||
board_member — Read(唯讀總覽)
|
||||
```
|
||||
|
||||
### CmsPages(靜態頁面 Slug)
|
||||
```
|
||||
about, vision, service-times, contact
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
# 子專案 B — 1099 收款人追蹤(1099 Recipient Tracking)設計
|
||||
|
||||
**日期:** 2026-06-25
|
||||
**狀態:** Approved(user 已核可,待轉 implementation plan)
|
||||
**範圍:** 僅子專案 B。支出 Part IX(A)已上線;收入端 Part VIII(C)為獨立 spec,不在此。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目標與背景
|
||||
|
||||
教會依 IRC §6033(a)(3)(A) 免於申報 990,但對**獨立承攬人/廠商**的付款仍須在年底產出 **1099-NEC**(非員工報酬)記錄。本子專案讓系統能:辨識收款人身分、保存 W-9/TIN、依**已付**金額按年彙總、標示 $600 門檻與缺漏 W-9,並產出可交付的收款人聯(Copy B)PDF 與申報用資料檔。
|
||||
|
||||
**現況缺口:** 系統沒有收款人身分。廠商付款只存自由文字 `Expense.VendorName`(nullable, max 200);出納工作清單以該**字串**分組。沒有任何 W-9/TIN 資料,也無法把一整年付款依收款人加總。
|
||||
|
||||
**實際驅動案例:** 一位**兼職同工同時也是 Member**,以獨立承攬人身分受款,需開立 1099-NEC。
|
||||
|
||||
### 設計原則
|
||||
- **資料驅動、疊在現有分類軸之上**,沿用子專案 A 的「映射欄位 + 參考表」風格,不重寫分類樹。
|
||||
- 收款人身分以**獨立 master** 表達,與 Member **可選關聯**(不強耦合)。
|
||||
- 1099 應報與否需**兩個條件同時成立**:收款人被追蹤 + 該筆科目映射到 1099 box。
|
||||
- 員工(W-2/薪資)**不在範圍**(本系統無 payroll 模組)。
|
||||
- 向後相容:新增欄位皆 nullable,既有資料不破。
|
||||
|
||||
---
|
||||
|
||||
## 2. 資料模型變更
|
||||
|
||||
### 2.1 新表 `Payee1099`(收款人 master)— 繼承 `SoftDeleteEntity, IAuditable`
|
||||
檔案:`API/ROLAC.API/Entities/Payee1099.cs`
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|---|---|---|
|
||||
| Id | int PK | |
|
||||
| LegalName | varchar(200) NOT NULL | W-9 上的法定名稱 |
|
||||
| DisplayName | varchar(200)? | 友善 / DBA 名稱 |
|
||||
| MemberId | int? FK→Member (SetNull) | 收款人同時是 Member 時連結(兼職同工案例) |
|
||||
| TaxClassification | varchar(40) | Individual/SoleProprietor、Partnership、CCorp、SCorp、LLC、Other — 決定 `Is1099Tracked` 預設 |
|
||||
| Is1099Tracked | bool NOT NULL DEFAULT true | 可覆寫;公司(C/S Corp)預設 false |
|
||||
| TinType | varchar(10)? | "SSN" \| "EIN" |
|
||||
| TinEncrypted | text? | 經 Data Protection API 加密的 TIN |
|
||||
| TinLast4 | varchar(4)? | 遮罩顯示 / 搜尋用,免解密 |
|
||||
| AddressLine1/2, City, State, Zip | varchar | 1099 表單用地址 |
|
||||
| Email, Phone | varchar? | W-9 催收用 |
|
||||
| W9Status | varchar(20) DEFAULT 'Missing' | Missing \| Requested \| OnFile \| Expired |
|
||||
| W9ReceivedDate | DateOnly? | |
|
||||
| W9BlobPath | text? | 上傳的 W-9 PDF/影像(比照 `Expense.ReceiptBlobPath`) |
|
||||
| IsActive | bool DEFAULT true | |
|
||||
| Notes | text? | |
|
||||
| + audit + soft-delete | | 由 `SoftDeleteEntity` 提供 |
|
||||
|
||||
### 2.2 `Expense` 新增 `PayeeId int?` FK → Payee1099 (SetNull)
|
||||
檔案:`API/ROLAC.API/Entities/Expense.cs`。**表頭層**(一筆支出/一張支票 = 一位收款人,與 `Check.PayeeName` 一致)。與 `Type` 無關 — 外部廠商與「同工承攬人」皆適用。`VendorName` 仍保留為自由文字 fallback/snapshot。
|
||||
|
||||
### 2.3 新參考表 `Form1099Box` — 繼承 `AuditableEntity, IAuditable`(比照 `Form990ExpenseLine`)
|
||||
檔案:`API/ROLAC.API/Entities/Form1099Box.cs`
|
||||
- Id、BoxCode(unique,如 `"NEC-1"`、`"MISC-1"`)、Name_en、Name_zh?、FormType(`"1099-NEC"` | `"1099-MISC"`)、SortOrder、IsActive。
|
||||
- **seed 子集:** `NEC-1` Nonemployee compensation 非員工報酬;`MISC-1` Rents 租金。目錄可擴充。
|
||||
|
||||
### 2.4 映射欄位(完全比照 990-line 模式)
|
||||
- `ExpenseSubCategory.Form1099BoxId int?` FK → Form1099Box (SetNull)— **主要映射**
|
||||
- `ExpenseCategoryGroup.Form1099BoxId int?` FK — 大類 fallback
|
||||
|
||||
**有效 box = `sub ?? group ?? null`。** 與 990 不同(990 fallback 為 line 24,人人有歸屬);此處 **null = 不列入 1099** 才是預設 — 只有勞務性科目才給 box。
|
||||
|
||||
**預設 seed 映射(子項目 → box),僅列可報者:**
|
||||
- Personnel ▸ Honorarium → NEC-1
|
||||
- Personnel ▸ Contract Labor → NEC-1
|
||||
- Professional Services ▸ Legal / Accounting & Audit / Other Professional → NEC-1
|
||||
- Facility ▸ Rent → MISC-1
|
||||
- **其餘一律 unmapped(排除)。** Salary & Wages / Officer Compensation 維持 unmapped(那是 W-2 薪資,永不入 1099)— 即使被追蹤的收款人記在這些科目,box gate 也會擋下。
|
||||
|
||||
---
|
||||
|
||||
## 3. 報表層
|
||||
|
||||
新服務 `Form1099ReportService`(讀取為主,與 `Form990ReportService` 並列)。
|
||||
檔案:`API/ROLAC.API/Services/{IForm1099ReportService,Form1099ReportService}.cs`、DTOs `API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs`、controller `API/ROLAC.API/Controllers/Form1099ReportController.cs`。
|
||||
|
||||
```csharp
|
||||
Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear);
|
||||
Task<Form1099RecipientDetailDto> GetRecipientDetailAsync(int payeeId, int taxYear);
|
||||
Task<List<Form1099BoxDto>> GetBoxesAsync();
|
||||
```
|
||||
|
||||
**現金基礎查詢(與 990 報表不同):** `Status == "Paid"` 且 `PaidAt` 年份 == taxYear。(1099 報的是**該曆年實際支付**的金額,而非 990 報表採用的 Approved/ExpenseDate。`Expense.PaidAt` 為支付日;`Check.CheckDate` 為日後若要更精準的替代基準。)
|
||||
|
||||
**彙總邏輯:**
|
||||
1. Join 已付支出(PaidAt 落在該年、`PayeeId` 非 null)→ `ExpenseLines` → SubCategory/Group → 有效 box。
|
||||
2. 只保留 **有效 box ≠ null 且 `payee.Is1099Tracked`** 的行。
|
||||
3. 依 `(PayeeId, BoxCode)` 加總。
|
||||
4. 每位收款人:各 box 小計;`MeetsThreshold`(每 box ≥ **$600**,常數 `Form1099.ReportingThreshold`);`W9Missing`(`W9Status != "OnFile"`)。
|
||||
|
||||
**DTOs:**
|
||||
- `Form1099SummaryDto { TaxYear, Rows:[Form1099RecipientRowDto], TotalReportable, RecipientsAtThreshold, RecipientsMissingW9 }`
|
||||
- `Form1099RecipientRowDto { PayeeId, LegalName, TinLast4, W9Status, NecTotal, RentsTotal, GrandTotal, MeetsThreshold, W9Missing }`
|
||||
- `Form1099RecipientDetailDto { 收款人表頭 + 構成付款明細: [date, description, categoryName, boxCode, amount] }`
|
||||
|
||||
---
|
||||
|
||||
## 4. TIN 加密
|
||||
|
||||
採用 ASP.NET Core **Data Protection API**(`IDataProtectionProvider.CreateProtector("Payee1099.Tin")`)— 可逆、由框架管理金鑰、不引入新加密相依。寫入時加密;另存 `TinLast4` 供顯示/搜尋。完整 TIN 解密僅透過專屬 endpoint,並以本模組 **Write** action 把關;其餘一律遮罩(`***-**-1234`)。
|
||||
|
||||
## 5. 1099-NEC Copy B PDF + 申報資料匯出
|
||||
|
||||
新服務 `I1099FormService`,沿用 DevExpress 管道(`ICheckPrintService` / `DevExpress.Document.Processor`;授權檔已設定,見 [[project-devexpress-check-printing]])。產出**收款人聯 Copy B** 1099-NEC(payer = `ChurchProfile`、recipient = `Payee1099`、box 1 = NEC 合計),純白紙列印。另產出供 IRIS/會計師用的**申報資料 CSV/試算表**。不含 IRS 傳輸。
|
||||
|
||||
## 6. 權限
|
||||
|
||||
`API/ROLAC.API/Authorization/Modules.cs`(+ `Modules.All`)新增模組 `Form1099`,並同步前端 `PermissionModules`(`APP/src/app/core/models/permission.model.ts`)。Actions:Read(收款人 + 報表)、Write(編輯收款人、連結 payee、顯示完整 TIN)、Delete。seed 財務角色之 RolePermission;super_admin 自動 bypass。
|
||||
|
||||
## 7. 前端(Angular,admin)
|
||||
|
||||
慣例:`UserPortalComponent` 財務導覽群組 + `app.routes.ts` 路由 data(`title/titleZh/section` + `PermissionGuard`)、unified header(`appPageHeaderActions`)、Kendo UI、Tailwind 表單版面、行動裝置 `hidden md:block` + `md:hidden` 卡片。([[project-real-sidebar-nav]]、[[project-unified-system-header]]、[[feedback-mobile-friendly-all-screens]]、[[feedback-form-layout-tailwind]])
|
||||
|
||||
1. **1099 收款人維護頁**(`features/payee1099/pages/payee-1099-page`)— Kendo Grid(LegalName、member 連結、分類、TIN 末四碼遮罩、W-9 狀態徽章、Tracked 開關、Active);右鍵 context menu Edit/Deactivate;編輯對話框含 W-9 欄位 + Member 選擇器 + 遮罩 TIN 輸入 + W-9 上傳;行動卡片。比照 `expense-categories-page`。
|
||||
2. **科目 → box 映射** — 擴充現有 `expense-categories-page`,在既有 990-line 下拉旁加一個「1099 Box」下拉(大類/子項目皆可設 `Form1099BoxId`)。`[valuePrimitive]="true"`([[feedback-kendo-value-primitive]])。
|
||||
3. **支出表單**(`expense-form-dialog`)— 新增可選「1099 收款人」payee 選擇器(DropdownList、`valuePrimitive`)。
|
||||
4. **1099 年度報表頁**(`features/finance-report/pages/form1099-report-page`)— 年度選擇器;收款人 grid(NEC/Rents 合計、門檻旗標、缺 W-9 旗標);下鑽收款人明細(構成付款,[[feedback-kendo-table-select-via-row-click]]);header actions「匯出申報資料」+「產生 Copy B PDF」。行動卡片。比照 `form990-report-page`。
|
||||
|
||||
## 8. Migration / 落地
|
||||
|
||||
- EF migration:新表 `Payee1099s`、`Form1099Boxes`;新欄 `Expenses.PayeeId`、`ExpenseSubCategories.Form1099BoxId`、`ExpenseCategoryGroups.Form1099BoxId`(FK、SetNull)。
|
||||
- `DbSeeder`:seed `Form1099Box` 目錄 + 子項目→box 映射(**只填 NULL**,比照 `SeedForm990ExpenseLinesAsync` 的冪等性;不得覆蓋 admin 編輯)。無 catch-all fallback(unmapped = 不列入)。
|
||||
- 同步更新 `docs/DB_SCHEMA.md`(新表 + 新欄)。
|
||||
- v1 **不**自動把既有自由文字 `VendorName` 回填成 master(教會規模小,手動連結即可)。列為已知後續。
|
||||
|
||||
---
|
||||
|
||||
## 9. 測試
|
||||
|
||||
沿用既有測試模式(`ExpenseServiceTests` 等;Release build,見 [[project-build-run-env]]):
|
||||
- `EffectiveBox` 解析:子項目 ?? 大類 ?? null。
|
||||
- 報表現金基礎:Paid + PaidAt 年份;每收款人/每 box 加總正確。
|
||||
- 門檻:恰 $600 觸發旗標;缺 W-9 旗標正確。
|
||||
- `Is1099Tracked` gate;員工薪資科目被排除;同工(member-linked)收款人正確加總。
|
||||
- TIN 加解密 round-trip + 末四碼 + 遮罩。
|
||||
|
||||
## 10. 不在此範圍(已知缺口)
|
||||
|
||||
- IRS 電子申報(IRIS/FIRE)整合。
|
||||
- 官方 Copy A / 1096 表單(v1 僅 Copy B + 資料匯出)。
|
||||
- payroll / W-2(員工)。
|
||||
- 既有 `VendorName` → master 自動回填。
|
||||
- 可設定門檻(v1 以常數)。
|
||||
|
||||
## 11. 驗收標準
|
||||
|
||||
1. 可在收款人維護頁建立 `Payee1099`(含 W-9/TIN,TIN 遮罩),並可連結 Member。
|
||||
2. 支出可選填 1099 收款人;科目可設 1099 box;映射採子項目優先、大類 fallback、否則不列入。
|
||||
3. 年度報表依**已付**金額按收款人 × box 加總,正確標示 $600 門檻與缺 W-9;可下鑽明細。
|
||||
4. 完整 TIN 僅在具 Write 權限時可揭示;其餘遮罩為末四碼。
|
||||
5. 可產出收款人聯 Copy B 1099-NEC PDF(無 DevExpress 浮水印)與申報資料 CSV。
|
||||
6. 員工薪資科目即使付給被追蹤收款人,也不出現在 1099 報表;既有支出資料不受影響。
|
||||
Reference in New Issue
Block a user