Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page

Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:52:54 +02:00
co-authored by Claude Sonnet 5
parent 56f2a9f7fc
commit 0de3f4be7d
42 changed files with 4297 additions and 1586 deletions
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION,
ADD COLUMN "feeChannel" TEXT,
ADD COLUMN "feePayer" TEXT,
ADD COLUMN "feeRate" DOUBLE PRECISION;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost.
- You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "feePayerOnline";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feePayer";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "recordedById" TEXT;
-- CreateIndex
CREATE INDEX "Payment_recordedById_idx" ON "Payment"("recordedById");
-- CreateIndex
CREATE INDEX "Payment_originalPaymentId_idx" ON "Payment"("originalPaymentId");
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "EventCashupPersonCount" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"enteredById" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventCashupPersonCount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventCashupPersonCountDenomination" (
"id" TEXT NOT NULL,
"countId" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"count" INTEGER NOT NULL,
CONSTRAINT "EventCashupPersonCountDenomination_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventCashupPersonCount_eventId_idx" ON "EventCashupPersonCount"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "EventCashupPersonCount_eventId_userId_key" ON "EventCashupPersonCount"("eventId", "userId");
-- CreateIndex
CREATE INDEX "EventCashupPersonCountDenomination_countId_idx" ON "EventCashupPersonCountDenomination"("countId");
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_enteredById_fkey" FOREIGN KEY ("enteredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCountDenomination" ADD CONSTRAINT "EventCashupPersonCountDenomination_countId_fkey" FOREIGN KEY ("countId") REFERENCES "EventCashupPersonCount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+39
View File
@@ -63,6 +63,7 @@ model User {
notificationPreference NotificationPreference @default(email)
registrations Registration[]
payments Payment[]
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
tickets Ticket[]
ticketScans TicketUsage[]
passwordResets PasswordReset[]
@@ -74,6 +75,9 @@ model User {
eventsReopened Event[] @relation("EventReopenedBy")
cashupsPerformed EventCashup[]
notifyForEvents Event[] @relation("EventNotifyRecipients")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
}
model Event {
@@ -112,6 +116,7 @@ model Event {
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
personCashCounts EventCashupPersonCount[]
// Users who should receive registration/payment/daily-summary notifications for this
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
@@ -215,6 +220,7 @@ model Payment {
amount Float
method String
userId String
recordedById String?
registrationId String?
eventId String?
isDonation Boolean @default(false)
@@ -223,6 +229,7 @@ model Payment {
originalPaymentId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
@@ -230,9 +237,11 @@ model Payment {
YocoTransaction YocoTransaction[]
@@index([userId])
@@index([recordedById])
@@index([registrationId])
@@index([eventId])
@@index([createdAt])
@@index([originalPaymentId])
}
model Ticket {
@@ -479,6 +488,36 @@ model EventCashupDenomination {
@@index([lineId])
}
// Actual physical cash counted for one staff member's recorded cash payments, entered any
// time (not part of the close flow) purely for accountability — compared against the
// system-expected amount (computeCashAccountabilityByUser) to show a per-person variance.
model EventCashupPersonCount {
id String @id @default(uuid())
eventId String
userId String
enteredById String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade)
enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull)
denominations EventCashupPersonCountDenomination[]
@@unique([eventId, userId])
@@index([eventId])
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
//
// model Ministry {