Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.0 KiB
Vendor Payment Snapshot — Design
Date: 2026-06-25 Status: Approved (design); pending implementation plan
Problem
Bookkeepers re-enter the same recurring vendor payments every month — rent, internet, a fixed catered-meal cost — retyping vendor, categories, amounts, and memo each time. The only thing that genuinely changes per entry is the Expense Date.
A Snapshot lets a user save the current vendor-payment form as a reusable, named template and re-apply it later with one click, pre-filling everything except the date.
Scope
- Applies to vendor payments only (
Expense.Type == "VendorPayment"). - Snapshots are shared church-wide — every user with vendor-payment access sees the full list — and each row is tagged with the creator for accountability.
- A snapshot is independent of any expense: deleting an expense never affects a snapshot, and applying a snapshot creates a brand-new expense.
What a snapshot captures
Everything needed to refill the vendor-payment form except ExpenseDate.
| Group | Fields |
|---|---|
| Label | Name (required, user-supplied, e.g. "Monthly Rent — Landlord X") |
| Header | MinistryId, Description, VendorName, CheckNumber, Notes |
| Lines (1..n) | CategoryGroupId, SubCategoryId, Amount, FunctionalClass, Description |
| Audit | CreatedBy + CreatedAt (auto-stamped by AuditSaveChangesInterceptor); the creator display name is resolved at read time, not stored |
Excluded: ExpenseDate (always starts fresh / today), the receipt file, and MemberId
(not used in vendor mode).
Note on CheckNumber: captured to honor "only Expense Date is excluded." Because every
field stays editable on apply (see below), a stale check number is harmless if the user
overwrites it. Captured value is shown editable in the form.
On apply
- Pre-fill all header + line fields from the snapshot.
- All fields remain fully editable — the snapshot is a starting point, not a lock. A varying value (e.g. this month's meal cost) is a one-field tweak.
ExpenseDatestarts at today (fresh), never taken from the snapshot.- The user then saves it as a normal expense via the existing
createpath.
Backend
Entities (API/ROLAC.API/Entities/)
ExpenseSnapshot— header, extendsSoftDeleteEntity(so it getsCreatedBy,CreatedAt,UpdatedBy,UpdatedAt,IsDeletedauto-stamped). Fields:Id,Name,MinistryId,Description,VendorName,CheckNumber,Notes. OwnsLines. The creator's display name is resolved at read time (mirroringReviewedByName), not stored.ExpenseSnapshotLine— mirrorsExpenseLine:Id,SnapshotId(cascade),CategoryGroupId,SubCategoryId,Amount,FunctionalClass,Description. Category FKs useRestrictdelete (same asExpenseLine).
Registered as DbSets in API/ROLAC.API/Data/AppDbContext.cs with a soft-delete query
filter on ExpenseSnapshot. One EF migration adds both tables (PostgreSQL).
DTOs (API/ROLAC.API/DTOs/Expense/)
ExpenseSnapshotDto— list + detail response, includeslines,createdByName, computedtotalAmountandlineCountfor the list view.CreateExpenseSnapshotRequest— same shape as the expense create payload minusexpenseDate/receipt/memberId, plus requiredname. Reused for update.
Controller (API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs)
Route prefix api/expense-snapshots. Authorization reuses the vendor-payment Write
permission check used by ExpensesController.
| Method | Route | Purpose |
|---|---|---|
| GET | / |
List all snapshots (shared), newest first, with createdByName + totals |
| GET | /{id} |
Full snapshot incl. lines (for apply / management edit) |
| POST | / |
Create from form payload (CreatedBy/CreatedAt auto-stamped by the interceptor) |
| PUT | /{id} |
Update (rename and/or re-save fields from the management page) |
| DELETE | /{id} |
Soft-delete |
CreatedBy is read with the ?? "sub" JWT fallback used elsewhere in the project.
Frontend
Service + models (APP/src/app/features/expense/)
services/expense-snapshot-api.service.ts—getAll(),getById(id),create(req),update(id, req),delete(id).models/expense-snapshot.model.ts—ExpenseSnapshotDto,CreateExpenseSnapshotRequest, line interface.
Expense form dialog (vendor mode only)
components/expense-form-dialog/ — gated on mode === 'vendor':
- "Load from snapshot…" picker near the top of the form. Selecting one fetches the
snapshot detail and patches the FormGroup, reusing the existing line-building logic so
category sub-lists populate correctly.
ExpenseDateis left at today. - "Save as snapshot" button. Prompts for the required
Name, then posts the current header + lines viacreate(). Shows a success toast; does not submit the expense.
Management page
A simple page wired into the finance sidebar nav (portals/user-portal,
financeNavItems + getPageTitle, per the unified-header route-data convention):
- List columns: label (
Name), vendor, total amount, created-by, created date. - Actions: rename and delete (row context menu per house convention; single primary action may be inline).
- Mobile-friendly: desktop
hidden md:blockgrid + amd:hiddencard list; layout via Tailwind utilities (nodisplayin component SCSS), per project house rules.
Testing
- Backend: controller/integration tests for create → list → get → update → delete,
asserting
ExpenseDateis never stored andCreatedByNameis stamped. Lines round-trip intact. - Frontend: service unit tests (inline-template components per the test-runner gotcha) covering apply-patches-form-without-date and save-posts-current-state.
Out of scope (YAGNI)
- Per-field "ask every time" configuration (decided: all fields editable instead).
- Snapshot from an already-saved expense (decided: save from the form only).
- Per-user private snapshots (decided: shared with creator tag).
- Reimbursement-mode snapshots.