Compare commits
53 Commits
73077295a4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b949dff9b | |||
| 773d38d838 | |||
| d987ddea0e | |||
| a4ded78442 | |||
| 831b868d9d | |||
| 771889a99a | |||
| 4d396601f7 | |||
| d29de83116 | |||
| ad276c01f3 | |||
| fb95bf0048 | |||
| d8e6f3ed61 | |||
| 402826ee3d | |||
| 82096e7e6f | |||
| 6ffaaf37ac | |||
| d1747b510e | |||
| bf247726e1 | |||
| 8cb6245560 | |||
| b7eb95056d | |||
| 556abba687 | |||
| 1a8002015a | |||
| 7c63f6c9ba | |||
| 7c5348969b | |||
| 0a9b82544d | |||
| 6080946e74 | |||
| 560fb79bf0 | |||
| 0767a3fe94 | |||
| 0754ed8d69 | |||
| 9aa64b5f4c | |||
| 5e2fbe800c | |||
| 89238bba99 | |||
| 225e64b992 | |||
| 7809ba9741 | |||
| 48ae014def | |||
| 89f02d020b | |||
| 3b76ff43fc | |||
| a0b96b056a | |||
| 93374c3c0a | |||
| 55543af5e1 | |||
| d32eea3523 | |||
| 099303995b | |||
| 44a7dcf089 | |||
| a8f5547c3c | |||
| 41dce076d6 | |||
| 315d85ddcc | |||
| bc827e8b60 | |||
| 8922bb69de | |||
| 4877fec1da | |||
| 73c52ded88 | |||
| f1de8d7ab7 | |||
| 5957d0f45e | |||
| c5405a95c3 | |||
| 5d03e42302 | |||
| d4c20df34f |
@@ -22,6 +22,7 @@
|
|||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb(string userId)
|
||||||
|
{
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(mock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1")
|
||||||
|
{
|
||||||
|
var db = BuildDb(userId);
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return (new ExpenseSnapshotService(db, http.Object), db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateExpenseSnapshotRequest Rent() => new()
|
||||||
|
{
|
||||||
|
Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X",
|
||||||
|
CheckNumber = "1001",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PersistsHeaderAndLines_StampsCreator()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("creator-1");
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
var saved = await db.ExpenseSnapshots.FindAsync(id);
|
||||||
|
Assert.Equal("Monthly Rent", saved!.Name);
|
||||||
|
Assert.Equal("creator-1", saved.CreatedBy);
|
||||||
|
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithNoLines_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var r = Rent(); r.Lines.Clear();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithInvalidFunctionalClass_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var r = Rent();
|
||||||
|
r.Lines[0].FunctionalClass = "NotAClass";
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_ReturnsLines_TotalsAndCreatorName()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("creator-1");
|
||||||
|
db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" });
|
||||||
|
db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.Equal(1200m, dto!.TotalAmount);
|
||||||
|
Assert.Equal(1, dto.LineCount);
|
||||||
|
Assert.Equal("Rent", dto.Lines.Single().SubCategoryName);
|
||||||
|
Assert.Equal("Joy Wong", dto.CreatedByName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAll_ReturnsNewestFirst()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var first = await svc.CreateAsync(Rent());
|
||||||
|
var second = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
var all = await svc.GetAllAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, all.Count);
|
||||||
|
Assert.Equal(second, all[0].Id);
|
||||||
|
Assert.Equal(first, all[1].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_RenamesAndReplacesLines()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
Name = "Monthly Internet", MinistryId = 1, Description = "ISP",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } },
|
||||||
|
});
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal("Monthly Internet", dto!.Name);
|
||||||
|
Assert.Equal(80m, dto.TotalAmount);
|
||||||
|
Assert.Equal("Internet", dto.Lines.Single().SubCategoryName);
|
||||||
|
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_MissingId_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
Name = "x", MinistryId = 1, Description = "x",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_SoftDeletes_HidesFromQueries()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
|
||||||
|
Assert.Empty(await svc.GetAllAsync());
|
||||||
|
Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_StampsDeletedBy()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("deleter-1");
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id);
|
||||||
|
Assert.Equal("deleter-1", row.DeletedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class Form1099FormServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export.</summary>
|
||||||
|
private sealed class StubReportService : IForm1099ReportService
|
||||||
|
{
|
||||||
|
private readonly Form1099SummaryDto _summary;
|
||||||
|
public StubReportService(Form1099SummaryDto summary) => _summary = summary;
|
||||||
|
|
||||||
|
public Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary);
|
||||||
|
public Task<List<Form1099BoxDto>> GetBoxesAsync() => throw new NotImplementedException();
|
||||||
|
public Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
|
||||||
|
=> throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Form1099FormService BuildService(Form1099SummaryDto summary) =>
|
||||||
|
// IPayee1099Service and AppDbContext are only used by RenderCopyBAsync, not by the CSV path.
|
||||||
|
new Form1099FormService(new StubReportService(summary), payees: null!, db: null!);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExportFilingCsvAsync_WritesHeaderRowPerRecipientAndInvariantNumbers()
|
||||||
|
{
|
||||||
|
var summary = new Form1099SummaryDto
|
||||||
|
{
|
||||||
|
TaxYear = 2026,
|
||||||
|
Rows =
|
||||||
|
{
|
||||||
|
new Form1099RecipientRowDto
|
||||||
|
{
|
||||||
|
PayeeId = 1, LegalName = "Acme, LLC", TinLast4 = "1234", W9Status = "OnFile",
|
||||||
|
NecTotal = 1234.50m, RentsTotal = 0m, GrandTotal = 1234.50m, MeetsThreshold = true
|
||||||
|
},
|
||||||
|
new Form1099RecipientRowDto
|
||||||
|
{
|
||||||
|
PayeeId = 2, LegalName = "Bob Smith", TinLast4 = "9876", W9Status = "Missing",
|
||||||
|
NecTotal = 100m, RentsTotal = 50m, GrandTotal = 150m, MeetsThreshold = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = BuildService(summary);
|
||||||
|
var (stream, contentType, fileName) = await service.ExportFilingCsvAsync(2026);
|
||||||
|
|
||||||
|
Assert.Equal("text/csv", contentType);
|
||||||
|
Assert.Equal("1099-filing-2026.csv", fileName);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
var text = await reader.ReadToEndAsync();
|
||||||
|
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Header + one data line per row.
|
||||||
|
Assert.Equal(3, lines.Length);
|
||||||
|
Assert.Equal("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold", lines[0]);
|
||||||
|
|
||||||
|
// A value containing a comma is quoted.
|
||||||
|
Assert.StartsWith("\"Acme, LLC\",1234,OnFile,", lines[1]);
|
||||||
|
|
||||||
|
// Invariant numeric formatting (period decimal separator) and Y/N threshold flag.
|
||||||
|
Assert.Contains("1234.50", lines[1]);
|
||||||
|
Assert.EndsWith(",Y", lines[1]);
|
||||||
|
Assert.EndsWith(",N", lines[2]);
|
||||||
|
|
||||||
|
// Sanity: the period really is the invariant separator regardless of current culture.
|
||||||
|
Assert.Equal("1234.50", 1234.50m.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class Form1099ReportServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
|
||||||
|
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||||
|
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId)
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" });
|
||||||
|
var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 };
|
||||||
|
var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 };
|
||||||
|
db.Form1099Boxes.AddRange(nec, rent);
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null });
|
||||||
|
db.SaveChanges();
|
||||||
|
necSubId = 1; rentSubId = 2; salarySubId = 3;
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn)
|
||||||
|
{
|
||||||
|
var e = new Expense
|
||||||
|
{
|
||||||
|
MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId,
|
||||||
|
Amount = amount, Description = "x", ExpenseDate = paidOn,
|
||||||
|
PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
|
||||||
|
Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ],
|
||||||
|
};
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9()
|
||||||
|
{
|
||||||
|
var db = Seeded(out var necSub, out var rentSub, out _);
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" });
|
||||||
|
db.SaveChanges();
|
||||||
|
AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1));
|
||||||
|
AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var svc = new Form1099ReportService(db);
|
||||||
|
var sum = await svc.GetAnnualSummaryAsync(2026);
|
||||||
|
|
||||||
|
var row = Assert.Single(sum.Rows);
|
||||||
|
Assert.Equal(700m, row.NecTotal);
|
||||||
|
Assert.Equal(500m, row.RentsTotal);
|
||||||
|
Assert.Equal(1200m, row.GrandTotal);
|
||||||
|
Assert.True(row.MeetsThreshold);
|
||||||
|
Assert.True(row.W9Missing);
|
||||||
|
Assert.Equal(1, sum.RecipientsAtThreshold);
|
||||||
|
Assert.Equal(1, sum.RecipientsMissingW9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year()
|
||||||
|
{
|
||||||
|
var db = Seeded(out var necSub, out _, out var salarySub);
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" });
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" });
|
||||||
|
db.SaveChanges();
|
||||||
|
AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked
|
||||||
|
AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box
|
||||||
|
AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year
|
||||||
|
|
||||||
|
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
|
||||||
|
Assert.Empty(sum.Rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Threshold_flag_is_false_below_600()
|
||||||
|
{
|
||||||
|
var db = Seeded(out var necSub, out _, out _);
|
||||||
|
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" });
|
||||||
|
db.SaveChanges();
|
||||||
|
AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1));
|
||||||
|
|
||||||
|
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
|
||||||
|
var row = Assert.Single(sum.Rows);
|
||||||
|
Assert.False(row.MeetsThreshold);
|
||||||
|
Assert.False(row.W9Missing);
|
||||||
|
Assert.Equal(0, sum.RecipientsAtThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class Payee1099ServiceTests
|
||||||
|
{
|
||||||
|
// Minimal in-memory IFileStorage (mirrors the ExpenseServiceTests fake).
|
||||||
|
private sealed class FakeStorage : IFileStorage
|
||||||
|
{
|
||||||
|
public Dictionary<string, byte[]> Files = new();
|
||||||
|
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
|
||||||
|
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
|
||||||
|
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
|
||||||
|
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Payee1099Service svc, AppDbContext db) Build()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||||
|
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||||
|
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||||
|
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).Options);
|
||||||
|
var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
||||||
|
return (new Payee1099Service(db, tin, new FakeStorage()), db);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request
|
||||||
|
{ LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });
|
||||||
|
|
||||||
|
var saved = await db.Payee1099s.FindAsync(id);
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
Assert.Equal("6789", saved!.TinLast4);
|
||||||
|
Assert.NotNull(saved.TinEncrypted);
|
||||||
|
Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
|
||||||
|
Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_with_null_tin_keeps_existing_ciphertext()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
|
||||||
|
var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;
|
||||||
|
|
||||||
|
await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });
|
||||||
|
|
||||||
|
var after = await db.Payee1099s.FindAsync(id);
|
||||||
|
Assert.Equal("X renamed", after!.LegalName);
|
||||||
|
Assert.Equal(before, after.TinEncrypted);
|
||||||
|
Assert.Equal("3333", after.TinLast4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_dto_masks_tin_to_last4()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
|
||||||
|
var list = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
var item = Assert.Single(list);
|
||||||
|
Assert.Equal("7777", item.TinLast4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_is_soft_and_hides_from_list()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
Assert.Empty(await svc.GetAllAsync(includeInactive: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveW9_records_document_and_round_trips_bytes()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "W9 Payee" });
|
||||||
|
|
||||||
|
var bytes = new byte[] { 1, 2, 3, 4, 5 };
|
||||||
|
await svc.SaveW9Async(id, new MemoryStream(bytes), "w9.pdf");
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.True(dto!.HasW9Document);
|
||||||
|
|
||||||
|
var opened = await svc.OpenW9Async(id);
|
||||||
|
Assert.NotNull(opened);
|
||||||
|
Assert.Equal("application/pdf", opened!.Value.contentType);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await opened.Value.stream.CopyToAsync(ms);
|
||||||
|
Assert.Equal(bytes, ms.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class TinProtectorTests
|
||||||
|
{
|
||||||
|
private static TinProtector Build() =>
|
||||||
|
new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Protect_then_Unprotect_round_trips()
|
||||||
|
{
|
||||||
|
var p = Build();
|
||||||
|
var cipher = p.Protect("123-45-6789");
|
||||||
|
Assert.NotEqual("123-45-6789", cipher);
|
||||||
|
Assert.Equal("123-45-6789", p.Unprotect(cipher));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("123-45-6789", "6789")]
|
||||||
|
[InlineData("12-3456789", "6789")]
|
||||||
|
[InlineData("7", "7")]
|
||||||
|
public void Last4_keeps_only_trailing_digits(string raw, string expected)
|
||||||
|
=> Assert.Equal(expected, TinProtector.Last4(raw));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null));
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public static class Modules
|
|||||||
public const string Ministries = "Ministries";
|
public const string Ministries = "Ministries";
|
||||||
public const string FinanceDashboard = "FinanceDashboard";
|
public const string FinanceDashboard = "FinanceDashboard";
|
||||||
public const string Form990Report = "Form990Report";
|
public const string Form990Report = "Form990Report";
|
||||||
|
public const string Form1099 = "Form1099";
|
||||||
public const string MonthlyStatements = "MonthlyStatements";
|
public const string MonthlyStatements = "MonthlyStatements";
|
||||||
public const string ChurchProfile = "ChurchProfile";
|
public const string ChurchProfile = "ChurchProfile";
|
||||||
public const string Disbursements = "Disbursements";
|
public const string Disbursements = "Disbursements";
|
||||||
@@ -39,6 +40,7 @@ public static class Modules
|
|||||||
Ministries,
|
Ministries,
|
||||||
FinanceDashboard,
|
FinanceDashboard,
|
||||||
Form990Report,
|
Form990Report,
|
||||||
|
Form1099,
|
||||||
MonthlyStatements,
|
MonthlyStatements,
|
||||||
ChurchProfile,
|
ChurchProfile,
|
||||||
Disbursements,
|
Disbursements,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
// Snapshots are reusable vendor-payment templates — a finance tool. Every action requires
|
||||||
|
// Expenses:Write (super_admin bypasses), matching who can create vendor payments.
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-snapshots")]
|
||||||
|
[Authorize]
|
||||||
|
public class ExpenseSnapshotsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseSnapshotService _svc;
|
||||||
|
private readonly IPermissionService _perms;
|
||||||
|
public ExpenseSnapshotsController(IExpenseSnapshotService svc, IPermissionService perms)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_perms = perms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||||
|
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
|
||||||
|
private async Task<bool> CanManageAsync() =>
|
||||||
|
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
return Ok(await _svc.GetAllAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/form1099-report")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Read)]
|
||||||
|
public class Form1099ReportController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IForm1099ReportService _svc;
|
||||||
|
private readonly I1099FormService _form;
|
||||||
|
public Form1099ReportController(IForm1099ReportService svc, I1099FormService form)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_form = form;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("boxes")]
|
||||||
|
public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());
|
||||||
|
|
||||||
|
[HttpGet("summary")]
|
||||||
|
public async Task<IActionResult> Summary([FromQuery] int taxYear)
|
||||||
|
=> Ok(await _svc.GetAnnualSummaryAsync(taxYear));
|
||||||
|
|
||||||
|
[HttpGet("recipient/{payeeId:int}")]
|
||||||
|
public async Task<IActionResult> Recipient(int payeeId, [FromQuery] int taxYear)
|
||||||
|
=> await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound();
|
||||||
|
|
||||||
|
[HttpGet("recipient/{payeeId:int}/copy-b")]
|
||||||
|
public async Task<IActionResult> CopyB(int payeeId, [FromQuery] int taxYear)
|
||||||
|
{
|
||||||
|
var (stream, contentType, fileName) = await _form.RenderCopyBAsync(payeeId, taxYear);
|
||||||
|
return File(stream, contentType, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export-csv")]
|
||||||
|
public async Task<IActionResult> ExportCsv([FromQuery] int taxYear)
|
||||||
|
{
|
||||||
|
var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear);
|
||||||
|
return File(stream, contentType, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/payee-1099")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Read)]
|
||||||
|
public class Payee1099Controller : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPayee1099Service _svc;
|
||||||
|
public Payee1099Controller(IPayee1099Service svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
=> await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
|
||||||
|
=> Ok(new { id = await _svc.CreateAsync(r) });
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
|
||||||
|
{ await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Delete)]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{ await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
|
||||||
|
// Full TIN reveal is gated on Write (a stronger right than Read).
|
||||||
|
[HttpGet("{id:int}/tin")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> RevealTin(int id)
|
||||||
|
=> Ok(new { tin = await _svc.RevealTinAsync(id) });
|
||||||
|
|
||||||
|
// Mirrors the expense-receipt upload: multipart form file, size-limited, type-checked.
|
||||||
|
[HttpPost("{id:int}/w9")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
[RequestSizeLimit(10_485_760)]
|
||||||
|
public async Task<IActionResult> UploadW9(int id, IFormFile file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||||
|
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
|
||||||
|
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
await _svc.SaveW9Async(id, stream, file.FileName);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class-level Read gate covers viewing the stored W-9 (mirrors the receipt GET).
|
||||||
|
[HttpGet("{id:int}/w9")]
|
||||||
|
public async Task<IActionResult> GetW9(int id)
|
||||||
|
{
|
||||||
|
var result = await _svc.OpenW9Async(id);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public class ChurchProfileDto
|
|||||||
public string? BankName { get; set; }
|
public string? BankName { get; set; }
|
||||||
public string? BankAccountNumber { get; set; }
|
public string? BankAccountNumber { get; set; }
|
||||||
public string? BankRoutingNumber { get; set; }
|
public string? BankRoutingNumber { get; set; }
|
||||||
|
public string? PayerEin { get; set; }
|
||||||
public int NextCheckNumber { get; set; }
|
public int NextCheckNumber { get; set; }
|
||||||
public string AiProvider { get; set; } = "Claude";
|
public string AiProvider { get; set; } = "Claude";
|
||||||
public string? ClaudeModel { get; set; }
|
public string? ClaudeModel { get; set; }
|
||||||
@@ -38,6 +39,7 @@ public class UpdateChurchProfileRequest
|
|||||||
[MaxLength(200)] public string? BankName { get; set; }
|
[MaxLength(200)] public string? BankName { get; set; }
|
||||||
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
||||||
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
||||||
|
[MaxLength(20)] public string? PayerEin { get; set; }
|
||||||
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
||||||
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
|
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
|
||||||
[MaxLength(100)] public string? ClaudeModel { get; set; }
|
[MaxLength(100)] public string? ClaudeModel { get; set; }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ public class ExpenseSubCategoryDto
|
|||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public string? Form990LineCode { get; set; }
|
public string? Form990LineCode { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
|
public string? Form1099BoxCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseCategoryGroupDto
|
public class ExpenseCategoryGroupDto
|
||||||
@@ -22,6 +24,8 @@ public class ExpenseCategoryGroupDto
|
|||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public string? Form990LineCode { get; set; }
|
public string? Form990LineCode { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
|
public string? Form1099BoxCode { get; set; }
|
||||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ public class CreateExpenseGroupRequest
|
|||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||||
{
|
{
|
||||||
@@ -44,6 +49,7 @@ public class CreateExpenseSubCategoryRequest
|
|||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ public class ExpenseListItemDto
|
|||||||
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
|
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
|
||||||
public string? VendorName { get; set; }
|
public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
public string? MemberName { get; set; }
|
public string? MemberName { get; set; } // legal name "FirstName_en LastName_en" (used on the printed check)
|
||||||
|
public string? MemberNickName { get; set; } // "NickName LastName_en"; null when the member has no distinct nickname
|
||||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
public bool HasReceipt { get; set; }
|
public bool HasReceipt { get; set; }
|
||||||
public string? CheckNumber { get; set; }
|
public string? CheckNumber { get; set; }
|
||||||
@@ -34,6 +35,7 @@ public class ExpenseListItemDto
|
|||||||
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
|
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
|
||||||
public DateTimeOffset? ReviewedAt { get; set; }
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
public string? ReviewNotes { get; set; } // reject reason (or approval note)
|
public string? ReviewNotes { get; set; } // reject reason (or approval note)
|
||||||
|
public int? PayeeId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseDto : ExpenseListItemDto
|
public class ExpenseDto : ExpenseListItemDto
|
||||||
@@ -65,6 +67,7 @@ public class CreateExpenseRequest
|
|||||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
[Required] public DateOnly ExpenseDate { get; set; }
|
[Required] public DateOnly ExpenseDate { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public int? PayeeId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotLineDto
|
||||||
|
{
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public string CategoryGroupName { get; set; } = "";
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string SubCategoryName { get; set; } = "";
|
||||||
|
public string? FunctionalClass { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExpenseSnapshotDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public int MinistryId { get; set; }
|
||||||
|
public string MinistryName { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; } // sum of line amounts (list hint)
|
||||||
|
public int LineCount { get; set; }
|
||||||
|
public string? CreatedByName { get; set; } // resolved Member full name, email fallback
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public List<ExpenseSnapshotLineDto> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(150)] public string Name { get; set; } = "";
|
||||||
|
[Required] public int MinistryId { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
|
||||||
|
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? VendorName { get; set; }
|
||||||
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
public class UpdateExpenseSnapshotRequest : CreateExpenseSnapshotRequest { }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Finance;
|
||||||
|
|
||||||
|
public class Form1099BoxDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string BoxCode { get; set; } = "";
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public string FormType { get; set; } = "";
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099RecipientRowDto
|
||||||
|
{
|
||||||
|
public int PayeeId { get; set; }
|
||||||
|
public string LegalName { get; set; } = "";
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string W9Status { get; set; } = "";
|
||||||
|
public decimal NecTotal { get; set; }
|
||||||
|
public decimal RentsTotal { get; set; }
|
||||||
|
public decimal GrandTotal { get; set; }
|
||||||
|
public bool MeetsThreshold { get; set; }
|
||||||
|
public bool W9Missing { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099SummaryDto
|
||||||
|
{
|
||||||
|
public int TaxYear { get; set; }
|
||||||
|
public List<Form1099RecipientRowDto> Rows { get; set; } = [];
|
||||||
|
public decimal TotalReportable { get; set; }
|
||||||
|
public int RecipientsAtThreshold { get; set; }
|
||||||
|
public int RecipientsMissingW9 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099PaymentDto
|
||||||
|
{
|
||||||
|
public string PaidDate { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
public string BoxCode { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Form1099RecipientDetailDto
|
||||||
|
{
|
||||||
|
public int PayeeId { get; set; }
|
||||||
|
public string LegalName { get; set; } = "";
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string W9Status { get; set; } = "";
|
||||||
|
public int TaxYear { get; set; }
|
||||||
|
public List<Form1099PaymentDto> Payments { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Payee;
|
||||||
|
|
||||||
|
public class Payee1099ListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string LegalName { get; set; } = "";
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public string TaxClassification { get; set; } = "";
|
||||||
|
public bool Is1099Tracked { get; set; }
|
||||||
|
public string? TinType { get; set; }
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string W9Status { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Payee1099Dto : Payee1099ListItemDto
|
||||||
|
{
|
||||||
|
public string? AddressLine1 { get; set; }
|
||||||
|
public string? AddressLine2 { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? W9ReceivedDate { get; set; }
|
||||||
|
public bool HasW9Document { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SavePayee1099Request
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string LegalName { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? DisplayName { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
[Required, MaxLength(40)] public string TaxClassification { get; set; } = "Individual";
|
||||||
|
public bool Is1099Tracked { get; set; } = true;
|
||||||
|
[MaxLength(10)] public string? TinType { get; set; }
|
||||||
|
/// <summary>Plain TIN; null = leave unchanged on update. Encrypted server-side.</summary>
|
||||||
|
public string? Tin { get; set; }
|
||||||
|
[MaxLength(100)] public string? AddressLine1 { get; set; }
|
||||||
|
[MaxLength(100)] public string? AddressLine2 { get; set; }
|
||||||
|
[MaxLength(60)] public string? City { get; set; }
|
||||||
|
[MaxLength(2)] public string? State { get; set; }
|
||||||
|
[MaxLength(10)] public string? Zip { get; set; }
|
||||||
|
[MaxLength(120)] public string? Email { get; set; }
|
||||||
|
[MaxLength(40)] public string? Phone { get; set; }
|
||||||
|
[MaxLength(20)] public string W9Status { get; set; } = "Missing";
|
||||||
|
public DateOnly? W9ReceivedDate { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -21,8 +21,12 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||||
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||||||
|
public DbSet<Payee1099> Payee1099s => Set<Payee1099>();
|
||||||
|
public DbSet<Form1099Box> Form1099Boxes => Set<Form1099Box>();
|
||||||
public DbSet<Expense> Expenses => Set<Expense>();
|
public DbSet<Expense> Expenses => Set<Expense>();
|
||||||
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||||
|
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
|
||||||
|
public DbSet<ExpenseSnapshotLine> ExpenseSnapshotLines => Set<ExpenseSnapshotLine>();
|
||||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||||
public DbSet<Check> Checks => Set<Check>();
|
public DbSet<Check> Checks => Set<Check>();
|
||||||
@@ -216,6 +220,45 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.HasIndex(e => e.LineCode).IsUnique();
|
entity.HasIndex(e => e.LineCode).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Form1099Box (1099 reporting box catalog) ──────────────────────────
|
||||||
|
builder.Entity<Form1099Box>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.BoxCode).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.FormType).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasIndex(e => e.BoxCode).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Payee1099 (1099 recipient master) ────────────────────────────────
|
||||||
|
builder.Entity<Payee1099>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(p => !p.IsDeleted);
|
||||||
|
entity.Property(e => e.LegalName).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.DisplayName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.TaxClassification).HasMaxLength(40).IsRequired();
|
||||||
|
entity.Property(e => e.TinType).HasMaxLength(10);
|
||||||
|
entity.Property(e => e.TinLast4).HasMaxLength(4);
|
||||||
|
entity.Property(e => e.State).HasMaxLength(2);
|
||||||
|
entity.Property(e => e.Zip).HasMaxLength(10);
|
||||||
|
entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing);
|
||||||
|
entity.Property(e => e.AddressLine1).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.AddressLine2).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.City).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Phone).HasMaxLength(30);
|
||||||
|
entity.Property(e => e.Notes).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.W9BlobPath).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.TinEncrypted).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
||||||
builder.Entity<ExpenseCategoryGroup>(entity =>
|
builder.Entity<ExpenseCategoryGroup>(entity =>
|
||||||
{
|
{
|
||||||
@@ -225,6 +268,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasOne(e => e.Form990Line).WithMany()
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||||
@@ -238,6 +283,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.Form990Line).WithMany()
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Expense ──────────────────────────────────────────────────────────
|
// ── Expense ──────────────────────────────────────────────────────────
|
||||||
@@ -268,6 +315,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.Member).WithMany()
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Payee).WithMany()
|
||||||
|
.HasForeignKey(e => e.PayeeId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
|
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
|
||||||
@@ -292,6 +341,47 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── ExpenseSnapshot (reusable vendor-payment template) ───────────────
|
||||||
|
builder.Entity<ExpenseSnapshot>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(s => !s.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.Name).HasMaxLength(150).IsRequired();
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
|
||||||
|
entity.Property(e => e.VendorName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.CheckNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.CreatedAt);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Ministry).WithMany()
|
||||||
|
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseSnapshotLine (category breakdown of one snapshot) ─────────
|
||||||
|
builder.Entity<ExpenseSnapshotLine>(entity =>
|
||||||
|
{
|
||||||
|
// Mirror the parent snapshot's soft-delete filter (required relationship).
|
||||||
|
entity.HasQueryFilter(l => !l.Snapshot!.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.SnapshotId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Snapshot).WithMany(x => x.Lines)
|
||||||
|
.HasForeignKey(e => e.SnapshotId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(e => e.CategoryGroup).WithMany()
|
||||||
|
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(e => e.SubCategory).WithMany()
|
||||||
|
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
||||||
builder.Entity<ChurchProfile>(entity =>
|
builder.Entity<ChurchProfile>(entity =>
|
||||||
{
|
{
|
||||||
@@ -303,6 +393,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.PayerEin).HasMaxLength(20);
|
||||||
entity.Property(e => e.NameZh).HasMaxLength(200);
|
entity.Property(e => e.NameZh).HasMaxLength(200);
|
||||||
entity.Property(e => e.Phone).HasMaxLength(50);
|
entity.Property(e => e.Phone).HasMaxLength(50);
|
||||||
entity.Property(e => e.Email).HasMaxLength(200);
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
|||||||
@@ -137,6 +137,23 @@ public static class DbSeeder
|
|||||||
("Other", "Gifts", "24"),
|
("Other", "Gifts", "24"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed =
|
||||||
|
[
|
||||||
|
(Form1099.BoxNec1, "Nonemployee compensation", "非員工報酬", "1099-NEC", 1),
|
||||||
|
(Form1099.BoxMisc1, "Rents", "租金", "1099-MISC", 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only service/rent subcategories get a box. Everything else stays unmapped (not reportable).
|
||||||
|
private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed =
|
||||||
|
[
|
||||||
|
("Personnel", "Honorarium", Form1099.BoxNec1),
|
||||||
|
("Personnel", "Contract Labor", Form1099.BoxNec1),
|
||||||
|
("Professional Services", "Legal", Form1099.BoxNec1),
|
||||||
|
("Professional Services", "Accounting & Audit", Form1099.BoxNec1),
|
||||||
|
("Professional Services", "Other Professional", Form1099.BoxNec1),
|
||||||
|
("Facility", "Rent", Form1099.BoxMisc1),
|
||||||
|
];
|
||||||
|
|
||||||
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
|
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
|
||||||
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
|
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
|
||||||
// value — this block does. Idempotent: each row fires only while the subcategory still holds
|
// value — this block does. Idempotent: each row fires only while the subcategory still holds
|
||||||
@@ -190,6 +207,11 @@ public static class DbSeeder
|
|||||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||||
("finance", Modules.Disbursements, true, true, true, true),
|
("finance", Modules.Disbursements, true, true, true, true),
|
||||||
("finance", Modules.Form990Report, true, false, false, false),
|
("finance", Modules.Form990Report, true, false, false, false),
|
||||||
|
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
|
||||||
|
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
|
||||||
|
("finance", Modules.Form1099, true, true, true, false),
|
||||||
|
("pastor", Modules.Form1099, true, false, false, false),
|
||||||
|
("board_member", Modules.Form1099, true, false, false, false),
|
||||||
|
|
||||||
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
||||||
// governance value, so finance and board members can read them too.
|
// governance value, so finance and board members can read them too.
|
||||||
@@ -375,6 +397,25 @@ public static class DbSeeder
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedForm1099BoxesAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed)
|
||||||
|
if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code))
|
||||||
|
db.Form1099Boxes.Add(new Form1099Box
|
||||||
|
{ BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id);
|
||||||
|
var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
|
||||||
|
foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed)
|
||||||
|
{
|
||||||
|
var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||||
|
if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId))
|
||||||
|
sub.Form1099BoxId = boxId;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
// Singleton row used by the disbursement module (issuer info + check counter).
|
// Singleton row used by the disbursement module (issuer info + check counter).
|
||||||
@@ -454,6 +495,7 @@ public static class DbSeeder
|
|||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(db);
|
await SeedExpenseCategoriesAsync(db);
|
||||||
await SeedForm990ExpenseLinesAsync(db);
|
await SeedForm990ExpenseLinesAsync(db);
|
||||||
|
await SeedForm1099BoxesAsync(db);
|
||||||
await SeedChurchProfileAsync(db);
|
await SeedChurchProfileAsync(db);
|
||||||
await SeedSiteSettingAsync(db);
|
await SeedSiteSettingAsync(db);
|
||||||
await SeedNotificationSettingAsync(db, config);
|
await SeedNotificationSettingAsync(db, config);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
|||||||
public string? BankAccountNumber { get; set; }
|
public string? BankAccountNumber { get; set; }
|
||||||
public string? BankRoutingNumber { get; set; }
|
public string? BankRoutingNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Payer EIN printed on Form 1099-NEC Copy B; the church's own public business identifier.</summary>
|
||||||
|
public string? PayerEin { get; set; }
|
||||||
|
|
||||||
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
|
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
|
||||||
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
|
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
|
||||||
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
|
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public class Expense : SoftDeleteEntity, IAuditable
|
|||||||
public string Description { get; set; } = null!;
|
public string Description { get; set; } = null!;
|
||||||
public string? VendorName { get; set; }
|
public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
|
public int? PayeeId { get; set; } // 1099 recipient attribution (header-level)
|
||||||
public string? CheckNumber { get; set; }
|
public string? CheckNumber { get; set; }
|
||||||
public DateOnly ExpenseDate { get; set; }
|
public DateOnly ExpenseDate { get; set; }
|
||||||
public string? ReceiptBlobPath { get; set; }
|
public string? ReceiptBlobPath { get; set; }
|
||||||
@@ -25,5 +26,6 @@ public class Expense : SoftDeleteEntity, IAuditable
|
|||||||
|
|
||||||
public Ministry? Ministry { get; set; }
|
public Ministry? Ministry { get; set; }
|
||||||
public Member? Member { get; set; }
|
public Member? Member { get; set; }
|
||||||
|
public Payee1099? Payee { get; set; }
|
||||||
public List<ExpenseLine> Lines { get; set; } = new();
|
public List<ExpenseLine> Lines { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
|
|||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public Form990ExpenseLine? Form990Line { get; set; }
|
public Form990ExpenseLine? Form990Line { get; set; }
|
||||||
|
|
||||||
|
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
|
||||||
|
public Form1099Box? Form1099Box { get; set; }
|
||||||
|
|
||||||
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reusable template of a vendor payment. Lets finance save a recurring fixed expense
|
||||||
|
/// (rent, internet, a fixed catered-meal cost) and re-apply it later, pre-filling everything
|
||||||
|
/// except the ExpenseDate. Shared church-wide; the creator is the auditable CreatedBy.
|
||||||
|
/// Lines are wholly owned by the header (replaced as a set on update, like ExpenseLine).
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseSnapshot : SoftDeleteEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!; // user label, e.g. "Monthly Rent — Landlord X"
|
||||||
|
public int MinistryId { get; set; }
|
||||||
|
public string Description { get; set; } = null!;
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public Ministry? Ministry { get; set; }
|
||||||
|
public List<ExpenseSnapshotLine> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>One category line of an <see cref="ExpenseSnapshot"/>, mirroring <see cref="ExpenseLine"/>.</summary>
|
||||||
|
public class ExpenseSnapshotLine : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int SnapshotId { get; set; }
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public ExpenseSnapshot? Snapshot { get; set; }
|
||||||
|
public ExpenseCategoryGroup? CategoryGroup { get; set; }
|
||||||
|
public ExpenseSubCategory? SubCategory { get; set; }
|
||||||
|
}
|
||||||
@@ -13,5 +13,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
|
|||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public Form990ExpenseLine? Form990Line { get; set; }
|
public Form990ExpenseLine? Form990Line { get; set; }
|
||||||
|
|
||||||
|
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
|
||||||
|
public Form1099Box? Form1099Box { get; set; }
|
||||||
|
|
||||||
public ExpenseCategoryGroup? Group { get; set; }
|
public ExpenseCategoryGroup? Group { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>Shared 1099 constants. Box codes match Form1099Box.BoxCode seed values.</summary>
|
||||||
|
public static class Form1099
|
||||||
|
{
|
||||||
|
/// <summary>IRS reporting threshold (USD) per box, per recipient, per calendar year.</summary>
|
||||||
|
public const decimal ReportingThreshold = 600m;
|
||||||
|
|
||||||
|
public const string BoxNec1 = "NEC-1"; // Nonemployee compensation
|
||||||
|
public const string BoxMisc1 = "MISC-1"; // Rents
|
||||||
|
|
||||||
|
public static class W9Status
|
||||||
|
{
|
||||||
|
public const string Missing = "Missing";
|
||||||
|
public const string Requested = "Requested";
|
||||||
|
public const string OnFile = "OnFile";
|
||||||
|
public const string Expired = "Expired";
|
||||||
|
public static readonly IReadOnlyList<string> All = [Missing, Requested, OnFile, Expired];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>A 1099 reporting box, e.g. "NEC-1 — Nonemployee compensation".</summary>
|
||||||
|
public class Form1099Box : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string BoxCode { get; set; } = null!; // "NEC-1", "MISC-1"
|
||||||
|
public string Name_en { get; set; } = null!;
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public string FormType { get; set; } = "1099-NEC"; // "1099-NEC" | "1099-MISC"
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A 1099 recipient (independent contractor / vendor). Holds W-9 data and an encrypted TIN.
|
||||||
|
/// Optionally linked to a Member (e.g. a part-time co-worker paid as a contractor).
|
||||||
|
/// </summary>
|
||||||
|
public class Payee1099 : SoftDeleteEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string LegalName { get; set; } = null!; // name on the W-9
|
||||||
|
public string? DisplayName { get; set; } // friendly / DBA
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public Member? Member { get; set; }
|
||||||
|
public string TaxClassification { get; set; } = "Individual"; // drives Is1099Tracked default
|
||||||
|
public bool Is1099Tracked { get; set; } = true;
|
||||||
|
public string? TinType { get; set; } // "SSN" | "EIN"
|
||||||
|
public string? TinEncrypted { get; set; } // Data-Protection ciphertext
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string? AddressLine1 { get; set; }
|
||||||
|
public string? AddressLine2 { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string W9Status { get; set; } = Form1099.W9Status.Missing;
|
||||||
|
public DateOnly? W9ReceivedDate { get; set; }
|
||||||
|
public string? W9BlobPath { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExpenseSnapshots : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExpenseSnapshots",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||||
|
MinistryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||||
|
VendorName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
CheckNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "text", 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_ExpenseSnapshots", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshots_Ministries_MinistryId",
|
||||||
|
column: x => x.MinistryId,
|
||||||
|
principalTable: "Ministries",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExpenseSnapshotLines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
SnapshotId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CategoryGroupId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SubCategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
FunctionalClass = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||||
|
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
Description = 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)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExpenseSnapshotLines", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshotLines_ExpenseCategoryGroups_CategoryGroupId",
|
||||||
|
column: x => x.CategoryGroupId,
|
||||||
|
principalTable: "ExpenseCategoryGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshotLines_ExpenseSnapshots_SnapshotId",
|
||||||
|
column: x => x.SnapshotId,
|
||||||
|
principalTable: "ExpenseSnapshots",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshotLines_ExpenseSubCategories_SubCategoryId",
|
||||||
|
column: x => x.SubCategoryId,
|
||||||
|
principalTable: "ExpenseSubCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshotLines_CategoryGroupId",
|
||||||
|
table: "ExpenseSnapshotLines",
|
||||||
|
column: "CategoryGroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshotLines_SnapshotId",
|
||||||
|
table: "ExpenseSnapshotLines",
|
||||||
|
column: "SnapshotId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshotLines_SubCategoryId",
|
||||||
|
table: "ExpenseSnapshotLines",
|
||||||
|
column: "SubCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshots_CreatedAt",
|
||||||
|
table: "ExpenseSnapshots",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshots_MinistryId",
|
||||||
|
table: "ExpenseSnapshots",
|
||||||
|
column: "MinistryId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseSnapshotLines");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseSnapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2676
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddForm1099RecipientTracking : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Form1099BoxId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PayeeId",
|
||||||
|
table: "Expenses",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Form1099BoxId",
|
||||||
|
table: "ExpenseCategoryGroups",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Form1099Boxes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BoxCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
FormType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Form1099Boxes", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Payee1099s",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
LegalName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
MemberId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
TaxClassification = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||||
|
Is1099Tracked = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
TinType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
|
||||||
|
TinEncrypted = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
TinLast4 = table.Column<string>(type: "character varying(4)", maxLength: 4, nullable: true),
|
||||||
|
AddressLine1 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
AddressLine2 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
State = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: true),
|
||||||
|
Zip = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
|
||||||
|
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Phone = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||||
|
W9Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Missing"),
|
||||||
|
W9ReceivedDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
W9BlobPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Payee1099s", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Payee1099s_Members_MemberId",
|
||||||
|
column: x => x.MemberId,
|
||||||
|
principalTable: "Members",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSubCategories_Form1099BoxId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
column: "Form1099BoxId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_PayeeId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "PayeeId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseCategoryGroups_Form1099BoxId",
|
||||||
|
table: "ExpenseCategoryGroups",
|
||||||
|
column: "Form1099BoxId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Form1099Boxes_BoxCode",
|
||||||
|
table: "Form1099Boxes",
|
||||||
|
column: "BoxCode",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Payee1099s_MemberId",
|
||||||
|
table: "Payee1099s",
|
||||||
|
column: "MemberId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId",
|
||||||
|
table: "ExpenseCategoryGroups",
|
||||||
|
column: "Form1099BoxId",
|
||||||
|
principalTable: "Form1099Boxes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Expenses_Payee1099s_PayeeId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "PayeeId",
|
||||||
|
principalTable: "Payee1099s",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
column: "Form1099BoxId",
|
||||||
|
principalTable: "Form1099Boxes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId",
|
||||||
|
table: "ExpenseCategoryGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Expenses_Payee1099s_PayeeId",
|
||||||
|
table: "Expenses");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId",
|
||||||
|
table: "ExpenseSubCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Form1099Boxes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Payee1099s");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExpenseSubCategories_Form1099BoxId",
|
||||||
|
table: "ExpenseSubCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Expenses_PayeeId",
|
||||||
|
table: "Expenses");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExpenseCategoryGroups_Form1099BoxId",
|
||||||
|
table: "ExpenseCategoryGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Form1099BoxId",
|
||||||
|
table: "ExpenseSubCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PayeeId",
|
||||||
|
table: "Expenses");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Form1099BoxId",
|
||||||
|
table: "ExpenseCategoryGroups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2680
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPayerEinToChurchProfile : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PayerEin",
|
||||||
|
table: "ChurchProfiles",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PayerEin",
|
||||||
|
table: "ChurchProfiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -506,6 +506,10 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Property<int>("NextCheckNumber")
|
b.Property<int>("NextCheckNumber")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PayerEin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -598,6 +602,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("PayeeId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("ReceiptBlobPath")
|
b.Property<string>("ReceiptBlobPath")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
@@ -652,6 +659,8 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasIndex("MinistryId");
|
b.HasIndex("MinistryId");
|
||||||
|
|
||||||
|
b.HasIndex("PayeeId");
|
||||||
|
|
||||||
b.HasIndex("Status")
|
b.HasIndex("Status")
|
||||||
.HasFilter("\"IsDeleted\" = false");
|
.HasFilter("\"IsDeleted\" = false");
|
||||||
|
|
||||||
@@ -674,6 +683,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("Form1099BoxId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("Form990LineId")
|
b.Property<int?>("Form990LineId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -702,6 +714,8 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Form1099BoxId");
|
||||||
|
|
||||||
b.HasIndex("Form990LineId");
|
b.HasIndex("Form990LineId");
|
||||||
|
|
||||||
b.ToTable("ExpenseCategoryGroups");
|
b.ToTable("ExpenseCategoryGroups");
|
||||||
@@ -762,6 +776,128 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("ExpenseLines");
|
b.ToTable("ExpenseLines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("CheckNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
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>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("MinistryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("VendorName")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("MinistryId");
|
||||||
|
|
||||||
|
b.ToTable("ExpenseSnapshots");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryGroupId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("FunctionalClass")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("SnapshotId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SubCategoryId")
|
||||||
|
.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("CategoryGroupId");
|
||||||
|
|
||||||
|
b.HasIndex("SnapshotId");
|
||||||
|
|
||||||
|
b.HasIndex("SubCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("ExpenseSnapshotLines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -778,6 +914,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("Form1099BoxId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("Form990LineId")
|
b.Property<int?>("Form990LineId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -809,6 +948,8 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Form1099BoxId");
|
||||||
|
|
||||||
b.HasIndex("Form990LineId");
|
b.HasIndex("Form990LineId");
|
||||||
|
|
||||||
b.HasIndex("GroupId");
|
b.HasIndex("GroupId");
|
||||||
@@ -854,6 +995,63 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("FamilyUnits");
|
b.ToTable("FamilyUnits");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Form1099Box", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("BoxCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("FormType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Name_zh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BoxCode")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Form1099Boxes");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1803,6 +2001,128 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("OfferingSessions");
|
b.ToTable("OfferingSessions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("AddressLine1")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("AddressLine2")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("City")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("Is1099Tracked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("LegalName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("MemberId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.HasMaxLength(2)
|
||||||
|
.HasColumnType("character varying(2)");
|
||||||
|
|
||||||
|
b.Property<string>("TaxClassification")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(40)
|
||||||
|
.HasColumnType("character varying(40)");
|
||||||
|
|
||||||
|
b.Property<string>("TinEncrypted")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("TinLast4")
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("character varying(4)");
|
||||||
|
|
||||||
|
b.Property<string>("TinType")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("W9BlobPath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("W9ReceivedDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("W9Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Missing");
|
||||||
|
|
||||||
|
b.Property<string>("Zip")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MemberId");
|
||||||
|
|
||||||
|
b.ToTable("Payee1099s");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2086,18 +2406,32 @@ namespace ROLAC.API.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.Payee1099", "Payee")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PayeeId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.Navigation("Member");
|
b.Navigation("Member");
|
||||||
|
|
||||||
b.Navigation("Ministry");
|
b.Navigation("Ministry");
|
||||||
|
|
||||||
|
b.Navigation("Payee");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("Form1099BoxId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("Form990LineId")
|
.HasForeignKey("Form990LineId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Form1099Box");
|
||||||
|
|
||||||
b.Navigation("Form990Line");
|
b.Navigation("Form990Line");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2128,8 +2462,51 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("SubCategory");
|
b.Navigation("SubCategory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.Ministry", "Ministry")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MinistryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Ministry");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryGroupId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.ExpenseSnapshot", "Snapshot")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("SnapshotId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CategoryGroup");
|
||||||
|
|
||||||
|
b.Navigation("Snapshot");
|
||||||
|
|
||||||
|
b.Navigation("SubCategory");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("Form1099BoxId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("Form990LineId")
|
.HasForeignKey("Form990LineId")
|
||||||
@@ -2141,6 +2518,8 @@ namespace ROLAC.API.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Form1099Box");
|
||||||
|
|
||||||
b.Navigation("Form990Line");
|
b.Navigation("Form990Line");
|
||||||
|
|
||||||
b.Navigation("Group");
|
b.Navigation("Group");
|
||||||
@@ -2220,6 +2599,16 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("MessagingGroup");
|
b.Navigation("MessagingGroup");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MemberId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Member");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
@@ -2273,6 +2662,11 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("SubCategories");
|
b.Navigation("SubCategories");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Givings");
|
b.Navigation("Givings");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using ROLAC.API.Json;
|
|||||||
using ROLAC.API.Middleware;
|
using ROLAC.API.Middleware;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
using ROLAC.API.Services.Logging;
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var config = builder.Configuration;
|
var config = builder.Configuration;
|
||||||
@@ -153,14 +154,23 @@ builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
|
|||||||
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
|
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
|
||||||
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
||||||
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
||||||
|
builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
|
||||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||||
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
|
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
|
||||||
|
builder.Services.AddScoped<IForm1099ReportService, Form1099ReportService>();
|
||||||
|
builder.Services.AddScoped<IPayee1099Service, Payee1099Service>();
|
||||||
|
builder.Services.AddScoped<I1099FormService, Form1099FormService>();
|
||||||
|
builder.Services.AddDataProtection();
|
||||||
|
builder.Services.AddScoped<ITinProtector, TinProtector>();
|
||||||
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||||
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
||||||
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
|
// Pre-printed check-stock field coordinates; tune in appsettings.json without recompiling.
|
||||||
|
builder.Services.Configure<ROLAC.API.Services.Disbursement.CheckPrintLayoutOptions>(
|
||||||
|
config.GetSection("CheckPrint:Layout"));
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||||
|
|
||||||
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
||||||
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
||||||
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
||||||
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
BankRoutingNumber = p.BankRoutingNumber, PayerEin = p.PayerEin, NextCheckNumber = p.NextCheckNumber,
|
||||||
AiProvider = p.AiProvider,
|
AiProvider = p.AiProvider,
|
||||||
ClaudeModel = p.ClaudeModel,
|
ClaudeModel = p.ClaudeModel,
|
||||||
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
|
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
|
||||||
@@ -33,7 +33,7 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
||||||
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
||||||
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
||||||
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
p.BankRoutingNumber = r.BankRoutingNumber; p.PayerEin = r.PayerEin; p.NextCheckNumber = r.NextCheckNumber;
|
||||||
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
|
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
|
||||||
p.ClaudeModel = r.ClaudeModel;
|
p.ClaudeModel = r.ClaudeModel;
|
||||||
p.GeminiModel = r.GeminiModel;
|
p.GeminiModel = r.GeminiModel;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
namespace ROLAC.API.Services.Disbursement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Field coordinates (in inches) for printing onto pre-printed three-stub check stock.
|
||||||
|
/// Every position is bound from the "CheckPrint:Layout" section of appsettings.json so alignment
|
||||||
|
/// can be tuned and reloaded without recompiling. Positions are expressed as an X (absolute from the
|
||||||
|
/// page's left edge) plus a Y offset within the field's stub; the per-stub origins below place the
|
||||||
|
/// three stacked regions (check + two identical receipt copies) down the 8.5"x11" page.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CheckPrintLayoutOptions
|
||||||
|
{
|
||||||
|
// Calibration nudge (inches): a TextBox renders its text inset down-and-right from the box
|
||||||
|
// origin by a fixed internal margin + line leading, so configured X/Y don't match the ink.
|
||||||
|
// These values are subtracted from every position so configured X/Y == actual printed position.
|
||||||
|
// To recalibrate: set both to 0, print, measure how far the ink sits past a known X/Y, and put
|
||||||
|
// those differences here (defaults are the measured inset for this stock).
|
||||||
|
public float TextInsetX { get; set; } = 0.13f;
|
||||||
|
public float TextInsetY { get; set; } = 0.15f;
|
||||||
|
|
||||||
|
// Stub origins — top edge of each region, inches from the top of the page.
|
||||||
|
public float CheckOriginY { get; set; } = 0f;
|
||||||
|
public float Receipt1OriginY { get; set; } = 3.67f;
|
||||||
|
public float Receipt2OriginY { get; set; } = 7.33f;
|
||||||
|
|
||||||
|
// Check stub fields (offset within the check stub).
|
||||||
|
public FieldPos Payee { get; set; } = new() { X = 1.25f, OffsetY = 1.75f, FontSize = 11, Bold = true };
|
||||||
|
public FieldPos AmountNumeric { get; set; } = new() { X = 6.50f, OffsetY = 1.75f, FontSize = 11, Bold = true };
|
||||||
|
public FieldPos AmountWords { get; set; } = new() { X = 0.60f, OffsetY = 2.20f, FontSize = 10 };
|
||||||
|
public FieldPos Memo { get; set; } = new() { X = 0.60f, OffsetY = 2.90f, FontSize = 9 };
|
||||||
|
public FieldPos CheckDate { get; set; } = new() { X = 6.50f, OffsetY = 1.25f, FontSize = 10 };
|
||||||
|
|
||||||
|
// Receipt stub fields (offset within a receipt stub — shared by both identical copies).
|
||||||
|
public FieldPos ReceiptPayee { get; set; } = new() { X = 1.00f, OffsetY = 0.30f, FontSize = 10, Bold = true };
|
||||||
|
public FieldPos ReceiptAmount { get; set; } = new() { X = 6.50f, OffsetY = 0.30f, FontSize = 10, Bold = true };
|
||||||
|
public FieldPos ReceiptMemo { get; set; } = new() { X = 1.00f, OffsetY = 0.60f, FontSize = 9 };
|
||||||
|
public FieldPos ReceiptDate { get; set; } = new() { X = 6.50f, OffsetY = 0.60f, FontSize = 9 };
|
||||||
|
|
||||||
|
// Voucher detail grid (offsets within a receipt stub).
|
||||||
|
public VoucherGridOptions Grid { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One printable field: where it sits and how it renders.</summary>
|
||||||
|
public sealed class FieldPos
|
||||||
|
{
|
||||||
|
/// <summary>Absolute X from the page's left edge, in inches.</summary>
|
||||||
|
public float X { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Y offset within the field's stub, in inches (added to the stub origin).</summary>
|
||||||
|
public float OffsetY { get; set; }
|
||||||
|
|
||||||
|
public float FontSize { get; set; } = 10;
|
||||||
|
public bool Bold { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-column voucher detail grid on each receipt stub: 6 rows per column, 12 expense lines max,
|
||||||
|
/// filled column-major (lines 1-6 left, 7-12 right). All offsets are within the receipt stub.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoucherGridOptions
|
||||||
|
{
|
||||||
|
/// <summary>Left edge of the left column block, in inches from the page's left edge.</summary>
|
||||||
|
public float OriginX { get; set; } = 0.60f;
|
||||||
|
|
||||||
|
/// <summary>Y offset (within the stub) of the first data row.</summary>
|
||||||
|
public float OffsetY { get; set; } = 1.10f;
|
||||||
|
|
||||||
|
public float RowHeight { get; set; } = 0.22f;
|
||||||
|
|
||||||
|
/// <summary>Horizontal gap between the left and right column blocks, in inches.</summary>
|
||||||
|
public float ColumnGap { get; set; } = 0.30f;
|
||||||
|
|
||||||
|
public float DateWidth { get; set; } = 0.85f;
|
||||||
|
public float DescWidth { get; set; } = 2.10f;
|
||||||
|
public float AmountWidth { get; set; } = 0.80f;
|
||||||
|
|
||||||
|
/// <summary>Draw our own Date/Description/Amount headers. Set false if the stock pre-prints them.</summary>
|
||||||
|
public bool ShowGridHeaders { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Y offset (within the stub) of the header row.</summary>
|
||||||
|
public float HeaderOffsetY { get; set; } = 0.88f;
|
||||||
|
|
||||||
|
/// <summary>Y offset (within the stub) of the "...and N more lines" overflow hint.</summary>
|
||||||
|
public float OverflowOffsetY { get; set; } = 2.55f;
|
||||||
|
|
||||||
|
public float FontSize { get; set; } = 8.5f;
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
|
using System.Drawing;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using DevExpress.Office;
|
using DevExpress.Office;
|
||||||
using DevExpress.XtraRichEdit;
|
using DevExpress.XtraRichEdit;
|
||||||
using DevExpress.XtraRichEdit.API.Native;
|
using DevExpress.XtraRichEdit.API.Native;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Disbursement;
|
namespace ROLAC.API.Services.Disbursement;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders a check on 8.5"x11" stock using the DevExpress Office (RichEdit) API:
|
/// Renders a check onto pre-printed 8.5"x11" three-stub stock using the DevExpress Office
|
||||||
/// a check block on top followed by two identical ledger detail stubs. The layout is
|
/// (RichEdit) API. Fields are placed as absolutely-positioned floating TextBoxes so they align to
|
||||||
/// built programmatically (no external .docx template) and exported to PDF.
|
/// the boxes printed on the stock; every coordinate comes from <see cref="CheckPrintLayoutOptions"/>
|
||||||
|
/// (bound from "CheckPrint:Layout" in appsettings.json) so alignment can be tuned without recompiling.
|
||||||
|
/// The page is one check stub on top followed by two identical receipt copies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CheckPrintService : ICheckPrintService
|
public class CheckPrintService : ICheckPrintService
|
||||||
{
|
{
|
||||||
|
private readonly CheckPrintLayoutOptions _layout;
|
||||||
|
|
||||||
|
public CheckPrintService(IOptions<CheckPrintLayoutOptions> layout)
|
||||||
|
{
|
||||||
|
_layout = layout.Value;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
|
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
|
||||||
{
|
{
|
||||||
using var server = new RichEditDocumentServer();
|
using var server = new RichEditDocumentServer();
|
||||||
@@ -26,9 +37,13 @@ public class CheckPrintService : ICheckPrintService
|
|||||||
section.Margins.Left = section.Margins.Right = 0.6f;
|
section.Margins.Left = section.Margins.Right = 0.6f;
|
||||||
section.Margins.Top = section.Margins.Bottom = 0.5f;
|
section.Margins.Top = section.Margins.Bottom = 0.5f;
|
||||||
|
|
||||||
BuildCheckBlock(doc, model);
|
// Floating TextBoxes must anchor to a paragraph; everything is positioned relative to
|
||||||
BuildStub(doc, model, "PAYMENT ADVICE — DETAIL");
|
// the page, so a single empty anchor paragraph at the document start is enough.
|
||||||
BuildStub(doc, model, "PAYMENT ADVICE — RECORD COPY");
|
var anchor = doc.Paragraphs[0];
|
||||||
|
|
||||||
|
BuildCheckStub(doc, anchor, model);
|
||||||
|
BuildReceiptStub(doc, anchor, model, _layout.Receipt1OriginY);
|
||||||
|
BuildReceiptStub(doc, anchor, model, _layout.Receipt2OriginY);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -41,6 +56,125 @@ public class CheckPrintService : ICheckPrintService
|
|||||||
return Task.FromResult<Stream>(ms);
|
return Task.FromResult<Stream>(ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── check page builders (absolute positioning) ─────────────────────────────
|
||||||
|
|
||||||
|
private void BuildCheckStub(Document doc, Paragraph anchor, CheckPrintModel model)
|
||||||
|
{
|
||||||
|
var check = model.Check;
|
||||||
|
var originY = _layout.CheckOriginY;
|
||||||
|
|
||||||
|
PlaceField(doc, anchor, _layout.Payee, originY, 4.5f, check.PayeeName);
|
||||||
|
PlaceField(doc, anchor, _layout.AmountNumeric, originY, 1.6f, FormatCurrency(check.Amount,13), rightAlign: false);
|
||||||
|
PlaceField(doc, anchor, _layout.AmountWords, originY, 6.0f, model.AmountInWords);
|
||||||
|
PlaceField(doc, anchor, _layout.CheckDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(check.Memo))
|
||||||
|
PlaceField(doc, anchor, _layout.Memo, originY, 4.5f, check.Memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildReceiptStub(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
|
||||||
|
{
|
||||||
|
var check = model.Check;
|
||||||
|
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptPayee, originY, 4.5f, "Pay To The Order Of: " + check.PayeeName);
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptAmount, originY, 1.6f, "Amount: " + FormatCurrency(check.Amount), rightAlign: true);
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(check.Memo))
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptMemo, originY, 4.5f, check.Memo);
|
||||||
|
|
||||||
|
BuildVoucherGrid(doc, anchor, model, originY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-column voucher detail grid: up to 12 expense lines (6 per column), filled column-major.
|
||||||
|
/// Beyond 12 lines, prints the first 12 plus an overflow hint so the receipt total stays honest.
|
||||||
|
/// </summary>
|
||||||
|
private void BuildVoucherGrid(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
|
||||||
|
{
|
||||||
|
var grid = _layout.Grid;
|
||||||
|
var blockWidth = grid.DateWidth + grid.DescWidth + grid.AmountWidth;
|
||||||
|
const int rowsPerColumn = 6;
|
||||||
|
const int maxLines = rowsPerColumn * 2;
|
||||||
|
|
||||||
|
float ColumnX(int column) => grid.OriginX + column * (blockWidth + grid.ColumnGap);
|
||||||
|
|
||||||
|
void PlaceRow(float x, float y, string date, string description, string amount, bool bold)
|
||||||
|
{
|
||||||
|
PlaceText(doc, anchor, x, y, grid.DateWidth, date, grid.FontSize, bold, rightAlign: false);
|
||||||
|
PlaceText(doc, anchor, x + grid.DateWidth, y, grid.DescWidth, description, grid.FontSize, bold, rightAlign: false);
|
||||||
|
PlaceText(doc, anchor, x + grid.DateWidth + grid.DescWidth, y, grid.AmountWidth, amount, grid.FontSize, bold, rightAlign: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.ShowGridHeaders)
|
||||||
|
{
|
||||||
|
var headerY = originY + grid.HeaderOffsetY;
|
||||||
|
for (var column = 0; column < 2; column++)
|
||||||
|
PlaceRow(ColumnX(column), headerY, "Date", "Description", "Amount", bold: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var printed = Math.Min(model.Lines.Count, maxLines);
|
||||||
|
for (var i = 0; i < printed; i++)
|
||||||
|
{
|
||||||
|
var line = model.Lines[i];
|
||||||
|
var column = i < rowsPerColumn ? 0 : 1;
|
||||||
|
var row = i % rowsPerColumn;
|
||||||
|
var y = originY + grid.OffsetY + row * grid.RowHeight;
|
||||||
|
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
|
||||||
|
PlaceRow(ColumnX(column), y, date, line.Description, FormatCurrency(line.Amount), bold: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.Lines.Count > maxLines)
|
||||||
|
{
|
||||||
|
var remaining = model.Lines.Count - maxLines;
|
||||||
|
var remainingTotal = model.Lines.Skip(maxLines).Sum(line => line.Amount);
|
||||||
|
var hint = $"…and {remaining} more line(s) ({FormatCurrency(remainingTotal)})";
|
||||||
|
PlaceText(doc, anchor, grid.OriginX, originY + grid.OverflowOffsetY,
|
||||||
|
blockWidth * 2 + grid.ColumnGap, hint, grid.FontSize, bold: false, rightAlign: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Places one configured field's value at its stub-relative position.</summary>
|
||||||
|
private void PlaceField(Document doc, Paragraph anchor, FieldPos field, float stubOriginY,
|
||||||
|
float width, string text, bool rightAlign = false)
|
||||||
|
{
|
||||||
|
PlaceText(doc, anchor, field.X, stubOriginY + field.OffsetY, width, text,
|
||||||
|
field.FontSize, field.Bold, rightAlign);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts a borderless, fill-less floating TextBox positioned absolutely relative to the page
|
||||||
|
/// (X from the left edge, Y from the top edge) and writes <paramref name="text"/> into it.
|
||||||
|
/// </summary>
|
||||||
|
private void PlaceText(Document doc, Paragraph anchor, float x, float y, float width,
|
||||||
|
string text, float fontSize, bool bold, bool rightAlign)
|
||||||
|
{
|
||||||
|
var shape = doc.Shapes.InsertTextBox(anchor.Range.Start);
|
||||||
|
shape.HorizontalAlignment = ShapeHorizontalAlignment.None;
|
||||||
|
shape.VerticalAlignment = ShapeVerticalAlignment.None;
|
||||||
|
shape.RelativeHorizontalPosition = ShapeRelativeHorizontalPosition.Page;
|
||||||
|
shape.RelativeVerticalPosition = ShapeRelativeVerticalPosition.Page;
|
||||||
|
// Pull the box up-and-left by the calibration inset so the text inside lands exactly on the
|
||||||
|
// configured (x, y) rather than down-and-right of it.
|
||||||
|
shape.Offset = new PointF(x - _layout.TextInsetX, y - _layout.TextInsetY);
|
||||||
|
shape.Size = new SizeF(width, Math.Max(0.2f, fontSize / 72f + 0.08f));
|
||||||
|
shape.Fill.SetNoFill();
|
||||||
|
shape.Line.Fill.SetNoFill();
|
||||||
|
|
||||||
|
var body = shape.ShapeFormat.TextBox.Document;
|
||||||
|
var range = body.InsertText(body.Range.Start, text);
|
||||||
|
|
||||||
|
var characters = body.BeginUpdateCharacters(range);
|
||||||
|
characters.FontSize = fontSize;
|
||||||
|
characters.Bold = bold;
|
||||||
|
body.EndUpdateCharacters(characters);
|
||||||
|
|
||||||
|
if (rightAlign)
|
||||||
|
{
|
||||||
|
var paragraphs = body.BeginUpdateParagraphs(range);
|
||||||
|
paragraphs.Alignment = ParagraphAlignment.Right;
|
||||||
|
body.EndUpdateParagraphs(paragraphs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
|
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
|
||||||
{
|
{
|
||||||
using var server = new RichEditDocumentServer();
|
using var server = new RichEditDocumentServer();
|
||||||
@@ -131,110 +265,15 @@ public class CheckPrintService : ICheckPrintService
|
|||||||
|
|
||||||
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
|
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
|
||||||
|
|
||||||
private static void BuildCheckBlock(Document doc, CheckPrintModel m)
|
|
||||||
{
|
|
||||||
var issuer = m.Issuer;
|
|
||||||
var check = m.Check;
|
|
||||||
|
|
||||||
AppendLine(doc, issuer.Name, bold: true, size: 13);
|
|
||||||
var issuerAddr = JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode);
|
|
||||||
if (!string.IsNullOrWhiteSpace(issuerAddr)) AppendLine(doc, issuerAddr, size: 9);
|
|
||||||
if (!string.IsNullOrWhiteSpace(issuer.BankName)) AppendLine(doc, issuer.BankName, size: 9);
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy}", bold: true, size: 10);
|
|
||||||
AppendLine(doc, "");
|
|
||||||
|
|
||||||
AppendLine(doc, $"PAY TO THE ORDER OF: {check.PayeeName}", bold: true, size: 11);
|
|
||||||
var payeeAddr = JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip);
|
|
||||||
if (!string.IsNullOrWhiteSpace(payeeAddr)) AppendLine(doc, payeeAddr, size: 9);
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, $"AMOUNT: {FormatCurrency(check.Amount)}", bold: true, size: 12);
|
|
||||||
AppendLine(doc, m.AmountInWords, size: 10);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(check.Memo)) { AppendLine(doc, ""); AppendLine(doc, $"Memo: {check.Memo}", size: 9); }
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, "____________________________________", size: 10);
|
|
||||||
AppendLine(doc, "Authorized Signature", size: 8);
|
|
||||||
AppendSeparator(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void BuildStub(Document doc, CheckPrintModel m, string title)
|
|
||||||
{
|
|
||||||
var check = m.Check;
|
|
||||||
AppendLine(doc, title, bold: true, size: 10);
|
|
||||||
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy} Payee: {check.PayeeName}", size: 9);
|
|
||||||
AppendLine(doc, "");
|
|
||||||
|
|
||||||
var rows = m.Lines.Count + 2; // header + lines + total
|
|
||||||
var table = doc.Tables.Create(doc.Range.End, rows, 3, AutoFitBehaviorType.AutoFitToWindow);
|
|
||||||
table.Borders.InsideHorizontalBorder.LineStyle = TableBorderLineStyle.Single;
|
|
||||||
table.Borders.Top.LineStyle = table.Borders.Bottom.LineStyle = TableBorderLineStyle.Single;
|
|
||||||
|
|
||||||
SetCell(doc, table[0, 0], "Date", bold: true);
|
|
||||||
SetCell(doc, table[0, 1], "Description", bold: true);
|
|
||||||
SetCell(doc, table[0, 2], "Amount", bold: true, right: true);
|
|
||||||
|
|
||||||
for (var i = 0; i < m.Lines.Count; i++)
|
|
||||||
{
|
|
||||||
var line = m.Lines[i];
|
|
||||||
var r = i + 1;
|
|
||||||
// CheckLine snapshots description; date comes from the source expense if loaded.
|
|
||||||
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
|
|
||||||
SetCell(doc, table[r, 0], date);
|
|
||||||
SetCell(doc, table[r, 1], line.Description);
|
|
||||||
SetCell(doc, table[r, 2], FormatCurrency(line.Amount), right: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalRow = rows - 1;
|
|
||||||
SetCell(doc, table[totalRow, 0], "");
|
|
||||||
SetCell(doc, table[totalRow, 1], "TOTAL", bold: true, right: true);
|
|
||||||
SetCell(doc, table[totalRow, 2], FormatCurrency(check.Amount), bold: true, right: true);
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
if (check.ReceiptSignedAt is { } signedAt)
|
|
||||||
AppendLine(doc, $"Received by: {check.ReceiptSignedName} on {signedAt:MM/dd/yyyy HH:mm}", size: 9);
|
|
||||||
AppendSeparator(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── low-level helpers ──────────────────────────────────────────────────────
|
// ── low-level helpers ──────────────────────────────────────────────────────
|
||||||
|
private static string FormatCurrency(decimal amount, int paddingAstric = 0)
|
||||||
private static void AppendLine(Document doc, string text, bool bold = false, float size = 10)
|
|
||||||
{
|
{
|
||||||
var range = doc.AppendText(text + "\r\n");
|
var c = (CultureInfo)CultureInfo.GetCultureInfo("en-US").Clone();
|
||||||
var cp = doc.BeginUpdateCharacters(range);
|
c.NumberFormat.CurrencySymbol = "";
|
||||||
cp.Bold = bold;
|
string formatedAmount = amount.ToString("#,##0.00", CultureInfo.GetCultureInfo("en-US"));
|
||||||
cp.FontSize = size;
|
return paddingAstric > 0 ? formatedAmount.PadLeft(paddingAstric, '*') : formatedAmount;
|
||||||
doc.EndUpdateCharacters(cp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AppendSeparator(Document doc)
|
|
||||||
{
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, "------------------------------------------------------------------------------------------", size: 8);
|
|
||||||
AppendLine(doc, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetCell(Document doc, TableCell cell, string text, bool bold = false, bool right = false)
|
|
||||||
{
|
|
||||||
var range = doc.InsertText(cell.ContentRange.Start, text);
|
|
||||||
var cp = doc.BeginUpdateCharacters(range);
|
|
||||||
cp.Bold = bold;
|
|
||||||
cp.FontSize = 9;
|
|
||||||
doc.EndUpdateCharacters(cp);
|
|
||||||
if (right)
|
|
||||||
{
|
|
||||||
var pp = doc.BeginUpdateParagraphs(range);
|
|
||||||
pp.Alignment = ParagraphAlignment.Right;
|
|
||||||
doc.EndUpdateParagraphs(pp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCurrency(decimal amount) =>
|
|
||||||
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
|
|
||||||
|
|
||||||
private static string JoinAddress(string? addr, string? city, string? state, string? zip)
|
private static string JoinAddress(string? addr, string? city, string? state, string? zip)
|
||||||
{
|
{
|
||||||
var cityLine = string.Join(", ",
|
var cityLine = string.Join(", ",
|
||||||
|
|||||||
@@ -25,25 +25,32 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
|
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
|
||||||
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
|
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
|
||||||
|
|
||||||
|
var boxCodes = await _db.Form1099Boxes.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(b => b.Id, b => b.BoxCode);
|
||||||
|
|
||||||
return groups.Select(g => new ExpenseCategoryGroupDto
|
return groups.Select(g => new ExpenseCategoryGroupDto
|
||||||
{
|
{
|
||||||
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
||||||
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
||||||
Form990LineId = g.Form990LineId,
|
Form990LineId = g.Form990LineId,
|
||||||
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
|
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
|
||||||
|
Form1099BoxId = g.Form1099BoxId,
|
||||||
|
Form1099BoxCode = g.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(g.Form1099BoxId.Value) : null,
|
||||||
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
|
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
|
||||||
{
|
{
|
||||||
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
||||||
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
||||||
Form990LineId = s.Form990LineId,
|
Form990LineId = s.Form990LineId,
|
||||||
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
|
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
|
||||||
|
Form1099BoxId = s.Form1099BoxId,
|
||||||
|
Form1099BoxCode = s.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(s.Form1099BoxId.Value) : null,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
||||||
{
|
{
|
||||||
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
|
||||||
_db.ExpenseCategoryGroups.Add(g);
|
_db.ExpenseCategoryGroups.Add(g);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return g.Id;
|
return g.Id;
|
||||||
@@ -53,7 +60,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
||||||
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId;
|
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId; g.Form1099BoxId = r.Form1099BoxId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +76,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
||||||
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
||||||
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
|
||||||
_db.ExpenseSubCategories.Add(s);
|
_db.ExpenseSubCategories.Add(s);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return s.Id;
|
return s.Id;
|
||||||
@@ -79,7 +86,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
||||||
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId;
|
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId; s.Form1099BoxId = r.Form1099BoxId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ public class ExpenseService : IExpenseService
|
|||||||
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
|
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
|
||||||
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
|
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
|
||||||
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
|
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
|
||||||
var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
|
var memberNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
|
||||||
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
|
.Select(m => new { m.Id, m.FirstName_en, m.LastName_en, m.NickName })
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
m => m.Id,
|
||||||
|
m => new MemberPayeeName($"{m.FirstName_en} {m.LastName_en}",
|
||||||
|
BuildNickPayeeName(m.NickName, m.FirstName_en, m.LastName_en)));
|
||||||
var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
|
var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
|
||||||
|
|
||||||
// Line count + first line's category, per expense on this page.
|
// Line count + first line's category, per expense on this page.
|
||||||
@@ -108,13 +112,15 @@ public class ExpenseService : IExpenseService
|
|||||||
LineCount = ls?.Count ?? 0,
|
LineCount = ls?.Count ?? 0,
|
||||||
PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
|
PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
|
||||||
VendorName = e.VendorName, MemberId = e.MemberId,
|
VendorName = e.VendorName, MemberId = e.MemberId,
|
||||||
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
|
MemberName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Legal : null,
|
||||||
|
MemberNickName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Nick : null,
|
||||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||||
HasReceipt = e.ReceiptBlobPath != null,
|
HasReceipt = e.ReceiptBlobPath != null,
|
||||||
CheckNumber = e.CheckNumber,
|
CheckNumber = e.CheckNumber,
|
||||||
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
|
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
|
||||||
ReviewedAt = e.ReviewedAt,
|
ReviewedAt = e.ReviewedAt,
|
||||||
ReviewNotes = e.ReviewNotes,
|
ReviewNotes = e.ReviewNotes,
|
||||||
|
PayeeId = e.PayeeId,
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -145,14 +151,41 @@ public class ExpenseService : IExpenseService
|
|||||||
: (u.Email ?? u.Id));
|
: (u.Email ?? u.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Member payee names carried to the frontend: the legal name (printed on the check) and an
|
||||||
|
// optional friendly "NickName LastName" line shown above it.
|
||||||
|
private sealed record MemberPayeeName(string Legal, string? Nick);
|
||||||
|
|
||||||
|
// Build the friendly "NickName LastName" payee line, or null when the member has no distinct
|
||||||
|
// nickname (mirrors the frontend memberDisplayName rule: a nickname equal to the first name is not shown).
|
||||||
|
private static string? BuildNickPayeeName(string? nickName, string firstNameEn, string lastNameEn)
|
||||||
|
{
|
||||||
|
bool hasDistinctNickName = !string.IsNullOrWhiteSpace(nickName) && nickName != firstNameEn;
|
||||||
|
if (!hasDistinctNickName)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $"{nickName} {lastNameEn}";
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ExpenseDto?> GetByIdAsync(int id)
|
public async Task<ExpenseDto?> GetByIdAsync(int id)
|
||||||
{
|
{
|
||||||
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (e is null) return null;
|
if (e is null) return null;
|
||||||
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
||||||
string? memName = e.MemberId != null
|
string? memberName = null;
|
||||||
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
|
string? memberNickName = null;
|
||||||
: null;
|
if (e.MemberId != null)
|
||||||
|
{
|
||||||
|
var member = await _db.Members.AsNoTracking()
|
||||||
|
.Where(m => m.Id == e.MemberId)
|
||||||
|
.Select(m => new { m.FirstName_en, m.LastName_en, m.NickName })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (member != null)
|
||||||
|
{
|
||||||
|
memberName = $"{member.FirstName_en} {member.LastName_en}";
|
||||||
|
memberNickName = BuildNickPayeeName(member.NickName, member.FirstName_en, member.LastName_en);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var reviewerName = e.ReviewedBy != null
|
var reviewerName = e.ReviewedBy != null
|
||||||
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
|
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
|
||||||
@@ -174,11 +207,12 @@ public class ExpenseService : IExpenseService
|
|||||||
MinistryId = e.MinistryId, MinistryName = minName,
|
MinistryId = e.MinistryId, MinistryName = minName,
|
||||||
LineCount = lineDtos.Count,
|
LineCount = lineDtos.Count,
|
||||||
PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
|
PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
|
||||||
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
|
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memberName, MemberNickName = memberNickName,
|
||||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
|
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
|
||||||
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
||||||
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
|
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
|
||||||
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
|
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
|
||||||
|
PayeeId = e.PayeeId,
|
||||||
Lines = lineDtos,
|
Lines = lineDtos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,6 +275,7 @@ public class ExpenseService : IExpenseService
|
|||||||
e.VendorName = null;
|
e.VendorName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.PayeeId = r.PayeeId;
|
||||||
_db.Expenses.Add(e);
|
_db.Expenses.Add(e);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return e.Id;
|
return e.Id;
|
||||||
@@ -262,7 +297,7 @@ public class ExpenseService : IExpenseService
|
|||||||
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
|
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
|
||||||
|
|
||||||
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
|
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.PayeeId = r.PayeeId;
|
||||||
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
|
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
|
||||||
|
|
||||||
// Replace the line set wholesale (lines are owned by the header), recompute the total.
|
// Replace the line set wholesale (lines are owned by the header), recompute the total.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotService : IExpenseSnapshotService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IHttpContextAccessor _http;
|
||||||
|
public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http)
|
||||||
|
{ _db = db; _http = http; }
|
||||||
|
|
||||||
|
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
|
||||||
|
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
|
||||||
|
// then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production.
|
||||||
|
private string CurrentUserId =>
|
||||||
|
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? _http.HttpContext?.User.FindFirstValue("sub")
|
||||||
|
?? "system";
|
||||||
|
|
||||||
|
public async Task<List<ExpenseSnapshotDto>> GetAllAsync()
|
||||||
|
{
|
||||||
|
var snaps = await _db.ExpenseSnapshots.AsNoTracking()
|
||||||
|
.OrderByDescending(s => s.CreatedAt).ThenByDescending(s => s.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
if (snaps.Count == 0) return new();
|
||||||
|
|
||||||
|
var ids = snaps.Select(s => s.Id).ToList();
|
||||||
|
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
|
||||||
|
.Where(l => ids.Contains(l.SnapshotId)).ToListAsync();
|
||||||
|
var linesBySnapshot = lines.GroupBy(l => l.SnapshotId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
|
||||||
|
var creatorNames = await ResolveUserNamesAsync(snaps.Select(s => s.CreatedBy));
|
||||||
|
|
||||||
|
return snaps.Select(s =>
|
||||||
|
{
|
||||||
|
linesBySnapshot.TryGetValue(s.Id, out var ls);
|
||||||
|
return new ExpenseSnapshotDto
|
||||||
|
{
|
||||||
|
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId,
|
||||||
|
MinistryName = minNames.GetValueOrDefault(s.MinistryId, ""),
|
||||||
|
Description = s.Description, VendorName = s.VendorName,
|
||||||
|
CheckNumber = s.CheckNumber, Notes = s.Notes,
|
||||||
|
TotalAmount = ls?.Sum(l => l.Amount) ?? 0,
|
||||||
|
LineCount = ls?.Count ?? 0,
|
||||||
|
CreatedByName = creatorNames.GetValueOrDefault(s.CreatedBy),
|
||||||
|
CreatedAt = s.CreatedAt,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseSnapshotDto?> GetByIdAsync(int id)
|
||||||
|
{
|
||||||
|
var s = await _db.ExpenseSnapshots.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (s is null) return null;
|
||||||
|
|
||||||
|
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
|
||||||
|
.Where(l => l.SnapshotId == id).OrderBy(l => l.Id).ToListAsync();
|
||||||
|
var minName = await _db.Ministries.Where(m => m.Id == s.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
||||||
|
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
|
||||||
|
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(x => x.Id, x => x.Name_en);
|
||||||
|
var creatorName = (await ResolveUserNamesAsync(new[] { s.CreatedBy })).GetValueOrDefault(s.CreatedBy);
|
||||||
|
|
||||||
|
return new ExpenseSnapshotDto
|
||||||
|
{
|
||||||
|
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minName,
|
||||||
|
Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes,
|
||||||
|
TotalAmount = lines.Sum(l => l.Amount), LineCount = lines.Count,
|
||||||
|
CreatedByName = creatorName, CreatedAt = s.CreatedAt,
|
||||||
|
Lines = lines.Select(l => new ExpenseSnapshotLineDto
|
||||||
|
{
|
||||||
|
CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
|
||||||
|
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
|
||||||
|
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(CreateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
|
var s = new ExpenseSnapshot
|
||||||
|
{
|
||||||
|
Name = r.Name.Trim(), MinistryId = r.MinistryId, Description = r.Description,
|
||||||
|
VendorName = r.VendorName, CheckNumber = r.CheckNumber, Notes = r.Notes,
|
||||||
|
Lines = BuildLines(r.Lines),
|
||||||
|
};
|
||||||
|
_db.ExpenseSnapshots.Add(s);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return s.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
|
var s = await _db.ExpenseSnapshots.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
|
||||||
|
|
||||||
|
s.Name = r.Name.Trim(); s.MinistryId = r.MinistryId; s.Description = r.Description;
|
||||||
|
s.VendorName = r.VendorName; s.CheckNumber = r.CheckNumber; s.Notes = r.Notes;
|
||||||
|
|
||||||
|
_db.ExpenseSnapshotLines.RemoveRange(s.Lines);
|
||||||
|
s.Lines = BuildLines(r.Lines);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id)
|
||||||
|
{
|
||||||
|
var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
|
||||||
|
s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateLines(List<ExpenseLineInput> lines)
|
||||||
|
{
|
||||||
|
if (lines is null || lines.Count == 0)
|
||||||
|
throw new InvalidOperationException("A snapshot must have at least one line.");
|
||||||
|
foreach (var l in lines)
|
||||||
|
{
|
||||||
|
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
|
||||||
|
throw new InvalidOperationException("Each snapshot line needs a category group and subcategory.");
|
||||||
|
if (l.Amount <= 0)
|
||||||
|
throw new InvalidOperationException("Each snapshot line amount must be greater than zero.");
|
||||||
|
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
|
||||||
|
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ExpenseSnapshotLine> BuildLines(List<ExpenseLineInput> inputs) =>
|
||||||
|
inputs.Select(l => new ExpenseSnapshotLine
|
||||||
|
{
|
||||||
|
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
|
||||||
|
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Resolve actor user ids (AppUser.Id, stored in CreatedBy) to a display name: the linked
|
||||||
|
// Member's full name when present, otherwise the account email. Mirrors ExpenseService.
|
||||||
|
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
|
||||||
|
{
|
||||||
|
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
|
||||||
|
if (ids.Count == 0) return new();
|
||||||
|
|
||||||
|
var users = await _db.Users.AsNoTracking()
|
||||||
|
.Where(u => ids.Contains(u.Id))
|
||||||
|
.Select(u => new { u.Id, u.Email, u.MemberId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
|
||||||
|
var memberNames = await _db.Members.AsNoTracking()
|
||||||
|
.Where(m => memberIds.Contains(m.Id))
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
|
||||||
|
|
||||||
|
return users.ToDictionary(
|
||||||
|
u => u.Id,
|
||||||
|
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
|
||||||
|
? name
|
||||||
|
: (u.Email ?? u.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using DevExpress.Office;
|
||||||
|
using DevExpress.XtraRichEdit;
|
||||||
|
using DevExpress.XtraRichEdit.API.Native;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the
|
||||||
|
/// DevExpress RichEdit/Office API, mirroring <c>CheckPrintService</c>) and a filing-data CSV.
|
||||||
|
/// </summary>
|
||||||
|
public class Form1099FormService : I1099FormService
|
||||||
|
{
|
||||||
|
private readonly IForm1099ReportService _report;
|
||||||
|
private readonly IPayee1099Service _payees;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public Form1099FormService(IForm1099ReportService report, IPayee1099Service payees, AppDbContext db)
|
||||||
|
{
|
||||||
|
_report = report;
|
||||||
|
_payees = payees;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear)
|
||||||
|
{
|
||||||
|
var payee = await _payees.GetByIdAsync(payeeId)
|
||||||
|
?? throw new InvalidOperationException($"Payee {payeeId} not found.");
|
||||||
|
|
||||||
|
var church = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
|
||||||
|
?? new ChurchProfile { Name = "Church" };
|
||||||
|
|
||||||
|
// Box 1 (Nonemployee compensation) = sum of this payee's NEC-1 payments for the year.
|
||||||
|
var detail = await _report.GetRecipientDetailAsync(payeeId, taxYear);
|
||||||
|
var box1Nec = detail?.Payments
|
||||||
|
.Where(payment => payment.BoxCode == Entities.Form1099.BoxNec1)
|
||||||
|
.Sum(payment => payment.Amount) ?? 0m;
|
||||||
|
|
||||||
|
using var server = new RichEditDocumentServer();
|
||||||
|
var document = server.Document;
|
||||||
|
document.BeginUpdate();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
document.Unit = DocumentUnit.Inch;
|
||||||
|
var section = document.Sections[0];
|
||||||
|
section.Page.Width = 8.5f;
|
||||||
|
section.Page.Height = 11f;
|
||||||
|
section.Margins.Left = section.Margins.Right = 0.8f;
|
||||||
|
section.Margins.Top = section.Margins.Bottom = 0.8f;
|
||||||
|
|
||||||
|
document.AppendHtmlText(BuildCopyBHtml(church, payee, taxYear, box1Nec));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
document.EndUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
server.ExportToPdf(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
return (stream, "application/pdf", $"1099-NEC-{payeeId}-{taxYear}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear)
|
||||||
|
{
|
||||||
|
var summary = await _report.GetAnnualSummaryAsync(taxYear);
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold");
|
||||||
|
foreach (var row in summary.Rows)
|
||||||
|
{
|
||||||
|
builder.AppendLine(string.Join(",",
|
||||||
|
Csv(row.LegalName), Csv(row.TinLast4 ?? ""), Csv(row.W9Status),
|
||||||
|
row.NecTotal.ToString(CultureInfo.InvariantCulture),
|
||||||
|
row.RentsTotal.ToString(CultureInfo.InvariantCulture),
|
||||||
|
row.GrandTotal.ToString(CultureInfo.InvariantCulture),
|
||||||
|
row.MeetsThreshold ? "Y" : "N"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||||
|
return (new MemoryStream(bytes), "text/csv", $"1099-filing-{taxYear}.csv");
|
||||||
|
|
||||||
|
static string Csv(string value) => value.Contains(',') || value.Contains('"')
|
||||||
|
? "\"" + value.Replace("\"", "\"\"") + "\"" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCopyBHtml(ChurchProfile church, Payee1099Dto payee, int taxYear, decimal box1Nec)
|
||||||
|
{
|
||||||
|
var payerAddress = JoinAddress(church.Address, church.City, church.State, church.ZipCode);
|
||||||
|
var recipientAddress = JoinAddress(
|
||||||
|
JoinLines(payee.AddressLine1, payee.AddressLine2), payee.City, payee.State, payee.Zip);
|
||||||
|
|
||||||
|
var payerEin = string.IsNullOrWhiteSpace(church.PayerEin) ? "" : church.PayerEin;
|
||||||
|
var maskedTin = string.IsNullOrWhiteSpace(payee.TinLast4) ? "" : $"***-**-{payee.TinLast4}";
|
||||||
|
|
||||||
|
return
|
||||||
|
"<div style=\"font-family:Arial;font-size:11pt;color:#111;\">" +
|
||||||
|
$"<h2 style=\"text-align:center;margin:0;\">Form 1099-NEC — Copy B (For Recipient)</h2>" +
|
||||||
|
$"<p style=\"text-align:center;margin:4px 0 16px 0;\"><b>Tax Year {taxYear}</b><br/>Nonemployee Compensation</p>" +
|
||||||
|
|
||||||
|
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"6\" width=\"100%\" style=\"border-collapse:collapse;\">" +
|
||||||
|
|
||||||
|
"<tr><td width=\"50%\" valign=\"top\">" +
|
||||||
|
"<b>PAYER’s name, address</b><br/>" +
|
||||||
|
$"{Encode(church.Name)}<br/>{payerAddress}" +
|
||||||
|
"</td>" +
|
||||||
|
"<td width=\"50%\" valign=\"top\">" +
|
||||||
|
$"<b>PAYER’s TIN (EIN)</b><br/>{Encode(payerEin)}" +
|
||||||
|
"</td></tr>" +
|
||||||
|
|
||||||
|
"<tr><td valign=\"top\">" +
|
||||||
|
"<b>RECIPIENT’s name, address</b><br/>" +
|
||||||
|
$"{Encode(payee.LegalName)}<br/>{recipientAddress}" +
|
||||||
|
"</td>" +
|
||||||
|
"<td valign=\"top\">" +
|
||||||
|
$"<b>RECIPIENT’s TIN</b><br/>{Encode(maskedTin)}" +
|
||||||
|
"</td></tr>" +
|
||||||
|
|
||||||
|
"<tr><td colspan=\"2\">" +
|
||||||
|
"<b>Box 1 — Nonemployee compensation</b><br/>" +
|
||||||
|
$"<span style=\"font-size:14pt;\"><b>{Encode(FormatCurrency(box1Nec))}</b></span>" +
|
||||||
|
"</td></tr>" +
|
||||||
|
|
||||||
|
"</table>" +
|
||||||
|
|
||||||
|
"<p style=\"font-size:8pt;color:#555;margin-top:12px;\">" +
|
||||||
|
"This is important tax information and is being furnished to the recipient. " +
|
||||||
|
"Recipient’s taxpayer identification number is shown masked for security." +
|
||||||
|
"</p>" +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
|
||||||
|
|
||||||
|
private static string FormatCurrency(decimal amount) =>
|
||||||
|
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
|
||||||
|
|
||||||
|
private static string? JoinLines(string? line1, string? line2)
|
||||||
|
{
|
||||||
|
var parts = new[] { line1, line2 }.Where(part => !string.IsNullOrWhiteSpace(part));
|
||||||
|
var joined = string.Join(", ", parts);
|
||||||
|
return string.IsNullOrWhiteSpace(joined) ? null : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds an HTML address block; each text part is HTML-encoded and the line break (<br/>) is literal.
|
||||||
|
private static string JoinAddress(string? address, string? city, string? state, string? zip)
|
||||||
|
{
|
||||||
|
var cityLine = string.Join(", ",
|
||||||
|
new[] { city, string.Join(" ", new[] { state, zip }.Where(part => !string.IsNullOrWhiteSpace(part))) }
|
||||||
|
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||||
|
var lines = new[] { address, cityLine }
|
||||||
|
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||||
|
.Select(Encode);
|
||||||
|
return string.Join("<br/>", lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface I1099FormService
|
||||||
|
{
|
||||||
|
/// <summary>Recipient Copy B 1099-NEC PDF for one payee/year (plain paper).</summary>
|
||||||
|
Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear);
|
||||||
|
|
||||||
|
/// <summary>Filing-data CSV (one row per reportable recipient) for IRIS/accountant.</summary>
|
||||||
|
Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only aggregation producing the year-end 1099 recipient summary. CASH BASIS:
|
||||||
|
/// only Paid expenses whose PaidAt falls in the tax year, attributed to a tracked payee,
|
||||||
|
/// on a line whose category maps to a 1099 box (sub ?? group). Unmapped lines are excluded.
|
||||||
|
/// </summary>
|
||||||
|
public class Form1099ReportService : IForm1099ReportService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public Form1099ReportService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<List<Form1099BoxDto>> GetBoxesAsync() =>
|
||||||
|
await _db.Form1099Boxes.AsNoTracking().Where(b => b.IsActive)
|
||||||
|
.OrderBy(b => b.SortOrder)
|
||||||
|
.Select(b => new Form1099BoxDto
|
||||||
|
{
|
||||||
|
Id = b.Id, BoxCode = b.BoxCode, Name_en = b.Name_en,
|
||||||
|
Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder,
|
||||||
|
}).ToListAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls the reportable expense lines for the tax year and materializes them (anonymous
|
||||||
|
/// projection -> ToListAsync -> in-memory map), mirroring Form990ReportService so the SQL
|
||||||
|
/// translation stays simple on Npgsql. The tax year is a half-open UTC range
|
||||||
|
/// [Jan 1 taxYear, Jan 1 taxYear+1), deterministic regardless of server timezone and matching
|
||||||
|
/// how Expense.PaidAt is written (midnight UTC). Unmapped lines (no 1099 box) are dropped here
|
||||||
|
/// so callers always receive reportable lines.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<PaidLine>> LoadReportableLinesAsync(int taxYear)
|
||||||
|
{
|
||||||
|
var start = new DateTimeOffset(new DateTime(taxYear, 1, 1), TimeSpan.Zero);
|
||||||
|
var end = start.AddYears(1);
|
||||||
|
var raw = await (
|
||||||
|
from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null
|
||||||
|
&& e.PaidAt >= start && e.PaidAt < end && e.PayeeId != null)
|
||||||
|
join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id
|
||||||
|
join l in _db.ExpenseLines on e.Id equals l.ExpenseId
|
||||||
|
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
|
||||||
|
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
PayeeId = p.Id,
|
||||||
|
p.LegalName,
|
||||||
|
p.TinLast4,
|
||||||
|
p.W9Status,
|
||||||
|
PaidAt = e.PaidAt!.Value,
|
||||||
|
e.Description,
|
||||||
|
GroupName = grp.Name_en,
|
||||||
|
SubName = sub.Name_en,
|
||||||
|
l.Amount,
|
||||||
|
BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId,
|
||||||
|
}).ToListAsync();
|
||||||
|
|
||||||
|
return raw.Where(x => x.BoxId != null)
|
||||||
|
.Select(x => new PaidLine
|
||||||
|
{
|
||||||
|
PayeeId = x.PayeeId,
|
||||||
|
LegalName = x.LegalName,
|
||||||
|
TinLast4 = x.TinLast4,
|
||||||
|
W9Status = x.W9Status,
|
||||||
|
PaidAt = x.PaidAt,
|
||||||
|
Description = x.Description,
|
||||||
|
CategoryName = x.GroupName + " / " + x.SubName,
|
||||||
|
Amount = x.Amount,
|
||||||
|
BoxId = x.BoxId,
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear)
|
||||||
|
{
|
||||||
|
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
|
||||||
|
var lines = await LoadReportableLinesAsync(taxYear);
|
||||||
|
|
||||||
|
var dto = new Form1099SummaryDto { TaxYear = taxYear };
|
||||||
|
foreach (var g in lines.GroupBy(x => x.PayeeId))
|
||||||
|
{
|
||||||
|
var first = g.First();
|
||||||
|
var nec = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxNec1).Sum(x => x.Amount);
|
||||||
|
var rents = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxMisc1).Sum(x => x.Amount);
|
||||||
|
var w9Missing = first.W9Status != Form1099.W9Status.OnFile;
|
||||||
|
var meets = nec >= Form1099.ReportingThreshold || rents >= Form1099.ReportingThreshold;
|
||||||
|
dto.Rows.Add(new Form1099RecipientRowDto
|
||||||
|
{
|
||||||
|
PayeeId = first.PayeeId, LegalName = first.LegalName, TinLast4 = first.TinLast4,
|
||||||
|
W9Status = first.W9Status, NecTotal = nec, RentsTotal = rents,
|
||||||
|
GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ThenBy(r => r.LegalName).ToList();
|
||||||
|
dto.TotalReportable = dto.Rows.Sum(r => r.GrandTotal);
|
||||||
|
dto.RecipientsAtThreshold = dto.Rows.Count(r => r.MeetsThreshold);
|
||||||
|
dto.RecipientsMissingW9 = dto.Rows.Count(r => r.W9Missing);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
|
||||||
|
{
|
||||||
|
var payee = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(p => p.Id == payeeId);
|
||||||
|
if (payee is null) return null;
|
||||||
|
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
|
||||||
|
var lines = (await LoadReportableLinesAsync(taxYear)).Where(x => x.PayeeId == payeeId).ToList();
|
||||||
|
|
||||||
|
return new Form1099RecipientDetailDto
|
||||||
|
{
|
||||||
|
PayeeId = payee.Id, LegalName = payee.LegalName, TinLast4 = payee.TinLast4,
|
||||||
|
W9Status = payee.W9Status, TaxYear = taxYear,
|
||||||
|
Payments = lines.OrderBy(x => x.PaidAt).Select(x => new Form1099PaymentDto
|
||||||
|
{
|
||||||
|
PaidDate = DateOnly.FromDateTime(x.PaidAt.Date).ToString("yyyy-MM-dd"),
|
||||||
|
Description = x.Description, CategoryName = x.CategoryName,
|
||||||
|
BoxCode = boxes.GetValueOrDefault(x.BoxId!.Value) ?? "", Amount = x.Amount,
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PaidLine
|
||||||
|
{
|
||||||
|
public int PayeeId { get; set; }
|
||||||
|
public string LegalName { get; set; } = "";
|
||||||
|
public string? TinLast4 { get; set; }
|
||||||
|
public string W9Status { get; set; } = "";
|
||||||
|
public DateTimeOffset PaidAt { get; set; }
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public int? BoxId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IExpenseSnapshotService
|
||||||
|
{
|
||||||
|
Task<List<ExpenseSnapshotDto>> GetAllAsync();
|
||||||
|
Task<ExpenseSnapshotDto?> GetByIdAsync(int id);
|
||||||
|
Task<int> CreateAsync(CreateExpenseSnapshotRequest r);
|
||||||
|
Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r);
|
||||||
|
Task DeleteAsync(int id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IForm1099ReportService
|
||||||
|
{
|
||||||
|
Task<List<Form1099BoxDto>> GetBoxesAsync();
|
||||||
|
Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear);
|
||||||
|
Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IPayee1099Service
|
||||||
|
{
|
||||||
|
Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive);
|
||||||
|
Task<Payee1099Dto?> GetByIdAsync(int id);
|
||||||
|
Task<int> CreateAsync(SavePayee1099Request r);
|
||||||
|
Task UpdateAsync(int id, SavePayee1099Request r);
|
||||||
|
Task DeleteAsync(int id);
|
||||||
|
/// <summary>Full decrypted TIN. Caller must be authorized (gated at controller).</summary>
|
||||||
|
Task<string?> RevealTinAsync(int id);
|
||||||
|
/// <summary>Stores the uploaded W-9 blob and records its path. Throws KeyNotFoundException if the payee is missing.</summary>
|
||||||
|
Task SaveW9Async(int id, Stream content, string fileName);
|
||||||
|
/// <summary>Opens the stored W-9 blob; null when none is attached.</summary>
|
||||||
|
Task<(Stream stream, string contentType)?> OpenW9Async(int id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class Payee1099Service : IPayee1099Service
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITinProtector _tin;
|
||||||
|
private readonly IFileStorage _storage;
|
||||||
|
|
||||||
|
public Payee1099Service(AppDbContext db, ITinProtector tin, IFileStorage storage)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tin = tin;
|
||||||
|
_storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive)
|
||||||
|
{
|
||||||
|
var q = _db.Payee1099s.AsNoTracking().Include(p => p.Member).AsQueryable();
|
||||||
|
if (!includeInactive) q = q.Where(p => p.IsActive);
|
||||||
|
return await q.OrderBy(p => p.LegalName).Select(p => new Payee1099ListItemDto
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
LegalName = p.LegalName,
|
||||||
|
DisplayName = p.DisplayName,
|
||||||
|
MemberId = p.MemberId,
|
||||||
|
MemberName = p.Member != null ? p.Member.FirstName_en + " " + p.Member.LastName_en : null,
|
||||||
|
TaxClassification = p.TaxClassification,
|
||||||
|
Is1099Tracked = p.Is1099Tracked,
|
||||||
|
TinType = p.TinType,
|
||||||
|
TinLast4 = p.TinLast4,
|
||||||
|
W9Status = p.W9Status,
|
||||||
|
IsActive = p.IsActive,
|
||||||
|
}).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Payee1099Dto?> GetByIdAsync(int id)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.AsNoTracking().Include(x => x.Member).FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (p is null) return null;
|
||||||
|
return new Payee1099Dto
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
LegalName = p.LegalName,
|
||||||
|
DisplayName = p.DisplayName,
|
||||||
|
MemberId = p.MemberId,
|
||||||
|
MemberName = p.Member != null ? $"{p.Member.FirstName_en} {p.Member.LastName_en}" : null,
|
||||||
|
TaxClassification = p.TaxClassification,
|
||||||
|
Is1099Tracked = p.Is1099Tracked,
|
||||||
|
TinType = p.TinType,
|
||||||
|
TinLast4 = p.TinLast4,
|
||||||
|
W9Status = p.W9Status,
|
||||||
|
IsActive = p.IsActive,
|
||||||
|
AddressLine1 = p.AddressLine1,
|
||||||
|
AddressLine2 = p.AddressLine2,
|
||||||
|
City = p.City,
|
||||||
|
State = p.State,
|
||||||
|
Zip = p.Zip,
|
||||||
|
Email = p.Email,
|
||||||
|
Phone = p.Phone,
|
||||||
|
W9ReceivedDate = p.W9ReceivedDate?.ToString("yyyy-MM-dd"),
|
||||||
|
HasW9Document = p.W9BlobPath != null,
|
||||||
|
Notes = p.Notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(SavePayee1099Request r)
|
||||||
|
{
|
||||||
|
var p = new Payee1099();
|
||||||
|
Apply(p, r);
|
||||||
|
_db.Payee1099s.Add(p);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return p.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, SavePayee1099Request r)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
|
||||||
|
Apply(p, r);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
|
||||||
|
p.IsDeleted = true;
|
||||||
|
p.DeletedAt = DateTimeOffset.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> RevealTinAsync(int id)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
return p?.TinEncrypted is null ? null : _tin.Unprotect(p.TinEncrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveW9Async(int id, Stream content, string fileName)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
|
||||||
|
|
||||||
|
// Mirror the expense-receipt blob convention: a stable per-record path under a feature folder,
|
||||||
|
// preserving the original extension. Re-uploads overwrite the prior blob.
|
||||||
|
var ext = Path.GetExtension(fileName);
|
||||||
|
var path = $"finance/w9/{p.Id}{ext}";
|
||||||
|
if (p.W9BlobPath != null && p.W9BlobPath != path)
|
||||||
|
await _storage.DeleteAsync(p.W9BlobPath);
|
||||||
|
var saved = await _storage.SaveAsync(content, path);
|
||||||
|
p.W9BlobPath = saved;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Stream stream, string contentType)?> OpenW9Async(int id)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (p?.W9BlobPath is null) return null;
|
||||||
|
var stream = await _storage.OpenReadAsync(p.W9BlobPath);
|
||||||
|
if (stream is null) return null;
|
||||||
|
var ext = Path.GetExtension(p.W9BlobPath).ToLowerInvariant();
|
||||||
|
var contentType = ext switch
|
||||||
|
{
|
||||||
|
".png" => "image/png", ".webp" => "image/webp", ".pdf" => "application/pdf",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
};
|
||||||
|
return (stream, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps request fields onto the entity. A null/blank Tin leaves the existing ciphertext untouched (update case).
|
||||||
|
private void Apply(Payee1099 p, SavePayee1099Request r)
|
||||||
|
{
|
||||||
|
p.LegalName = r.LegalName;
|
||||||
|
p.DisplayName = r.DisplayName;
|
||||||
|
p.MemberId = r.MemberId;
|
||||||
|
p.TaxClassification = r.TaxClassification;
|
||||||
|
p.Is1099Tracked = r.Is1099Tracked;
|
||||||
|
p.TinType = r.TinType;
|
||||||
|
p.AddressLine1 = r.AddressLine1;
|
||||||
|
p.AddressLine2 = r.AddressLine2;
|
||||||
|
p.City = r.City;
|
||||||
|
p.State = r.State;
|
||||||
|
p.Zip = r.Zip;
|
||||||
|
p.Email = r.Email;
|
||||||
|
p.Phone = r.Phone;
|
||||||
|
p.W9Status = r.W9Status;
|
||||||
|
p.W9ReceivedDate = r.W9ReceivedDate;
|
||||||
|
p.IsActive = r.IsActive;
|
||||||
|
p.Notes = r.Notes;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.Tin))
|
||||||
|
{
|
||||||
|
p.TinEncrypted = _tin.Protect(r.Tin);
|
||||||
|
p.TinLast4 = TinProtector.Last4(r.Tin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ROLAC.API.Services.Security;
|
||||||
|
|
||||||
|
/// <summary>Reversible protection for taxpayer identification numbers (SSN/EIN).</summary>
|
||||||
|
public interface ITinProtector
|
||||||
|
{
|
||||||
|
string Protect(string plaintext);
|
||||||
|
string Unprotect(string ciphertext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Security;
|
||||||
|
|
||||||
|
public class TinProtector : ITinProtector
|
||||||
|
{
|
||||||
|
private readonly IDataProtector _protector;
|
||||||
|
|
||||||
|
public TinProtector(IDataProtectionProvider provider)
|
||||||
|
=> _protector = provider.CreateProtector("Payee1099.Tin");
|
||||||
|
|
||||||
|
public string Protect(string plaintext) => _protector.Protect(plaintext);
|
||||||
|
public string Unprotect(string ciphertext) => _protector.Unprotect(ciphertext);
|
||||||
|
|
||||||
|
/// <summary>Last four digits of a TIN (ignoring dashes/spaces); null/empty in => null.</summary>
|
||||||
|
public static string? Last4(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
var digits = new string(raw.Where(char.IsDigit).ToArray());
|
||||||
|
return digits.Length <= 4 ? digits : digits[^4..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,5 +55,38 @@
|
|||||||
},
|
},
|
||||||
"Ai": {
|
"Ai": {
|
||||||
"Provider": "Claude"
|
"Provider": "Claude"
|
||||||
|
},
|
||||||
|
"CheckPrint": {
|
||||||
|
"//": "Field coordinates (inches) for pre-printed three-stub check stock. X = from page left edge; OffsetY = within the field's stub, added to the stub origin. Tune to match your stock, then restart — no recompile.",
|
||||||
|
"Layout": {
|
||||||
|
"//cal": "TextInset* compensates for the fixed inset the TextBox adds around its text so configured X/Y == actual ink. To recalibrate, set both to 0, print, measure the drift past a known X/Y, and enter the differences here.",
|
||||||
|
"TextInsetX": 0.13,
|
||||||
|
"TextInsetY": 0.15,
|
||||||
|
"CheckOriginY": 0.0,
|
||||||
|
"Receipt1OriginY": 3.67,
|
||||||
|
"Receipt2OriginY": 7.33,
|
||||||
|
"Payee": { "X": 1.1, "OffsetY": 1.35, "FontSize": 11, "Bold": true },
|
||||||
|
"AmountNumeric": { "X": 7.0, "OffsetY": 1.35, "FontSize": 12, "Bold": true },
|
||||||
|
"AmountWords": { "X": 0.30, "OffsetY": 1.67, "FontSize": 10 },
|
||||||
|
"Memo": { "X": 0.60, "OffsetY": 2.85, "FontSize": 9 },
|
||||||
|
"CheckDate": { "X": 7.00, "OffsetY": 0.90, "FontSize": 10 },
|
||||||
|
"ReceiptPayee": { "X": 1.00, "OffsetY": 0.30, "FontSize": 10, "Bold": true },
|
||||||
|
"ReceiptAmount": { "X": 6.50, "OffsetY": 0.30, "FontSize": 10, "Bold": true },
|
||||||
|
"ReceiptMemo": { "X": 1.00, "OffsetY": 0.60, "FontSize": 9 },
|
||||||
|
"ReceiptDate": { "X": 6.50, "OffsetY": 0.60, "FontSize": 9 },
|
||||||
|
"Grid": {
|
||||||
|
"OriginX": 0.60,
|
||||||
|
"OffsetY": 1.10,
|
||||||
|
"RowHeight": 0.22,
|
||||||
|
"ColumnGap": 0.30,
|
||||||
|
"DateWidth": 0.85,
|
||||||
|
"DescWidth": 2.10,
|
||||||
|
"AmountWidth": 0.80,
|
||||||
|
"ShowGridHeaders": true,
|
||||||
|
"HeaderOffsetY": 0.88,
|
||||||
|
"OverflowOffsetY": 2.55,
|
||||||
|
"FontSize": 8.5
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GivingsPageComponent } from './features/giving/pages/givings-page/givin
|
|||||||
import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component';
|
import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component';
|
||||||
import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense-categories-page/expense-categories-page.component';
|
import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense-categories-page/expense-categories-page.component';
|
||||||
import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component';
|
import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component';
|
||||||
|
import { ExpenseSnapshotsPageComponent } from './features/expense/pages/expense-snapshots-page/expense-snapshots-page.component';
|
||||||
import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component';
|
import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component';
|
||||||
import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component';
|
import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component';
|
||||||
import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component';
|
import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component';
|
||||||
@@ -21,6 +22,8 @@ import { DisbursementPageComponent } from './features/disbursement/pages/disburs
|
|||||||
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
|
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
|
||||||
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
||||||
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
|
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
|
||||||
|
import { Form1099ReportPageComponent } from './features/finance-report/pages/form1099-report-page/form1099-report-page.component';
|
||||||
|
import { Payee1099PageComponent } from './features/payee1099/pages/payee-1099-page/payee-1099-page.component';
|
||||||
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
||||||
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
||||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||||
@@ -162,6 +165,17 @@ export const routes: Routes = [
|
|||||||
title: 'Expenses', titleZh: '支出', section: 'Finance',
|
title: 'Expenses', titleZh: '支出', section: 'Finance',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'finance/expense-snapshots',
|
||||||
|
component: ExpenseSnapshotsPageComponent,
|
||||||
|
canActivate: [PermissionGuard],
|
||||||
|
data: {
|
||||||
|
// Snapshots are a write-only management surface (the API gates every action on
|
||||||
|
// Expenses:Write), so require write — a read-only user has nothing to do here.
|
||||||
|
permission: { module: PermissionModules.Expenses, action: 'write' },
|
||||||
|
title: 'Expense Snapshots', titleZh: '費用範本', section: 'Finance',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/expense-categories',
|
path: 'finance/expense-categories',
|
||||||
component: ExpenseCategoriesPageComponent,
|
component: ExpenseCategoriesPageComponent,
|
||||||
@@ -216,6 +230,24 @@ export const routes: Routes = [
|
|||||||
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
|
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'finance/payee-1099',
|
||||||
|
component: Payee1099PageComponent,
|
||||||
|
canActivate: [PermissionGuard],
|
||||||
|
data: {
|
||||||
|
permission: { module: PermissionModules.Form1099, action: 'read' },
|
||||||
|
title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'finance/form1099-report',
|
||||||
|
component: Form1099ReportPageComponent,
|
||||||
|
canActivate: [PermissionGuard],
|
||||||
|
data: {
|
||||||
|
permission: { module: PermissionModules.Form1099, action: 'read' },
|
||||||
|
title: '1099 Year-End Report', titleZh: '1099 年度報表', section: 'Finance',
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const PermissionModules = {
|
|||||||
SystemLogs: 'SystemLogs',
|
SystemLogs: 'SystemLogs',
|
||||||
AuditLogs: 'AuditLogs',
|
AuditLogs: 'AuditLogs',
|
||||||
Settings: 'Settings',
|
Settings: 'Settings',
|
||||||
|
Form1099: 'Form1099',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export interface ChurchProfileDto {
|
|||||||
id: number; name: string; nameZh: string | null; phone: string | null;
|
id: number; name: string; nameZh: string | null; phone: string | null;
|
||||||
email: string | null; website: string | null; address: string | null; city: string | null;
|
email: string | null; website: string | null; address: string | null; city: string | null;
|
||||||
state: string | null; zipCode: string | null; bankName: string | null;
|
state: string | null; zipCode: string | null; bankName: string | null;
|
||||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
bankAccountNumber: string | null; bankRoutingNumber: string | null; payerEin: string | null; nextCheckNumber: number;
|
||||||
aiProvider: string;
|
aiProvider: string;
|
||||||
claudeModel: string | null; claudeApiKeyMasked: string | null;
|
claudeModel: string | null; claudeApiKeyMasked: string | null;
|
||||||
geminiModel: string | null; geminiApiKeyMasked: string | null;
|
geminiModel: string | null; geminiApiKeyMasked: string | null;
|
||||||
|
|||||||
+4
@@ -55,6 +55,10 @@
|
|||||||
Routing # / 路由號碼
|
Routing # / 路由號碼
|
||||||
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Payer EIN / 雇主識別號 (EIN)
|
||||||
|
<kendo-textbox [(ngModel)]="model.payerEin" placeholder="XX-XXXXXXX"></kendo-textbox>
|
||||||
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
Next Check # / 下一張支票號碼
|
Next Check # / 下一張支票號碼
|
||||||
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||||
|
|||||||
+36
-6
@@ -5,6 +5,19 @@
|
|||||||
|
|
||||||
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
|
||||||
|
<!-- Snapshot tools (vendor mode): quick-load a saved template, or save the current form -->
|
||||||
|
<div *ngIf="showSnapshotTools" class="md:col-span-2 flex flex-wrap items-end gap-2 rounded border border-gray-200 bg-gray-50 p-2">
|
||||||
|
<label class="flex flex-1 min-w-[14rem] flex-col gap-1">範本 / Load from snapshot
|
||||||
|
<kendo-dropdownlist [data]="snapshots" textField="name" valueField="id" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="selectedSnapshotId" (valueChange)="applySnapshot($event)"
|
||||||
|
[defaultItem]="{ id: null, name: '-- 選擇範本 / Select a saved snapshot --' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="!isValid" (click)="openSnapshotPrompt()"
|
||||||
|
title="Save the current form as a reusable snapshot / 儲存為範本">💾 存為範本 / Save as snapshot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
||||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||||
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
||||||
@@ -115,11 +128,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Per-line AI assist: translate this line's note + suggest its category from its own amount -->
|
|
||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Per-line suggestion card: the "suggest & confirm" step for this line -->
|
<!-- Per-line suggestion card: the "suggest & confirm" step for this line -->
|
||||||
<div *ngIf="hasLineSuggestion(line)"
|
<div *ngIf="hasLineSuggestion(line)"
|
||||||
class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||||
@@ -150,7 +158,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Vendor mode: vendor name + check number -->
|
<!-- Vendor mode: vendor name + check number + optional 1099 recipient -->
|
||||||
<ng-container *ngIf="mode === 'vendor'">
|
<ng-container *ngIf="mode === 'vendor'">
|
||||||
<label class="flex flex-col gap-1">Vendor Name
|
<label class="flex flex-col gap-1">Vendor Name
|
||||||
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
|
||||||
@@ -158,6 +166,12 @@
|
|||||||
<label class="flex flex-col gap-1">Check #
|
<label class="flex flex-col gap-1">Check #
|
||||||
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">1099 Recipient / 1099 收款人 <span class="text-gray-400 font-normal">(optional)</span>
|
||||||
|
<kendo-dropdownlist [data]="payees" textField="legalName" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, legalName: '— none —' }"
|
||||||
|
[(ngModel)]="form.payeeId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Reimbursement mode: receipt file input -->
|
<!-- Reimbursement mode: receipt file input -->
|
||||||
@@ -211,3 +225,19 @@
|
|||||||
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
||||||
</kendo-dialog-actions>
|
</kendo-dialog-actions>
|
||||||
</kendo-dialog>
|
</kendo-dialog>
|
||||||
|
|
||||||
|
<!-- Save-as-snapshot name prompt -->
|
||||||
|
<kendo-dialog *ngIf="showSnapshotNamePrompt" title="存為範本 / Save as Snapshot" [width]="420" [maxWidth]="'95vw'"
|
||||||
|
(close)="cancelSnapshotPrompt()">
|
||||||
|
<div class="flex flex-col gap-2 p-2">
|
||||||
|
<label class="flex flex-col gap-1">名稱 / Name
|
||||||
|
<kendo-textbox [(ngModel)]="snapshotName" placeholder="e.g. Monthly Rent — Landlord X"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500">費用日期不會存入範本 / The Expense Date is not saved in a snapshot.</p>
|
||||||
|
</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancelSnapshotPrompt()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="!snapshotName.trim() || snapshotSaving"
|
||||||
|
(click)="saveSnapshot()">{{ snapshotSaving ? '儲存中… / Saving…' : 'Save' }}</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
+91
@@ -10,9 +10,13 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
|||||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
import { ExpenseApiService } from '../../services/expense-api.service';
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
|
import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
|
||||||
|
import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model';
|
||||||
import { ExpenseAiService } from '../../services/expense-ai.service';
|
import { ExpenseAiService } from '../../services/expense-ai.service';
|
||||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||||
|
import { Payee1099ApiService } from '../../../payee1099/services/payee1099-api.service';
|
||||||
|
import { Payee1099ListItem } from '../../../payee1099/models/payee1099.model';
|
||||||
import {
|
import {
|
||||||
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
||||||
ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
|
ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
|
||||||
@@ -64,6 +68,21 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
ministries: MinistryDto[] = [];
|
ministries: MinistryDto[] = [];
|
||||||
groups: ExpenseCategoryGroupDto[] = [];
|
groups: ExpenseCategoryGroupDto[] = [];
|
||||||
|
|
||||||
|
payees: Payee1099ListItem[] = [];
|
||||||
|
|
||||||
|
/** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */
|
||||||
|
snapshots: ExpenseSnapshotDto[] = [];
|
||||||
|
/** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */
|
||||||
|
selectedSnapshotId: number | null = null;
|
||||||
|
|
||||||
|
/** "Save as snapshot" name-prompt state. */
|
||||||
|
showSnapshotNamePrompt = false;
|
||||||
|
snapshotName = '';
|
||||||
|
snapshotSaving = false;
|
||||||
|
|
||||||
|
/** Snapshot tools (load/save) are a vendor-payment feature only. */
|
||||||
|
get showSnapshotTools(): boolean { return this.mode === 'vendor'; }
|
||||||
|
|
||||||
memberResults: MemberOption[] = [];
|
memberResults: MemberOption[] = [];
|
||||||
|
|
||||||
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
||||||
@@ -80,6 +99,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
vendorName: '',
|
vendorName: '',
|
||||||
checkNumber: '',
|
checkNumber: '',
|
||||||
memberId: null as number | null,
|
memberId: null as number | null,
|
||||||
|
payeeId: null as number | null,
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
};
|
};
|
||||||
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
|
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
|
||||||
@@ -116,12 +136,16 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
private catApi: ExpenseCategoryApiService,
|
private catApi: ExpenseCategoryApiService,
|
||||||
private memberApi: MemberApiService,
|
private memberApi: MemberApiService,
|
||||||
private expenseApi: ExpenseApiService,
|
private expenseApi: ExpenseApiService,
|
||||||
|
private snapshotApi: ExpenseSnapshotApiService,
|
||||||
private aiApi: ExpenseAiService,
|
private aiApi: ExpenseAiService,
|
||||||
|
private payeeApi: Payee1099ApiService,
|
||||||
private sanitizer: DomSanitizer,
|
private sanitizer: DomSanitizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||||
|
this.payeeApi.getAll(false).subscribe(list => (this.payees = list));
|
||||||
|
if (this.showSnapshotTools) this.loadSnapshots();
|
||||||
this.catApi.getAll(false).subscribe(groups => {
|
this.catApi.getAll(false).subscribe(groups => {
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
||||||
@@ -152,6 +176,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
vendorName: expense.vendorName ?? '',
|
vendorName: expense.vendorName ?? '',
|
||||||
checkNumber: expense.checkNumber ?? '',
|
checkNumber: expense.checkNumber ?? '',
|
||||||
memberId: expense.memberId,
|
memberId: expense.memberId,
|
||||||
|
payeeId: expense.payeeId ?? null,
|
||||||
expenseDate: new Date(year, month - 1, day),
|
expenseDate: new Date(year, month - 1, day),
|
||||||
};
|
};
|
||||||
this.lines = (expense.lines ?? []).map(l => ({
|
this.lines = (expense.lines ?? []).map(l => ({
|
||||||
@@ -315,6 +340,71 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.receiptPdfUrl = null;
|
this.receiptPdfUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadSnapshots(): void {
|
||||||
|
this.snapshotApi.getAll().subscribe(list => (this.snapshots = list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a saved snapshot: prefill header + lines, but keep today's Expense Date. */
|
||||||
|
applySnapshot(snapshotId: number | null): void {
|
||||||
|
if (snapshotId == null) return;
|
||||||
|
this.snapshotApi.getById(snapshotId).subscribe(s => {
|
||||||
|
this.form.ministryId = s.ministryId;
|
||||||
|
this.form.description = s.description;
|
||||||
|
this.form.vendorName = s.vendorName ?? '';
|
||||||
|
this.form.checkNumber = s.checkNumber ?? '';
|
||||||
|
// Expense Date is intentionally NOT taken from the snapshot — leave it as-is (today).
|
||||||
|
this.lines = s.lines.map(line => ({
|
||||||
|
categoryGroupId: line.categoryGroupId,
|
||||||
|
subCategoryId: line.subCategoryId,
|
||||||
|
amount: line.amount,
|
||||||
|
description: line.description ?? '',
|
||||||
|
functionalClass: line.functionalClass,
|
||||||
|
subs: this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [],
|
||||||
|
}));
|
||||||
|
if (this.lines.length === 0) this.lines = [this.emptyLine()];
|
||||||
|
this.selectedSnapshotId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the name prompt for saving the current form as a snapshot (requires a valid form). */
|
||||||
|
openSnapshotPrompt(): void {
|
||||||
|
if (!this.isValid) return;
|
||||||
|
this.snapshotName = '';
|
||||||
|
this.showSnapshotNamePrompt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSnapshotPrompt(): void { this.showSnapshotNamePrompt = false; }
|
||||||
|
|
||||||
|
/** Save the current header + lines as a named snapshot (Expense Date is not stored). */
|
||||||
|
saveSnapshot(): void {
|
||||||
|
const name = this.snapshotName.trim();
|
||||||
|
if (!name || this.snapshotSaving) return;
|
||||||
|
const request: CreateExpenseSnapshotRequest = {
|
||||||
|
name,
|
||||||
|
ministryId: this.form.ministryId!,
|
||||||
|
lines: this.lines.map(l => ({
|
||||||
|
categoryGroupId: l.categoryGroupId!,
|
||||||
|
subCategoryId: l.subCategoryId!,
|
||||||
|
amount: l.amount,
|
||||||
|
functionalClass: l.functionalClass,
|
||||||
|
description: l.description.trim() || null,
|
||||||
|
})),
|
||||||
|
description: this.form.description.trim(),
|
||||||
|
vendorName: this.form.vendorName || null,
|
||||||
|
checkNumber: this.form.checkNumber || null,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
this.snapshotSaving = true;
|
||||||
|
this.snapshotApi.create(request).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snapshotSaving = false;
|
||||||
|
this.showSnapshotNamePrompt = false;
|
||||||
|
this.loadSnapshots();
|
||||||
|
},
|
||||||
|
error: () => { this.snapshotSaving = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get isValid(): boolean {
|
get isValid(): boolean {
|
||||||
return !!this.form.ministryId
|
return !!this.form.ministryId
|
||||||
&& this.form.description.trim().length > 0
|
&& this.form.description.trim().length > 0
|
||||||
@@ -342,6 +432,7 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
|
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
|
||||||
expenseDate,
|
expenseDate,
|
||||||
notes: null,
|
notes: null,
|
||||||
|
payeeId: this.form.payeeId,
|
||||||
};
|
};
|
||||||
// The request and receipt are snapshotted here, so resetting the form right
|
// The request and receipt are snapshotted here, so resetting the form right
|
||||||
// after emitting is safe even though the parent saves asynchronously.
|
// after emitting is safe even though the parent saves asynchronously.
|
||||||
|
|||||||
+11
-1
@@ -10,7 +10,17 @@
|
|||||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
<div class="text-gray-500">Date / 日期</div><div>{{ expense.expenseDate }}</div>
|
<div class="text-gray-500">Date / 日期</div><div>{{ expense.expenseDate }}</div>
|
||||||
<div class="text-gray-500">Ministry / 事工</div><div>{{ expense.ministryName }}</div>
|
<div class="text-gray-500">Ministry / 事工</div><div>{{ expense.ministryName }}</div>
|
||||||
<div class="text-gray-500">Payee / 收款人</div><div>{{ expense.vendorName || expense.memberName || '—' }}</div>
|
<div class="text-gray-500">Payee / 收款人</div>
|
||||||
|
<div>
|
||||||
|
<ng-container *ngIf="expense.vendorName; else memberPayee">{{ expense.vendorName }}</ng-container>
|
||||||
|
<ng-template #memberPayee>
|
||||||
|
<ng-container *ngIf="expense.memberName; else dash">
|
||||||
|
<div *ngIf="expense.memberNickName">{{ expense.memberNickName }}</div>
|
||||||
|
<div [class.text-gray-500]="expense.memberNickName" [class.text-xs]="expense.memberNickName">{{ expense.memberName }}</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #dash>—</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
<div class="text-gray-500">Description / 說明</div><div>{{ expense.description }}</div>
|
<div class="text-gray-500">Description / 說明</div><div>{{ expense.description }}</div>
|
||||||
<div class="text-gray-500">Status / 狀態</div><div>{{ expense.status }}</div>
|
<div class="text-gray-500">Status / 狀態</div><div>{{ expense.status }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ExpenseLineInput, FunctionalClass } from './expense.model';
|
||||||
|
|
||||||
|
export interface ExpenseSnapshotLineDto {
|
||||||
|
categoryGroupId: number; categoryGroupName: string;
|
||||||
|
subCategoryId: number; subCategoryName: string;
|
||||||
|
functionalClass: FunctionalClass | null; amount: number; description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseSnapshotDto {
|
||||||
|
id: number; name: string; ministryId: number; ministryName: string;
|
||||||
|
description: string; vendorName: string | null; checkNumber: string | null; notes: string | null;
|
||||||
|
totalAmount: number; lineCount: number;
|
||||||
|
createdByName: string | null; createdAt: string;
|
||||||
|
lines: ExpenseSnapshotLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateExpenseSnapshotRequest {
|
||||||
|
name: string; ministryId: number; lines: ExpenseLineInput[];
|
||||||
|
description: string; vendorName: string | null; checkNumber: string | null; notes: string | null;
|
||||||
|
}
|
||||||
|
export type UpdateExpenseSnapshotRequest = CreateExpenseSnapshotRequest;
|
||||||
@@ -8,11 +8,11 @@ export interface PagedResult<T> {
|
|||||||
|
|
||||||
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
|
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
|
||||||
|
|
||||||
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; }
|
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; }
|
||||||
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; }
|
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; }
|
||||||
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
|
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; }
|
||||||
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
|
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
|
||||||
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
|
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; }
|
||||||
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
|
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
|
||||||
|
|
||||||
export interface ExpenseLineItemDto {
|
export interface ExpenseLineItemDto {
|
||||||
@@ -24,9 +24,11 @@ export interface ExpenseListItemDto {
|
|||||||
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
|
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
|
||||||
ministryId: number; ministryName: string; lineCount: number; primaryCategoryName: string;
|
ministryId: number; ministryName: string; lineCount: number; primaryCategoryName: string;
|
||||||
vendorName: string | null;
|
vendorName: string | null;
|
||||||
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
|
memberId: number | null; memberName: string | null; memberNickName: string | null;
|
||||||
|
expenseDate: string; hasReceipt: boolean;
|
||||||
checkNumber: string | null;
|
checkNumber: string | null;
|
||||||
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
|
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
|
||||||
|
payeeId: number | null;
|
||||||
}
|
}
|
||||||
export interface ExpenseDto extends ExpenseListItemDto {
|
export interface ExpenseDto extends ExpenseListItemDto {
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
@@ -69,6 +71,7 @@ export interface CreateExpenseRequest {
|
|||||||
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
|
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
|
||||||
description: string; vendorName: string | null; memberId: number | null;
|
description: string; vendorName: string | null; memberId: number | null;
|
||||||
checkNumber: string | null; expenseDate: string; notes: string | null;
|
checkNumber: string | null; expenseDate: string; notes: string | null;
|
||||||
|
payeeId: number | null;
|
||||||
}
|
}
|
||||||
export type UpdateExpenseRequest = CreateExpenseRequest;
|
export type UpdateExpenseRequest = CreateExpenseRequest;
|
||||||
export interface RejectExpenseRequest { reviewNotes: string | null; }
|
export interface RejectExpenseRequest { reviewNotes: string | null; }
|
||||||
|
|||||||
+20
-2
@@ -91,7 +91,7 @@
|
|||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1">
|
||||||
<span>Form 990 Line / 990 行</span>
|
<span>Form 990 Line / 990 行</span>
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist
|
||||||
[data]="form990Lines"
|
[data]="form990Lines"
|
||||||
@@ -100,6 +100,15 @@
|
|||||||
[(ngModel)]="groupForm.form990LineId">
|
[(ngModel)]="groupForm.form990LineId">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>1099 Box / 1099 框</span>
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="form1099Boxes"
|
||||||
|
textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, label: '— none —' }"
|
||||||
|
[(ngModel)]="groupForm.form1099BoxId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
|
||||||
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
|
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
|
||||||
</label>
|
</label>
|
||||||
@@ -158,7 +167,7 @@
|
|||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1">
|
||||||
<span>Form 990 Line / 990 行</span>
|
<span>Form 990 Line / 990 行</span>
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist
|
||||||
[data]="form990Lines"
|
[data]="form990Lines"
|
||||||
@@ -167,6 +176,15 @@
|
|||||||
[(ngModel)]="subForm.form990LineId">
|
[(ngModel)]="subForm.form990LineId">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>1099 Box / 1099 框</span>
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="form1099Boxes"
|
||||||
|
textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, label: '— none —' }"
|
||||||
|
[(ngModel)]="subForm.form1099BoxId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
|
||||||
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
|
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+11
-8
@@ -10,6 +10,7 @@ import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from
|
|||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
|
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
|
||||||
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
||||||
|
import { Form1099Box } from '../../../payee1099/models/payee1099.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expense-categories-page',
|
selector: 'app-expense-categories-page',
|
||||||
@@ -23,6 +24,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
form990Lines: Form990ExpenseLineDto[] = [];
|
form990Lines: Form990ExpenseLineDto[] = [];
|
||||||
|
form1099Boxes: (Form1099Box & { label: string })[] = [];
|
||||||
|
|
||||||
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
||||||
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
||||||
@@ -33,13 +35,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
|
|
||||||
groupDialogOpen = false;
|
groupDialogOpen = false;
|
||||||
editingGroupId: number | null = null;
|
editingGroupId: number | null = null;
|
||||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
|
||||||
groupAiLoading = false;
|
groupAiLoading = false;
|
||||||
groupAiSuggestion: CategoryAiSuggestion | null = null;
|
groupAiSuggestion: CategoryAiSuggestion | null = null;
|
||||||
|
|
||||||
subDialogOpen = false;
|
subDialogOpen = false;
|
||||||
editingSubId: number | null = null;
|
editingSubId: number | null = null;
|
||||||
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
|
||||||
subAiLoading = false;
|
subAiLoading = false;
|
||||||
subAiSuggestion: CategoryAiSuggestion | null = null;
|
subAiSuggestion: CategoryAiSuggestion | null = null;
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.load();
|
this.load();
|
||||||
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
|
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
|
||||||
|
this.api.getForm1099Boxes().subscribe(boxes => { this.form1099Boxes = boxes; });
|
||||||
}
|
}
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
@@ -111,13 +114,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
|
|
||||||
openNewGroup(): void {
|
openNewGroup(): void {
|
||||||
this.editingGroupId = null;
|
this.editingGroupId = null;
|
||||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
|
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
|
||||||
this.resetGroupAi();
|
this.resetGroupAi();
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
||||||
this.editingGroupId = g.id;
|
this.editingGroupId = g.id;
|
||||||
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
|
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId, form1099BoxId: g.form1099BoxId };
|
||||||
this.resetGroupAi();
|
this.resetGroupAi();
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
@@ -143,7 +146,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
|
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
|
||||||
saveGroup(): void {
|
saveGroup(): void {
|
||||||
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
|
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId, form1099BoxId: this.groupForm.form1099BoxId };
|
||||||
const done = () => { this.groupDialogOpen = false; this.load(); };
|
const done = () => { this.groupDialogOpen = false; this.load(); };
|
||||||
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
|
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
|
||||||
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
|
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
|
||||||
@@ -156,13 +159,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
openNewSub(): void {
|
openNewSub(): void {
|
||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
this.editingSubId = null;
|
this.editingSubId = null;
|
||||||
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
|
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
|
||||||
this.resetSubAi();
|
this.resetSubAi();
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditSub(s: ExpenseSubCategoryDto): void {
|
openEditSub(s: ExpenseSubCategoryDto): void {
|
||||||
this.editingSubId = s.id;
|
this.editingSubId = s.id;
|
||||||
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
|
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId, form1099BoxId: s.form1099BoxId };
|
||||||
this.resetSubAi();
|
this.resetSubAi();
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
@@ -195,7 +198,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
|
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
|
||||||
saveSub(): void {
|
saveSub(): void {
|
||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
|
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId, form1099BoxId: this.subForm.form1099BoxId };
|
||||||
const done = () => { this.subDialogOpen = false; this.load(); };
|
const done = () => { this.subDialogOpen = false; this.load(); };
|
||||||
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
|
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
|
||||||
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
|
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<div class="page">
|
||||||
|
<p class="mb-4 text-sm text-gray-600">
|
||||||
|
儲存常用的固定費用(房租、網路、餐費…)為範本,下次可快速套用。費用日期不會儲存。<br>
|
||||||
|
Save recurring fixed expenses as snapshots to quickly re-use them. The Expense Date is never saved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Desktop: grid -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<kendo-grid [data]="rows" [loading]="loading">
|
||||||
|
<kendo-grid-column field="name" title="Snapshot / 範本" [width]="240"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="vendorName" title="Vendor / 廠商" [width]="180">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.vendorName || '—' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="ministryName" title="Ministry / 事工"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="totalAmount" title="Amount / 金額" [width]="120" format="c2"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Created by / 建立者" [width]="200">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
{{ dataItem.createdByName || '—' }}<br>
|
||||||
|
<span class="text-xs text-gray-500">{{ dataItem.createdAt | date:'yyyy-MM-dd' }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Actions" [width]="160">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
<button kendoButton fillMode="flat" (click)="openRename(dataItem)">Rename</button>
|
||||||
|
<button kendoButton fillMode="flat" themeColor="error" (click)="openDelete(dataItem)">Delete</button>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
</kendo-grid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: card list -->
|
||||||
|
<div class="md:hidden flex flex-col gap-3">
|
||||||
|
<div *ngFor="let row of rows" class="rounded border border-gray-200 p-3 flex flex-col gap-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">{{ row.name }}</span>
|
||||||
|
<span class="tabular-nums">{{ row.totalAmount | currency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ row.vendorName || '—' }} · {{ row.ministryName }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ row.createdByName || '—' }} · {{ row.createdAt | date:'yyyy-MM-dd' }}</div>
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button kendoButton size="small" fillMode="outline" (click)="openRename(row)">Rename</button>
|
||||||
|
<button kendoButton size="small" fillMode="outline" themeColor="error" (click)="openDelete(row)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p *ngIf="!loading && rows.length === 0" class="text-sm text-gray-500">尚無範本 / No snapshots yet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename dialog -->
|
||||||
|
<kendo-dialog *ngIf="renameRow" title="重新命名 / Rename Snapshot" [width]="420" [maxWidth]="'95vw'" (close)="cancelRename()">
|
||||||
|
<label class="flex flex-col gap-1 p-2">名稱 / Name
|
||||||
|
<kendo-textbox [(ngModel)]="renameValue"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancelRename()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="!renameValue.trim() || renameSaving"
|
||||||
|
(click)="confirmRename()">{{ renameSaving ? 'Saving…' : 'Save' }}</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirm dialog -->
|
||||||
|
<kendo-dialog *ngIf="deleteRow" title="刪除 / Delete Snapshot" [width]="420" [maxWidth]="'95vw'" (close)="cancelDelete()">
|
||||||
|
<p class="p-2">確定刪除「{{ deleteRow.name }}」? / Delete "{{ deleteRow.name }}"?</p>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancelDelete()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="error" (click)="confirmDelete()">Delete</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
</div>
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
.page {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { GridModule } from '@progress/kendo-angular-grid';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
|
||||||
|
import { ExpenseSnapshotDto } from '../../models/expense-snapshot.model';
|
||||||
|
import { switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-snapshots-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, InputsModule, DialogsModule],
|
||||||
|
templateUrl: './expense-snapshots-page.component.html',
|
||||||
|
styleUrls: ['./expense-snapshots-page.component.scss'],
|
||||||
|
})
|
||||||
|
export class ExpenseSnapshotsPageComponent implements OnInit {
|
||||||
|
rows: ExpenseSnapshotDto[] = [];
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
/** Row being renamed (drives the rename dialog); null when closed. */
|
||||||
|
renameRow: ExpenseSnapshotDto | null = null;
|
||||||
|
renameValue = '';
|
||||||
|
renameSaving = false;
|
||||||
|
|
||||||
|
/** Row pending delete confirmation. */
|
||||||
|
deleteRow: ExpenseSnapshotDto | null = null;
|
||||||
|
|
||||||
|
constructor(private api: ExpenseSnapshotApiService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void { this.load(); }
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.api.getAll().subscribe({
|
||||||
|
next: list => { this.rows = list; this.loading = false; },
|
||||||
|
error: () => { this.loading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRename(row: ExpenseSnapshotDto): void {
|
||||||
|
this.renameRow = row;
|
||||||
|
this.renameValue = row.name;
|
||||||
|
}
|
||||||
|
cancelRename(): void { this.renameRow = null; }
|
||||||
|
|
||||||
|
confirmRename(): void {
|
||||||
|
const row = this.renameRow;
|
||||||
|
const name = this.renameValue.trim();
|
||||||
|
if (!row || !name || this.renameSaving) return;
|
||||||
|
this.renameSaving = true;
|
||||||
|
// Fetch the full snapshot, swap the name, PUT it back (lines/fields preserved).
|
||||||
|
this.api.getById(row.id).pipe(
|
||||||
|
switchMap(full => this.api.update(row.id, {
|
||||||
|
name,
|
||||||
|
ministryId: full.ministryId,
|
||||||
|
description: full.description,
|
||||||
|
vendorName: full.vendorName,
|
||||||
|
checkNumber: full.checkNumber,
|
||||||
|
notes: full.notes,
|
||||||
|
lines: full.lines.map(l => ({
|
||||||
|
categoryGroupId: l.categoryGroupId,
|
||||||
|
subCategoryId: l.subCategoryId,
|
||||||
|
amount: l.amount,
|
||||||
|
functionalClass: l.functionalClass,
|
||||||
|
description: l.description,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
).subscribe({
|
||||||
|
next: () => { this.renameSaving = false; this.renameRow = null; this.load(); },
|
||||||
|
error: () => { this.renameSaving = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openDelete(row: ExpenseSnapshotDto): void { this.deleteRow = row; }
|
||||||
|
cancelDelete(): void { this.deleteRow = null; }
|
||||||
|
|
||||||
|
confirmDelete(): void {
|
||||||
|
if (!this.deleteRow) return;
|
||||||
|
this.api.delete(this.deleteRow.id).subscribe(() => { this.deleteRow = null; this.load(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,27 +30,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main grid -->
|
<!-- Desktop grid -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<div class="hint-text-sm mb-2">Right-click a row for actions / 右鍵顯示動作</div>
|
||||||
|
|
||||||
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
||||||
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
[pageSize]="pageSize" (pageChange)="onPageChange($event)" (cellClick)="onCellClick($event)">
|
||||||
|
|
||||||
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
||||||
|
|
||||||
<!-- <kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column> -->
|
<!-- <kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column> -->
|
||||||
|
|
||||||
|
|
||||||
<kendo-grid-column field="ministryName" title="Ministry" [width]="280"></kendo-grid-column>
|
<kendo-grid-column title="Ministry / Category" [width]="240">
|
||||||
|
|
||||||
<kendo-grid-column title="Category" [width]="360">
|
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
|
<div>{{ dataItem.ministryName }}</div>
|
||||||
|
<div class="text-gray-500 text-xs">
|
||||||
|
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1"> +{{ dataItem.lineCount - 1 }}</span>
|
||||||
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
||||||
<kendo-grid-column title="Payee" [width]="150">
|
<kendo-grid-column title="Payee" [width]="180">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
{{ dataItem.vendorName || dataItem.memberName || '—' }}
|
<ng-container *ngIf="dataItem.vendorName; else memberPayee">{{ dataItem.vendorName }}</ng-container>
|
||||||
|
<ng-template #memberPayee>
|
||||||
|
<ng-container *ngIf="dataItem.memberName; else dash">
|
||||||
|
<div *ngIf="dataItem.memberNickName">{{ dataItem.memberNickName }}</div>
|
||||||
|
<div [class.text-gray-500]="dataItem.memberNickName" [class.text-xs]="dataItem.memberNickName">{{ dataItem.memberName }}</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #dash>—</ng-template>
|
||||||
|
</ng-template>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
@@ -74,19 +85,75 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
<kendo-grid-column title="Actions" [width]="160">
|
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
|
||||||
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
|
||||||
<button *ngIf="canApproveOrReject(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
|
||||||
(click)="openReview(dataItem)">Review</button>
|
|
||||||
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
|
||||||
(click)="openPay(dataItem)">Pay</button>
|
|
||||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
|
|
||||||
class="receipt-link">Receipt</button>
|
|
||||||
</ng-template>
|
|
||||||
</kendo-grid-column>
|
|
||||||
|
|
||||||
</kendo-grid>
|
</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 dataItem of rows" class="rounded border p-3 flex flex-col gap-2">
|
||||||
|
<div class="flex justify-between items-start gap-2">
|
||||||
|
<div class="text-sm text-gray-500">{{ dataItem.expenseDate }}</div>
|
||||||
|
<div class="font-semibold">{{ dataItem.amount | currency }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ dataItem.ministryName }}</div>
|
||||||
|
<div class="text-gray-500 text-xs">
|
||||||
|
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1"> +{{ dataItem.lineCount - 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="dataItem.description" class="text-sm">{{ dataItem.description }}</div>
|
||||||
|
|
||||||
|
<div class="text-sm flex justify-between gap-2">
|
||||||
|
<span class="text-gray-500">Payee / 收款人</span>
|
||||||
|
<span class="text-right">
|
||||||
|
<ng-container *ngIf="dataItem.vendorName; else mobileMemberPayee">{{ dataItem.vendorName }}</ng-container>
|
||||||
|
<ng-template #mobileMemberPayee>
|
||||||
|
<ng-container *ngIf="dataItem.memberName; else mobileDash">
|
||||||
|
<span *ngIf="dataItem.memberNickName">{{ dataItem.memberNickName }} </span>
|
||||||
|
<span [class.text-gray-500]="dataItem.memberNickName">{{ dataItem.memberName }}</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #mobileDash>—</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="dataItem.status === 'Paid' && dataItem.checkNumber" class="text-sm flex justify-between gap-2">
|
||||||
|
<span class="text-gray-500">Check # / 支票號</span>
|
||||||
|
<span>{{ dataItem.checkNumber }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||||
|
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
|
||||||
|
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
|
||||||
|
<div *ngIf="dataItem.reviewedByName && dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
|
||||||
|
✗ Rejected by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
|
||||||
|
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 pt-1">
|
||||||
|
<button *ngIf="canEdit(dataItem)" kendoButton size="small" (click)="openEdit(dataItem)">Edit</button>
|
||||||
|
<button *ngIf="canApproveOrReject(dataItem)" kendoButton size="small" themeColor="primary"
|
||||||
|
(click)="openReview(dataItem)">Review</button>
|
||||||
|
<button *ngIf="canPay(dataItem)" kendoButton size="small" themeColor="primary"
|
||||||
|
(click)="openPay(dataItem)">Pay</button>
|
||||||
|
<button *ngIf="dataItem.hasReceipt" kendoButton size="small" fillMode="outline"
|
||||||
|
(click)="openReceipt(dataItem.id)">Receipt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="!loading && rows.length === 0" class="text-center text-gray-500 py-6">No expenses / 無支出資料</div>
|
||||||
|
|
||||||
|
<div *ngIf="rows.length > 0" class="flex items-center justify-between gap-2 pt-1">
|
||||||
|
<button kendoButton size="small" [disabled]="page <= 1" (click)="prevPage()">‹ Prev</button>
|
||||||
|
<span class="text-sm text-gray-500">{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button kendoButton size="small" [disabled]="page >= totalPages" (click)="nextPage()">Next ›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Vendor Payment dialog -->
|
<!-- Vendor Payment dialog -->
|
||||||
<app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)"
|
<app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
|
import { GridModule, PageChangeEvent, CellClickEvent } from '@progress/kendo-angular-grid';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
|
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
|
||||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
@@ -20,8 +21,8 @@ import { switchMap, of } from 'rxjs';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
||||||
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent,
|
InputsModule, DialogsModule, DateInputsModule, ContextMenuModule,
|
||||||
ExpenseReviewDialogComponent,
|
ExpenseFormDialogComponent, ExpenseReviewDialogComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './expenses-page.component.html',
|
templateUrl: './expenses-page.component.html',
|
||||||
styleUrls: ['./expenses-page.component.scss'],
|
styleUrls: ['./expenses-page.component.scss'],
|
||||||
@@ -51,6 +52,11 @@ export class ExpensesPageComponent implements OnInit {
|
|||||||
/** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
|
/** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
|
||||||
reviewRow: ExpenseListItemDto | null = null;
|
reviewRow: ExpenseListItemDto | null = null;
|
||||||
|
|
||||||
|
/** Right-click row-action menu: items are rebuilt per row from what that row currently allows. */
|
||||||
|
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
|
||||||
|
rowMenuItems: { text: string }[] = [];
|
||||||
|
private contextRow: ExpenseListItemDto | null = null;
|
||||||
|
|
||||||
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
|
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
|
||||||
toast: string | null = null;
|
toast: string | null = null;
|
||||||
private toastTimer?: ReturnType<typeof setTimeout>;
|
private toastTimer?: ReturnType<typeof setTimeout>;
|
||||||
@@ -79,6 +85,52 @@ export class ExpensesPageComponent implements OnInit {
|
|||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mobile pager (the Kendo grid pager is desktop-only) ───────────────────────
|
||||||
|
get totalPages(): number { return Math.max(1, Math.ceil(this.total / this.pageSize)); }
|
||||||
|
|
||||||
|
prevPage(): void {
|
||||||
|
if (this.page <= 1) return;
|
||||||
|
this.page--;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPage(): void {
|
||||||
|
if (this.page >= this.totalPages) return;
|
||||||
|
this.page++;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row interaction: right-click opens the per-row action menu ────────────────
|
||||||
|
onCellClick(event: CellClickEvent): void {
|
||||||
|
if (event.type !== 'contextmenu') return;
|
||||||
|
event.originalEvent.preventDefault();
|
||||||
|
const items = this.buildMenuItems(event.dataItem);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
this.contextRow = event.dataItem;
|
||||||
|
this.rowMenuItems = items;
|
||||||
|
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||||
|
}
|
||||||
|
|
||||||
|
onRowMenuSelect(event: ContextMenuSelectEvent): void {
|
||||||
|
const row = this.contextRow;
|
||||||
|
if (!row) return;
|
||||||
|
switch (event.item.text) {
|
||||||
|
case 'Edit': this.openEdit(row); break;
|
||||||
|
case 'Review': this.openReview(row); break;
|
||||||
|
case 'Pay': this.openPay(row); break;
|
||||||
|
case 'Receipt': this.openReceipt(row.id); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMenuItems(row: ExpenseListItemDto): { text: string }[] {
|
||||||
|
const items: { text: string }[] = [];
|
||||||
|
if (this.canEdit(row)) items.push({ text: 'Edit' });
|
||||||
|
if (this.canApproveOrReject(row)) items.push({ text: 'Review' });
|
||||||
|
if (this.canPay(row)) items.push({ text: 'Pay' });
|
||||||
|
if (row.hasReceipt) items.push({ text: 'Receipt' });
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
onVendorSave(result: ExpenseFormResult): void {
|
onVendorSave(result: ExpenseFormResult): void {
|
||||||
this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); });
|
this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ExpenseCategoryAiRequest, CategoryAiSuggestion,
|
ExpenseCategoryAiRequest, CategoryAiSuggestion,
|
||||||
} from '../models/expense.model';
|
} from '../models/expense.model';
|
||||||
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
|
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
|
||||||
|
import { Form1099Box } from '../../payee1099/models/payee1099.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ExpenseCategoryApiService {
|
export class ExpenseCategoryApiService {
|
||||||
@@ -38,4 +39,9 @@ export class ExpenseCategoryApiService {
|
|||||||
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
|
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
|
||||||
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode} — ${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
|
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode} — ${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getForm1099Boxes(): Observable<(Form1099Box & { label: string })[]> {
|
||||||
|
return this.http.get<Form1099Box[]>(this.apiConfig.getApiUrl('form1099-report') + '/boxes')
|
||||||
|
.pipe(map(rows => rows.map(b => ({ ...b, label: `${b.boxCode} — ${b.name_en}${b.name_zh ? ' / ' + b.name_zh : ''}` }))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { ExpenseSnapshotApiService } from './expense-snapshot-api.service';
|
||||||
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
import { CreateExpenseSnapshotRequest } from '../models/expense-snapshot.model';
|
||||||
|
|
||||||
|
describe('ExpenseSnapshotApiService', () => {
|
||||||
|
let service: ExpenseSnapshotApiService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
const base = 'http://test/api/expense-snapshots';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
ExpenseSnapshotApiService,
|
||||||
|
{ provide: ApiConfigService, useValue: { getApiUrl: () => base } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(ExpenseSnapshotApiService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('getAll() GETs the collection endpoint', () => {
|
||||||
|
service.getAll().subscribe();
|
||||||
|
const req = httpMock.expectOne(base);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create() POSTs the request body', () => {
|
||||||
|
const body: CreateExpenseSnapshotRequest = {
|
||||||
|
name: 'Rent', ministryId: 1, description: 'Office rent',
|
||||||
|
vendorName: 'Landlord X', checkNumber: null, notes: null,
|
||||||
|
lines: [{ categoryGroupId: 1, subCategoryId: 1, amount: 1200, functionalClass: null, description: null }],
|
||||||
|
};
|
||||||
|
service.create(body).subscribe();
|
||||||
|
const req = httpMock.expectOne(base);
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body.name).toBe('Rent');
|
||||||
|
req.flush({ id: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete() DELETEs by id', () => {
|
||||||
|
service.delete(9).subscribe();
|
||||||
|
const req = httpMock.expectOne(`${base}/9`);
|
||||||
|
expect(req.request.method).toBe('DELETE');
|
||||||
|
req.flush(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
import {
|
||||||
|
ExpenseSnapshotDto, CreateExpenseSnapshotRequest, UpdateExpenseSnapshotRequest,
|
||||||
|
} from '../models/expense-snapshot.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ExpenseSnapshotApiService {
|
||||||
|
private readonly endpoint: string;
|
||||||
|
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||||
|
this.endpoint = apiConfig.getApiUrl('expense-snapshots');
|
||||||
|
}
|
||||||
|
getAll(): Observable<ExpenseSnapshotDto[]> {
|
||||||
|
return this.http.get<ExpenseSnapshotDto[]>(this.endpoint);
|
||||||
|
}
|
||||||
|
getById(id: number): Observable<ExpenseSnapshotDto> {
|
||||||
|
return this.http.get<ExpenseSnapshotDto>(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
create(r: CreateExpenseSnapshotRequest): Observable<{ id: number }> {
|
||||||
|
return this.http.post<{ id: number }>(this.endpoint, r);
|
||||||
|
}
|
||||||
|
update(id: number, r: UpdateExpenseSnapshotRequest): Observable<void> {
|
||||||
|
return this.http.put<void>(`${this.endpoint}/${id}`, r);
|
||||||
|
}
|
||||||
|
delete(id: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
<div class="page">
|
||||||
|
<ng-template appPageHeaderActions>
|
||||||
|
<button kendoButton themeColor="primary" (click)="exportCsv()">
|
||||||
|
Export filing CSV / 匯出申報資料
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- Year selector -->
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>Tax Year / 稅務年度</span>
|
||||||
|
<kendo-dropdownlist [data]="years" [(ngModel)]="taxYear" [style.width.px]="140"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<button kendoButton themeColor="primary" (click)="load()">Load / 載入</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary chips -->
|
||||||
|
<div *ngIf="summary" class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<div class="summary-chip">
|
||||||
|
<div class="summary-label">Total Reportable / 應申報總額</div>
|
||||||
|
<div class="summary-value">{{ summary.totalReportable | currency }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-chip">
|
||||||
|
<div class="summary-label">Recipients ≥ $600 / 達門檻收款人</div>
|
||||||
|
<div class="summary-value">{{ summary.recipientsAtThreshold }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-chip" [class.summary-chip-flag]="summary.recipientsMissingW9 > 0">
|
||||||
|
<div class="summary-label">Missing W-9 / 缺少 W-9</div>
|
||||||
|
<div class="summary-value">{{ summary.recipientsMissingW9 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-text-sm">Click a name for payment detail · right-click a row for Copy B / 點選名稱檢視明細 · 右鍵下載 Copy B</div>
|
||||||
|
|
||||||
|
<!-- Desktop grid -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<kendo-grid class="clickable-rows" [data]="summary?.rows ?? []" [loading]="loading"
|
||||||
|
(cellClick)="onCellClick($event)">
|
||||||
|
<kendo-grid-column field="legalName" title="Legal Name / 法定名稱">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>
|
||||||
|
<span class="legal-name">{{ r.legalName }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column title="TIN" [width]="120">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="w9Status" title="W-9" [width]="130">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>
|
||||||
|
<span class="badge" [ngClass]="r.w9Missing ? 'badge-missing' : 'badge-' + r.w9Status.toLowerCase()">
|
||||||
|
{{ r.w9Status }}
|
||||||
|
</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="necTotal" title="NEC / 非雇員報酬" format="{0:c2}" [width]="150"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="rentsTotal" title="Rents / 租金" format="{0:c2}" [width]="140"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="grandTotal" title="Total / 總計" format="{0:c2}" [width]="150"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Threshold / 門檻" [width]="130">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>
|
||||||
|
<span *ngIf="r.meetsThreshold" class="badge badge-threshold">≥ $600</span>
|
||||||
|
<span *ngIf="!r.meetsThreshold">—</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
</kendo-grid>
|
||||||
|
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile cards -->
|
||||||
|
<div class="md:hidden flex flex-col gap-3">
|
||||||
|
<div *ngFor="let r of summary?.rows ?? []" class="rounded border p-3" (click)="openDetail(r)">
|
||||||
|
<div class="flex justify-between items-start gap-2">
|
||||||
|
<div class="font-semibold">{{ r.legalName }}</div>
|
||||||
|
<span class="badge" [ngClass]="r.w9Missing ? 'badge-missing' : 'badge-' + r.w9Status.toLowerCase()">
|
||||||
|
{{ r.w9Status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm flex justify-between"><span>TIN</span><span>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between"><span>NEC / 非雇員報酬</span><span>{{ r.necTotal | currency }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between"><span>Rents / 租金</span><span>{{ r.rentsTotal | currency }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between font-semibold"><span>Total / 總計</span><span>{{ r.grandTotal | currency }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between">
|
||||||
|
<span>Threshold / 門檻</span>
|
||||||
|
<span><span *ngIf="r.meetsThreshold" class="badge badge-threshold">≥ $600</span><span *ngIf="!r.meetsThreshold">—</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipient detail dialog -->
|
||||||
|
<kendo-dialog *ngIf="detail || detailLoading"
|
||||||
|
[title]="'Recipient Detail / 收款人明細'"
|
||||||
|
(close)="closeDetail()"
|
||||||
|
[width]="760" [maxWidth]="'95vw'">
|
||||||
|
|
||||||
|
<div *ngIf="detailLoading" class="p-3">Loading… / 載入中…</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-name">{{ detail.legalName }}</div>
|
||||||
|
<div class="detail-meta">
|
||||||
|
<span>TIN {{ detail.tinLast4 ? '***-**-' + detail.tinLast4 : '—' }}</span>
|
||||||
|
<span class="badge" [ngClass]="'badge-' + detail.w9Status.toLowerCase()">{{ detail.w9Status }}</span>
|
||||||
|
<span>Year / 年度 {{ detail.taxYear }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<kendo-grid [data]="detail.payments">
|
||||||
|
<kendo-grid-column field="paidDate" title="Date / 日期" [width]="120"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="description" title="Description / 說明"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="categoryName" title="Category / 類別" [width]="170"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="boxCode" title="Box" [width]="90"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="amount" title="Amount / 金額" format="{0:c2}" [width]="140"></kendo-grid-column>
|
||||||
|
</kendo-grid>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="closeDetail()">Close / 關閉</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
.hint-text-sm {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid rows are clickable to open the recipient detail.
|
||||||
|
.clickable-rows ::ng-deep .k-grid-content tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary chips.
|
||||||
|
.summary-chip {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing-W-9 chip is a governance flag — make it stand out.
|
||||||
|
.summary-chip-flag {
|
||||||
|
border-color: #fca5a5;
|
||||||
|
background-color: #fef2f2;
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient detail header.
|
||||||
|
.detail-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status / threshold badges.
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-onfile {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-requested {
|
||||||
|
background-color: #fef9c3;
|
||||||
|
color: #854d0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-missing {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-expired {
|
||||||
|
background-color: #fed7aa;
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-threshold {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
|
import { Form1099ReportApiService } from '../../../payee1099/services/form1099-report-api.service';
|
||||||
|
import {
|
||||||
|
Form1099Summary, Form1099RecipientRow, Form1099RecipientDetail,
|
||||||
|
} from '../../../payee1099/models/payee1099.model';
|
||||||
|
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-form1099-report-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
|
||||||
|
DropDownsModule, ContextMenuModule, PageHeaderActionsDirective,
|
||||||
|
],
|
||||||
|
templateUrl: './form1099-report-page.component.html',
|
||||||
|
styleUrls: ['./form1099-report-page.component.scss'],
|
||||||
|
})
|
||||||
|
export class Form1099ReportPageComponent implements OnInit {
|
||||||
|
/** Recent years offered in the selector: current year and the prior four. */
|
||||||
|
readonly years: number[] = [];
|
||||||
|
taxYear: number = new Date().getFullYear();
|
||||||
|
|
||||||
|
summary: Form1099Summary | null = null;
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
// Per-row "Copy B" action, surfaced through a right-click context menu (matches
|
||||||
|
// the recipients page convention of putting row actions in a context menu).
|
||||||
|
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
|
||||||
|
rowMenuItems: { text: string }[] = [];
|
||||||
|
private contextRow: Form1099RecipientRow | null = null;
|
||||||
|
|
||||||
|
detail: Form1099RecipientDetail | null = null;
|
||||||
|
detailLoading = false;
|
||||||
|
|
||||||
|
constructor(private api: Form1099ReportApiService) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
for (let offset = 0; offset < 5; offset++) {
|
||||||
|
this.years.push(currentYear - offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.api.getSummary(this.taxYear).subscribe({
|
||||||
|
next: (summary) => {
|
||||||
|
this.summary = summary;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: () => { this.loading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row interaction: primary click opens the detail; right-click shows actions ──
|
||||||
|
onCellClick(event: CellClickEvent): void {
|
||||||
|
if (event.type === 'contextmenu') {
|
||||||
|
event.originalEvent.preventDefault();
|
||||||
|
this.contextRow = event.dataItem;
|
||||||
|
this.rowMenuItems = [{ text: 'Copy B PDF' }];
|
||||||
|
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||||
|
} else {
|
||||||
|
this.openDetail(event.dataItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRowMenuSelect(event: ContextMenuSelectEvent): void {
|
||||||
|
if (!this.contextRow) return;
|
||||||
|
if (event.item.text === 'Copy B PDF') this.copyB(this.contextRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
openDetail(row: Form1099RecipientRow): void {
|
||||||
|
this.detail = null;
|
||||||
|
this.detailLoading = true;
|
||||||
|
this.api.getRecipient(row.payeeId, this.taxYear).subscribe({
|
||||||
|
next: (detail) => {
|
||||||
|
this.detail = detail;
|
||||||
|
this.detailLoading = false;
|
||||||
|
},
|
||||||
|
error: () => { this.detailLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDetail(): void {
|
||||||
|
this.detail = null;
|
||||||
|
this.detailLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Downloads: fetched as blobs so the auth interceptor attaches the token ──────
|
||||||
|
exportCsv(): void {
|
||||||
|
this.api.downloadCsv(this.taxYear).subscribe((blob) => {
|
||||||
|
this.saveBlob(blob, `1099-filing-${this.taxYear}.csv`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyB(row: Form1099RecipientRow): void {
|
||||||
|
this.api.downloadCopyB(row.payeeId, this.taxYear).subscribe((blob) => {
|
||||||
|
this.saveBlob(blob, `1099-NEC-${row.payeeId}-${this.taxYear}.pdf`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a browser save of a downloaded blob via a temporary anchor. */
|
||||||
|
private saveBlob(blob: Blob, fileName: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface Payee1099ListItem {
|
||||||
|
id: number; legalName: string; displayName?: string;
|
||||||
|
memberId?: number; memberName?: string; taxClassification: string;
|
||||||
|
is1099Tracked: boolean; tinType?: string; tinLast4?: string;
|
||||||
|
w9Status: string; isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payee1099 extends Payee1099ListItem {
|
||||||
|
addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
|
||||||
|
email?: string; phone?: string; w9ReceivedDate?: string; hasW9Document: boolean; notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavePayee1099Request {
|
||||||
|
legalName: string; displayName?: string; memberId?: number | null;
|
||||||
|
taxClassification: string; is1099Tracked: boolean;
|
||||||
|
tinType?: string; tin?: string | null;
|
||||||
|
addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string;
|
||||||
|
email?: string; phone?: string; w9Status: string; w9ReceivedDate?: string | null;
|
||||||
|
isActive: boolean; notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Form1099Box {
|
||||||
|
id: number; boxCode: string; name_en: string; name_zh?: string; formType: string; sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Form1099RecipientRow {
|
||||||
|
payeeId: number; legalName: string; tinLast4?: string; w9Status: string;
|
||||||
|
necTotal: number; rentsTotal: number; grandTotal: number; meetsThreshold: boolean; w9Missing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Form1099Summary {
|
||||||
|
taxYear: number; rows: Form1099RecipientRow[];
|
||||||
|
totalReportable: number; recipientsAtThreshold: number; recipientsMissingW9: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Form1099Payment {
|
||||||
|
paidDate: string; description: string; categoryName: string; boxCode: string; amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Form1099RecipientDetail {
|
||||||
|
payeeId: number; legalName: string; tinLast4?: string; w9Status: string;
|
||||||
|
taxYear: number; payments: Form1099Payment[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<div class="page">
|
||||||
|
<ng-template appPageHeaderActions>
|
||||||
|
<label class="inactive-toggle">
|
||||||
|
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive / 顯示停用
|
||||||
|
</label>
|
||||||
|
<button kendoButton themeColor="primary"
|
||||||
|
*appHasPermission="{ module: 'Form1099', action: 'write' }"
|
||||||
|
(click)="openNew()">+ New Recipient / 新增收款人</button>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div class="hint-text-sm">Click a name to edit · right-click a row for actions / 點選名稱編輯 · 右鍵顯示動作</div>
|
||||||
|
|
||||||
|
<!-- Desktop grid -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<kendo-grid class="clickable-rows" [data]="recipients" [loading]="loading"
|
||||||
|
(cellClick)="onCellClick($event)">
|
||||||
|
<kendo-grid-column field="legalName" title="Legal Name / 法定名稱">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>
|
||||||
|
<span class="legal-name">{{ r.legalName }}</span>
|
||||||
|
<span *ngIf="r.displayName" class="display-name"> ({{ r.displayName }})</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="memberName" title="Member / 會友">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>{{ r.memberName || '—' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="taxClassification" title="Tax Class / 稅務分類" [width]="150"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="TIN" [width]="120">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="w9Status" title="W-9" [width]="120">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>
|
||||||
|
<span class="badge" [ngClass]="'badge-' + r.w9Status.toLowerCase()">{{ r.w9Status }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="is1099Tracked" title="1099 Tracked" [width]="120">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>{{ r.is1099Tracked ? 'Yes' : 'No' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="isActive" title="Active" [width]="90">
|
||||||
|
<ng-template kendoGridCellTemplate let-r>{{ r.isActive ? 'Yes' : 'No' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
</kendo-grid>
|
||||||
|
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile cards -->
|
||||||
|
<div class="md:hidden flex flex-col gap-3">
|
||||||
|
<div *ngFor="let r of recipients" class="rounded border p-3" (click)="openEdit(r)">
|
||||||
|
<div class="flex justify-between items-start gap-2">
|
||||||
|
<div class="font-semibold">{{ r.legalName }}</div>
|
||||||
|
<span class="badge" [ngClass]="'badge-' + r.w9Status.toLowerCase()">{{ r.w9Status }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="r.displayName" class="text-sm text-gray-500">{{ r.displayName }}</div>
|
||||||
|
<div class="text-sm flex justify-between"><span>Member / 會友</span><span>{{ r.memberName || '—' }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between"><span>Tax Class</span><span>{{ r.taxClassification }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between"><span>TIN</span><span>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between"><span>1099 Tracked</span><span>{{ r.is1099Tracked ? 'Yes' : 'No' }}</span></div>
|
||||||
|
<div class="text-sm flex justify-between"><span>Active</span><span>{{ r.isActive ? 'Yes' : 'No' }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New / Edit dialog -->
|
||||||
|
<kendo-dialog *ngIf="dialogOpen"
|
||||||
|
[title]="editingId != null ? 'Edit Recipient / 編輯收款人' : 'New Recipient / 新增收款人'"
|
||||||
|
(close)="dialogOpen = false"
|
||||||
|
[width]="720" [maxWidth]="'95vw'">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Legal Name / 法定名稱 *
|
||||||
|
<kendo-textbox [(ngModel)]="form.legalName"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Display Name / 顯示名稱
|
||||||
|
<kendo-textbox [(ngModel)]="form.displayName"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
Linked Member / 連結會友
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="memberResults"
|
||||||
|
textField="displayName" valueField="id" [valuePrimitive]="true"
|
||||||
|
[filterable]="true" (filterChange)="onMemberFilter($event)"
|
||||||
|
[defaultItem]="{ id: null, displayName: '(None / 無)' }"
|
||||||
|
[(ngModel)]="form.memberId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Tax Classification / 稅務分類
|
||||||
|
<kendo-dropdownlist [data]="taxClassifications" [(ngModel)]="form.taxClassification"
|
||||||
|
(valueChange)="onTaxClassificationChange($event)"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 md:mt-6">
|
||||||
|
<kendo-switch [(ngModel)]="form.is1099Tracked" (valueChange)="onTrackedToggle()"></kendo-switch>
|
||||||
|
<span>1099 Tracked / 列入 1099</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
TIN Type / 稅號類型
|
||||||
|
<kendo-dropdownlist [data]="tinTypes" [(ngModel)]="form.tinType"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
TIN / 稅號
|
||||||
|
<kendo-textbox [(ngModel)]="form.tin"
|
||||||
|
[placeholder]="editingId != null && editingTinLast4 ? '***-**-' + editingTinLast4 : ''"></kendo-textbox>
|
||||||
|
<span *ngIf="editingId != null" class="hint-text-sm">Leave blank to keep the existing TIN / 留空則保留現有稅號</span>
|
||||||
|
<div *ngIf="editingId != null" class="flex flex-col gap-1">
|
||||||
|
<button kendoButton type="button" fillMode="link" class="self-start"
|
||||||
|
*appHasPermission="{ module: 'Form1099', action: 'write' }"
|
||||||
|
(click)="revealTin()">Reveal full TIN / 顯示完整 TIN</button>
|
||||||
|
<span *ngIf="revealedTin" class="font-mono">{{ revealedTin }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
Address Line 1 / 地址 1
|
||||||
|
<kendo-textbox [(ngModel)]="form.addressLine1"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
Address Line 2 / 地址 2
|
||||||
|
<kendo-textbox [(ngModel)]="form.addressLine2"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
City / 城市
|
||||||
|
<kendo-textbox [(ngModel)]="form.city"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-x-4">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
State / 州
|
||||||
|
<kendo-textbox [(ngModel)]="form.state"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Zip / 郵遞區號
|
||||||
|
<kendo-textbox [(ngModel)]="form.zip"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Email / 電郵
|
||||||
|
<kendo-textbox [(ngModel)]="form.email"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Phone / 電話
|
||||||
|
<kendo-textbox [(ngModel)]="form.phone"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
W-9 Status / W-9 狀態
|
||||||
|
<kendo-dropdownlist [data]="w9Statuses" [(ngModel)]="form.w9Status"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
W-9 Received / W-9 收到日期
|
||||||
|
<kendo-datepicker [(value)]="form.w9ReceivedDate"></kendo-datepicker>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- W-9 document upload/view: edit mode only (a new record is saved first, then re-opened to attach). -->
|
||||||
|
<div *ngIf="editingId != null" class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
<span>W-9 Document / W-9 文件</span>
|
||||||
|
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||||
|
*appHasPermission="{ module: 'Form1099', action: 'write' }"
|
||||||
|
(change)="onW9FileSelected($event)" />
|
||||||
|
<span class="hint-text-sm">Upload W-9 / 上傳 W-9</span>
|
||||||
|
<button *ngIf="editingHasW9" kendoButton type="button" fillMode="link" class="self-start"
|
||||||
|
(click)="viewW9()">View W-9 / 檢視 W-9</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
Notes / 備註
|
||||||
|
<kendo-textarea [(ngModel)]="form.notes" [rows]="3"></kendo-textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label *ngIf="editingId != null" class="flex items-center gap-2 md:col-span-2">
|
||||||
|
<input type="checkbox" [(ngModel)]="form.isActive" /> Active / 啟用
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="dialogOpen = false">Cancel / 取消</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="!form.legalName" (click)="save()">Save / 儲存</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.hint-text-sm {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid rows are clickable to open the editor.
|
||||||
|
.clickable-rows ::ng-deep .k-grid-content tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// W-9 status badges.
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-onfile {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-requested {
|
||||||
|
background-color: #fef9c3;
|
||||||
|
color: #854d0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-missing {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-expired {
|
||||||
|
background-color: #fed7aa;
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
|
import { Payee1099ApiService } from '../../services/payee1099-api.service';
|
||||||
|
import { Payee1099ListItem, Payee1099, SavePayee1099Request } from '../../models/payee1099.model';
|
||||||
|
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||||
|
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||||
|
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||||
|
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||||
|
|
||||||
|
/** Flattened member item with a single displayName field for the picker. */
|
||||||
|
interface MemberOption { id: number; displayName: string; }
|
||||||
|
|
||||||
|
/** Editable form model for the New/Edit dialog. */
|
||||||
|
interface Payee1099Form {
|
||||||
|
legalName: string;
|
||||||
|
displayName: string;
|
||||||
|
memberId: number | null;
|
||||||
|
taxClassification: string;
|
||||||
|
is1099Tracked: boolean;
|
||||||
|
tinType: string;
|
||||||
|
tin: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
w9Status: string;
|
||||||
|
w9ReceivedDate: Date | null;
|
||||||
|
isActive: boolean;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-payee-1099-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
|
||||||
|
InputsModule, DropDownsModule, DateInputsModule, ContextMenuModule,
|
||||||
|
PageHeaderActionsDirective, HasPermissionDirective,
|
||||||
|
],
|
||||||
|
templateUrl: './payee-1099-page.component.html',
|
||||||
|
styleUrls: ['./payee-1099-page.component.scss'],
|
||||||
|
})
|
||||||
|
export class Payee1099PageComponent implements OnInit {
|
||||||
|
recipients: Payee1099ListItem[] = [];
|
||||||
|
loading = false;
|
||||||
|
includeInactive = false;
|
||||||
|
|
||||||
|
readonly taxClassifications = ['Individual', 'SoleProprietor', 'Partnership', 'CCorp', 'SCorp', 'LLC', 'Other'];
|
||||||
|
readonly tinTypes = ['SSN', 'EIN'];
|
||||||
|
readonly w9Statuses = ['Missing', 'Requested', 'OnFile', 'Expired'];
|
||||||
|
|
||||||
|
/** Member picker options, filled on demand from the members search. */
|
||||||
|
memberResults: MemberOption[] = [];
|
||||||
|
|
||||||
|
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
|
||||||
|
rowMenuItems: { text: string }[] = [];
|
||||||
|
private contextRow: Payee1099ListItem | null = null;
|
||||||
|
|
||||||
|
dialogOpen = false;
|
||||||
|
editingId: number | null = null;
|
||||||
|
/** Last-4 of the existing TIN (edit mode), so the TIN box can show a masked placeholder. */
|
||||||
|
editingTinLast4: string | null = null;
|
||||||
|
/** True when the record being edited already has a W-9 document attached. */
|
||||||
|
editingHasW9 = false;
|
||||||
|
/** Full TIN revealed on demand (write-gated); shown read-only, never logged or persisted. */
|
||||||
|
revealedTin: string | null = null;
|
||||||
|
/** Whether the user has manually toggled "1099 Tracked" in this dialog session (suppresses the classification default). */
|
||||||
|
private trackedTouched = false;
|
||||||
|
form: Payee1099Form = this.blankForm();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private api: Payee1099ApiService,
|
||||||
|
private memberApi: MemberApiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.api.getAll(this.includeInactive).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
this.recipients = rows;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: () => { this.loading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private blankForm(): Payee1099Form {
|
||||||
|
return {
|
||||||
|
legalName: '', displayName: '', memberId: null,
|
||||||
|
taxClassification: 'Individual', is1099Tracked: true,
|
||||||
|
tinType: 'SSN', tin: '',
|
||||||
|
addressLine1: '', addressLine2: '', city: '', state: '', zip: '',
|
||||||
|
email: '', phone: '',
|
||||||
|
w9Status: 'Missing', w9ReceivedDate: null,
|
||||||
|
isActive: true, notes: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Member picker (server-side search, same source as the expense form) ──────
|
||||||
|
onMemberFilter(term: string): void {
|
||||||
|
if (!term || term.length < 1) { this.memberResults = []; return; }
|
||||||
|
this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe((result) => {
|
||||||
|
this.memberResults = result.items.map((member: MemberListItemDto) => ({
|
||||||
|
id: member.id,
|
||||||
|
displayName: memberDisplayName(member),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row interaction: primary click opens the editor; right-click shows actions ──
|
||||||
|
onCellClick(event: CellClickEvent): void {
|
||||||
|
if (event.type === 'contextmenu') {
|
||||||
|
event.originalEvent.preventDefault();
|
||||||
|
this.contextRow = event.dataItem;
|
||||||
|
this.rowMenuItems = this.buildMenuItems(event.dataItem.isActive);
|
||||||
|
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||||
|
} else {
|
||||||
|
this.openEdit(event.dataItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRowMenuSelect(event: ContextMenuSelectEvent): void {
|
||||||
|
if (!this.contextRow) return;
|
||||||
|
if (event.item.text === 'Edit') this.openEdit(this.contextRow);
|
||||||
|
else if (event.item.text === 'Deactivate') this.deactivate(this.contextRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMenuItems(isActive: boolean): { text: string }[] {
|
||||||
|
const items: { text: string }[] = [{ text: 'Edit' }];
|
||||||
|
if (isActive) items.push({ text: 'Deactivate' });
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dialog open ──────────────────────────────────────────────────────────────
|
||||||
|
openNew(): void {
|
||||||
|
this.editingId = null;
|
||||||
|
this.editingTinLast4 = null;
|
||||||
|
this.editingHasW9 = false;
|
||||||
|
this.revealedTin = null;
|
||||||
|
this.trackedTouched = false;
|
||||||
|
this.form = this.blankForm();
|
||||||
|
this.dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
openEdit(row: Payee1099ListItem): void {
|
||||||
|
this.editingId = row.id;
|
||||||
|
this.editingHasW9 = false;
|
||||||
|
this.revealedTin = null;
|
||||||
|
this.trackedTouched = false;
|
||||||
|
this.dialogOpen = true;
|
||||||
|
// Load the full record so the dialog can prefill the address/contact/notes fields.
|
||||||
|
this.api.getById(row.id).subscribe((payee: Payee1099) => {
|
||||||
|
this.editingTinLast4 = payee.tinLast4 ?? null;
|
||||||
|
this.editingHasW9 = payee.hasW9Document;
|
||||||
|
this.form = {
|
||||||
|
legalName: payee.legalName,
|
||||||
|
displayName: payee.displayName ?? '',
|
||||||
|
memberId: payee.memberId ?? null,
|
||||||
|
taxClassification: payee.taxClassification,
|
||||||
|
is1099Tracked: payee.is1099Tracked,
|
||||||
|
tinType: payee.tinType ?? 'SSN',
|
||||||
|
tin: '',
|
||||||
|
addressLine1: payee.addressLine1 ?? '',
|
||||||
|
addressLine2: payee.addressLine2 ?? '',
|
||||||
|
city: payee.city ?? '',
|
||||||
|
state: payee.state ?? '',
|
||||||
|
zip: payee.zip ?? '',
|
||||||
|
email: payee.email ?? '',
|
||||||
|
phone: payee.phone ?? '',
|
||||||
|
w9Status: payee.w9Status,
|
||||||
|
w9ReceivedDate: this.parseDateOnly(payee.w9ReceivedDate),
|
||||||
|
isActive: payee.isActive,
|
||||||
|
notes: payee.notes ?? '',
|
||||||
|
};
|
||||||
|
// Seed the picker with the linked member so its name shows even before a search.
|
||||||
|
if (payee.memberId != null && payee.memberName) {
|
||||||
|
this.memberResults = [{ id: payee.memberId, displayName: payee.memberName }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save ─────────────────────────────────────────────────────────────────────
|
||||||
|
save(): void {
|
||||||
|
if (!this.form.legalName.trim()) return;
|
||||||
|
const typedTin = this.form.tin.trim();
|
||||||
|
const request: SavePayee1099Request = {
|
||||||
|
legalName: this.form.legalName.trim(),
|
||||||
|
displayName: this.form.displayName.trim() || undefined,
|
||||||
|
memberId: this.form.memberId ?? null,
|
||||||
|
taxClassification: this.form.taxClassification,
|
||||||
|
is1099Tracked: this.form.is1099Tracked,
|
||||||
|
tinType: this.form.tinType,
|
||||||
|
// Send the typed TIN when present. On edit a blank leaves the stored value
|
||||||
|
// unchanged (null = no change); on new a blank simply means no TIN yet.
|
||||||
|
tin: typedTin || null,
|
||||||
|
addressLine1: this.form.addressLine1.trim() || undefined,
|
||||||
|
addressLine2: this.form.addressLine2.trim() || undefined,
|
||||||
|
city: this.form.city.trim() || undefined,
|
||||||
|
state: this.form.state.trim() || undefined,
|
||||||
|
zip: this.form.zip.trim() || undefined,
|
||||||
|
email: this.form.email.trim() || undefined,
|
||||||
|
phone: this.form.phone.trim() || undefined,
|
||||||
|
w9Status: this.form.w9Status,
|
||||||
|
w9ReceivedDate: this.toDateOnly(this.form.w9ReceivedDate),
|
||||||
|
isActive: this.form.isActive,
|
||||||
|
notes: this.form.notes.trim() || undefined,
|
||||||
|
};
|
||||||
|
const done = () => { this.dialogOpen = false; this.load(); };
|
||||||
|
if (this.editingId == null) this.api.create(request).subscribe(done);
|
||||||
|
else this.api.update(this.editingId, request).subscribe(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(row: Payee1099ListItem): void {
|
||||||
|
if (!confirm(`Deactivate "${row.legalName}"?`)) return;
|
||||||
|
this.api.delete(row.id).subscribe(() => this.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tax classification drives the 1099-tracked default (spec §2.1/§2.3) ────────
|
||||||
|
// Corporations default to NOT tracked; everyone else defaults to tracked. Only applies
|
||||||
|
// to NEW records and only until the user manually flips the toggle (no override of an
|
||||||
|
// explicit choice or an existing saved value on edit).
|
||||||
|
onTaxClassificationChange(classification: string): void {
|
||||||
|
if (this.editingId != null || this.trackedTouched) return;
|
||||||
|
const isCorporation = classification === 'CCorp' || classification === 'SCorp';
|
||||||
|
this.form.is1099Tracked = !isCorporation;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTrackedToggle(): void { this.trackedTouched = true; }
|
||||||
|
|
||||||
|
// ── W-9 document upload/view (edit mode only; a new record is saved first) ─────
|
||||||
|
onW9FileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0] ?? null;
|
||||||
|
if (!file || this.editingId == null) return;
|
||||||
|
this.api.uploadW9(this.editingId, file).subscribe(() => {
|
||||||
|
this.editingHasW9 = true;
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the stored W-9 via HttpClient (auth interceptor attaches the JWT) and open it in a new tab. */
|
||||||
|
viewW9(): void {
|
||||||
|
if (this.editingId == null) return;
|
||||||
|
this.api.downloadW9(this.editingId).subscribe((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full TIN reveal (write-gated; acceptance criterion #11.4) ──────────────────
|
||||||
|
revealTin(): void {
|
||||||
|
if (this.editingId == null) return;
|
||||||
|
this.api.revealTin(this.editingId).subscribe((result) => {
|
||||||
|
this.revealedTin = result.tin;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date-only helpers: build/parse "yyyy-MM-dd" from LOCAL components ─────────
|
||||||
|
private parseDateOnly(value: string | undefined | null): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDateOnly(date: Date | null): string | null {
|
||||||
|
if (!date) return null;
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
import {
|
||||||
|
Form1099Box, Form1099Summary, Form1099RecipientDetail,
|
||||||
|
} from '../models/payee1099.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class Form1099ReportApiService {
|
||||||
|
private readonly endpoint: string;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||||
|
this.endpoint = apiConfig.getApiUrl('form1099-report');
|
||||||
|
}
|
||||||
|
|
||||||
|
getBoxes(): Observable<Form1099Box[]> {
|
||||||
|
return this.http.get<Form1099Box[]>(`${this.endpoint}/boxes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSummary(taxYear: number): Observable<Form1099Summary> {
|
||||||
|
return this.http.get<Form1099Summary>(`${this.endpoint}/summary`, {
|
||||||
|
params: { taxYear: String(taxYear) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecipient(payeeId: number, taxYear: number): Observable<Form1099RecipientDetail> {
|
||||||
|
return this.http.get<Form1099RecipientDetail>(`${this.endpoint}/recipient/${payeeId}`, {
|
||||||
|
params: { taxYear: String(taxYear) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated blob downloads: routed through HttpClient so the auth
|
||||||
|
// interceptor attaches the bearer token (a raw window.open would 401).
|
||||||
|
downloadCsv(taxYear: number): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.endpoint}/export-csv`, {
|
||||||
|
params: { taxYear: String(taxYear) },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadCopyB(payeeId: number, taxYear: number): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.endpoint}/recipient/${payeeId}/copy-b`, {
|
||||||
|
params: { taxYear: String(taxYear) },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
import {
|
||||||
|
Payee1099ListItem, Payee1099, SavePayee1099Request,
|
||||||
|
} from '../models/payee1099.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class Payee1099ApiService {
|
||||||
|
private readonly endpoint: string;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||||
|
this.endpoint = apiConfig.getApiUrl('payee-1099');
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(includeInactive = false): Observable<Payee1099ListItem[]> {
|
||||||
|
return this.http.get<Payee1099ListItem[]>(this.endpoint, {
|
||||||
|
params: { includeInactive: String(includeInactive) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<Payee1099> {
|
||||||
|
return this.http.get<Payee1099>(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(req: SavePayee1099Request): Observable<{ id: number }> {
|
||||||
|
return this.http.post<{ id: number }>(this.endpoint, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: number, req: SavePayee1099Request): Observable<void> {
|
||||||
|
return this.http.put<void>(`${this.endpoint}/${id}`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revealTin(id: number): Observable<{ tin: string | null }> {
|
||||||
|
return this.http.get<{ tin: string | null }>(`${this.endpoint}/${id}/tin`);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadW9(id: number, file: File): Observable<void> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return this.http.post<void>(`${this.endpoint}/${id}/w9`, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the stored W-9 as a Blob via HttpClient so the auth interceptor attaches
|
||||||
|
* the JWT. A plain window.open on the API URL would be an unauthenticated browser
|
||||||
|
* navigation and the API's permission gate would reject it.
|
||||||
|
*/
|
||||||
|
downloadW9(id: number): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.endpoint}/${id}/w9`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,10 +132,16 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
permission: { module: PermissionModules.Expenses, action: 'read' } },
|
permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories',
|
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories',
|
||||||
permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||||
|
{ text: 'Expense Snapshots', icon: categorizeIcon, path: '/user-portal/finance/expense-snapshots',
|
||||||
|
permission: { module: PermissionModules.Expenses, action: 'write' } },
|
||||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements',
|
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements',
|
||||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
|
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
|
||||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||||
|
{ text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099',
|
||||||
|
permission: { module: PermissionModules.Form1099, action: 'read' } },
|
||||||
|
{ text: '1099 Report', icon: fileReportIcon, path: '/user-portal/finance/form1099-report',
|
||||||
|
permission: { module: PermissionModules.Form1099, action: 'read' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
6. [Phase 1 — CMS](#6-cms)
|
6. [Phase 1 — CMS](#6-cms)
|
||||||
7. [Phase 1 — Giving & Donations(奉獻)](#7-giving--donations-奉獻)
|
7. [Phase 1 — Giving & Donations(奉獻)](#7-giving--donations-奉獻)
|
||||||
8. [Phase 1 — Expense Tracking(支出)](#8-expense-tracking-支出)
|
8. [Phase 1 — Expense Tracking(支出)](#8-expense-tracking-支出)
|
||||||
|
- [Form1099Box(1099 欄位目錄)](#form1099box-irs-1099-報告欄位目錄)
|
||||||
|
- [Payee1099(收款人主檔)](#payee1099-1099-申報收款人主檔)
|
||||||
|
- [現有表新增欄位(1099 歸屬)](#現有表新增欄位1099-歸屬)
|
||||||
9. [Phase 1 — Prayer Requests(代禱)](#9-prayer-requests-代禱)
|
9. [Phase 1 — Prayer Requests(代禱)](#9-prayer-requests-代禱)
|
||||||
10. [Phase 1 — Audit Log](#10-audit-log)
|
10. [Phase 1 — Audit Log](#10-audit-log)
|
||||||
11. [Phase 1 — Notifications](#11-notifications)
|
11. [Phase 1 — Notifications](#11-notifications)
|
||||||
@@ -704,6 +707,91 @@ Table: MonthlyStatements
|
|||||||
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
|
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
|
||||||
| **UNIQUE** | (Year, Month) | 每個月只有一份月結報表 |
|
| **UNIQUE** | (Year, Month) | 每個月只有一份月結報表 |
|
||||||
|
|
||||||
|
### Form1099Box(IRS 1099 報告欄位目錄)
|
||||||
|
|
||||||
|
```
|
||||||
|
Table: Form1099Boxes
|
||||||
|
```
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Id | int PK | |
|
||||||
|
| BoxCode | varchar(20) NOT NULL UNIQUE | 欄位代碼,如 "NEC-1"、"MISC-1" |
|
||||||
|
| Name_en | varchar(200) NOT NULL | 英文欄位名稱 |
|
||||||
|
| Name_zh | varchar(200)? | 中文欄位名稱 |
|
||||||
|
| FormType | varchar(20) NOT NULL | '1099-NEC' \| '1099-MISC' |
|
||||||
|
| SortOrder | int NOT NULL DEFAULT 0 | 顯示排序 |
|
||||||
|
| IsActive | bool NOT NULL DEFAULT true | |
|
||||||
|
| CreatedAt | timestamp NOT NULL | |
|
||||||
|
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
|
||||||
|
| UpdatedAt | timestamp NOT NULL | |
|
||||||
|
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
|
||||||
|
|
||||||
|
> **說明:** IRS 1099 申報欄位目錄(catalog)。Seed 預設兩個欄位:`NEC-1`(Nonemployee compensation — 非員工報酬,1099-NEC 第 1 欄)與 `MISC-1`(Rents — 租金,1099-MISC 第 1 欄)。此表為唯讀參考資料,僅透過 seed 管理;新增欄位須更新 seed 並重新執行 migration。
|
||||||
|
|
||||||
|
### Payee1099(1099 申報收款人主檔)
|
||||||
|
|
||||||
|
```
|
||||||
|
Table: Payee1099s
|
||||||
|
```
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Id | int PK | |
|
||||||
|
| LegalName | varchar(200) NOT NULL | IRS 法定全名(個人或公司)|
|
||||||
|
| DisplayName | varchar(200)? | 顯示用簡稱(選填)|
|
||||||
|
| MemberId | int? | FK → Members.Id,ON DELETE SET NULL。收款人同時為教友時可選填關聯 |
|
||||||
|
| TaxClassification | varchar(50) NOT NULL | 稅務分類,如 'Individual'、'SoleProprietor'、'Corporation'、'Partnership' 等 |
|
||||||
|
| Is1099Tracked | bool NOT NULL DEFAULT true | 是否需要申報 1099 |
|
||||||
|
| TinType | varchar(10)? | 'SSN' \| 'EIN';null = 尚未收到 W-9 |
|
||||||
|
| **TinEncrypted** | varchar(MAX)? | **TIN 加密密文(使用 ASP.NET Data Protection API 加密靜態儲存,明文永不入庫)** |
|
||||||
|
| **TinLast4** | varchar(4)? | **TIN 末四碼明文(僅供遮罩顯示用,如 \*\*\*-\*\*-1234)** |
|
||||||
|
| AddressLine1 | varchar(200)? | |
|
||||||
|
| AddressLine2 | varchar(200)? | |
|
||||||
|
| City | varchar(100)? | |
|
||||||
|
| State | varchar(50)? | |
|
||||||
|
| Zip | varchar(20)? | |
|
||||||
|
| Email | varchar(200)? | |
|
||||||
|
| Phone | varchar(30)? | |
|
||||||
|
| W9Status | varchar(20) NOT NULL DEFAULT 'Missing' | 'Missing' \| 'Requested' \| 'OnFile' \| 'Expired' |
|
||||||
|
| W9ReceivedDate | date? | W-9 文件收到日期 |
|
||||||
|
| W9BlobPath | varchar(500)? | 上傳的 W-9 文件 Azure Blob 路徑 |
|
||||||
|
| IsActive | bool NOT NULL DEFAULT true | |
|
||||||
|
| Notes | text? | 內部備注 |
|
||||||
|
| IsDeleted | bool NOT NULL DEFAULT false | 軟刪除 |
|
||||||
|
| DeletedAt | timestamp? | |
|
||||||
|
| DeletedBy | varchar(450)? | FK → AspNetUsers.Id |
|
||||||
|
| CreatedAt | timestamp NOT NULL | |
|
||||||
|
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
|
||||||
|
| UpdatedAt | timestamp NOT NULL | |
|
||||||
|
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
|
||||||
|
|
||||||
|
> **TIN 靜態加密(Encryption at Rest):** 納稅識別碼(SSN / EIN)屬高敏感個人資料。`TinEncrypted` 欄位儲存使用 ASP.NET Data Protection API(`IDataProtector`)加密後的密文;`TinLast4` 僅儲存末四碼明文供前端遮罩顯示(\*\*\*-\*\*-XXXX)。明文 TIN 永遠不寫入資料庫,也不出現在 Audit Log 快照中。
|
||||||
|
|
||||||
|
### 現有表新增欄位(1099 歸屬)
|
||||||
|
|
||||||
|
以下欄位由 1099 功能新增至現有表,透過 EF Core Migration 套用:
|
||||||
|
|
||||||
|
**`Expenses`(新增欄位)**
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **PayeeId** | int? | FK → Payee1099s.Id,ON DELETE SET NULL。費用標題層級 1099 收款人歸屬;null = 不申報 1099 |
|
||||||
|
|
||||||
|
**`ExpenseSubCategories`(新增欄位)**
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Form1099BoxId** | int? | FK → Form1099Boxes.Id,ON DELETE SET NULL。子項目層級 1099 申報欄位映射(優先於大類值)|
|
||||||
|
|
||||||
|
**`ExpenseCategoryGroups`(新增欄位)**
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Form1099BoxId** | int? | FK → Form1099Boxes.Id,ON DELETE SET NULL。大類層級 1099 申報欄位備援映射 |
|
||||||
|
|
||||||
|
> **有效 1099 欄位解析順序:** `SubCategory.Form1099BoxId ?? Group.Form1099BoxId ?? null`(先取子項目欄位;若為 null 則取大類欄位;仍為 null = 該費用不需申報 1099)。此解析邏輯與 Form 990 行號解析(`SubCategory.Form990LineId ?? Group.Form990LineId ?? "24"`)平行,但語意不同:1099 的 null 代表「不申報」,而 990 的 null 會回退至行 "24"(其他費用)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Prayer Requests(代禱)
|
## 9. Prayer Requests(代禱)
|
||||||
@@ -1033,6 +1121,14 @@ super_admin, pastor, board_member, coworker_chair, ministry_leader, district_lea
|
|||||||
Form990Report — 唯讀報表權限,授予角色:finance、pastor、board_member
|
Form990Report — 唯讀報表權限,授予角色:finance、pastor、board_member
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Form1099 權限模組
|
||||||
|
```
|
||||||
|
Form1099 — 1099 收款人管理與申報,授予角色:
|
||||||
|
finance — Read / Write / Delete(完整管理)
|
||||||
|
pastor — Read(唯讀總覽)
|
||||||
|
board_member — Read(唯讀總覽)
|
||||||
|
```
|
||||||
|
|
||||||
### CmsPages(靜態頁面 Slug)
|
### CmsPages(靜態頁面 Slug)
|
||||||
```
|
```
|
||||||
about, vision, service-times, contact
|
about, vision, service-times, contact
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
# 子專案 B — 1099 收款人追蹤(1099 Recipient Tracking)設計
|
||||||
|
|
||||||
|
**日期:** 2026-06-25
|
||||||
|
**狀態:** Approved(user 已核可,待轉 implementation plan)
|
||||||
|
**範圍:** 僅子專案 B。支出 Part IX(A)已上線;收入端 Part VIII(C)為獨立 spec,不在此。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目標與背景
|
||||||
|
|
||||||
|
教會依 IRC §6033(a)(3)(A) 免於申報 990,但對**獨立承攬人/廠商**的付款仍須在年底產出 **1099-NEC**(非員工報酬)記錄。本子專案讓系統能:辨識收款人身分、保存 W-9/TIN、依**已付**金額按年彙總、標示 $600 門檻與缺漏 W-9,並產出可交付的收款人聯(Copy B)PDF 與申報用資料檔。
|
||||||
|
|
||||||
|
**現況缺口:** 系統沒有收款人身分。廠商付款只存自由文字 `Expense.VendorName`(nullable, max 200);出納工作清單以該**字串**分組。沒有任何 W-9/TIN 資料,也無法把一整年付款依收款人加總。
|
||||||
|
|
||||||
|
**實際驅動案例:** 一位**兼職同工同時也是 Member**,以獨立承攬人身分受款,需開立 1099-NEC。
|
||||||
|
|
||||||
|
### 設計原則
|
||||||
|
- **資料驅動、疊在現有分類軸之上**,沿用子專案 A 的「映射欄位 + 參考表」風格,不重寫分類樹。
|
||||||
|
- 收款人身分以**獨立 master** 表達,與 Member **可選關聯**(不強耦合)。
|
||||||
|
- 1099 應報與否需**兩個條件同時成立**:收款人被追蹤 + 該筆科目映射到 1099 box。
|
||||||
|
- 員工(W-2/薪資)**不在範圍**(本系統無 payroll 模組)。
|
||||||
|
- 向後相容:新增欄位皆 nullable,既有資料不破。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 資料模型變更
|
||||||
|
|
||||||
|
### 2.1 新表 `Payee1099`(收款人 master)— 繼承 `SoftDeleteEntity, IAuditable`
|
||||||
|
檔案:`API/ROLAC.API/Entities/Payee1099.cs`
|
||||||
|
|
||||||
|
| 欄位 | 型別 | 說明 |
|
||||||
|
|---|---|---|
|
||||||
|
| Id | int PK | |
|
||||||
|
| LegalName | varchar(200) NOT NULL | W-9 上的法定名稱 |
|
||||||
|
| DisplayName | varchar(200)? | 友善 / DBA 名稱 |
|
||||||
|
| MemberId | int? FK→Member (SetNull) | 收款人同時是 Member 時連結(兼職同工案例) |
|
||||||
|
| TaxClassification | varchar(40) | Individual/SoleProprietor、Partnership、CCorp、SCorp、LLC、Other — 決定 `Is1099Tracked` 預設 |
|
||||||
|
| Is1099Tracked | bool NOT NULL DEFAULT true | 可覆寫;公司(C/S Corp)預設 false |
|
||||||
|
| TinType | varchar(10)? | "SSN" \| "EIN" |
|
||||||
|
| TinEncrypted | text? | 經 Data Protection API 加密的 TIN |
|
||||||
|
| TinLast4 | varchar(4)? | 遮罩顯示 / 搜尋用,免解密 |
|
||||||
|
| AddressLine1/2, City, State, Zip | varchar | 1099 表單用地址 |
|
||||||
|
| Email, Phone | varchar? | W-9 催收用 |
|
||||||
|
| W9Status | varchar(20) DEFAULT 'Missing' | Missing \| Requested \| OnFile \| Expired |
|
||||||
|
| W9ReceivedDate | DateOnly? | |
|
||||||
|
| W9BlobPath | text? | 上傳的 W-9 PDF/影像(比照 `Expense.ReceiptBlobPath`) |
|
||||||
|
| IsActive | bool DEFAULT true | |
|
||||||
|
| Notes | text? | |
|
||||||
|
| + audit + soft-delete | | 由 `SoftDeleteEntity` 提供 |
|
||||||
|
|
||||||
|
### 2.2 `Expense` 新增 `PayeeId int?` FK → Payee1099 (SetNull)
|
||||||
|
檔案:`API/ROLAC.API/Entities/Expense.cs`。**表頭層**(一筆支出/一張支票 = 一位收款人,與 `Check.PayeeName` 一致)。與 `Type` 無關 — 外部廠商與「同工承攬人」皆適用。`VendorName` 仍保留為自由文字 fallback/snapshot。
|
||||||
|
|
||||||
|
### 2.3 新參考表 `Form1099Box` — 繼承 `AuditableEntity, IAuditable`(比照 `Form990ExpenseLine`)
|
||||||
|
檔案:`API/ROLAC.API/Entities/Form1099Box.cs`
|
||||||
|
- Id、BoxCode(unique,如 `"NEC-1"`、`"MISC-1"`)、Name_en、Name_zh?、FormType(`"1099-NEC"` | `"1099-MISC"`)、SortOrder、IsActive。
|
||||||
|
- **seed 子集:** `NEC-1` Nonemployee compensation 非員工報酬;`MISC-1` Rents 租金。目錄可擴充。
|
||||||
|
|
||||||
|
### 2.4 映射欄位(完全比照 990-line 模式)
|
||||||
|
- `ExpenseSubCategory.Form1099BoxId int?` FK → Form1099Box (SetNull)— **主要映射**
|
||||||
|
- `ExpenseCategoryGroup.Form1099BoxId int?` FK — 大類 fallback
|
||||||
|
|
||||||
|
**有效 box = `sub ?? group ?? null`。** 與 990 不同(990 fallback 為 line 24,人人有歸屬);此處 **null = 不列入 1099** 才是預設 — 只有勞務性科目才給 box。
|
||||||
|
|
||||||
|
**預設 seed 映射(子項目 → box),僅列可報者:**
|
||||||
|
- Personnel ▸ Honorarium → NEC-1
|
||||||
|
- Personnel ▸ Contract Labor → NEC-1
|
||||||
|
- Professional Services ▸ Legal / Accounting & Audit / Other Professional → NEC-1
|
||||||
|
- Facility ▸ Rent → MISC-1
|
||||||
|
- **其餘一律 unmapped(排除)。** Salary & Wages / Officer Compensation 維持 unmapped(那是 W-2 薪資,永不入 1099)— 即使被追蹤的收款人記在這些科目,box gate 也會擋下。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 報表層
|
||||||
|
|
||||||
|
新服務 `Form1099ReportService`(讀取為主,與 `Form990ReportService` 並列)。
|
||||||
|
檔案:`API/ROLAC.API/Services/{IForm1099ReportService,Form1099ReportService}.cs`、DTOs `API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs`、controller `API/ROLAC.API/Controllers/Form1099ReportController.cs`。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear);
|
||||||
|
Task<Form1099RecipientDetailDto> GetRecipientDetailAsync(int payeeId, int taxYear);
|
||||||
|
Task<List<Form1099BoxDto>> GetBoxesAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
**現金基礎查詢(與 990 報表不同):** `Status == "Paid"` 且 `PaidAt` 年份 == taxYear。(1099 報的是**該曆年實際支付**的金額,而非 990 報表採用的 Approved/ExpenseDate。`Expense.PaidAt` 為支付日;`Check.CheckDate` 為日後若要更精準的替代基準。)
|
||||||
|
|
||||||
|
**彙總邏輯:**
|
||||||
|
1. Join 已付支出(PaidAt 落在該年、`PayeeId` 非 null)→ `ExpenseLines` → SubCategory/Group → 有效 box。
|
||||||
|
2. 只保留 **有效 box ≠ null 且 `payee.Is1099Tracked`** 的行。
|
||||||
|
3. 依 `(PayeeId, BoxCode)` 加總。
|
||||||
|
4. 每位收款人:各 box 小計;`MeetsThreshold`(每 box ≥ **$600**,常數 `Form1099.ReportingThreshold`);`W9Missing`(`W9Status != "OnFile"`)。
|
||||||
|
|
||||||
|
**DTOs:**
|
||||||
|
- `Form1099SummaryDto { TaxYear, Rows:[Form1099RecipientRowDto], TotalReportable, RecipientsAtThreshold, RecipientsMissingW9 }`
|
||||||
|
- `Form1099RecipientRowDto { PayeeId, LegalName, TinLast4, W9Status, NecTotal, RentsTotal, GrandTotal, MeetsThreshold, W9Missing }`
|
||||||
|
- `Form1099RecipientDetailDto { 收款人表頭 + 構成付款明細: [date, description, categoryName, boxCode, amount] }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. TIN 加密
|
||||||
|
|
||||||
|
採用 ASP.NET Core **Data Protection API**(`IDataProtectionProvider.CreateProtector("Payee1099.Tin")`)— 可逆、由框架管理金鑰、不引入新加密相依。寫入時加密;另存 `TinLast4` 供顯示/搜尋。完整 TIN 解密僅透過專屬 endpoint,並以本模組 **Write** action 把關;其餘一律遮罩(`***-**-1234`)。
|
||||||
|
|
||||||
|
## 5. 1099-NEC Copy B PDF + 申報資料匯出
|
||||||
|
|
||||||
|
新服務 `I1099FormService`,沿用 DevExpress 管道(`ICheckPrintService` / `DevExpress.Document.Processor`;授權檔已設定,見 [[project-devexpress-check-printing]])。產出**收款人聯 Copy B** 1099-NEC(payer = `ChurchProfile`、recipient = `Payee1099`、box 1 = NEC 合計),純白紙列印。另產出供 IRIS/會計師用的**申報資料 CSV/試算表**。不含 IRS 傳輸。
|
||||||
|
|
||||||
|
## 6. 權限
|
||||||
|
|
||||||
|
`API/ROLAC.API/Authorization/Modules.cs`(+ `Modules.All`)新增模組 `Form1099`,並同步前端 `PermissionModules`(`APP/src/app/core/models/permission.model.ts`)。Actions:Read(收款人 + 報表)、Write(編輯收款人、連結 payee、顯示完整 TIN)、Delete。seed 財務角色之 RolePermission;super_admin 自動 bypass。
|
||||||
|
|
||||||
|
## 7. 前端(Angular,admin)
|
||||||
|
|
||||||
|
慣例:`UserPortalComponent` 財務導覽群組 + `app.routes.ts` 路由 data(`title/titleZh/section` + `PermissionGuard`)、unified header(`appPageHeaderActions`)、Kendo UI、Tailwind 表單版面、行動裝置 `hidden md:block` + `md:hidden` 卡片。([[project-real-sidebar-nav]]、[[project-unified-system-header]]、[[feedback-mobile-friendly-all-screens]]、[[feedback-form-layout-tailwind]])
|
||||||
|
|
||||||
|
1. **1099 收款人維護頁**(`features/payee1099/pages/payee-1099-page`)— Kendo Grid(LegalName、member 連結、分類、TIN 末四碼遮罩、W-9 狀態徽章、Tracked 開關、Active);右鍵 context menu Edit/Deactivate;編輯對話框含 W-9 欄位 + Member 選擇器 + 遮罩 TIN 輸入 + W-9 上傳;行動卡片。比照 `expense-categories-page`。
|
||||||
|
2. **科目 → box 映射** — 擴充現有 `expense-categories-page`,在既有 990-line 下拉旁加一個「1099 Box」下拉(大類/子項目皆可設 `Form1099BoxId`)。`[valuePrimitive]="true"`([[feedback-kendo-value-primitive]])。
|
||||||
|
3. **支出表單**(`expense-form-dialog`)— 新增可選「1099 收款人」payee 選擇器(DropdownList、`valuePrimitive`)。
|
||||||
|
4. **1099 年度報表頁**(`features/finance-report/pages/form1099-report-page`)— 年度選擇器;收款人 grid(NEC/Rents 合計、門檻旗標、缺 W-9 旗標);下鑽收款人明細(構成付款,[[feedback-kendo-table-select-via-row-click]]);header actions「匯出申報資料」+「產生 Copy B PDF」。行動卡片。比照 `form990-report-page`。
|
||||||
|
|
||||||
|
## 8. Migration / 落地
|
||||||
|
|
||||||
|
- EF migration:新表 `Payee1099s`、`Form1099Boxes`;新欄 `Expenses.PayeeId`、`ExpenseSubCategories.Form1099BoxId`、`ExpenseCategoryGroups.Form1099BoxId`(FK、SetNull)。
|
||||||
|
- `DbSeeder`:seed `Form1099Box` 目錄 + 子項目→box 映射(**只填 NULL**,比照 `SeedForm990ExpenseLinesAsync` 的冪等性;不得覆蓋 admin 編輯)。無 catch-all fallback(unmapped = 不列入)。
|
||||||
|
- 同步更新 `docs/DB_SCHEMA.md`(新表 + 新欄)。
|
||||||
|
- v1 **不**自動把既有自由文字 `VendorName` 回填成 master(教會規模小,手動連結即可)。列為已知後續。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 測試
|
||||||
|
|
||||||
|
沿用既有測試模式(`ExpenseServiceTests` 等;Release build,見 [[project-build-run-env]]):
|
||||||
|
- `EffectiveBox` 解析:子項目 ?? 大類 ?? null。
|
||||||
|
- 報表現金基礎:Paid + PaidAt 年份;每收款人/每 box 加總正確。
|
||||||
|
- 門檻:恰 $600 觸發旗標;缺 W-9 旗標正確。
|
||||||
|
- `Is1099Tracked` gate;員工薪資科目被排除;同工(member-linked)收款人正確加總。
|
||||||
|
- TIN 加解密 round-trip + 末四碼 + 遮罩。
|
||||||
|
|
||||||
|
## 10. 不在此範圍(已知缺口)
|
||||||
|
|
||||||
|
- IRS 電子申報(IRIS/FIRE)整合。
|
||||||
|
- 官方 Copy A / 1096 表單(v1 僅 Copy B + 資料匯出)。
|
||||||
|
- payroll / W-2(員工)。
|
||||||
|
- 既有 `VendorName` → master 自動回填。
|
||||||
|
- 可設定門檻(v1 以常數)。
|
||||||
|
|
||||||
|
## 11. 驗收標準
|
||||||
|
|
||||||
|
1. 可在收款人維護頁建立 `Payee1099`(含 W-9/TIN,TIN 遮罩),並可連結 Member。
|
||||||
|
2. 支出可選填 1099 收款人;科目可設 1099 box;映射採子項目優先、大類 fallback、否則不列入。
|
||||||
|
3. 年度報表依**已付**金額按收款人 × box 加總,正確標示 $600 門檻與缺 W-9;可下鑽明細。
|
||||||
|
4. 完整 TIN 僅在具 Write 權限時可揭示;其餘遮罩為末四碼。
|
||||||
|
5. 可產出收款人聯 Copy B 1099-NEC PDF(無 DevExpress 浮水印)與申報資料 CSV。
|
||||||
|
6. 員工薪資科目即使付給被追蹤收款人,也不出現在 1099 報表;既有支出資料不受影響。
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Vendor Payment Snapshot — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-25
|
||||||
|
**Status:** Approved (design); pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Bookkeepers re-enter the same recurring vendor payments every month — rent, internet,
|
||||||
|
a fixed catered-meal cost — retyping vendor, categories, amounts, and memo each time.
|
||||||
|
The only thing that genuinely changes per entry is the **Expense Date**.
|
||||||
|
|
||||||
|
A **Snapshot** lets a user save the current vendor-payment form as a reusable, named
|
||||||
|
template and re-apply it later with one click, pre-filling everything except the date.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Applies to **vendor payments only** (`Expense.Type == "VendorPayment"`).
|
||||||
|
- Snapshots are **shared church-wide** — every user with vendor-payment access sees the
|
||||||
|
full list — and each row is **tagged with the creator** for accountability.
|
||||||
|
- A snapshot is **independent** of any expense: deleting an expense never affects a
|
||||||
|
snapshot, and applying a snapshot creates a brand-new expense.
|
||||||
|
|
||||||
|
## What a snapshot captures
|
||||||
|
|
||||||
|
Everything needed to refill the vendor-payment form **except `ExpenseDate`**.
|
||||||
|
|
||||||
|
| Group | Fields |
|
||||||
|
|---|---|
|
||||||
|
| Label | `Name` (required, user-supplied, e.g. "Monthly Rent — Landlord X") |
|
||||||
|
| Header | `MinistryId`, `Description`, `VendorName`, `CheckNumber`, `Notes` |
|
||||||
|
| Lines (1..n) | `CategoryGroupId`, `SubCategoryId`, `Amount`, `FunctionalClass`, `Description` |
|
||||||
|
| Audit | `CreatedBy` + `CreatedAt` (auto-stamped by `AuditSaveChangesInterceptor`); the creator display name is resolved at read time, not stored |
|
||||||
|
|
||||||
|
**Excluded:** `ExpenseDate` (always starts fresh / today), the receipt file, and `MemberId`
|
||||||
|
(not used in vendor mode).
|
||||||
|
|
||||||
|
**Note on `CheckNumber`:** captured to honor "only Expense Date is excluded." Because every
|
||||||
|
field stays editable on apply (see below), a stale check number is harmless if the user
|
||||||
|
overwrites it. Captured value is shown editable in the form.
|
||||||
|
|
||||||
|
## On apply
|
||||||
|
|
||||||
|
- Pre-fill **all** header + line fields from the snapshot.
|
||||||
|
- **All fields remain fully editable** — the snapshot is a starting point, not a lock.
|
||||||
|
A varying value (e.g. this month's meal cost) is a one-field tweak.
|
||||||
|
- `ExpenseDate` starts at today (fresh), never taken from the snapshot.
|
||||||
|
- The user then saves it as a normal expense via the existing `create` path.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Entities (`API/ROLAC.API/Entities/`)
|
||||||
|
|
||||||
|
- **`ExpenseSnapshot`** — header, extends `SoftDeleteEntity` (so it gets `CreatedBy`,
|
||||||
|
`CreatedAt`, `UpdatedBy`, `UpdatedAt`, `IsDeleted` auto-stamped). Fields: `Id`, `Name`,
|
||||||
|
`MinistryId`, `Description`, `VendorName`, `CheckNumber`, `Notes`. Owns `Lines`. The
|
||||||
|
creator's display name is resolved at read time (mirroring `ReviewedByName`), not stored.
|
||||||
|
- **`ExpenseSnapshotLine`** — mirrors `ExpenseLine`: `Id`, `SnapshotId` (cascade),
|
||||||
|
`CategoryGroupId`, `SubCategoryId`, `Amount`, `FunctionalClass`, `Description`.
|
||||||
|
Category FKs use `Restrict` delete (same as `ExpenseLine`).
|
||||||
|
|
||||||
|
Registered as `DbSet`s in `API/ROLAC.API/Data/AppDbContext.cs` with a soft-delete query
|
||||||
|
filter on `ExpenseSnapshot`. One EF migration adds both tables (PostgreSQL).
|
||||||
|
|
||||||
|
### DTOs (`API/ROLAC.API/DTOs/Expense/`)
|
||||||
|
|
||||||
|
- `ExpenseSnapshotDto` — list + detail response, includes `lines`, `createdByName`,
|
||||||
|
computed `totalAmount` and `lineCount` for the list view.
|
||||||
|
- `CreateExpenseSnapshotRequest` — same shape as the expense create payload minus
|
||||||
|
`expenseDate`/receipt/`memberId`, plus required `name`. Reused for update.
|
||||||
|
|
||||||
|
### Controller (`API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs`)
|
||||||
|
|
||||||
|
Route prefix `api/expense-snapshots`. Authorization reuses the vendor-payment **Write**
|
||||||
|
permission check used by `ExpensesController`.
|
||||||
|
|
||||||
|
| Method | Route | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/` | List all snapshots (shared), newest first, with `createdByName` + totals |
|
||||||
|
| GET | `/{id}` | Full snapshot incl. lines (for apply / management edit) |
|
||||||
|
| POST | `/` | Create from form payload (`CreatedBy`/`CreatedAt` auto-stamped by the interceptor) |
|
||||||
|
| PUT | `/{id}` | Update (rename and/or re-save fields from the management page) |
|
||||||
|
| DELETE | `/{id}` | Soft-delete |
|
||||||
|
|
||||||
|
`CreatedBy` is read with the `?? "sub"` JWT fallback used elsewhere in the project.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Service + models (`APP/src/app/features/expense/`)
|
||||||
|
|
||||||
|
- `services/expense-snapshot-api.service.ts` — `getAll()`, `getById(id)`, `create(req)`,
|
||||||
|
`update(id, req)`, `delete(id)`.
|
||||||
|
- `models/expense-snapshot.model.ts` — `ExpenseSnapshotDto`, `CreateExpenseSnapshotRequest`,
|
||||||
|
line interface.
|
||||||
|
|
||||||
|
### Expense form dialog (vendor mode only)
|
||||||
|
|
||||||
|
`components/expense-form-dialog/` — gated on `mode === 'vendor'`:
|
||||||
|
|
||||||
|
- **"Load from snapshot…"** picker near the top of the form. Selecting one fetches the
|
||||||
|
snapshot detail and patches the FormGroup, reusing the existing line-building logic so
|
||||||
|
category sub-lists populate correctly. `ExpenseDate` is left at today.
|
||||||
|
- **"Save as snapshot"** button. Prompts for the required `Name`, then posts the current
|
||||||
|
header + lines via `create()`. Shows a success toast; does not submit the expense.
|
||||||
|
|
||||||
|
### Management page
|
||||||
|
|
||||||
|
A simple page wired into the finance sidebar nav (`portals/user-portal`,
|
||||||
|
`financeNavItems` + `getPageTitle`, per the unified-header route-data convention):
|
||||||
|
|
||||||
|
- List columns: label (`Name`), vendor, total amount, created-by, created date.
|
||||||
|
- Actions: **rename** and **delete** (row context menu per house convention; single
|
||||||
|
primary action may be inline).
|
||||||
|
- **Mobile-friendly:** desktop `hidden md:block` grid + a `md:hidden` card list; layout
|
||||||
|
via Tailwind utilities (no `display` in component SCSS), per project house rules.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Backend:** controller/integration tests for create → list → get → update → delete,
|
||||||
|
asserting `ExpenseDate` is never stored and `CreatedByName` is stamped. Lines round-trip
|
||||||
|
intact.
|
||||||
|
- **Frontend:** service unit tests (inline-template components per the test-runner gotcha)
|
||||||
|
covering apply-patches-form-without-date and save-posts-current-state.
|
||||||
|
|
||||||
|
## Out of scope (YAGNI)
|
||||||
|
|
||||||
|
- Per-field "ask every time" configuration (decided: all fields editable instead).
|
||||||
|
- Snapshot from an already-saved expense (decided: save from the form only).
|
||||||
|
- Per-user private snapshots (decided: shared with creator tag).
|
||||||
|
- Reimbursement-mode snapshots.
|
||||||
Reference in New Issue
Block a user