Files
ROLAC/docs/superpowers/specs/2026-06-25-vendor-payment-snapshot-design.md
T
2026-06-25 14:37:00 -07:00

5.9 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, CreatedByName, CreatedAt (+ soft-delete / auditable fields)

Excluded: ExpenseDate (always starts fresh / today), the receipt file, and MemberId (not used in vendor mode).

Note on CheckNumber: captured to honor "only Expense Date is excluded." Because every field stays editable on apply (see below), a stale check number is harmless if the user overwrites it. Captured value is shown editable in the form.

On apply

  • Pre-fill all header + line fields from the snapshot.
  • All fields remain fully editable — the snapshot is a starting point, not a lock. A varying value (e.g. this month's meal cost) is a one-field tweak.
  • ExpenseDate starts at today (fresh), never taken from the snapshot.
  • The user then saves it as a normal expense via the existing create path.

Backend

Entities (API/ROLAC.API/Entities/)

  • ExpenseSnapshot — header. Fields: Id, Name, MinistryId, Description, VendorName, CheckNumber, Notes, CreatedBy, CreatedByName, plus auditable (CreatedAt, UpdatedAt, UpdatedBy) and soft-delete (IsDeleted). Owns Lines.
  • ExpenseSnapshotLine — mirrors ExpenseLine: Id, SnapshotId (cascade), CategoryGroupId, SubCategoryId, Amount, FunctionalClass, Description. Category FKs use Restrict delete (same as ExpenseLine).

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, includes lines, createdByName, computed totalAmount and lineCount for the list view.
  • CreateExpenseSnapshotRequest — same shape as the expense create payload minus expenseDate/receipt/memberId, plus required name. Reused for update.

Controller (API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs)

Route prefix api/expense-snapshots. Authorization reuses the vendor-payment Write permission check used by ExpensesController.

Method Route Purpose
GET / List all snapshots (shared), newest first, with createdByName + totals
GET /{id} Full snapshot incl. lines (for apply / management edit)
POST / Create from form payload; stamps CreatedBy/CreatedByName from current user
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.tsgetAll(), getById(id), create(req), update(id, req), delete(id).
  • models/expense-snapshot.model.tsExpenseSnapshotDto, CreateExpenseSnapshotRequest, line interface.

Expense form dialog (vendor mode only)

components/expense-form-dialog/ — gated on mode === 'vendor':

  • "Load from snapshot…" picker near the top of the form. Selecting one fetches the snapshot detail and patches the FormGroup, reusing the existing line-building logic so category sub-lists populate correctly. ExpenseDate is left at today.
  • "Save as snapshot" button. Prompts for the required Name, then posts the current header + lines via create(). Shows a success toast; does not submit the expense.

Management page

A simple page wired into the finance sidebar nav (portals/user-portal, financeNavItems + getPageTitle, per the unified-header route-data convention):

  • List columns: label (Name), vendor, total amount, created-by, created date.
  • Actions: rename and delete (row context menu per house convention; single primary action may be inline).
  • Mobile-friendly: desktop hidden md:block grid + a md:hidden card list; layout via Tailwind utilities (no display in component SCSS), per project house rules.

Testing

  • Backend: controller/integration tests for create → list → get → update → delete, asserting ExpenseDate is never stored and CreatedByName is stamped. Lines round-trip intact.
  • Frontend: service unit tests (inline-template components per the test-runner gotcha) covering apply-patches-form-without-date and save-posts-current-state.

Out of scope (YAGNI)

  • Per-field "ask every time" configuration (decided: all fields editable instead).
  • Snapshot from an already-saved expense (decided: save from the form only).
  • Per-user private snapshots (decided: shared with creator tag).
  • Reimbursement-mode snapshots.