# 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.