Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f88ce5e0e | ||
|
|
1815f78c85 | ||
|
|
29036d0612 | ||
|
|
c07e9c928a |
+21
-1
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Yoco processing fee tracking: Settings → Payments lets you pick your Yoco plan (Core/Plus/Pro) to pre-fill estimated in-person and online fee percentages, both freely editable. The church always absorbs this fee — it's never added to what attendees pay.
|
||||||
|
- Every card payment now snapshots its estimated Yoco fee at the time it's recorded, so later changes to the settings don't retroactively alter historical reports.
|
||||||
|
- Finance report, Profit report, Master Orders, and Revenue Detailed (including their CSV/PDF/email exports) now show the estimated Yoco fee and a "net after fees" figure alongside gross revenue, so the numbers reflect what actually lands in the bank.
|
||||||
|
- Cashup reconciliation (the per-event close-out screen, the Cashup report, and the Cashup Audit Trail) now shows three figures for the card method: expected before Yoco fees, the fee itself, and expected after fees — plus overall Yoco fees and net-profit-after-fees totals — so counts reconcile against what actually settles from Yoco rather than the gross card total.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
|
||||||
|
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
|
||||||
|
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
|
||||||
|
|
||||||
## [1.2.0] - 2026-07-27
|
## [1.2.0] - 2026-07-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -57,7 +76,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
||||||
|
|
||||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...main
|
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main
|
||||||
|
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
|
||||||
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
|
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
|
||||||
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
|
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
|
||||||
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
|
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "feeChannel" TEXT,
|
||||||
|
ADD COLUMN "feePayer" TEXT,
|
||||||
|
ADD COLUMN "feeRate" DOUBLE PRECISION;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" DROP COLUMN "feePayerOnline";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Payment" DROP COLUMN "feePayer";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
|
||||||
@@ -221,6 +221,9 @@ model Payment {
|
|||||||
externalId String? @unique
|
externalId String? @unique
|
||||||
status String?
|
status String?
|
||||||
originalPaymentId String?
|
originalPaymentId String?
|
||||||
|
feeAmount Float?
|
||||||
|
feeRate Float?
|
||||||
|
feeChannel String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||||
@@ -461,6 +464,7 @@ model EventCashupLine {
|
|||||||
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
||||||
method String
|
method String
|
||||||
expectedAmount Float @default(0)
|
expectedAmount Float @default(0)
|
||||||
|
feeAmount Float?
|
||||||
actualAmount Float?
|
actualAmount Float?
|
||||||
variance Float?
|
variance Float?
|
||||||
notes String?
|
notes String?
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ const closeEvent = async (req, res) => {
|
|||||||
? lines
|
? lines
|
||||||
.filter(l => l && ALL_METHODS.includes(l.method))
|
.filter(l => l && ALL_METHODS.includes(l.method))
|
||||||
.map(l => {
|
.map(l => {
|
||||||
const expected = financials.expectedCashByMethod[l.method] || 0;
|
// expectedInBankByMethod nets out Yoco fees (only ever nonzero for 'card') so the
|
||||||
|
// variance reconciles against what should actually land in the bank, not the gross figure.
|
||||||
|
const expected = financials.expectedInBankByMethod[l.method] || 0;
|
||||||
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
|
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
|
||||||
? l.denominations
|
? l.denominations
|
||||||
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
|
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
|
||||||
@@ -66,6 +68,7 @@ const closeEvent = async (req, res) => {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
method: l.method,
|
method: l.method,
|
||||||
expectedAmount: expected,
|
expectedAmount: expected,
|
||||||
|
feeAmount: financials.feesByMethod[l.method] || 0,
|
||||||
actualAmount: actual,
|
actualAmount: actual,
|
||||||
variance: actual !== null ? actual - expected : null,
|
variance: actual !== null ? actual - expected : null,
|
||||||
notes: l.notes || null,
|
notes: l.notes || null,
|
||||||
@@ -77,7 +80,7 @@ const closeEvent = async (req, res) => {
|
|||||||
const totalActualRevenue = isFullCashup
|
const totalActualRevenue = isFullCashup
|
||||||
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
|
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
|
||||||
: null;
|
: null;
|
||||||
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0);
|
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedInBankByMethod[m], 0);
|
||||||
|
|
||||||
const cashup = await prisma.eventCashup.create({
|
const cashup = await prisma.eventCashup.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
const { assertEventOpen, bucketForMethod } = require('../utils/cashupUtils');
|
||||||
|
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
|
||||||
|
|
||||||
// @desc Create a new payment
|
// @desc Create a new payment
|
||||||
// @route POST /api/payments
|
// @route POST /api/payments
|
||||||
@@ -58,6 +59,14 @@ const createPayment = async (req, res) => {
|
|||||||
throw new Error('Payment method is required');
|
throw new Error('Payment method is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the Yoco fee estimate for this payment's method/amount at creation time —
|
||||||
|
// never recomputed later, so historical reports stay stable if settings change (see
|
||||||
|
// priceSnapshot in utils/pricing.js for the same philosophy). In-person is always
|
||||||
|
// church-borne: this app doesn't control the amount charged on a physical card machine.
|
||||||
|
const feeConfig = await getYocoFeeConfig();
|
||||||
|
const feeBucket = bucketForMethod(method);
|
||||||
|
const feeFor = (amt) => computeFeeSnapshot({ grossAmount: amt, channel: 'in_person', bucket: feeBucket, config: feeConfig });
|
||||||
|
|
||||||
// Validate required parameters based on isDonation flag
|
// Validate required parameters based on isDonation flag
|
||||||
if (isDonation && !eventId) {
|
if (isDonation && !eventId) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
@@ -160,7 +169,8 @@ const createPayment = async (req, res) => {
|
|||||||
registrationId: null,
|
registrationId: null,
|
||||||
eventId: registration.eventId,
|
eventId: registration.eventId,
|
||||||
isDonation: true,
|
isDonation: true,
|
||||||
createdAt: paidAtDate || undefined
|
createdAt: paidAtDate || undefined,
|
||||||
|
...feeFor(requestedAmount)
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
@@ -178,7 +188,8 @@ const createPayment = async (req, res) => {
|
|||||||
registrationId,
|
registrationId,
|
||||||
eventId: registrationEventId || eventId || null,
|
eventId: registrationEventId || eventId || null,
|
||||||
isDonation: false,
|
isDonation: false,
|
||||||
createdAt: paidAtDate || undefined
|
createdAt: paidAtDate || undefined,
|
||||||
|
...feeFor(applyAmount)
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
@@ -199,7 +210,8 @@ const createPayment = async (req, res) => {
|
|||||||
eventId: registration.eventId,
|
eventId: registration.eventId,
|
||||||
isDonation: true,
|
isDonation: true,
|
||||||
originalPaymentId: payment.id,
|
originalPaymentId: payment.id,
|
||||||
createdAt: paidAtDate || undefined
|
createdAt: paidAtDate || undefined,
|
||||||
|
...feeFor(excess)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -215,7 +227,8 @@ const createPayment = async (req, res) => {
|
|||||||
registrationId: registrationId || null,
|
registrationId: registrationId || null,
|
||||||
eventId: registrationEventId || eventId || null,
|
eventId: registrationEventId || eventId || null,
|
||||||
isDonation: isDonation || false,
|
isDonation: isDonation || false,
|
||||||
createdAt: paidAtDate || undefined
|
createdAt: paidAtDate || undefined,
|
||||||
|
...feeFor(parseFloat(amount))
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
|
|||||||
e.title AS "eventTitle",
|
e.title AS "eventTitle",
|
||||||
eo.id AS "eventOptionId",
|
eo.id AS "eventOptionId",
|
||||||
eo.name AS "eventOptionName",
|
eo.name AS "eventOptionName",
|
||||||
|
eo."isMainTicket" AS "isMainTicket",
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
|
|||||||
isUsed: r.isUsed,
|
isUsed: r.isUsed,
|
||||||
eventId: r.eventId,
|
eventId: r.eventId,
|
||||||
event: { title: r.eventTitle },
|
event: { title: r.eventTitle },
|
||||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
|
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
|
||||||
usages: r.usages || []
|
usages: r.usages || []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,6 +399,14 @@ const scanTicket = async (req, res) => {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
|
||||||
|
// the QR camera flow are unaffected)
|
||||||
|
if (ticket.registrationOption?.eventOption?.isMainTicket) {
|
||||||
|
require('../utils/notifications')
|
||||||
|
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
|
||||||
|
.catch(e => console.error('Failed to send check-in notification:', e));
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||||
ticketUsage,
|
ticketUsage,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const { v4: uuidv4 } = require('uuid');
|
|||||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||||
|
const { bucketForMethod } = require('../utils/cashupUtils');
|
||||||
|
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
|
||||||
|
|
||||||
// @desc Handle Yoco webhook events
|
// @desc Handle Yoco webhook events
|
||||||
// @route POST /api/webhooks/yoco
|
// @route POST /api/webhooks/yoco
|
||||||
@@ -313,18 +315,25 @@ const handlePaymentSucceeded = async (webhookData) => {
|
|||||||
} catch (e) { /* ignore — payment will fail below if still null */ }
|
} catch (e) { /* ignore — payment will fail below if still null */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the Yoco fee for this payment at creation time, using current settings — never
|
||||||
|
// recomputed later, so historical reports stay stable if settings change.
|
||||||
|
const grossAmount = amount / 100;
|
||||||
|
const feeConfig = await getYocoFeeConfig();
|
||||||
|
const feeSnapshot = computeFeeSnapshot({ grossAmount, channel: 'online', bucket: bucketForMethod(method.type), config: feeConfig });
|
||||||
|
|
||||||
// Create payment record
|
// Create payment record
|
||||||
const payment = await prisma.payment.create({
|
const payment = await prisma.payment.create({
|
||||||
data: {
|
data: {
|
||||||
id: uuidv4(), // Generate a UUID for the payment
|
id: uuidv4(), // Generate a UUID for the payment
|
||||||
amount: amount / 100, // Convert cents to your currency unit
|
amount: grossAmount, // Convert cents to your currency unit
|
||||||
method: method.type, // The type of payment from the webhook
|
method: method.type, // The type of payment from the webhook
|
||||||
status: status === 'succeeded' ? 'completed' : status,
|
status: status === 'succeeded' ? 'completed' : status,
|
||||||
externalId: yocoPaymentId,
|
externalId: yocoPaymentId,
|
||||||
registrationId: registration?.id || null,
|
registrationId: registration?.id || null,
|
||||||
userId: resolvedUserId,
|
userId: resolvedUserId,
|
||||||
eventId: registration?.eventId || metadata?.eventId || null,
|
eventId: registration?.eventId || metadata?.eventId || null,
|
||||||
isDonation: !registration?.id
|
isDonation: !registration?.id,
|
||||||
|
...feeSnapshot
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ async function computeEventFinancials(eventId) {
|
|||||||
}
|
}
|
||||||
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
|
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
|
||||||
|
// Yoco processing fees, snapshotted per-payment at creation time (see utils/yocoFees.js).
|
||||||
|
// Always absorbed by the church — never passed on to the buyer.
|
||||||
|
const feesByMethod = emptyByMethod();
|
||||||
|
for (const p of nonRefundPayments) {
|
||||||
|
if (p.feeAmount == null) continue;
|
||||||
|
feesByMethod[bucketForMethod(p.method)] += p.feeAmount;
|
||||||
|
}
|
||||||
|
const totalFees = ALL_METHODS.reduce((s, m) => s + feesByMethod[m], 0);
|
||||||
|
|
||||||
// Costs, with computed totals and attribution to a payment method's float (if tagged)
|
// Costs, with computed totals and attribution to a payment method's float (if tagged)
|
||||||
const costBreakdown = costs.map(c => {
|
const costBreakdown = costs.map(c => {
|
||||||
const total = c.costType === 'per_item'
|
const total = c.costType === 'per_item'
|
||||||
@@ -126,6 +135,13 @@ async function computeEventFinancials(eventId) {
|
|||||||
const expectedCashByMethod = emptyByMethod();
|
const expectedCashByMethod = emptyByMethod();
|
||||||
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
|
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
|
||||||
|
|
||||||
|
// What should actually land in the bank per method — same as expectedCashByMethod, minus
|
||||||
|
// Yoco's cut (only ever nonzero for 'card'). This is a reconciliation-only figure: it must
|
||||||
|
// NOT feed effectiveGrossIncomeByMethod/netProfit below, since totalFees is already subtracted
|
||||||
|
// there once via netProfitAfterFees — folding it into expectedCashByMethod too would double-count.
|
||||||
|
const expectedInBankByMethod = emptyByMethod();
|
||||||
|
for (const m of ALL_METHODS) expectedInBankByMethod[m] = expectedCashByMethod[m] - feesByMethod[m];
|
||||||
|
|
||||||
// Most recent reconciliation on record (if any) — the source of "actual" truth
|
// Most recent reconciliation on record (if any) — the source of "actual" truth
|
||||||
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
|
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
|
||||||
const reconciled = latestReconciled ? {
|
const reconciled = latestReconciled ? {
|
||||||
@@ -139,7 +155,8 @@ async function computeEventFinancials(eventId) {
|
|||||||
for (const m of ALL_METHODS) {
|
for (const m of ALL_METHODS) {
|
||||||
const line = latestReconciled.lines.find(l => l.method === m);
|
const line = latestReconciled.lines.find(l => l.method === m);
|
||||||
out[m] = {
|
out[m] = {
|
||||||
expected: line ? line.expectedAmount : expectedCashByMethod[m],
|
expected: line ? line.expectedAmount : expectedInBankByMethod[m],
|
||||||
|
fee: line && line.feeAmount != null ? line.feeAmount : feesByMethod[m],
|
||||||
actual: line && line.actualAmount != null ? line.actualAmount : null,
|
actual: line && line.actualAmount != null ? line.actualAmount : null,
|
||||||
variance: line && line.variance != null ? line.variance : null,
|
variance: line && line.variance != null ? line.variance : null,
|
||||||
notes: line ? line.notes : null,
|
notes: line ? line.notes : null,
|
||||||
@@ -161,6 +178,7 @@ async function computeEventFinancials(eventId) {
|
|||||||
}
|
}
|
||||||
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
||||||
const netProfit = effectiveTotalRevenue - totalCosts;
|
const netProfit = effectiveTotalRevenue - totalCosts;
|
||||||
|
const netProfitAfterFees = netProfit - totalFees;
|
||||||
|
|
||||||
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
|
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
|
||||||
const salesByOptionMap = {};
|
const salesByOptionMap = {};
|
||||||
@@ -183,10 +201,14 @@ async function computeEventFinancials(eventId) {
|
|||||||
untaggedCostsTotal,
|
untaggedCostsTotal,
|
||||||
totalCosts,
|
totalCosts,
|
||||||
expectedCashByMethod,
|
expectedCashByMethod,
|
||||||
|
expectedInBankByMethod,
|
||||||
reconciled,
|
reconciled,
|
||||||
effectiveGrossIncomeByMethod,
|
effectiveGrossIncomeByMethod,
|
||||||
effectiveTotalRevenue,
|
effectiveTotalRevenue,
|
||||||
netProfit,
|
netProfit,
|
||||||
|
feesByMethod,
|
||||||
|
totalFees,
|
||||||
|
netProfitAfterFees,
|
||||||
unallocatedDonations,
|
unallocatedDonations,
|
||||||
unallocatedDonationsTotal,
|
unallocatedDonationsTotal,
|
||||||
totalDonations,
|
totalDonations,
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTicketFull(ticketId) {
|
||||||
|
return prisma.ticket.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||||
|
event: true,
|
||||||
|
registrationOption: { include: { eventOption: true, variant: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPaymentFull(paymentId) {
|
async function loadPaymentFull(paymentId) {
|
||||||
return prisma.payment.findUnique({
|
return prisma.payment.findUnique({
|
||||||
where: { id: paymentId },
|
where: { id: paymentId },
|
||||||
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
|
|||||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
|
||||||
|
|
||||||
|
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
|
||||||
|
const org = getOrg();
|
||||||
|
const eventTitle = ticket.event?.title || 'the event';
|
||||||
|
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
|
||||||
|
const fullyCheckedIn = remaining <= 0;
|
||||||
|
const subject = `Checked in – ${eventTitle}`;
|
||||||
|
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! ✅</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||||
|
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
|
||||||
|
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
|
||||||
|
}`, fullyCheckedIn ? 'success' : 'info')}`;
|
||||||
|
|
||||||
|
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name} — ${org.email}`;
|
||||||
|
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Donation applied to a registration ────────────────────────────────────────
|
// ─── Donation applied to a registration ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
|
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
|
||||||
@@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
|
||||||
|
try {
|
||||||
|
const ticket = await loadTicketFull(ticketId);
|
||||||
|
if (!ticket) return;
|
||||||
|
const user = ticket.user;
|
||||||
|
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||||
|
const { buildWACheckIn } = require('./waMessages');
|
||||||
|
|
||||||
|
const sends = [];
|
||||||
|
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||||
|
if (hasValidEmail && shouldEmail(user)) {
|
||||||
|
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
|
||||||
|
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||||
|
}
|
||||||
|
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
|
||||||
|
if (hasValidEmail) {
|
||||||
|
sends.push(waText(user, waMsg));
|
||||||
|
} else {
|
||||||
|
sends.push(waTextAny(user, waMsg));
|
||||||
|
}
|
||||||
|
await Promise.all(sends);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send check-in emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sent when staff apply a donation to someone's registration. Only the registrant is
|
// Sent when staff apply a donation to someone's registration. Only the registrant is
|
||||||
// notified (anonymously, per design) — the donor already received their donation-received
|
// notified (anonymously, per design) — the donor already received their donation-received
|
||||||
// notification when the donation was originally made, so they are deliberately not emailed
|
// notification when the donation was originally made, so they are deliberately not emailed
|
||||||
@@ -981,4 +1045,6 @@ module.exports = {
|
|||||||
sendRefundEmail,
|
sendRefundEmail,
|
||||||
sendSelfServiceRegistrationEmails,
|
sendSelfServiceRegistrationEmails,
|
||||||
sendDonationAssignmentEmails,
|
sendDonationAssignmentEmails,
|
||||||
|
sendCheckInEmails,
|
||||||
|
buildCheckInConfirmation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
|
||||||
|
|
||||||
|
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
|
||||||
|
const org = getOrg();
|
||||||
|
const eventTitle = ticket.event?.title || 'the event';
|
||||||
|
const name = ticket.user?.name || 'there';
|
||||||
|
const fullyCheckedIn = remaining <= 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`✅ *Checked In*`,
|
||||||
|
'',
|
||||||
|
`Hi ${name},`,
|
||||||
|
'',
|
||||||
|
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
|
||||||
|
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
|
||||||
|
'',
|
||||||
|
`_${org.name}_ | ${org.url}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Login notification ───────────────────────────────────────────────────────
|
// ─── Login notification ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildWALogin({ name, when, location, userAgent }) {
|
function buildWALogin({ name, when, location, userAgent }) {
|
||||||
@@ -255,4 +275,5 @@ module.exports = {
|
|||||||
buildWAWelcome,
|
buildWAWelcome,
|
||||||
buildWAAccountClosed,
|
buildWAAccountClosed,
|
||||||
buildWATicketCaption,
|
buildWATicketCaption,
|
||||||
|
buildWACheckIn,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Yoco processing fee estimation.
|
||||||
|
*
|
||||||
|
* Yoco's real rate depends on plan, channel (in-person card machine vs. online checkout),
|
||||||
|
* monthly volume tier, and card type — none of which this app tracks. Instead the admin
|
||||||
|
* configures one flat, editable % per channel (Settings → Payments), pre-filled from a
|
||||||
|
* representative default when they pick a plan. The church always absorbs this fee — it's
|
||||||
|
* shown in reports so payout figures are accurate, never passed on to the buyer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { getSetting } = require('./settingsCache');
|
||||||
|
|
||||||
|
async function getYocoFeeConfig() {
|
||||||
|
const [inPersonPct, onlinePct] = await Promise.all([
|
||||||
|
getSetting('yoco_fee_in_person_pct', '0'),
|
||||||
|
getSetting('yoco_fee_online_pct', '0'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
inPersonRate: parseFloat(inPersonPct) || 0, // percent, e.g. 2.30
|
||||||
|
onlineRate: parseFloat(onlinePct) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot to store on a Payment row at creation time. Only 'card' payments are fee-eligible —
|
||||||
|
// cash/eft/voucher never incur a Yoco fee.
|
||||||
|
function computeFeeSnapshot({ grossAmount, channel, bucket, config }) {
|
||||||
|
if (bucket !== 'card') {
|
||||||
|
return { feeAmount: null, feeRate: null, feeChannel: null };
|
||||||
|
}
|
||||||
|
const rate = channel === 'online' ? config.onlineRate : config.inPersonRate;
|
||||||
|
const feeAmount = Math.round(grossAmount * (rate / 100) * 100) / 100;
|
||||||
|
return { feeAmount, feeRate: rate, feeChannel: channel };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getYocoFeeConfig, computeFeeSnapshot };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -372,7 +372,9 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
<th className="py-1">Method</th>
|
<th className="py-1">Method</th>
|
||||||
<th className="py-1 text-right">Income</th>
|
<th className="py-1 text-right">Income</th>
|
||||||
<th className="py-1 text-right">Costs from method</th>
|
<th className="py-1 text-right">Costs from method</th>
|
||||||
<th className="py-1 text-right">Expected cash</th>
|
<th className="py-1 text-right">Expected (before fees)</th>
|
||||||
|
<th className="py-1 text-right">Yoco fee</th>
|
||||||
|
<th className="py-1 text-right">Expected (after fees)</th>
|
||||||
<th className="py-1 text-right">Actual</th>
|
<th className="py-1 text-right">Actual</th>
|
||||||
<th className="py-1 text-right">Variance</th>
|
<th className="py-1 text-right">Variance</th>
|
||||||
<th className="py-1">Notes</th>
|
<th className="py-1">Notes</th>
|
||||||
@@ -381,12 +383,15 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
<tbody>
|
<tbody>
|
||||||
{METHODS.map(m => {
|
{METHODS.map(m => {
|
||||||
const r = reconciled?.byMethod?.[m];
|
const r = reconciled?.byMethod?.[m];
|
||||||
|
const expected = data.expectedInBankByMethod[m];
|
||||||
return (
|
return (
|
||||||
<tr key={m} className="border-b last:border-0 align-top">
|
<tr key={m} className="border-b last:border-0 align-top">
|
||||||
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
||||||
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
||||||
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
|
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
|
||||||
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
||||||
|
<td className="py-1.5 text-right">{money(data.feesByMethod[m])}</td>
|
||||||
|
<td className="py-1.5 text-right">{money(expected)}</td>
|
||||||
<td className="py-1.5 text-right">
|
<td className="py-1.5 text-right">
|
||||||
{isClosed ? (
|
{isClosed ? (
|
||||||
money(r?.actual ?? null)
|
money(r?.actual ?? null)
|
||||||
@@ -400,8 +405,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
{isClosed
|
{isClosed
|
||||||
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
||||||
: (m === "cash"
|
: (m === "cash"
|
||||||
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—")
|
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - expected) : "—")
|
||||||
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
|
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - expected) : "—"))}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-1.5">
|
||||||
{isClosed ? (r?.notes || "—") : (
|
{isClosed ? (r?.notes || "—") : (
|
||||||
@@ -447,7 +452,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-4 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||||
@@ -457,8 +462,12 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-gray-500 text-xs">Net profit</div>
|
<div className="text-gray-500 text-xs">Yoco fees</div>
|
||||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
<div className="font-semibold">{money(data.totalFees)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">Net profit (after fees)</div>
|
||||||
|
<div className="font-semibold">{money(data.netProfitAfterFees)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
|||||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
|
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "payments";
|
||||||
|
|
||||||
const TABS: { id: TabId; label: string }[] = [
|
const TABS: { id: TabId; label: string }[] = [
|
||||||
{ id: "organisation", label: "Organisation" },
|
{ id: "organisation", label: "Organisation" },
|
||||||
@@ -15,8 +15,19 @@ const TABS: { id: TabId; label: string }[] = [
|
|||||||
{ id: "notifications", label: "Notifications" },
|
{ id: "notifications", label: "Notifications" },
|
||||||
{ id: "email", label: "Email" },
|
{ id: "email", label: "Email" },
|
||||||
{ id: "legal", label: "Legal" },
|
{ id: "legal", label: "Legal" },
|
||||||
|
{ id: "payments", label: "Payments" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Default fee % pre-fill per Yoco plan — the "Up to R50k / Debit" rate, since debit is the
|
||||||
|
// most common consumer card type in SA and small churches likely stay in the lowest volume
|
||||||
|
// tier. Purely a UI convenience for the plan <select>; the actual % fields stay freely
|
||||||
|
// editable afterward so the admin can match their real blended rate.
|
||||||
|
const YOCO_PLAN_DEFAULTS: Record<"core" | "plus" | "pro", { inPerson: string; online: string }> = {
|
||||||
|
core: { inPerson: "2.30", online: "2.95" },
|
||||||
|
plus: { inPerson: "2.10", online: "2.75" },
|
||||||
|
pro: { inPerson: "1.95", online: "2.55" },
|
||||||
|
};
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
|
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
|
||||||
|
|
||||||
@@ -112,6 +123,11 @@ export default function SiteSettingsPage() {
|
|||||||
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
||||||
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
||||||
|
|
||||||
|
// ── Payments (Yoco fees) ──
|
||||||
|
const [yocoPlan, setYocoPlan] = useState<"core" | "plus" | "pro">("core");
|
||||||
|
const [feeInPersonPct, setFeeInPersonPct] = useState("");
|
||||||
|
const [feeOnlinePct, setFeeOnlinePct] = useState("");
|
||||||
|
|
||||||
// ── Load all settings once ────────────────────────────────────────────────
|
// ── Load all settings once ────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -138,6 +154,9 @@ export default function SiteSettingsPage() {
|
|||||||
setLegalIoEmail(s.legal_io_email || "");
|
setLegalIoEmail(s.legal_io_email || "");
|
||||||
setLegalWebsiteUrl(s.legal_website_url || "");
|
setLegalWebsiteUrl(s.legal_website_url || "");
|
||||||
setLegalEffectiveDate(s.legal_effective_date || "");
|
setLegalEffectiveDate(s.legal_effective_date || "");
|
||||||
|
setYocoPlan((s.yoco_plan as "core" | "plus" | "pro") || "core");
|
||||||
|
setFeeInPersonPct(s.yoco_fee_in_person_pct || "");
|
||||||
|
setFeeOnlinePct(s.yoco_fee_online_pct || "");
|
||||||
})
|
})
|
||||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||||
.finally(() => setLoadingInitial(false));
|
.finally(() => setLoadingInitial(false));
|
||||||
@@ -239,6 +258,19 @@ export default function SiteSettingsPage() {
|
|||||||
legal_effective_date: legalEffectiveDate.trim(),
|
legal_effective_date: legalEffectiveDate.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleYocoPlanChange = (plan: "core" | "plus" | "pro") => {
|
||||||
|
setYocoPlan(plan);
|
||||||
|
const defaults = YOCO_PLAN_DEFAULTS[plan];
|
||||||
|
setFeeInPersonPct(defaults.inPerson);
|
||||||
|
setFeeOnlinePct(defaults.online);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePayments = () => save({
|
||||||
|
yoco_plan: yocoPlan,
|
||||||
|
yoco_fee_in_person_pct: feeInPersonPct.trim(),
|
||||||
|
yoco_fee_online_pct: feeOnlinePct.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
const handleTestSmtp = async () => {
|
const handleTestSmtp = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setSmtpTesting(true);
|
setSmtpTesting(true);
|
||||||
@@ -505,6 +537,44 @@ export default function SiteSettingsPage() {
|
|||||||
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Payments (Yoco fees) ─────────────────────────────────────────────── */}
|
||||||
|
{activeTab === "payments" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Yoco deducts a processing fee from every card transaction before it reaches your bank account.
|
||||||
|
Reports use the estimate below to show a more accurate payout. Yoco'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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api";
|
|||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type Mode = "registration" | "payment" | "tickets" | "refund";
|
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<Mode, string> = {
|
||||||
|
registration: "REGISTRATION",
|
||||||
|
payment: "PAYMENT",
|
||||||
|
checkin: "CHECK-IN",
|
||||||
|
tickets: "TICKETS",
|
||||||
|
refund: "REFUND",
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -159,8 +167,7 @@ export default function AtTheDoorPage() {
|
|||||||
const [error, setError] = useDismissingState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
const handleRegistrationCreated = (registration: any) => {
|
const handleRegistrationCreated = (registration: any) => {
|
||||||
setActiveRegistration(registration);
|
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
|
||||||
setMode("payment"); // 🚀 Jump automatically
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegistrationSelected = (registration: any) => {
|
const handleRegistrationSelected = (registration: any) => {
|
||||||
@@ -171,7 +178,9 @@ export default function AtTheDoorPage() {
|
|||||||
if (!activeRegistration) return;
|
if (!activeRegistration) return;
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
setMode("payment");
|
// Fully paid → head straight to check-in; otherwise there's still a
|
||||||
|
// balance to collect, so go capture payment first.
|
||||||
|
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
@@ -317,28 +326,12 @@ export default function AtTheDoorPage() {
|
|||||||
|
|
||||||
const reg = registration || activeRegistration;
|
const reg = registration || activeRegistration;
|
||||||
|
|
||||||
try {
|
// Tickets are generated and emailed/WhatsApped automatically server-side
|
||||||
const res = await apiFetch<any>("/api/tickets/generate", {
|
// once a registration reaches "paid" (see paymentController) — no need to
|
||||||
method: "POST",
|
// fetch/print them here, just move straight to checking the attendee in.
|
||||||
authToken: token,
|
setActiveRegistration(reg);
|
||||||
body: {
|
setInfo("Payment recorded — tickets sent. Ready to check in.");
|
||||||
registrationId: reg.id
|
setMode("checkin");
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const tickets = res?.tickets || [];
|
|
||||||
|
|
||||||
if (tickets.length) {
|
|
||||||
printTickets(tickets);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInfo("Payment recorded & tickets printed");
|
|
||||||
setActiveRegistration(null);
|
|
||||||
setMode("registration");
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || "Tickets failed to generate");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDonation = (user?: any) => {
|
const handleDonation = (user?: any) => {
|
||||||
@@ -346,97 +339,6 @@ export default function AtTheDoorPage() {
|
|||||||
setShowDonationModal(true);
|
setShowDonationModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildTicketHtmlCard = (t: any) => {
|
|
||||||
const eventTitle =
|
|
||||||
t.event?.title ||
|
|
||||||
t.registrationOption?.registration?.event?.title ||
|
|
||||||
"Event";
|
|
||||||
|
|
||||||
const ticketType = ticketLabel(t);
|
|
||||||
const qty = t.quantity || 1;
|
|
||||||
const holder =
|
|
||||||
t.user?.name ||
|
|
||||||
t.registrationOption?.registration?.user?.name ||
|
|
||||||
"";
|
|
||||||
const qrValue = t.qrCode || t.id;
|
|
||||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
|
|
||||||
|
|
||||||
return `<div class="ticket">
|
|
||||||
<div class="top">
|
|
||||||
<div class="evt">${eventTitle}</div>
|
|
||||||
<div class="type">${ticketType}</div>
|
|
||||||
${holder ? `<div class="holder">${holder}</div>` : ""}
|
|
||||||
<div class="qty">Qty: <strong>${qty}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
|
||||||
<div class="meta">${t.id}</div>
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPrintWindow = (cardsHtml: string) => {
|
|
||||||
const w = window.open("", "_blank");
|
|
||||||
if (!w) return;
|
|
||||||
w.document.write(`<!doctype html><html><head><style>
|
|
||||||
@page { size: A4; margin: 8mm; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
|
|
||||||
.page {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
width: 194mm;
|
|
||||||
height: 281mm;
|
|
||||||
gap: 4mm;
|
|
||||||
page-break-after: always;
|
|
||||||
break-after: page;
|
|
||||||
}
|
|
||||||
.page:last-child { page-break-after: avoid; break-after: avoid; }
|
|
||||||
.ticket {
|
|
||||||
border: 1.5px solid #222;
|
|
||||||
border-radius: 3mm;
|
|
||||||
padding: 5mm;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.top { flex: 0 0 auto; }
|
|
||||||
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
|
|
||||||
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
|
|
||||||
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
|
|
||||||
.qty { font-size: 10pt; margin-top: 1mm; }
|
|
||||||
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1 1 auto; padding: 3mm 0; }
|
|
||||||
.qrwrap img { width: 48mm; height: 48mm; display: block; }
|
|
||||||
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
|
|
||||||
</style></head><body>${cardsHtml}<script>
|
|
||||||
(function(){
|
|
||||||
function go(){
|
|
||||||
var imgs=Array.prototype.slice.call(document.images);
|
|
||||||
if(!imgs.length){window.print();return;}
|
|
||||||
var n=imgs.length;
|
|
||||||
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
|
|
||||||
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
|
|
||||||
}
|
|
||||||
if(document.readyState==='complete')go();else window.addEventListener('load',go);
|
|
||||||
})();
|
|
||||||
</script></body></html>`);
|
|
||||||
w.document.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const printTickets = (tickets: any[]) => {
|
|
||||||
if (!tickets?.length) return;
|
|
||||||
// 4 per A4 page (2 columns × 2 rows)
|
|
||||||
const PAGE_SIZE = 4;
|
|
||||||
const pages: string[] = [];
|
|
||||||
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
|
|
||||||
const chunk = tickets.slice(i, i + PAGE_SIZE);
|
|
||||||
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
|
|
||||||
}
|
|
||||||
openPrintWindow(pages.join(""));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||||
|
|
||||||
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() {
|
|||||||
|
|
||||||
{/* Mode Buttons */}
|
{/* Mode Buttons */}
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => (
|
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
onClick={() => setMode(m)}
|
onClick={() => setMode(m)}
|
||||||
@@ -494,7 +396,7 @@ export default function AtTheDoorPage() {
|
|||||||
: "bg-white hover:bg-gray-50"
|
: "bg-white hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m.toUpperCase()}
|
{MODE_LABELS[m]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "checkin" && (
|
||||||
|
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === "tickets" && (
|
{mode === "tickets" && (
|
||||||
<DoorTicketsPanel token={token} eventId={eventId} />
|
<DoorTicketsPanel token={token} eventId={eventId} />
|
||||||
)}
|
)}
|
||||||
@@ -1081,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
|
||||||
|
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
|
||||||
|
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!token || !eventId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||||
|
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
|
||||||
|
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
|
||||||
|
} catch {
|
||||||
|
setTicketsForEvent([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [token, eventId]);
|
||||||
|
|
||||||
|
if (!registration) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
|
||||||
|
No registration selected
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const myTickets = ticketsForEvent
|
||||||
|
.filter(t => t.registrationOption?.registration?.id === registration.id)
|
||||||
|
.map(t => {
|
||||||
|
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||||
|
const remaining = (t.quantity || 1) - totalRedeemed;
|
||||||
|
return { ...t, totalRedeemed, remaining };
|
||||||
|
});
|
||||||
|
|
||||||
|
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
|
||||||
|
const setQtyFor = (t: any, n: number) =>
|
||||||
|
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
|
||||||
|
|
||||||
|
const commit = async (ticket: any) => {
|
||||||
|
const qty = qtyFor(ticket);
|
||||||
|
try {
|
||||||
|
setSubmittingId(ticket.id);
|
||||||
|
const res = await apiFetch<any>(
|
||||||
|
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
|
||||||
|
{ method: "POST", authToken: token, body: { qty } }
|
||||||
|
);
|
||||||
|
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
|
||||||
|
res?.remaining > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||||
|
}. Confirmation sent.`);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || "Check-in failed");
|
||||||
|
} finally {
|
||||||
|
setSubmittingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||||||
|
|
||||||
|
<div className="text-xl font-semibold mb-4">Check-In</div>
|
||||||
|
|
||||||
|
{/* ✅ User */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-lg font-semibold">
|
||||||
|
{registration.user?.name || "Guest"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-gray-500">Loading…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && myTickets.length === 0 && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
No Main Tickets on this registration
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{myTickets.map(t => {
|
||||||
|
const qty = qtyFor(t);
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="border rounded-lg p-4">
|
||||||
|
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-500">TOTAL</div>
|
||||||
|
<div className="text-lg font-semibold">{t.quantity || 1}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-500">CHECKED IN</div>
|
||||||
|
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-3 text-center bg-emerald-50">
|
||||||
|
<div className="text-xs text-gray-500">REMAINING</div>
|
||||||
|
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t.remaining > 0 ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setQtyFor(t, qty - 1)}
|
||||||
|
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setQtyFor(t, qty + 1)}
|
||||||
|
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => commit(t)}
|
||||||
|
disabled={submittingId === t.id}
|
||||||
|
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-emerald-600 font-medium">Fully checked in ✓</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
|
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -473,11 +473,26 @@ export default function ReportsV2() {
|
|||||||
return map;
|
return map;
|
||||||
}, [paymentsByEvent]);
|
}, [paymentsByEvent]);
|
||||||
|
|
||||||
|
// Yoco processing fee totals per registration, from the per-payment snapshot (see
|
||||||
|
// Payment.feeAmount in the backend) — never recomputed from current settings, so this
|
||||||
|
// always reflects what was actually estimated at payment time.
|
||||||
|
const feeByReg = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
Object.keys(paymentsByEvent).forEach(evId => {
|
||||||
|
(paymentsByEvent[evId] || []).forEach((p: any) => {
|
||||||
|
if (p.registrationId && p.feeAmount != null) {
|
||||||
|
map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.feeAmount || 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [paymentsByEvent]);
|
||||||
|
|
||||||
const revDetRows = useMemo(() => {
|
const revDetRows = useMemo(() => {
|
||||||
if (report !== "revenueDetailed") return [] as any[];
|
if (report !== "revenueDetailed") return [] as any[];
|
||||||
const df = payFrom ? new Date(payFrom).getTime() : null;
|
const df = payFrom ? new Date(payFrom).getTime() : null;
|
||||||
const dt = payTo ? new Date(payTo).getTime() : null;
|
const dt = payTo ? new Date(payTo).getTime() : null;
|
||||||
type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number };
|
type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number; fee: number; netAfterFee: number };
|
||||||
const rows: Row[] = [];
|
const rows: Row[] = [];
|
||||||
// Iterate registrations to include those with zero payments and show each once
|
// Iterate registrations to include those with zero payments and show each once
|
||||||
Object.keys(registrationsByEvent).forEach(evId => {
|
Object.keys(registrationsByEvent).forEach(evId => {
|
||||||
@@ -489,6 +504,7 @@ export default function ReportsV2() {
|
|||||||
if (dt && created && created > dt) return;
|
if (dt && created && created > dt) return;
|
||||||
const totalPaid = paidByReg.get(r.id) || 0;
|
const totalPaid = paidByReg.get(r.id) || 0;
|
||||||
const outstanding = outstandingByReg.get(r.id) || 0;
|
const outstanding = outstandingByReg.get(r.id) || 0;
|
||||||
|
const fee = feeByReg.get(r.id) || 0;
|
||||||
rows.push({
|
rows.push({
|
||||||
eventId: evId,
|
eventId: evId,
|
||||||
eventTitle: evTitle,
|
eventTitle: evTitle,
|
||||||
@@ -498,12 +514,14 @@ export default function ReportsV2() {
|
|||||||
status: r.status,
|
status: r.status,
|
||||||
registrationId: r.id,
|
registrationId: r.id,
|
||||||
outstanding,
|
outstanding,
|
||||||
|
fee,
|
||||||
|
netAfterFee: totalPaid - fee,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '')));
|
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '')));
|
||||||
return rows;
|
return rows;
|
||||||
}, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg]);
|
}, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg, feeByReg]);
|
||||||
|
|
||||||
// Derived for attendees (layered) for a single event
|
// Derived for attendees (layered) for a single event
|
||||||
const attendeesLayer = useMemo(() => {
|
const attendeesLayer = useMemo(() => {
|
||||||
@@ -631,6 +649,7 @@ export default function ReportsV2() {
|
|||||||
totalPaid: paidByReg.get(r.id) || 0,
|
totalPaid: paidByReg.get(r.id) || 0,
|
||||||
outstanding: outstandingByReg.get(r.id) || 0,
|
outstanding: outstandingByReg.get(r.id) || 0,
|
||||||
donations: donationsByUser.get(r.user?.id || r.userId) || 0,
|
donations: donationsByUser.get(r.user?.id || r.userId) || 0,
|
||||||
|
fee: feeByReg.get(r.id) || 0,
|
||||||
__prices: {} as Record<string, number> // 👈 hidden helper
|
__prices: {} as Record<string, number> // 👈 hidden helper
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -660,7 +679,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
|
|
||||||
}, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg]);
|
}, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg, feeByReg]);
|
||||||
|
|
||||||
//Master Report Totals
|
//Master Report Totals
|
||||||
const masterTotals = useMemo(() => {
|
const masterTotals = useMemo(() => {
|
||||||
@@ -671,6 +690,7 @@ export default function ReportsV2() {
|
|||||||
totalPaid: 0,
|
totalPaid: 0,
|
||||||
outstanding: 0,
|
outstanding: 0,
|
||||||
donations: 0,
|
donations: 0,
|
||||||
|
fee: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 👇 initialise dynamic option totals
|
// 👇 initialise dynamic option totals
|
||||||
@@ -683,6 +703,7 @@ export default function ReportsV2() {
|
|||||||
totals.orderTotal += row.orderTotal;
|
totals.orderTotal += row.orderTotal;
|
||||||
totals.totalPaid += row.totalPaid;
|
totals.totalPaid += row.totalPaid;
|
||||||
totals.outstanding += row.outstanding;
|
totals.outstanding += row.outstanding;
|
||||||
|
totals.fee += row.fee || 0;
|
||||||
|
|
||||||
masterOptions.forEach(opt => {
|
masterOptions.forEach(opt => {
|
||||||
const qty = row[opt.name] || 0;
|
const qty = row[opt.name] || 0;
|
||||||
@@ -761,6 +782,8 @@ export default function ReportsV2() {
|
|||||||
Email: r.userEmail || "",
|
Email: r.userEmail || "",
|
||||||
Status: r.status || "",
|
Status: r.status || "",
|
||||||
TotalPaid: r.totalPaid,
|
TotalPaid: r.totalPaid,
|
||||||
|
Fee: r.fee,
|
||||||
|
NetAfterFee: r.netAfterFee,
|
||||||
Outstanding: r.outstanding,
|
Outstanding: r.outstanding,
|
||||||
RegistrationId: r.registrationId
|
RegistrationId: r.registrationId
|
||||||
})));
|
})));
|
||||||
@@ -793,7 +816,7 @@ export default function ReportsV2() {
|
|||||||
return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows);
|
return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows);
|
||||||
}
|
}
|
||||||
if (report === "cashup") {
|
if (report === "cashup") {
|
||||||
const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedCash", "Actual", "Variance"];
|
const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance"];
|
||||||
const rows: any[] = [];
|
const rows: any[] = [];
|
||||||
Object.keys(financialsByEvent).forEach(evId => {
|
Object.keys(financialsByEvent).forEach(evId => {
|
||||||
const f = financialsByEvent[evId];
|
const f = financialsByEvent[evId];
|
||||||
@@ -802,15 +825,15 @@ export default function ReportsV2() {
|
|||||||
const title = ev?.title || evId;
|
const title = ev?.title || evId;
|
||||||
METHOD_KEYS.forEach(m => {
|
METHOD_KEYS.forEach(m => {
|
||||||
const r = f.reconciled?.byMethod?.[m];
|
const r = f.reconciled?.byMethod?.[m];
|
||||||
rows.push({ Event: title, Section: "Method", Detail: METHOD_LABEL[m], Income: f.paymentsByMethod?.[m] || 0, CostsFromMethod: f.costsByMethod?.[m] || 0, ExpectedCash: f.expectedCashByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" });
|
rows.push({ Event: title, Section: "Method", Detail: METHOD_LABEL[m], Income: f.paymentsByMethod?.[m] || 0, CostsFromMethod: f.costsByMethod?.[m] || 0, ExpectedBeforeFees: f.expectedCashByMethod?.[m] || 0, YocoFee: f.feesByMethod?.[m] || 0, ExpectedAfterFees: f.expectedInBankByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" });
|
||||||
if (m === "cash") {
|
if (m === "cash") {
|
||||||
(r?.denominations || []).forEach((d: any) => rows.push({ Event: title, Section: "Denomination", Detail: denomLabel(d.value) + " × " + d.count, Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: d.value * d.count, Variance: "" }));
|
(r?.denominations || []).forEach((d: any) => rows.push({ Event: title, Section: "Denomination", Detail: denomLabel(d.value) + " × " + d.count, Income: "", CostsFromMethod: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: d.value * d.count, Variance: "" }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if ((f.untaggedCostsTotal || 0) > 0) {
|
if ((f.untaggedCostsTotal || 0) > 0) {
|
||||||
rows.push({ Event: title, Section: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedCash: "", Actual: "", Variance: "" });
|
rows.push({ Event: title, Section: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "" });
|
||||||
}
|
}
|
||||||
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" });
|
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" });
|
||||||
});
|
});
|
||||||
return downloadCsv(`cashup_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
|
return downloadCsv(`cashup_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
|
||||||
}
|
}
|
||||||
@@ -833,7 +856,8 @@ export default function ReportsV2() {
|
|||||||
});
|
});
|
||||||
(f.costs || []).forEach((c: any) => rows.push({ Event: title, Section: "Costs", Detail: `${c.label}${c.paidFromMethod ? ` (paid via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Amount: -(c.total ?? c.amount) }));
|
(f.costs || []).forEach((c: any) => rows.push({ Event: title, Section: "Costs", Detail: `${c.label}${c.paidFromMethod ? ` (paid via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Amount: -(c.total ?? c.amount) }));
|
||||||
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 });
|
rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 });
|
||||||
rows.push({ Event: title, Section: "Net profit", Detail: "", Amount: f.netProfit || 0 });
|
rows.push({ Event: title, Section: "Fees", Detail: "Yoco processing fees", Amount: -(f.totalFees || 0) });
|
||||||
|
rows.push({ Event: title, Section: "Net profit", Detail: "After fees", Amount: f.netProfitAfterFees || 0 });
|
||||||
});
|
});
|
||||||
return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
|
return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c })));
|
||||||
}
|
}
|
||||||
@@ -841,20 +865,20 @@ export default function ReportsV2() {
|
|||||||
const rows = Object.keys(financialsByEvent).map(evId => {
|
const rows = Object.keys(financialsByEvent).map(evId => {
|
||||||
const f = financialsByEvent[evId];
|
const f = financialsByEvent[evId];
|
||||||
const ev = filteredEvents.find(e => e.id === evId);
|
const ev = filteredEvents.find(e => e.id === evId);
|
||||||
return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, NetProfit: f?.netProfit || 0 };
|
return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, Fees: f?.totalFees || 0, NetProfit: f?.netProfitAfterFees || 0 };
|
||||||
});
|
});
|
||||||
return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows);
|
return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows);
|
||||||
}
|
}
|
||||||
if (report === "cashupAudit") {
|
if (report === "cashupAudit") {
|
||||||
const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "Expected", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"];
|
const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "ExpectedBeforeFees", "YocoFee", "ExpectedAfterFees", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"];
|
||||||
const rows: any[] = [];
|
const rows: any[] = [];
|
||||||
auditRows.forEach((r: any) => {
|
auditRows.forEach((r: any) => {
|
||||||
const base = { Event: r.event?.title || r.eventId, Action: actionLabel(r.action), PerformedBy: r.performedBy?.name || "", Date: new Date(r.createdAt).toLocaleString() };
|
const base = { Event: r.event?.title || r.eventId, Action: actionLabel(r.action), PerformedBy: r.performedBy?.name || "", Date: new Date(r.createdAt).toLocaleString() };
|
||||||
if (r.action === "reopened") {
|
if (r.action === "reopened") {
|
||||||
rows.push({ ...base, Method: "", Expected: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" });
|
rows.push({ ...base, Method: "", ExpectedBeforeFees: "", YocoFee: "", ExpectedAfterFees: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" });
|
||||||
} else {
|
} else {
|
||||||
(r.lines || []).forEach((l: any) => rows.push({
|
(r.lines || []).forEach((l: any) => rows.push({
|
||||||
...base, Method: METHOD_LABEL[l.method] || l.method, Expected: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "",
|
...base, Method: METHOD_LABEL[l.method] || l.method, ExpectedBeforeFees: (l.expectedAmount || 0) + (l.feeAmount || 0), YocoFee: l.feeAmount ?? 0, ExpectedAfterFees: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "",
|
||||||
DeltaVsPreviousClose: r._deltaVsPrevious?.[l.method] ?? "", DonationsCountedAsProfit: r.unallocatedDonationsTotal || 0, Notes: l.notes || r.notes || ""
|
DeltaVsPreviousClose: r._deltaVsPrevious?.[l.method] ?? "", DonationsCountedAsProfit: r.unallocatedDonationsTotal || 0, Notes: l.notes || r.notes || ""
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -874,6 +898,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
OrderTotal: r.orderTotal,
|
OrderTotal: r.orderTotal,
|
||||||
Paid: r.totalPaid,
|
Paid: r.totalPaid,
|
||||||
|
Fee: r.fee,
|
||||||
Outstanding: r.outstanding,
|
Outstanding: r.outstanding,
|
||||||
Donations: r.donations
|
Donations: r.donations
|
||||||
}));
|
}));
|
||||||
@@ -891,6 +916,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
OrderTotal: masterTotals?.orderTotal ?? 0,
|
OrderTotal: masterTotals?.orderTotal ?? 0,
|
||||||
Paid: masterTotals?.totalPaid ?? 0,
|
Paid: masterTotals?.totalPaid ?? 0,
|
||||||
|
Fee: masterTotals?.fee ?? 0,
|
||||||
Outstanding: masterTotals?.outstanding ?? 0,
|
Outstanding: masterTotals?.outstanding ?? 0,
|
||||||
Donations: masterTotals?.donations ?? 0,
|
Donations: masterTotals?.donations ?? 0,
|
||||||
});
|
});
|
||||||
@@ -1006,6 +1032,8 @@ export default function ReportsV2() {
|
|||||||
r.userEmail || "",
|
r.userEmail || "",
|
||||||
r.status || "",
|
r.status || "",
|
||||||
Number((r.totalPaid || 0).toFixed(2)),
|
Number((r.totalPaid || 0).toFixed(2)),
|
||||||
|
Number((r.fee || 0).toFixed(2)),
|
||||||
|
Number((r.netAfterFee || 0).toFixed(2)),
|
||||||
Number((r.outstanding || 0).toFixed(2)),
|
Number((r.outstanding || 0).toFixed(2)),
|
||||||
r.registrationId || "",
|
r.registrationId || "",
|
||||||
]);
|
]);
|
||||||
@@ -1013,7 +1041,7 @@ export default function ReportsV2() {
|
|||||||
title: 'Revenue Detailed',
|
title: 'Revenue Detailed',
|
||||||
kind: 'table',
|
kind: 'table',
|
||||||
orientation: 'landscape',
|
orientation: 'landscape',
|
||||||
table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }
|
table: { columns: ["Event","Name","Email","Status","Total paid","Fee","Net after fee","Outstanding","RegistrationId"], rows }
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1042,15 +1070,15 @@ export default function ReportsV2() {
|
|||||||
const title = ev?.title || evId;
|
const title = ev?.title || evId;
|
||||||
METHOD_KEYS.forEach(m => {
|
METHOD_KEYS.forEach(m => {
|
||||||
const r = f.reconciled?.byMethod?.[m];
|
const r = f.reconciled?.byMethod?.[m];
|
||||||
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
|
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), Number((f.feesByMethod?.[m] || 0).toFixed(2)), Number((f.expectedInBankByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
|
||||||
if (m === "cash") {
|
if (m === "cash") {
|
||||||
(r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
|
(r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]);
|
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", "", "", ""]);
|
||||||
rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
|
rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
|
||||||
});
|
});
|
||||||
await downloadReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows } });
|
await downloadReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance"], rows } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (report === "financeReport") {
|
if (report === "financeReport") {
|
||||||
@@ -1073,7 +1101,8 @@ export default function ReportsV2() {
|
|||||||
rows.push([title, "Costs", "", ""]);
|
rows.push([title, "Costs", "", ""]);
|
||||||
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
|
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
|
||||||
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
|
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
|
||||||
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
|
rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]);
|
||||||
|
rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]);
|
||||||
});
|
});
|
||||||
await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } });
|
await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } });
|
||||||
return;
|
return;
|
||||||
@@ -1082,9 +1111,9 @@ export default function ReportsV2() {
|
|||||||
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
|
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
|
||||||
const f = financialsByEvent[evId];
|
const f = financialsByEvent[evId];
|
||||||
const ev = filteredEvents.find(e => e.id === evId);
|
const ev = filteredEvents.find(e => e.id === evId);
|
||||||
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))];
|
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))];
|
||||||
});
|
});
|
||||||
await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows } });
|
await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Fees", "Net profit (after fees)"], rows } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (report === "cashupAudit") {
|
if (report === "cashupAudit") {
|
||||||
@@ -1092,13 +1121,13 @@ export default function ReportsV2() {
|
|||||||
auditRows.forEach((r: any) => {
|
auditRows.forEach((r: any) => {
|
||||||
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
|
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
|
||||||
if (r.action === "reopened") {
|
if (r.action === "reopened") {
|
||||||
rows.push([...base, "", "", "", "", "", r.notes || ""]);
|
rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]);
|
||||||
} else {
|
} else {
|
||||||
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""]));
|
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number(((l.expectedAmount || 0) + (l.feeAmount || 0)).toFixed(2)), Number((l.feeAmount || 0).toFixed(2)), Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""]));
|
||||||
if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]);
|
if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } });
|
await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (report === "masterOrders") {
|
if (report === "masterOrders") {
|
||||||
@@ -1111,6 +1140,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
Number(r.orderTotal.toFixed(2)),
|
Number(r.orderTotal.toFixed(2)),
|
||||||
Number(r.totalPaid.toFixed(2)),
|
Number(r.totalPaid.toFixed(2)),
|
||||||
|
Number((r.fee || 0).toFixed(2)),
|
||||||
Number(r.outstanding.toFixed(2))
|
Number(r.outstanding.toFixed(2))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1124,6 +1154,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
Number((masterTotals?.orderTotal ?? 0).toFixed(2)),
|
Number((masterTotals?.orderTotal ?? 0).toFixed(2)),
|
||||||
Number((masterTotals?.totalPaid ?? 0).toFixed(2)),
|
Number((masterTotals?.totalPaid ?? 0).toFixed(2)),
|
||||||
|
Number((masterTotals?.fee ?? 0).toFixed(2)),
|
||||||
Number((masterTotals?.outstanding ?? 0).toFixed(2))
|
Number((masterTotals?.outstanding ?? 0).toFixed(2))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1137,6 +1168,7 @@ export default function ReportsV2() {
|
|||||||
`R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}`
|
`R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
""
|
""
|
||||||
@@ -1156,6 +1188,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
"Order Total",
|
"Order Total",
|
||||||
"Paid",
|
"Paid",
|
||||||
|
"Fee",
|
||||||
"Outstanding"
|
"Outstanding"
|
||||||
],
|
],
|
||||||
rows: bodyRows
|
rows: bodyRows
|
||||||
@@ -1217,8 +1250,8 @@ export default function ReportsV2() {
|
|||||||
});
|
});
|
||||||
await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body });
|
await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body });
|
||||||
} else if (report === 'revenueDetailed') {
|
} else if (report === 'revenueDetailed') {
|
||||||
const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "" ]);
|
const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.fee || 0).toFixed(2)), Number((r.netAfterFee || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "" ]);
|
||||||
await emailReportPdf(API_BASE, token, { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }, subject, body });
|
await emailReportPdf(API_BASE, token, { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', table: { columns: ["Event","Name","Email","Status","Total paid","Fee","Net after fee","Outstanding","RegistrationId"], rows }, subject, body });
|
||||||
} else if (report === 'regStatus') {
|
} else if (report === 'regStatus') {
|
||||||
const rows: (string|number)[][] = [];
|
const rows: (string|number)[][] = [];
|
||||||
Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); });
|
Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); });
|
||||||
@@ -1230,13 +1263,13 @@ export default function ReportsV2() {
|
|||||||
const title = ev?.title || evId;
|
const title = ev?.title || evId;
|
||||||
METHOD_KEYS.forEach(m => {
|
METHOD_KEYS.forEach(m => {
|
||||||
const r = f.reconciled?.byMethod?.[m];
|
const r = f.reconciled?.byMethod?.[m];
|
||||||
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
|
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), Number((f.feesByMethod?.[m] || 0).toFixed(2)), Number((f.expectedInBankByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
|
||||||
if (m === "cash") (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
|
if (m === "cash") (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
|
||||||
});
|
});
|
||||||
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]);
|
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", "", "", ""]);
|
||||||
rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
|
rows.push([title, "Donations counted as profit", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
|
||||||
});
|
});
|
||||||
await emailReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows }, subject, body });
|
await emailReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance"], rows }, subject, body });
|
||||||
} else if (report === 'financeReport') {
|
} else if (report === 'financeReport') {
|
||||||
const rows: (string | number)[][] = [];
|
const rows: (string | number)[][] = [];
|
||||||
Object.keys(financialsByEvent).forEach(evId => {
|
Object.keys(financialsByEvent).forEach(evId => {
|
||||||
@@ -1255,26 +1288,27 @@ export default function ReportsV2() {
|
|||||||
rows.push([title, "Costs", "", ""]);
|
rows.push([title, "Costs", "", ""]);
|
||||||
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
|
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
|
||||||
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
|
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
|
||||||
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
|
rows.push([title, "Fees", "Yoco processing fees", Number((-(f.totalFees || 0)).toFixed(2))]);
|
||||||
|
rows.push([title, "", "Net profit (after fees)", Number((f.netProfitAfterFees || 0).toFixed(2))]);
|
||||||
});
|
});
|
||||||
await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body });
|
await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body });
|
||||||
} else if (report === 'profitReport') {
|
} else if (report === 'profitReport') {
|
||||||
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
|
const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => {
|
||||||
const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId);
|
const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId);
|
||||||
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))];
|
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.totalFees || 0).toFixed(2)), Number((f?.netProfitAfterFees || 0).toFixed(2))];
|
||||||
});
|
});
|
||||||
await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows }, subject, body });
|
await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Fees", "Net profit (after fees)"], rows }, subject, body });
|
||||||
} else if (report === 'cashupAudit') {
|
} else if (report === 'cashupAudit') {
|
||||||
const rows: (string | number)[][] = [];
|
const rows: (string | number)[][] = [];
|
||||||
auditRows.forEach((r: any) => {
|
auditRows.forEach((r: any) => {
|
||||||
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
|
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
|
||||||
if (r.action === "reopened") {
|
if (r.action === "reopened") {
|
||||||
rows.push([...base, "", "", "", "", "", r.notes || ""]);
|
rows.push([...base, "", "", "", "", "", "", "", r.notes || ""]);
|
||||||
} else {
|
} else {
|
||||||
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""]));
|
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number(((l.expectedAmount || 0) + (l.feeAmount || 0)).toFixed(2)), Number((l.feeAmount || 0).toFixed(2)), Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""]));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await emailReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }, subject, body });
|
await emailReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected (before fees)", "Yoco fee", "Expected (after fees)", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }, subject, body });
|
||||||
}
|
}
|
||||||
alert('Email sent with PDF attachment');
|
alert('Email sent with PDF attachment');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -1710,6 +1744,8 @@ export default function ReportsV2() {
|
|||||||
<th className="text-left">Email</th>
|
<th className="text-left">Email</th>
|
||||||
<th className="text-left">Status</th>
|
<th className="text-left">Status</th>
|
||||||
<th className="text-left">Total paid</th>
|
<th className="text-left">Total paid</th>
|
||||||
|
<th className="text-left">Fee</th>
|
||||||
|
<th className="text-left">Net after fee</th>
|
||||||
<th className="text-left">Outstanding</th>
|
<th className="text-left">Outstanding</th>
|
||||||
<th className="text-left">Registration</th>
|
<th className="text-left">Registration</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1721,6 +1757,8 @@ export default function ReportsV2() {
|
|||||||
<td>{r.userEmail || ''}</td>
|
<td>{r.userEmail || ''}</td>
|
||||||
<td className="capitalize">{r.status || ''}</td>
|
<td className="capitalize">{r.status || ''}</td>
|
||||||
<td>R {Number(r.totalPaid || 0).toFixed(2)}</td>
|
<td>R {Number(r.totalPaid || 0).toFixed(2)}</td>
|
||||||
|
<td>R {Number(r.fee || 0).toFixed(2)}</td>
|
||||||
|
<td>R {Number(r.netAfterFee || 0).toFixed(2)}</td>
|
||||||
<td>R {Number(r.outstanding || 0).toFixed(2)}</td>
|
<td>R {Number(r.outstanding || 0).toFixed(2)}</td>
|
||||||
<td className="font-mono text-xs">{r.registrationId || ''}</td>
|
<td className="font-mono text-xs">{r.registrationId || ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1823,6 +1861,7 @@ export default function ReportsV2() {
|
|||||||
<span className="font-medium">Totals:</span>
|
<span className="font-medium">Totals:</span>
|
||||||
<span className="ml-3">Order: R {masterTotals?.orderTotal.toFixed(2)}</span>
|
<span className="ml-3">Order: R {masterTotals?.orderTotal.toFixed(2)}</span>
|
||||||
<span className="ml-3">Paid: R {masterTotals?.totalPaid.toFixed(2)}</span>
|
<span className="ml-3">Paid: R {masterTotals?.totalPaid.toFixed(2)}</span>
|
||||||
|
<span className="ml-3">Fee: R {(masterTotals?.fee ?? 0).toFixed(2)}</span>
|
||||||
<span className="ml-3">Outstanding: R {masterTotals?.outstanding.toFixed(2)}</span>
|
<span className="ml-3">Outstanding: R {masterTotals?.outstanding.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1840,6 +1879,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
<th>Order Total</th>
|
<th>Order Total</th>
|
||||||
<th>Paid</th>
|
<th>Paid</th>
|
||||||
|
<th>Fee</th>
|
||||||
<th>Outstanding</th>
|
<th>Outstanding</th>
|
||||||
<th>Donations</th>
|
<th>Donations</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1859,6 +1899,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
<td>R {r.orderTotal.toFixed(2)}</td>
|
<td>R {r.orderTotal.toFixed(2)}</td>
|
||||||
<td>R {r.totalPaid.toFixed(2)}</td>
|
<td>R {r.totalPaid.toFixed(2)}</td>
|
||||||
|
<td>R {(r.fee || 0).toFixed(2)}</td>
|
||||||
<td>R {r.outstanding.toFixed(2)}</td>
|
<td>R {r.outstanding.toFixed(2)}</td>
|
||||||
<td>R {r.donations.toFixed(2)}</td>
|
<td>R {r.donations.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1874,6 +1915,7 @@ export default function ReportsV2() {
|
|||||||
|
|
||||||
<td>R {masterTotals?.orderTotal.toFixed(2)}</td>
|
<td>R {masterTotals?.orderTotal.toFixed(2)}</td>
|
||||||
<td>R {masterTotals?.totalPaid.toFixed(2)}</td>
|
<td>R {masterTotals?.totalPaid.toFixed(2)}</td>
|
||||||
|
<td>R {(masterTotals?.fee ?? 0).toFixed(2)}</td>
|
||||||
<td>R {masterTotals?.outstanding.toFixed(2)}</td>
|
<td>R {masterTotals?.outstanding.toFixed(2)}</td>
|
||||||
<td>R {masterTotals?.donations.toFixed(2)}</td>
|
<td>R {masterTotals?.donations.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1886,7 +1928,7 @@ export default function ReportsV2() {
|
|||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<td colSpan={4}></td>
|
<td colSpan={5}></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -1912,7 +1954,7 @@ export default function ReportsV2() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="min-w-[640px] text-sm">
|
<table className="min-w-[640px] text-sm">
|
||||||
<thead><tr><th className="text-left">Method</th><th className="text-left">Income</th><th className="text-left">Costs from method</th><th className="text-left">Expected cash</th><th className="text-left">Actual</th><th className="text-left">Variance</th></tr></thead>
|
<thead><tr><th className="text-left">Method</th><th className="text-left">Income</th><th className="text-left">Costs from method</th><th className="text-left">Expected (before fees)</th><th className="text-left">Yoco fee</th><th className="text-left">Expected (after fees)</th><th className="text-left">Actual</th><th className="text-left">Variance</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{METHOD_KEYS.map(m => {
|
{METHOD_KEYS.map(m => {
|
||||||
const r = f.reconciled?.byMethod?.[m];
|
const r = f.reconciled?.byMethod?.[m];
|
||||||
@@ -1923,13 +1965,15 @@ export default function ReportsV2() {
|
|||||||
<td>R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}</td>
|
<td>R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}</td>
|
||||||
<td>R {(f.costsByMethod?.[m] || 0).toFixed(2)}</td>
|
<td>R {(f.costsByMethod?.[m] || 0).toFixed(2)}</td>
|
||||||
<td>R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td>
|
<td>R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td>
|
||||||
|
<td>R {(f.feesByMethod?.[m] || 0).toFixed(2)}</td>
|
||||||
|
<td>R {(f.expectedInBankByMethod?.[m] || 0).toFixed(2)}</td>
|
||||||
<td>{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
|
<td>{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
|
||||||
<td className={r?.variance != null && r.variance !== 0 ? (r.variance < 0 ? "text-red-600" : "text-emerald-600") : ""}>{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td>
|
<td className={r?.variance != null && r.variance !== 0 ? (r.variance < 0 ? "text-red-600" : "text-emerald-600") : ""}>{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => (
|
{m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => (
|
||||||
<tr key={d.id} className="text-xs text-gray-500">
|
<tr key={d.id} className="text-xs text-gray-500">
|
||||||
<td className="pl-4">{denomLabel(d.value)} × {d.count}</td>
|
<td className="pl-4">{denomLabel(d.value)} × {d.count}</td>
|
||||||
<td colSpan={4}></td>
|
<td colSpan={6}></td>
|
||||||
<td>R {(d.value * d.count).toFixed(2)}</td>
|
<td>R {(d.value * d.count).toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -2011,7 +2055,8 @@ export default function ReportsV2() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm flex items-center justify-between border-t pt-1"><span>Donations counted as profit</span><span>R {(f.unallocatedDonationsTotal || 0).toFixed(2)}</span></div>
|
<div className="text-sm flex items-center justify-between border-t pt-1"><span>Donations counted as profit</span><span>R {(f.unallocatedDonationsTotal || 0).toFixed(2)}</span></div>
|
||||||
<div className="text-sm font-semibold flex items-center justify-between"><span>Net profit</span><span>R {(f.netProfit || 0).toFixed(2)}</span></div>
|
<div className="text-sm flex items-center justify-between"><span>Yoco processing fees</span><span>-R {(f.totalFees || 0).toFixed(2)}</span></div>
|
||||||
|
<div className="text-sm font-semibold flex items-center justify-between"><span>Net profit (after fees)</span><span>R {(f.netProfitAfterFees || 0).toFixed(2)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -2024,7 +2069,7 @@ export default function ReportsV2() {
|
|||||||
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
|
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="min-w-[600px] text-sm">
|
<table className="min-w-[600px] text-sm">
|
||||||
<thead><tr><th className="text-left">Event</th><th className="text-left">Revenue</th><th className="text-left">Costs</th><th className="text-left">Net profit</th></tr></thead>
|
<thead><tr><th className="text-left">Event</th><th className="text-left">Revenue</th><th className="text-left">Costs</th><th className="text-left">Fees</th><th className="text-left">Net profit (after fees)</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.keys(financialsByEvent).map(evId => {
|
{Object.keys(financialsByEvent).map(evId => {
|
||||||
const f = financialsByEvent[evId];
|
const f = financialsByEvent[evId];
|
||||||
@@ -2034,7 +2079,8 @@ export default function ReportsV2() {
|
|||||||
<td>{ev?.title || evId}</td>
|
<td>{ev?.title || evId}</td>
|
||||||
<td>R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td>
|
<td>R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td>
|
||||||
<td>R {(f?.totalCosts || 0).toFixed(2)}</td>
|
<td>R {(f?.totalCosts || 0).toFixed(2)}</td>
|
||||||
<td className="font-medium">R {(f?.netProfit || 0).toFixed(2)}</td>
|
<td>R {(f?.totalFees || 0).toFixed(2)}</td>
|
||||||
|
<td className="font-medium">R {(f?.netProfitAfterFees || 0).toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2063,11 +2109,13 @@ export default function ReportsV2() {
|
|||||||
{(r.action === "closed" || r.action === "quick_closed") && (
|
{(r.action === "closed" || r.action === "quick_closed") && (
|
||||||
<div className="overflow-auto mt-2">
|
<div className="overflow-auto mt-2">
|
||||||
<table className="min-w-[560px] text-sm">
|
<table className="min-w-[560px] text-sm">
|
||||||
<thead><tr><th className="text-left">Method</th><th className="text-left">Expected</th><th className="text-left">Actual</th><th className="text-left">Variance</th><th className="text-left">Δ vs previous close</th></tr></thead>
|
<thead><tr><th className="text-left">Method</th><th className="text-left">Expected (before fees)</th><th className="text-left">Yoco fee</th><th className="text-left">Expected (after fees)</th><th className="text-left">Actual</th><th className="text-left">Variance</th><th className="text-left">Δ vs previous close</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, actualAmount: null, variance: null }))).map((l: any) => (
|
{(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, feeAmount: null, actualAmount: null, variance: null }))).map((l: any) => (
|
||||||
<tr key={l.method}>
|
<tr key={l.method}>
|
||||||
<td>{METHOD_LABEL[l.method] || l.method}</td>
|
<td>{METHOD_LABEL[l.method] || l.method}</td>
|
||||||
|
<td>{l.expectedAmount != null ? `R ${(l.expectedAmount + (l.feeAmount || 0)).toFixed(2)}` : "—"}</td>
|
||||||
|
<td>{l.feeAmount != null ? `R ${l.feeAmount.toFixed(2)}` : "—"}</td>
|
||||||
<td>{l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"}</td>
|
<td>{l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"}</td>
|
||||||
<td>{l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
|
<td>{l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
|
||||||
<td>{l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}</td>
|
<td>{l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}</td>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export type EventCashupLine = {
|
|||||||
cashupId: string;
|
cashupId: string;
|
||||||
method: CashupMethod;
|
method: CashupMethod;
|
||||||
expectedAmount: number;
|
expectedAmount: number;
|
||||||
|
feeAmount?: number | null;
|
||||||
actualAmount?: number | null;
|
actualAmount?: number | null;
|
||||||
variance?: number | null;
|
variance?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
@@ -102,6 +103,7 @@ export type EventCashup = {
|
|||||||
|
|
||||||
export type ReconciledByMethod = {
|
export type ReconciledByMethod = {
|
||||||
expected: number;
|
expected: number;
|
||||||
|
fee: number;
|
||||||
actual: number | null;
|
actual: number | null;
|
||||||
variance: number | null;
|
variance: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
@@ -133,10 +135,14 @@ export type EventFinancials = {
|
|||||||
untaggedCostsTotal: number;
|
untaggedCostsTotal: number;
|
||||||
totalCosts: number;
|
totalCosts: number;
|
||||||
expectedCashByMethod: Record<CashupMethod, number>;
|
expectedCashByMethod: Record<CashupMethod, number>;
|
||||||
|
expectedInBankByMethod: Record<CashupMethod, number>;
|
||||||
reconciled: ReconciledSnapshot | null;
|
reconciled: ReconciledSnapshot | null;
|
||||||
effectiveGrossIncomeByMethod: Record<CashupMethod, number>;
|
effectiveGrossIncomeByMethod: Record<CashupMethod, number>;
|
||||||
effectiveTotalRevenue: number;
|
effectiveTotalRevenue: number;
|
||||||
netProfit: number;
|
netProfit: number;
|
||||||
|
feesByMethod: Record<CashupMethod, number>;
|
||||||
|
totalFees: number;
|
||||||
|
netProfitAfterFees: number;
|
||||||
unallocatedDonations: Payment[];
|
unallocatedDonations: Payment[];
|
||||||
unallocatedDonationsTotal: number;
|
unallocatedDonationsTotal: number;
|
||||||
totalDonations: number;
|
totalDonations: number;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user