diff --git a/CHANGELOG.md b/CHANGELOG.md index babe3c4..2b3a77e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.20.0] - 2026-10-19 + +### Added + +- Sponsor a spot: a new event setting, "Allow sponsorships" (event wizard → Sponsor a spot). + - It adds a "Sponsor a spot" button on the event page so people can fund a place for someone who can't afford it. + - Sponsors choose one or more whole spots (at the main ticket price) or a custom amount, and pay through Yoco. Guests just give a name and email. + - Sponsors can give anonymously, or add a name and a message for the organisers. + - An optional goal ("12 of 20 spots sponsored") and public progress bar. +- Each sponsorship is recorded as a donation tagged to the event, in that event's sponsorship pool. + - Supervisors assign pool money to registrations with the existing donation assign/unassign flow, fully or partly. + - Under Payments → Donations, sponsorships are labelled, there's an "Only the sponsorship pool" filter, and the sponsor's message is shown. + - Who receives help is never shown to sponsors. +- Sponsors get a "Thank you for sponsoring a spot" receipt. + - Automations has a new "Thank Your Sponsors" email for events with sponsorships. + - Email attendees can target an event's sponsors (`filter.audience: 'sponsors'`). +- The Donations breakdown report has sponsorship pool columns per event: received, assigned and remaining. +- New `/api/sponsorships` endpoints. + - Requires the `add_sponsorships` database migration. + ## [1.19.0] - 2026-10-19 ### Added diff --git a/FEATURES.md b/FEATURES.md index e2a2cfc..e37607d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -171,6 +171,16 @@ Items & Pricing → Sections → Form → Visibility → Attachments**. Each payment records **who recorded it** as well as who it's for. - **Refunds** — negative payments against an original payment, netted against the original method. +- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page + lets anyone, including guests, pay for one or more spots (at the main ticket price) + or any amount through Yoco. They can give anonymously or add a name and a message. + The money is a donation tagged to the event and goes into its sponsorship pool, which + supervisors assign to registrations with the usual donation flow. + - An optional public progress bar ("12 of 20 spots sponsored") with an optional goal. + - Sponsors get a thank-you receipt, and an Automations thank-you can go to all of an + event's sponsors. + - The Donations breakdown report shows each pool: received, assigned and remaining. + - Who is helped is never shown. - **Donations** — users can donate from their dashboard (`/dashboard/user/donate`). Supervisors can **assign** a donation (fully or partly) to someone's registration and **unassign** it later. Assignments are kept as separate @@ -388,7 +398,7 @@ a viewer with its own filters, stat tiles and bar charts. | Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) | | Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list | | Ticketing | Ticket usage summary | -| Donations | Donations breakdown (used / unused per event) | +| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) | | Cashup | Cashup reconciliation · Cashup audit trail | **Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook, diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 50235a8..691a49d 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -24,6 +24,7 @@ Tokens are issued by `POST /api/users/login`. - [Offline scanning](#offline-scanning) - [Ticket transfer](#ticket-transfer) - [Rooms & groups](#rooms--groups) +- [Sponsor a spot](#sponsor-a-spot) - [Payments](#payments) - [Tickets](#tickets) - [Broadcasts (Email)](#broadcasts-email) @@ -281,6 +282,24 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`: --- +## Sponsor a spot + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients | +| POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email | +| GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` | + +How the money is handled: +- A spot costs the cheapest paid main ticket. +- Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`. +- Pool funds are assigned with `PUT /api/payments/assign-donation`. + +Other changes: +- Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`. +- `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`. +- `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`. + ## Rooms & groups | Method | Path | Auth | Description | diff --git a/backend/package-lock.json b/backend/package-lock.json index 86e6dc8..d6eef4f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "event-management-backend", - "version": "1.19.0", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "event-management-backend", - "version": "1.19.0", + "version": "1.20.0", "hasInstallScript": true, "dependencies": { "@prisma/client": "^5.4.2", diff --git a/backend/package.json b/backend/package.json index 6567667..c1390a8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "event-management-backend", - "version": "1.19.0", + "version": "1.20.0", "description": "Event Management System Backend", "main": "src/index.js", "scripts": { diff --git a/backend/prisma/migrations/20261019145150_add_sponsorships/migration.sql b/backend/prisma/migrations/20261019145150_add_sponsorships/migration.sql new file mode 100644 index 0000000..3e34ca5 --- /dev/null +++ b/backend/prisma/migrations/20261019145150_add_sponsorships/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "allowSponsorships" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "showSponsorshipProgress" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "sponsorshipGoalSpots" INTEGER; + +-- AlterTable +ALTER TABLE "Payment" ADD COLUMN "isSponsorship" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "sponsorAnonymous" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "sponsorMessage" TEXT, +ADD COLUMN "sponsorName" TEXT; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6f8e122..2100d58 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -146,6 +146,10 @@ model Event { waitlistEnabled Boolean @default(false) allowTicketTransfer Boolean @default(true) collectGender Boolean @default(false) + // Sponsor a spot: public donations into a bursary pool for this event. + allowSponsorships Boolean @default(false) + sponsorshipGoalSpots Int? + showSponsorshipProgress Boolean @default(true) allocationSets AllocationSet[] waitlistOfferHours Int @default(24) waitlistPartialOffers Boolean @default(true) @@ -330,6 +334,11 @@ model Payment { registrationId String? eventId String? isDonation Boolean @default(false) + // A "sponsor a spot" donation (also isDonation): goes into the event's sponsorship pool. + isSponsorship Boolean @default(false) + sponsorName String? + sponsorMessage String? + sponsorAnonymous Boolean @default(false) externalId String? @unique status String? originalPaymentId String? @@ -737,167 +746,6 @@ model EventCashupPersonCountDenomination { @@index([countId]) } -// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ── -// -// model Ministry { -// id String @id @default(uuid()) -// slug String @unique -// title String -// description String? -// image String? -// isActive Boolean @default(true) -// order Int @default(0) -// leaderId String? -// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull) -// interests MinistryInterest[] -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// -// @@index([isActive, order]) -// @@index([leaderId]) -// } -// -// model TeamMember { -// id String @id @default(uuid()) -// name String -// title String -// bio String? -// photo String? -// order Int @default(0) -// isActive Boolean @default(true) -// email String? -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// ministries Ministry[] -// -// @@index([isActive, order]) -// } -// -// model ContentBlock { -// id String @id @default(uuid()) -// page String -// key String -// value String -// type String @default("text") -// updatedAt DateTime @updatedAt -// -// @@unique([page, key]) -// @@index([page]) -// } -// -// model ChurchDocument { -// id String @id @default(uuid()) -// title String -// description String? -// fileUrl String -// category String @default("other") -// isPublic Boolean @default(true) -// uploadedAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// } -// -// model FellowshipGroup { -// id String @id @default(uuid()) -// name String -// description String? -// leader String? -// isActive Boolean @default(true) -// order Int @default(0) -// members ChurchMember[] -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// -// @@index([isActive, order]) -// } -// -// model ChurchMember { -// id String @id @default(uuid()) -// status String @default("pending") -// mainName String -// mainEmail String -// mainPhone String? -// mainBirthday DateTime? -// mainOccupation String? -// mainEmploymentStatus String? -// mainDateOfSalvation DateTime? -// mainDateOfWaterBaptism DateTime? -// mainHolySpiritBaptism Boolean @default(false) -// spouseName String? -// spouseEmail String? -// spousePhone String? -// spouseBirthday DateTime? -// spouseOccupation String? -// spouseEmploymentStatus String? -// spouseDateOfSalvation DateTime? -// spouseDateOfWaterBaptism DateTime? -// spouseHolySpiritBaptism Boolean? -// homeAddress String? -// inFellowshipGroup Boolean @default(false) -// fellowshipGroupId String? -// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull) -// membershipConfirmed Boolean @default(false) -// notes String? -// appliedAt DateTime @default(now()) -// approvedAt DateTime? -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// children ChurchChild[] -// ministryInterests MinistryInterest[] -// donations Donation[] -// -// @@index([status]) -// @@index([mainEmail]) -// @@index([fellowshipGroupId]) -// } -// -// model ChurchChild { -// id String @id @default(uuid()) -// memberId String -// name String -// birthday DateTime? -// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade) -// -// @@index([memberId]) -// } -// -// model MinistryInterest { -// id String @id @default(uuid()) -// memberId String -// ministryId String -// forSpouse Boolean @default(false) -// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade) -// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade) -// -// @@unique([memberId, ministryId, forSpouse]) -// @@index([memberId]) -// @@index([ministryId]) -// } -// -// model Donation { -// id String @id @default(uuid()) -// donorName String? -// donorEmail String? -// donorPhone String? -// amount Float -// method String -// reference String? -// note String? -// isAnonymous Boolean @default(false) -// linkedMemberId String? -// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull) -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// -// @@index([linkedMemberId]) -// @@index([createdAt]) -// @@index([method]) -// } - -// ─── Coupon codes ───────────────────────────────────────────────────────────── -// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration. -// The discount is locked into a CouponRedemption when applied, like early-bird -// tranches: later pricing refreshes never re-price it (see utils/coupons.js). - enum CouponType { percent fixed diff --git a/backend/src/controllers/automationController.js b/backend/src/controllers/automationController.js index 2c74e8d..1fef7bf 100644 --- a/backend/src/controllers/automationController.js +++ b/backend/src/controllers/automationController.js @@ -24,7 +24,7 @@ async function scheduleAutomations(req, res) { html: j.html ? String(j.html) : undefined, text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined), template: 'custom', - filter: { status: undefined, attendeeIds: undefined }, + filter: { status: undefined, attendeeIds: undefined, ...(j.audience === 'sponsors' ? { audience: 'sponsors' } : {}) }, }; if (j.promoEventId && typeof j.promoEventId === 'string') { payload.promoEventId = j.promoEventId; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index af2e4d2..7cb4261 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) { // @access Private/Admin const createEvent = async (req, res) => { try { - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, contactName, contactPhone, contactEmail, location } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body; const data = { id: uuidv4(), @@ -72,6 +72,9 @@ const createEvent = async (req, res) => { waitlistEnabled: waitlistEnabled === true, allowTicketTransfer: allowTicketTransfer !== false, collectGender: collectGender === true, + allowSponsorships: allowSponsorships === true, + sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null, + showSponsorshipProgress: showSponsorshipProgress !== false, waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)), waitlistPartialOffers: waitlistPartialOffers !== false, contactName: contactName || null, @@ -538,7 +541,7 @@ const updateEvent = async (req, res) => { ip: getClientIp(req), }); - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, contactName, contactPhone, contactEmail, location } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body; const data = { title: title || event.title, @@ -563,6 +566,9 @@ const updateEvent = async (req, res) => { waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled, allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer, collectGender: collectGender !== undefined ? collectGender === true : event.collectGender, + allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships, + sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots, + showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress, waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours, waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers, // Editing the consent text starts a new version; parents who already agreed keep @@ -1388,6 +1394,24 @@ const emailEventAttendees = async (req, res) => { } const recipients = Array.from(uniqMap.values()); + // Sponsors of this event instead of its attendees (e.g. the post-event thank-you). + if (filter?.audience === 'sponsors') { + recipients.length = 0; + registrations.length = 0; + const sponsorships = await prisma.payment.findMany({ + where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } }, + include: { user: { select: { name: true, email: true, isActive: true } } }, + orderBy: { createdAt: 'asc' }, + }); + const seen = new Set(); + for (const s of sponsorships) { + const email = (s.user?.email || '').trim(); + if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue; + seen.add(email); + recipients.push({ email, name: s.sponsorName || s.user?.name || '' }); + } + } + if (dryRun) { return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) }); } diff --git a/backend/src/controllers/sponsorshipController.js b/backend/src/controllers/sponsorshipController.js new file mode 100644 index 0000000..e206d93 --- /dev/null +++ b/backend/src/controllers/sponsorshipController.js @@ -0,0 +1,112 @@ +const axios = require('axios'); +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: `Bearer ${process.env.YOCO_SECRET_KEY}`, '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 }; diff --git a/backend/src/controllers/webhookController.js b/backend/src/controllers/webhookController.js index 139165a..bedc8b7 100644 --- a/backend/src/controllers/webhookController.js +++ b/backend/src/controllers/webhookController.js @@ -325,7 +325,13 @@ const handlePaymentSucceeded = async (webhookData) => { userId: resolvedUserId, recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder eventId: registration?.eventId || metadata?.eventId || null, - isDonation: !registration?.id + isDonation: !registration?.id, + ...(!registration?.id && metadata?.type === 'sponsorship' ? { + isSponsorship: true, + sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true', + sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null), + sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null, + } : {}) }, include: { user: { diff --git a/backend/src/index.js b/backend/src/index.js index 5488530..78f2525 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -180,6 +180,7 @@ app.use('/api/consent', require('./routes/consentRoutes')); app.use('/api/waitlist', require('./routes/waitlistRoutes')); app.use('/api/scan-locks', require('./routes/scanLockRoutes')); app.use('/api/allocations', require('./routes/allocationRoutes')); +app.use('/api/sponsorships', require('./routes/sponsorshipRoutes')); app.use('/api/backups', backupRoutes); // Pre-warm the settings cache so synchronous helpers have DB values from startup @@ -636,6 +637,14 @@ app.get('/docs', async (req, res) => { ]}, ]}, + { title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [ + { method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped', + responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]}, + { method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool', + request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }}, + responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]}, + { method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' }, + ]}, { title: 'Rooms & groups', base: '/api/allocations', endpoints: [ { method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' }, { method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}}, diff --git a/backend/src/routes/sponsorshipRoutes.js b/backend/src/routes/sponsorshipRoutes.js new file mode 100644 index 0000000..2a14ac4 --- /dev/null +++ b/backend/src/routes/sponsorshipRoutes.js @@ -0,0 +1,10 @@ +const express = require('express'); +const router = express.Router(); +const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController'); +const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware'); + +router.get('/event/:eventId', getEventSponsorship); +router.get('/event/:eventId/pool', protect, supervisor, getPool); +router.post('/checkout', optionalAuth, createSponsorCheckout); + +module.exports = router; diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js index 140d141..51ff309 100644 --- a/backend/src/utils/notifications.js +++ b/backend/src/utils/notifications.js @@ -533,7 +533,7 @@ function buildPaymentReceipt(payment) { } // Donation receipt - const subject = `Donation received – ${eventTitle}`; + const subject = payment.isSponsorship ? `Thank you for sponsoring a spot – ${eventTitle}` : `Donation received – ${eventTitle}`; const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`; const body = `
Thank you for your donation!
@@ -541,7 +541,7 @@ function buildPaymentReceipt(payment) {Hi ${payment.user?.name || 'there'},
- We received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}. Your support means the world to us. + We received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}. Your support means the world to us.${payment.isSponsorship ? ' It goes into the sponsorship pool and helps someone attend who otherwise couldn’t.' : ''}
${callout(`${fmtAmount(payment.amount)} donated