- src/payments: provider registry and PaymentProvider interface (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund, listTransactions as capabilities), Yoco as the first provider. All four direct Yoco checkout calls go through payments.createCheckout. - Webhook split into the provider part (signature, parsing) and shared processing; same URL (/api/webhooks/yoco), same behaviour. - YocoTransaction renamed in place to ProviderTransaction (+ provider, unique per provider + externalId); Payment.provider added and backfilled to "yoco". Payment.method stays the wallet type so cashups are unchanged. - Site settings → Payments: Enabled toggle and order per provider; Yoco's settings shown while it's enabled. R15 minimum for all online payments. - New endpoint names (/api/payments/checkout, /api/provider-transactions); the old /yoco-checkout and /yoco-transactions paths still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
|
const { emailTickets } = require('./ticketController');
|
|
const { getProvider } = require('../payments');
|
|
|
|
const canReconcile = (req) => !!req.user && ['admin', 'supervisor'].includes(req.user.role);
|
|
// The checkout metadata (who/what the payment was for), read by the provider that sent it.
|
|
const metadataOf = (tx) => {
|
|
const provider = getProvider(tx.provider);
|
|
try { return (provider && tx.raw ? provider.parseWebhook(tx.raw).transaction.metadata : null) || {}; } catch { return {}; }
|
|
};
|
|
const withLabel = (tx) => ({ ...tx, providerLabel: getProvider(tx.provider)?.label || tx.provider, metadata: metadataOf(tx) });
|
|
|
|
// @desc Payment provider transactions (optionally filter by provider, reconciled, ignored)
|
|
// @route GET /api/provider-transactions (also /api/yoco-transactions)
|
|
// @access Private/Admin or Supervisor
|
|
const getAllProviderTransactions = async (req, res) => {
|
|
try {
|
|
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
|
|
const { reconciled, ignored, provider } = req.query;
|
|
const where = {};
|
|
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
|
|
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
|
|
if (provider) where.provider = String(provider);
|
|
|
|
const items = await prisma.providerTransaction.findMany({ where, orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }] });
|
|
return res.status(200).json({ success: true, data: items.map(withLabel) });
|
|
} catch (error) {
|
|
console.error('Failed to fetch provider transactions:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Unreconciled provider transactions (not reconciled and not ignored)
|
|
// @route GET /api/provider-transactions/unreconciled (also /api/yoco-transactions/unreconciled)
|
|
// @access Private/Admin or Supervisor
|
|
const getUnreconciledProviderTransactions = async (req, res) => {
|
|
try {
|
|
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
|
|
const items = await prisma.providerTransaction.findMany({
|
|
where: { reconciled: false, ignored: false, ...(req.query.provider ? { provider: String(req.query.provider) } : {}) },
|
|
orderBy: { createdDate: 'desc' },
|
|
});
|
|
return res.status(200).json({ success: true, data: items.map(withLabel) });
|
|
} catch (error) {
|
|
console.error('Failed to fetch unreconciled provider transactions:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Reconcile a provider transaction to a Registration or Donation (creates Payment)
|
|
// @route POST /api/provider-transactions/:id/reconcile (also /api/yoco-transactions/:id/reconcile)
|
|
// @access Private/Admin or Supervisor
|
|
const reconcileProviderTransaction = async (req, res) => {
|
|
try {
|
|
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
|
|
const { id } = req.params;
|
|
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
|
|
|
|
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
|
|
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
|
|
if (tx.reconciled && tx.paymentId) {
|
|
return res.status(409).json({ success: false, message: 'Already reconciled', data: withLabel(tx) });
|
|
}
|
|
|
|
// 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 tx.amount === 'number' ? (tx.amount / 100) : 0;
|
|
if (!amountFloat || amountFloat <= 0) {
|
|
return res.status(400).json({ success: false, message: 'Invalid amount on the transaction for reconciliation' });
|
|
}
|
|
|
|
const metadata = metadataOf(tx);
|
|
|
|
// 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 && metadata.userId) {
|
|
try {
|
|
const exists = await prisma.user.findUnique({ where: { id: String(metadata.userId) } });
|
|
if (exists) resolvedUserId = String(metadata.userId);
|
|
} 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' });
|
|
}
|
|
|
|
const paymentData = {
|
|
amount: amountFloat,
|
|
method: tx.methodType || 'card',
|
|
provider: tx.provider,
|
|
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: tx.externalId || undefined,
|
|
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' });
|
|
}
|
|
|
|
// Keep the provider's creation time so early-bird pricing is evaluated correctly
|
|
const createdAt = tx.createdDate || tx.createdAt || new Date();
|
|
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
|
|
|
// Update registration status and generate tickets if paid. Ticket generation stays
|
|
// synchronous so `generatedTickets` can be in the response; emailing is backgrounded below.
|
|
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 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 reconciliation:', e); }
|
|
}
|
|
})();
|
|
|
|
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: payment.id } });
|
|
|
|
return res.status(200).json({ success: true, data: { transaction: withLabel(updatedTx), payment, generatedTickets: generatedTickets.length ? generatedTickets : undefined } });
|
|
} catch (error) {
|
|
console.error('Failed to reconcile provider transaction:', error);
|
|
// A Payment with the same externalId already exists: link to it instead
|
|
if (error?.code === 'P2002') {
|
|
try {
|
|
const tx = await prisma.providerTransaction.findUnique({ where: { id: req.params.id } });
|
|
const existing = tx ? await prisma.payment.findFirst({ where: { externalId: tx.externalId } }) : null;
|
|
if (existing) {
|
|
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: existing.id } });
|
|
return res.status(200).json({ success: true, data: { transaction: withLabel(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 provider transaction (mark as ignored and reconciled without creating a payment)
|
|
// @route POST /api/provider-transactions/:id/ignore (also /api/yoco-transactions/:id/ignore)
|
|
// @access Private/Admin or Supervisor
|
|
const ignoreProviderTransaction = async (req, res) => {
|
|
try {
|
|
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
|
const { id } = req.params;
|
|
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
|
|
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
|
|
|
|
if (tx.reconciled && tx.ignored) {
|
|
return res.status(200).json({ success: true, data: withLabel(tx), message: 'Already ignored' });
|
|
}
|
|
|
|
const updated = await prisma.providerTransaction.update({ where: { id }, data: { ignored: true, reconciled: true } });
|
|
return res.status(200).json({ success: true, data: withLabel(updated) });
|
|
} catch (error) {
|
|
console.error('Failed to ignore provider transaction:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getAllProviderTransactions,
|
|
getUnreconciledProviderTransactions,
|
|
reconcileProviderTransaction,
|
|
ignoreProviderTransaction
|
|
};
|