Compare commits

...

35 Commits

Author SHA1 Message Date
Chris Chen d987ddea0e Merge: 1099 Recipient Tracking feature (sub-project B)
ci-cd-vm / ci-cd (push) Successful in 2m57s
Adds a Payee1099 recipient master (encrypted TIN, W-9, optional Member
link), Form1099Box catalog + category->box mapping, a cash-basis year-end
1099 report (per-recipient x box, $600 + missing-W9 flags), recipient
Copy B 1099-NEC PDF + filing CSV, W-9 upload, write-gated TIN reveal, and
a ChurchProfile payer EIN. New Form1099 permission module + admin pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:29:29 -07:00
Chris Chen a4ded78442 feat(1099): show payer EIN on church profile page and 1099-NEC PDF
Populate the PAYER's TIN (EIN) box in Form1099FormService.BuildCopyBHtml
from ChurchProfile.PayerEin (blank when null, matching prior behaviour).
Add payerEin field to ChurchProfileDto TS model (flows into
UpdateChurchProfileRequest via the existing Omit type) and a text input on
the Church Info tab of the church-profile settings page, mirroring the
Routing # field pattern. CSV left unchanged — adding a payer context line
would break the existing ExportFilingCsvAsync assertion (3 lines expected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:23:49 -07:00
Chris Chen 831b868d9d feat(1099): add payer EIN to ChurchProfile (entity, DTO, migration)
Add PayerEin (nullable string, max 20) to ChurchProfile entity, AppDbContext
config, ChurchProfileDto response, UpdateChurchProfileRequest, and
ChurchProfileService round-trip — mirroring the Phone/BankRoutingNumber
nullable-string pattern. Migration AddPayerEinToChurchProfile adds only the
one nullable column to ChurchProfiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:23:40 -07:00
Chris Chen 771889a99a feat(1099): default Is1099Tracked from tax classification
In the recipient dialog, changing tax classification on a NEW record sets the
1099-tracked default: CCorp/SCorp default to NOT tracked (spec §2.1/§2.3),
others to tracked. Only applies until the user manually toggles it; never
overrides an explicit choice or an existing saved value on edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:11:44 -07:00
Chris Chen 4d396601f7 feat(1099): add reveal-full-TIN button (write-gated) to recipient dialog
Wires the existing GET payee-1099/{id}/tin endpoint into the edit dialog:
a "Reveal full TIN" button guarded by *appHasPermission Form1099:write that
fetches and displays the decrypted TIN read-only. Satisfies acceptance
criterion #11.4. The value is never logged or persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:11:32 -07:00
Chris Chen d29de83116 feat(1099): wire W-9 document upload/view for recipients
Adds POST/GET payee-1099/{id}/w9, mirroring the expense-receipt upload:
IFileStorage saves to finance/w9/{id}{ext}, content-type derived from the
blob extension. Frontend dialog (edit mode) gains a W-9 file input and an
auth-correct blob "View W-9" link. Payee1099Service ctor now takes
IFileStorage; tests updated with an in-memory FakeStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:11:11 -07:00
Chris Chen ad276c01f3 docs(1099): document Payee1099s/Form1099Boxes schema and seed Form1099 permissions
- DB_SCHEMA.md §8: add Form1099Box catalog table, Payee1099 recipient master
  (with TIN at-rest encryption note), and new FK columns on Expenses /
  ExpenseSubCategories / ExpenseCategoryGroups; update TOC and Seed Data section
- DbSeeder.cs: grant Modules.Form1099 to finance (R/W/D), pastor (R), and
  board_member (R), mirroring the Form990Report + Disbursements pattern;
  idempotent (only inserts if row absent, never clobbers admin edits)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:56:09 -07:00
Chris Chen fb95bf0048 feat(1099): add 1099 recipient picker to expense form
Add optional payeeId to CreateExpenseRequest + ExpenseListItemDto
frontend models. In the expense form dialog: inject Payee1099ApiService,
load active payees on init, add payeeId to the form state, pre-populate
it from expense.payeeId in edit mode, and include it in the emitSave
payload. Render a "1099 Recipient / 1099 收款人" Kendo DropdownList
(textField=legalName, valueField=id, [valuePrimitive]="true",
md:col-span-2) inside the vendor-mode ng-container below Vendor Name
and Check #.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:50:25 -07:00
Chris Chen d8e6f3ed61 feat(1099): add 1099 box dropdowns to category admin page
Mirror the 990-line dropdown in both the group and subcategory edit
dialogs: add form1099BoxId to the frontend group/subcategory DTOs and
request interfaces, load boxes via a new getForm1099Boxes() method on
ExpenseCategoryApiService (same label pattern as getForm990Lines:
"boxCode — name_en / name_zh"), wire form1099BoxId into all
open/edit/save paths, and render a side-by-side "1099 Box / 1099 框"
Kendo DropdownList with [valuePrimitive]="true" and "— none —" default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:50:12 -07:00
Chris Chen 402826ee3d feat(1099): round-trip Form1099BoxId through expense category DTOs/service
Mirror Form990LineId: add Form1099BoxId + Form1099BoxCode to all four
category DTOs (response + request, group + sub); load a boxCodes lookup
dictionary in GetAllAsync and project it; set/copy the field in
CreateGroupAsync, UpdateGroupAsync, CreateSubCategoryAsync, and
UpdateSubCategoryAsync. All 4 category-service unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:46:31 -07:00
Chris Chen 82096e7e6f feat(1099): 1099 year-end report page with drill-in, CSV, Copy B
Add Form1099ReportPageComponent (year selector, summary chips with a
prominent missing-W-9 flag, desktop grid + mobile cards, recipient detail
dialog). Per-row Copy B PDF via right-click context menu and a header
Export filing CSV action, both downloaded as auth-correct blobs. Wire the
eager route + sidebar nav item, gated on Form1099:read. Also convert the
neighboring finance/payee-1099 route from lazy loadComponent to an eager
component import so both 1099 routes match the surrounding convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:39:20 -07:00
Chris Chen 6ffaaf37ac feat(1099): add authenticated blob downloads to report API service
Add downloadCsv/downloadCopyB returning Blob via HttpClient so the auth
interceptor attaches the bearer token (raw window.open would 401). Remove
the now-unused copyBUrl/exportCsvUrl raw-URL builders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:38:47 -07:00
Chris Chen d1747b510e feat(1099): 1099 recipients master page with nav + route
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:31:48 -07:00
Chris Chen bf247726e1 feat(1099): frontend models, API services, and permission module entry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:26:57 -07:00
Chris Chen 8cb6245560 feat(1099): add 1099 Copy B + filing CSV download endpoints
Injects I1099FormService into Form1099ReportController and adds two
Read-gated GET endpoints: recipient/{payeeId}/copy-b (Copy B PDF) and
export-csv (filing-data CSV). Registers Form1099FormService in DI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:21:14 -07:00
Chris Chen b7eb95056d feat(1099): add I1099FormService with filing CSV export + Copy B PDF
Adds I1099FormService and Form1099FormService: an IRIS/accountant filing-data
CSV (one row per reportable recipient) and a plain-paper recipient Copy B
1099-NEC PDF rendered via the DevExpress RichEdit/Office API (mirroring
CheckPrintService). Includes a CSV-export unit test over a stub report service.

Service lives in namespace ROLAC.API.Services (not ...Services.Form1099) to
avoid shadowing the ROLAC.API.Entities.Form1099 constants class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:21:04 -07:00
Chris Chen 556abba687 feat(1099): EF migration for Payee1099, Form1099Box, mapping columns
Creates Form1099Boxes and Payee1099s tables; adds Form1099BoxId to
ExpenseSubCategories and ExpenseCategoryGroups; adds PayeeId to
Expenses. All new columns nullable, all FKs with SetNull, unique index
on Form1099Boxes.BoxCode. No data backfill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:14:57 -07:00
Chris Chen 1a8002015a feat(1099): seed Form1099Box catalog and default subcategory mappings
Adds Form1099BoxSeed (NEC-1, MISC-1) and Form1099SubMappingSeed
(6 service/rent subcategories), SeedForm1099BoxesAsync method with
null-fill idempotency (never clobbers admin edits), and wires it into
SeedAsync after SeedForm990ExpenseLinesAsync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:13:44 -07:00
Chris Chen 7c63f6c9ba feat(1099): carry PayeeId through expense create/update/read
Add int? PayeeId to CreateExpenseRequest (UpdateExpenseRequest inherits)
and to ExpenseListItemDto (so it round-trips to the form). Set e.PayeeId
unconditionally in CreateAsync and UpdateAsync so 1099 attribution is
independent of VendorPayment vs StaffReimbursement type. Map PayeeId in
both DTO projections: the paged-list lambda and GetByIdAsync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:08:01 -07:00
Chris Chen 7c5348969b feat(1099): add recipient and report controllers
Payee1099Controller (api/payee-1099): CRUD + TIN reveal, class-level
Read gate, method-level Write/Delete overrides — mirrors the
HasPermission class+method stacking pattern from ExpensesController.
Form1099ReportController (api/form1099-report): boxes, annual summary,
and per-recipient detail; read-only, no method-level overrides needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:06:49 -07:00
Chris Chen 0a9b82544d feat(1099): register Form1099 permission module and services
Add Form1099 const to Modules.cs (after Form990Report) and insert it
into the All display-order list. Register IForm1099ReportService and
IPayee1099Service in Program.cs beside the existing Form990Report entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:06:03 -07:00
Chris Chen 6080946e74 feat(1099): add Payee1099Service recipient CRUD with TIN protection
Implement IPayee1099Service and Payee1099Service: list/get/create/update/
soft-delete and RevealTin. TIN is encrypted via ITinProtector on write;
TinLast4 is the only clear-text fragment stored. Null Tin on update
preserves the existing ciphertext. Four xUnit tests cover encrypt-on-create,
null-tin-keeps-ciphertext, list-masks-to-last4, and soft-delete hides from list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:02:45 -07:00
Chris Chen 560fb79bf0 feat(1099): add recipient DTOs
Add Payee1099ListItemDto, Payee1099Dto, and SavePayee1099Request in
DTOs/Payee for the 1099 recipient CRUD surface.

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