Files
hope-events/backend/src/controllers/sponsorshipController.js
T
joshuaandClaude Opus 5.5 865a16abe9 Store Yoco secrets in the database; add Yoco settings tab
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>
2026-10-20 10:28:50 +02:00

114 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };