diff --git a/CHANGELOG.md b/CHANGELOG.md index 51202d5..6408bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Yoco processing fee tracking: Settings → Payments lets you pick your Yoco plan (Core/Plus/Pro) to pre-fill estimated in-person and online fee percentages, both freely editable. The church always absorbs this fee — it's never added to what attendees pay. +- Every card payment now snapshots its estimated Yoco fee at the time it's recorded, so later changes to the settings don't retroactively alter historical reports. +- Finance report, Profit report, Master Orders, and Revenue Detailed (including their CSV/PDF/email exports) now show the estimated Yoco fee and a "net after fees" figure alongside gross revenue, so the numbers reflect what actually lands in the bank. +- Cashup reconciliation (the per-event close-out screen, the Cashup report, and the Cashup Audit Trail) now shows three figures for the card method: expected before Yoco fees, the fee itself, and expected after fees — plus overall Yoco fees and net-profit-after-fees totals — so counts reconcile against what actually settles from Yoco rather than the gross card total. + ## [1.3.0] - 2026-07-27 ### Added diff --git a/backend/prisma/migrations/20260728060433_add_yoco_fee_fields_to_payment/migration.sql b/backend/prisma/migrations/20260728060433_add_yoco_fee_fields_to_payment/migration.sql new file mode 100644 index 0000000..9d95348 --- /dev/null +++ b/backend/prisma/migrations/20260728060433_add_yoco_fee_fields_to_payment/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION, +ADD COLUMN "feeChannel" TEXT, +ADD COLUMN "feePayer" TEXT, +ADD COLUMN "feeRate" DOUBLE PRECISION; diff --git a/backend/prisma/migrations/20260728065200_add_event_fee_payer_override/migration.sql b/backend/prisma/migrations/20260728065200_add_event_fee_payer_override/migration.sql new file mode 100644 index 0000000..4d29411 --- /dev/null +++ b/backend/prisma/migrations/20260728065200_add_event_fee_payer_override/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT; diff --git a/backend/prisma/migrations/20260728070306_remove_fee_payer_fields/migration.sql b/backend/prisma/migrations/20260728070306_remove_fee_payer_fields/migration.sql new file mode 100644 index 0000000..6bb74db --- /dev/null +++ b/backend/prisma/migrations/20260728070306_remove_fee_payer_fields/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost. + - You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Event" DROP COLUMN "feePayerOnline"; + +-- AlterTable +ALTER TABLE "Payment" DROP COLUMN "feePayer"; diff --git a/backend/prisma/migrations/20260728085349_add_fee_amount_to_cashup_line/migration.sql b/backend/prisma/migrations/20260728085349_add_fee_amount_to_cashup_line/migration.sql new file mode 100644 index 0000000..bef60fa --- /dev/null +++ b/backend/prisma/migrations/20260728085349_add_fee_amount_to_cashup_line/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index daab2b6..ea550ed 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -221,6 +221,9 @@ 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) @@ -461,6 +464,7 @@ 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? diff --git a/backend/src/controllers/cashupController.js b/backend/src/controllers/cashupController.js index 48d8641..d818677 100644 --- a/backend/src/controllers/cashupController.js +++ b/backend/src/controllers/cashupController.js @@ -53,7 +53,9 @@ const closeEvent = async (req, res) => { ? lines .filter(l => l && ALL_METHODS.includes(l.method)) .map(l => { - const expected = financials.expectedCashByMethod[l.method] || 0; + // expectedInBankByMethod nets out Yoco fees (only ever nonzero for 'card') so the + // variance reconciles against what should actually land in the bank, not the gross figure. + const expected = financials.expectedInBankByMethod[l.method] || 0; const denominations = l.method === 'cash' && Array.isArray(l.denominations) ? l.denominations .map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 })) @@ -66,6 +68,7 @@ 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, @@ -77,7 +80,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.expectedCashByMethod[m], 0); + const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedInBankByMethod[m], 0); const cashup = await prisma.eventCashup.create({ data: { diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index da273e4..a8677d6 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -5,7 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require(' const axios = require('axios'); const { emailTickets } = require('./ticketController'); const { safeErrorMessage } = require('../utils/errorUtils'); -const { assertEventOpen } = require('../utils/cashupUtils'); +const { assertEventOpen, bucketForMethod } = require('../utils/cashupUtils'); +const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees'); // @desc Create a new payment // @route POST /api/payments @@ -58,6 +59,14 @@ 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); @@ -160,7 +169,8 @@ const createPayment = async (req, res) => { registrationId: null, eventId: registration.eventId, isDonation: true, - createdAt: paidAtDate || undefined + createdAt: paidAtDate || undefined, + ...feeFor(requestedAmount) }, include: { user: { select: { id: true, name: true, email: true } }, @@ -178,7 +188,8 @@ const createPayment = async (req, res) => { registrationId, eventId: registrationEventId || eventId || null, isDonation: false, - createdAt: paidAtDate || undefined + createdAt: paidAtDate || undefined, + ...feeFor(applyAmount) }, include: { user: { select: { id: true, name: true, email: true } }, @@ -199,7 +210,8 @@ const createPayment = async (req, res) => { eventId: registration.eventId, isDonation: true, originalPaymentId: payment.id, - createdAt: paidAtDate || undefined + createdAt: paidAtDate || undefined, + ...feeFor(excess) } }); } @@ -215,7 +227,8 @@ const createPayment = async (req, res) => { registrationId: registrationId || null, eventId: registrationEventId || eventId || null, isDonation: isDonation || false, - createdAt: paidAtDate || undefined + createdAt: paidAtDate || undefined, + ...feeFor(parseFloat(amount)) }, include: { user: { select: { id: true, name: true, email: true } }, diff --git a/backend/src/controllers/webhookController.js b/backend/src/controllers/webhookController.js index 83ae861..6d00a4d 100644 --- a/backend/src/controllers/webhookController.js +++ b/backend/src/controllers/webhookController.js @@ -4,6 +4,8 @@ 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 @@ -313,18 +315,25 @@ 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: amount / 100, // Convert cents to your currency unit + amount: grossAmount, // Convert cents to your currency unit method: method.type, // The type of payment from the webhook status: status === 'succeeded' ? 'completed' : status, externalId: yocoPaymentId, registrationId: registration?.id || null, userId: resolvedUserId, eventId: registration?.eventId || metadata?.eventId || null, - isDonation: !registration?.id + isDonation: !registration?.id, + ...feeSnapshot }, include: { user: { diff --git a/backend/src/utils/cashupUtils.js b/backend/src/utils/cashupUtils.js index 33259fb..572072f 100644 --- a/backend/src/utils/cashupUtils.js +++ b/backend/src/utils/cashupUtils.js @@ -104,6 +104,15 @@ 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' @@ -126,6 +135,13 @@ 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 ? { @@ -139,7 +155,8 @@ 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 : expectedCashByMethod[m], + expected: line ? line.expectedAmount : expectedInBankByMethod[m], + fee: line && line.feeAmount != null ? line.feeAmount : feesByMethod[m], actual: line && line.actualAmount != null ? line.actualAmount : null, variance: line && line.variance != null ? line.variance : null, notes: line ? line.notes : null, @@ -161,6 +178,7 @@ 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 = {}; @@ -183,10 +201,14 @@ async function computeEventFinancials(eventId) { untaggedCostsTotal, totalCosts, expectedCashByMethod, + expectedInBankByMethod, reconciled, effectiveGrossIncomeByMethod, effectiveTotalRevenue, netProfit, + feesByMethod, + totalFees, + netProfitAfterFees, unallocatedDonations, unallocatedDonationsTotal, totalDonations, diff --git a/backend/src/utils/yocoFees.js b/backend/src/utils/yocoFees.js new file mode 100644 index 0000000..2ef9881 --- /dev/null +++ b/backend/src/utils/yocoFees.js @@ -0,0 +1,35 @@ +/** + * Yoco processing fee estimation. + * + * Yoco's real rate depends on plan, channel (in-person card machine vs. online checkout), + * monthly volume tier, and card type — none of which this app tracks. Instead the admin + * configures one flat, editable % per channel (Settings → Payments), pre-filled from a + * representative default when they pick a plan. The church always absorbs this fee — it's + * shown in reports so payout figures are accurate, never passed on to the buyer. + */ + +const { getSetting } = require('./settingsCache'); + +async function getYocoFeeConfig() { + const [inPersonPct, onlinePct] = await Promise.all([ + getSetting('yoco_fee_in_person_pct', '0'), + getSetting('yoco_fee_online_pct', '0'), + ]); + return { + inPersonRate: parseFloat(inPersonPct) || 0, // percent, e.g. 2.30 + onlineRate: parseFloat(onlinePct) || 0, + }; +} + +// Snapshot to store on a Payment row at creation time. Only 'card' payments are fee-eligible — +// cash/eft/voucher never incur a Yoco fee. +function computeFeeSnapshot({ grossAmount, channel, bucket, config }) { + if (bucket !== 'card') { + return { feeAmount: null, feeRate: null, feeChannel: null }; + } + const rate = channel === 'online' ? config.onlineRate : config.inPersonRate; + const feeAmount = Math.round(grossAmount * (rate / 100) * 100) / 100; + return { feeAmount, feeRate: rate, feeChannel: channel }; +} + +module.exports = { getYocoFeeConfig, computeFeeSnapshot }; diff --git a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx index e0a465a..87adf57 100644 --- a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx @@ -372,7 +372,9 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh Method Income Costs from method - Expected cash + Expected (before fees) + Yoco fee + Expected (after fees) Actual Variance Notes @@ -381,12 +383,15 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh {METHODS.map(m => { const r = reconciled?.byMethod?.[m]; + const expected = data.expectedInBankByMethod[m]; return ( {METHOD_LABELS[m]} {money(data.paymentsByMethod[m])} {money(data.costsByMethod[m])} {money(data.expectedCashByMethod[m])} + {money(data.feesByMethod[m])} + {money(expected)} {isClosed ? ( money(r?.actual ?? null) @@ -400,8 +405,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh {isClosed ? (r?.variance != null ? money(r.variance) : not reconciled) : (m === "cash" - ? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—") - : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))} + ? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - expected) : "—") + : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - expected) : "—"))} {isClosed ? (r?.notes || "—") : ( @@ -447,7 +452,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh )} -
+
Donations counted as profit
{money(data.unallocatedDonationsTotal)}
@@ -457,8 +462,12 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{money(data.totalCosts)}
-
Net profit
-
{money(data.netProfit)}
+
Yoco fees
+
{money(data.totalFees)}
+
+
+
Net profit (after fees)
+
{money(data.netProfitAfterFees)}
diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx index 88d1070..8cce787 100644 --- a/frontend/src/app/dashboard/admin/settings/page.tsx +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -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"; +type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "payments"; const TABS: { id: TabId; label: string }[] = [ { id: "organisation", label: "Organisation" }, @@ -15,8 +15,19 @@ 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 handleYocoPlanChange(e.target.value as "core" | "plus" | "pro")} + > + + + + + + +
+ + setFeeInPersonPct(e.target.value)} /> + + + setFeeOnlinePct(e.target.value)} /> + +
+

These fees are always absorbed by the church — they're shown in reports for an accurate payout, never added to what attendees pay.

+ + +
+ )} ); } \ No newline at end of file diff --git a/frontend/src/components/reports/ReportsV2.tsx b/frontend/src/components/reports/ReportsV2.tsx index 3973c04..38402f4 100644 --- a/frontend/src/components/reports/ReportsV2.tsx +++ b/frontend/src/components/reports/ReportsV2.tsx @@ -473,11 +473,26 @@ 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(); + 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 }; + type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number; fee: number; netAfterFee: number }; const rows: Row[] = []; // Iterate registrations to include those with zero payments and show each once Object.keys(registrationsByEvent).forEach(evId => { @@ -489,6 +504,7 @@ 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, @@ -498,12 +514,14 @@ 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]); + }, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg, feeByReg]); // Derived for attendees (layered) for a single event const attendeesLayer = useMemo(() => { @@ -631,6 +649,7 @@ 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 // 👈 hidden helper }; @@ -660,7 +679,7 @@ export default function ReportsV2() { return rows; - }, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg]); + }, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg, feeByReg]); //Master Report Totals const masterTotals = useMemo(() => { @@ -671,6 +690,7 @@ export default function ReportsV2() { totalPaid: 0, outstanding: 0, donations: 0, + fee: 0, }; // 👇 initialise dynamic option totals @@ -683,6 +703,7 @@ 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; @@ -761,6 +782,8 @@ 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 }))); @@ -793,7 +816,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", "ExpectedCash", "Actual", "Variance"]; + const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance"]; const rows: any[] = []; Object.keys(financialsByEvent).forEach(evId => { const f = financialsByEvent[evId]; @@ -802,15 +825,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, ExpectedCash: f.expectedCashByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" }); + rows.push({ Event: title, Section: "Method", Detail: METHOD_LABEL[m], Income: f.paymentsByMethod?.[m] || 0, CostsFromMethod: f.costsByMethod?.[m] || 0, ExpectedBeforeFees: f.expectedCashByMethod?.[m] || 0, YocoFee: f.feesByMethod?.[m] || 0, ExpectedAfterFees: f.expectedInBankByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" }); if (m === "cash") { - (r?.denominations || []).forEach((d: any) => rows.push({ Event: title, Section: "Denomination", Detail: denomLabel(d.value) + " × " + d.count, Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: d.value * d.count, Variance: "" })); + (r?.denominations || []).forEach((d: any) => rows.push({ Event: title, Section: "Denomination", Detail: denomLabel(d.value) + " × " + d.count, Income: "", CostsFromMethod: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: d.value * d.count, Variance: "" })); } }); if ((f.untaggedCostsTotal || 0) > 0) { - rows.push({ Event: title, Section: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedCash: "", Actual: "", Variance: "" }); + rows.push({ Event: title, Section: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "" }); } - rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" }); + rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" }); }); return downloadCsv(`cashup_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); } @@ -833,7 +856,8 @@ export default function ReportsV2() { }); (f.costs || []).forEach((c: any) => rows.push({ Event: title, Section: "Costs", Detail: `${c.label}${c.paidFromMethod ? ` (paid via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Amount: -(c.total ?? c.amount) })); rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 }); - rows.push({ Event: title, Section: "Net profit", Detail: "", Amount: f.netProfit || 0 }); + rows.push({ Event: title, Section: "Fees", Detail: "Yoco processing fees", Amount: -(f.totalFees || 0) }); + rows.push({ Event: title, Section: "Net profit", Detail: "After fees", Amount: f.netProfitAfterFees || 0 }); }); return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); } @@ -841,20 +865,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, NetProfit: f?.netProfit || 0 }; + return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, Fees: f?.totalFees || 0, NetProfit: f?.netProfitAfterFees || 0 }; }); return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows); } if (report === "cashupAudit") { - const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "Expected", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"]; + const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"]; const rows: any[] = []; 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: "", Expected: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" }); + rows.push({ ...base, Method: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" }); } else { (r.lines || []).forEach((l: any) => rows.push({ - ...base, Method: METHOD_LABEL[l.method] || l.method, Expected: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "", + ...base, Method: METHOD_LABEL[l.method] || l.method, ExpectedBeforeFees: (l.expectedAmount || 0) + (l.feeAmount || 0), YocoFee: l.feeAmount ?? 0, ExpectedAfterFees: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "", DeltaVsPreviousClose: r._deltaVsPrevious?.[l.method] ?? "", DonationsCountedAsProfit: r.unallocatedDonationsTotal || 0, Notes: l.notes || r.notes || "" })); } @@ -874,6 +898,7 @@ export default function ReportsV2() { OrderTotal: r.orderTotal, Paid: r.totalPaid, + Fee: r.fee, Outstanding: r.outstanding, Donations: r.donations })); @@ -891,6 +916,7 @@ export default function ReportsV2() { OrderTotal: masterTotals?.orderTotal ?? 0, Paid: masterTotals?.totalPaid ?? 0, + Fee: masterTotals?.fee ?? 0, Outstanding: masterTotals?.outstanding ?? 0, Donations: masterTotals?.donations ?? 0, }); @@ -1006,6 +1032,8 @@ 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 || "", ]); @@ -1013,7 +1041,7 @@ export default function ReportsV2() { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', - table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows } + table: { columns: ["Event","Name","Email","Status","Total paid","Fee","Net after fee","Outstanding","RegistrationId"], rows } }); return; } @@ -1042,15 +1070,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)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); + rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), Number((f.feesByMethod?.[m] || 0).toFixed(2)), Number((f.expectedInBankByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); if (m === "cash") { - (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""])); + (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 cash", "Actual", "Variance"], rows } }); + await downloadReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance"], rows } }); return; } if (report === "financeReport") { @@ -1073,7 +1101,8 @@ 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, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]); + rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]); + rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]); }); await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } }); return; @@ -1082,9 +1111,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?.netProfit || 0).toFixed(2))]; + return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))]; }); - await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows } }); + await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Fees", "Net profit (after fees)"], rows } }); return; } if (report === "cashupAudit") { @@ -1092,13 +1121,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).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) + (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)}`]); } }); - await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } }); + await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } }); return; } if (report === "masterOrders") { @@ -1111,6 +1140,7 @@ 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)) ]); @@ -1124,6 +1154,7 @@ 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)) ]); @@ -1137,6 +1168,7 @@ export default function ReportsV2() { `R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}` ), + "", "", "", "" @@ -1156,6 +1188,7 @@ export default function ReportsV2() { "Order Total", "Paid", + "Fee", "Outstanding" ], rows: bodyRows @@ -1217,8 +1250,8 @@ export default function ReportsV2() { }); await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body }); } else if (report === 'revenueDetailed') { - const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "" ]); - 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 }); + 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 }); } 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 = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); }); @@ -1230,13 +1263,13 @@ export default function ReportsV2() { const title = ev?.title || evId; 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)), 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)), Number((f.feesByMethod?.[m] || 0).toFixed(2)), Number((f.expectedInBankByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); + if (m === "cash") (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", "", "", Number((d.value * d.count).toFixed(2)), ""])); }); - if ((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 cash", "Actual", "Variance"], rows }, subject, body }); + await emailReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance"], rows }, subject, body }); } else if (report === 'financeReport') { const rows: (string | number)[][] = []; Object.keys(financialsByEvent).forEach(evId => { @@ -1255,26 +1288,27 @@ 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, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]); + rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]); + rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]); }); await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body }); } 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?.netProfit || 0).toFixed(2))]; + return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))]; }); - await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows }, subject, body }); + await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Fees", "Net profit (after fees)"], rows }, subject, body }); } else if (report === 'cashupAudit') { 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).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""])); + (r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number(((l.expectedAmount || 0) + (l.feeAmount || 0)).toFixed(2)), Number((l.feeAmount || 0).toFixed(2)), Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""])); } }); - await emailReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }, subject, body }); + await emailReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }, subject, body }); } alert('Email sent with PDF attachment'); } catch (e: any) { @@ -1710,6 +1744,8 @@ export default function ReportsV2() { Email Status Total paid + Fee + Net after fee Outstanding Registration @@ -1721,6 +1757,8 @@ export default function ReportsV2() { {r.userEmail || ''} {r.status || ''} R {Number(r.totalPaid || 0).toFixed(2)} + R {Number(r.fee || 0).toFixed(2)} + R {Number(r.netAfterFee || 0).toFixed(2)} R {Number(r.outstanding || 0).toFixed(2)} {r.registrationId || ''} @@ -1823,6 +1861,7 @@ export default function ReportsV2() { Totals: Order: R {masterTotals?.orderTotal.toFixed(2)} Paid: R {masterTotals?.totalPaid.toFixed(2)} + Fee: R {(masterTotals?.fee ?? 0).toFixed(2)} Outstanding: R {masterTotals?.outstanding.toFixed(2)} @@ -1840,6 +1879,7 @@ export default function ReportsV2() { Order Total Paid + Fee Outstanding Donations @@ -1859,6 +1899,7 @@ export default function ReportsV2() { R {r.orderTotal.toFixed(2)} R {r.totalPaid.toFixed(2)} + R {(r.fee || 0).toFixed(2)} R {r.outstanding.toFixed(2)} R {r.donations.toFixed(2)} @@ -1874,6 +1915,7 @@ export default function ReportsV2() { R {masterTotals?.orderTotal.toFixed(2)} R {masterTotals?.totalPaid.toFixed(2)} + R {(masterTotals?.fee ?? 0).toFixed(2)} R {masterTotals?.outstanding.toFixed(2)} R {masterTotals?.donations.toFixed(2)} @@ -1886,7 +1928,7 @@ export default function ReportsV2() { ))} - + @@ -1912,7 +1954,7 @@ export default function ReportsV2() {
- + {METHOD_KEYS.map(m => { const r = f.reconciled?.byMethod?.[m]; @@ -1923,13 +1965,15 @@ export default function ReportsV2() { + + {m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => ( - + ))} @@ -2011,7 +2055,8 @@ export default function ReportsV2() {
Donations counted as profitR {(f.unallocatedDonationsTotal || 0).toFixed(2)}
-
Net profitR {(f.netProfit || 0).toFixed(2)}
+
Yoco processing fees-R {(f.totalFees || 0).toFixed(2)}
+
Net profit (after fees)R {(f.netProfitAfterFees || 0).toFixed(2)}
); }) @@ -2024,7 +2069,7 @@ export default function ReportsV2() {
No data available. Select events and view.
) : (
MethodIncomeCosts from methodExpected cashActualVariance
MethodIncomeCosts from methodExpected (before fees)Yoco feeExpected (after fees)ActualVariance
R {(f.paymentsByMethod?.[m] || 0).toFixed(2)} R {(f.costsByMethod?.[m] || 0).toFixed(2)} R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}R {(f.feesByMethod?.[m] || 0).toFixed(2)}R {(f.expectedInBankByMethod?.[m] || 0).toFixed(2)} {r?.actual != null ? `R ${r.actual.toFixed(2)}` : not reconciled} {r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}
{denomLabel(d.value)} × {d.count} R {(d.value * d.count).toFixed(2)}
- + {Object.keys(financialsByEvent).map(evId => { const f = financialsByEvent[evId]; @@ -2034,7 +2079,8 @@ export default function ReportsV2() { - + + ); })} @@ -2063,11 +2109,13 @@ export default function ReportsV2() { {(r.action === "closed" || r.action === "quick_closed") && (
EventRevenueCostsNet profit
EventRevenueCostsFeesNet profit (after fees)
{ev?.title || evId} R {(f?.effectiveTotalRevenue || 0).toFixed(2)} R {(f?.totalCosts || 0).toFixed(2)}R {(f?.netProfit || 0).toFixed(2)}R {(f?.totalFees || 0).toFixed(2)}R {(f?.netProfitAfterFees || 0).toFixed(2)}
- + - {(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, actualAmount: null, variance: null }))).map((l: any) => ( + {(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, feeAmount: null, actualAmount: null, variance: null }))).map((l: any) => ( + + diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d0252f9..dabc58f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -76,6 +76,7 @@ export type EventCashupLine = { cashupId: string; method: CashupMethod; expectedAmount: number; + feeAmount?: number | null; actualAmount?: number | null; variance?: number | null; notes?: string | null; @@ -102,6 +103,7 @@ export type EventCashup = { export type ReconciledByMethod = { expected: number; + fee: number; actual: number | null; variance: number | null; notes?: string | null; @@ -133,10 +135,14 @@ export type EventFinancials = { untaggedCostsTotal: number; totalCosts: number; expectedCashByMethod: Record; + expectedInBankByMethod: Record; reconciled: ReconciledSnapshot | null; effectiveGrossIncomeByMethod: Record; effectiveTotalRevenue: number; netProfit: number; + feesByMethod: Record; + totalFees: number; + netProfitAfterFees: number; unallocatedDonations: Payment[]; unallocatedDonationsTotal: number; totalDonations: number;
MethodExpectedActualVarianceΔ vs previous close
MethodExpected (before fees)Yoco feeExpected (after fees)ActualVarianceΔ vs previous close
{METHOD_LABEL[l.method] || l.method}{l.expectedAmount != null ? `R ${(l.expectedAmount + (l.feeAmount || 0)).toFixed(2)}` : "—"}{l.feeAmount != null ? `R ${l.feeAmount.toFixed(2)}` : "—"} {l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"} {l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : not reconciled} {l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}