The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows them masked card-style, registers a webhook with Yoco (storing the secret it returns) or accepts one entered by hand, and lists/deletes the account's webhooks. Generic settings endpoints never read or write these keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
6.1 KiB
JavaScript
114 lines
6.1 KiB
JavaScript
const axios = require('axios');
|
||
const { yocoAuthHeader } = require('../utils/yoco');
|
||
const prisma = require('../config/db');
|
||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
|
||
|
||
const fail = (res, error) => res.status(error.statusCode || error.response?.status || 400).json({ message: safeErrorMessage(error) });
|
||
|
||
async function sponsorableEvent(eventId) {
|
||
const event = await prisma.event.findUnique({ where: { id: String(eventId || '') }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||
if (!event || !event.isActive) throw sponsorshipError('Event not found', 404);
|
||
if (!event.allowSponsorships) throw sponsorshipError('This event isn’t taking sponsorships.');
|
||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) throw sponsorshipError('This event has ended and is no longer taking sponsorships.');
|
||
return event;
|
||
}
|
||
|
||
async function poolFor(event) {
|
||
const sponsorships = await prisma.payment.findMany({ where: { eventId: event.id, isDonation: true, isSponsorship: true, amount: { gt: 0 } }, select: { id: true, amount: true } });
|
||
const legs = sponsorships.length
|
||
? await prisma.payment.findMany({ where: { originalPaymentId: { in: sponsorships.map(s => s.id) }, isDonation: false }, select: { originalPaymentId: true, amount: true } })
|
||
: [];
|
||
return poolSummary(sponsorships, legs, spotPrice(event));
|
||
}
|
||
|
||
// @desc Public sponsorship progress for an event (never names who was helped)
|
||
// @route GET /api/sponsorships/event/:eventId
|
||
// @access Public
|
||
const getEventSponsorship = async (req, res) => {
|
||
try {
|
||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||
if (!event || !event.isActive || !event.allowSponsorships) return res.json({ enabled: false });
|
||
const pool = await poolFor(event);
|
||
const open = event.cashupStatus !== 'closed' && new Date(event.endDate) >= new Date();
|
||
res.json({
|
||
enabled: true,
|
||
open,
|
||
spotPrice: spotPrice(event),
|
||
showProgress: event.showSponsorshipProgress,
|
||
goalSpots: event.sponsorshipGoalSpots,
|
||
spotsFunded: event.showSponsorshipProgress ? pool.spotsFunded : null,
|
||
sponsors: event.showSponsorshipProgress ? pool.sponsors : null,
|
||
});
|
||
} catch (error) {
|
||
fail(res, error);
|
||
}
|
||
};
|
||
|
||
// @desc Start a Yoco checkout to sponsor spots (or a custom amount) at an event
|
||
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
|
||
// @access Public (guests give a name and email for the receipt)
|
||
const createSponsorCheckout = async (req, res) => {
|
||
try {
|
||
const { eventId, spots, amount, anonymous, sponsorName, message, name, email, phone } = req.body || {};
|
||
const event = await sponsorableEvent(eventId);
|
||
const charge = sponsorshipCharge({ spots, amount }, spotPrice(event));
|
||
|
||
let payer = req.user || null;
|
||
if (!payer) {
|
||
const { hasValidEmail, findOrCreateAccount } = require('../utils/accountMatch');
|
||
const cleanName = String(name || '').trim().slice(0, 100);
|
||
if (!cleanName) throw sponsorshipError('Enter your name.');
|
||
if (!hasValidEmail(email)) throw sponsorshipError('Enter your email address so we can send your receipt.');
|
||
// An existing account is reused; otherwise a quiet inactive one holds the receipt —
|
||
// no activation link is sent, since a sponsor doesn't need to log in.
|
||
payer = (await findOrCreateAccount({ name: cleanName, email: String(email).trim(), phone })).user;
|
||
}
|
||
const isAnonymous = anonymous === true;
|
||
const displayName = isAnonymous ? null : (String(sponsorName || '').trim() || payer.name || '').slice(0, 100) || null;
|
||
const note = String(message || '').trim().slice(0, 300) || null;
|
||
|
||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||
const back = `${base}/events/${encodeURIComponent(event.id)}`;
|
||
const amountInCents = Math.round(charge.amount * 100);
|
||
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
|
||
const checkoutData = {
|
||
amount: amountInCents,
|
||
currency: 'ZAR',
|
||
metadata: {
|
||
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
|
||
sponsorAnonymous: isAnonymous ? 'true' : 'false',
|
||
...(displayName ? { sponsorName: displayName } : {}),
|
||
...(note ? { sponsorMessage: note } : {}),
|
||
...(charge.spots ? { spots: String(charge.spots) } : {}),
|
||
},
|
||
successUrl: `${back}?sponsored=1`,
|
||
cancelUrl: back,
|
||
failureUrl: `${back}?sponsored=failed`,
|
||
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
|
||
};
|
||
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
|
||
headers: { 'Content-Type': 'application/json', Authorization: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
|
||
});
|
||
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
|
||
} catch (error) {
|
||
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
|
||
fail(res, error);
|
||
}
|
||
};
|
||
|
||
// @desc The sponsorship pool for an event (supervisors)
|
||
// @route GET /api/sponsorships/event/:eventId/pool
|
||
// @access Private/Supervisor
|
||
const getPool = async (req, res) => {
|
||
try {
|
||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||
if (!event) throw sponsorshipError('Event not found', 404);
|
||
res.json({ eventId: event.id, spotPrice: spotPrice(event), goalSpots: event.sponsorshipGoalSpots, ...(await poolFor(event)) });
|
||
} catch (error) {
|
||
fail(res, error);
|
||
}
|
||
};
|
||
|
||
module.exports = { getEventSponsorship, createSponsorCheckout, getPool, poolFor };
|