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, userId: bodyUserId } = 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 (bodyUserId) { // Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that // over metadata guesswork, but still verify the user actually exists. try { const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } }); if (exists) resolvedUserId = String(bodyUserId); } catch {} } if (!resolvedUserId && 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 tickets if paid. // Ticket generation stays synchronous so `generatedTickets` can be included in the response; // emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since // they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request. let generatedTickets = []; if (registration) { try { const updatedReg = await updateRegistrationStatus(registration.id); if (updatedReg && updatedReg.status === 'paid') { try { generatedTickets = await generateTicketsForRegistration(registration.id); } 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); } } // Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order) const { sendPaymentEmails } = require('../utils/notifications'); const _rxPaymentId = payment.id; const _rxUserId = registration?.userId; const _rxRegId = registration?.id; const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0; (async () => { try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); } if (_rxShouldEmailTickets && _rxUserId) { const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } }; const mockRes = { status: () => mockRes, json: () => {} }; try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco 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 };