From 5d03e423025c1dddfd7d8ba1d2150874a063b1fb Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 14:45:08 -0700 Subject: [PATCH] docs(expense-snapshot): implementation plan + spec read-time creator-name refinement Co-Authored-By: Claude Opus 4.8 --- .../2026-06-25-vendor-payment-snapshot.md | 1359 +++++++++++++++++ ...26-06-25-vendor-payment-snapshot-design.md | 11 +- 2 files changed, 1365 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-25-vendor-payment-snapshot.md diff --git a/docs/superpowers/plans/2026-06-25-vendor-payment-snapshot.md b/docs/superpowers/plans/2026-06-25-vendor-payment-snapshot.md new file mode 100644 index 0000000..ae756c1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-vendor-payment-snapshot.md @@ -0,0 +1,1359 @@ +# Vendor Payment Snapshot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let finance save a vendor payment as a reusable named "snapshot" and re-apply it later, pre-filling every field except the Expense Date, to speed up entry of recurring fixed bills (rent, internet, fixed meal cost). + +**Architecture:** A new `ExpenseSnapshot` aggregate (header + owned `ExpenseSnapshotLine` rows) mirrors the existing `Expense`/`ExpenseLine` shape minus `ExpenseDate`/receipt/`MemberId`. A thin service + controller (`api/expense-snapshots`) exposes list/get/create/update/delete, gated on the existing `Expenses:Write` permission. The Angular vendor-payment form gains a "Load from snapshot" picker and a "Save as snapshot" action; a new management page lists snapshots with rename/delete. Snapshots are shared church-wide and tagged with their creator (resolved at read time, like `ReviewedByName`). + +**Tech Stack:** C# / EF Core (PostgreSQL) / ASP.NET controllers / xUnit + Moq + EF InMemory; Angular standalone components / Kendo UI / Tailwind v4 / Jasmine-Karma. + +**Spec:** `docs/superpowers/specs/2026-06-25-vendor-payment-snapshot-design.md` + +**Conventions verified from the codebase:** +- `CreatedBy`/`CreatedAt`/`UpdatedBy`/`UpdatedAt` are auto-stamped by `AuditSaveChangesInterceptor` for any `AuditableEntity` (which `SoftDeleteEntity` extends). Do NOT set them manually. +- Soft delete = set `IsDeleted = true` + `DeletedAt`; a `HasQueryFilter(e => !e.IsDeleted)` hides them. +- Owned lines are replaced wholesale on update (`RemoveRange` then rebuild), exactly like `ExpenseService.UpdateAsync`. +- Build/test under `-c Release` (Visual Studio locks `bin/Debug`). EF commands take `--configuration Release` for the same reason. +- Frontend unit-tested files: scope `ng test` with `--include` and run under Edge via `CHROME_BIN` (full `ng test` is pre-existing broken; the esbuild runner has no `.html` loader, so only test plain services, not templated components). + +--- + +## File Structure + +**Backend (create):** +- `API/ROLAC.API/Entities/ExpenseSnapshot.cs` — snapshot header entity +- `API/ROLAC.API/Entities/ExpenseSnapshotLine.cs` — snapshot line entity +- `API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs` — request/response DTOs +- `API/ROLAC.API/Services/IExpenseSnapshotService.cs` — service contract +- `API/ROLAC.API/Services/ExpenseSnapshotService.cs` — service implementation +- `API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs` — REST endpoints +- `API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs` — service unit tests +- `API/ROLAC.API/Migrations/*_AddExpenseSnapshots.cs` — generated migration + +**Backend (modify):** +- `API/ROLAC.API/Data/AppDbContext.cs` — register DbSets + entity config +- `API/ROLAC.API/Program.cs` — register `IExpenseSnapshotService` + +**Frontend (create):** +- `APP/src/app/features/expense/models/expense-snapshot.model.ts` — TS interfaces +- `APP/src/app/features/expense/services/expense-snapshot-api.service.ts` — HTTP client +- `APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts` — service tests +- `APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.ts` — management page +- `APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.html` +- `APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.scss` + +**Frontend (modify):** +- `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts` — load/save snapshot logic +- `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html` — picker + save button + name prompt +- `APP/src/app/app.routes.ts` — route for the management page +- `APP/src/app/portals/user-portal/user-portal.component.ts` — sidebar nav item + +--- + +## Task 1: Snapshot entities + +**Files:** +- Create: `API/ROLAC.API/Entities/ExpenseSnapshot.cs` +- Create: `API/ROLAC.API/Entities/ExpenseSnapshotLine.cs` + +- [ ] **Step 1: Create the header entity** + +Create `API/ROLAC.API/Entities/ExpenseSnapshot.cs`: + +```csharp +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// 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). +/// +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 Lines { get; set; } = new(); +} +``` + +- [ ] **Step 2: Create the line entity** + +Create `API/ROLAC.API/Entities/ExpenseSnapshotLine.cs`: + +```csharp +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// One category line of an , mirroring . +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; } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add API/ROLAC.API/Entities/ExpenseSnapshot.cs API/ROLAC.API/Entities/ExpenseSnapshotLine.cs +git commit -m "feat(expense-snapshot): add ExpenseSnapshot + ExpenseSnapshotLine entities" +``` + +--- + +## Task 2: Register entities in DbContext + migration + +**Files:** +- Modify: `API/ROLAC.API/Data/AppDbContext.cs:25` (DbSets) and `:293` (after ExpenseLine config block) + +- [ ] **Step 1: Add the DbSets** + +In `API/ROLAC.API/Data/AppDbContext.cs`, after the `ExpenseLines` DbSet (line 25), add: + +```csharp + public DbSet ExpenseSnapshots => Set(); + public DbSet ExpenseSnapshotLines => Set(); +``` + +- [ ] **Step 2: Add entity configuration** + +In `OnModelCreating`, immediately after the closing `});` of the `ExpenseLine` configuration block (ends at line 293, just before the `ChurchProfile` block), add: + +```csharp + // ── ExpenseSnapshot (reusable vendor-payment template) ─────────────── + builder.Entity(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(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); + }); +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Generate the migration** + +Run from the repo root: +`dotnet ef migrations add AddExpenseSnapshots --project API/ROLAC.API --configuration Release` +Expected: creates `API/ROLAC.API/Migrations/_AddExpenseSnapshots.cs` creating two tables (`ExpenseSnapshots`, `ExpenseSnapshotLines`). Open the file and confirm both `CreateTable` calls are present and the down-migration drops both. + +(If `dotnet ef` is not installed: `dotnet tool install --global dotnet-ef` first.) + +- [ ] **Step 5: Apply the migration** + +Run: `dotnet ef database update --project API/ROLAC.API --configuration Release` +Expected: "Done." and the two tables exist in the dev database. + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Migrations/ +git commit -m "feat(expense-snapshot): register snapshot tables + EF migration" +``` + +--- + +## Task 3: DTOs + +**Files:** +- Create: `API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs` + +- [ ] **Step 1: Create the DTO file** + +Note: `ExpenseLineInput` is reused from `ExpenseDtos.cs` (same namespace `ROLAC.API.DTOs.Expense`), so snapshot lines share the expense line validation. + +Create `API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs`: + +```csharp +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 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 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 { } +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs +git commit -m "feat(expense-snapshot): add snapshot DTOs" +``` + +--- + +## Task 4: Service (TDD) + +**Files:** +- Create: `API/ROLAC.API/Services/IExpenseSnapshotService.cs` +- Create: `API/ROLAC.API/Services/ExpenseSnapshotService.cs` +- Test: `API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs` + +- [ ] **Step 1: Write the service contract** + +Create `API/ROLAC.API/Services/IExpenseSnapshotService.cs`: + +```csharp +using ROLAC.API.DTOs.Expense; +namespace ROLAC.API.Services; + +public interface IExpenseSnapshotService +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task CreateAsync(CreateExpenseSnapshotRequest r); + Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r); + Task DeleteAsync(int id); +} +``` + +- [ ] **Step 2: Write the failing tests** + +Create `API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs`: + +```csharp +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 +{ + // DbContext whose CreatedBy/CreatedAt are stamped from the given user id (via the audit interceptor), + // mirroring the production wiring used in ExpenseServiceTests. + 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(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .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(); + return (new ExpenseSnapshotService(db), 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(() => 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); // CreatedAt desc, then Id desc + 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(() => 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)); + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail to compile** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests` +Expected: FAILS to compile — `ExpenseSnapshotService` does not exist yet. + +- [ ] **Step 4: Implement the service** + +Create `API/ROLAC.API/Services/ExpenseSnapshotService.cs`: + +```csharp +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; + public ExpenseSnapshotService(AppDbContext db) => _db = db; + + public async Task> 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 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 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); + // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. + 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; + + // Replace the line set wholesale (lines are owned by the header). + _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; + await _db.SaveChangesAsync(); + } + + private static void ValidateLines(List 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."); + } + } + + private static List BuildLines(List 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> ResolveUserNamesAsync(IEnumerable 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)); + } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests` +Expected: PASS — 7 passed. + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Services/IExpenseSnapshotService.cs API/ROLAC.API/Services/ExpenseSnapshotService.cs API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs +git commit -m "feat(expense-snapshot): snapshot service with creator-name resolution + tests" +``` + +--- + +## Task 5: Controller + DI registration + +**Files:** +- Create: `API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs` +- Modify: `API/ROLAC.API/Program.cs:155` + +- [ ] **Step 1: Create the controller** + +Authorization mirrors `ExpensesController`: snapshots are a finance-only vendor-payment tool, so every endpoint is gated on `Expenses:Write` (super_admin bypasses). Create `API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs`: + +```csharp +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 Roles() => User.FindAll("role").Select(claim => claim.Value).ToList(); + private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole); + private async Task CanManageAsync() => + IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write); + + [HttpGet] + public async Task GetAll() + { + if (!await CanManageAsync()) return Forbid(); + return Ok(await _svc.GetAllAsync()); + } + + [HttpGet("{id:int}")] + public async Task 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 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 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 Delete(int id) + { + if (!await CanManageAsync()) return Forbid(); + try { await _svc.DeleteAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } +} +``` + +- [ ] **Step 2: Register the service in DI** + +In `API/ROLAC.API/Program.cs`, immediately after line 155 (`builder.Services.AddScoped();`), add: + +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 3: Verify build + full test suite still green** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded, 0 errors. +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests` +Expected: PASS — 7 passed. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs API/ROLAC.API/Program.cs +git commit -m "feat(expense-snapshot): REST controller + DI registration" +``` + +--- + +## Task 6: Frontend model + service (TDD) + +**Files:** +- Create: `APP/src/app/features/expense/models/expense-snapshot.model.ts` +- Create: `APP/src/app/features/expense/services/expense-snapshot-api.service.ts` +- Test: `APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts` + +- [ ] **Step 1: Create the model** + +Create `APP/src/app/features/expense/models/expense-snapshot.model.ts`: + +```typescript +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; +``` + +- [ ] **Step 2: Create the service** + +Create `APP/src/app/features/expense/services/expense-snapshot-api.service.ts`: + +```typescript +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 { + return this.http.get(this.endpoint); + } + getById(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + create(r: CreateExpenseSnapshotRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, r); + } + update(id: number, r: UpdateExpenseSnapshotRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, r); + } + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} +``` + +- [ ] **Step 3: Write the failing test** + +Create `APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts`: + +```typescript +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); + }); +}); +``` + +- [ ] **Step 4: Run the test** + +Run from `APP/`: +```powershell +$env:CHROME_BIN = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" +npx ng test --include="**/expense-snapshot-api.service.spec.ts" --watch=false --browsers=ChromeHeadless +``` +Expected: PASS — 3 specs, 0 failures. (If the Edge path differs on this machine, point `CHROME_BIN` at the local `msedge.exe`.) + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/features/expense/models/expense-snapshot.model.ts APP/src/app/features/expense/services/expense-snapshot-api.service.ts APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts +git commit -m "feat(expense-snapshot): frontend model + api service with tests" +``` + +--- + +## Task 7: Form dialog — load + save snapshot (vendor mode) + +**Files:** +- Modify: `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts` +- Modify: `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html` + +- [ ] **Step 1: Add the snapshot imports + state to the component** + +In `expense-form-dialog.component.ts`, add to the imports near the other service imports (after line 12, `import { ExpenseApiService } ...`): + +```typescript +import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service'; +import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model'; +``` + +Inside the class, after the `groups` field (line 65), add: + +```typescript + /** 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'; } +``` + +- [ ] **Step 2: Inject the snapshot service** + +In the constructor (line 114-121), add the parameter: + +```typescript + private snapshotApi: ExpenseSnapshotApiService, +``` + +(Place it after `private expenseApi: ExpenseApiService,`.) + +- [ ] **Step 3: Load snapshots on init (vendor mode)** + +In `ngOnInit()`, after the `this.ministryApi.getAll()...` line (line 124), add: + +```typescript + if (this.showSnapshotTools) this.loadSnapshots(); +``` + +- [ ] **Step 4: Add the load/save methods** + +Add these methods to the class (e.g. just before `get isValid()` at line 318): + +```typescript + 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; }, + }); + } +``` + +- [ ] **Step 5: Add the picker + save button to the template** + +In `expense-form-dialog.component.html`, insert this block right after the opening `
` (after line 6), as the first child of the left form column: + +```html + +
+ + +
+``` + +Then add the name-prompt dialog as a sibling of the main ``, immediately after its closing `` (after line 213): + +```html + + +
+ +

費用日期不會存入範本 / The Expense Date is not saved in a snapshot.

+
+ + + + +
+``` + +- [ ] **Step 6: Verify the build compiles** + +Run from `APP/`: `npx ng build --configuration development` +Expected: build completes with no errors referencing `expense-form-dialog`. + +- [ ] **Step 7: Commit** + +```bash +git add APP/src/app/features/expense/components/expense-form-dialog/ +git commit -m "feat(expense-snapshot): load/save snapshot in vendor payment form" +``` + +--- + +## Task 8: Management page + +**Files:** +- Create: `APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.ts` +- Create: `APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.html` +- Create: `APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.scss` + +- [ ] **Step 1: Create the component class** + +Rename re-uses the `update` endpoint: the page fetches the full snapshot, swaps `name`, and PUTs it back (the line set is preserved). Create `expense-snapshots-page.component.ts`: + +```typescript +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(); }); + } +} +``` + +- [ ] **Step 2: Create the template (mobile-friendly: desktop grid + card list)** + +Per house rule, desktop uses `hidden md:block`, mobile uses a `md:hidden` card list, and the card list's flex layout lives in Tailwind utilities (never component SCSS). Create `expense-snapshots-page.component.html`: + +```html +
+

+ 儲存常用的固定費用(房租、網路、餐費…)為範本,下次可快速套用。費用日期不會儲存。
+ Save recurring fixed expenses as snapshots to quickly re-use them. The Expense Date is never saved. +

+ + + + + +
+
+
+ {{ row.name }} + {{ row.totalAmount | currency }} +
+
{{ row.vendorName || '—' }} · {{ row.ministryName }}
+
{{ row.createdByName || '—' }} · {{ row.createdAt | date:'yyyy-MM-dd' }}
+
+ + +
+
+

尚無範本 / No snapshots yet.

+
+ + + + + + + + + + + + +

確定刪除「{{ deleteRow.name }}」? / Delete "{{ deleteRow.name }}"?

+ + + + +
+
+``` + +- [ ] **Step 3: Create a minimal SCSS file** + +Per house rule, do NOT put `display` rules here. Create `expense-snapshots-page.component.scss`: + +```scss +.page { + padding: 0.5rem 0; +} +``` + +- [ ] **Step 4: Verify the build compiles** + +Run from `APP/`: `npx ng build --configuration development` +Expected: build completes with no errors referencing `expense-snapshots-page`. + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/features/expense/pages/expense-snapshots-page/ +git commit -m "feat(expense-snapshot): snapshot management page (rename/delete)" +``` + +--- + +## Task 9: Route + sidebar nav + +**Files:** +- Modify: `APP/src/app/app.routes.ts` (finance routes block, near line 192) +- Modify: `APP/src/app/portals/user-portal/user-portal.component.ts:139` (Expenses nav group) + +- [ ] **Step 1: Import the page component in the routes file** + +In `APP/src/app/app.routes.ts`, add an import alongside the other expense page imports (e.g. near the `ExpensesPageComponent` import): + +```typescript +import { ExpenseSnapshotsPageComponent } from './features/expense/pages/expense-snapshots-page/expense-snapshots-page.component'; +``` + +(Confirm the exact relative path matches the other expense page imports in that file; they live under `./features/expense/pages/...`.) + +- [ ] **Step 2: Add the route** + +In `app.routes.ts`, immediately after the `finance/expenses` route object (closes at line 164), add: + +```typescript + { + path: 'finance/expense-snapshots', + component: ExpenseSnapshotsPageComponent, + canActivate: [PermissionGuard], + data: { + permission: { module: PermissionModules.Expenses, action: 'read' }, + title: 'Expense Snapshots', titleZh: '費用範本', section: 'Finance', + }, + }, +``` + +- [ ] **Step 3: Add the sidebar nav item** + +In `APP/src/app/portals/user-portal/user-portal.component.ts`, inside the `Expenses` finance group's `items` array, after the `Expense Categories` item (line 133-134), add: + +```typescript + { text: 'Expense Snapshots', icon: categorizeIcon, path: '/user-portal/finance/expense-snapshots', + permission: { module: PermissionModules.Expenses, action: 'read' } }, +``` + +(`categorizeIcon` is already imported and used in this file.) + +- [ ] **Step 4: Verify the build compiles** + +Run from `APP/`: `npx ng build --configuration development` +Expected: build completes, 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/app.routes.ts APP/src/app/portals/user-portal/user-portal.component.ts +git commit -m "feat(expense-snapshot): route + sidebar nav for snapshot management" +``` + +--- + +## Task 10: End-to-end verification + +**Files:** none (manual + automated verification) + +- [ ] **Step 1: Backend — full build + targeted tests green** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` → Build succeeded. +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests` → 7 passed. + +- [ ] **Step 2: Frontend — service tests + build green** + +Run from `APP/`: +```powershell +$env:CHROME_BIN = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" +npx ng test --include="**/expense-snapshot-api.service.spec.ts" --watch=false --browsers=ChromeHeadless +npx ng build --configuration development +``` +Expected: 3 specs pass; build succeeds. + +- [ ] **Step 3: Manual smoke test** + +Start the API and the frontend (see the Build/Run env memory). As a finance user: +1. Finance → Expenses → **+ Vendor Payment**. Fill ministry, description, vendor, one line with an amount. Click **存為範本 / Save as snapshot**, name it "Smoke Rent", Save. Confirm no error. +2. Close the dialog, reopen **+ Vendor Payment**. In **Load from snapshot**, pick "Smoke Rent". Confirm ministry/description/vendor/line all prefill and **Expense Date stays today** (not blank/altered). +3. Finance → **Expense Snapshots**. Confirm "Smoke Rent" appears with vendor, amount, and your name as creator. **Rename** it → confirm the new name shows. **Delete** it → confirm it disappears. +4. Resize the browser to mobile width on the Expense Snapshots page → confirm the card list shows (not the grid) and actions work. + +- [ ] **Step 4: Final commit (if any manual-fix tweaks were needed)** + +```bash +git add -A +git commit -m "test(expense-snapshot): verification fixes" +``` + +(Skip if nothing changed.) + +--- + +## Self-Review Notes + +- **Spec coverage:** capture fields (Task 1/3), shared + creator tag resolved at read (Task 4 `ResolveUserNamesAsync` + `GetByIdName` test), exclude ExpenseDate/receipt/MemberId (entities omit them; Task 7 `applySnapshot` keeps today's date), save-from-form (Task 7), quick picker + management page both present (Tasks 7 & 8 — spec Q4=C), rename + delete (Task 8), `Expenses:Write` gating (Task 5), mobile-friendly management page (Task 8). All covered. +- **CheckNumber:** captured and editable per spec; no special handling needed. +- **Type consistency:** `CreateExpenseSnapshotRequest`/`UpdateExpenseSnapshotRequest`, `ExpenseSnapshotDto`, `ExpenseSnapshotLineDto`, `ExpenseLineInput` names match across backend DTOs (Task 3), service (Task 4), frontend model (Task 6), and consumers (Tasks 7-8). Service method names (`getAll/getById/create/update/delete`) are identical across interface, service, and Angular service. diff --git a/docs/superpowers/specs/2026-06-25-vendor-payment-snapshot-design.md b/docs/superpowers/specs/2026-06-25-vendor-payment-snapshot-design.md index f437a61..868d34e 100644 --- a/docs/superpowers/specs/2026-06-25-vendor-payment-snapshot-design.md +++ b/docs/superpowers/specs/2026-06-25-vendor-payment-snapshot-design.md @@ -29,7 +29,7 @@ Everything needed to refill the vendor-payment form **except `ExpenseDate`**. | 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`, `CreatedByName`, `CreatedAt` (+ soft-delete / auditable fields) | +| 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). @@ -50,9 +50,10 @@ overwrites it. Captured value is shown editable in the form. ### Entities (`API/ROLAC.API/Entities/`) -- **`ExpenseSnapshot`** — header. Fields: `Id`, `Name`, `MinistryId`, `Description`, - `VendorName`, `CheckNumber`, `Notes`, `CreatedBy`, `CreatedByName`, plus auditable - (`CreatedAt`, `UpdatedAt`, `UpdatedBy`) and soft-delete (`IsDeleted`). Owns `Lines`. +- **`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`). @@ -76,7 +77,7 @@ permission check used by `ExpensesController`. |---|---|---| | 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; stamps `CreatedBy`/`CreatedByName` from current user | +| 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 |