Files
ROLAC/docs/superpowers/plans/2026-05-28-giving-donation-tracking.md
T
Chris Chen 3974cec967 docs: add implementation plan for giving/donation tracking module
15 bite-sized TDD tasks: entities+EF+seed+migration, three services
(giving-category, single giving, offering-session) with server-side
totals and lock-after-submit, controllers, Angular models/services and
three pages (categories, single entry, keyboard-first batch entry +
quick-add member), role-gated finance nav, and E2E verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:57:47 -07:00

2781 lines
105 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Giving / Donation Tracking 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:** Build the manual giving module — giving-category config, single-entry giving, and a keyboard-first Sunday offering batch-entry screen with finance-gated reconciliation.
**Architecture:** ASP.NET Core 8 Web API (thin controller → service → EF Core/PostgreSQL) + standalone Angular 18 + Kendo UI. Batch entry buffers all rows client-side and submits the whole `OfferingSession` (header + lines) in one transactional `POST` (spec decision B); the server is authoritative for `SystemTotal`/`Difference`. Once `Submitted`, a session is locked — editing requires `finance` to `reopen` it.
**Tech Stack:** C#, EF Core 8 (Npgsql), xUnit + Moq + EF InMemory, Angular standalone components, `@progress/kendo-angular-grid`, RxJS.
**Spec:** `docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md`
**Conventions to follow (from existing code):**
- Entities inherit `AuditableEntity` (`CreatedAt/By`, `UpdatedAt/By`, all `DateTimeOffset`). `AuditSaveChangesInterceptor` stamps these automatically. **There is no separate `AuditLog` table** — "audit" = the stamp fields (spec R4).
- Services: `interface + impl`, ctor-inject `AppDbContext` + `IHttpContextAccessor`, `CurrentUserId` helper, manual DTO mapping, `PagedResult<T>` for lists.
- Controllers: thin, `[Authorize(Roles=...)]`, translate `KeyNotFoundException`→404, lock conflicts→409.
- Tests: `BuildDb()` InMemory + `AuditSaveChangesInterceptor` (see `ROLAC.API.Tests/Services/MemberServiceTests.cs`).
- Frontend feature folder: `features/<name>/{pages,components,services,models}`, standalone components, `ApiConfigService.getApiUrl(...)`, DTO interfaces in `models/*.model.ts` (camelCase, `_en/_zh` suffixes preserved as `name_en`).
**Run commands (from repo root unless noted):**
- Backend build: `dotnet build API/ROLAC.API/ROLAC.API.csproj`
- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj`
- Migrations (run inside `API/ROLAC.API`): `dotnet ef migrations add <Name>` / applied automatically on app startup via `db.Database.MigrateAsync()`
- Frontend build: `cd APP; npm run build`
---
## File Structure
**Backend — create:**
- `API/ROLAC.API/Entities/GivingCategory.cs`
- `API/ROLAC.API/Entities/OfferingSession.cs`
- `API/ROLAC.API/Entities/Giving.cs`
- `API/ROLAC.API/DTOs/Giving/``GivingCategoryDto.cs`, `CreateGivingCategoryRequest.cs`, `UpdateGivingCategoryRequest.cs`, `GivingDto.cs`, `GivingListItemDto.cs`, `CreateGivingRequest.cs`, `UpdateGivingRequest.cs`, `OfferingSessionDto.cs`, `OfferingSessionListItemDto.cs`, `OfferingGivingLineDto.cs`, `CreateOfferingSessionRequest.cs`, `OfferingGivingLineRequest.cs`
- `API/ROLAC.API/Services/IGivingCategoryService.cs` + `GivingCategoryService.cs`
- `API/ROLAC.API/Services/IGivingService.cs` + `GivingService.cs`
- `API/ROLAC.API/Services/IOfferingSessionService.cs` + `OfferingSessionService.cs`
- `API/ROLAC.API/Controllers/GivingCategoriesController.cs`
- `API/ROLAC.API/Controllers/GivingsController.cs`
- `API/ROLAC.API/Controllers/OfferingSessionsController.cs`
- `API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs`
- `API/ROLAC.API.Tests/Services/GivingServiceTests.cs`
- `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`
**Backend — modify:**
- `API/ROLAC.API/Data/AppDbContext.cs` (add 3 `DbSet` + fluent config)
- `API/ROLAC.API/Data/DbSeeder.cs` (seed giving categories)
- `API/ROLAC.API/Program.cs` (register 3 services)
**Frontend — create:**
- `APP/src/app/features/giving/models/giving.model.ts`
- `APP/src/app/features/giving/services/giving-category-api.service.ts`
- `APP/src/app/features/giving/services/giving-api.service.ts`
- `APP/src/app/features/giving/services/offering-session-api.service.ts`
- `APP/src/app/features/giving/pages/giving-categories-page/` (component .ts/.html/.scss)
- `APP/src/app/features/giving/pages/givings-page/` (component .ts/.html/.scss)
- `APP/src/app/features/giving/pages/offering-session-page/` (component .ts/.html/.scss)
- `APP/src/app/features/giving/components/member-quick-add-dialog/` (component .ts/.html)
**Frontend — modify:**
- `APP/src/app/app.routes.ts` (3 routes, RoleGuard finance/super_admin)
- `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts` + `.html` (finance nav section)
---
## Task 1: Giving entities + EF Core configuration
Entities have no behavior, so verification is a clean build (no unit test).
**Files:**
- Create: `API/ROLAC.API/Entities/GivingCategory.cs`, `OfferingSession.cs`, `Giving.cs`
- Modify: `API/ROLAC.API/Data/AppDbContext.cs`
- [ ] **Step 1: Create `GivingCategory.cs`**
```csharp
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class GivingCategory : AuditableEntity
{
public int Id { get; set; }
public string Name_en { get; set; } = null!;
public string? Name_zh { get; set; }
public string? Description_en { get; set; }
public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
```
- [ ] **Step 2: Create `OfferingSession.cs`**
```csharp
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class OfferingSession : AuditableEntity
{
public int Id { get; set; }
public DateOnly SessionDate { get; set; }
public string Status { get; set; } = "Draft"; // Draft | Submitted | Reconciled
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public string? SubmittedBy { get; set; }
public DateTimeOffset? ReconciledAt { get; set; }
public string? ReconciledBy { get; set; }
public List<Giving> Givings { get; set; } = [];
}
```
- [ ] **Step 3: Create `Giving.cs`**
```csharp
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class Giving : AuditableEntity
{
public int Id { get; set; }
public int? MemberId { get; set; }
public int GivingCategoryId { get; set; }
public int? OfferingSessionId { get; set; }
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "Cash"; // Cash|Check|Zelle|PayPal|Other
public string? CheckNumber { get; set; }
public string? ZelleReferenceCode { get; set; }
public string? PayPalTransactionId{ get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
public Member? Member { get; set; }
public GivingCategory? GivingCategory { get; set; }
public OfferingSession? OfferingSession { get; set; }
}
```
- [ ] **Step 4: Add DbSets + fluent config in `AppDbContext.cs`**
Add after the existing `DbSet<FamilyUnit>` line (around line 13):
```csharp
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
public DbSet<OfferingSession> OfferingSessions => Set<OfferingSession>();
public DbSet<Giving> Givings => Set<Giving>();
```
Add inside `OnModelCreating`, after the `Member` configuration block (before the closing brace of the method):
```csharp
// ── GivingCategory ───────────────────────────────────────────────────
builder.Entity<GivingCategory>(entity =>
{
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.Description_en).HasMaxLength(500);
entity.Property(e => e.Description_zh).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── OfferingSession ──────────────────────────────────────────────────
builder.Entity<OfferingSession>(entity =>
{
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Draft");
entity.Property(e => e.CashTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.CheckTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.SystemTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
entity.Property(e => e.ReconciledBy).HasMaxLength(450);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.SessionDate).IsUnique();
});
// ── Giving ───────────────────────────────────────────────────────────
builder.Entity<Giving>(entity =>
{
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.PaymentMethod).HasMaxLength(20).IsRequired();
entity.Property(e => e.CheckNumber).HasMaxLength(50);
entity.Property(e => e.ZelleReferenceCode).HasMaxLength(100);
entity.Property(e => e.PayPalTransactionId).HasMaxLength(100);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.MemberId, e.GivingDate });
entity.HasIndex(e => e.OfferingSessionId).HasFilter("\"OfferingSessionId\" IS NOT NULL");
entity.HasIndex(e => e.GivingDate);
entity.HasOne(e => e.GivingCategory).WithMany()
.HasForeignKey(e => e.GivingCategoryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.OfferingSession).WithMany(s => s.Givings)
.HasForeignKey(e => e.OfferingSessionId).OnDelete(DeleteBehavior.Cascade);
});
```
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/Entities/GivingCategory.cs API/ROLAC.API/Entities/OfferingSession.cs API/ROLAC.API/Entities/Giving.cs API/ROLAC.API/Data/AppDbContext.cs
git commit -m "feat(giving): add GivingCategory, OfferingSession, Giving entities + EF config"
```
---
## Task 2: Seed giving categories
**Files:**
- Modify: `API/ROLAC.API/Data/DbSeeder.cs`
- [ ] **Step 1: Add seed data array + method to `DbSeeder`**
Add this static array near the top `Roles` array:
```csharp
private static readonly (string En, string Zh, int Sort)[] GivingCategorySeed =
[
("Tithe", "什一奉獻", 1),
("General Offering", "一般奉獻", 2),
("Special Offering", "特別奉獻", 3),
("Building Fund", "建堂基金", 4),
("Mission", "宣教奉獻", 5),
];
public static async Task SeedGivingCategoriesAsync(AppDbContext db)
{
foreach (var (en, zh, sort) in GivingCategorySeed)
{
if (!db.GivingCategories.Any(c => c.Name_en == en))
{
db.GivingCategories.Add(new GivingCategory
{
Name_en = en,
Name_zh = zh,
SortOrder = sort,
IsActive = true,
// Audit fields are stamped by AuditSaveChangesInterceptor on save.
});
}
}
await db.SaveChangesAsync();
}
```
- [ ] **Step 2: Call it from `SeedAsync`**
In `SeedAsync(IServiceProvider services)`, after `await SeedRolesAsync(roleManager);`, add:
```csharp
var db = services.GetRequiredService<AppDbContext>();
await SeedGivingCategoriesAsync(db);
```
Add `using ROLAC.API.Data;` is unnecessary (same namespace); ensure `using Microsoft.Extensions.DependencyInjection;` is present (it is via implicit usings).
- [ ] **Step 3: Build**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/Data/DbSeeder.cs
git commit -m "feat(giving): seed default giving categories"
```
---
## Task 3: EF migration
**Files:**
- Create: `API/ROLAC.API/Migrations/*_AddGivingModule.cs` (generated)
- [ ] **Step 1: Generate the migration**
Run (from `API/ROLAC.API`): `dotnet ef migrations add AddGivingModule`
Expected: New migration files generated under `Migrations/`. If `dotnet ef` is missing: `dotnet tool install --global dotnet-ef`.
- [ ] **Step 2: Inspect the generated migration**
Open the generated `*_AddGivingModule.cs` and confirm it creates `GivingCategories`, `OfferingSessions`, `Givings` tables with the unique index on `OfferingSessions.SessionDate`, the three `Givings` indexes, and the FK restrict/setnull/cascade behaviors. No manual edits expected.
- [ ] **Step 3: Build**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/Migrations/
git commit -m "feat(giving): add EF migration for giving module"
```
---
## Task 4: GivingCategory DTOs + service (TDD)
**Files:**
- Create: `API/ROLAC.API/DTOs/Giving/GivingCategoryDto.cs`, `CreateGivingCategoryRequest.cs`, `UpdateGivingCategoryRequest.cs`
- Create: `API/ROLAC.API/Services/IGivingCategoryService.cs`, `GivingCategoryService.cs`
- Create: `API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs`
- Modify: `API/ROLAC.API/Program.cs`
- [ ] **Step 1: Create the DTOs**
`DTOs/Giving/GivingCategoryDto.cs`:
```csharp
namespace ROLAC.API.DTOs.Giving;
public class GivingCategoryDto
{
public int Id { get; set; }
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public string? Description_en { get; set; }
public string? Description_zh { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
```
`DTOs/Giving/CreateGivingCategoryRequest.cs`:
```csharp
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateGivingCategoryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public int SortOrder { get; set; }
}
```
`DTOs/Giving/UpdateGivingCategoryRequest.cs`:
```csharp
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class UpdateGivingCategoryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
```
- [ ] **Step 2: Create the service interface**
`Services/IGivingCategoryService.cs`:
```csharp
using ROLAC.API.DTOs.Giving;
namespace ROLAC.API.Services;
public interface IGivingCategoryService
{
Task<List<GivingCategoryDto>> GetAllAsync(bool includeInactive);
Task<int> CreateAsync(CreateGivingCategoryRequest request);
Task UpdateAsync(int id, UpdateGivingCategoryRequest request);
Task DeactivateAsync(int id); // soft-disable: IsActive = false
}
```
- [ ] **Step 3: Write the failing tests**
`API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class GivingCategoryServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
[Fact]
public async Task CreateAsync_ReturnsId_AndDefaultsActive()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db, BuildAccessor());
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Tithe", Name_zh = "什一" });
var saved = await db.GivingCategories.FindAsync(id);
Assert.NotNull(saved);
Assert.True(saved!.IsActive);
Assert.Equal("Tithe", saved.Name_en);
}
[Fact]
public async Task GetAllAsync_ExcludesInactive_ByDefault()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db, BuildAccessor());
var id1 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Active" });
var id2 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Gone" });
await svc.DeactivateAsync(id2);
var active = await svc.GetAllAsync(includeInactive: false);
var all = await svc.GetAllAsync(includeInactive: true);
Assert.Single(active);
Assert.Equal(2, all.Count);
}
[Fact]
public async Task DeactivateAsync_SetsIsActiveFalse()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db, BuildAccessor());
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Temp" });
await svc.DeactivateAsync(id);
var saved = await db.GivingCategories.FindAsync(id);
Assert.False(saved!.IsActive);
}
[Fact]
public async Task UpdateAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db, BuildAccessor());
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateAsync(999, new UpdateGivingCategoryRequest { Name_en = "X" }));
}
}
```
- [ ] **Step 4: Run tests to verify they fail**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingCategoryServiceTests`
Expected: Compile error / FAIL — `GivingCategoryService` does not exist yet.
- [ ] **Step 5: Implement the service**
`Services/GivingCategoryService.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class GivingCategoryService : IGivingCategoryService
{
private readonly AppDbContext _db;
public GivingCategoryService(AppDbContext db, IHttpContextAccessor http) => _db = db;
public async Task<List<GivingCategoryDto>> GetAllAsync(bool includeInactive)
{
var query = _db.GivingCategories.AsNoTracking().AsQueryable();
if (!includeInactive) query = query.Where(c => c.IsActive);
return await query
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name_en)
.Select(c => new GivingCategoryDto
{
Id = c.Id, Name_en = c.Name_en, Name_zh = c.Name_zh,
Description_en = c.Description_en, Description_zh = c.Description_zh,
IsActive = c.IsActive, SortOrder = c.SortOrder,
})
.ToListAsync();
}
public async Task<int> CreateAsync(CreateGivingCategoryRequest r)
{
var entity = new GivingCategory
{
Name_en = r.Name_en, Name_zh = r.Name_zh,
Description_en = r.Description_en, Description_zh = r.Description_zh,
SortOrder = r.SortOrder, IsActive = true,
};
_db.GivingCategories.Add(entity);
await _db.SaveChangesAsync();
return entity.Id;
}
public async Task UpdateAsync(int id, UpdateGivingCategoryRequest r)
{
var c = await _db.GivingCategories.FindAsync(id)
?? throw new KeyNotFoundException($"GivingCategory {id} not found.");
c.Name_en = r.Name_en; c.Name_zh = r.Name_zh;
c.Description_en = r.Description_en; c.Description_zh = r.Description_zh;
c.IsActive = r.IsActive; c.SortOrder = r.SortOrder;
await _db.SaveChangesAsync();
}
public async Task DeactivateAsync(int id)
{
var c = await _db.GivingCategories.FindAsync(id)
?? throw new KeyNotFoundException($"GivingCategory {id} not found.");
c.IsActive = false;
await _db.SaveChangesAsync();
}
}
```
- [ ] **Step 6: Register the service in `Program.cs`**
After `builder.Services.AddScoped<IMemberService, MemberService>();` add:
```csharp
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingCategoryServiceTests`
Expected: PASS (4 tests).
- [ ] **Step 8: Commit**
```bash
git add API/ROLAC.API/DTOs/Giving/ API/ROLAC.API/Services/IGivingCategoryService.cs API/ROLAC.API/Services/GivingCategoryService.cs API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs API/ROLAC.API/Program.cs
git commit -m "feat(giving): giving-category service with CRUD + soft-disable"
```
---
## Task 5: GivingCategoriesController
**Files:**
- Create: `API/ROLAC.API/Controllers/GivingCategoriesController.cs`
- [ ] **Step 1: Create the controller**
```csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/giving-categories")]
[Authorize(Roles = "finance,super_admin")]
public class GivingCategoriesController : ControllerBase
{
private readonly IGivingCategoryService _svc;
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
```
- [ ] **Step 2: Build**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add API/ROLAC.API/Controllers/GivingCategoriesController.cs
git commit -m "feat(giving): add giving-categories controller"
```
---
## Task 6: Giving DTOs + single-entry service (TDD)
**Files:**
- Create: `API/ROLAC.API/DTOs/Giving/GivingDto.cs`, `GivingListItemDto.cs`, `CreateGivingRequest.cs`, `UpdateGivingRequest.cs`
- Create: `API/ROLAC.API/Services/IGivingService.cs`, `GivingService.cs`
- Create: `API/ROLAC.API.Tests/Services/GivingServiceTests.cs`
- Modify: `API/ROLAC.API/Program.cs`
- [ ] **Step 1: Create the DTOs**
`DTOs/Giving/GivingListItemDto.cs`:
```csharp
namespace ROLAC.API.DTOs.Giving;
public class GivingListItemDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; } // resolved display name, null if anonymous
public int GivingCategoryId { get; set; }
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string GivingDate { get; set; } = ""; // ISO yyyy-MM-dd
public bool IsAnonymous { get; set; }
public int? OfferingSessionId{ get; set; }
}
```
`DTOs/Giving/GivingDto.cs`:
```csharp
namespace ROLAC.API.DTOs.Giving;
public class GivingDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public int? OfferingSessionId { get; set; }
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string? CheckNumber { get; set; }
public string? ZelleReferenceCode { get; set; }
public string? PayPalTransactionId { get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
}
```
`DTOs/Giving/CreateGivingRequest.cs`:
```csharp
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateGivingRequest
{
public int? MemberId { get; set; }
[Required] public int GivingCategoryId { get; set; }
[Range(0.01, 9999999)] public decimal Amount { get; set; }
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
[MaxLength(50)] public string? CheckNumber { get; set; }
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
[MaxLength(500)] public string? Notes { get; set; }
}
```
`DTOs/Giving/UpdateGivingRequest.cs`:
```csharp
namespace ROLAC.API.DTOs.Giving;
public class UpdateGivingRequest : CreateGivingRequest { }
```
- [ ] **Step 2: Create the service interface**
`Services/IGivingService.cs`:
```csharp
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IGivingService
{
Task<PagedResult<GivingListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to);
Task<GivingDto?> GetByIdAsync(int id);
Task<int> CreateAsync(CreateGivingRequest request);
Task UpdateAsync(int id, UpdateGivingRequest request);
Task DeleteAsync(int id);
}
```
- [ ] **Step 3: Write the failing tests**
`API/ROLAC.API.Tests/Services/GivingServiceTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class GivingServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedCategoryAsync(AppDbContext db)
{
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
db.GivingCategories.Add(c);
await db.SaveChangesAsync();
return c.Id;
}
[Fact]
public async Task CreateAsync_PersistsGiving()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db, BuildAccessor());
var id = await svc.CreateAsync(new CreateGivingRequest
{
GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31), IsAnonymous = true,
});
var saved = await db.Givings.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal(100m, saved!.Amount);
Assert.Null(saved.OfferingSessionId); // single entry is not session-bound
}
[Fact]
public async Task GetPagedAsync_FiltersByCategory()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db, BuildAccessor());
await svc.CreateAsync(new CreateGivingRequest { GivingCategoryId = catId, Amount = 10m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) });
var page = await svc.GetPagedAsync(1, 20, null, catId, null, null);
Assert.Equal(1, page.TotalCount);
Assert.Equal("Tithe", page.Items[0].CategoryName);
}
[Fact]
public async Task UpdateAsync_Throws_WhenGivingBelongsToSubmittedSession()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var session = new OfferingSession { SessionDate = new DateOnly(2026,5,31), Status = "Submitted" };
db.OfferingSessions.Add(session);
await db.SaveChangesAsync();
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026,5,31), OfferingSessionId = session.Id };
db.Givings.Add(giving);
await db.SaveChangesAsync();
var svc = new GivingService(db, BuildAccessor());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(giving.Id, new UpdateGivingRequest
{ GivingCategoryId = catId, Amount = 999m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) }));
}
[Fact]
public async Task DeleteAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingService(db, BuildAccessor());
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(999));
}
}
```
- [ ] **Step 4: Run tests to verify they fail**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingServiceTests`
Expected: Compile error / FAIL — `GivingService` does not exist.
- [ ] **Step 5: Implement the service**
`Services/GivingService.cs`:
```csharp
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class GivingService : IGivingService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
public GivingService(AppDbContext db, IHttpContextAccessor http)
{
_db = db;
_http = http;
}
public async Task<PagedResult<GivingListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to)
{
var query = _db.Givings.AsNoTracking().AsQueryable();
if (categoryId.HasValue) query = query.Where(g => g.GivingCategoryId == categoryId.Value);
if (from.HasValue) query = query.Where(g => g.GivingDate >= from.Value);
if (to.HasValue) query = query.Where(g => g.GivingDate <= to.Value);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(g =>
(g.CheckNumber != null && g.CheckNumber.ToLower().Contains(s)) ||
(g.Notes != null && g.Notes.ToLower().Contains(s)));
}
var total = await query.CountAsync();
var rows = await query
.OrderByDescending(g => g.GivingDate).ThenByDescending(g => g.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.ToListAsync();
// Resolve names via separate lookups (mirrors MemberService InMemory-safe pattern).
var catNames = await _db.GivingCategories.AsNoTracking()
.ToDictionaryAsync(c => c.Id, c => c.Name_en);
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.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}");
var items = rows.Select(g => new GivingListItemDto
{
Id = g.Id, MemberId = g.MemberId,
MemberName = g.MemberId != null && memberNames.TryGetValue(g.MemberId.Value, out var n) ? n : null,
GivingCategoryId = g.GivingCategoryId,
CategoryName = catNames.TryGetValue(g.GivingCategoryId, out var cn) ? cn : "",
Amount = g.Amount, PaymentMethod = g.PaymentMethod,
GivingDate = g.GivingDate.ToString("yyyy-MM-dd"),
IsAnonymous = g.IsAnonymous, OfferingSessionId = g.OfferingSessionId,
}).ToList();
return new PagedResult<GivingListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
public async Task<GivingDto?> GetByIdAsync(int id)
{
var g = await _db.Givings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return null;
string? memberName = null;
if (g.MemberId != null)
memberName = await _db.Members.AsNoTracking()
.Where(m => m.Id == g.MemberId)
.Select(m => m.FirstName_en + " " + m.LastName_en)
.FirstOrDefaultAsync();
return new GivingDto
{
Id = g.Id, MemberId = g.MemberId, MemberName = memberName,
GivingCategoryId = g.GivingCategoryId, OfferingSessionId = g.OfferingSessionId,
Amount = g.Amount, PaymentMethod = g.PaymentMethod, CheckNumber = g.CheckNumber,
ZelleReferenceCode = g.ZelleReferenceCode, PayPalTransactionId = g.PayPalTransactionId,
GivingDate = g.GivingDate, IsAnonymous = g.IsAnonymous, Notes = g.Notes,
};
}
public async Task<int> CreateAsync(CreateGivingRequest r)
{
var g = MapFromRequest(new Giving(), r);
g.OfferingSessionId = null; // single entry is never session-bound
_db.Givings.Add(g);
await _db.SaveChangesAsync();
return g.Id;
}
public async Task UpdateAsync(int id, UpdateGivingRequest r)
{
var g = await _db.Givings.FindAsync(id)
?? throw new KeyNotFoundException($"Giving {id} not found.");
await GuardSessionNotLockedAsync(g.OfferingSessionId);
MapFromRequest(g, r);
await _db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var g = await _db.Givings.FindAsync(id)
?? throw new KeyNotFoundException($"Giving {id} not found.");
await GuardSessionNotLockedAsync(g.OfferingSessionId);
_db.Givings.Remove(g);
await _db.SaveChangesAsync();
}
// A giving that belongs to a Submitted/Reconciled session cannot be edited directly.
private async Task GuardSessionNotLockedAsync(int? sessionId)
{
if (sessionId is null) return;
var status = await _db.OfferingSessions
.Where(s => s.Id == sessionId).Select(s => s.Status).FirstOrDefaultAsync();
if (status is "Submitted" or "Reconciled")
throw new InvalidOperationException(
"This giving belongs to a locked offering session. Reopen the session to edit.");
}
private static Giving MapFromRequest(Giving g, CreateGivingRequest r)
{
g.MemberId = r.IsAnonymous ? null : r.MemberId;
g.GivingCategoryId = r.GivingCategoryId;
g.Amount = r.Amount; g.PaymentMethod = r.PaymentMethod;
g.CheckNumber = r.CheckNumber; g.ZelleReferenceCode = r.ZelleReferenceCode;
g.PayPalTransactionId = r.PayPalTransactionId; g.GivingDate = r.GivingDate;
g.IsAnonymous = r.IsAnonymous; g.Notes = r.Notes;
return g;
}
}
```
- [ ] **Step 6: Register the service in `Program.cs`**
After the `IGivingCategoryService` registration add:
```csharp
builder.Services.AddScoped<IGivingService, GivingService>();
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingServiceTests`
Expected: PASS (4 tests).
- [ ] **Step 8: Commit**
```bash
git add API/ROLAC.API/DTOs/Giving/ API/ROLAC.API/Services/IGivingService.cs API/ROLAC.API/Services/GivingService.cs API/ROLAC.API.Tests/Services/GivingServiceTests.cs API/ROLAC.API/Program.cs
git commit -m "feat(giving): single-entry giving service with paging + lock guard"
```
---
## Task 7: GivingsController
**Files:**
- Create: `API/ROLAC.API/Controllers/GivingsController.cs`
- [ ] **Step 1: Create the controller**
```csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/givings")]
[Authorize(Roles = "finance,super_admin")]
public class GivingsController : ControllerBase
{
private readonly IGivingService _svc;
public GivingsController(IGivingService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
}
```
- [ ] **Step 2: Build**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add API/ROLAC.API/Controllers/GivingsController.cs
git commit -m "feat(giving): add givings controller"
```
---
## Task 8: OfferingSession DTOs + batch service (TDD)
This is the core. The service builds the whole session + lines in one save, recomputes `SystemTotal`/`Difference` server-side, and enforces locking.
**Files:**
- Create: `API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs`, `OfferingSessionListItemDto.cs`, `OfferingGivingLineDto.cs`, `CreateOfferingSessionRequest.cs`, `OfferingGivingLineRequest.cs`
- Create: `API/ROLAC.API/Services/IOfferingSessionService.cs`, `OfferingSessionService.cs`
- Create: `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`
- Modify: `API/ROLAC.API/Program.cs`
- [ ] **Step 1: Create the DTOs**
`DTOs/Giving/OfferingGivingLineRequest.cs`:
```csharp
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class OfferingGivingLineRequest
{
public int? MemberId { get; set; }
[Required] public int GivingCategoryId { get; set; }
[Range(0.01, 9999999)] public decimal Amount { get; set; }
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
[MaxLength(50)] public string? CheckNumber { get; set; }
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
public bool IsAnonymous { get; set; }
[MaxLength(500)] public string? Notes { get; set; }
}
```
`DTOs/Giving/CreateOfferingSessionRequest.cs`:
```csharp
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateOfferingSessionRequest
{
[Required] public DateOnly SessionDate { get; set; }
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public string? Notes { get; set; }
public List<OfferingGivingLineRequest> Givings { get; set; } = [];
}
```
`DTOs/Giving/OfferingGivingLineDto.cs`:
```csharp
namespace ROLAC.API.DTOs.Giving;
public class OfferingGivingLineDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string? CheckNumber { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
}
```
`DTOs/Giving/OfferingSessionListItemDto.cs`:
```csharp
namespace ROLAC.API.DTOs.Giving;
public class OfferingSessionListItemDto
{
public int Id { get; set; }
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public string Status { get; set; } = "";
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public int LineCount { get; set; }
}
```
`DTOs/Giving/OfferingSessionDto.cs`:
```csharp
namespace ROLAC.API.DTOs.Giving;
public class OfferingSessionDto
{
public int Id { get; set; }
public DateOnly SessionDate{ get; set; }
public string Status { get; set; } = "";
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public List<OfferingGivingLineDto> Givings { get; set; } = [];
}
```
- [ ] **Step 2: Create the service interface**
`Services/IOfferingSessionService.cs`:
```csharp
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IOfferingSessionService
{
Task<PagedResult<OfferingSessionListItemDto>> GetPagedAsync(
int page, int pageSize, DateOnly? from, DateOnly? to);
Task<OfferingSessionDto?> GetByIdAsync(int id);
Task<bool> DateExistsAsync(DateOnly date);
Task<int> CreateAsync(CreateOfferingSessionRequest request); // creates + submits in one tx
Task ReopenAsync(int id); // Submitted -> Draft
Task ReplaceAsync(int id, CreateOfferingSessionRequest request); // edit a reopened (Draft) session
}
```
- [ ] **Step 3: Write the failing tests**
`API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class OfferingSessionServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedCategoryAsync(AppDbContext db)
{
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
db.GivingCategories.Add(c);
await db.SaveChangesAsync();
return c.Id;
}
private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new()
{
SessionDate = date, CashTotal = 150m, CheckTotal = 0m,
Givings =
[
new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" },
new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true },
],
};
[Fact]
public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
var saved = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Submitted", saved!.Status);
Assert.Equal(150m, saved.SystemTotal); // 100 + 50, server-computed
Assert.Equal(0m, saved.Difference); // (150 cash + 0 check) - 150
Assert.NotNull(saved.SubmittedAt);
Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
}
[Fact]
public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync();
Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate));
Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null);
}
[Fact]
public async Task CreateAsync_Throws_OnDuplicateSessionDate()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))));
}
[Fact]
public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31))));
}
[Fact]
public async Task ReopenThenReplace_SwapsLinesAndResubmits()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await svc.ReopenAsync(id);
var reopened = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Draft", reopened!.Status);
var newReq = new CreateOfferingSessionRequest
{
SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m,
Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ],
};
await svc.ReplaceAsync(id, newReq);
var after = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Submitted", after!.Status);
Assert.Equal(200m, after.SystemTotal);
Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
}
}
```
- [ ] **Step 4: Run tests to verify they fail**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter OfferingSessionServiceTests`
Expected: Compile error / FAIL — `OfferingSessionService` does not exist.
- [ ] **Step 5: Implement the service**
`Services/OfferingSessionService.cs`:
```csharp
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class OfferingSessionService : IOfferingSessionService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
public OfferingSessionService(AppDbContext db, IHttpContextAccessor http)
{
_db = db;
_http = http;
}
private string CurrentUserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
public async Task<PagedResult<OfferingSessionListItemDto>> GetPagedAsync(
int page, int pageSize, DateOnly? from, DateOnly? to)
{
var query = _db.OfferingSessions.AsNoTracking().AsQueryable();
if (from.HasValue) query = query.Where(s => s.SessionDate >= from.Value);
if (to.HasValue) query = query.Where(s => s.SessionDate <= to.Value);
var total = await query.CountAsync();
var rows = await query
.OrderByDescending(s => s.SessionDate)
.Skip((page - 1) * pageSize).Take(pageSize)
.ToListAsync();
var ids = rows.Select(r => r.Id).ToList();
var counts = await _db.Givings.AsNoTracking()
.Where(g => g.OfferingSessionId != null && ids.Contains(g.OfferingSessionId.Value))
.GroupBy(g => g.OfferingSessionId!.Value)
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Count);
var items = rows.Select(s => new OfferingSessionListItemDto
{
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
SystemTotal = s.SystemTotal, Difference = s.Difference,
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
}).ToList();
return new PagedResult<OfferingSessionListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
public async Task<bool> DateExistsAsync(DateOnly date)
=> await _db.OfferingSessions.AnyAsync(s => s.SessionDate == date);
public async Task<OfferingSessionDto?> GetByIdAsync(int id)
{
var s = await _db.OfferingSessions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (s is null) return null;
var lines = await _db.Givings.AsNoTracking()
.Where(g => g.OfferingSessionId == id).ToListAsync();
var catNames = await _db.GivingCategories.AsNoTracking()
.ToDictionaryAsync(c => c.Id, c => c.Name_en);
var memberIds = lines.Where(l => l.MemberId != null).Select(l => l.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}");
return new OfferingSessionDto
{
Id = s.Id, SessionDate = s.SessionDate, Status = s.Status,
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes,
Givings = lines.Select(l => new OfferingGivingLineDto
{
Id = l.Id, MemberId = l.MemberId,
MemberName = l.MemberId != null && memberNames.TryGetValue(l.MemberId.Value, out var n) ? n : null,
GivingCategoryId = l.GivingCategoryId,
CategoryName = catNames.TryGetValue(l.GivingCategoryId, out var cn) ? cn : "",
Amount = l.Amount, PaymentMethod = l.PaymentMethod,
CheckNumber = l.CheckNumber, IsAnonymous = l.IsAnonymous, Notes = l.Notes,
}).ToList(),
};
}
public async Task<int> CreateAsync(CreateOfferingSessionRequest r)
{
if (await DateExistsAsync(r.SessionDate))
throw new InvalidOperationException($"An offering session for {r.SessionDate:yyyy-MM-dd} already exists.");
var systemTotal = r.Givings.Sum(g => g.Amount);
var session = new OfferingSession
{
SessionDate = r.SessionDate, Status = "Submitted",
CashTotal = r.CashTotal, CheckTotal = r.CheckTotal,
SystemTotal = systemTotal,
Difference = (r.CashTotal + r.CheckTotal) - systemTotal,
Notes = r.Notes,
SubmittedAt = DateTimeOffset.UtcNow, SubmittedBy = CurrentUserId,
Givings = r.Givings.Select(line => MapLine(line, r.SessionDate)).ToList(),
};
_db.OfferingSessions.Add(session);
await _db.SaveChangesAsync(); // header + lines inserted together
return session.Id;
}
public async Task ReopenAsync(int id)
{
var s = await _db.OfferingSessions.FindAsync(id)
?? throw new KeyNotFoundException($"OfferingSession {id} not found.");
if (s.Status != "Submitted")
throw new InvalidOperationException($"Only a Submitted session can be reopened (current: {s.Status}).");
s.Status = "Draft";
s.SubmittedAt = null; s.SubmittedBy = null;
await _db.SaveChangesAsync();
}
public async Task ReplaceAsync(int id, CreateOfferingSessionRequest r)
{
var s = await _db.OfferingSessions
.Include(x => x.Givings)
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"OfferingSession {id} not found.");
if (s.Status != "Draft")
throw new InvalidOperationException($"Only a Draft (reopened) session can be edited (current: {s.Status}).");
_db.Givings.RemoveRange(s.Givings); // drop old lines
var systemTotal = r.Givings.Sum(g => g.Amount);
s.CashTotal = r.CashTotal; s.CheckTotal = r.CheckTotal;
s.SystemTotal = systemTotal;
s.Difference = (r.CashTotal + r.CheckTotal) - systemTotal;
s.Notes = r.Notes;
s.Status = "Submitted";
s.SubmittedAt = DateTimeOffset.UtcNow; s.SubmittedBy = CurrentUserId;
s.Givings = r.Givings.Select(line => MapLine(line, s.SessionDate)).ToList();
await _db.SaveChangesAsync();
}
private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new()
{
MemberId = line.IsAnonymous ? null : line.MemberId,
GivingCategoryId = line.GivingCategoryId,
Amount = line.Amount,
PaymentMethod = line.PaymentMethod,
CheckNumber = line.CheckNumber,
ZelleReferenceCode = line.ZelleReferenceCode,
PayPalTransactionId= line.PayPalTransactionId,
GivingDate = sessionDate,
IsAnonymous = line.IsAnonymous,
Notes = line.Notes,
};
}
```
> **Note on transactions:** With Npgsql, a single `SaveChangesAsync` wraps all inserts in one DB transaction automatically, satisfying spec decision B. The EF InMemory provider used in tests does not support real transactions but still applies all changes in the one `SaveChangesAsync`, so the tests are valid.
- [ ] **Step 6: Register the service in `Program.cs`**
After the `IGivingService` registration add:
```csharp
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter OfferingSessionServiceTests`
Expected: PASS (5 tests).
- [ ] **Step 8: Run the full backend test suite**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj`
Expected: All tests pass (existing + new).
- [ ] **Step 9: Commit**
```bash
git add API/ROLAC.API/DTOs/Giving/ API/ROLAC.API/Services/IOfferingSessionService.cs API/ROLAC.API/Services/OfferingSessionService.cs API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs API/ROLAC.API/Program.cs
git commit -m "feat(giving): offering-session batch service with server-side totals + locking"
```
---
## Task 9: OfferingSessionsController
**Files:**
- Create: `API/ROLAC.API/Controllers/OfferingSessionsController.cs`
- [ ] **Step 1: Create the controller**
```csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/offering-sessions")]
[Authorize(Roles = "finance,super_admin")]
public class OfferingSessionsController : ControllerBase
{
private readonly IOfferingSessionService _svc;
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
[HttpGet("check-date")]
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
{
try
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/reopen")]
public async Task<IActionResult> Reopen(int id)
{
try { await _svc.ReopenAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
{
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
}
```
- [ ] **Step 2: Build**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add API/ROLAC.API/Controllers/OfferingSessionsController.cs
git commit -m "feat(giving): add offering-sessions controller"
```
---
## Task 10: Frontend models + API services
**Files:**
- Create: `APP/src/app/features/giving/models/giving.model.ts`
- Create: `APP/src/app/features/giving/services/giving-category-api.service.ts`, `giving-api.service.ts`, `offering-session-api.service.ts`
- [ ] **Step 1: Create the models file**
`APP/src/app/features/giving/models/giving.model.ts`:
```typescript
export type PaymentMethod = 'Cash' | 'Check' | 'Zelle' | 'PayPal' | 'Other';
export type SessionStatus = 'Draft' | 'Submitted' | 'Reconciled';
export interface PagedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}
// ── Giving categories ─────────────────────────────────────────────
export interface GivingCategoryDto {
id: number;
name_en: string;
name_zh: string | null;
description_en: string | null;
description_zh: string | null;
isActive: boolean;
sortOrder: number;
}
export interface CreateGivingCategoryRequest {
name_en: string;
name_zh: string | null;
description_en: string | null;
description_zh: string | null;
sortOrder: number;
}
export interface UpdateGivingCategoryRequest extends CreateGivingCategoryRequest {
isActive: boolean;
}
// ── Single giving ─────────────────────────────────────────────────
export interface GivingListItemDto {
id: number;
memberId: number | null;
memberName: string | null;
givingCategoryId: number;
categoryName: string;
amount: number;
paymentMethod: PaymentMethod;
givingDate: string; // yyyy-MM-dd
isAnonymous: boolean;
offeringSessionId: number | null;
}
export interface CreateGivingRequest {
memberId: number | null;
givingCategoryId: number;
amount: number;
paymentMethod: PaymentMethod;
checkNumber: string | null;
zelleReferenceCode: string | null;
payPalTransactionId: string | null;
givingDate: string; // yyyy-MM-dd
isAnonymous: boolean;
notes: string | null;
}
export type UpdateGivingRequest = CreateGivingRequest;
// ── Offering session (batch) ──────────────────────────────────────
export interface OfferingGivingLineRequest {
memberId: number | null;
givingCategoryId: number;
amount: number;
paymentMethod: PaymentMethod;
checkNumber: string | null;
zelleReferenceCode: string | null;
payPalTransactionId: string | null;
isAnonymous: boolean;
notes: string | null;
}
export interface CreateOfferingSessionRequest {
sessionDate: string; // yyyy-MM-dd
cashTotal: number;
checkTotal: number;
notes: string | null;
givings: OfferingGivingLineRequest[];
}
export interface OfferingGivingLineDto {
id: number;
memberId: number | null;
memberName: string | null;
givingCategoryId: number;
categoryName: string;
amount: number;
paymentMethod: PaymentMethod;
checkNumber: string | null;
isAnonymous: boolean;
notes: string | null;
}
export interface OfferingSessionDto {
id: number;
sessionDate: string;
status: SessionStatus;
cashTotal: number;
checkTotal: number;
systemTotal: number;
difference: number;
notes: string | null;
givings: OfferingGivingLineDto[];
}
export interface OfferingSessionListItemDto {
id: number;
sessionDate: string;
status: SessionStatus;
cashTotal: number;
checkTotal: number;
systemTotal: number;
difference: number;
lineCount: number;
}
/** A row held in the client-side batch buffer before submit. */
export interface OfferingBufferLine extends OfferingGivingLineRequest {
memberName: string | null; // for display only
categoryName: string; // for display only
}
```
- [ ] **Step 2: Create the giving-category API service**
`APP/src/app/features/giving/services/giving-category-api.service.ts`:
```typescript
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest,
} from '../models/giving.model';
@Injectable({ providedIn: 'root' })
export class GivingCategoryApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('giving-categories');
}
getAll(includeInactive = false): Observable<GivingCategoryDto[]> {
const params = new HttpParams().set('includeInactive', includeInactive);
return this.http.get<GivingCategoryDto[]>(this.endpoint, { params });
}
create(request: CreateGivingCategoryRequest): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, request);
}
update(id: number, request: UpdateGivingCategoryRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
deactivate(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
```
- [ ] **Step 3: Create the giving API service**
`APP/src/app/features/giving/services/giving-api.service.ts`:
```typescript
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
GivingListItemDto, CreateGivingRequest, UpdateGivingRequest, PagedResult,
} from '../models/giving.model';
export interface GivingQuery {
page?: number; pageSize?: number; search?: string;
categoryId?: number; from?: string; to?: string;
}
@Injectable({ providedIn: 'root' })
export class GivingApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('givings');
}
getPaged(q: GivingQuery = {}): Observable<PagedResult<GivingListItemDto>> {
let p = new HttpParams().set('page', q.page ?? 1).set('pageSize', q.pageSize ?? 20);
if (q.search) p = p.set('search', q.search);
if (q.categoryId != null) p = p.set('categoryId', q.categoryId);
if (q.from) p = p.set('from', q.from);
if (q.to) p = p.set('to', q.to);
return this.http.get<PagedResult<GivingListItemDto>>(this.endpoint, { params: p });
}
create(request: CreateGivingRequest): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, request);
}
update(id: number, request: UpdateGivingRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
```
- [ ] **Step 4: Create the offering-session API service**
`APP/src/app/features/giving/services/offering-session-api.service.ts`:
```typescript
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
OfferingSessionDto, OfferingSessionListItemDto,
CreateOfferingSessionRequest, PagedResult,
} from '../models/giving.model';
@Injectable({ providedIn: 'root' })
export class OfferingSessionApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('offering-sessions');
}
getPaged(page = 1, pageSize = 20): Observable<PagedResult<OfferingSessionListItemDto>> {
const params = new HttpParams().set('page', page).set('pageSize', pageSize);
return this.http.get<PagedResult<OfferingSessionListItemDto>>(this.endpoint, { params });
}
getById(id: number): Observable<OfferingSessionDto> {
return this.http.get<OfferingSessionDto>(`${this.endpoint}/${id}`);
}
checkDate(date: string): Observable<{ exists: boolean }> {
const params = new HttpParams().set('date', date);
return this.http.get<{ exists: boolean }>(`${this.endpoint}/check-date`, { params });
}
create(request: CreateOfferingSessionRequest): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, request);
}
reopen(id: number): Observable<void> {
return this.http.post<void>(`${this.endpoint}/${id}/reopen`, {});
}
replace(id: number, request: CreateOfferingSessionRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
}
```
- [ ] **Step 5: Build the frontend to verify it compiles**
Run: `cd APP; npm run build`
Expected: Build succeeds (no usages yet — just type-checking the new files).
- [ ] **Step 6: Commit**
```bash
git add APP/src/app/features/giving/models APP/src/app/features/giving/services
git commit -m "feat(giving): frontend models + API services"
```
---
## Task 11: Routes + role-gated navigation
**Files:**
- Modify: `APP/src/app/app.routes.ts`
- Modify: `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts` + `.html`
- [ ] **Step 1: Add routes**
In `app.routes.ts`, add these imports at the top:
```typescript
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component';
import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component';
```
Inside the `user-portal` `children` array, after the `admin/users` route, add:
```typescript
{
path: 'finance/giving-categories',
component: GivingCategoriesPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
{
path: 'finance/givings',
component: GivingsPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
{
path: 'finance/offering-session',
component: OfferingSessionPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
```
> These imports reference components created in Tasks 12-14. If executing strictly in order, this step will fail to build until those components exist. **Execute Step 1 of this task last** (after Tasks 12-14), or temporarily stub the three components as empty standalone components. The nav changes (Steps 2-3) are independent and can be done now.
- [ ] **Step 2: Add the finance nav section to `user-navbar.component.ts`**
Add a nav array after `userAdminNavItems` (around line 69):
```typescript
public financeNavItems: NavItem[] = [
{ text: 'Offering Entry', icon: this.creditCardIcon, path: '/user-portal/finance/offering-session' },
{ text: 'Givings', icon: this.creditCardIcon, path: '/user-portal/finance/givings' },
{ text: 'Giving Types', icon: this.creditCardIcon, path: '/user-portal/finance/giving-categories' },
];
public showFinanceSection = false;
```
In `ngOnInit`, inside the `currentUser$` subscription (after `this.showUserAdminSection = ...`), add:
```typescript
this.showFinanceSection = roles.some(r => r === 'finance' || r === 'super_admin');
```
In `updateActiveStates`, add `...this.financeNavItems` to **both** the reset array and the find array (lines ~115-122):
```typescript
[...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems]
.forEach(item => item.active = false);
const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems]
.find(item => currentUrl.startsWith(item.path));
```
- [ ] **Step 3: Render the finance section in `user-navbar.component.html`**
Find the existing member/user admin section markup (rendered with `*ngIf="showMemberAdminSection"` / `showUserAdminSection`). Mirror that exact structure for finance. Add after the user-admin block:
```html
<ng-container *ngIf="showFinanceSection">
<div class="nav-section-title">Finance</div>
<button *ngFor="let item of financeNavItems"
kendoButton fillMode="flat"
[class.active]="item.active"
(click)="navigateTo(item.path)">
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
<span>{{ item.text }}</span>
</button>
</ng-container>
```
> Match the actual class names / button markup used by the existing admin sections in this file — copy their pattern rather than the placeholder classes above if they differ.
- [ ] **Step 4: Commit (nav only; routes after Tasks 12-14)**
```bash
git add APP/src/app/portals/user-portal/components/user-navbar/
git commit -m "feat(giving): add role-gated finance nav section"
```
---
## Task 12: Giving categories management page
**Files:**
- Create: `APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.ts` + `.html` + `.scss`
Follow the `MembersPageComponent` pattern (Kendo Grid + inline/dialog edit). Categories are few, so a simple grid + add/edit form panel is enough (no paging).
- [ ] **Step 1: Create the component .ts**
`giving-categories-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 { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
import {
GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest,
} from '../../models/giving.model';
@Component({
selector: 'app-giving-categories-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule],
templateUrl: './giving-categories-page.component.html',
styleUrls: ['./giving-categories-page.component.scss'],
})
export class GivingCategoriesPageComponent implements OnInit {
data: GivingCategoryDto[] = [];
isLoading = false;
includeInactive = false;
showDialog = false;
editing: GivingCategoryDto | null = null;
form: UpdateGivingCategoryRequest = this.blankForm();
constructor(private api: GivingCategoryApiService) {}
ngOnInit(): void { this.load(); }
load(): void {
this.isLoading = true;
this.api.getAll(this.includeInactive).subscribe({
next: rows => { this.data = rows; this.isLoading = false; },
error: () => { this.isLoading = false; },
});
}
openAdd(): void { this.editing = null; this.form = this.blankForm(); this.showDialog = true; }
openEdit(c: GivingCategoryDto): void {
this.editing = c;
this.form = {
name_en: c.name_en, name_zh: c.name_zh,
description_en: c.description_en, description_zh: c.description_zh,
isActive: c.isActive, sortOrder: c.sortOrder,
};
this.showDialog = true;
}
save(): void {
if (this.editing) {
this.api.update(this.editing.id, this.form).subscribe(() => { this.showDialog = false; this.load(); });
} else {
const create: CreateGivingCategoryRequest = {
name_en: this.form.name_en, name_zh: this.form.name_zh,
description_en: this.form.description_en, description_zh: this.form.description_zh,
sortOrder: this.form.sortOrder,
};
this.api.create(create).subscribe(() => { this.showDialog = false; this.load(); });
}
}
deactivate(c: GivingCategoryDto): void {
if (!confirm(`Deactivate "${c.name_en}"?`)) return;
this.api.deactivate(c.id).subscribe(() => this.load());
}
private blankForm(): UpdateGivingCategoryRequest {
return { name_en: '', name_zh: null, description_en: null, description_zh: null, isActive: true, sortOrder: 0 };
}
}
```
- [ ] **Step 2: Create the .html**
`giving-categories-page.component.html`:
```html
<div class="page">
<header class="page-header">
<h2>Giving Types / 奉獻類型</h2>
<div>
<label><input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive</label>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
</div>
</header>
<kendo-grid [data]="data" [loading]="isLoading">
<kendo-grid-column field="sortOrder" title="#" [width]="60"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="90">
<ng-template kendoGridCellTemplate let-c>{{ c.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-c>
<button kendoButton fillMode="flat" (click)="openEdit(c)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="c.isActive" (click)="deactivate(c)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480">
<div class="form-grid">
<label>Name (EN) *<input kendoTextBox [(ngModel)]="form.name_en" /></label>
<label>名稱 (中)<input kendoTextBox [(ngModel)]="form.name_zh" /></label>
<label>Description (EN)<input kendoTextBox [(ngModel)]="form.description_en" /></label>
<label>說明 (中)<input kendoTextBox [(ngModel)]="form.description_zh" /></label>
<label>Sort order<input kendoTextBox type="number" [(ngModel)]="form.sortOrder" /></label>
<label *ngIf="editing"><input type="checkbox" [(ngModel)]="form.isActive" /> Active</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="showDialog=false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!form.name_en" (click)="save()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
```
> Add `DialogsModule` from `@progress/kendo-angular-dialog` to the component `imports` array (needed for `kendo-dialog`).
- [ ] **Step 3: Create an empty .scss**
`giving-categories-page.component.scss`:
```scss
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.form-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; }
```
- [ ] **Step 4: Build**
Run: `cd APP; npm run build`
Expected: Build succeeds.
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/features/giving/pages/giving-categories-page/
git commit -m "feat(giving): giving categories management page"
```
---
## Task 13: Single giving entry page
**Files:**
- Create: `APP/src/app/features/giving/pages/givings-page/givings-page.component.ts` + `.html` + `.scss`
Reuses the member search endpoint (`GET /api/members?search=`) for picking a giver. Import `MemberApiService` from the members feature.
- [ ] **Step 1: Create the component .ts**
`givings-page.component.ts`:
```typescript
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { DatePickerModule } from '@progress/kendo-angular-dateinputs';
import { GivingApiService } from '../../services/giving-api.service';
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult,
} from '../../models/giving.model';
@Component({
selector: 'app-givings-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DialogsModule, DatePickerModule,
],
templateUrl: './givings-page.component.html',
styleUrls: ['./givings-page.component.scss'],
})
export class GivingsPageComponent implements OnInit {
data: GivingListItemDto[] = [];
totalCount = 0;
page = 1;
pageSize = 20;
isLoading = false;
search = '';
filterCategoryId: number | null = null;
categories: GivingCategoryDto[] = [];
readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other'];
readonly memberDisplayName = memberDisplayName;
// Member search for the dialog
memberResults: MemberListItemDto[] = [];
showDialog = false;
editingId: number | null = null;
form: CreateGivingRequest = this.blankForm();
selectedMember: MemberListItemDto | null = null;
constructor(
private api: GivingApiService,
private categoryApi: GivingCategoryApiService,
private memberApi: MemberApiService,
) {}
ngOnInit(): void {
this.categoryApi.getAll(false).subscribe(c => this.categories = c);
this.load();
}
load(): void {
this.isLoading = true;
this.api.getPaged({
page: this.page, pageSize: this.pageSize,
search: this.search || undefined,
categoryId: this.filterCategoryId ?? undefined,
}).subscribe({
next: (r: PagedResult<GivingListItemDto>) => {
this.data = r.items; this.totalCount = r.totalCount; this.isLoading = false;
},
error: () => { this.isLoading = false; },
});
}
onPageChange(e: PageChangeEvent): void {
this.page = e.skip / this.pageSize + 1; this.pageSize = e.take; this.load();
}
onSearch(): void { this.page = 1; this.load(); }
onMemberFilter(term: string): void {
if (!term || term.length < 1) { this.memberResults = []; return; }
this.memberApi.getPaged({ search: term, pageSize: 10 })
.subscribe(r => this.memberResults = r.items);
}
openAdd(): void {
this.editingId = null; this.form = this.blankForm(); this.selectedMember = null;
this.showDialog = true;
}
onMemberSelected(m: MemberListItemDto | null): void {
this.selectedMember = m;
this.form.memberId = m ? m.id : null;
}
toggleAnonymous(): void {
this.form.isAnonymous = !this.form.isAnonymous;
if (this.form.isAnonymous) { this.form.memberId = null; this.selectedMember = null; }
}
save(): void {
const today = new Date().toISOString().slice(0, 10);
if (!this.form.givingDate) this.form.givingDate = today;
if (this.editingId) {
this.api.update(this.editingId, this.form).subscribe(() => { this.showDialog = false; this.load(); });
} else {
this.api.create(this.form).subscribe(() => { this.showDialog = false; this.load(); });
}
}
delete(g: GivingListItemDto): void {
if (!confirm('Delete this giving record?')) return;
this.api.delete(g.id).subscribe({
next: () => this.load(),
error: err => alert(err?.error?.message ?? 'Delete failed (record may belong to a locked session).'),
});
}
private blankForm(): CreateGivingRequest {
return {
memberId: null, givingCategoryId: this.categories[0]?.id ?? 0, amount: 0,
paymentMethod: 'Cash', checkNumber: null, zelleReferenceCode: null,
payPalTransactionId: null, givingDate: new Date().toISOString().slice(0, 10),
isAnonymous: false, notes: null,
};
}
}
```
- [ ] **Step 2: Create the .html**
`givings-page.component.html`:
```html
<div class="page">
<header class="page-header">
<h2>Givings / 單筆奉獻</h2>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add Giving</button>
</header>
<div class="filters">
<input kendoTextBox placeholder="Search check # / notes" [(ngModel)]="search" (keyup.enter)="onSearch()" />
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="filterCategoryId" (valueChange)="onSearch()"
[defaultItem]="{ id: null, name_en: 'All types' }"></kendo-dropdownlist>
<button kendoButton (click)="onSearch()">Search</button>
</div>
<kendo-grid [data]="data" [loading]="isLoading"
[pageable]="true" [skip]="(page-1)*pageSize" [pageSize]="pageSize"
[total]="totalCount" (pageChange)="onPageChange($event)">
<kendo-grid-column field="givingDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column title="Giver">
<ng-template kendoGridCellTemplate let-g>{{ g.isAnonymous ? '(Anonymous)' : g.memberName }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="categoryName" title="Type"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column field="paymentMethod" title="Method" [width]="100"></kendo-grid-column>
<kendo-grid-column title="Actions" [width]="140">
<ng-template kendoGridCellTemplate let-g>
<button kendoButton fillMode="flat" (click)="delete(g)">Delete</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520">
<div class="form-grid">
<label><input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous</label>
<label *ngIf="!form.isAnonymous">Giver
<kendo-dropdownlist [data]="memberResults" [textField]="'firstName_en'" [valueField]="'id'"
[filterable]="true" (filterChange)="onMemberFilter($event)"
(valueChange)="onMemberSelected($event)" [valuePrimitive]="false"
placeholder="Search member by name"></kendo-dropdownlist>
</label>
<label>Type
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="form.givingCategoryId"></kendo-dropdownlist>
</label>
<label>Payment method
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="form.paymentMethod"></kendo-dropdownlist>
</label>
<label *ngIf="form.paymentMethod === 'Check'">Check #<input kendoTextBox [(ngModel)]="form.checkNumber" /></label>
<label *ngIf="form.paymentMethod === 'Zelle'">Zelle ref<input kendoTextBox [(ngModel)]="form.zelleReferenceCode" /></label>
<label *ngIf="form.paymentMethod === 'PayPal'">PayPal txn<input kendoTextBox [(ngModel)]="form.payPalTransactionId" /></label>
<label>Amount<kendo-numerictextbox [(ngModel)]="form.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<label>Date<kendo-datepicker [(ngModel)]="form.givingDate"></kendo-datepicker></label>
<label>Notes<input kendoTextBox [(ngModel)]="form.notes" /></label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="showDialog=false">Cancel</button>
<button kendoButton themeColor="primary"
[disabled]="form.amount <= 0 || (form.paymentMethod==='Check' && !form.checkNumber)"
(click)="save()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
```
> The Kendo `datepicker` binds a `Date`; the model uses an ISO string. If binding errors occur, convert in `save()` (`new Date(...).toISOString().slice(0,10)`) and store the picker value in a separate `Date` field. Keep it simple — adjust only if the build/runtime complains.
- [ ] **Step 3: Create the .scss**
`givings-page.component.scss`:
```scss
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.form-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; }
```
- [ ] **Step 4: Build**
Run: `cd APP; npm run build`
Expected: Build succeeds.
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/features/giving/pages/givings-page/
git commit -m "feat(giving): single giving entry page"
```
---
## Task 14: Sunday offering batch-entry page (core)
**Files:**
- Create: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts` + `.html` + `.scss`
- Create: `APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts` + `.html`
Client-buffered batch (decision B). All lines live in `buffer: OfferingBufferLine[]`; the front-end subtotal is `Σ amount`; one `POST` submits everything.
- [ ] **Step 1: Create the quick-add member dialog component**
`member-quick-add-dialog.component.ts`:
```typescript
import { Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { MemberApiService } from '../../../members/services/member-api.service';
import { CreateMemberRequest, MemberListItemDto } from '../../../members/models/member.model';
@Component({
selector: 'app-member-quick-add-dialog',
standalone: true,
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule],
templateUrl: './member-quick-add-dialog.component.html',
})
export class MemberQuickAddDialogComponent {
@Output() created = new EventEmitter<MemberListItemDto>();
@Output() cancelled = new EventEmitter<void>();
firstName_en = '';
lastName_en = '';
firstName_zh: string | null = null;
lastName_zh: string | null = null;
phoneCell: string | null = null;
saving = false;
constructor(private memberApi: MemberApiService) {}
save(): void {
if (!this.firstName_en || !this.lastName_en) return;
this.saving = true;
const req: CreateMemberRequest = {
firstName_en: this.firstName_en, lastName_en: this.lastName_en,
nickName: null, firstName_zh: this.firstName_zh, lastName_zh: this.lastName_zh,
gender: null, dateOfBirth: null, baptismDate: null, baptismChurch: null,
email: null, phoneCell: this.phoneCell, phoneHome: null, address: null,
city: null, state: null, zipCode: null, country: 'USA',
status: 'Visitor', languagePreference: 'en', joinDate: null,
notes: null, familyUnitId: null,
};
this.memberApi.create(req).subscribe({
next: ({ id }) => {
this.saving = false;
this.created.emit({
id, firstName_en: this.firstName_en, lastName_en: this.lastName_en,
nickName: null, firstName_zh: this.firstName_zh, lastName_zh: this.lastName_zh,
status: 'Visitor', email: null, phoneCell: this.phoneCell, joinDate: null,
linkedUserId: null,
});
},
error: () => { this.saving = false; },
});
}
}
```
`member-quick-add-dialog.component.html`:
```html
<kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420">
<div class="form-grid" style="display:flex;flex-direction:column;gap:0.75rem;">
<label>First name (EN) *<input kendoTextBox [(ngModel)]="firstName_en" /></label>
<label>Last name (EN) *<input kendoTextBox [(ngModel)]="lastName_en" /></label>
<label>名 (中)<input kendoTextBox [(ngModel)]="firstName_zh" /></label>
<label>姓 (中)<input kendoTextBox [(ngModel)]="lastName_zh" /></label>
<label>Cell phone<input kendoTextBox [(ngModel)]="phoneCell" /></label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="cancelled.emit()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!firstName_en || !lastName_en || saving" (click)="save()">Create</button>
</kendo-dialog-actions>
</kendo-dialog>
```
- [ ] **Step 2: Create the batch page component .ts**
`offering-session-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 { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DatePickerModule } from '@progress/kendo-angular-dateinputs';
import { OfferingSessionApiService } from '../../services/offering-session-api.service';
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto } from '../../../members/models/member.model';
import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component';
import {
GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest,
} from '../../models/giving.model';
@Component({
selector: 'app-offering-session-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DatePickerModule, MemberQuickAddDialogComponent,
],
templateUrl: './offering-session-page.component.html',
styleUrls: ['./offering-session-page.component.scss'],
})
export class OfferingSessionPageComponent implements OnInit {
sessionDate: Date = new Date();
dateConflict = false;
categories: GivingCategoryDto[] = [];
readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other'];
// Current entry row
memberResults: MemberListItemDto[] = [];
selectedMember: MemberListItemDto | null = null;
entry = this.blankEntry();
// Buffer
buffer: OfferingBufferLine[] = [];
editingIndex: number | null = null;
// Reconciliation
cashTotal = 0;
checkTotal = 0;
notes: string | null = null;
showQuickAdd = false;
submitting = false;
constructor(
private api: OfferingSessionApiService,
private categoryApi: GivingCategoryApiService,
private memberApi: MemberApiService,
) {}
ngOnInit(): void {
this.categoryApi.getAll(false).subscribe(c => {
this.categories = c;
this.entry.givingCategoryId = c[0]?.id ?? 0;
});
this.checkDate();
}
get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); }
get difference(): number { return (this.cashTotal + this.checkTotal) - this.systemTotal; }
checkDate(): void {
const d = this.toIso(this.sessionDate);
this.api.checkDate(d).subscribe(r => this.dateConflict = r.exists);
}
onMemberFilter(term: string): void {
if (!term) { this.memberResults = []; return; }
this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe(r => this.memberResults = r.items);
}
onMemberSelected(m: MemberListItemDto | null): void {
this.selectedMember = m;
this.entry.memberId = m ? m.id : null;
this.entry.isAnonymous = false;
}
markAnonymous(): void {
this.entry.isAnonymous = true; this.entry.memberId = null; this.selectedMember = null;
}
addLine(): void {
if (this.entry.amount <= 0) return;
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return;
const cat = this.categories.find(c => c.id === this.entry.givingCategoryId);
const line: OfferingBufferLine = {
...this.entry,
memberName: this.entry.isAnonymous ? null : (this.selectedMember
? `${this.selectedMember.firstName_en} ${this.selectedMember.lastName_en}` : null),
categoryName: cat?.name_en ?? '',
};
if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; }
else { this.buffer = [...this.buffer, line]; }
this.resetEntry();
}
editLine(i: number): void {
const l = this.buffer[i];
this.entry = { ...l };
this.selectedMember = null;
this.editingIndex = i;
}
removeLine(i: number): void { this.buffer = this.buffer.filter((_, idx) => idx !== i); }
onMemberQuickCreated(m: MemberListItemDto): void {
this.showQuickAdd = false;
this.onMemberSelected(m);
}
submit(): void {
if (this.buffer.length === 0 || this.dateConflict) return;
this.submitting = true;
const req: CreateOfferingSessionRequest = {
sessionDate: this.toIso(this.sessionDate),
cashTotal: this.cashTotal, checkTotal: this.checkTotal, notes: this.notes,
givings: this.buffer.map(l => ({
memberId: l.memberId, givingCategoryId: l.givingCategoryId, amount: l.amount,
paymentMethod: l.paymentMethod, checkNumber: l.checkNumber,
zelleReferenceCode: l.zelleReferenceCode, payPalTransactionId: l.payPalTransactionId,
isAnonymous: l.isAnonymous, notes: l.notes,
})),
};
this.api.create(req).subscribe({
next: () => {
this.submitting = false;
alert('Offering session submitted.');
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.checkDate();
},
error: err => {
this.submitting = false;
alert(err?.error?.message ?? 'Submit failed.');
},
});
}
private resetEntry(): void {
this.selectedMember = null; this.memberResults = [];
this.entry = this.blankEntry();
this.entry.givingCategoryId = this.categories[0]?.id ?? 0;
}
private blankEntry(): OfferingBufferLine {
return {
memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash',
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
isAnonymous: false, notes: null, memberName: null, categoryName: '',
};
}
private toIso(d: Date): string { return d.toISOString().slice(0, 10); }
}
```
- [ ] **Step 3: Create the batch page .html**
`offering-session-page.component.html`:
```html
<div class="page">
<header class="page-header">
<h2>Sunday Offering Entry / 主日奉獻錄入</h2>
<label>Date
<kendo-datepicker [(ngModel)]="sessionDate" (valueChange)="checkDate()"></kendo-datepicker>
</label>
</header>
<div *ngIf="dateConflict" class="warn">
An offering session for this date already exists. Pick another date, or reopen the existing session to edit.
</div>
<section class="entry-row">
<label *ngIf="!entry.isAnonymous">Giver
<kendo-dropdownlist [data]="memberResults" [textField]="'firstName_en'" [valueField]="'id'"
[filterable]="true" (filterChange)="onMemberFilter($event)"
(valueChange)="onMemberSelected($event)" placeholder="Search by name"></kendo-dropdownlist>
</label>
<span *ngIf="entry.isAnonymous" class="anon-chip">Anonymous</span>
<label>Type
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="entry.givingCategoryId"></kendo-dropdownlist>
</label>
<label>Method
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="entry.paymentMethod"></kendo-dropdownlist>
</label>
<label *ngIf="entry.paymentMethod === 'Check'">Check #<input kendoTextBox [(ngModel)]="entry.checkNumber" /></label>
<label>Amount
<kendo-numerictextbox [(ngModel)]="entry.amount" [min]="0" [format]="'c2'"
(keyup.enter)="addLine()"></kendo-numerictextbox>
</label>
<label>Notes<input kendoTextBox [(ngModel)]="entry.notes" (keyup.enter)="addLine()" /></label>
<div class="entry-actions">
<button kendoButton (click)="markAnonymous()">Anonymous</button>
<button kendoButton (click)="showQuickAdd = true">+ Quick add member</button>
<button kendoButton themeColor="primary" (click)="addLine()">+ Add (Enter)</button>
</div>
</section>
<kendo-grid [data]="buffer">
<kendo-grid-column title="Giver">
<ng-template kendoGridCellTemplate let-l>{{ l.isAnonymous ? '(Anonymous)' : l.memberName }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="categoryName" title="Type"></kendo-grid-column>
<kendo-grid-column field="paymentMethod" title="Method" [width]="90"></kendo-grid-column>
<kendo-grid-column field="checkNumber" title="Check #" [width]="90"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="120">
<ng-template kendoGridCellTemplate let-l let-i="rowIndex">
<button kendoButton fillMode="flat" (click)="editLine(i)">Edit</button>
<button kendoButton fillMode="flat" (click)="removeLine(i)">×</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<section class="reconcile">
<div>Lines: {{ buffer.length }} | System total: {{ systemTotal | currency }}</div>
<label>Cash counted<kendo-numerictextbox [(ngModel)]="cashTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<label>Check counted<kendo-numerictextbox [(ngModel)]="checkTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<div [class.ok]="difference === 0" [class.bad]="difference !== 0">
Difference: {{ difference | currency }}
</div>
<button kendoButton themeColor="primary"
[disabled]="buffer.length === 0 || dateConflict || submitting"
(click)="submit()">Submit</button>
</section>
<app-member-quick-add-dialog *ngIf="showQuickAdd"
(created)="onMemberQuickCreated($event)"
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
</div>
```
- [ ] **Step 4: Create the batch page .scss**
`offering-session-page.component.scss`:
```scss
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.warn { background: #fff3cd; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.entry-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 1rem; }
.entry-row label { display: flex; flex-direction: column; gap: 0.25rem; }
.entry-actions { display: flex; gap: 0.5rem; }
.anon-chip { padding: 0.25rem 0.5rem; background: #eee; border-radius: 4px; }
.reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; }
.reconcile .ok { color: green; font-weight: 600; }
.reconcile .bad { color: #c00; font-weight: 600; }
```
- [ ] **Step 5: Build**
Run: `cd APP; npm run build`
Expected: Build succeeds.
- [ ] **Step 6: Wire the routes (Task 11 Step 1)**
Now apply Task 11 Step 1 (add the three component imports + routes to `app.routes.ts`).
Run: `cd APP; npm run build`
Expected: Build succeeds with routes wired.
- [ ] **Step 7: Commit**
```bash
git add APP/src/app/features/giving/pages/offering-session-page/ APP/src/app/features/giving/components/member-quick-add-dialog/ APP/src/app/app.routes.ts
git commit -m "feat(giving): keyboard-first Sunday offering batch entry page + routes"
```
---
## Task 15: End-to-end manual verification
**Files:** none (manual)
- [ ] **Step 1: Start the backend**
Run (from `API/ROLAC.API`): `dotnet run`
Expected: App starts, migrations apply, giving categories seeded. Check Swagger at the dev URL shows `giving-categories`, `givings`, `offering-sessions` endpoints.
- [ ] **Step 2: Start the frontend**
Run (from `APP`): `npm start`
Expected: App serves on http://localhost:4200.
- [ ] **Step 3: Verify the happy path as a finance/super_admin user**
Log in as `admin@rolac.org` / `Admin1234!` (super_admin). Confirm:
1. Finance nav section is visible; non-finance roles do not see it.
2. Giving Types page lists the 5 seeded categories; add/edit/deactivate works.
3. Single Givings page: add a cash giving and an anonymous giving; both appear in the grid.
4. Offering Entry page: pick a date, add several lines (member + anonymous + a check with check #), watch the system subtotal update; enter cash/check counted; Difference shows; Submit succeeds.
5. Re-submitting the same date shows the duplicate-date conflict warning.
6. Quick-add member during batch entry creates a Visitor and selects them into the current line.
- [ ] **Step 4: Verify locking**
In Swagger or via the UI, attempt to edit/delete a giving that belongs to the submitted session → expect 409. `POST /offering-sessions/{id}/reopen` → then `PUT /offering-sessions/{id}` with new lines succeeds and re-locks.
- [ ] **Step 5: Final full test run**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj`
Expected: All tests pass.
- [ ] **Step 6: Commit any verification fixes, then finish the branch**
Use the `superpowers:finishing-a-development-branch` skill to decide merge/PR.
---
## Notes for the implementer
- **No AuditLog table exists** (spec R4). "Audit" = `CreatedBy/UpdatedBy/UpdatedAt` stamp fields set automatically by `AuditSaveChangesInterceptor`. Reopen/replace overwrites `UpdatedBy/UpdatedAt`; old line values are not retained beyond that. If full audit history is later required, that's a separate module.
- **Decision B trade-off (spec R2):** the batch buffer is client-only until Submit. If the browser closes mid-entry, unsaved lines are lost. `localStorage` autosave is intentionally out of scope; add later if users hit this.
- **Kendo module names** may need small adjustments to import paths/module names depending on the installed `@progress/kendo-angular-*` versions. Follow whatever the existing members/users pages import.
- **DateOnly / date strings:** backend uses `DateOnly` (JSON `yyyy-MM-dd` via the configured `TolerantDateOnlyConverter`). Kendo date pickers bind `Date`; convert at the boundary (`toIso`).
```