Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.
Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
read-only "Report" tab that opens automatically once an event is closed.
Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
toggles), searchable/categorized report grid, and a popup viewer with
Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.
Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
disconnected session, instead of a bare axios status-code message.
Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
|
const { emailTickets } = require('./ticketController');
|
|
|
|
function getYocoModel() {
|
|
// Gracefully handle environments where the Prisma client hasn't been regenerated
|
|
// and YocoTransaction model is not available yet.
|
|
return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null;
|
|
}
|
|
|
|
// @desc Get all Yoco transactions (optionally filter by reconciled and ignored)
|
|
// @route GET /api/yoco-transactions
|
|
// @access Private/Admin or Supervisor
|
|
const getAllYocoTransactions = async (req, res) => {
|
|
try {
|
|
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
}
|
|
|
|
const { reconciled, ignored } = req.query;
|
|
const where = {};
|
|
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
|
|
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
|
|
|
|
const Yoco = getYocoModel();
|
|
if (!Yoco) {
|
|
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
|
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
|
}
|
|
|
|
const items = await Yoco.findMany({
|
|
where,
|
|
orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }]
|
|
});
|
|
|
|
return res.status(200).json({ success: true, data: items });
|
|
} catch (error) {
|
|
console.error('Failed to fetch Yoco transactions:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get unreconciled Yoco transactions (not reconciled and not ignored)
|
|
// @route GET /api/yoco-transactions/unreconciled
|
|
// @access Private/Admin or Supervisor
|
|
const getUnreconciledYocoTransactions = async (req, res) => {
|
|
try {
|
|
// Basic role check if auth middleware sets req.user
|
|
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
}
|
|
|
|
const Yoco = getYocoModel();
|
|
if (!Yoco) {
|
|
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
|
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
|
}
|
|
|
|
const items = await Yoco.findMany({
|
|
where: { reconciled: false, ignored: false },
|
|
orderBy: { createdDate: 'desc' }
|
|
});
|
|
|
|
return res.status(200).json({ success: true, data: items });
|
|
} catch (error) {
|
|
console.error('Failed to fetch unreconciled Yoco transactions:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment)
|
|
// @route POST /api/yoco-transactions/:id/reconcile
|
|
// @access Private/Admin or Supervisor
|
|
const reconcileYocoTransaction = async (req, res) => {
|
|
try {
|
|
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
}
|
|
|
|
const Yoco = getYocoModel();
|
|
if (!Yoco) {
|
|
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const { registrationId, eventId } = req.body || {};
|
|
|
|
const ytx = await Yoco.findUnique({ where: { id } });
|
|
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
|
if (ytx.reconciled && ytx.paymentId) {
|
|
return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx });
|
|
}
|
|
|
|
// Load registration if provided
|
|
let registration = null;
|
|
if (registrationId) {
|
|
registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
event: true,
|
|
user: true,
|
|
registrationOptions: { include: { eventOption: true } },
|
|
payments: true
|
|
}
|
|
});
|
|
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
|
|
}
|
|
|
|
// Determine payment fields
|
|
const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0;
|
|
if (!amountFloat || amountFloat <= 0) {
|
|
return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' });
|
|
}
|
|
|
|
const externalId = ytx.externalId || undefined;
|
|
|
|
// Resolve a valid userId for the Payment to satisfy FK constraints
|
|
let resolvedUserId = null;
|
|
if (registration?.userId) {
|
|
resolvedUserId = registration.userId;
|
|
} else if (ytx?.raw?.payload?.metadata?.userId) {
|
|
const metaUserId = String(ytx.raw.payload.metadata.userId);
|
|
try {
|
|
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
|
|
if (exists) resolvedUserId = metaUserId;
|
|
} catch {}
|
|
}
|
|
if (!resolvedUserId && req.user?.id) {
|
|
// Fallback to the acting supervisor/admin to avoid FK violations
|
|
resolvedUserId = req.user.id;
|
|
}
|
|
if (!resolvedUserId) {
|
|
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
|
|
}
|
|
|
|
// Build payment data
|
|
const paymentData = {
|
|
amount: amountFloat,
|
|
method: ytx.methodType || 'card',
|
|
userId: resolvedUserId,
|
|
recordedById: req.user?.id || null, // staff who performed the reconciliation
|
|
registrationId: registration?.id || null,
|
|
eventId: registration?.eventId || eventId || null,
|
|
isDonation: !registration?.id,
|
|
externalId: externalId,
|
|
status: 'completed'
|
|
};
|
|
|
|
// Ensure donation has an eventId
|
|
if (!registration && !paymentData.eventId) {
|
|
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
|
|
}
|
|
|
|
// Create payment
|
|
// Preserve original Yoco creation time to correctly evaluate early-bird pricing
|
|
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
|
|
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
|
|
|
// Optionally update registration status when applicable and generate/email tickets if paid
|
|
let generatedTickets = [];
|
|
if (registration) {
|
|
try {
|
|
const updatedReg = await updateRegistrationStatus(registration.id);
|
|
if (updatedReg && updatedReg.status === 'paid') {
|
|
try {
|
|
generatedTickets = await generateTicketsForRegistration(registration.id);
|
|
if (Array.isArray(generatedTickets) && generatedTickets.length > 0) {
|
|
try {
|
|
const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
} catch (emailErr) {
|
|
console.error('Error emailing tickets after Yoco reconciliation:', emailErr);
|
|
}
|
|
}
|
|
} catch (genErr) {
|
|
console.error('Error generating tickets after Yoco reconciliation:', genErr);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// log but do not fail reconciliation
|
|
console.warn('Failed to update registration after reconciliation:', e?.message || e);
|
|
}
|
|
}
|
|
|
|
// Send emails for the reconciled payment
|
|
try {
|
|
const { sendPaymentEmails } = require('../utils/notifications');
|
|
await sendPaymentEmails(payment.id);
|
|
} catch (e) {
|
|
console.error('Failed to send payment emails after reconciliation:', e);
|
|
}
|
|
|
|
// Update yoco transaction as reconciled
|
|
const updatedTx = await Yoco.update({
|
|
where: { id: ytx.id },
|
|
data: { reconciled: true, paymentId: payment.id }
|
|
});
|
|
|
|
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } });
|
|
} catch (error) {
|
|
console.error('Failed to reconcile Yoco transaction:', error);
|
|
// Handle unique externalId conflicts (if a Payment with same externalId already exists)
|
|
if (error?.code === 'P2002') {
|
|
try {
|
|
const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } });
|
|
if (existing) {
|
|
const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } });
|
|
if (updatedTx) {
|
|
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' });
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
|
}
|
|
};
|
|
|
|
// Minimal registration status update mirroring webhook logic
|
|
async function updateRegistrationStatus(registrationId) {
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
|
payments: true
|
|
}
|
|
});
|
|
if (!registration) return null;
|
|
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
|
|
let newStatus = 'pending';
|
|
if (totalPaid >= totalDue) newStatus = 'paid';
|
|
else if (totalPaid > 0) newStatus = 'partial_paid';
|
|
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
|
|
}
|
|
|
|
// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment)
|
|
// @route POST /api/yoco-transactions/:id/ignore
|
|
// @access Private/Admin or Supervisor
|
|
const ignoreYocoTransaction = async (req, res) => {
|
|
try {
|
|
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
}
|
|
const Yoco = getYocoModel();
|
|
if (!Yoco) {
|
|
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
|
}
|
|
const { id } = req.params;
|
|
const ytx = await Yoco.findUnique({ where: { id } });
|
|
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
|
|
|
if (ytx.reconciled && ytx.ignored) {
|
|
return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' });
|
|
}
|
|
|
|
const updated = await Yoco.update({
|
|
where: { id },
|
|
data: { ignored: true, reconciled: true }
|
|
});
|
|
return res.status(200).json({ success: true, data: updated });
|
|
} catch (error) {
|
|
console.error('Failed to ignore Yoco transaction:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getAllYocoTransactions,
|
|
getUnreconciledYocoTransactions,
|
|
reconcileYocoTransaction,
|
|
ignoreYocoTransaction
|
|
};
|