Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b081ed3c8b | ||
|
|
8a75c9155b | ||
|
|
8850984055 | ||
|
|
21176e1e0b | ||
|
|
b4cd140168 | ||
|
|
5167706d1b | ||
|
|
047f61b627 |
+23
-5
@@ -7,12 +7,29 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.2] - 2026-08-03
|
||||
|
||||
### 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.
|
||||
- Self-service kiosk: all password fields (supervisor sign-in, change event, and the visitor "Choose a password" field) now have a show/hide toggle button, so staff can verify what they've typed on the touchscreen instead of typing blind.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Self-service kiosk: removed the separate "Look up existing account" search field — for privacy, staff no longer type a visitor's email/phone into a dedicated search box. Instead, entering an email or phone number in the registration form itself (Email and Cell Number are now the first two fields, followed by Name) automatically checks for a matching account once that field is left.
|
||||
- Self-service kiosk: matched accounts are no longer updated silently. If the operator's typed Name, Email, Cell Number, or "Send tickets via" preference differs from what's on file, a confirmation dialog now lists exactly what will change (old value → new value) and requires the operator to confirm before the account is updated.
|
||||
- Self-service kiosk / manual registration: an existing account's name is now actually updated when confirmed changed (previously silently discarded), and email/phone corrections are applied even when the account already had a real value on file (previously only blank phone numbers or guest-placeholder emails could be replaced).
|
||||
- Self-service kiosk: fixed a bug where changing the phone number to one belonging to a different account would silently replace the Name/Email fields with that other account's details, and re-editing the email back to the original value afterward would not re-check it — together this could result in a registration being (or looking like it would be) saved under the wrong account. Email and phone matches are now tracked independently; if they resolve to two different existing accounts, the kiosk shows a clear warning naming both accounts and blocks registration until the operator corrects one of the fields, instead of silently merging or overwriting details.
|
||||
- Manual registration API: added a server-side check, independent of the kiosk UI, that rejects (`409`) a registration whose submitted email and phone number belong to two different existing accounts — a defense-in-depth safeguard against one account's contact details being overwritten with, or hijacked by, another's.
|
||||
- Manual registration API: an existing account's notification preference is now validated against its final email/phone after any confirmed update (e.g. falls back off "WhatsApp"/"Both" if no valid phone remains, or onto "WhatsApp" if the email was cleared in favor of a real phone), instead of persisting a preference that no longer matches the account's actual contact info.
|
||||
- Self-service kiosk: tapping anywhere else on the page (e.g. a ticket quantity +/− button) while Email or Cell Number was focused blurred that field and silently re-ran its account lookup; even though the match hadn't changed, this reset Name/Email/Phone/preference back to the matched account's original values, discarding any edits the operator had just made. The autofill now only applies once per distinct matched account instead of on every re-check.
|
||||
|
||||
## [1.3.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
|
||||
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
|
||||
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
|
||||
|
||||
## [1.3.0] - 2026-07-27
|
||||
|
||||
@@ -76,7 +93,8 @@ 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).
|
||||
|
||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main
|
||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...main
|
||||
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
|
||||
[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,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION,
|
||||
ADD COLUMN "feeChannel" TEXT,
|
||||
ADD COLUMN "feePayer" TEXT,
|
||||
ADD COLUMN "feeRate" DOUBLE PRECISION;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
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";
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
|
||||
@@ -221,9 +221,6 @@ model Payment {
|
||||
externalId String? @unique
|
||||
status String?
|
||||
originalPaymentId String?
|
||||
feeAmount Float?
|
||||
feeRate Float?
|
||||
feeChannel String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
@@ -464,7 +461,6 @@ model EventCashupLine {
|
||||
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
||||
method String
|
||||
expectedAmount Float @default(0)
|
||||
feeAmount Float?
|
||||
actualAmount Float?
|
||||
variance Float?
|
||||
notes String?
|
||||
|
||||
@@ -53,9 +53,7 @@ const closeEvent = async (req, res) => {
|
||||
? lines
|
||||
.filter(l => l && ALL_METHODS.includes(l.method))
|
||||
.map(l => {
|
||||
// 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 expected = financials.expectedCashByMethod[l.method] || 0;
|
||||
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
|
||||
? l.denominations
|
||||
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
|
||||
@@ -68,7 +66,6 @@ const closeEvent = async (req, res) => {
|
||||
id: uuidv4(),
|
||||
method: l.method,
|
||||
expectedAmount: expected,
|
||||
feeAmount: financials.feesByMethod[l.method] || 0,
|
||||
actualAmount: actual,
|
||||
variance: actual !== null ? actual - expected : null,
|
||||
notes: l.notes || null,
|
||||
@@ -80,7 +77,7 @@ const closeEvent = async (req, res) => {
|
||||
const totalActualRevenue = isFullCashup
|
||||
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
|
||||
: null;
|
||||
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedInBankByMethod[m], 0);
|
||||
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0);
|
||||
|
||||
const cashup = await prisma.eventCashup.create({
|
||||
data: {
|
||||
|
||||
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
|
||||
try {
|
||||
const includePast = req.query.includePast === 'true';
|
||||
const includeInactive = req.query.includeInactive === 'true';
|
||||
const excludeClosed = req.query.excludeClosed === 'true';
|
||||
|
||||
const where = {};
|
||||
if (!includeInactive) where.isActive = true;
|
||||
if (!includePast) where.endDate = { gte: new Date() };
|
||||
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
|
||||
@@ -5,8 +5,7 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
|
||||
const axios = require('axios');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, bucketForMethod } = require('../utils/cashupUtils');
|
||||
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
|
||||
// @desc Create a new payment
|
||||
// @route POST /api/payments
|
||||
@@ -59,14 +58,6 @@ const createPayment = async (req, res) => {
|
||||
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
|
||||
if (isDonation && !eventId) {
|
||||
res.status(400);
|
||||
@@ -169,8 +160,7 @@ const createPayment = async (req, res) => {
|
||||
registrationId: null,
|
||||
eventId: registration.eventId,
|
||||
isDonation: true,
|
||||
createdAt: paidAtDate || undefined,
|
||||
...feeFor(requestedAmount)
|
||||
createdAt: paidAtDate || undefined
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
@@ -188,8 +178,7 @@ const createPayment = async (req, res) => {
|
||||
registrationId,
|
||||
eventId: registrationEventId || eventId || null,
|
||||
isDonation: false,
|
||||
createdAt: paidAtDate || undefined,
|
||||
...feeFor(applyAmount)
|
||||
createdAt: paidAtDate || undefined
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
@@ -210,8 +199,7 @@ const createPayment = async (req, res) => {
|
||||
eventId: registration.eventId,
|
||||
isDonation: true,
|
||||
originalPaymentId: payment.id,
|
||||
createdAt: paidAtDate || undefined,
|
||||
...feeFor(excess)
|
||||
createdAt: paidAtDate || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -227,8 +215,7 @@ const createPayment = async (req, res) => {
|
||||
registrationId: registrationId || null,
|
||||
eventId: registrationEventId || eventId || null,
|
||||
isDonation: isDonation || false,
|
||||
createdAt: paidAtDate || undefined,
|
||||
...feeFor(parseFloat(amount))
|
||||
createdAt: paidAtDate || undefined
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
|
||||
@@ -764,32 +764,66 @@ const createManualRegistration = async (req, res) => {
|
||||
? prefFromBody
|
||||
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
||||
|
||||
// Always search by email AND/OR phone regardless of guestOnly
|
||||
// Also try the alternate format (27xxx ↔ 0xxx) so both representations match
|
||||
// Always search by email AND/OR phone regardless of guestOnly.
|
||||
// Resolve each channel independently (rather than a single findFirst with an OR
|
||||
// across both) so that an email belonging to one account and a phone number
|
||||
// belonging to a *different* account can never be silently collapsed into
|
||||
// whichever record happens to match first — that would let a registration
|
||||
// hijack or corrupt someone else's account. Also try the alternate phone
|
||||
// format (27xxx ↔ 0xxx) so both representations match.
|
||||
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
||||
const searchClauses = [
|
||||
...(hasValidEmail ? [{ email: user.email }] : []),
|
||||
...(phone ? [{ phoneNumber: phone }] : []),
|
||||
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
|
||||
];
|
||||
const existingUser = searchClauses.length > 0
|
||||
? await prisma.user.findFirst({ where: { OR: searchClauses } })
|
||||
const emailUser = hasValidEmail
|
||||
? await prisma.user.findUnique({ where: { email: user.email } })
|
||||
: null;
|
||||
const phoneUser = phone
|
||||
? await prisma.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } })
|
||||
: null;
|
||||
|
||||
if (emailUser && phoneUser && emailUser.id !== phoneUser.id) {
|
||||
res.status(409);
|
||||
throw new Error(
|
||||
`This email and phone number belong to two different existing accounts (${emailUser.name} vs ${phoneUser.name}). Please verify the visitor's details before registering.`
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = emailUser || phoneUser || null;
|
||||
|
||||
if (existingUser) {
|
||||
userId = existingUser.id;
|
||||
const updateData = {};
|
||||
// Update preference only when the caller explicitly specified one
|
||||
if (prefFromBody && validPrefs.includes(prefFromBody)) {
|
||||
updateData.notificationPreference = prefFromBody;
|
||||
// The kiosk shows the operator a diff of name/email/phone against the matched
|
||||
// account and requires explicit confirmation before submitting, so any
|
||||
// difference reaching this point is an already-confirmed correction — apply
|
||||
// it as a full overwrite rather than only filling in blanks.
|
||||
if (user.name && user.name.trim() && user.name.trim() !== existingUser.name) {
|
||||
updateData.name = user.name.trim();
|
||||
}
|
||||
// Fill in a missing contact channel with the newly supplied value, without overwriting an existing one
|
||||
if (phone && !existingUser.phoneNumber) {
|
||||
if (phone && phone !== existingUser.phoneNumber) {
|
||||
updateData.phoneNumber = phone;
|
||||
}
|
||||
if (hasValidEmail && existingUser.email !== user.email && existingUser.email.endsWith('@guest.local')) {
|
||||
if (hasValidEmail && existingUser.email !== user.email) {
|
||||
updateData.email = user.email;
|
||||
}
|
||||
|
||||
// Update preference only when the caller explicitly specified one, but validate it
|
||||
// against the contact info that will actually be on the account after this update —
|
||||
// a stale "whatsapp"/"both" preference must not survive a phone number being
|
||||
// removed, nor "email" survive an email being cleared in favor of a real phone.
|
||||
if (prefFromBody && validPrefs.includes(prefFromBody)) {
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const finalPhone = updateData.phoneNumber !== undefined ? updateData.phoneNumber : existingUser.phoneNumber;
|
||||
const finalEmail = updateData.email !== undefined ? updateData.email : existingUser.email;
|
||||
const finalEmailValid = !!(finalEmail && !finalEmail.endsWith('@guest.local'));
|
||||
let candidatePref = prefFromBody;
|
||||
if ((candidatePref === 'whatsapp' || candidatePref === 'both') && !isValidZAPhone(finalPhone)) {
|
||||
candidatePref = finalEmailValid ? 'email' : candidatePref;
|
||||
}
|
||||
if (candidatePref === 'email' && !finalEmailValid && isValidZAPhone(finalPhone)) {
|
||||
candidatePref = 'whatsapp';
|
||||
}
|
||||
updateData.notificationPreference = candidatePref;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -482,13 +482,28 @@ const checkUserExists = async (req, res) => {
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: searchClauses },
|
||||
select: { email: true, phoneNumber: true },
|
||||
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
|
||||
});
|
||||
|
||||
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
|
||||
const hasPhone = !!existingUser?.phoneNumber;
|
||||
|
||||
res.json({
|
||||
exists: !!existingUser,
|
||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
||||
hasPhone: !!existingUser?.phoneNumber,
|
||||
hasEmail,
|
||||
hasPhone,
|
||||
// Safe-to-display fields only, for autofilling a lookup form — never the password.
|
||||
// Guest placeholder emails are withheld the same way hasEmail already treats them.
|
||||
// `id` lets the kiosk tell two different matched accounts apart (e.g. when the
|
||||
// typed email and phone number resolve to different people) — it's never shown,
|
||||
// only compared client-side, and this endpoint is already Private/Supervisor.
|
||||
user: existingUser ? {
|
||||
id: existingUser.id,
|
||||
name: existingUser.name,
|
||||
email: hasEmail ? existingUser.email : null,
|
||||
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
|
||||
notificationPreference: existingUser.notificationPreference,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
@@ -4,8 +4,6 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
const { bucketForMethod } = require('../utils/cashupUtils');
|
||||
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
|
||||
|
||||
// @desc Handle Yoco webhook events
|
||||
// @route POST /api/webhooks/yoco
|
||||
@@ -315,25 +313,18 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
} 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
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), // Generate a UUID for the payment
|
||||
amount: grossAmount, // Convert cents to your currency unit
|
||||
amount: amount / 100, // Convert cents to your currency unit
|
||||
method: method.type, // The type of payment from the webhook
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
externalId: yocoPaymentId,
|
||||
registrationId: registration?.id || null,
|
||||
userId: resolvedUserId,
|
||||
eventId: registration?.eventId || metadata?.eventId || null,
|
||||
isDonation: !registration?.id,
|
||||
...feeSnapshot
|
||||
isDonation: !registration?.id
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
|
||||
@@ -104,15 +104,6 @@ async function computeEventFinancials(eventId) {
|
||||
}
|
||||
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)
|
||||
const costBreakdown = costs.map(c => {
|
||||
const total = c.costType === 'per_item'
|
||||
@@ -135,13 +126,6 @@ async function computeEventFinancials(eventId) {
|
||||
const expectedCashByMethod = emptyByMethod();
|
||||
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
|
||||
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
|
||||
const reconciled = latestReconciled ? {
|
||||
@@ -155,8 +139,7 @@ async function computeEventFinancials(eventId) {
|
||||
for (const m of ALL_METHODS) {
|
||||
const line = latestReconciled.lines.find(l => l.method === m);
|
||||
out[m] = {
|
||||
expected: line ? line.expectedAmount : expectedInBankByMethod[m],
|
||||
fee: line && line.feeAmount != null ? line.feeAmount : feesByMethod[m],
|
||||
expected: line ? line.expectedAmount : expectedCashByMethod[m],
|
||||
actual: line && line.actualAmount != null ? line.actualAmount : null,
|
||||
variance: line && line.variance != null ? line.variance : null,
|
||||
notes: line ? line.notes : null,
|
||||
@@ -178,7 +161,6 @@ async function computeEventFinancials(eventId) {
|
||||
}
|
||||
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
||||
const netProfit = effectiveTotalRevenue - totalCosts;
|
||||
const netProfitAfterFees = netProfit - totalFees;
|
||||
|
||||
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
|
||||
const salesByOptionMap = {};
|
||||
@@ -201,14 +183,10 @@ async function computeEventFinancials(eventId) {
|
||||
untaggedCostsTotal,
|
||||
totalCosts,
|
||||
expectedCashByMethod,
|
||||
expectedInBankByMethod,
|
||||
reconciled,
|
||||
effectiveGrossIncomeByMethod,
|
||||
effectiveTotalRevenue,
|
||||
netProfit,
|
||||
feesByMethod,
|
||||
totalFees,
|
||||
netProfitAfterFees,
|
||||
unallocatedDonations,
|
||||
unallocatedDonationsTotal,
|
||||
totalDonations,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 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,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -372,9 +372,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
<th className="py-1">Method</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">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">Expected cash</th>
|
||||
<th className="py-1 text-right">Actual</th>
|
||||
<th className="py-1 text-right">Variance</th>
|
||||
<th className="py-1">Notes</th>
|
||||
@@ -383,15 +381,12 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
<tbody>
|
||||
{METHODS.map(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
const expected = data.expectedInBankByMethod[m];
|
||||
return (
|
||||
<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 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.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">
|
||||
{isClosed ? (
|
||||
money(r?.actual ?? null)
|
||||
@@ -405,8 +400,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
{isClosed
|
||||
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
||||
: (m === "cash"
|
||||
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - expected) : "—")
|
||||
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - expected) : "—"))}
|
||||
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—")
|
||||
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
{isClosed ? (r?.notes || "—") : (
|
||||
@@ -452,7 +447,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-4 gap-3 text-sm">
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
@@ -462,12 +457,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Yoco fees</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 className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "payments";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "organisation", label: "Organisation" },
|
||||
@@ -15,19 +15,8 @@ const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "email", label: "Email" },
|
||||
{ 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 =
|
||||
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
|
||||
|
||||
@@ -123,11 +112,6 @@ export default function SiteSettingsPage() {
|
||||
const [legalWebsiteUrl, setLegalWebsiteUrl] = 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 ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -154,9 +138,6 @@ export default function SiteSettingsPage() {
|
||||
setLegalIoEmail(s.legal_io_email || "");
|
||||
setLegalWebsiteUrl(s.legal_website_url || "");
|
||||
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" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
@@ -258,19 +239,6 @@ export default function SiteSettingsPage() {
|
||||
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 () => {
|
||||
if (!token) return;
|
||||
setSmtpTesting(true);
|
||||
@@ -537,44 +505,6 @@ export default function SiteSettingsPage() {
|
||||
<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's real rate varies by
|
||||
monthly volume and card type, which this app doesn'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>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,43 @@ function fmtCurrency(val: number) {
|
||||
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function prefLabel(pref: "email" | "whatsapp" | "both") {
|
||||
return pref === "whatsapp" ? "WhatsApp" : pref === "both" ? "Email & WhatsApp" : "Email";
|
||||
}
|
||||
|
||||
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
|
||||
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
|
||||
function looksLikePhone(s: string): boolean {
|
||||
const digits = s.replace(/\D/g, "");
|
||||
return digits.length >= 9 && digits.length <= 11;
|
||||
}
|
||||
|
||||
// Show/hide toggle rendered inside a password input — absolutely positioned on its
|
||||
// right edge, so callers must wrap the input in a `relative` container and add
|
||||
// enough right padding (`pr-12`) for it not to overlap the typed text.
|
||||
function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
|
||||
aria-label={shown ? "Hide password" : "Show password"}
|
||||
>
|
||||
{shown ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c1.563 0 3.042-.34 4.377-.955M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
||||
export default function SelfServicePage() {
|
||||
const [screen, setScreen] = useState<Screen>("setup");
|
||||
@@ -92,6 +129,7 @@ export default function SelfServicePage() {
|
||||
// ── Setup state ─────────────────────────────────────────────────
|
||||
const [setupEmail, setSetupEmail] = useState("");
|
||||
const [setupPassword, setSetupPassword] = useState("");
|
||||
const [showSetupPassword, setShowSetupPassword] = useState(false);
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||
@@ -102,6 +140,7 @@ export default function SelfServicePage() {
|
||||
// ── Change-event modal ───────────────────────────────────────────
|
||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
||||
const [changePassword, setChangePassword] = useState("");
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const [changeError, setChangeError] = useState<string | null>(null);
|
||||
const [changeLoading, setChangeLoading] = useState(false);
|
||||
|
||||
@@ -112,9 +151,23 @@ export default function SelfServicePage() {
|
||||
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [createAccount, setCreateAccount] = useState(false);
|
||||
const [visitorPassword, setVisitorPassword] = useState("");
|
||||
const [accountExists, setAccountExists] = useState(false);
|
||||
const [checkingAccount, setCheckingAccount] = useState(false);
|
||||
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
// ── Account lookup (automatic, triggered when email/phone is entered) ──
|
||||
// Email and phone are resolved to an existing account independently. If they
|
||||
// resolve to the SAME account, that account is "matched". If they resolve to
|
||||
// two DIFFERENT accounts, that's a conflict — surfaced to the operator instead
|
||||
// of silently overwriting one field's details with the other's account.
|
||||
type MatchedAccount = { id: string; name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" };
|
||||
const [emailMatch, setEmailMatch] = useState<MatchedAccount | null>(null);
|
||||
const [phoneMatch, setPhoneMatch] = useState<MatchedAccount | null>(null);
|
||||
const accountConflict = !!(emailMatch && phoneMatch && emailMatch.id !== phoneMatch.id);
|
||||
const matchedAccount = accountConflict ? null : (emailMatch || phoneMatch);
|
||||
const accountExists = !!matchedAccount;
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
|
||||
const [showUpdateConfirm, setShowUpdateConfirm] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
@@ -175,12 +228,39 @@ export default function SelfServicePage() {
|
||||
}, 0);
|
||||
}, [selectedEvent, quantities]);
|
||||
|
||||
// Differences between what's on file for the matched account and what's currently
|
||||
// typed in the form — shown to the operator for confirmation before anything is saved.
|
||||
const pendingChanges = useMemo(() => {
|
||||
if (!matchedAccount) return [];
|
||||
const changes: { field: string; from: string; to: string }[] = [];
|
||||
const name = visitorName.trim();
|
||||
const email = visitorEmail.trim();
|
||||
const phone = visitorPhone.trim();
|
||||
if (name && name !== matchedAccount.name) {
|
||||
changes.push({ field: "Name", from: matchedAccount.name, to: name });
|
||||
}
|
||||
if (email && email !== (matchedAccount.email || "")) {
|
||||
changes.push({ field: "Email", from: matchedAccount.email || "(none on file)", to: email });
|
||||
}
|
||||
if (phone && phone !== (matchedAccount.phoneNumber || "")) {
|
||||
changes.push({ field: "Phone", from: matchedAccount.phoneNumber || "(none on file)", to: phone });
|
||||
}
|
||||
if (notificationPref !== matchedAccount.notificationPreference) {
|
||||
changes.push({
|
||||
field: "Send tickets via",
|
||||
from: prefLabel(matchedAccount.notificationPreference),
|
||||
to: prefLabel(notificationPref),
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}, [matchedAccount, visitorName, visitorEmail, visitorPhone, notificationPref]);
|
||||
|
||||
// ─── Load events after supervisor login ─────────────────────────
|
||||
const loadEvents = useCallback(async (token: string) => {
|
||||
setEventsLoading(true);
|
||||
try {
|
||||
const data: KioskEvent[] = await apiFetch(
|
||||
"/api/events/all?includePast=false&includeInactive=false",
|
||||
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
|
||||
{ authToken: token }
|
||||
);
|
||||
setEvents(data);
|
||||
@@ -262,7 +342,9 @@ export default function SelfServicePage() {
|
||||
}
|
||||
|
||||
// ─── Visitor registration ────────────────────────────────────────
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
// Validates the form and, if the typed name/email/phone differ from the matched
|
||||
// account's details, shows a confirmation modal before anything is saved.
|
||||
function handleRegisterSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
|
||||
@@ -277,7 +359,18 @@ export default function SelfServicePage() {
|
||||
setFormError("No event selected.");
|
||||
return;
|
||||
}
|
||||
if (accountConflict) {
|
||||
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
|
||||
return;
|
||||
}
|
||||
if (pendingChanges.length > 0) {
|
||||
setShowUpdateConfirm(true);
|
||||
return;
|
||||
}
|
||||
handleRegister();
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
setFormLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
@@ -415,7 +508,12 @@ export default function SelfServicePage() {
|
||||
setNotificationPref("email");
|
||||
setCreateAccount(false);
|
||||
setVisitorPassword("");
|
||||
setAccountExists(false);
|
||||
setShowVisitorPassword(false);
|
||||
setEmailMatch(null);
|
||||
setPhoneMatch(null);
|
||||
appliedMatchIdRef.current = null;
|
||||
setShowUpdateConfirm(false);
|
||||
setLookupMessage(null);
|
||||
setFormError(null);
|
||||
setCurrentRegistrationId(null);
|
||||
setCurrentUserId(null);
|
||||
@@ -431,33 +529,67 @@ export default function SelfServicePage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Check whether an account already exists for the entered email/phone ──
|
||||
useEffect(() => {
|
||||
const email = visitorEmail.trim();
|
||||
const phone = visitorPhone.trim();
|
||||
if (!supervisorToken || (!email && !phone)) {
|
||||
setAccountExists(false);
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(async () => {
|
||||
setCheckingAccount(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (email) params.set("email", email);
|
||||
if (phone) params.set("phone", phone);
|
||||
const data = await apiFetch<{ exists: boolean }>(
|
||||
`/api/users/check-exists?${params.toString()}`,
|
||||
{ authToken: supervisorToken }
|
||||
);
|
||||
setAccountExists(!!data?.exists);
|
||||
} catch {
|
||||
setAccountExists(false);
|
||||
} finally {
|
||||
setCheckingAccount(false);
|
||||
// ─── Automatic account lookup — runs once the operator finishes entering the
|
||||
// email or phone field (on blur), never on keystroke. Matches exactly against
|
||||
// that one value and only ever returns that one matched account (or nothing) —
|
||||
// never a broader/fuzzy match. Email and phone are tracked as two independent
|
||||
// matches (emailMatch/phoneMatch, above) rather than being merged into a single
|
||||
// "last found account" — that's what previously let changing the phone number
|
||||
// silently pull in and overwrite the form with an unrelated account's details. ──
|
||||
async function performLookup(kind: "email" | "phone", rawValue: string) {
|
||||
const value = rawValue.trim();
|
||||
const setMatch = kind === "email" ? setEmailMatch : setPhoneMatch;
|
||||
if (!value || !supervisorToken) { setMatch(null); return; }
|
||||
if (kind === "email" ? !value.includes("@") : !looksLikePhone(value)) { setMatch(null); return; }
|
||||
|
||||
setLookupLoading(true);
|
||||
setLookupMessage(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (kind === "email") params.set("email", value);
|
||||
else params.set("phone", value);
|
||||
const data = await apiFetch<{
|
||||
exists: boolean;
|
||||
user?: MatchedAccount | null;
|
||||
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
|
||||
if (data?.exists && data.user) {
|
||||
setMatch(data.user);
|
||||
setLookupMessage("Account found — details filled in below.");
|
||||
} else {
|
||||
setMatch(null);
|
||||
setLookupMessage(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
}, [visitorEmail, visitorPhone, supervisorToken]);
|
||||
} catch {
|
||||
setLookupMessage("Lookup failed. Please try again.");
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmailBlur() {
|
||||
performLookup("email", visitorEmail);
|
||||
}
|
||||
|
||||
function handlePhoneBlur() {
|
||||
performLookup("phone", visitorPhone);
|
||||
}
|
||||
|
||||
// Autofill Name/Email/Phone/preference from the matched account — but only once per
|
||||
// distinct account id. Re-focusing elsewhere on the page (e.g. tapping a ticket
|
||||
// quantity button) blurs whatever field was last active and re-runs its lookup;
|
||||
// that returns the same account as a new object each time, and keying this off
|
||||
// object identity instead of id made it re-fire and stomp every field — including
|
||||
// ones the operator had already deliberately edited — back to the account's
|
||||
// original values on every unrelated tap.
|
||||
const appliedMatchIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!matchedAccount || appliedMatchIdRef.current === matchedAccount.id) return;
|
||||
appliedMatchIdRef.current = matchedAccount.id;
|
||||
setVisitorName(matchedAccount.name);
|
||||
if (matchedAccount.email) setVisitorEmail(matchedAccount.email);
|
||||
if (matchedAccount.phoneNumber) setVisitorPhone(matchedAccount.phoneNumber);
|
||||
setNotificationPref(matchedAccount.notificationPreference);
|
||||
}, [matchedAccount]);
|
||||
|
||||
// Existing accounts are linked automatically — never show the "create account" toggle for them
|
||||
useEffect(() => {
|
||||
@@ -494,15 +626,18 @@ export default function SelfServicePage() {
|
||||
<form onSubmit={handleChangeEvent} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={changePassword}
|
||||
onChange={(e) => setChangePassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Your password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showChangePassword ? "text" : "password"}
|
||||
value={changePassword}
|
||||
onChange={(e) => setChangePassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Your password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
@@ -551,15 +686,18 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSetupPassword ? "text" : "password"}
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
|
||||
<button
|
||||
@@ -628,18 +766,7 @@ export default function SelfServicePage() {
|
||||
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={visitorName}
|
||||
onChange={(e) => setVisitorName(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="John Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleRegisterSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address
|
||||
@@ -656,6 +783,7 @@ export default function SelfServicePage() {
|
||||
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
|
||||
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
|
||||
}}
|
||||
onBlur={handleEmailBlur}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
@@ -675,11 +803,40 @@ export default function SelfServicePage() {
|
||||
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
|
||||
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
|
||||
}}
|
||||
onBlur={handlePhoneBlur}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="+27 82 000 0000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lookupLoading && (
|
||||
<p className="text-sm text-gray-500 -mt-2">Checking for an existing account…</p>
|
||||
)}
|
||||
{!lookupLoading && accountConflict && emailMatch && phoneMatch && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm -mt-2">
|
||||
This email matches an existing account for <strong>{emailMatch.name}</strong>, but this phone number
|
||||
matches a different existing account for <strong>{phoneMatch.name}</strong>. Please check and correct
|
||||
one of these fields before continuing.
|
||||
</div>
|
||||
)}
|
||||
{!lookupLoading && !accountConflict && lookupMessage && (
|
||||
<p className={`text-sm -mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
|
||||
{lookupMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={visitorName}
|
||||
onChange={(e) => setVisitorName(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="John Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preference selector — only shown when both channels are available */}
|
||||
{visitorEmail.trim() && visitorPhone.trim() && (
|
||||
<div>
|
||||
@@ -808,22 +965,23 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Create an account</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Save your details for future events</p>
|
||||
</div>
|
||||
</label>
|
||||
{createAccount && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Min. 6 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showVisitorPassword ? "text" : "password"}
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Min. 6 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordToggleButton shown={showVisitorPassword} onToggle={() => setShowVisitorPassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -837,7 +995,7 @@ export default function SelfServicePage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
disabled={formLoading || accountConflict}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
|
||||
@@ -847,6 +1005,43 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
|
||||
{showUpdateConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
These details differ from what's on file for this account. Confirm to update them.
|
||||
</p>
|
||||
<div className="space-y-3 mb-6">
|
||||
{pendingChanges.map((c) => (
|
||||
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
|
||||
<p className="text-sm text-gray-400 line-through">{c.from}</p>
|
||||
<p className="text-sm text-green-700 font-medium">{c.to}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateConfirm(false)}
|
||||
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-3 text-base font-medium hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
|
||||
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
|
||||
>
|
||||
Confirm & Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Event Form Screen ──────────────────────────────────────── */}
|
||||
{screen === "eventform" && selectedEvent?.form && (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
|
||||
@@ -473,26 +473,11 @@ export default function ReportsV2() {
|
||||
return map;
|
||||
}, [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(() => {
|
||||
if (report !== "revenueDetailed") return [] as any[];
|
||||
const df = payFrom ? new Date(payFrom).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; fee: number; netAfterFee: number };
|
||||
type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number };
|
||||
const rows: Row[] = [];
|
||||
// Iterate registrations to include those with zero payments and show each once
|
||||
Object.keys(registrationsByEvent).forEach(evId => {
|
||||
@@ -504,7 +489,6 @@ export default function ReportsV2() {
|
||||
if (dt && created && created > dt) return;
|
||||
const totalPaid = paidByReg.get(r.id) || 0;
|
||||
const outstanding = outstandingByReg.get(r.id) || 0;
|
||||
const fee = feeByReg.get(r.id) || 0;
|
||||
rows.push({
|
||||
eventId: evId,
|
||||
eventTitle: evTitle,
|
||||
@@ -514,14 +498,12 @@ export default function ReportsV2() {
|
||||
status: r.status,
|
||||
registrationId: r.id,
|
||||
outstanding,
|
||||
fee,
|
||||
netAfterFee: totalPaid - fee,
|
||||
});
|
||||
});
|
||||
});
|
||||
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '')));
|
||||
return rows;
|
||||
}, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg, feeByReg]);
|
||||
}, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg]);
|
||||
|
||||
// Derived for attendees (layered) for a single event
|
||||
const attendeesLayer = useMemo(() => {
|
||||
@@ -649,7 +631,6 @@ export default function ReportsV2() {
|
||||
totalPaid: paidByReg.get(r.id) || 0,
|
||||
outstanding: outstandingByReg.get(r.id) || 0,
|
||||
donations: donationsByUser.get(r.user?.id || r.userId) || 0,
|
||||
fee: feeByReg.get(r.id) || 0,
|
||||
__prices: {} as Record<string, number> // 👈 hidden helper
|
||||
};
|
||||
|
||||
@@ -679,7 +660,7 @@ export default function ReportsV2() {
|
||||
|
||||
return rows;
|
||||
|
||||
}, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg, feeByReg]);
|
||||
}, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg]);
|
||||
|
||||
//Master Report Totals
|
||||
const masterTotals = useMemo(() => {
|
||||
@@ -690,7 +671,6 @@ export default function ReportsV2() {
|
||||
totalPaid: 0,
|
||||
outstanding: 0,
|
||||
donations: 0,
|
||||
fee: 0,
|
||||
};
|
||||
|
||||
// 👇 initialise dynamic option totals
|
||||
@@ -703,7 +683,6 @@ export default function ReportsV2() {
|
||||
totals.orderTotal += row.orderTotal;
|
||||
totals.totalPaid += row.totalPaid;
|
||||
totals.outstanding += row.outstanding;
|
||||
totals.fee += row.fee || 0;
|
||||
|
||||
masterOptions.forEach(opt => {
|
||||
const qty = row[opt.name] || 0;
|
||||
@@ -782,8 +761,6 @@ export default function ReportsV2() {
|
||||
Email: r.userEmail || "",
|
||||
Status: r.status || "",
|
||||
TotalPaid: r.totalPaid,
|
||||
Fee: r.fee,
|
||||
NetAfterFee: r.netAfterFee,
|
||||
Outstanding: r.outstanding,
|
||||
RegistrationId: r.registrationId
|
||||
})));
|
||||
@@ -816,7 +793,7 @@ export default function ReportsV2() {
|
||||
return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows);
|
||||
}
|
||||
if (report === "cashup") {
|
||||
const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance"];
|
||||
const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedCash", "Actual", "Variance"];
|
||||
const rows: any[] = [];
|
||||
Object.keys(financialsByEvent).forEach(evId => {
|
||||
const f = financialsByEvent[evId];
|
||||
@@ -825,15 +802,15 @@ export default function ReportsV2() {
|
||||
const title = ev?.title || evId;
|
||||
METHOD_KEYS.forEach(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, ExpectedBeforeFees: f.expectedCashByMethod?.[m] || 0, YocoFee: f.feesByMethod?.[m] || 0, ExpectedAfterFees: f.expectedInBankByMethod?.[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, ExpectedCash: f.expectedCashByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" });
|
||||
if (m === "cash") {
|
||||
(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: "" }));
|
||||
(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: "" }));
|
||||
}
|
||||
});
|
||||
if ((f.untaggedCostsTotal || 0) > 0) {
|
||||
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: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedCash: "", Actual: "", Variance: "" });
|
||||
}
|
||||
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" });
|
||||
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" });
|
||||
});
|
||||
return downloadCsv(`cashup_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
|
||||
}
|
||||
@@ -856,8 +833,7 @@ 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) }));
|
||||
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 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 });
|
||||
rows.push({ Event: title, Section: "Net profit", Detail: "", Amount: f.netProfit || 0 });
|
||||
});
|
||||
return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
|
||||
}
|
||||
@@ -865,20 +841,20 @@ export default function ReportsV2() {
|
||||
const rows = Object.keys(financialsByEvent).map(evId => {
|
||||
const f = financialsByEvent[evId];
|
||||
const ev = filteredEvents.find(e => e.id === evId);
|
||||
return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, Fees: f?.totalFees || 0, NetProfit: f?.netProfitAfterFees || 0 };
|
||||
return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, NetProfit: f?.netProfit || 0 };
|
||||
});
|
||||
return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows);
|
||||
}
|
||||
if (report === "cashupAudit") {
|
||||
const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"];
|
||||
const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "Expected", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"];
|
||||
const rows: 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() };
|
||||
if (r.action === "reopened") {
|
||||
rows.push({ ...base, Method: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" });
|
||||
rows.push({ ...base, Method: "", Expected: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" });
|
||||
} else {
|
||||
(r.lines || []).forEach((l: any) => rows.push({
|
||||
...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 ?? "",
|
||||
...base, Method: METHOD_LABEL[l.method] || l.method, Expected: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "",
|
||||
DeltaVsPreviousClose: r._deltaVsPrevious?.[l.method] ?? "", DonationsCountedAsProfit: r.unallocatedDonationsTotal || 0, Notes: l.notes || r.notes || ""
|
||||
}));
|
||||
}
|
||||
@@ -898,7 +874,6 @@ export default function ReportsV2() {
|
||||
|
||||
OrderTotal: r.orderTotal,
|
||||
Paid: r.totalPaid,
|
||||
Fee: r.fee,
|
||||
Outstanding: r.outstanding,
|
||||
Donations: r.donations
|
||||
}));
|
||||
@@ -916,7 +891,6 @@ export default function ReportsV2() {
|
||||
|
||||
OrderTotal: masterTotals?.orderTotal ?? 0,
|
||||
Paid: masterTotals?.totalPaid ?? 0,
|
||||
Fee: masterTotals?.fee ?? 0,
|
||||
Outstanding: masterTotals?.outstanding ?? 0,
|
||||
Donations: masterTotals?.donations ?? 0,
|
||||
});
|
||||
@@ -1032,8 +1006,6 @@ export default function ReportsV2() {
|
||||
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 || "",
|
||||
]);
|
||||
@@ -1041,7 +1013,7 @@ export default function ReportsV2() {
|
||||
title: 'Revenue Detailed',
|
||||
kind: 'table',
|
||||
orientation: 'landscape',
|
||||
table: { columns: ["Event","Name","Email","Status","Total paid","Fee","Net after fee","Outstanding","RegistrationId"], rows }
|
||||
table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1070,15 +1042,15 @@ export default function ReportsV2() {
|
||||
const title = ev?.title || evId;
|
||||
METHOD_KEYS.forEach(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)), 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)) : ""]);
|
||||
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)) : ""]);
|
||||
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)), "", "", "", "", ""]);
|
||||
rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).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)), ""]);
|
||||
});
|
||||
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 } });
|
||||
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 } });
|
||||
return;
|
||||
}
|
||||
if (report === "financeReport") {
|
||||
@@ -1101,8 +1073,7 @@ export default function ReportsV2() {
|
||||
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))]));
|
||||
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 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))]);
|
||||
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
|
||||
});
|
||||
await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } });
|
||||
return;
|
||||
@@ -1111,9 +1082,9 @@ export default function ReportsV2() {
|
||||
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(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?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))];
|
||||
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))];
|
||||
});
|
||||
await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Fees", "Net profit (after fees)"], rows } });
|
||||
await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows } });
|
||||
return;
|
||||
}
|
||||
if (report === "cashupAudit") {
|
||||
@@ -1121,13 +1092,13 @@ export default function ReportsV2() {
|
||||
auditRows.forEach((r: any) => {
|
||||
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
|
||||
if (r.action === "reopened") {
|
||||
rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]);
|
||||
rows.push([...base, "", "", "", "", "", r.notes || ""]);
|
||||
} else {
|
||||
(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)}`]);
|
||||
(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 || ""]));
|
||||
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 (before fees)", "Yoco fee", "Expected (after fees)", "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", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } });
|
||||
return;
|
||||
}
|
||||
if (report === "masterOrders") {
|
||||
@@ -1140,7 +1111,6 @@ export default function ReportsV2() {
|
||||
|
||||
Number(r.orderTotal.toFixed(2)),
|
||||
Number(r.totalPaid.toFixed(2)),
|
||||
Number((r.fee || 0).toFixed(2)),
|
||||
Number(r.outstanding.toFixed(2))
|
||||
]);
|
||||
|
||||
@@ -1154,7 +1124,6 @@ export default function ReportsV2() {
|
||||
|
||||
Number((masterTotals?.orderTotal ?? 0).toFixed(2)),
|
||||
Number((masterTotals?.totalPaid ?? 0).toFixed(2)),
|
||||
Number((masterTotals?.fee ?? 0).toFixed(2)),
|
||||
Number((masterTotals?.outstanding ?? 0).toFixed(2))
|
||||
]);
|
||||
|
||||
@@ -1168,7 +1137,6 @@ export default function ReportsV2() {
|
||||
`R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}`
|
||||
),
|
||||
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
@@ -1188,7 +1156,6 @@ export default function ReportsV2() {
|
||||
|
||||
"Order Total",
|
||||
"Paid",
|
||||
"Fee",
|
||||
"Outstanding"
|
||||
],
|
||||
rows: bodyRows
|
||||
@@ -1250,8 +1217,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 });
|
||||
} 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.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","Fee","Net after fee","Outstanding","RegistrationId"], rows }, subject, body });
|
||||
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 || "" ]);
|
||||
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 });
|
||||
} else if (report === 'regStatus') {
|
||||
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]); });
|
||||
@@ -1263,13 +1230,13 @@ export default function ReportsV2() {
|
||||
const title = ev?.title || evId;
|
||||
METHOD_KEYS.forEach(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)), 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)), ""]));
|
||||
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)) : ""]);
|
||||
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)), "", "", "", "", ""]);
|
||||
rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).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)), ""]);
|
||||
});
|
||||
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 });
|
||||
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 });
|
||||
} else if (report === 'financeReport') {
|
||||
const rows: (string | number)[][] = [];
|
||||
Object.keys(financialsByEvent).forEach(evId => {
|
||||
@@ -1288,27 +1255,26 @@ export default function ReportsV2() {
|
||||
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))]));
|
||||
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 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))]);
|
||||
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
|
||||
});
|
||||
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') {
|
||||
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(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?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))];
|
||||
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))];
|
||||
});
|
||||
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 });
|
||||
await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows }, subject, body });
|
||||
} else if (report === 'cashupAudit') {
|
||||
const rows: (string | number)[][] = [];
|
||||
auditRows.forEach((r: any) => {
|
||||
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
|
||||
if (r.action === "reopened") {
|
||||
rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]);
|
||||
rows.push([...base, "", "", "", "", "", r.notes || ""]);
|
||||
} else {
|
||||
(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 || ""]));
|
||||
(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 || ""]));
|
||||
}
|
||||
});
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
alert('Email sent with PDF attachment');
|
||||
} catch (e: any) {
|
||||
@@ -1744,8 +1710,6 @@ export default function ReportsV2() {
|
||||
<th className="text-left">Email</th>
|
||||
<th className="text-left">Status</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">Registration</th>
|
||||
</tr>
|
||||
@@ -1757,8 +1721,6 @@ export default function ReportsV2() {
|
||||
<td>{r.userEmail || ''}</td>
|
||||
<td className="capitalize">{r.status || ''}</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 className="font-mono text-xs">{r.registrationId || ''}</td>
|
||||
</tr>
|
||||
@@ -1861,7 +1823,6 @@ export default function ReportsV2() {
|
||||
<span className="font-medium">Totals:</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">Fee: R {(masterTotals?.fee ?? 0).toFixed(2)}</span>
|
||||
<span className="ml-3">Outstanding: R {masterTotals?.outstanding.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1879,7 +1840,6 @@ export default function ReportsV2() {
|
||||
|
||||
<th>Order Total</th>
|
||||
<th>Paid</th>
|
||||
<th>Fee</th>
|
||||
<th>Outstanding</th>
|
||||
<th>Donations</th>
|
||||
</tr>
|
||||
@@ -1899,7 +1859,6 @@ export default function ReportsV2() {
|
||||
|
||||
<td>R {r.orderTotal.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.donations.toFixed(2)}</td>
|
||||
</tr>
|
||||
@@ -1915,7 +1874,6 @@ export default function ReportsV2() {
|
||||
|
||||
<td>R {masterTotals?.orderTotal.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?.donations.toFixed(2)}</td>
|
||||
</tr>
|
||||
@@ -1928,7 +1886,7 @@ export default function ReportsV2() {
|
||||
</td>
|
||||
))}
|
||||
|
||||
<td colSpan={5}></td>
|
||||
<td colSpan={4}></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1954,7 +1912,7 @@ export default function ReportsV2() {
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<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 (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>
|
||||
<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>
|
||||
<tbody>
|
||||
{METHOD_KEYS.map(m => {
|
||||
const r = f.reconciled?.byMethod?.[m];
|
||||
@@ -1965,15 +1923,13 @@ export default function ReportsV2() {
|
||||
<td>R {(f.paymentsByMethod?.[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.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 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>
|
||||
{m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => (
|
||||
<tr key={d.id} className="text-xs text-gray-500">
|
||||
<td className="pl-4">{denomLabel(d.value)} × {d.count}</td>
|
||||
<td colSpan={6}></td>
|
||||
<td colSpan={4}></td>
|
||||
<td>R {(d.value * d.count).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -2055,8 +2011,7 @@ export default function ReportsV2() {
|
||||
</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"><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 className="text-sm font-semibold flex items-center justify-between"><span>Net profit</span><span>R {(f.netProfit || 0).toFixed(2)}</span></div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -2069,7 +2024,7 @@ export default function ReportsV2() {
|
||||
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
|
||||
) : (
|
||||
<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">Fees</th><th className="text-left">Net profit (after fees)</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">Net profit</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.keys(financialsByEvent).map(evId => {
|
||||
const f = financialsByEvent[evId];
|
||||
@@ -2079,8 +2034,7 @@ export default function ReportsV2() {
|
||||
<td>{ev?.title || evId}</td>
|
||||
<td>R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td>
|
||||
<td>R {(f?.totalCosts || 0).toFixed(2)}</td>
|
||||
<td>R {(f?.totalFees || 0).toFixed(2)}</td>
|
||||
<td className="font-medium">R {(f?.netProfitAfterFees || 0).toFixed(2)}</td>
|
||||
<td className="font-medium">R {(f?.netProfit || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -2109,13 +2063,11 @@ export default function ReportsV2() {
|
||||
{(r.action === "closed" || r.action === "quick_closed") && (
|
||||
<div className="overflow-auto mt-2">
|
||||
<table className="min-w-[560px] text-sm">
|
||||
<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>
|
||||
<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>
|
||||
<tbody>
|
||||
{(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) => (
|
||||
{(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, actualAmount: null, variance: null }))).map((l: any) => (
|
||||
<tr key={l.method}>
|
||||
<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.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>
|
||||
|
||||
@@ -76,7 +76,6 @@ export type EventCashupLine = {
|
||||
cashupId: string;
|
||||
method: CashupMethod;
|
||||
expectedAmount: number;
|
||||
feeAmount?: number | null;
|
||||
actualAmount?: number | null;
|
||||
variance?: number | null;
|
||||
notes?: string | null;
|
||||
@@ -103,7 +102,6 @@ export type EventCashup = {
|
||||
|
||||
export type ReconciledByMethod = {
|
||||
expected: number;
|
||||
fee: number;
|
||||
actual: number | null;
|
||||
variance: number | null;
|
||||
notes?: string | null;
|
||||
@@ -135,14 +133,10 @@ export type EventFinancials = {
|
||||
untaggedCostsTotal: number;
|
||||
totalCosts: number;
|
||||
expectedCashByMethod: Record<CashupMethod, number>;
|
||||
expectedInBankByMethod: Record<CashupMethod, number>;
|
||||
reconciled: ReconciledSnapshot | null;
|
||||
effectiveGrossIncomeByMethod: Record<CashupMethod, number>;
|
||||
effectiveTotalRevenue: number;
|
||||
netProfit: number;
|
||||
feesByMethod: Record<CashupMethod, number>;
|
||||
totalFees: number;
|
||||
netProfitAfterFees: number;
|
||||
unallocatedDonations: Payment[];
|
||||
unallocatedDonationsTotal: number;
|
||||
totalDonations: number;
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -8,7 +8,8 @@
|
||||
"start:backend": "cd backend && npm run start",
|
||||
"start:frontend": "cd frontend && npm run start",
|
||||
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\""
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user