Compare commits

...
Author SHA1 Message Date
joshua 1f88ce5e0e Add Yoco processing fee tracking to payments, settings, and reports
Estimates and snapshots the Yoco card-processing fee (always church-borne)
on every card payment, lets the admin configure in-person/online fee % per
plan in Settings -> Payments, and surfaces the fee alongside gross revenue
across the Finance/Profit reports, Master Orders, Revenue Detailed, and the
full cashup reconciliation flow (close-out screen, Cashup report, and audit
trail) so payout figures match what actually lands in the bank.
2026-07-28 11:23:46 +02:00
joshuaandClaude Sonnet 5 1815f78c85 Bump version to 1.3.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:06:08 +02:00
joshuaandClaude Sonnet 5 29036d0612 Merge branch 'feature/door-checkin'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:04:51 +02:00
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00
joshuaandClaude Sonnet 5 adb512a7d9 Bump version to 1.2.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:35:21 +02:00
joshua ad9ead807d Fix wrong auth token key and mislabeled loading dialog on payment flows
registration/success read the token from the wrong localStorage key
(token instead of hope_events_token), which broke registration/form
loading there and made the new "Pay with Yoco" checkout call fail with
"Not authorized, token failed" — switched to the shared auth context
like the rest of the app.

dashboard/user's "Make payment" reused the ticket-email dialog, which
hardcoded "Sending tickets…" while loading — the dialog now takes an
optional loading title/subtitle so payNow can show its own message.
2026-07-27 10:29:42 +02:00
joshua 36b61d968c Simplify self-service payments to full-amount checkout, add manual-page payment tab
Self-service "pay now" flows (registration success + user dashboard) now go
straight to a Yoco checkout for the full outstanding balance instead of
prompting for a partial amount; that page has been removed. Partial-amount
payment links remain supervisor/admin-only via the Payments dashboard.

Also adds a "Record Payment" tab to the supervisor manual registration page,
pre-filled with the most recently created registration, so staff can capture
a payment right after registering someone without leaving the page.
2026-07-27 10:14:49 +02:00
joshuaandClaude Sonnet 5 c710da4dff Bump version to 1.1.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:36:38 +02:00
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00
joshuaandClaude Sonnet 5 193739c042 Apply payment method normalization consistently across user-facing views
Audited every place Payment.method reaches the UI. The main user
dashboard's per-registration payment list (fed by GET
/api/payments/registration/:id) was still showing the raw gateway
string, since that endpoint is shared with the staff-facing supervisor
payments page and wasn't touched by the earlier /mypayments fix.

Extracted the cash/card/eft/voucher/other normalization (matching
normalizeUserMethod on the backend) into frontend/src/lib/paymentMethod.ts
and applied it to both user-facing payment displays. Staff-facing views
(supervisor payments, at-the-door, reports, cashup) intentionally keep
showing the raw method for reconciliation and were left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:41:43 +02:00
joshuaandClaude Sonnet 5 12e5dfc643 Fold apple_pay/google_pay into the card bucket, keep everything else under other
Per feedback: card-network wallet payments (Apple Pay, Google Pay) should
report and filter as "card" on the user payment history page rather than
"other", since they settle the same way as a card payment. Any other
gateway-reported method still falls under "other".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:27:52 +02:00
joshuaandClaude Sonnet 5 325ab87729 Switch payment method filter to a static list with an "other" bucket
Per feedback, drop the dynamic /mypayments/methods lookup (wasn't
loading reliably) in favor of a static cash/card/eft/voucher/other
dropdown. Server-side normalization now folds any gateway-reported
method outside those four manual-entry values (apple_pay, google_pay,
yoco, etc.) into "other" instead of "card", both in the returned data
and in the filter query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:24:53 +02:00
joshuaandClaude Sonnet 5 7f074e43a6 Normalize user-facing payment method to cash/card/eft/voucher
Per feedback, stop trying to surface every gateway-reported wallet
type (apple_pay, google_pay, yoco, ...) as its own filter/display
value on the user payment history page. Both /mypayments and
/mypayments/methods now fold anything outside the four manual-entry
methods into "card", both in the returned data and in the filter
query, so Apple Pay/Google Pay payments show up under Card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:02:59 +02:00
joshuaandClaude Sonnet 5 59194349d2 Populate payment method filter from real data instead of a guessed list
The previous fix hardcoded apple_pay/google_pay as extra filter options,
but Payment.method is free-text set by whatever the gateway reports, so
guessing at literal values was fragile and still didn't surface them for
this user. Add GET /api/payments/mypayments/methods returning the
distinct method values actually present in the user's payments, and have
the dashboard filter build its options from that instead. Also switch
the method filter from a startsWith match to an exact match, since the
values now come straight from the same column being filtered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 08:55:58 +02:00
joshuaandClaude Sonnet 5 90bea25338 Fix payment method handling for non-default types (apple_pay, google_pay)
Yoco webhook payments are tagged with whatever wallet type the gateway
reports, not just cash/card/eft/voucher. Cashup/report totals were
silently dropping those into the "other" bucket instead of "card", and
the new user payment history page couldn't filter by them and showed
raw snake_case values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 08:28:59 +02:00
joshua 37681aec50 Add paginated payment history to user dashboard
Users can now view their own payments (excluding donations) with
server-side pagination (25/page), date range, method, and
payment/refund filters, both on the API and the new client page.
2026-07-23 18:02:53 +02:00
49 changed files with 1374 additions and 511 deletions
+56 -1
View File
@@ -7,6 +7,58 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added
- Yoco processing fee tracking: Settings → Payments lets you pick your Yoco plan (Core/Plus/Pro) to pre-fill estimated in-person and online fee percentages, both freely editable. The church always absorbs this fee — it's never added to what attendees pay.
- Every card payment now snapshots its estimated Yoco fee at the time it's recorded, so later changes to the settings don't retroactively alter historical reports.
- Finance report, Profit report, Master Orders, and Revenue Detailed (including their CSV/PDF/email exports) now show the estimated Yoco fee and a "net after fees" figure alongside gross revenue, so the numbers reflect what actually lands in the bank.
- Cashup reconciliation (the per-event close-out screen, the Cashup report, and the Cashup Audit Trail) now shows three figures for the card method: expected before Yoco fees, the fee itself, and expected after fees — plus overall Yoco fees and net-profit-after-fees totals — so counts reconcile against what actually settles from Yoco rather than the gross card total.
## [1.3.0] - 2026-07-27
### Added
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
### Changed
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
## [1.2.0] - 2026-07-27
### Added
- Supervisor dashboard: `/dashboard/supervisor/manual` now has a "Record Payment" tab (alongside "Register"), for capturing a payment without leaving the page. It pre-fills with the user and registration from the most recently created manual registration, but only switches tabs when a supervisor/admin clicks it themselves.
### Changed
- Self-service payments ("Pay with Yoco" after registering, and "Make payment" on the user dashboard) now go straight to a Yoco checkout for the full remaining outstanding balance, instead of first showing a page to choose a custom/partial amount. Generating a partial-amount payment link remains available only from the supervisor/admin Payments dashboard.
### Removed
- `/dashboard/user/pay` — the self-service partial-payment page — has been removed; it's no longer linked to from anywhere in the app.
### Fixed
- `/registration/success` read the auth token from the wrong `localStorage` key (`token` instead of `hope_events_token`), which silently broke loading the registration/attendee-form data on that page and made the "Pay with Yoco" button fail with "Not authorized, token failed". Now uses the shared auth context, like the rest of the app.
- User dashboard: clicking "Make payment" briefly showed a "Sending tickets…" loading dialog (borrowed from the ticket-email flow) instead of a payment-specific message.
## [1.1.0] - 2026-07-24
### Added
- User dashboard: new "Payment history" page listing the user's own payments (donations excluded), with server-side pagination (25 per page), date range, method, and payment/refund filters.
- User dashboard: registration status (Pending/Confirmed/Partially Paid/Paid/Cancelled) is now shown as a colored badge, matching the existing event Closed/Past/Inactive badge convention, instead of a raw status string.
- Dashboard-wide: inline success/error/confirmation messages (e.g. after creating a manual registration on `/dashboard/supervisor/manual`) now auto-dismiss after 7 seconds instead of persisting indefinitely, via a new shared `useDismissingState` hook. Applied consistently across all dashboard pages with this pattern; excluded are message-only modal dialogs (e.g. ticket-scanning's success/error confirmations, which still require a manual OK) and a couple of mixed validation/async error states shown inside actively-open forms (the registration-edit modal and the event create/edit modal), which continue to persist until the user acts.
### Fixed
- Cashup/reports: payments tagged with a digital wallet method (e.g. `apple_pay`, `google_pay` from online checkouts) are now bucketed as "card" for reconciliation instead of silently falling into "other".
- User dashboard payment history: `GET /api/payments/mypayments` now normalizes `method` to the fixed set cash/card/eft/voucher/other. Card-network wallet payments (`apple_pay`, `google_pay`) are reported and filterable as "card"; any other gateway-reported value falls under "other" — instead of exposing raw, inconsistent gateway strings the filter dropdown didn't know about.
- User dashboard: the registration payment list (shown on the main dashboard when viewing a registration's bill) applies the same cash/card/eft/voucher/other normalization client-side, so it no longer shows a raw `apple_pay`/`google_pay` string. Staff-facing payment views (supervisor payments, reports, cashup) are unaffected — they still show the raw method, which is what reconciliation needs.
## [1.0.1] - 2026-07-23 ## [1.0.1] - 2026-07-23
### Added ### Added
@@ -24,6 +76,9 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend). - Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...main [Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1 [1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0 [1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.0.1", "version": "1.3.0",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -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;
+4
View File
@@ -221,6 +221,9 @@ model Payment {
externalId String? @unique externalId String? @unique
status String? status String?
originalPaymentId String? originalPaymentId String?
feeAmount Float?
feeRate Float?
feeChannel String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict) user User @relation(fields: [userId], references: [id], onDelete: Restrict)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull) registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@ -461,6 +464,7 @@ model EventCashupLine {
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade) cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
method String method String
expectedAmount Float @default(0) expectedAmount Float @default(0)
feeAmount Float?
actualAmount Float? actualAmount Float?
variance Float? variance Float?
notes String? notes String?
+5 -2
View File
@@ -53,7 +53,9 @@ const closeEvent = async (req, res) => {
? lines ? lines
.filter(l => l && ALL_METHODS.includes(l.method)) .filter(l => l && ALL_METHODS.includes(l.method))
.map(l => { .map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0; // expectedInBankByMethod nets out Yoco fees (only ever nonzero for 'card') so the
// variance reconciles against what should actually land in the bank, not the gross figure.
const expected = financials.expectedInBankByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations) const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations ? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 })) .map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
@@ -66,6 +68,7 @@ const closeEvent = async (req, res) => {
id: uuidv4(), id: uuidv4(),
method: l.method, method: l.method,
expectedAmount: expected, expectedAmount: expected,
feeAmount: financials.feesByMethod[l.method] || 0,
actualAmount: actual, actualAmount: actual,
variance: actual !== null ? actual - expected : null, variance: actual !== null ? actual - expected : null,
notes: l.notes || null, notes: l.notes || null,
@@ -77,7 +80,7 @@ const closeEvent = async (req, res) => {
const totalActualRevenue = isFullCashup const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0) ? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
: null; : null;
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0); const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedInBankByMethod[m], 0);
const cashup = await prisma.eventCashup.create({ const cashup = await prisma.eventCashup.create({
data: { data: {
+96 -24
View File
@@ -5,7 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const axios = require('axios'); const axios = require('axios');
const { emailTickets } = require('./ticketController'); const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils'); const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen } = require('../utils/cashupUtils'); const { assertEventOpen, bucketForMethod } = require('../utils/cashupUtils');
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
// @desc Create a new payment // @desc Create a new payment
// @route POST /api/payments // @route POST /api/payments
@@ -58,6 +59,14 @@ const createPayment = async (req, res) => {
throw new Error('Payment method is required'); throw new Error('Payment method is required');
} }
// Snapshot the Yoco fee estimate for this payment's method/amount at creation time —
// never recomputed later, so historical reports stay stable if settings change (see
// priceSnapshot in utils/pricing.js for the same philosophy). In-person is always
// church-borne: this app doesn't control the amount charged on a physical card machine.
const feeConfig = await getYocoFeeConfig();
const feeBucket = bucketForMethod(method);
const feeFor = (amt) => computeFeeSnapshot({ grossAmount: amt, channel: 'in_person', bucket: feeBucket, config: feeConfig });
// Validate required parameters based on isDonation flag // Validate required parameters based on isDonation flag
if (isDonation && !eventId) { if (isDonation && !eventId) {
res.status(400); res.status(400);
@@ -160,7 +169,8 @@ const createPayment = async (req, res) => {
registrationId: null, registrationId: null,
eventId: registration.eventId, eventId: registration.eventId,
isDonation: true, isDonation: true,
createdAt: paidAtDate || undefined createdAt: paidAtDate || undefined,
...feeFor(requestedAmount)
}, },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
@@ -178,7 +188,8 @@ const createPayment = async (req, res) => {
registrationId, registrationId,
eventId: registrationEventId || eventId || null, eventId: registrationEventId || eventId || null,
isDonation: false, isDonation: false,
createdAt: paidAtDate || undefined createdAt: paidAtDate || undefined,
...feeFor(applyAmount)
}, },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
@@ -199,7 +210,8 @@ const createPayment = async (req, res) => {
eventId: registration.eventId, eventId: registration.eventId,
isDonation: true, isDonation: true,
originalPaymentId: payment.id, originalPaymentId: payment.id,
createdAt: paidAtDate || undefined createdAt: paidAtDate || undefined,
...feeFor(excess)
} }
}); });
} }
@@ -215,7 +227,8 @@ const createPayment = async (req, res) => {
registrationId: registrationId || null, registrationId: registrationId || null,
eventId: registrationEventId || eventId || null, eventId: registrationEventId || eventId || null,
isDonation: isDonation || false, isDonation: isDonation || false,
createdAt: paidAtDate || undefined createdAt: paidAtDate || undefined,
...feeFor(parseFloat(amount))
}, },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
@@ -349,30 +362,89 @@ const getPayments = async (req, res) => {
} }
}; };
// @desc Get user payments // Payment.method is free-text — online checkouts get tagged with whatever wallet type the
// gateway reports (apple_pay, google_pay, ...), not just the manual-entry methods below.
// For the user-facing dashboard: card-network wallets count as "card" (same settlement, no
// separate float); anything else unrecognized falls into "other".
const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
const CARD_ALIASES = ['apple_pay', 'google_pay'];
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
function normalizeUserMethod(method) {
const m = String(method || '').toLowerCase();
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return 'card';
return 'other';
}
// @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters)
// @route GET /api/payments/mypayments // @route GET /api/payments/mypayments
// @access Private // @access Private
const getUserPayments = async (req, res) => { const getUserPayments = async (req, res) => {
try { try {
// Users should see payments they made (donations) and payments tied to their registrations const page = Math.max(1, parseInt(req.query.page) || 1);
const payments = await prisma.payment.findMany({ const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25));
where: { const skip = (page - 1) * limit;
OR: [
{ userId: req.user.id }, // payments made by the user (includes donations)
{ registration: { userId: req.user.id } } // payments tied to the user's registrations
]
},
include: {
registration: {
include: {
event: true
}
},
event: true
}
});
res.json(payments); // Users should see payments they made and payments tied to their registrations,
// excluding donations.
const where = {
isDonation: false,
OR: [
{ userId: req.user.id },
{ registration: { userId: req.user.id } }
]
};
if (req.query.method) {
const requested = String(req.query.method).toLowerCase();
if (requested === 'card') {
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
// settlement as a card payment, no separate float to reconcile.
where.AND = [{
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
} else if (requested === 'other') {
// "Other" covers every method that isn't one of the recognized buckets above.
where.NOT = {
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else if (USER_FACING_METHODS.includes(requested)) {
where.method = { equals: requested, mode: 'insensitive' };
}
}
if (req.query.kind === 'refund') {
where.amount = { lt: 0 };
} else if (req.query.kind === 'payment') {
where.amount = { gte: 0 };
}
if (req.query.startDate || req.query.endDate) {
where.createdAt = {
...(req.query.startDate && { gte: new Date(req.query.startDate) }),
...(req.query.endDate && { lte: new Date(req.query.endDate) })
};
}
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({
where,
include: {
registration: { include: { event: true } },
event: true
},
orderBy: { createdAt: 'desc' },
skip,
take: limit
}),
prisma.payment.count({ where })
]);
// Normalize the displayed method so the dashboard never shows a raw gateway string.
const normalizedPayments = payments.map(p => ({ ...p, method: normalizeUserMethod(p.method) }));
res.json({ data: normalizedPayments, total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) { } catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
} }
+10 -1
View File
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
e.title AS "eventTitle", e.title AS "eventTitle",
eo.id AS "eventOptionId", eo.id AS "eventOptionId",
eo.name AS "eventOptionName", eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE( COALESCE(
json_agg( json_agg(
json_build_object( json_build_object(
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
isUsed: r.isUsed, isUsed: r.isUsed,
eventId: r.eventId, eventId: r.eventId,
event: { title: r.eventTitle }, event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } }, registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || [] usages: r.usages || []
}; };
@@ -398,6 +399,14 @@ const scanTicket = async (req, res) => {
}) })
]); ]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
res.json({ res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`, message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
ticketUsage, ticketUsage,
+11 -2
View File
@@ -4,6 +4,8 @@ const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils'); const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController'); const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing'); const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { bucketForMethod } = require('../utils/cashupUtils');
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
// @desc Handle Yoco webhook events // @desc Handle Yoco webhook events
// @route POST /api/webhooks/yoco // @route POST /api/webhooks/yoco
@@ -313,18 +315,25 @@ const handlePaymentSucceeded = async (webhookData) => {
} catch (e) { /* ignore — payment will fail below if still null */ } } catch (e) { /* ignore — payment will fail below if still null */ }
} }
// Snapshot the Yoco fee for this payment at creation time, using current settings — never
// recomputed later, so historical reports stay stable if settings change.
const grossAmount = amount / 100;
const feeConfig = await getYocoFeeConfig();
const feeSnapshot = computeFeeSnapshot({ grossAmount, channel: 'online', bucket: bucketForMethod(method.type), config: feeConfig });
// Create payment record // Create payment record
const payment = await prisma.payment.create({ const payment = await prisma.payment.create({
data: { data: {
id: uuidv4(), // Generate a UUID for the payment id: uuidv4(), // Generate a UUID for the payment
amount: amount / 100, // Convert cents to your currency unit amount: grossAmount, // Convert cents to your currency unit
method: method.type, // The type of payment from the webhook method: method.type, // The type of payment from the webhook
status: status === 'succeeded' ? 'completed' : status, status: status === 'succeeded' ? 'completed' : status,
externalId: yocoPaymentId, externalId: yocoPaymentId,
registrationId: registration?.id || null, registrationId: registration?.id || null,
userId: resolvedUserId, userId: resolvedUserId,
eventId: registration?.eventId || metadata?.eventId || null, eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id isDonation: !registration?.id,
...feeSnapshot
}, },
include: { include: {
user: { user: {
+3 -2
View File
@@ -543,8 +543,9 @@ app.get('/docs', async (req, res) => {
responses:[ responses:[
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }}, { status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }},
]}, ]},
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history', { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]}, queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', { method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' }, queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]}, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
+26 -2
View File
@@ -11,11 +11,13 @@ function emptyByMethod(fill = 0) {
} }
// Normalizes a free-text Payment.method into one of the fixed cashup buckets. // Normalizes a free-text Payment.method into one of the fixed cashup buckets.
// Card-network wallets (Apple Pay, Google Pay, etc.) settle exactly like a card payment —
// no separate float to reconcile — so they belong in the 'card' bucket, not 'other'.
function bucketForMethod(method) { function bucketForMethod(method) {
const m = String(method || '').toLowerCase(); const m = String(method || '').toLowerCase();
if (m.includes('cash')) return 'cash'; if (m.includes('cash')) return 'cash';
if (m.includes('eft')) return 'eft'; if (m.includes('eft')) return 'eft';
if (m.includes('card') || m.includes('yoco')) return 'card'; if (m.includes('card') || m.includes('yoco') || m.includes('pay')) return 'card';
return 'other'; return 'other';
} }
@@ -102,6 +104,15 @@ async function computeEventFinancials(eventId) {
} }
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0); const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
// Yoco processing fees, snapshotted per-payment at creation time (see utils/yocoFees.js).
// Always absorbed by the church — never passed on to the buyer.
const feesByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
if (p.feeAmount == null) continue;
feesByMethod[bucketForMethod(p.method)] += p.feeAmount;
}
const totalFees = ALL_METHODS.reduce((s, m) => s + feesByMethod[m], 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged) // Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => { const costBreakdown = costs.map(c => {
const total = c.costType === 'per_item' const total = c.costType === 'per_item'
@@ -124,6 +135,13 @@ async function computeEventFinancials(eventId) {
const expectedCashByMethod = emptyByMethod(); const expectedCashByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m]; for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
// What should actually land in the bank per method — same as expectedCashByMethod, minus
// Yoco's cut (only ever nonzero for 'card'). This is a reconciliation-only figure: it must
// NOT feed effectiveGrossIncomeByMethod/netProfit below, since totalFees is already subtracted
// there once via netProfitAfterFees — folding it into expectedCashByMethod too would double-count.
const expectedInBankByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedInBankByMethod[m] = expectedCashByMethod[m] - feesByMethod[m];
// Most recent reconciliation on record (if any) — the source of "actual" truth // Most recent reconciliation on record (if any) — the source of "actual" truth
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null; const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
const reconciled = latestReconciled ? { const reconciled = latestReconciled ? {
@@ -137,7 +155,8 @@ async function computeEventFinancials(eventId) {
for (const m of ALL_METHODS) { for (const m of ALL_METHODS) {
const line = latestReconciled.lines.find(l => l.method === m); const line = latestReconciled.lines.find(l => l.method === m);
out[m] = { out[m] = {
expected: line ? line.expectedAmount : expectedCashByMethod[m], expected: line ? line.expectedAmount : expectedInBankByMethod[m],
fee: line && line.feeAmount != null ? line.feeAmount : feesByMethod[m],
actual: line && line.actualAmount != null ? line.actualAmount : null, actual: line && line.actualAmount != null ? line.actualAmount : null,
variance: line && line.variance != null ? line.variance : null, variance: line && line.variance != null ? line.variance : null,
notes: line ? line.notes : null, notes: line ? line.notes : null,
@@ -159,6 +178,7 @@ async function computeEventFinancials(eventId) {
} }
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0); const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts; const netProfit = effectiveTotalRevenue - totalCosts;
const netProfitAfterFees = netProfit - totalFees;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown // What was actually sold, by ticket type — for the Finance report's income-stream breakdown
const salesByOptionMap = {}; const salesByOptionMap = {};
@@ -181,10 +201,14 @@ async function computeEventFinancials(eventId) {
untaggedCostsTotal, untaggedCostsTotal,
totalCosts, totalCosts,
expectedCashByMethod, expectedCashByMethod,
expectedInBankByMethod,
reconciled, reconciled,
effectiveGrossIncomeByMethod, effectiveGrossIncomeByMethod,
effectiveTotalRevenue, effectiveTotalRevenue,
netProfit, netProfit,
feesByMethod,
totalFees,
netProfitAfterFees,
unallocatedDonations, unallocatedDonations,
unallocatedDonationsTotal, unallocatedDonationsTotal,
totalDonations, totalDonations,
+66
View File
@@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) {
}); });
} }
async function loadTicketFull(ticketId) {
return prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true } },
},
});
}
async function loadPaymentFull(paymentId) { async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({ return prisma.payment.findUnique({
where: { id: paymentId }, where: { id: paymentId },
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
return { subject, text, html: emailWrapper(body, { preheader }) }; return { subject, text, html: emailWrapper(body, { preheader }) };
} }
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
const fullyCheckedIn = remaining <= 0;
const subject = `Checked in ${eventTitle}`;
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! </p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
}`, fullyCheckedIn ? 'success' : 'info')}`;
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ──────────────────────────────────────── // ─── Donation applied to a registration ────────────────────────────────────────
// //
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply // Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
@@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) {
} }
} }
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticket.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail && shouldEmail(user)) {
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
if (hasValidEmail) {
sends.push(waText(user, waMsg));
} else {
sends.push(waTextAny(user, waMsg));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send check-in emails:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is // Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received // notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed // notification when the donation was originally made, so they are deliberately not emailed
@@ -981,4 +1045,6 @@ module.exports = {
sendRefundEmail, sendRefundEmail,
sendSelfServiceRegistrationEmails, sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails, sendDonationAssignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
}; };
+21
View File
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
].join('\n'); ].join('\n');
} }
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const name = ticket.user?.name || 'there';
const fullyCheckedIn = remaining <= 0;
return [
`✅ *Checked In*`,
'',
`Hi ${name},`,
'',
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ─────────────────────────────────────────────────────── // ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) { function buildWALogin({ name, when, location, userAgent }) {
@@ -255,4 +275,5 @@ module.exports = {
buildWAWelcome, buildWAWelcome,
buildWAAccountClosed, buildWAAccountClosed,
buildWATicketCaption, buildWATicketCaption,
buildWACheckIn,
}; };
+35
View File
@@ -0,0 +1,35 @@
/**
* Yoco processing fee estimation.
*
* Yoco's real rate depends on plan, channel (in-person card machine vs. online checkout),
* monthly volume tier, and card type none of which this app tracks. Instead the admin
* configures one flat, editable % per channel (Settings Payments), pre-filled from a
* representative default when they pick a plan. The church always absorbs this fee it's
* shown in reports so payout figures are accurate, never passed on to the buyer.
*/
const { getSetting } = require('./settingsCache');
async function getYocoFeeConfig() {
const [inPersonPct, onlinePct] = await Promise.all([
getSetting('yoco_fee_in_person_pct', '0'),
getSetting('yoco_fee_online_pct', '0'),
]);
return {
inPersonRate: parseFloat(inPersonPct) || 0, // percent, e.g. 2.30
onlineRate: parseFloat(onlinePct) || 0,
};
}
// Snapshot to store on a Payment row at creation time. Only 'card' payments are fee-eligible —
// cash/eft/voucher never incur a Yoco fee.
function computeFeeSnapshot({ grossAmount, channel, bucket, config }) {
if (bucket !== 'card') {
return { feeAmount: null, feeRate: null, feeChannel: null };
}
const rate = channel === 'online' ? config.onlineRate : config.inPersonRate;
const feeAmount = Math.round(grossAmount * (rate / 100) * 100) / 100;
return { feeAmount, feeRate: rate, feeChannel: channel };
}
module.exports = { getYocoFeeConfig, computeFeeSnapshot };
+1 -2
View File
@@ -164,7 +164,6 @@ frontend/src/
|-------|-------------| |-------|-------------|
| `/dashboard/user` | My registrations + tickets overview | | `/dashboard/user` | My registrations + tickets overview |
| `/dashboard/user/profile` | Edit profile, notification preferences | | `/dashboard/user/profile` | Edit profile, notification preferences |
| `/dashboard/user/pay` | Pay outstanding balance |
| `/dashboard/user/donate` | Make a donation | | `/dashboard/user/donate` | Make a donation |
| `/dashboard/user/forms` | Complete registration forms | | `/dashboard/user/forms` | Complete registration forms |
| `/dashboard/user/reset-password` | Change password (authenticated) | | `/dashboard/user/reset-password` | Change password (authenticated) |
@@ -255,7 +254,7 @@ A "Select Email/both" or "Select WhatsApp/both" quick-select button is available
5. Backend reconciles payment, updates registration status, generates and emails tickets 5. Backend reconciles payment, updates registration status, generates and emails tickets
6. User sees `/payment/success` and tickets appear in their dashboard 6. User sees `/payment/success` and tickets appear in their dashboard
Outstanding balances can be paid at any time from `/dashboard/user/pay`. Outstanding balances can be paid at any time from the "Make payment" button on `/dashboard/user` — this always creates a checkout for the full remaining balance. Partial-amount payment links can only be generated by a supervisor/admin from `/dashboard/supervisor/payments`.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.0.1", "version": "1.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types"; import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
@@ -29,7 +30,7 @@ export default function EventCashupPage() {
const [data, setData] = useState<EventFinancials | null>(null); const [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]); const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const isClosed = data?.event?.cashupStatus === "closed"; const isClosed = data?.event?.cashupStatus === "closed";
@@ -371,7 +372,9 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<th className="py-1">Method</th> <th className="py-1">Method</th>
<th className="py-1 text-right">Income</th> <th className="py-1 text-right">Income</th>
<th className="py-1 text-right">Costs from method</th> <th className="py-1 text-right">Costs from method</th>
<th className="py-1 text-right">Expected cash</th> <th className="py-1 text-right">Expected (before fees)</th>
<th className="py-1 text-right">Yoco fee</th>
<th className="py-1 text-right">Expected (after fees)</th>
<th className="py-1 text-right">Actual</th> <th className="py-1 text-right">Actual</th>
<th className="py-1 text-right">Variance</th> <th className="py-1 text-right">Variance</th>
<th className="py-1">Notes</th> <th className="py-1">Notes</th>
@@ -380,12 +383,15 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<tbody> <tbody>
{METHODS.map(m => { {METHODS.map(m => {
const r = reconciled?.byMethod?.[m]; const r = reconciled?.byMethod?.[m];
const expected = data.expectedInBankByMethod[m];
return ( return (
<tr key={m} className="border-b last:border-0 align-top"> <tr key={m} className="border-b last:border-0 align-top">
<td className="py-1.5">{METHOD_LABELS[m]}</td> <td className="py-1.5">{METHOD_LABELS[m]}</td>
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td> <td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td> <td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td> <td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.feesByMethod[m])}</td>
<td className="py-1.5 text-right">{money(expected)}</td>
<td className="py-1.5 text-right"> <td className="py-1.5 text-right">
{isClosed ? ( {isClosed ? (
money(r?.actual ?? null) money(r?.actual ?? null)
@@ -399,8 +405,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed {isClosed
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>) ? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
: (m === "cash" : (m === "cash"
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—") ? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - expected) : "—")
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))} : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - expected) : "—"))}
</td> </td>
<td className="py-1.5"> <td className="py-1.5">
{isClosed ? (r?.notes || "—") : ( {isClosed ? (r?.notes || "—") : (
@@ -446,7 +452,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
)} )}
</div> </div>
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm"> <div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-4 gap-3 text-sm">
<div> <div>
<div className="text-gray-500 text-xs">Donations counted as profit</div> <div className="text-gray-500 text-xs">Donations counted as profit</div>
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div> <div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
@@ -456,8 +462,12 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<div className="font-semibold">{money(data.totalCosts)}</div> <div className="font-semibold">{money(data.totalCosts)}</div>
</div> </div>
<div> <div>
<div className="text-gray-500 text-xs">Net profit</div> <div className="text-gray-500 text-xs">Yoco fees</div>
<div className="font-semibold">{money(data.netProfit)}</div> <div className="font-semibold">{money(data.totalFees)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Net profit (after fees)</div>
<div className="font-semibold">{money(data.netProfitAfterFees)}</div>
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function CashupLandingPage() { export default function CashupLandingPage() {
const { token } = useAuth(); const { token } = useAuth();
@@ -11,7 +12,7 @@ export default function CashupLandingPage() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showPast, setShowPast] = useState(true); const [showPast, setShowPast] = useState(true);
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const; const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -29,8 +30,8 @@ export default function AdminRegistrationsPage() {
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false); const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Filters // Filters
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -5,8 +5,9 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal"; type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "payments";
const TABS: { id: TabId; label: string }[] = [ const TABS: { id: TabId; label: string }[] = [
{ id: "organisation", label: "Organisation" }, { id: "organisation", label: "Organisation" },
@@ -14,8 +15,19 @@ const TABS: { id: TabId; label: string }[] = [
{ id: "notifications", label: "Notifications" }, { id: "notifications", label: "Notifications" },
{ id: "email", label: "Email" }, { id: "email", label: "Email" },
{ id: "legal", label: "Legal" }, { id: "legal", label: "Legal" },
{ id: "payments", label: "Payments" },
]; ];
// Default fee % pre-fill per Yoco plan — the "Up to R50k / Debit" rate, since debit is the
// most common consumer card type in SA and small churches likely stay in the lowest volume
// tier. Purely a UI convenience for the plan <select>; the actual % fields stay freely
// editable afterward so the admin can match their real blended rate.
const YOCO_PLAN_DEFAULTS: Record<"core" | "plus" | "pro", { inPerson: string; online: string }> = {
core: { inPerson: "2.30", online: "2.95" },
plus: { inPerson: "2.10", online: "2.75" },
pro: { inPerson: "1.95", online: "2.55" },
};
const inputCls = const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"; "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
@@ -36,19 +48,17 @@ function Field({
} }
function SaveBar({ function SaveBar({
saving, onSave, result, onDismiss, saving, onSave, result,
}: { }: {
saving: boolean; saving: boolean;
onSave: () => void; onSave: () => void;
result: { ok: boolean; message: string } | null; result: { ok: boolean; message: string } | null;
onDismiss: () => void;
}) { }) {
return ( return (
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3"> <div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
{result ? ( {result ? (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}> <span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message} {result.ok ? "✓" : "✗"} {result.message}
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
</span> </span>
) : ( ) : (
<span /> <span />
@@ -75,7 +85,7 @@ export default function SiteSettingsPage() {
// Per-tab save state // Per-tab save state
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); const [result, setResult] = useDismissingState<{ ok: boolean; message: string } | null>(null);
// ── Organisation ── // ── Organisation ──
const [orgName, setOrgName] = useState(""); const [orgName, setOrgName] = useState("");
@@ -104,7 +114,7 @@ export default function SiteSettingsPage() {
const [smtpPass, setSmtpPass] = useState(""); const [smtpPass, setSmtpPass] = useState("");
const [smtpPassSet, setSmtpPassSet] = useState(false); const [smtpPassSet, setSmtpPassSet] = useState(false);
const [smtpTesting, setSmtpTesting] = useState(false); const [smtpTesting, setSmtpTesting] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string; raw?: string } | null>(null); const [smtpTestResult, setSmtpTestResult] = useDismissingState<{ ok: boolean; message: string; raw?: string } | null>(null);
// ── Legal ── // ── Legal ──
const [legalOperatorName, setLegalOperatorName] = useState(""); const [legalOperatorName, setLegalOperatorName] = useState("");
@@ -113,6 +123,11 @@ export default function SiteSettingsPage() {
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState(""); const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState(""); const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
// ── Payments (Yoco fees) ──
const [yocoPlan, setYocoPlan] = useState<"core" | "plus" | "pro">("core");
const [feeInPersonPct, setFeeInPersonPct] = useState("");
const [feeOnlinePct, setFeeOnlinePct] = useState("");
// ── Load all settings once ──────────────────────────────────────────────── // ── Load all settings once ────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
@@ -139,6 +154,9 @@ export default function SiteSettingsPage() {
setLegalIoEmail(s.legal_io_email || ""); setLegalIoEmail(s.legal_io_email || "");
setLegalWebsiteUrl(s.legal_website_url || ""); setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || ""); setLegalEffectiveDate(s.legal_effective_date || "");
setYocoPlan((s.yoco_plan as "core" | "plus" | "pro") || "core");
setFeeInPersonPct(s.yoco_fee_in_person_pct || "");
setFeeOnlinePct(s.yoco_fee_online_pct || "");
}) })
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" })) .catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false)); .finally(() => setLoadingInitial(false));
@@ -240,6 +258,19 @@ export default function SiteSettingsPage() {
legal_effective_date: legalEffectiveDate.trim(), legal_effective_date: legalEffectiveDate.trim(),
}); });
const handleYocoPlanChange = (plan: "core" | "plus" | "pro") => {
setYocoPlan(plan);
const defaults = YOCO_PLAN_DEFAULTS[plan];
setFeeInPersonPct(defaults.inPerson);
setFeeOnlinePct(defaults.online);
};
const savePayments = () => save({
yoco_plan: yocoPlan,
yoco_fee_in_person_pct: feeInPersonPct.trim(),
yoco_fee_online_pct: feeOnlinePct.trim(),
});
const handleTestSmtp = async () => { const handleTestSmtp = async () => {
if (!token) return; if (!token) return;
setSmtpTesting(true); setSmtpTesting(true);
@@ -332,7 +363,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="https://events.yourchurch.org" <input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} /> value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field> </Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div> </div>
)} )}
@@ -371,7 +402,7 @@ export default function SiteSettingsPage() {
}} className="text-sm" /> }} className="text-sm" />
</Field> </Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveBranding} result={result} />
</div> </div>
)} )}
@@ -385,7 +416,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org" <input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} /> value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
</Field> </Field>
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveNotifications} result={result} />
</div> </div>
)} )}
@@ -464,7 +495,7 @@ export default function SiteSettingsPage() {
)} )}
</div> </div>
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveSmtp} result={result} />
</div> </div>
)} )}
@@ -503,7 +534,45 @@ export default function SiteSettingsPage() {
</div> </div>
</div> </div>
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
{/* ── Payments (Yoco fees) ─────────────────────────────────────────────── */}
{activeTab === "payments" && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Yoco deducts a processing fee from every card transaction before it reaches your bank account.
Reports use the estimate below to show a more accurate payout. Yoco&apos;s real rate varies by
monthly volume and card type, which this app doesn&apos;t track pick your plan to pre-fill a
starting value, then adjust the percentages to match your actual bank statement.
</p>
<Field label="Yoco plan">
<select
className={inputCls}
value={yocoPlan}
onChange={e => handleYocoPlanChange(e.target.value as "core" | "plus" | "pro")}
>
<option value="core">Core</option>
<option value="plus">Plus</option>
<option value="pro">Pro</option>
</select>
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="In-person (card machine) fee %">
<input type="number" step="0.01" min="0" max="100" className={inputCls} placeholder="2.30"
value={feeInPersonPct} onChange={e => setFeeInPersonPct(e.target.value)} />
</Field>
<Field label="Online (checkout link) fee %">
<input type="number" step="0.01" min="0" max="100" className={inputCls} placeholder="2.95"
value={feeOnlinePct} onChange={e => setFeeOnlinePct(e.target.value)} />
</Field>
</div>
<p className="text-xs text-gray-400">These fees are always absorbed by the church they're shown in reports for an accurate payout, never added to what attendees pay.</p>
<SaveBar saving={saving} onSave={savePayments} result={result} />
</div> </div>
)} )}
</div> </div>
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
interface UserItem { interface UserItem {
id: string; id: string;
@@ -43,7 +44,7 @@ export default function AdminUsersPage() {
// Data state // Data state
const [users, setUsers] = useState<UserItem[]>([]); const [users, setUsers] = useState<UserItem[]>([]);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -123,7 +124,7 @@ export default function WhatsAppAdminPage() {
const [pairingPhone, setPairingPhone] = useState(""); const [pairingPhone, setPairingPhone] = useState("");
// ── Shared action feedback ─────────────────────────────────────────────────── // ── Shared action feedback ───────────────────────────────────────────────────
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
// ── Load config ────────────────────────────────────────────────────────────── // ── Load config ──────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react"; import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage"; import { QrImage } from "@/components/shared/QrImage";
@@ -18,7 +19,7 @@ function EventTicketsContent() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId); const [eventId, setEventId] = useState<string>(paramEventId);
const [tickets, setTickets] = useState<any[]>([]); const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId); const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
@@ -4,14 +4,15 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner"; import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function TicketScanningPage() { export default function TicketScanningPage() {
const router = useRouter(); const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null); const scannerRef = useRef<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null); const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useState<string | null>(null); const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null); const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null); const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth(); const { token, user } = useAuth();
@@ -5,8 +5,17 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "tickets" | "refund"; type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
const MODE_LABELS: Record<Mode, string> = {
registration: "REGISTRATION",
payment: "PAYMENT",
checkin: "CHECK-IN",
tickets: "TICKETS",
refund: "REFUND",
};
// ─── Fuzzy search helpers ───────────────────────────────────────────────────── // ─── Fuzzy search helpers ─────────────────────────────────────────────────────
@@ -154,34 +163,11 @@ export default function AtTheDoorPage() {
const [mode, setMode] = useState<Mode>("registration"); const [mode, setMode] = useState<Mode>("registration");
const [activeRegistration, setActiveRegistration] = useState<any | null>(null); const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
useEffect(() => {
if (!info) return;
const id = setTimeout(() => {
setInfo(null);
}, 10000); // ⏱ disappears after 5s
return () => clearTimeout(id);
}, [info]);
useEffect(() => {
if (!error) return;
const id = setTimeout(() => {
setError(null);
}, 15000); // errors linger slightly longer
return () => clearTimeout(id);
}, [error]);
const handleRegistrationCreated = (registration: any) => { const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration); setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
setMode("payment"); // 🚀 Jump automatically
}; };
const handleRegistrationSelected = (registration: any) => { const handleRegistrationSelected = (registration: any) => {
@@ -192,7 +178,9 @@ export default function AtTheDoorPage() {
if (!activeRegistration) return; if (!activeRegistration) return;
const id = setTimeout(() => { const id = setTimeout(() => {
setMode("payment"); // Fully paid → head straight to check-in; otherwise there's still a
// balance to collect, so go capture payment first.
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
}, 0); }, 0);
return () => clearTimeout(id); return () => clearTimeout(id);
@@ -338,28 +326,12 @@ export default function AtTheDoorPage() {
const reg = registration || activeRegistration; const reg = registration || activeRegistration;
try { // Tickets are generated and emailed/WhatsApped automatically server-side
const res = await apiFetch<any>("/api/tickets/generate", { // once a registration reaches "paid" (see paymentController) — no need to
method: "POST", // fetch/print them here, just move straight to checking the attendee in.
authToken: token, setActiveRegistration(reg);
body: { setInfo("Payment recorded — tickets sent. Ready to check in.");
registrationId: reg.id setMode("checkin");
}
});
const tickets = res?.tickets || [];
if (tickets.length) {
printTickets(tickets);
}
setInfo("Payment recorded & tickets printed");
setActiveRegistration(null);
setMode("registration");
} catch (e: any) {
setError(e?.message || "Tickets failed to generate");
}
}; };
const handleDonation = (user?: any) => { const handleDonation = (user?: any) => {
@@ -367,97 +339,6 @@ export default function AtTheDoorPage() {
setShowDonationModal(true); setShowDonationModal(true);
}; };
const buildTicketHtmlCard = (t: any) => {
const eventTitle =
t.event?.title ||
t.registrationOption?.registration?.event?.title ||
"Event";
const ticketType = ticketLabel(t);
const qty = t.quantity || 1;
const holder =
t.user?.name ||
t.registrationOption?.registration?.user?.name ||
"";
const qrValue = t.qrCode || t.id;
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
return `<div class="ticket">
<div class="top">
<div class="evt">${eventTitle}</div>
<div class="type">${ticketType}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${t.id}</div>
</div>`;
};
const openPrintWindow = (cardsHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><style>
@page { size: A4; margin: 8mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
.page {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 194mm;
height: 281mm;
gap: 4mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket {
border: 1.5px solid #222;
border-radius: 3mm;
padding: 5mm;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
min-height: 0;
}
.top { flex: 0 0 auto; }
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1 1 auto; padding: 3mm 0; }
.qrwrap img { width: 48mm; height: 48mm; display: block; }
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
</style></head><body>${cardsHtml}<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script></body></html>`);
w.document.close();
};
const printTickets = (tickets: any[]) => {
if (!tickets?.length) return;
// 4 per A4 page (2 columns × 2 rows)
const PAGE_SIZE = 4;
const pages: string[] = [];
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
const chunk = tickets.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
return ( return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6"> <div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
@@ -503,7 +384,7 @@ export default function AtTheDoorPage() {
{/* Mode Buttons */} {/* Mode Buttons */}
<div className="flex gap-2 mb-4 flex-wrap"> <div className="flex gap-2 mb-4 flex-wrap">
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => ( {(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
<button <button
key={m} key={m}
onClick={() => setMode(m)} onClick={() => setMode(m)}
@@ -515,7 +396,7 @@ export default function AtTheDoorPage() {
: "bg-white hover:bg-gray-50" : "bg-white hover:bg-gray-50"
}`} }`}
> >
{m.toUpperCase()} {MODE_LABELS[m]}
</button> </button>
))} ))}
</div> </div>
@@ -543,6 +424,10 @@ export default function AtTheDoorPage() {
/> />
)} )}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
)}
{mode === "tickets" && ( {mode === "tickets" && (
<DoorTicketsPanel token={token} eventId={eventId} /> <DoorTicketsPanel token={token} eventId={eventId} />
)} )}
@@ -1102,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
); );
} }
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
setTicketsForEvent([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [token, eventId]);
if (!registration) {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
No registration selected
</div>
);
}
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
const setQtyFor = (t: any, n: number) =>
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
const commit = async (ticket: any) => {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<any>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
res?.remaining > 0 ? `${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e: any) {
setError(e?.message || "Check-in failed");
} finally {
setSubmittingId(null);
}
};
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
{/* ✅ User */}
<div className="mb-4">
<div className="text-lg font-semibold">
{registration.user?.name || "Guest"}
</div>
</div>
{loading && (
<div className="text-sm text-gray-500">Loading</div>
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
)}
<div className="space-y-4">
{myTickets.map(t => {
const qty = qtyFor(t);
return (
<div key={t.id} className="border rounded-lg p-4">
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="text-lg font-semibold">{t.quantity || 1}</div>
</div>
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">CHECKED IN</div>
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
</div>
<div className="border rounded-lg p-3 text-center bg-emerald-50">
<div className="text-xs text-gray-500">REMAINING</div>
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
</div>
</div>
{t.remaining > 0 ? (
<div className="flex items-center gap-3">
<button
onClick={() => setQtyFor(t, qty - 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
>
</button>
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
<button
onClick={() => setQtyFor(t, qty + 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
>
+
</button>
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
</div>
) : (
<div className="text-sm text-emerald-600 font-medium">Fully checked in </div>
)}
</div>
);
})}
</div>
</div>
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) { function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
if (!open) return null; if (!open) return null;
@@ -1332,7 +1359,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useDismissingState("");
const [localInfo, setLocalInfo] = useState(""); const [localInfo, setLocalInfo] = useState("");
useEffect(() => { useEffect(() => {
@@ -1369,7 +1396,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
}, },
}); });
setLocalInfo("Tickets sent successfully."); setLocalInfo("Tickets sent successfully.");
setTimeout(() => { setLocalInfo(""); onClose(); }, 2000); setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
} catch (e: any) { } catch (e: any) {
setLocalError(e?.message || "Failed to send tickets"); setLocalError(e?.message || "Failed to send tickets");
} finally { } finally {
@@ -1461,7 +1488,7 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
const [method, setMethod] = useState("cash"); const [method, setMethod] = useState("cash");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useDismissingState("");
useEffect(() => { useEffect(() => {
if (open) { setAmount(""); setReason(""); setLocalError(""); } if (open) { setAmount(""); setReason(""); setLocalError(""); }
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type Attendee = { id: string; name: string; email: string; pref: string }; type Attendee = { id: string; name: string; email: string; pref: string };
@@ -171,8 +172,8 @@ function EmailAttendeesPageInner() {
// Load events for selection // Load events for selection
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Tabs: attendees (current), automations (coming soon), broadcasts // Tabs: attendees (current), automations (coming soon), broadcasts
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees'); const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local"> // Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm // This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -112,8 +113,8 @@ function EventOptionsContent() {
const [selectedEventId, setSelectedEventId] = useState<string>(""); const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]); const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false); const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── helpers ──────────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────────
@@ -690,7 +691,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []); const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []);
const [loading, setLoading] = useState(!hasInitial); const [loading, setLoading] = useState(!hasInitial);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
useEffect(() => { useEffect(() => {
if (hasInitial) return; // already have the data — skip the network round trip entirely if (hasInitial) return; // already have the data — skip the network round trip entirely
@@ -1415,7 +1416,7 @@ export default function ManageEventsPage() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph'; type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
@@ -144,8 +145,8 @@ export default function FormsBrowserPage() {
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -18,7 +19,7 @@ export default function ManualRegistrationPage() {
const [phoneNumber, setPhoneNumber] = useState(""); const [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = useState(false); const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null); const [createdReg, setCreatedReg] = useState<any | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({}); const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
@@ -110,8 +111,8 @@ export default function ManualRegistrationPage() {
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) { function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
const { token } = useAuth(); const { token } = useAuth();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0); const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets); const count = Math.max(0, mainTickets);
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Pricing helpers ───────────────────────────────────────────────────────── // ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -38,6 +39,18 @@ function fmtPrice(n: number) {
return n === 0 ? "Free" : `R ${n.toFixed(2)}`; return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
} }
// Effective unit price for an existing registration option (as returned by /api/registrations).
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
// back to a live early-bird calculation for legacy rows without a snapshot.
function optionUnitPrice(opt: any): number {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
return effectiveOptionUnit(eo);
}
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
export default function ManualRegistrationPage() { export default function ManualRegistrationPage() {
@@ -72,8 +85,25 @@ export default function ManualRegistrationPage() {
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
// ── Tabs ──────────────────────────────────────────────────────────────────
const [tab, setTab] = useState<"register" | "payment">("register");
// ── Record Payment tab state ─────────────────────────────────────────────
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
const [payUserId, setPayUserId] = useState<string>("");
const [payUserQuery, setPayUserQuery] = useState("");
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
const paySearchRef = useRef<HTMLDivElement>(null);
const [payRegistrationId, setPayRegistrationId] = useState<string>("");
const [payAmount, setPayAmount] = useState<string>("");
const [payMethod, setPayMethod] = useState<string>("cash");
const [payPaidAtLocal, setPayPaidAtLocal] = useState<string>("");
const [paySubmitting, setPaySubmitting] = useState(false);
const [payMessage, setPayMessage] = useDismissingState<string | null>(null);
const [payError, setPayError] = useDismissingState<string | null>(null);
// Load all users for client-side fuzzy matching // Load all users for client-side fuzzy matching
useEffect(() => { useEffect(() => {
@@ -83,6 +113,97 @@ export default function ManualRegistrationPage() {
.catch(() => {}); .catch(() => {});
}, [token]); }, [token]);
// Load all registrations for the Record Payment tab (embeds payments, so outstanding
// balances can be computed without a per-registration fetch loop).
useEffect(() => {
if (!token) return;
apiFetch<any[]>("/api/registrations", { authToken: token })
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
.catch(() => {});
}, [token]);
const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
return map;
}, [allRegistrations]);
const payMatchedUsers = useMemo(() => {
if (payUserQuery.trim().length < 2) return [];
return allUsers
.map(u => ({ u, score: scoreUser(u, payUserQuery) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 6)
.map(x => x.u);
}, [payUserQuery, allUsers]);
const selectPayUser = (u: any) => {
setPayUserId(u.id);
setPayUserQuery(u.name || "");
setPayDropdownOpen(false);
setPayRegistrationId("");
};
// Close payment-tab user dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (paySearchRef.current && !paySearchRef.current.contains(e.target as Node)) {
setPayDropdownOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const regsForPayUser = useMemo(() => {
if (!payUserId) return [];
return allRegistrations
.filter((r: any) => String(r.userId || r.user?.id) === String(payUserId))
.filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [allRegistrations, payUserId, regOutstanding]);
const createManualPayment = async () => {
if (!token) return;
setPayError(null);
setPayMessage(null);
const amt = parseFloat(payAmount || "0");
if (!amt || amt <= 0) { setPayError("Enter a valid amount"); return; }
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiFetch<any>("/api/payments", {
method: "POST",
authToken: token,
body: {
amount: amt,
method: payMethod,
userId: payUserId || undefined,
registrationId: payRegistrationId,
isDonation: false,
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
}
});
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
setPayAmount("");
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
try {
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
setAllRegistrations(Array.isArray(regs) ? regs : []);
} catch {}
} catch (e: any) {
setPayError(e?.message || "Failed to record payment");
} finally {
setPaySubmitting(false);
}
};
// Fuzzy match results (top 6, score threshold 0.45) // Fuzzy match results (top 6, score threshold 0.45)
const matchedUsers = useMemo(() => { const matchedUsers = useMemo(() => {
if (userQuery.trim().length < 2) return []; if (userQuery.trim().length < 2) return [];
@@ -192,6 +313,14 @@ export default function ManualRegistrationPage() {
}); });
setMessage("Manual registration created successfully."); setMessage("Manual registration created successfully.");
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]);
setPayUserId(res.userId || res.user?.id || "");
setPayUserQuery(res.user?.name || guest.name || "");
setPayRegistrationId(res.id);
setPayDropdownOpen(false);
// Reset guest/ticket fields so the next registration starts from a clean slate // Reset guest/ticket fields so the next registration starts from a clean slate
setGuest({ name: "", email: "", phoneNumber: "" }); setGuest({ name: "", email: "", phoneNumber: "" });
setRegisterAsGuest(false); setRegisterAsGuest(false);
@@ -219,6 +348,19 @@ export default function ManualRegistrationPage() {
</div> </div>
)} )}
<div className="mb-4 flex items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
Register
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
Record Payment
</label>
</div>
{tab === 'register' && (
<>
{message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>} {message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>} {error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
@@ -446,6 +588,92 @@ export default function ManualRegistrationPage() {
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div> <div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button> <button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div> </div>
</>
)}
{tab === 'payment' && (
<div className="max-w-xl">
{payMessage && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{payMessage}</div>}
{payError && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{payError}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Record a payment</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Amount</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="number" step="0.01" value={payAmount} onChange={e => setPayAmount(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={payMethod} onChange={e => setPayMethod(e.target.value)}>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
<option value="voucher">Voucher</option>
</select>
</div>
<div ref={paySearchRef} className="relative">
<label className="block text-xs text-gray-600 mb-1">User</label>
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Search by name, email or phone…"
value={payUserQuery}
autoComplete="off"
onChange={e => { setPayUserQuery(e.target.value); setPayDropdownOpen(true); if (!e.target.value) { setPayUserId(""); setPayRegistrationId(""); } }}
onFocus={() => { if (payUserQuery.length >= 2) setPayDropdownOpen(true); }}
/>
{payDropdownOpen && payUserQuery.trim().length >= 2 && (
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{payMatchedUsers.length > 0 ? (
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
{payMatchedUsers.length} match{payMatchedUsers.length !== 1 ? "es" : ""}
</div>
{payMatchedUsers.map(u => (
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectPayUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
</div>
</button>
))}
</>
) : (
<div className="px-3 py-3 text-sm text-gray-500 italic">No matching users found.</div>
)}
</div>
)}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={payRegistrationId} onChange={e => setPayRegistrationId(e.target.value)} disabled={!payUserId}>
<option value="">Select registration</option>
{regsForPayUser.map((r: any) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
})}
</select>
{!payUserId && <div className="text-xs text-gray-500 mt-1">Search for a user above to see their registrations.</div>}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Paid at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={payPaidAtLocal} onChange={e => setPayPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} />
<div className="text-[10px] text-gray-500 mt-1">Leave blank to use current time</div>
</div>
</div>
<div className="mt-3">
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api"; import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
function RegistrationOptions({ regs, regOutstanding }: { function RegistrationOptions({ regs, regOutstanding }: {
@@ -115,8 +116,8 @@ function PaymentsContent() {
const [payments, setPayments] = useState<any[]>([]); const [payments, setPayments] = useState<any[]>([]);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false); const [loadingRegs, setLoadingRegs] = useState(false);
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({}); const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Attendee with preference info // Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string }; type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -358,8 +359,8 @@ function WhatsAppAttendeesPageInner() {
if (!user) router.replace("/login"); if (!user) router.replace("/login");
}, [user, loading, router]); }, [user, loading, router]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees"); const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]); const [allEvents, setAllEvents] = useState<any[]>([]);
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function DonatePage() { export default function DonatePage() {
const { token } = useAuth(); const { token } = useAuth();
@@ -9,8 +10,8 @@ export default function DonatePage() {
const [eventId, setEventId] = useState<string>(""); const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>(""); const [amount, setAmount] = useState<string>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -3,6 +3,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Types for form fields // Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -16,8 +17,8 @@ function FormsContent() {
const [registration, setRegistration] = useState<any | null>(null); const [registration, setRegistration] = useState<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Local entry state for new responses // Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({}); const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
+65 -10
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage"; import { QrImage } from "@/components/shared/QrImage";
import { useDismissingState } from "@/hooks/useDismissingState";
// Helper formatters // Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`; const formatRand = (n: number) => `R ${n.toFixed(2)}`;
@@ -29,6 +31,20 @@ function EventStatusBadge({ event }: { event: any }) {
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>; return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>;
} }
// Status badge for a registration, shown wherever registration.status is displayed on this
// page. Colors match the status coloring already used on dashboard/admin/registrations.
const REGISTRATION_STATUS_STYLES: Record<string, { label: string; className: string }> = {
pending: { label: 'Pending', className: 'bg-gray-100 text-gray-600' },
confirmed: { label: 'Confirmed', className: 'bg-blue-50 text-blue-700' },
partial_paid: { label: 'Partially Paid', className: 'bg-amber-50 text-amber-700' },
paid: { label: 'Paid', className: 'bg-green-50 text-green-700' },
cancelled: { label: 'Cancelled', className: 'bg-red-50 text-red-700' },
};
function RegistrationStatusBadge({ status }: { status: string }) {
const s = REGISTRATION_STATUS_STYLES[status] || { label: status, className: 'bg-gray-100 text-gray-500' };
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${s.className}`}>{s.label}</span>;
}
// Effective unit price for an event option (or one of its variants), early-bird aware. // Effective unit price for an event option (or one of its variants), early-bird aware.
// Used by the registration editor, which works off raw /api/events/:id data rather than // Used by the registration editor, which works off raw /api/events/:id data rather than
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx. // a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
@@ -52,8 +68,8 @@ export default function UserDashboardPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]); const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
// Filters // Filters
@@ -71,7 +87,7 @@ export default function UserDashboardPage() {
// Registration details modal // Registration details modal
const [activeRegId, setActiveRegId] = useState<string | null>(null); const [activeRegId, setActiveRegId] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean }>({ open: false, message: "", loading: false }); const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false });
// Track whether the active registration's event has attendee forms // Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null); const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -296,6 +312,41 @@ export default function UserDashboardPage() {
} }
}; };
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({ open: true, message: "Creating payment link…", loading: true, loadingTitle: "Creating payment link…", loadingSubtitle: "Please wait while we redirect you to Yoco." });
try {
const res = await createFullPaymentCheckout(token, registrationId);
if (res.priceUpdated) {
setDialog({ open: false, message: "", loading: false });
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
// Refresh this registration's billing so the displayed outstanding reflects the new price
try {
const now = new Date();
const r = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
} catch {}
return;
}
if (!res.redirectUrl) {
setDialog({ open: false, message: "", loading: false });
setError("Failed to create checkout");
return;
}
window.location.href = res.redirectUrl;
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to create checkout");
}
};
// Print helpers // Print helpers
const buildTicketHtmlCard = (t: any, buyerName?: string) => { const buildTicketHtmlCard = (t: any, buyerName?: string) => {
const eventTitle = t.event?.title || t.eventId || "Event"; const eventTitle = t.event?.title || t.eventId || "Event";
@@ -629,6 +680,10 @@ export default function UserDashboardPage() {
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/reset-password")} onClick={() => router.push("/dashboard/user/reset-password")}
>Reset password</button> >Reset password</button>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button>
<button <button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")} onClick={() => router.push("/dashboard/user/donate")}
@@ -677,7 +732,7 @@ export default function UserDashboardPage() {
<EventStatusBadge event={r.event} /> <EventStatusBadge event={r.event} />
</div> </div>
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600">
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>} Status: <RegistrationStatusBadge status={r.status} />
</div> </div>
{(() => { {(() => {
const fs = formStatuses[r.id]; const fs = formStatuses[r.id];
@@ -864,7 +919,7 @@ export default function UserDashboardPage() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="text-sm text-gray-600">Status: {activeReg.status}</div> <div className="text-sm text-gray-600">Status: <RegistrationStatusBadge status={activeReg.status} /></div>
{activeBill && ( {activeBill && (
<div className="text-sm"> <div className="text-sm">
<div>Total: {formatRand(activeBill.totalDue)}</div> <div>Total: {formatRand(activeBill.totalDue)}</div>
@@ -1004,7 +1059,7 @@ export default function UserDashboardPage() {
<div className="font-medium mb-1">Payments</div> <div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1"> <ul className="text-sm list-disc pl-5 space-y-1">
{activeBill.payments.map((p: any) => ( {activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({p.method || "Payment"})</li> <li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -1019,7 +1074,7 @@ export default function UserDashboardPage() {
{canModifyActive && activeBill && activeBill.outstanding > 0 ? ( {canModifyActive && activeBill && activeBill.outstanding > 0 ? (
<button <button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(activeReg.id)}`)} onClick={() => payNow(activeReg.id)}
>Make payment</button> >Make payment</button>
) : ( ) : (
<> <>
@@ -1076,8 +1131,8 @@ export default function UserDashboardPage() {
{dialog.loading ? ( {dialog.loading ? (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" /> <div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">Sending tickets</div> <div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<p className="text-sm text-gray-600 mt-1">Please wait while we send your tickets.</p> <p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -1,207 +0,0 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
function MakePaymentContent() {
const searchParams = useSearchParams();
const registrationId = searchParams.get("registrationId");
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [registration, setRegistration] = useState<any | null>(null);
const [payments, setPayments] = useState<any[]>([]);
const [amount, setAmount] = useState<string>("");
const [priceUpdated, setPriceUpdated] = useState<{ newTotal: number; message: string } | null>(null);
// Compute effective unit price for a registration option.
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
const variantId: string | null = opt.variantId || null;
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
? Number(opt.variant.price)
: Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
const tiers = variantId
? allTiers.filter((t: any) => t.variantId === variantId)
: allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const t = atTime ? new Date(atTime) : new Date();
const ref = referenceTime ? new Date(referenceTime) : t;
const applicable = tiers
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
if (applicable.length === 0) return base;
const price = Number(applicable[0].price);
return (price >= 0) ? price : base;
};
const totals = useMemo(() => {
if (!registration) return { totalDue: 0, totalPaid: 0, outstanding: 0 };
const now = new Date();
// totalDue uses priceSnapshot — not time-dependent, consistent with what the backend charges
const totalDue = (registration.registrationOptions || []).reduce((s: number, opt: any) => s + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = payments.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
return { totalDue, totalPaid, outstanding };
}, [registration, payments]);
const minAllowed = useMemo(() => {
if (totals.outstanding <= 0) return 0;
return totals.outstanding >= 15 ? 15 : totals.outstanding;
}, [totals.outstanding]);
useEffect(() => {
(async () => {
if (!token || !registrationId) return;
try {
setError(null);
setLoading(true);
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch (e: any) {
setError(e?.message || "Failed to load registration");
} finally {
setLoading(false);
}
})();
}, [token, registrationId]);
useEffect(() => {
if (totals.outstanding <= 0) {
setAmount("");
}
}, [totals.outstanding]);
const submit = async () => {
if (!token || !registrationId) return;
const amt = parseFloat(amount);
if (!(amt > 0)) { setError("Enter a valid amount"); return; }
if (amt > totals.outstanding) { setError(`Amount cannot exceed outstanding (R ${totals.outstanding.toFixed(2)})`); return; }
if (amt < minAllowed) {
if (totals.outstanding >= 15) {
setError("Minimum payment is R15");
} else {
setError(`Please pay the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`);
}
return;
}
try {
setError(null);
setInfo(null);
setPriceUpdated(null);
setLoading(true);
const res = await apiFetch<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }>("/api/payments/yoco-checkout", {
method: "POST",
body: {
registrationId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
if (res.priceUpdated) {
// Early-bird price changed — show warning and reload registration data
setPriceUpdated({ newTotal: res.newTotal ?? 0, message: res.message ?? 'Prices have changed.' });
// Reload registration so totals reflect updated priceSnapshot
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch {}
setAmount("");
return;
}
setInfo("Redirecting to payment...");
window.location.href = res.redirectUrl!;
} catch (e: any) {
setError(e?.message || "Failed to create checkout");
} finally {
setLoading(false);
}
};
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a payment</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{priceUpdated && (
<div className="mb-4 p-3 border border-amber-300 bg-amber-50 rounded text-sm text-amber-800">
<strong>Pricing has changed.</strong> {priceUpdated.message}
<div className="mt-1">New outstanding: <strong>R {priceUpdated.newTotal.toFixed(2)}</strong></div>
<button
className="mt-2 text-xs underline text-amber-700 hover:text-amber-900"
onClick={() => setPriceUpdated(null)}
>OK, I understand continue with new price</button>
</div>
)}
{registration ? (
<div className="border rounded p-3 mb-4">
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
<div className="text-sm text-gray-600">Registration #{registration.id.slice(0,8)}</div>
<div className="mt-2 text-sm">
<div>Total: R {totals.totalDue.toFixed(2)}</div>
<div>Paid: R {totals.totalPaid.toFixed(2)}</div>
<div>Outstanding: <span className={totals.outstanding>0?"text-red-600":"text-green-700"}>R {totals.outstanding.toFixed(2)}</span></div>
</div>
{(() => {
const tiers = (registration.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const upcoming = tiers.map((t: any) => ({ ...t, deadline: new Date(t.deadline) })).filter((t: any) => new Date() < t.deadline).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
if (upcoming.length === 0) return null;
const d = upcoming[0].deadline as Date;
const dateStr = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
return <div className="mt-2 text-xs text-amber-700">Early bird pricing applies if paid before {dateStr}.</div>;
})()}
</div>
) : (
<p className="text-sm text-gray-600 mb-4">Loading registration...</p>
)}
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min={minAllowed.toFixed(2)}
max={totals.outstanding > 0 ? totals.outstanding.toFixed(2) : "0"}
step="1"
placeholder={totals.outstanding >= 15 ? "Enter amount (min R15)" : `Enter amount (max R ${totals.outstanding.toFixed(2)})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={totals.outstanding <= 0}
className="w-full border rounded px-3 py-2 mb-1 disabled:opacity-60"
/>
{totals.outstanding > 0 && (
<p className="text-xs text-gray-600 mb-3">Minimum payment {totals.outstanding >= 15 ? "is R15" : `is the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`}.</p>
)}
<button
disabled={loading || totals.outstanding <= 0}
onClick={submit}
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Creating checkout...": totals.outstanding <= 0 ? "No outstanding amount" : "Pay now"}</button>
</div>
);
}
export default function MakePaymentPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<MakePaymentContent />
</Suspense>
);
}
@@ -0,0 +1,203 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
interface PaymentItem {
id: string;
amount: number;
method: string | null;
createdAt: string;
registrationId: string | null;
eventId: string | null;
registration?: { event?: { title?: string } | null } | null;
event?: { title?: string } | null;
}
const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`;
export default function UserPaymentsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// Filters
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [method, setMethod] = useState("");
const [kind, setKind] = useState<"" | "payment" | "refund">("");
const buildQuery = useCallback((p: number) => {
const qs = new URLSearchParams({ page: String(p), limit: "25" });
if (startDate) qs.set("startDate", startDate);
if (endDate) qs.set("endDate", endDate);
if (method) qs.set("method", method);
if (kind) qs.set("kind", kind);
return `/api/payments/mypayments?${qs.toString()}`;
}, [startDate, endDate, method, kind]);
const loadPayments = useCallback(async (p = 1) => {
if (!token) return;
setError(null);
setFetching(true);
try {
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
setPayments(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
setPage(p);
} catch (e: any) {
setError(e?.message || "Failed to load payments");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
const goToPage = (p: number) => loadPayments(p);
return (
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">My Payments</h1>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={startDate}
onChange={e => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
<option value="voucher">Voucher</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Type</label>
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
<option value="">Payments &amp; refunds</option>
<option value="payment">Payments only</option>
<option value="refund">Refunds only</option>
</select>
</div>
{(startDate || endDate || method || kind) && (
<button
className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200"
onClick={() => { setStartDate(""); setEndDate(""); setMethod(""); setKind(""); }}
>Clear filters</button>
)}
</div>
<div className="text-xs text-gray-500 mb-2">
{total} payment{total !== 1 ? "s" : ""} total
{total > 0 && ` — page ${page} of ${totalPages}`}
</div>
<ul className="text-sm space-y-2">
{payments.map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
const eventTitle = p.registration?.event?.title || p.event?.title;
return (
<li key={p.id} className={`border rounded p-2 ${isRefund ? "bg-red-50" : ""}`}>
<div className="flex justify-between">
<div className={`font-medium ${isRefund ? "text-red-700" : ""}`}>
{isRefund ? "-" : ""}{formatRand(amt)}
{isRefund && <span className="text-xs text-red-700 ml-1">(refund)</span>}
</div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
</div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
</li>
);
})}
{payments.length === 0 && !fetching && (
<li className="text-gray-500">No payments found.</li>
)}
{fetching && <li className="text-gray-400">Loading</li>}
</ul>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">Page {page} of {totalPages}</span>
<div className="flex gap-1">
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page <= 1 || fetching}
onClick={() => goToPage(page - 1)}
> Prev</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400"></span>
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>{p}</button>
)
)}
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page >= totalPages || fetching}
onClick={() => goToPage(page + 1)}
>Next </button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone"; import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function UserProfilePage() { export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth(); const { user, token, logout, updateToken } = useAuth();
@@ -19,7 +20,7 @@ export default function UserProfilePage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email"); const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [profileMsg, setProfileMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const hasValidPhone = isValidZAPhone(phone); const hasValidPhone = isValidZAPhone(phone);
@@ -63,7 +64,7 @@ export default function UserProfilePage() {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [pwMsg, setPwMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [pwMsg, setPwMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingPw, setSavingPw] = useState(false); const [savingPw, setSavingPw] = useState(false);
const changePassword = async (e: React.FormEvent) => { const changePassword = async (e: React.FormEvent) => {
@@ -98,7 +99,7 @@ export default function UserProfilePage() {
}; };
// ── Revoke sessions ─────────────────────────────────────────────────────── // ── Revoke sessions ───────────────────────────────────────────────────────
const [revokeMsg, setRevokeMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [revokeMsg, setRevokeMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [revoking, setRevoking] = useState(false); const [revoking, setRevoking] = useState(false);
const revokeSessions = async () => { const revokeSessions = async () => {
@@ -126,7 +127,7 @@ export default function UserProfilePage() {
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle"); const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false); const [deleteData, setDeleteData] = useState(false);
const [closePassword, setClosePassword] = useState(""); const [closePassword, setClosePassword] = useState("");
const [closeMsg, setCloseMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [closeMsg, setCloseMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const submitAccountClosure = async (e: React.FormEvent) => { const submitAccountClosure = async (e: React.FormEvent) => {
@@ -2,6 +2,7 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
@@ -11,7 +12,7 @@ export default function ResetPasswordPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]); const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
+32 -10
View File
@@ -3,12 +3,14 @@ import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
function RegistrationSuccessContent() { function RegistrationSuccessContent() {
const search = useSearchParams(); const search = useSearchParams();
const router = useRouter(); const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId") || search.get("id"); const registrationId = search.get("registrationId") || search.get("id");
const fallbackTotalParam = search.get("totalDue"); const fallbackTotalParam = search.get("totalDue");
const fallbackTotalDue = React.useMemo(() => { const fallbackTotalDue = React.useMemo(() => {
@@ -22,6 +24,7 @@ function RegistrationSuccessContent() {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [info, setInfo] = React.useState<string | null>(null); const [info, setInfo] = React.useState<string | null>(null);
const [payLoading, setPayLoading] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
@@ -31,7 +34,6 @@ function RegistrationSuccessContent() {
try { try {
setLoading(true); setLoading(true);
// Try to fetch registration and event form // Try to fetch registration and event form
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null; const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
if (r) { if (r) {
setReg(r); setReg(r);
@@ -49,7 +51,7 @@ function RegistrationSuccessContent() {
setLoading(false); setLoading(false);
} }
})(); })();
}, [registrationId]); }, [registrationId, token]);
const totalDue = React.useMemo(() => { const totalDue = React.useMemo(() => {
if (!reg) return 0; if (!reg) return 0;
@@ -69,7 +71,6 @@ function RegistrationSuccessContent() {
// attempt ticket generation if status is paid (may fail if required forms aren't completed) // attempt ticket generation if status is paid (may fail if required forms aren't completed)
(async () => { (async () => {
try { try {
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (token && reg?.id) { if (token && reg?.id) {
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } }); await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
} }
@@ -77,11 +78,33 @@ function RegistrationSuccessContent() {
setTimeout(() => router.replace('/dashboard'), 600); setTimeout(() => router.replace('/dashboard'), 600);
})(); })();
} }
}, [reg, totalDue, router]); }, [reg, totalDue, router, token]);
const goPay = () => { const goPay = async () => {
if (!registrationId) return; if (!registrationId) return;
router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(registrationId)}`); if (!token) { setError('Please login to pay.'); return; }
try {
setError(null);
setPayLoading(true);
const { createFullPaymentCheckout } = await import('@/lib/api');
const res = await createFullPaymentCheckout(token, registrationId);
if (res.priceUpdated) {
// Early-bird price changed since registration — surface the new total and
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
try {
const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
if (r) setReg(r);
} catch {}
return;
}
if (!res.redirectUrl) { setError('Failed to create checkout'); return; }
window.location.href = res.redirectUrl;
} catch (e: any) {
setError(e?.message || 'Failed to create checkout');
} finally {
setPayLoading(false);
}
}; };
const goDashboard = () => { const goDashboard = () => {
@@ -108,9 +131,9 @@ function RegistrationSuccessContent() {
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && ( {(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
<button <button
onClick={goPay} onClick={goPay}
disabled={!registrationId} disabled={!registrationId || payLoading}
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60" className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
>Pay with Yoco</button> >{payLoading ? "Creating checkout..." : "Pay with Yoco"}</button>
)} )}
<button <button
onClick={goDashboard} onClick={goDashboard}
@@ -129,6 +152,7 @@ function RegistrationSuccessContent() {
} }
function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }: { reg: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; setError: any; setInfo: any; }) { function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }: { reg: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; setError: any; setInfo: any; }) {
const { token } = useAuth();
const registrationId = reg?.id; const registrationId = reg?.id;
const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0); const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets); const count = Math.max(0, mainTickets);
@@ -157,7 +181,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
const submit = async () => { const submit = async () => {
try { try {
setError(null); setInfo(null); setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to submit forms.'); return; } if (!token) { setError('Please login to submit forms.'); return; }
const payload = [] as any[]; const payload = [] as any[];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -177,7 +200,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
const saveDraft = async () => { const saveDraft = async () => {
try { try {
setError(null); setInfo(null); setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to save drafts.'); return; } if (!token) { setError('Please login to save drafts.'); return; }
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
method: 'PUT', method: 'PUT',
+95 -47
View File
@@ -473,11 +473,26 @@ export default function ReportsV2() {
return map; return map;
}, [paymentsByEvent]); }, [paymentsByEvent]);
// Yoco processing fee totals per registration, from the per-payment snapshot (see
// Payment.feeAmount in the backend) — never recomputed from current settings, so this
// always reflects what was actually estimated at payment time.
const feeByReg = useMemo(() => {
const map = new Map<string, number>();
Object.keys(paymentsByEvent).forEach(evId => {
(paymentsByEvent[evId] || []).forEach((p: any) => {
if (p.registrationId && p.feeAmount != null) {
map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.feeAmount || 0));
}
});
});
return map;
}, [paymentsByEvent]);
const revDetRows = useMemo(() => { const revDetRows = useMemo(() => {
if (report !== "revenueDetailed") return [] as any[]; if (report !== "revenueDetailed") return [] as any[];
const df = payFrom ? new Date(payFrom).getTime() : null; const df = payFrom ? new Date(payFrom).getTime() : null;
const dt = payTo ? new Date(payTo).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null;
type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number }; type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number; fee: number; netAfterFee: number };
const rows: Row[] = []; const rows: Row[] = [];
// Iterate registrations to include those with zero payments and show each once // Iterate registrations to include those with zero payments and show each once
Object.keys(registrationsByEvent).forEach(evId => { Object.keys(registrationsByEvent).forEach(evId => {
@@ -489,6 +504,7 @@ export default function ReportsV2() {
if (dt && created && created > dt) return; if (dt && created && created > dt) return;
const totalPaid = paidByReg.get(r.id) || 0; const totalPaid = paidByReg.get(r.id) || 0;
const outstanding = outstandingByReg.get(r.id) || 0; const outstanding = outstandingByReg.get(r.id) || 0;
const fee = feeByReg.get(r.id) || 0;
rows.push({ rows.push({
eventId: evId, eventId: evId,
eventTitle: evTitle, eventTitle: evTitle,
@@ -498,12 +514,14 @@ export default function ReportsV2() {
status: r.status, status: r.status,
registrationId: r.id, registrationId: r.id,
outstanding, outstanding,
fee,
netAfterFee: totalPaid - fee,
}); });
}); });
}); });
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || ''))); rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '')));
return rows; return rows;
}, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg]); }, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg, feeByReg]);
// Derived for attendees (layered) for a single event // Derived for attendees (layered) for a single event
const attendeesLayer = useMemo(() => { const attendeesLayer = useMemo(() => {
@@ -631,6 +649,7 @@ export default function ReportsV2() {
totalPaid: paidByReg.get(r.id) || 0, totalPaid: paidByReg.get(r.id) || 0,
outstanding: outstandingByReg.get(r.id) || 0, outstanding: outstandingByReg.get(r.id) || 0,
donations: donationsByUser.get(r.user?.id || r.userId) || 0, donations: donationsByUser.get(r.user?.id || r.userId) || 0,
fee: feeByReg.get(r.id) || 0,
__prices: {} as Record<string, number> // 👈 hidden helper __prices: {} as Record<string, number> // 👈 hidden helper
}; };
@@ -660,7 +679,7 @@ export default function ReportsV2() {
return rows; return rows;
}, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg]); }, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg, feeByReg]);
//Master Report Totals //Master Report Totals
const masterTotals = useMemo(() => { const masterTotals = useMemo(() => {
@@ -671,6 +690,7 @@ export default function ReportsV2() {
totalPaid: 0, totalPaid: 0,
outstanding: 0, outstanding: 0,
donations: 0, donations: 0,
fee: 0,
}; };
// 👇 initialise dynamic option totals // 👇 initialise dynamic option totals
@@ -683,6 +703,7 @@ export default function ReportsV2() {
totals.orderTotal += row.orderTotal; totals.orderTotal += row.orderTotal;
totals.totalPaid += row.totalPaid; totals.totalPaid += row.totalPaid;
totals.outstanding += row.outstanding; totals.outstanding += row.outstanding;
totals.fee += row.fee || 0;
masterOptions.forEach(opt => { masterOptions.forEach(opt => {
const qty = row[opt.name] || 0; const qty = row[opt.name] || 0;
@@ -761,6 +782,8 @@ export default function ReportsV2() {
Email: r.userEmail || "", Email: r.userEmail || "",
Status: r.status || "", Status: r.status || "",
TotalPaid: r.totalPaid, TotalPaid: r.totalPaid,
Fee: r.fee,
NetAfterFee: r.netAfterFee,
Outstanding: r.outstanding, Outstanding: r.outstanding,
RegistrationId: r.registrationId RegistrationId: r.registrationId
}))); })));
@@ -793,7 +816,7 @@ export default function ReportsV2() {
return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows); return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows);
} }
if (report === "cashup") { if (report === "cashup") {
const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedCash", "Actual", "Variance"]; const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance"];
const rows: any[] = []; const rows: any[] = [];
Object.keys(financialsByEvent).forEach(evId => { Object.keys(financialsByEvent).forEach(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
@@ -802,15 +825,15 @@ export default function ReportsV2() {
const title = ev?.title || evId; const title = ev?.title || evId;
METHOD_KEYS.forEach(m => { METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m]; const r = f.reconciled?.byMethod?.[m];
rows.push({ Event: title, Section: "Method", Detail: METHOD_LABEL[m], Income: f.paymentsByMethod?.[m] || 0, CostsFromMethod: f.costsByMethod?.[m] || 0, ExpectedCash: f.expectedCashByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" }); rows.push({ Event: title, Section: "Method", Detail: METHOD_LABEL[m], Income: f.paymentsByMethod?.[m] || 0, CostsFromMethod: f.costsByMethod?.[m] || 0, ExpectedBeforeFees: f.expectedCashByMethod?.[m] || 0, YocoFee: f.feesByMethod?.[m] || 0, ExpectedAfterFees: f.expectedInBankByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" });
if (m === "cash") { if (m === "cash") {
(r?.denominations || []).forEach((d: any) => rows.push({ Event: title, Section: "Denomination", Detail: denomLabel(d.value) + " × " + d.count, Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: d.value * d.count, Variance: "" })); (r?.denominations || []).forEach((d: any) => rows.push({ Event: title, Section: "Denomination", Detail: denomLabel(d.value) + " × " + d.count, Income: "", CostsFromMethod: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: d.value * d.count, Variance: "" }));
} }
}); });
if ((f.untaggedCostsTotal || 0) > 0) { if ((f.untaggedCostsTotal || 0) > 0) {
rows.push({ Event: title, Section: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedCash: "", Actual: "", Variance: "" }); rows.push({ Event: title, Section: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "" });
} }
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" }); rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" });
}); });
return downloadCsv(`cashup_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); return downloadCsv(`cashup_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
} }
@@ -833,7 +856,8 @@ export default function ReportsV2() {
}); });
(f.costs || []).forEach((c: any) => rows.push({ Event: title, Section: "Costs", Detail: `${c.label}${c.paidFromMethod ? ` (paid via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Amount: -(c.total ?? c.amount) })); (f.costs || []).forEach((c: any) => rows.push({ Event: title, Section: "Costs", Detail: `${c.label}${c.paidFromMethod ? ` (paid via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Amount: -(c.total ?? c.amount) }));
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 }); rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 });
rows.push({ Event: title, Section: "Net profit", Detail: "", Amount: f.netProfit || 0 }); rows.push({ Event: title, Section: "Fees", Detail: "Yoco processing fees", Amount: -(f.totalFees || 0) });
rows.push({ Event: title, Section: "Net profit", Detail: "After fees", Amount: f.netProfitAfterFees || 0 });
}); });
return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
} }
@@ -841,20 +865,20 @@ export default function ReportsV2() {
const rows = Object.keys(financialsByEvent).map(evId => { const rows = Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId); const ev = filteredEvents.find(e => e.id === evId);
return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, NetProfit: f?.netProfit || 0 }; return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, Fees: f?.totalFees || 0, NetProfit: f?.netProfitAfterFees || 0 };
}); });
return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows); return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows);
} }
if (report === "cashupAudit") { if (report === "cashupAudit") {
const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "Expected", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"]; const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"];
const rows: any[] = []; const rows: any[] = [];
auditRows.forEach((r: any) => { auditRows.forEach((r: any) => {
const base = { Event: r.event?.title || r.eventId, Action: actionLabel(r.action), PerformedBy: r.performedBy?.name || "", Date: new Date(r.createdAt).toLocaleString() }; const base = { Event: r.event?.title || r.eventId, Action: actionLabel(r.action), PerformedBy: r.performedBy?.name || "", Date: new Date(r.createdAt).toLocaleString() };
if (r.action === "reopened") { if (r.action === "reopened") {
rows.push({ ...base, Method: "", Expected: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" }); rows.push({ ...base, Method: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" });
} else { } else {
(r.lines || []).forEach((l: any) => rows.push({ (r.lines || []).forEach((l: any) => rows.push({
...base, Method: METHOD_LABEL[l.method] || l.method, Expected: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "", ...base, Method: METHOD_LABEL[l.method] || l.method, ExpectedBeforeFees: (l.expectedAmount || 0) + (l.feeAmount || 0), YocoFee: l.feeAmount ?? 0, ExpectedAfterFees: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "",
DeltaVsPreviousClose: r._deltaVsPrevious?.[l.method] ?? "", DonationsCountedAsProfit: r.unallocatedDonationsTotal || 0, Notes: l.notes || r.notes || "" DeltaVsPreviousClose: r._deltaVsPrevious?.[l.method] ?? "", DonationsCountedAsProfit: r.unallocatedDonationsTotal || 0, Notes: l.notes || r.notes || ""
})); }));
} }
@@ -874,6 +898,7 @@ export default function ReportsV2() {
OrderTotal: r.orderTotal, OrderTotal: r.orderTotal,
Paid: r.totalPaid, Paid: r.totalPaid,
Fee: r.fee,
Outstanding: r.outstanding, Outstanding: r.outstanding,
Donations: r.donations Donations: r.donations
})); }));
@@ -891,6 +916,7 @@ export default function ReportsV2() {
OrderTotal: masterTotals?.orderTotal ?? 0, OrderTotal: masterTotals?.orderTotal ?? 0,
Paid: masterTotals?.totalPaid ?? 0, Paid: masterTotals?.totalPaid ?? 0,
Fee: masterTotals?.fee ?? 0,
Outstanding: masterTotals?.outstanding ?? 0, Outstanding: masterTotals?.outstanding ?? 0,
Donations: masterTotals?.donations ?? 0, Donations: masterTotals?.donations ?? 0,
}); });
@@ -1006,6 +1032,8 @@ export default function ReportsV2() {
r.userEmail || "", r.userEmail || "",
r.status || "", r.status || "",
Number((r.totalPaid || 0).toFixed(2)), Number((r.totalPaid || 0).toFixed(2)),
Number((r.fee || 0).toFixed(2)),
Number((r.netAfterFee || 0).toFixed(2)),
Number((r.outstanding || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)),
r.registrationId || "", r.registrationId || "",
]); ]);
@@ -1013,7 +1041,7 @@ export default function ReportsV2() {
title: 'Revenue Detailed', title: 'Revenue Detailed',
kind: 'table', kind: 'table',
orientation: 'landscape', orientation: 'landscape',
table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows } table: { columns: ["Event","Name","Email","Status","Total paid","Fee","Net after fee","Outstanding","RegistrationId"], rows }
}); });
return; return;
} }
@@ -1042,15 +1070,15 @@ export default function ReportsV2() {
const title = ev?.title || evId; const title = ev?.title || evId;
METHOD_KEYS.forEach(m => { METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m]; const r = f.reconciled?.byMethod?.[m];
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), Number((f.feesByMethod?.[m] || 0).toFixed(2)), Number((f.expectedInBankByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
if (m === "cash") { if (m === "cash") {
(r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""])); (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
} }
}); });
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]); if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", "", "", ""]);
rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]); rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
}); });
await downloadReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows } }); await downloadReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance"], rows } });
return; return;
} }
if (report === "financeReport") { if (report === "financeReport") {
@@ -1073,7 +1101,8 @@ export default function ReportsV2() {
rows.push([title, "Costs", "", ""]); rows.push([title, "Costs", "", ""]);
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))])); (f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]); rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]); rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]);
rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]);
}); });
await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } }); await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } });
return; return;
@@ -1082,9 +1111,9 @@ export default function ReportsV2() {
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => { const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId); const ev = filteredEvents.find(e => e.id === evId);
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))]; return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))];
}); });
await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows } }); await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Fees", "Net profit (after fees)"], rows } });
return; return;
} }
if (report === "cashupAudit") { if (report === "cashupAudit") {
@@ -1092,13 +1121,13 @@ export default function ReportsV2() {
auditRows.forEach((r: any) => { auditRows.forEach((r: any) => {
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()]; const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
if (r.action === "reopened") { if (r.action === "reopened") {
rows.push([...base, "", "", "", "", "", r.notes || ""]); rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]);
} else { } else {
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""])); (r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number(((l.expectedAmount || 0) + (l.feeAmount || 0)).toFixed(2)), Number((l.feeAmount || 0).toFixed(2)), Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""]));
if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]); if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]);
} }
}); });
await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } }); await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } });
return; return;
} }
if (report === "masterOrders") { if (report === "masterOrders") {
@@ -1111,6 +1140,7 @@ export default function ReportsV2() {
Number(r.orderTotal.toFixed(2)), Number(r.orderTotal.toFixed(2)),
Number(r.totalPaid.toFixed(2)), Number(r.totalPaid.toFixed(2)),
Number((r.fee || 0).toFixed(2)),
Number(r.outstanding.toFixed(2)) Number(r.outstanding.toFixed(2))
]); ]);
@@ -1124,6 +1154,7 @@ export default function ReportsV2() {
Number((masterTotals?.orderTotal ?? 0).toFixed(2)), Number((masterTotals?.orderTotal ?? 0).toFixed(2)),
Number((masterTotals?.totalPaid ?? 0).toFixed(2)), Number((masterTotals?.totalPaid ?? 0).toFixed(2)),
Number((masterTotals?.fee ?? 0).toFixed(2)),
Number((masterTotals?.outstanding ?? 0).toFixed(2)) Number((masterTotals?.outstanding ?? 0).toFixed(2))
]); ]);
@@ -1137,6 +1168,7 @@ export default function ReportsV2() {
`R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}` `R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}`
), ),
"",
"", "",
"", "",
"" ""
@@ -1156,6 +1188,7 @@ export default function ReportsV2() {
"Order Total", "Order Total",
"Paid", "Paid",
"Fee",
"Outstanding" "Outstanding"
], ],
rows: bodyRows rows: bodyRows
@@ -1217,8 +1250,8 @@ export default function ReportsV2() {
}); });
await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body }); await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body });
} else if (report === 'revenueDetailed') { } else if (report === 'revenueDetailed') {
const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "" ]); const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.fee || 0).toFixed(2)), Number((r.netAfterFee || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "" ]);
await emailReportPdf(API_BASE, token, { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }, subject, body }); await emailReportPdf(API_BASE, token, { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', table: { columns: ["Event","Name","Email","Status","Total paid","Fee","Net after fee","Outstanding","RegistrationId"], rows }, subject, body });
} else if (report === 'regStatus') { } else if (report === 'regStatus') {
const rows: (string|number)[][] = []; const rows: (string|number)[][] = [];
Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); }); Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); });
@@ -1230,13 +1263,13 @@ export default function ReportsV2() {
const title = ev?.title || evId; const title = ev?.title || evId;
METHOD_KEYS.forEach(m => { METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m]; const r = f.reconciled?.byMethod?.[m];
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), Number((f.feesByMethod?.[m] || 0).toFixed(2)), Number((f.expectedInBankByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
if (m === "cash") (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""])); if (m === "cash") (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
}); });
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]); if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", "", "", ""]);
rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]); rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
}); });
await emailReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows }, subject, body }); await emailReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance"], rows }, subject, body });
} else if (report === 'financeReport') { } else if (report === 'financeReport') {
const rows: (string | number)[][] = []; const rows: (string | number)[][] = [];
Object.keys(financialsByEvent).forEach(evId => { Object.keys(financialsByEvent).forEach(evId => {
@@ -1255,26 +1288,27 @@ export default function ReportsV2() {
rows.push([title, "Costs", "", ""]); rows.push([title, "Costs", "", ""]);
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))])); (f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]); rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]); rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]);
rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]);
}); });
await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body }); await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body });
} else if (report === 'profitReport') { } else if (report === 'profitReport') {
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => { const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId);
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))]; return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))];
}); });
await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows }, subject, body }); await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Fees", "Net profit (after fees)"], rows }, subject, body });
} else if (report === 'cashupAudit') { } else if (report === 'cashupAudit') {
const rows: (string | number)[][] = []; const rows: (string | number)[][] = [];
auditRows.forEach((r: any) => { auditRows.forEach((r: any) => {
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()]; const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
if (r.action === "reopened") { if (r.action === "reopened") {
rows.push([...base, "", "", "", "", "", r.notes || ""]); rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]);
} else { } else {
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""])); (r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number(((l.expectedAmount || 0) + (l.feeAmount || 0)).toFixed(2)), Number((l.feeAmount || 0).toFixed(2)), Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""]));
} }
}); });
await emailReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }, subject, body }); await emailReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }, subject, body });
} }
alert('Email sent with PDF attachment'); alert('Email sent with PDF attachment');
} catch (e: any) { } catch (e: any) {
@@ -1710,6 +1744,8 @@ export default function ReportsV2() {
<th className="text-left">Email</th> <th className="text-left">Email</th>
<th className="text-left">Status</th> <th className="text-left">Status</th>
<th className="text-left">Total paid</th> <th className="text-left">Total paid</th>
<th className="text-left">Fee</th>
<th className="text-left">Net after fee</th>
<th className="text-left">Outstanding</th> <th className="text-left">Outstanding</th>
<th className="text-left">Registration</th> <th className="text-left">Registration</th>
</tr> </tr>
@@ -1721,6 +1757,8 @@ export default function ReportsV2() {
<td>{r.userEmail || ''}</td> <td>{r.userEmail || ''}</td>
<td className="capitalize">{r.status || ''}</td> <td className="capitalize">{r.status || ''}</td>
<td>R {Number(r.totalPaid || 0).toFixed(2)}</td> <td>R {Number(r.totalPaid || 0).toFixed(2)}</td>
<td>R {Number(r.fee || 0).toFixed(2)}</td>
<td>R {Number(r.netAfterFee || 0).toFixed(2)}</td>
<td>R {Number(r.outstanding || 0).toFixed(2)}</td> <td>R {Number(r.outstanding || 0).toFixed(2)}</td>
<td className="font-mono text-xs">{r.registrationId || ''}</td> <td className="font-mono text-xs">{r.registrationId || ''}</td>
</tr> </tr>
@@ -1823,6 +1861,7 @@ export default function ReportsV2() {
<span className="font-medium">Totals:</span> <span className="font-medium">Totals:</span>
<span className="ml-3">Order: R {masterTotals?.orderTotal.toFixed(2)}</span> <span className="ml-3">Order: R {masterTotals?.orderTotal.toFixed(2)}</span>
<span className="ml-3">Paid: R {masterTotals?.totalPaid.toFixed(2)}</span> <span className="ml-3">Paid: R {masterTotals?.totalPaid.toFixed(2)}</span>
<span className="ml-3">Fee: R {(masterTotals?.fee ?? 0).toFixed(2)}</span>
<span className="ml-3">Outstanding: R {masterTotals?.outstanding.toFixed(2)}</span> <span className="ml-3">Outstanding: R {masterTotals?.outstanding.toFixed(2)}</span>
</div> </div>
@@ -1840,6 +1879,7 @@ export default function ReportsV2() {
<th>Order Total</th> <th>Order Total</th>
<th>Paid</th> <th>Paid</th>
<th>Fee</th>
<th>Outstanding</th> <th>Outstanding</th>
<th>Donations</th> <th>Donations</th>
</tr> </tr>
@@ -1859,6 +1899,7 @@ export default function ReportsV2() {
<td>R {r.orderTotal.toFixed(2)}</td> <td>R {r.orderTotal.toFixed(2)}</td>
<td>R {r.totalPaid.toFixed(2)}</td> <td>R {r.totalPaid.toFixed(2)}</td>
<td>R {(r.fee || 0).toFixed(2)}</td>
<td>R {r.outstanding.toFixed(2)}</td> <td>R {r.outstanding.toFixed(2)}</td>
<td>R {r.donations.toFixed(2)}</td> <td>R {r.donations.toFixed(2)}</td>
</tr> </tr>
@@ -1874,6 +1915,7 @@ export default function ReportsV2() {
<td>R {masterTotals?.orderTotal.toFixed(2)}</td> <td>R {masterTotals?.orderTotal.toFixed(2)}</td>
<td>R {masterTotals?.totalPaid.toFixed(2)}</td> <td>R {masterTotals?.totalPaid.toFixed(2)}</td>
<td>R {(masterTotals?.fee ?? 0).toFixed(2)}</td>
<td>R {masterTotals?.outstanding.toFixed(2)}</td> <td>R {masterTotals?.outstanding.toFixed(2)}</td>
<td>R {masterTotals?.donations.toFixed(2)}</td> <td>R {masterTotals?.donations.toFixed(2)}</td>
</tr> </tr>
@@ -1886,7 +1928,7 @@ export default function ReportsV2() {
</td> </td>
))} ))}
<td colSpan={4}></td> <td colSpan={5}></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -1912,7 +1954,7 @@ export default function ReportsV2() {
</div> </div>
<div className="overflow-auto"> <div className="overflow-auto">
<table className="min-w-[640px] text-sm"> <table className="min-w-[640px] text-sm">
<thead><tr><th className="text-left">Method</th><th className="text-left">Income</th><th className="text-left">Costs from method</th><th className="text-left">Expected cash</th><th className="text-left">Actual</th><th className="text-left">Variance</th></tr></thead> <thead><tr><th className="text-left">Method</th><th className="text-left">Income</th><th className="text-left">Costs from method</th><th className="text-left">Expected (before fees)</th><th className="text-left">Yoco fee</th><th className="text-left">Expected (after fees)</th><th className="text-left">Actual</th><th className="text-left">Variance</th></tr></thead>
<tbody> <tbody>
{METHOD_KEYS.map(m => { {METHOD_KEYS.map(m => {
const r = f.reconciled?.byMethod?.[m]; const r = f.reconciled?.byMethod?.[m];
@@ -1923,13 +1965,15 @@ export default function ReportsV2() {
<td>R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}</td> <td>R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.costsByMethod?.[m] || 0).toFixed(2)}</td> <td>R {(f.costsByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td> <td>R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.feesByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.expectedInBankByMethod?.[m] || 0).toFixed(2)}</td>
<td>{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td> <td>{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
<td className={r?.variance != null && r.variance !== 0 ? (r.variance < 0 ? "text-red-600" : "text-emerald-600") : ""}>{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td> <td className={r?.variance != null && r.variance !== 0 ? (r.variance < 0 ? "text-red-600" : "text-emerald-600") : ""}>{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td>
</tr> </tr>
{m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => ( {m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => (
<tr key={d.id} className="text-xs text-gray-500"> <tr key={d.id} className="text-xs text-gray-500">
<td className="pl-4">{denomLabel(d.value)} × {d.count}</td> <td className="pl-4">{denomLabel(d.value)} × {d.count}</td>
<td colSpan={4}></td> <td colSpan={6}></td>
<td>R {(d.value * d.count).toFixed(2)}</td> <td>R {(d.value * d.count).toFixed(2)}</td>
</tr> </tr>
))} ))}
@@ -2011,7 +2055,8 @@ export default function ReportsV2() {
</div> </div>
<div className="text-sm flex items-center justify-between border-t pt-1"><span>Donations counted as profit</span><span>R {(f.unallocatedDonationsTotal || 0).toFixed(2)}</span></div> <div className="text-sm flex items-center justify-between border-t pt-1"><span>Donations counted as profit</span><span>R {(f.unallocatedDonationsTotal || 0).toFixed(2)}</span></div>
<div className="text-sm font-semibold flex items-center justify-between"><span>Net profit</span><span>R {(f.netProfit || 0).toFixed(2)}</span></div> <div className="text-sm flex items-center justify-between"><span>Yoco processing fees</span><span>-R {(f.totalFees || 0).toFixed(2)}</span></div>
<div className="text-sm font-semibold flex items-center justify-between"><span>Net profit (after fees)</span><span>R {(f.netProfitAfterFees || 0).toFixed(2)}</span></div>
</div> </div>
); );
}) })
@@ -2024,7 +2069,7 @@ export default function ReportsV2() {
<div className="text-sm text-gray-500">No data available. Select events and view.</div> <div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : ( ) : (
<table className="min-w-[600px] text-sm"> <table className="min-w-[600px] text-sm">
<thead><tr><th className="text-left">Event</th><th className="text-left">Revenue</th><th className="text-left">Costs</th><th className="text-left">Net profit</th></tr></thead> <thead><tr><th className="text-left">Event</th><th className="text-left">Revenue</th><th className="text-left">Costs</th><th className="text-left">Fees</th><th className="text-left">Net profit (after fees)</th></tr></thead>
<tbody> <tbody>
{Object.keys(financialsByEvent).map(evId => { {Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
@@ -2034,7 +2079,8 @@ export default function ReportsV2() {
<td>{ev?.title || evId}</td> <td>{ev?.title || evId}</td>
<td>R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td> <td>R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td>
<td>R {(f?.totalCosts || 0).toFixed(2)}</td> <td>R {(f?.totalCosts || 0).toFixed(2)}</td>
<td className="font-medium">R {(f?.netProfit || 0).toFixed(2)}</td> <td>R {(f?.totalFees || 0).toFixed(2)}</td>
<td className="font-medium">R {(f?.netProfitAfterFees || 0).toFixed(2)}</td>
</tr> </tr>
); );
})} })}
@@ -2063,11 +2109,13 @@ export default function ReportsV2() {
{(r.action === "closed" || r.action === "quick_closed") && ( {(r.action === "closed" || r.action === "quick_closed") && (
<div className="overflow-auto mt-2"> <div className="overflow-auto mt-2">
<table className="min-w-[560px] text-sm"> <table className="min-w-[560px] text-sm">
<thead><tr><th className="text-left">Method</th><th className="text-left">Expected</th><th className="text-left">Actual</th><th className="text-left">Variance</th><th className="text-left">Δ vs previous close</th></tr></thead> <thead><tr><th className="text-left">Method</th><th className="text-left">Expected (before fees)</th><th className="text-left">Yoco fee</th><th className="text-left">Expected (after fees)</th><th className="text-left">Actual</th><th className="text-left">Variance</th><th className="text-left">Δ vs previous close</th></tr></thead>
<tbody> <tbody>
{(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, actualAmount: null, variance: null }))).map((l: any) => ( {(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, feeAmount: null, actualAmount: null, variance: null }))).map((l: any) => (
<tr key={l.method}> <tr key={l.method}>
<td>{METHOD_LABEL[l.method] || l.method}</td> <td>{METHOD_LABEL[l.method] || l.method}</td>
<td>{l.expectedAmount != null ? `R ${(l.expectedAmount + (l.feeAmount || 0)).toFixed(2)}` : "—"}</td>
<td>{l.feeAmount != null ? `R ${l.feeAmount.toFixed(2)}` : "—"}</td>
<td>{l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"}</td> <td>{l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"}</td>
<td>{l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td> <td>{l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
<td>{l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}</td> <td>{l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}</td>
+24
View File
@@ -0,0 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Like useState, but a truthy value auto-clears back to the initial falsy value after
* `ms` milliseconds. Every call to the setter cancels any pending timer and (if the new
* value is truthy) arms a fresh one, so rapid-fire updates reset the countdown instead of
* cutting it short. Meant for post-action confirmation/error banners that shouldn't linger.
*/
export function useDismissingState<T>(initial: T, ms = 7000) {
const [value, setValue] = useState<T>(initial);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const set = useCallback((next: T) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setValue(next);
if (next) {
timeoutRef.current = setTimeout(() => setValue(initial), ms);
}
}, [ms, initial]);
useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
return [value, set] as const;
}
+16
View File
@@ -155,3 +155,19 @@ export function fetchAllUsers(token: string, params: Record<string, string> = {}
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> { export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
return fetchAllPages("/api/payments", token, params); return fetchAllPages("/api/payments", token, params);
} }
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }> {
return apiFetch("/api/payments/yoco-checkout", {
method: "POST",
authToken: token,
body: {
registrationId,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
});
}
+26
View File
@@ -0,0 +1,26 @@
// Payment.method is free-text — online checkouts get tagged with whatever wallet type the
// gateway reports (apple_pay, google_pay, ...), not just the manual-entry methods below.
// Mirrors normalizeUserMethod in backend/src/controllers/paymentController.js: card-network
// wallets count as "card" (same settlement, no separate float); anything else is "other".
const USER_FACING_METHODS = ["cash", "card", "eft", "voucher"];
const CARD_ALIASES = ["apple_pay", "google_pay"];
const METHOD_LABELS: Record<string, string> = {
cash: "Cash",
card: "Card",
eft: "EFT",
voucher: "Voucher",
other: "Other",
};
export function normalizePaymentMethod(method: string | null | undefined): string {
const m = String(method || "").toLowerCase();
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return "card";
return "other";
}
export function formatPaymentMethod(method: string | null | undefined): string {
if (!method) return "Payment";
return METHOD_LABELS[normalizePaymentMethod(method)];
}
+6
View File
@@ -76,6 +76,7 @@ export type EventCashupLine = {
cashupId: string; cashupId: string;
method: CashupMethod; method: CashupMethod;
expectedAmount: number; expectedAmount: number;
feeAmount?: number | null;
actualAmount?: number | null; actualAmount?: number | null;
variance?: number | null; variance?: number | null;
notes?: string | null; notes?: string | null;
@@ -102,6 +103,7 @@ export type EventCashup = {
export type ReconciledByMethod = { export type ReconciledByMethod = {
expected: number; expected: number;
fee: number;
actual: number | null; actual: number | null;
variance: number | null; variance: number | null;
notes?: string | null; notes?: string | null;
@@ -133,10 +135,14 @@ export type EventFinancials = {
untaggedCostsTotal: number; untaggedCostsTotal: number;
totalCosts: number; totalCosts: number;
expectedCashByMethod: Record<CashupMethod, number>; expectedCashByMethod: Record<CashupMethod, number>;
expectedInBankByMethod: Record<CashupMethod, number>;
reconciled: ReconciledSnapshot | null; reconciled: ReconciledSnapshot | null;
effectiveGrossIncomeByMethod: Record<CashupMethod, number>; effectiveGrossIncomeByMethod: Record<CashupMethod, number>;
effectiveTotalRevenue: number; effectiveTotalRevenue: number;
netProfit: number; netProfit: number;
feesByMethod: Record<CashupMethod, number>;
totalFees: number;
netProfitAfterFees: number;
unallocatedDonations: Payment[]; unallocatedDonations: Payment[];
unallocatedDonationsTotal: number; unallocatedDonationsTotal: number;
totalDonations: number; totalDonations: number;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events", "name": "hope-events",
"version": "1.0.1", "version": "1.3.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",