Compare commits

..
Author SHA1 Message Date
joshua 21176e1e0b Populate email/phone from an unmatched kiosk search query
When the account lookup finds no match, carry the typed query into whichever
of Email/Phone it resembles (email-shaped or phone-shaped) instead of
discarding it, so the operator doesn't have to retype it. Leaves both blank
if it matches neither shape, and resets Name/notification preference for a
fresh entry.
2026-07-28 16:05:41 +02:00
joshua b4cd140168 Replace as-you-type account check with an explicit search field
The kiosk previously ran a debounced background lookup on every keystroke
in the visitor Email/Phone fields, silently autofilling matches. Replaced
with a dedicated "Look up existing account" field that only searches when
the operator presses Enter or clicks Search, and matches exactly against
either email or phone (never both from one query, to avoid digits in an
email being misread as an unrelated phone number). Also simplified the
"account already exists" banner wording.
2026-07-28 14:35:28 +02:00
joshua 5167706d1b Update changelog for self-service kiosk fixes 2026-07-28 14:24:21 +02:00
joshua 047f61b627 Fix self-service kiosk: hide closed events and autofill known visitors
- GET /api/events/all gains an opt-in excludeClosed=true param, used only by
  the kiosk, so closed events no longer show as selectable there while other
  admin/supervisor screens that still need to see closed events are unaffected.
- GET /api/users/check-exists now also returns the matched account's name,
  email, phone, and notification preference (safe fields only). The kiosk's
  existing debounced lookup uses this to autofill whichever fields are still
  blank when a visitor enters an email or phone that matches an existing
  account, without overwriting anything already typed.
2026-07-28 14:21:47 +02:00
18 changed files with 185 additions and 342 deletions
+3 -5
View File
@@ -7,12 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added ### Fixed
- 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. - Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
- 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. - 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.
- Finance report, Profit report, Master Orders, and Revenue Detailed (including their CSV/PDF/email exports) now show the estimated Yoco fee and a "net after fees" figure alongside gross revenue, so the numbers reflect what actually lands in the bank.
- Cashup reconciliation (the per-event close-out screen, the Cashup report, and the Cashup Audit Trail) now shows three figures for the card method: expected before Yoco fees, the fee itself, and expected after fees — plus overall Yoco fees and net-profit-after-fees totals — so counts reconcile against what actually settles from Yoco rather than the gross card total.
## [1.3.0] - 2026-07-27 ## [1.3.0] - 2026-07-27
@@ -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;
-4
View File
@@ -221,9 +221,6 @@ model Payment {
externalId String? @unique externalId String? @unique
status String? status String?
originalPaymentId String? originalPaymentId String?
feeAmount Float?
feeRate Float?
feeChannel String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict) user User @relation(fields: [userId], references: [id], onDelete: Restrict)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull) registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@ -464,7 +461,6 @@ model EventCashupLine {
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade) cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
method String method String
expectedAmount Float @default(0) expectedAmount Float @default(0)
feeAmount Float?
actualAmount Float? actualAmount Float?
variance Float? variance Float?
notes String? notes String?
+2 -5
View File
@@ -53,9 +53,7 @@ const closeEvent = async (req, res) => {
? lines ? lines
.filter(l => l && ALL_METHODS.includes(l.method)) .filter(l => l && ALL_METHODS.includes(l.method))
.map(l => { .map(l => {
// expectedInBankByMethod nets out Yoco fees (only ever nonzero for 'card') so the const expected = financials.expectedCashByMethod[l.method] || 0;
// variance reconciles against what should actually land in the bank, not the gross figure.
const expected = financials.expectedInBankByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations) const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations ? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 })) .map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
@@ -68,7 +66,6 @@ const closeEvent = async (req, res) => {
id: uuidv4(), id: uuidv4(),
method: l.method, method: l.method,
expectedAmount: expected, expectedAmount: expected,
feeAmount: financials.feesByMethod[l.method] || 0,
actualAmount: actual, actualAmount: actual,
variance: actual !== null ? actual - expected : null, variance: actual !== null ? actual - expected : null,
notes: l.notes || null, notes: l.notes || null,
@@ -80,7 +77,7 @@ const closeEvent = async (req, res) => {
const totalActualRevenue = isFullCashup const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0) ? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
: null; : null;
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedInBankByMethod[m], 0); const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0);
const cashup = await prisma.eventCashup.create({ const cashup = await prisma.eventCashup.create({
data: { data: {
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
try { try {
const includePast = req.query.includePast === 'true'; const includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true'; const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {}; const where = {};
if (!includeInactive) where.isActive = true; if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() }; if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
+5 -18
View File
@@ -5,8 +5,7 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const axios = require('axios'); const axios = require('axios');
const { emailTickets } = require('./ticketController'); const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils'); const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, bucketForMethod } = require('../utils/cashupUtils'); const { assertEventOpen } = require('../utils/cashupUtils');
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
// @desc Create a new payment // @desc Create a new payment
// @route POST /api/payments // @route POST /api/payments
@@ -59,14 +58,6 @@ const createPayment = async (req, res) => {
throw new Error('Payment method is required'); throw new Error('Payment method is required');
} }
// Snapshot the Yoco fee estimate for this payment's method/amount at creation time —
// never recomputed later, so historical reports stay stable if settings change (see
// priceSnapshot in utils/pricing.js for the same philosophy). In-person is always
// church-borne: this app doesn't control the amount charged on a physical card machine.
const feeConfig = await getYocoFeeConfig();
const feeBucket = bucketForMethod(method);
const feeFor = (amt) => computeFeeSnapshot({ grossAmount: amt, channel: 'in_person', bucket: feeBucket, config: feeConfig });
// Validate required parameters based on isDonation flag // Validate required parameters based on isDonation flag
if (isDonation && !eventId) { if (isDonation && !eventId) {
res.status(400); res.status(400);
@@ -169,8 +160,7 @@ const createPayment = async (req, res) => {
registrationId: null, registrationId: null,
eventId: registration.eventId, eventId: registration.eventId,
isDonation: true, isDonation: true,
createdAt: paidAtDate || undefined, createdAt: paidAtDate || undefined
...feeFor(requestedAmount)
}, },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
@@ -188,8 +178,7 @@ const createPayment = async (req, res) => {
registrationId, registrationId,
eventId: registrationEventId || eventId || null, eventId: registrationEventId || eventId || null,
isDonation: false, isDonation: false,
createdAt: paidAtDate || undefined, createdAt: paidAtDate || undefined
...feeFor(applyAmount)
}, },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
@@ -210,8 +199,7 @@ const createPayment = async (req, res) => {
eventId: registration.eventId, eventId: registration.eventId,
isDonation: true, isDonation: true,
originalPaymentId: payment.id, originalPaymentId: payment.id,
createdAt: paidAtDate || undefined, createdAt: paidAtDate || undefined
...feeFor(excess)
} }
}); });
} }
@@ -227,8 +215,7 @@ const createPayment = async (req, res) => {
registrationId: registrationId || null, registrationId: registrationId || null,
eventId: registrationEventId || eventId || null, eventId: registrationEventId || eventId || null,
isDonation: isDonation || false, isDonation: isDonation || false,
createdAt: paidAtDate || undefined, createdAt: paidAtDate || undefined
...feeFor(parseFloat(amount))
}, },
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
+14 -3
View File
@@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses }, where: { OR: searchClauses },
select: { email: true, phoneNumber: true }, select: { name: true, email: true, phoneNumber: true, notificationPreference: true },
}); });
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
const hasPhone = !!existingUser?.phoneNumber;
res.json({ res.json({
exists: !!existingUser, exists: !!existingUser,
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'), hasEmail,
hasPhone: !!existingUser?.phoneNumber, 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.
user: existingUser ? {
name: existingUser.name,
email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
notificationPreference: existingUser.notificationPreference,
} : null,
}); });
} catch (error) { } catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+2 -11
View File
@@ -4,8 +4,6 @@ const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils'); const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController'); const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing'); const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { bucketForMethod } = require('../utils/cashupUtils');
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
// @desc Handle Yoco webhook events // @desc Handle Yoco webhook events
// @route POST /api/webhooks/yoco // @route POST /api/webhooks/yoco
@@ -315,25 +313,18 @@ const handlePaymentSucceeded = async (webhookData) => {
} catch (e) { /* ignore — payment will fail below if still null */ } } catch (e) { /* ignore — payment will fail below if still null */ }
} }
// Snapshot the Yoco fee for this payment at creation time, using current settings — never
// recomputed later, so historical reports stay stable if settings change.
const grossAmount = amount / 100;
const feeConfig = await getYocoFeeConfig();
const feeSnapshot = computeFeeSnapshot({ grossAmount, channel: 'online', bucket: bucketForMethod(method.type), config: feeConfig });
// Create payment record // Create payment record
const payment = await prisma.payment.create({ const payment = await prisma.payment.create({
data: { data: {
id: uuidv4(), // Generate a UUID for the payment id: uuidv4(), // Generate a UUID for the payment
amount: 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 method: method.type, // The type of payment from the webhook
status: status === 'succeeded' ? 'completed' : status, status: status === 'succeeded' ? 'completed' : status,
externalId: yocoPaymentId, externalId: yocoPaymentId,
registrationId: registration?.id || null, registrationId: registration?.id || null,
userId: resolvedUserId, userId: resolvedUserId,
eventId: registration?.eventId || metadata?.eventId || null, eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id, isDonation: !registration?.id
...feeSnapshot
}, },
include: { include: {
user: { user: {
+1 -23
View File
@@ -104,15 +104,6 @@ async function computeEventFinancials(eventId) {
} }
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0); const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
// Yoco processing fees, snapshotted per-payment at creation time (see utils/yocoFees.js).
// Always absorbed by the church — never passed on to the buyer.
const feesByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
if (p.feeAmount == null) continue;
feesByMethod[bucketForMethod(p.method)] += p.feeAmount;
}
const totalFees = ALL_METHODS.reduce((s, m) => s + feesByMethod[m], 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged) // Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => { const costBreakdown = costs.map(c => {
const total = c.costType === 'per_item' const total = c.costType === 'per_item'
@@ -135,13 +126,6 @@ async function computeEventFinancials(eventId) {
const expectedCashByMethod = emptyByMethod(); const expectedCashByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m]; for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
// What should actually land in the bank per method — same as expectedCashByMethod, minus
// Yoco's cut (only ever nonzero for 'card'). This is a reconciliation-only figure: it must
// NOT feed effectiveGrossIncomeByMethod/netProfit below, since totalFees is already subtracted
// there once via netProfitAfterFees — folding it into expectedCashByMethod too would double-count.
const expectedInBankByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedInBankByMethod[m] = expectedCashByMethod[m] - feesByMethod[m];
// Most recent reconciliation on record (if any) — the source of "actual" truth // Most recent reconciliation on record (if any) — the source of "actual" truth
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null; const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
const reconciled = latestReconciled ? { const reconciled = latestReconciled ? {
@@ -155,8 +139,7 @@ async function computeEventFinancials(eventId) {
for (const m of ALL_METHODS) { for (const m of ALL_METHODS) {
const line = latestReconciled.lines.find(l => l.method === m); const line = latestReconciled.lines.find(l => l.method === m);
out[m] = { out[m] = {
expected: line ? line.expectedAmount : expectedInBankByMethod[m], expected: line ? line.expectedAmount : expectedCashByMethod[m],
fee: line && line.feeAmount != null ? line.feeAmount : feesByMethod[m],
actual: line && line.actualAmount != null ? line.actualAmount : null, actual: line && line.actualAmount != null ? line.actualAmount : null,
variance: line && line.variance != null ? line.variance : null, variance: line && line.variance != null ? line.variance : null,
notes: line ? line.notes : null, notes: line ? line.notes : null,
@@ -178,7 +161,6 @@ async function computeEventFinancials(eventId) {
} }
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0); const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts; const netProfit = effectiveTotalRevenue - totalCosts;
const netProfitAfterFees = netProfit - totalFees;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown // What was actually sold, by ticket type — for the Finance report's income-stream breakdown
const salesByOptionMap = {}; const salesByOptionMap = {};
@@ -201,14 +183,10 @@ async function computeEventFinancials(eventId) {
untaggedCostsTotal, untaggedCostsTotal,
totalCosts, totalCosts,
expectedCashByMethod, expectedCashByMethod,
expectedInBankByMethod,
reconciled, reconciled,
effectiveGrossIncomeByMethod, effectiveGrossIncomeByMethod,
effectiveTotalRevenue, effectiveTotalRevenue,
netProfit, netProfit,
feesByMethod,
totalFees,
netProfitAfterFees,
unallocatedDonations, unallocatedDonations,
unallocatedDonationsTotal, unallocatedDonationsTotal,
totalDonations, totalDonations,
-35
View File
@@ -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 };
@@ -372,9 +372,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<th className="py-1">Method</th> <th className="py-1">Method</th>
<th className="py-1 text-right">Income</th> <th className="py-1 text-right">Income</th>
<th className="py-1 text-right">Costs from method</th> <th className="py-1 text-right">Costs from method</th>
<th className="py-1 text-right">Expected (before fees)</th> <th className="py-1 text-right">Expected cash</th>
<th className="py-1 text-right">Yoco fee</th>
<th className="py-1 text-right">Expected (after fees)</th>
<th className="py-1 text-right">Actual</th> <th className="py-1 text-right">Actual</th>
<th className="py-1 text-right">Variance</th> <th className="py-1 text-right">Variance</th>
<th className="py-1">Notes</th> <th className="py-1">Notes</th>
@@ -383,15 +381,12 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<tbody> <tbody>
{METHODS.map(m => { {METHODS.map(m => {
const r = reconciled?.byMethod?.[m]; const r = reconciled?.byMethod?.[m];
const expected = data.expectedInBankByMethod[m];
return ( return (
<tr key={m} className="border-b last:border-0 align-top"> <tr key={m} className="border-b last:border-0 align-top">
<td className="py-1.5">{METHOD_LABELS[m]}</td> <td className="py-1.5">{METHOD_LABELS[m]}</td>
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td> <td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td> <td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td> <td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.feesByMethod[m])}</td>
<td className="py-1.5 text-right">{money(expected)}</td>
<td className="py-1.5 text-right"> <td className="py-1.5 text-right">
{isClosed ? ( {isClosed ? (
money(r?.actual ?? null) money(r?.actual ?? null)
@@ -405,8 +400,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed {isClosed
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>) ? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
: (m === "cash" : (m === "cash"
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - expected) : "") ? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "")
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - expected) : ""))} : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : ""))}
</td> </td>
<td className="py-1.5"> <td className="py-1.5">
{isClosed ? (r?.notes || "") : ( {isClosed ? (r?.notes || "") : (
@@ -452,7 +447,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
)} )}
</div> </div>
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-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>
<div className="text-gray-500 text-xs">Donations counted as profit</div> <div className="text-gray-500 text-xs">Donations counted as profit</div>
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div> <div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
@@ -462,12 +457,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<div className="font-semibold">{money(data.totalCosts)}</div> <div className="font-semibold">{money(data.totalCosts)}</div>
</div> </div>
<div> <div>
<div className="text-gray-500 text-xs">Yoco fees</div> <div className="text-gray-500 text-xs">Net profit</div>
<div className="font-semibold">{money(data.totalFees)}</div> <div className="font-semibold">{money(data.netProfit)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Net profit (after fees)</div>
<div className="font-semibold">{money(data.netProfitAfterFees)}</div>
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@ import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState"; 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 }[] = [ const TABS: { id: TabId; label: string }[] = [
{ id: "organisation", label: "Organisation" }, { id: "organisation", label: "Organisation" },
@@ -15,19 +15,8 @@ const TABS: { id: TabId; label: string }[] = [
{ id: "notifications", label: "Notifications" }, { id: "notifications", label: "Notifications" },
{ id: "email", label: "Email" }, { id: "email", label: "Email" },
{ id: "legal", label: "Legal" }, { id: "legal", label: "Legal" },
{ id: "payments", label: "Payments" },
]; ];
// Default fee % pre-fill per Yoco plan — the "Up to R50k / Debit" rate, since debit is the
// most common consumer card type in SA and small churches likely stay in the lowest volume
// tier. Purely a UI convenience for the plan <select>; the actual % fields stay freely
// editable afterward so the admin can match their real blended rate.
const YOCO_PLAN_DEFAULTS: Record<"core" | "plus" | "pro", { inPerson: string; online: string }> = {
core: { inPerson: "2.30", online: "2.95" },
plus: { inPerson: "2.10", online: "2.75" },
pro: { inPerson: "1.95", online: "2.55" },
};
const inputCls = const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"; "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
@@ -123,11 +112,6 @@ export default function SiteSettingsPage() {
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState(""); const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState(""); const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
// ── Payments (Yoco fees) ──
const [yocoPlan, setYocoPlan] = useState<"core" | "plus" | "pro">("core");
const [feeInPersonPct, setFeeInPersonPct] = useState("");
const [feeOnlinePct, setFeeOnlinePct] = useState("");
// ── Load all settings once ──────────────────────────────────────────────── // ── Load all settings once ────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
@@ -154,9 +138,6 @@ export default function SiteSettingsPage() {
setLegalIoEmail(s.legal_io_email || ""); setLegalIoEmail(s.legal_io_email || "");
setLegalWebsiteUrl(s.legal_website_url || ""); setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || ""); setLegalEffectiveDate(s.legal_effective_date || "");
setYocoPlan((s.yoco_plan as "core" | "plus" | "pro") || "core");
setFeeInPersonPct(s.yoco_fee_in_person_pct || "");
setFeeOnlinePct(s.yoco_fee_online_pct || "");
}) })
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" })) .catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false)); .finally(() => setLoadingInitial(false));
@@ -258,19 +239,6 @@ export default function SiteSettingsPage() {
legal_effective_date: legalEffectiveDate.trim(), legal_effective_date: legalEffectiveDate.trim(),
}); });
const handleYocoPlanChange = (plan: "core" | "plus" | "pro") => {
setYocoPlan(plan);
const defaults = YOCO_PLAN_DEFAULTS[plan];
setFeeInPersonPct(defaults.inPerson);
setFeeOnlinePct(defaults.online);
};
const savePayments = () => save({
yoco_plan: yocoPlan,
yoco_fee_in_person_pct: feeInPersonPct.trim(),
yoco_fee_online_pct: feeOnlinePct.trim(),
});
const handleTestSmtp = async () => { const handleTestSmtp = async () => {
if (!token) return; if (!token) return;
setSmtpTesting(true); setSmtpTesting(true);
@@ -537,44 +505,6 @@ export default function SiteSettingsPage() {
<SaveBar saving={saving} onSave={saveLegal} result={result} /> <SaveBar saving={saving} onSave={saveLegal} result={result} />
</div> </div>
)} )}
{/* ── Payments (Yoco fees) ─────────────────────────────────────────────── */}
{activeTab === "payments" && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Yoco deducts a processing fee from every card transaction before it reaches your bank account.
Reports use the estimate below to show a more accurate payout. Yoco&apos;s real rate varies by
monthly volume and card type, which this app doesn&apos;t track pick your plan to pre-fill a
starting value, then adjust the percentages to match your actual bank statement.
</p>
<Field label="Yoco plan">
<select
className={inputCls}
value={yocoPlan}
onChange={e => handleYocoPlanChange(e.target.value as "core" | "plus" | "pro")}
>
<option value="core">Core</option>
<option value="plus">Plus</option>
<option value="pro">Pro</option>
</select>
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="In-person (card machine) fee %">
<input type="number" step="0.01" min="0" max="100" className={inputCls} placeholder="2.30"
value={feeInPersonPct} onChange={e => setFeeInPersonPct(e.target.value)} />
</Field>
<Field label="Online (checkout link) fee %">
<input type="number" step="0.01" min="0" max="100" className={inputCls} placeholder="2.95"
value={feeOnlinePct} onChange={e => setFeeOnlinePct(e.target.value)} />
</Field>
</div>
<p className="text-xs text-gray-400">These fees are always absorbed by the church they're shown in reports for an accurate payout, never added to what attendees pay.</p>
<SaveBar saving={saving} onSave={savePayments} result={result} />
</div>
)}
</div> </div>
); );
} }
+100 -28
View File
@@ -85,6 +85,13 @@ function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`; return val === 0 ? "Free" : `R${val.toFixed(2)}`;
} }
// 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;
}
// ─── Kiosk Page ─────────────────────────────────────────────────────────────── // ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() { export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup"); const [screen, setScreen] = useState<Screen>("setup");
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
const [createAccount, setCreateAccount] = useState(false); const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState(""); const [visitorPassword, setVisitorPassword] = useState("");
const [accountExists, setAccountExists] = useState(false); const [accountExists, setAccountExists] = useState(false);
const [checkingAccount, setCheckingAccount] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({}); const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (search-by-email/phone) state ──────────────────
const [lookupQuery, setLookupQuery] = useState("");
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [formLoading, setFormLoading] = useState(false); const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
setEventsLoading(true); setEventsLoading(true);
try { try {
const data: KioskEvent[] = await apiFetch( const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false", "/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token } { authToken: token }
); );
setEvents(data); setEvents(data);
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
setCreateAccount(false); setCreateAccount(false);
setVisitorPassword(""); setVisitorPassword("");
setAccountExists(false); setAccountExists(false);
setLookupQuery("");
setLookupMessage(null);
setFormError(null); setFormError(null);
setCurrentRegistrationId(null); setCurrentRegistrationId(null);
setCurrentUserId(null); setCurrentUserId(null);
@@ -431,33 +444,66 @@ export default function SelfServicePage() {
}; };
}, []); }, []);
// ─── Check whether an account already exists for the entered email/phone ── // ─── Explicit account lookup — only runs when the operator submits the search
useEffect(() => { // (Enter or the Search button), never on keystroke. Matches exactly against a
const email = visitorEmail.trim(); // single email or phone value and only ever returns that one matched account
const phone = visitorPhone.trim(); // (or nothing) — never a broader/fuzzy match. ──
if (!supervisorToken || (!email && !phone)) { async function handleLookup() {
setAccountExists(false); const q = lookupQuery.trim();
return; if (!q || !supervisorToken) return;
} setLookupLoading(true);
const handle = setTimeout(async () => { setLookupMessage(null);
setCheckingAccount(true);
try { try {
// Classify the query as email- or phone-shaped and send it as only that param —
// sending the same raw string as both could let digits embedded in an email
// (e.g. a numeric local-part) get misread as an unrelated phone number.
const isEmailLike = q.includes("@");
const params = new URLSearchParams(); const params = new URLSearchParams();
if (email) params.set("email", email); if (isEmailLike) params.set("email", q);
if (phone) params.set("phone", phone); else params.set("phone", q);
const data = await apiFetch<{ exists: boolean }>( const data = await apiFetch<{
`/api/users/check-exists?${params.toString()}`, exists: boolean;
{ authToken: supervisorToken } user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null;
); }>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
setAccountExists(!!data?.exists); if (data?.exists && data.user) {
} catch { const found = data.user;
setVisitorName(found.name);
setVisitorEmail(found.email || "");
setVisitorPhone(found.phoneNumber || "");
setNotificationPref(found.notificationPreference);
setAccountExists(true);
setLookupMessage("Account found — details filled in below.");
} else {
// No match — still save the retype: carry the query into whichever field it
// resembles, and leave everything else blank for a fresh entry.
setAccountExists(false); setAccountExists(false);
} finally { setVisitorName("");
setCheckingAccount(false); setNotificationPref("email");
if (isEmailLike) {
setVisitorEmail(q);
setVisitorPhone("");
} else if (looksLikePhone(q)) {
setVisitorPhone(q);
setVisitorEmail("");
} else {
setVisitorEmail("");
setVisitorPhone("");
}
setLookupMessage("No matching account found.");
}
} catch {
setLookupMessage("Lookup failed. Please try again.");
} finally {
setLookupLoading(false);
}
}
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleLookup();
}
} }
}, 400);
return () => clearTimeout(handle);
}, [visitorEmail, visitorPhone, supervisorToken]);
// Existing accounts are linked automatically — never show the "create account" toggle for them // Existing accounts are linked automatically — never show the "create account" toggle for them
useEffect(() => { useEffect(() => {
@@ -628,6 +674,34 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p> <p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div> </div>
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor&apos;s details.</p>
<div className="flex gap-2">
<input
type="text"
value={lookupQuery}
onChange={(e) => setLookupQuery(e.target.value)}
onKeyDown={handleLookupKeyDown}
className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Email or phone number"
/>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading || !lookupQuery.trim()}
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
>
{lookupLoading ? "Searching…" : "Search"}
</button>
</div>
{lookupMessage && (
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
</div>
<form onSubmit={handleRegister} className="space-y-5"> <form onSubmit={handleRegister} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label> <label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
@@ -808,9 +882,7 @@ export default function SelfServicePage() {
</div> </div>
<div> <div>
<p className="font-medium text-gray-800">Create an account</p> <p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">Save your details for future events</p>
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
</p>
</div> </div>
</label> </label>
{createAccount && ( {createAccount && (
+47 -95
View File
@@ -473,26 +473,11 @@ export default function ReportsV2() {
return map; return map;
}, [paymentsByEvent]); }, [paymentsByEvent]);
// Yoco processing fee totals per registration, from the per-payment snapshot (see
// Payment.feeAmount in the backend) — never recomputed from current settings, so this
// always reflects what was actually estimated at payment time.
const feeByReg = useMemo(() => {
const map = new Map<string, number>();
Object.keys(paymentsByEvent).forEach(evId => {
(paymentsByEvent[evId] || []).forEach((p: any) => {
if (p.registrationId && p.feeAmount != null) {
map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.feeAmount || 0));
}
});
});
return map;
}, [paymentsByEvent]);
const revDetRows = useMemo(() => { const revDetRows = useMemo(() => {
if (report !== "revenueDetailed") return [] as any[]; if (report !== "revenueDetailed") return [] as any[];
const df = payFrom ? new Date(payFrom).getTime() : null; const df = payFrom ? new Date(payFrom).getTime() : null;
const dt = payTo ? new Date(payTo).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null;
type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number; 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[] = []; const rows: Row[] = [];
// Iterate registrations to include those with zero payments and show each once // Iterate registrations to include those with zero payments and show each once
Object.keys(registrationsByEvent).forEach(evId => { Object.keys(registrationsByEvent).forEach(evId => {
@@ -504,7 +489,6 @@ export default function ReportsV2() {
if (dt && created && created > dt) return; if (dt && created && created > dt) return;
const totalPaid = paidByReg.get(r.id) || 0; const totalPaid = paidByReg.get(r.id) || 0;
const outstanding = outstandingByReg.get(r.id) || 0; const outstanding = outstandingByReg.get(r.id) || 0;
const fee = feeByReg.get(r.id) || 0;
rows.push({ rows.push({
eventId: evId, eventId: evId,
eventTitle: evTitle, eventTitle: evTitle,
@@ -514,14 +498,12 @@ export default function ReportsV2() {
status: r.status, status: r.status,
registrationId: r.id, registrationId: r.id,
outstanding, outstanding,
fee,
netAfterFee: totalPaid - fee,
}); });
}); });
}); });
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || ''))); rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '')));
return rows; return rows;
}, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg, feeByReg]); }, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg]);
// Derived for attendees (layered) for a single event // Derived for attendees (layered) for a single event
const attendeesLayer = useMemo(() => { const attendeesLayer = useMemo(() => {
@@ -649,7 +631,6 @@ export default function ReportsV2() {
totalPaid: paidByReg.get(r.id) || 0, totalPaid: paidByReg.get(r.id) || 0,
outstanding: outstandingByReg.get(r.id) || 0, outstanding: outstandingByReg.get(r.id) || 0,
donations: donationsByUser.get(r.user?.id || r.userId) || 0, donations: donationsByUser.get(r.user?.id || r.userId) || 0,
fee: feeByReg.get(r.id) || 0,
__prices: {} as Record<string, number> // 👈 hidden helper __prices: {} as Record<string, number> // 👈 hidden helper
}; };
@@ -679,7 +660,7 @@ export default function ReportsV2() {
return rows; return rows;
}, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg, feeByReg]); }, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg]);
//Master Report Totals //Master Report Totals
const masterTotals = useMemo(() => { const masterTotals = useMemo(() => {
@@ -690,7 +671,6 @@ export default function ReportsV2() {
totalPaid: 0, totalPaid: 0,
outstanding: 0, outstanding: 0,
donations: 0, donations: 0,
fee: 0,
}; };
// 👇 initialise dynamic option totals // 👇 initialise dynamic option totals
@@ -703,7 +683,6 @@ export default function ReportsV2() {
totals.orderTotal += row.orderTotal; totals.orderTotal += row.orderTotal;
totals.totalPaid += row.totalPaid; totals.totalPaid += row.totalPaid;
totals.outstanding += row.outstanding; totals.outstanding += row.outstanding;
totals.fee += row.fee || 0;
masterOptions.forEach(opt => { masterOptions.forEach(opt => {
const qty = row[opt.name] || 0; const qty = row[opt.name] || 0;
@@ -782,8 +761,6 @@ export default function ReportsV2() {
Email: r.userEmail || "", Email: r.userEmail || "",
Status: r.status || "", Status: r.status || "",
TotalPaid: r.totalPaid, TotalPaid: r.totalPaid,
Fee: r.fee,
NetAfterFee: r.netAfterFee,
Outstanding: r.outstanding, Outstanding: r.outstanding,
RegistrationId: r.registrationId RegistrationId: r.registrationId
}))); })));
@@ -816,7 +793,7 @@ export default function ReportsV2() {
return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows); return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows);
} }
if (report === "cashup") { if (report === "cashup") {
const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance"]; const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedCash", "Actual", "Variance"];
const rows: any[] = []; const rows: any[] = [];
Object.keys(financialsByEvent).forEach(evId => { Object.keys(financialsByEvent).forEach(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
@@ -825,15 +802,15 @@ export default function ReportsV2() {
const title = ev?.title || evId; const title = ev?.title || evId;
METHOD_KEYS.forEach(m => { METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m]; const r = f.reconciled?.byMethod?.[m];
rows.push({ Event: title, Section: "Method", Detail: METHOD_LABEL[m], Income: f.paymentsByMethod?.[m] || 0, CostsFromMethod: f.costsByMethod?.[m] || 0, 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") { 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) { 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 }))); 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) })); (f.costs || []).forEach((c: any) => rows.push({ Event: title, Section: "Costs", Detail: `${c.label}${c.paidFromMethod ? ` (paid via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Amount: -(c.total ?? c.amount) }));
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 }); rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 });
rows.push({ Event: title, Section: "Fees", Detail: "Yoco processing fees", Amount: -(f.totalFees || 0) }); rows.push({ Event: title, Section: "Net profit", Detail: "", Amount: f.netProfit || 0 });
rows.push({ Event: title, Section: "Net profit", Detail: "After fees", Amount: f.netProfitAfterFees || 0 });
}); });
return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
} }
@@ -865,20 +841,20 @@ export default function ReportsV2() {
const rows = Object.keys(financialsByEvent).map(evId => { const rows = Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId); const ev = filteredEvents.find(e => e.id === evId);
return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, 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); return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows);
} }
if (report === "cashupAudit") { 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[] = []; const rows: any[] = [];
auditRows.forEach((r: any) => { auditRows.forEach((r: any) => {
const base = { Event: r.event?.title || r.eventId, Action: actionLabel(r.action), PerformedBy: r.performedBy?.name || "", Date: new Date(r.createdAt).toLocaleString() }; const base = { Event: r.event?.title || r.eventId, Action: actionLabel(r.action), PerformedBy: r.performedBy?.name || "", Date: new Date(r.createdAt).toLocaleString() };
if (r.action === "reopened") { if (r.action === "reopened") {
rows.push({ ...base, Method: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" }); rows.push({ ...base, Method: "", Expected: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" });
} else { } else {
(r.lines || []).forEach((l: any) => rows.push({ (r.lines || []).forEach((l: any) => rows.push({
...base, Method: METHOD_LABEL[l.method] || l.method, 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 || "" 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, OrderTotal: r.orderTotal,
Paid: r.totalPaid, Paid: r.totalPaid,
Fee: r.fee,
Outstanding: r.outstanding, Outstanding: r.outstanding,
Donations: r.donations Donations: r.donations
})); }));
@@ -916,7 +891,6 @@ export default function ReportsV2() {
OrderTotal: masterTotals?.orderTotal ?? 0, OrderTotal: masterTotals?.orderTotal ?? 0,
Paid: masterTotals?.totalPaid ?? 0, Paid: masterTotals?.totalPaid ?? 0,
Fee: masterTotals?.fee ?? 0,
Outstanding: masterTotals?.outstanding ?? 0, Outstanding: masterTotals?.outstanding ?? 0,
Donations: masterTotals?.donations ?? 0, Donations: masterTotals?.donations ?? 0,
}); });
@@ -1032,8 +1006,6 @@ export default function ReportsV2() {
r.userEmail || "", r.userEmail || "",
r.status || "", r.status || "",
Number((r.totalPaid || 0).toFixed(2)), Number((r.totalPaid || 0).toFixed(2)),
Number((r.fee || 0).toFixed(2)),
Number((r.netAfterFee || 0).toFixed(2)),
Number((r.outstanding || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)),
r.registrationId || "", r.registrationId || "",
]); ]);
@@ -1041,7 +1013,7 @@ export default function ReportsV2() {
title: 'Revenue Detailed', title: 'Revenue Detailed',
kind: 'table', kind: 'table',
orientation: 'landscape', orientation: 'landscape',
table: { columns: ["Event","Name","Email","Status","Total paid","Fee","Net after fee","Outstanding","RegistrationId"], rows } table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }
}); });
return; return;
} }
@@ -1070,15 +1042,15 @@ export default function ReportsV2() {
const title = ev?.title || evId; const title = ev?.title || evId;
METHOD_KEYS.forEach(m => { METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m]; const r = f.reconciled?.byMethod?.[m];
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), 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") { if (m === "cash") {
(r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", "", "", Number((d.value * d.count).toFixed(2)), ""])); (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
} }
}); });
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", "", "", ""]); if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]);
rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]); rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
}); });
await downloadReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (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; return;
} }
if (report === "financeReport") { if (report === "financeReport") {
@@ -1101,8 +1073,7 @@ export default function ReportsV2() {
rows.push([title, "Costs", "", ""]); rows.push([title, "Costs", "", ""]);
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))])); (f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]); rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]); rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]);
}); });
await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } }); await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } });
return; return;
@@ -1111,9 +1082,9 @@ export default function ReportsV2() {
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => { const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId); const ev = filteredEvents.find(e => e.id === evId);
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.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; return;
} }
if (report === "cashupAudit") { if (report === "cashupAudit") {
@@ -1121,13 +1092,13 @@ export default function ReportsV2() {
auditRows.forEach((r: any) => { auditRows.forEach((r: any) => {
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()]; const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
if (r.action === "reopened") { if (r.action === "reopened") {
rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]); rows.push([...base, "", "", "", "", "", r.notes || ""]);
} else { } else {
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number(((l.expectedAmount || 0) + (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 || ""]));
if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]); if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]);
} }
}); });
await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected (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; return;
} }
if (report === "masterOrders") { if (report === "masterOrders") {
@@ -1140,7 +1111,6 @@ export default function ReportsV2() {
Number(r.orderTotal.toFixed(2)), Number(r.orderTotal.toFixed(2)),
Number(r.totalPaid.toFixed(2)), Number(r.totalPaid.toFixed(2)),
Number((r.fee || 0).toFixed(2)),
Number(r.outstanding.toFixed(2)) Number(r.outstanding.toFixed(2))
]); ]);
@@ -1154,7 +1124,6 @@ export default function ReportsV2() {
Number((masterTotals?.orderTotal ?? 0).toFixed(2)), Number((masterTotals?.orderTotal ?? 0).toFixed(2)),
Number((masterTotals?.totalPaid ?? 0).toFixed(2)), Number((masterTotals?.totalPaid ?? 0).toFixed(2)),
Number((masterTotals?.fee ?? 0).toFixed(2)),
Number((masterTotals?.outstanding ?? 0).toFixed(2)) Number((masterTotals?.outstanding ?? 0).toFixed(2))
]); ]);
@@ -1168,7 +1137,6 @@ export default function ReportsV2() {
`R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}` `R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}`
), ),
"",
"", "",
"", "",
"" ""
@@ -1188,7 +1156,6 @@ export default function ReportsV2() {
"Order Total", "Order Total",
"Paid", "Paid",
"Fee",
"Outstanding" "Outstanding"
], ],
rows: bodyRows 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 }); await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body });
} else if (report === 'revenueDetailed') { } else if (report === 'revenueDetailed') {
const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.fee || 0).toFixed(2)), Number((r.netAfterFee || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "" ]); const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.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 }); 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') { } else if (report === 'regStatus') {
const rows: (string|number)[][] = []; const rows: (string|number)[][] = [];
Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); }); Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); });
@@ -1263,13 +1230,13 @@ export default function ReportsV2() {
const title = ev?.title || evId; const title = ev?.title || evId;
METHOD_KEYS.forEach(m => { METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m]; const r = f.reconciled?.byMethod?.[m];
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), 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)), ""])); if (m === "cash") (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
}); });
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", "", "", ""]); if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]);
rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]); rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
}); });
await emailReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (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') { } else if (report === 'financeReport') {
const rows: (string | number)[][] = []; const rows: (string | number)[][] = [];
Object.keys(financialsByEvent).forEach(evId => { Object.keys(financialsByEvent).forEach(evId => {
@@ -1288,27 +1255,26 @@ export default function ReportsV2() {
rows.push([title, "Costs", "", ""]); rows.push([title, "Costs", "", ""]);
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))])); (f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]); rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]); rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]);
}); });
await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body }); await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body });
} else if (report === 'profitReport') { } else if (report === 'profitReport') {
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => { const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId);
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.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') { } else if (report === 'cashupAudit') {
const rows: (string | number)[][] = []; const rows: (string | number)[][] = [];
auditRows.forEach((r: any) => { auditRows.forEach((r: any) => {
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()]; const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
if (r.action === "reopened") { if (r.action === "reopened") {
rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]); rows.push([...base, "", "", "", "", "", r.notes || ""]);
} else { } else {
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number(((l.expectedAmount || 0) + (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'); alert('Email sent with PDF attachment');
} catch (e: any) { } catch (e: any) {
@@ -1744,8 +1710,6 @@ export default function ReportsV2() {
<th className="text-left">Email</th> <th className="text-left">Email</th>
<th className="text-left">Status</th> <th className="text-left">Status</th>
<th className="text-left">Total paid</th> <th className="text-left">Total paid</th>
<th className="text-left">Fee</th>
<th className="text-left">Net after fee</th>
<th className="text-left">Outstanding</th> <th className="text-left">Outstanding</th>
<th className="text-left">Registration</th> <th className="text-left">Registration</th>
</tr> </tr>
@@ -1757,8 +1721,6 @@ export default function ReportsV2() {
<td>{r.userEmail || ''}</td> <td>{r.userEmail || ''}</td>
<td className="capitalize">{r.status || ''}</td> <td className="capitalize">{r.status || ''}</td>
<td>R {Number(r.totalPaid || 0).toFixed(2)}</td> <td>R {Number(r.totalPaid || 0).toFixed(2)}</td>
<td>R {Number(r.fee || 0).toFixed(2)}</td>
<td>R {Number(r.netAfterFee || 0).toFixed(2)}</td>
<td>R {Number(r.outstanding || 0).toFixed(2)}</td> <td>R {Number(r.outstanding || 0).toFixed(2)}</td>
<td className="font-mono text-xs">{r.registrationId || ''}</td> <td className="font-mono text-xs">{r.registrationId || ''}</td>
</tr> </tr>
@@ -1861,7 +1823,6 @@ export default function ReportsV2() {
<span className="font-medium">Totals:</span> <span className="font-medium">Totals:</span>
<span className="ml-3">Order: R {masterTotals?.orderTotal.toFixed(2)}</span> <span className="ml-3">Order: R {masterTotals?.orderTotal.toFixed(2)}</span>
<span className="ml-3">Paid: R {masterTotals?.totalPaid.toFixed(2)}</span> <span className="ml-3">Paid: R {masterTotals?.totalPaid.toFixed(2)}</span>
<span className="ml-3">Fee: R {(masterTotals?.fee ?? 0).toFixed(2)}</span>
<span className="ml-3">Outstanding: R {masterTotals?.outstanding.toFixed(2)}</span> <span className="ml-3">Outstanding: R {masterTotals?.outstanding.toFixed(2)}</span>
</div> </div>
@@ -1879,7 +1840,6 @@ export default function ReportsV2() {
<th>Order Total</th> <th>Order Total</th>
<th>Paid</th> <th>Paid</th>
<th>Fee</th>
<th>Outstanding</th> <th>Outstanding</th>
<th>Donations</th> <th>Donations</th>
</tr> </tr>
@@ -1899,7 +1859,6 @@ export default function ReportsV2() {
<td>R {r.orderTotal.toFixed(2)}</td> <td>R {r.orderTotal.toFixed(2)}</td>
<td>R {r.totalPaid.toFixed(2)}</td> <td>R {r.totalPaid.toFixed(2)}</td>
<td>R {(r.fee || 0).toFixed(2)}</td>
<td>R {r.outstanding.toFixed(2)}</td> <td>R {r.outstanding.toFixed(2)}</td>
<td>R {r.donations.toFixed(2)}</td> <td>R {r.donations.toFixed(2)}</td>
</tr> </tr>
@@ -1915,7 +1874,6 @@ export default function ReportsV2() {
<td>R {masterTotals?.orderTotal.toFixed(2)}</td> <td>R {masterTotals?.orderTotal.toFixed(2)}</td>
<td>R {masterTotals?.totalPaid.toFixed(2)}</td> <td>R {masterTotals?.totalPaid.toFixed(2)}</td>
<td>R {(masterTotals?.fee ?? 0).toFixed(2)}</td>
<td>R {masterTotals?.outstanding.toFixed(2)}</td> <td>R {masterTotals?.outstanding.toFixed(2)}</td>
<td>R {masterTotals?.donations.toFixed(2)}</td> <td>R {masterTotals?.donations.toFixed(2)}</td>
</tr> </tr>
@@ -1928,7 +1886,7 @@ export default function ReportsV2() {
</td> </td>
))} ))}
<td colSpan={5}></td> <td colSpan={4}></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -1954,7 +1912,7 @@ export default function ReportsV2() {
</div> </div>
<div className="overflow-auto"> <div className="overflow-auto">
<table className="min-w-[640px] text-sm"> <table className="min-w-[640px] text-sm">
<thead><tr><th className="text-left">Method</th><th className="text-left">Income</th><th className="text-left">Costs from method</th><th className="text-left">Expected (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> <tbody>
{METHOD_KEYS.map(m => { {METHOD_KEYS.map(m => {
const r = f.reconciled?.byMethod?.[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.paymentsByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.costsByMethod?.[m] || 0).toFixed(2)}</td> <td>R {(f.costsByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td> <td>R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.feesByMethod?.[m] || 0).toFixed(2)}</td>
<td>R {(f.expectedInBankByMethod?.[m] || 0).toFixed(2)}</td>
<td>{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td> <td>{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
<td className={r?.variance != null && r.variance !== 0 ? (r.variance < 0 ? "text-red-600" : "text-emerald-600") : ""}>{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td> <td className={r?.variance != null && r.variance !== 0 ? (r.variance < 0 ? "text-red-600" : "text-emerald-600") : ""}>{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td>
</tr> </tr>
{m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => ( {m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => (
<tr key={d.id} className="text-xs text-gray-500"> <tr key={d.id} className="text-xs text-gray-500">
<td className="pl-4">{denomLabel(d.value)} × {d.count}</td> <td className="pl-4">{denomLabel(d.value)} × {d.count}</td>
<td colSpan={6}></td> <td colSpan={4}></td>
<td>R {(d.value * d.count).toFixed(2)}</td> <td>R {(d.value * d.count).toFixed(2)}</td>
</tr> </tr>
))} ))}
@@ -2055,8 +2011,7 @@ export default function ReportsV2() {
</div> </div>
<div className="text-sm flex items-center justify-between border-t pt-1"><span>Donations counted as profit</span><span>R {(f.unallocatedDonationsTotal || 0).toFixed(2)}</span></div> <div className="text-sm flex items-center justify-between border-t pt-1"><span>Donations counted as profit</span><span>R {(f.unallocatedDonationsTotal || 0).toFixed(2)}</span></div>
<div className="text-sm 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</span><span>R {(f.netProfit || 0).toFixed(2)}</span></div>
<div className="text-sm font-semibold flex items-center justify-between"><span>Net profit (after fees)</span><span>R {(f.netProfitAfterFees || 0).toFixed(2)}</span></div>
</div> </div>
); );
}) })
@@ -2069,7 +2024,7 @@ export default function ReportsV2() {
<div className="text-sm text-gray-500">No data available. Select events and view.</div> <div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : ( ) : (
<table className="min-w-[600px] text-sm"> <table className="min-w-[600px] text-sm">
<thead><tr><th className="text-left">Event</th><th className="text-left">Revenue</th><th className="text-left">Costs</th><th className="text-left">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> <tbody>
{Object.keys(financialsByEvent).map(evId => { {Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId]; const f = financialsByEvent[evId];
@@ -2079,8 +2034,7 @@ export default function ReportsV2() {
<td>{ev?.title || evId}</td> <td>{ev?.title || evId}</td>
<td>R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td> <td>R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td>
<td>R {(f?.totalCosts || 0).toFixed(2)}</td> <td>R {(f?.totalCosts || 0).toFixed(2)}</td>
<td>R {(f?.totalFees || 0).toFixed(2)}</td> <td className="font-medium">R {(f?.netProfit || 0).toFixed(2)}</td>
<td className="font-medium">R {(f?.netProfitAfterFees || 0).toFixed(2)}</td>
</tr> </tr>
); );
})} })}
@@ -2109,13 +2063,11 @@ export default function ReportsV2() {
{(r.action === "closed" || r.action === "quick_closed") && ( {(r.action === "closed" || r.action === "quick_closed") && (
<div className="overflow-auto mt-2"> <div className="overflow-auto mt-2">
<table className="min-w-[560px] text-sm"> <table className="min-w-[560px] text-sm">
<thead><tr><th className="text-left">Method</th><th className="text-left">Expected (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> <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}> <tr key={l.method}>
<td>{METHOD_LABEL[l.method] || l.method}</td> <td>{METHOD_LABEL[l.method] || l.method}</td>
<td>{l.expectedAmount != null ? `R ${(l.expectedAmount + (l.feeAmount || 0)).toFixed(2)}` : "—"}</td>
<td>{l.feeAmount != null ? `R ${l.feeAmount.toFixed(2)}` : "—"}</td>
<td>{l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"}</td> <td>{l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"}</td>
<td>{l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td> <td>{l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
<td>{l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}</td> <td>{l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}</td>
-6
View File
@@ -76,7 +76,6 @@ export type EventCashupLine = {
cashupId: string; cashupId: string;
method: CashupMethod; method: CashupMethod;
expectedAmount: number; expectedAmount: number;
feeAmount?: number | null;
actualAmount?: number | null; actualAmount?: number | null;
variance?: number | null; variance?: number | null;
notes?: string | null; notes?: string | null;
@@ -103,7 +102,6 @@ export type EventCashup = {
export type ReconciledByMethod = { export type ReconciledByMethod = {
expected: number; expected: number;
fee: number;
actual: number | null; actual: number | null;
variance: number | null; variance: number | null;
notes?: string | null; notes?: string | null;
@@ -135,14 +133,10 @@ export type EventFinancials = {
untaggedCostsTotal: number; untaggedCostsTotal: number;
totalCosts: number; totalCosts: number;
expectedCashByMethod: Record<CashupMethod, number>; expectedCashByMethod: Record<CashupMethod, number>;
expectedInBankByMethod: Record<CashupMethod, number>;
reconciled: ReconciledSnapshot | null; reconciled: ReconciledSnapshot | null;
effectiveGrossIncomeByMethod: Record<CashupMethod, number>; effectiveGrossIncomeByMethod: Record<CashupMethod, number>;
effectiveTotalRevenue: number; effectiveTotalRevenue: number;
netProfit: number; netProfit: number;
feesByMethod: Record<CashupMethod, number>;
totalFees: number;
netProfitAfterFees: number;
unallocatedDonations: Payment[]; unallocatedDonations: Payment[];
unallocatedDonationsTotal: number; unallocatedDonationsTotal: number;
totalDonations: number; totalDonations: number;