Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21176e1e0b | ||
|
|
b4cd140168 | ||
|
|
5167706d1b | ||
|
|
047f61b627 |
+3
-5
@@ -7,12 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [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.
|
||||
- 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: 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.
|
||||
|
||||
## [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;
|
||||
@@ -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 } },
|
||||
|
||||
@@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => {
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
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({
|
||||
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.
|
||||
user: existingUser ? {
|
||||
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 };
|
||||
@@ -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,13 @@ function fmtCurrency(val: number) {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
export default function SelfServicePage() {
|
||||
const [screen, setScreen] = useState<Screen>("setup");
|
||||
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
|
||||
const [createAccount, setCreateAccount] = useState(false);
|
||||
const [visitorPassword, setVisitorPassword] = useState("");
|
||||
const [accountExists, setAccountExists] = useState(false);
|
||||
const [checkingAccount, setCheckingAccount] = useState(false);
|
||||
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 [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
|
||||
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);
|
||||
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
|
||||
setCreateAccount(false);
|
||||
setVisitorPassword("");
|
||||
setAccountExists(false);
|
||||
setLookupQuery("");
|
||||
setLookupMessage(null);
|
||||
setFormError(null);
|
||||
setCurrentRegistrationId(null);
|
||||
setCurrentUserId(null);
|
||||
@@ -431,33 +444,66 @@ 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);
|
||||
// ─── Explicit account lookup — only runs when the operator submits the search
|
||||
// (Enter or the Search button), never on keystroke. Matches exactly against a
|
||||
// single email or phone value and only ever returns that one matched account
|
||||
// (or nothing) — never a broader/fuzzy match. ──
|
||||
async function handleLookup() {
|
||||
const q = lookupQuery.trim();
|
||||
if (!q || !supervisorToken) return;
|
||||
setLookupLoading(true);
|
||||
setLookupMessage(null);
|
||||
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();
|
||||
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 {
|
||||
if (isEmailLike) params.set("email", q);
|
||||
else params.set("phone", q);
|
||||
const data = await apiFetch<{
|
||||
exists: boolean;
|
||||
user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null;
|
||||
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
|
||||
if (data?.exists && data.user) {
|
||||
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);
|
||||
} finally {
|
||||
setCheckingAccount(false);
|
||||
setVisitorName("");
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -628,6 +674,34 @@ export default function SelfServicePage() {
|
||||
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
||||
</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'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">
|
||||
<div>
|
||||
<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>
|
||||
<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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user