From 2d0bdc7dd1234ab7b83e24e28e62074a4dd5349b Mon Sep 17 00:00:00 2001
From: Jeff Emmett
Date: Thu, 12 Mar 2026 13:42:04 -0700
Subject: [PATCH] feat: CCG-style accommodation selection with upfront payment
and booking sheet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace simple accommodation dropdown with full venue/room-type selector
(Commons Hub + Herrnhof Villa) matching CCG registration flow. Accommodation
is now paid upfront with registration via Mollie instead of invoiced separately.
- Add 7 accommodation types with per-week pricing (€279-665/wk)
- Add 2% payment processing fee on registration + accommodation subtotal
- Create booking-sheet.js for automatic bed assignment on payment confirmation
- Update Mollie webhook to assign beds and send internal booking notifications
- Add accommodation_type column to DB schema + migration
- Update confirmation/admin emails with full price breakdown
- Add food interest checkbox with co-producing meals messaging
- Track accommodation type, venue, and food interest in Google Sheets
- Add startup migration for accommodation_type column in server.js
Co-Authored-By: Claude Opus 4.6
---
api/application.js | 63 ++++---
api/booking-sheet.js | 274 +++++++++++++++++++++++++++++
api/google-sheets.js | 11 +-
api/mollie.js | 209 ++++++++++++++++++----
apply.html | 196 ++++++++++++++++++---
db/migration-003-accommodation.sql | 5 +
db/schema.sql | 3 +-
server.js | 3 +-
8 files changed, 684 insertions(+), 80 deletions(-)
create mode 100644 api/booking-sheet.js
create mode 100644 db/migration-003-accommodation.sql
diff --git a/api/application.js b/api/application.js
index 348fed8..14f4219 100644
--- a/api/application.js
+++ b/api/application.js
@@ -4,7 +4,7 @@
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
const { syncApplication } = require('./google-sheets');
-const { createPayment, TICKET_LABELS, PRICE_PER_WEEK, calculateAmount } = require('./mollie');
+const { createPayment, TICKET_LABELS, PRICE_PER_WEEK, ACCOMMODATION_PRICES, ACCOMMODATION_LABELS, PROCESSING_FEE_PERCENT, calculateAmount } = require('./mollie');
const { addToListmonk } = require('./listmonk');
// Initialize PostgreSQL connection pool
@@ -36,17 +36,25 @@ const WEEK_LABELS = {
// Email templates
const confirmationEmail = (application) => {
const weeksCount = (application.weeks || []).length;
- const amount = calculateAmount('registration', weeksCount);
+ const accomType = application.accommodation_type || null;
+ const pricing = calculateAmount('registration', weeksCount, accomType);
const weeksHtml = (application.weeks || []).map(w => `${WEEK_LABELS[w] || w}`).join('');
- const addOns = [];
- if (application.need_accommodation) {
- const prefLabel = application.accommodation_preference ? ` (preference: ${application.accommodation_preference})` : '';
- addOns.push(`Accommodation${prefLabel}`);
+ // Accommodation row
+ let accomHtml = '';
+ if (accomType && ACCOMMODATION_PRICES[accomType]) {
+ const label = ACCOMMODATION_LABELS[accomType] || accomType;
+ const perWeek = ACCOMMODATION_PRICES[accomType];
+ accomHtml = `
+
+ | Accommodation: |
+ ${label} €${perWeek.toFixed(2)}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${pricing.accommodation} |
+
`;
}
- if (application.want_food) addOns.push('Food included');
- const addOnsHtml = addOns.length > 0
- ? `| Add-ons: | ${addOns.join(', ')} (invoiced separately) |
`
+
+ // Food note
+ const foodNote = application.want_food
+ ? '| Food: | Interest registered — we are exploring co-producing meals as a community. More details and costs coming soon. |
'
: '';
return {
@@ -64,13 +72,22 @@ const confirmationEmail = (application) => {
| Registration: |
- €${PRICE_PER_WEEK}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${amount} |
+ €${PRICE_PER_WEEK}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${pricing.registration} |
+
+ ${accomHtml}
+
+ | Processing fee (2%): |
+ €${pricing.processingFee} |
+
+
+ | Total: |
+ €${pricing.total} |
| Attendance: |
${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'} |
- ${addOnsHtml}
+ ${foodNote}
${weeksHtml ? `Weeks selected:
` : ''}
@@ -82,7 +99,7 @@ const confirmationEmail = (application) => {
Our team will review your application
We may reach out with follow-up questions
You'll receive a decision within 2-3 weeks
- ${addOns.length > 0 ? 'Accommodation and food costs will be invoiced separately after acceptance' : ''}
+ ${accomType ? 'Your bed will be assigned automatically once payment is confirmed' : ''}
@@ -134,11 +151,11 @@ const adminNotificationEmail = (application) => ({
| Accommodation: |
- ${application.need_accommodation ? `Yes${application.accommodation_preference ? ' (' + application.accommodation_preference + ')' : ''}` : 'No'} |
+ ${application.accommodation_type ? (ACCOMMODATION_LABELS[application.accommodation_type] || application.accommodation_type) : (application.need_accommodation ? 'Yes (no type selected)' : 'No')} |
- | Food: |
- ${application.want_food ? 'Yes' : 'No'} |
+ Food interest: |
+ ${application.want_food ? 'Yes — wants to co-produce meals' : 'No'} |
| Scholarship: |
@@ -235,11 +252,11 @@ module.exports = async function handler(req, res) {
how_heard, referral_name, previous_events, emergency_name, emergency_phone,
emergency_relationship, code_of_conduct_accepted, privacy_policy_accepted,
photo_consent, scholarship_needed, scholarship_reason, contribution_amount,
- ip_address, user_agent, need_accommodation, want_food
+ ip_address, user_agent, need_accommodation, want_food, accommodation_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32,
- $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43
+ $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44
) RETURNING id, submitted_at`,
[
data.first_name?.trim(),
@@ -284,7 +301,8 @@ module.exports = async function handler(req, res) {
req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
req.headers['user-agent'] || null,
data.need_accommodation || false,
- data.want_food || false
+ data.want_food || false,
+ data.accommodation_type || null
]
);
@@ -310,6 +328,7 @@ module.exports = async function handler(req, res) {
weeks: weeksSelected,
need_accommodation: data.need_accommodation || false,
accommodation_preference: data.accommodation_preference || null,
+ accommodation_type: data.accommodation_type || null,
want_food: data.want_food || false,
contribution_amount: 'registration',
};
@@ -358,7 +377,7 @@ module.exports = async function handler(req, res) {
}
}
- // Create Mollie payment for registration fee (€300/week)
+ // Create Mollie payment for registration + accommodation fee
let checkoutUrl = null;
if (weeksSelected.length > 0 && process.env.MOLLIE_API_KEY) {
try {
@@ -368,10 +387,12 @@ module.exports = async function handler(req, res) {
weeksSelected.length,
application.email,
application.first_name,
- application.last_name
+ application.last_name,
+ application.accommodation_type,
+ weeksSelected
);
checkoutUrl = paymentResult.checkoutUrl;
- console.log(`Mollie payment created: ${paymentResult.paymentId} (${paymentResult.amount} EUR)`);
+ console.log(`Mollie payment created: ${paymentResult.paymentId} (€${paymentResult.amount})`);
} catch (paymentError) {
console.error('Failed to create Mollie payment:', paymentError);
// Don't fail the application - payment can be retried
diff --git a/api/booking-sheet.js b/api/booking-sheet.js
new file mode 100644
index 0000000..f4ffed5
--- /dev/null
+++ b/api/booking-sheet.js
@@ -0,0 +1,274 @@
+// Booking sheet integration
+// Manages bed assignments on a Google Sheets booking sheet
+// Ported from CCG's lib/booking-sheet.ts, adapted for week-based columns
+
+const fs = require('fs');
+
+let sheetsClient = null;
+
+// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
+const ACCOMMODATION_CRITERIA = {
+ 'ch-multi': {
+ venue: 'Commons Hub',
+ bedTypes: ['Bunk up', 'Bunk down', 'Single'],
+ },
+ 'ch-double': {
+ venue: 'Commons Hub',
+ bedTypes: ['Double'],
+ },
+ 'hh-single': {
+ venue: 'Herrnhof Villa',
+ bedTypes: ['Single'],
+ },
+ 'hh-double-separate': {
+ venue: 'Herrnhof Villa',
+ bedTypes: ['Double (separate)', 'Twin'],
+ },
+ 'hh-double-shared': {
+ venue: 'Herrnhof Villa',
+ bedTypes: ['Double (shared)', 'Double'],
+ },
+ 'hh-triple': {
+ venue: 'Herrnhof Villa',
+ bedTypes: ['Triple'],
+ },
+ 'hh-daybed': {
+ venue: 'Herrnhof Villa',
+ bedTypes: ['Daybed', 'Extra bed', 'Sofa bed'],
+ },
+};
+
+// Week column headers expected in the sheet
+const WEEK_COLUMNS = ['Week 1', 'Week 2', 'Week 3', 'Week 4'];
+
+function getCredentials() {
+ const filePath = process.env.GOOGLE_SERVICE_ACCOUNT_FILE;
+ if (filePath) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch (err) {
+ console.error('[Booking Sheet] Failed to read credentials file:', err.message);
+ return null;
+ }
+ }
+
+ const raw = process.env.GOOGLE_SERVICE_ACCOUNT;
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw.trim());
+ } catch {
+ console.error('[Booking Sheet] Failed to parse service account JSON');
+ return null;
+ }
+}
+
+async function getSheetsClient() {
+ if (sheetsClient) return sheetsClient;
+
+ const creds = getCredentials();
+ if (!creds) return null;
+
+ const { google } = require('googleapis');
+ const auth = new google.auth.GoogleAuth({
+ credentials: creds,
+ scopes: ['https://www.googleapis.com/auth/spreadsheets'],
+ });
+ sheetsClient = google.sheets({ version: 'v4', auth });
+ return sheetsClient;
+}
+
+/**
+ * Read and parse the booking sheet.
+ * Expected structure per venue section:
+ * Row: "Commons Hub" or "Herrnhof Villa" (venue header)
+ * Row: Room | Bed Type | Week 1 | Week 2 | Week 3 | Week 4 (column headers)
+ * Row: 5 | Bunk up | | | | (bed rows)
+ * ...
+ * Empty row (section separator)
+ *
+ * Returns array of bed objects:
+ * { venue, room, bedType, rowIndex, weekColumns: { 'Week 1': colIndex, ... }, occupancy: { 'Week 1': 'Guest Name' | null, ... } }
+ */
+async function parseBookingSheet() {
+ const sheets = await getSheetsClient();
+ if (!sheets) {
+ console.log('[Booking Sheet] No credentials configured — skipping');
+ return null;
+ }
+
+ const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
+ const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
+
+ const response = await sheets.spreadsheets.values.get({
+ spreadsheetId: sheetId,
+ range: `${sheetName}!A:G`,
+ });
+
+ const rows = response.data.values || [];
+ const beds = [];
+ let currentVenue = null;
+ let weekColIndexes = {};
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) {
+ // Empty row — reset venue context
+ currentVenue = null;
+ weekColIndexes = {};
+ continue;
+ }
+
+ const firstCell = (row[0] || '').toString().trim();
+
+ // Check if this is a venue header
+ if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') {
+ currentVenue = firstCell;
+ weekColIndexes = {};
+ continue;
+ }
+
+ // Check if this is the column header row (contains "Room" and week columns)
+ if (firstCell.toLowerCase() === 'room' && currentVenue) {
+ for (let c = 0; c < row.length; c++) {
+ const header = (row[c] || '').toString().trim();
+ if (WEEK_COLUMNS.includes(header)) {
+ weekColIndexes[header] = c;
+ }
+ }
+ continue;
+ }
+
+ // If we have a venue and week columns, this is a bed row
+ if (currentVenue && Object.keys(weekColIndexes).length > 0 && firstCell) {
+ const room = firstCell;
+ const bedType = (row[1] || '').toString().trim();
+ if (!bedType) continue;
+
+ const occupancy = {};
+ for (const [week, colIdx] of Object.entries(weekColIndexes)) {
+ const cellValue = (row[colIdx] || '').toString().trim();
+ occupancy[week] = cellValue || null;
+ }
+
+ beds.push({
+ venue: currentVenue,
+ room,
+ bedType,
+ rowIndex: i,
+ weekColumns: { ...weekColIndexes },
+ occupancy,
+ });
+ }
+ }
+
+ return beds;
+}
+
+/**
+ * Find an available bed matching the accommodation criteria for the given weeks.
+ * A bed is "available" only if ALL requested week columns are empty.
+ */
+function findAvailableBed(beds, accommodationType, selectedWeeks) {
+ const criteria = ACCOMMODATION_CRITERIA[accommodationType];
+ if (!criteria) {
+ console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`);
+ return null;
+ }
+
+ // Map week values (week1, week2, etc.) to column headers (Week 1, Week 2, etc.)
+ const weekHeaders = selectedWeeks.map(w => {
+ const num = w.replace('week', '');
+ return `Week ${num}`;
+ });
+
+ for (const bed of beds) {
+ // Match venue
+ if (bed.venue !== criteria.venue) continue;
+
+ // Match bed type (case-insensitive partial match)
+ const bedTypeLower = bed.bedType.toLowerCase();
+ const matchesBedType = criteria.bedTypes.some(bt => bedTypeLower.includes(bt.toLowerCase()));
+ if (!matchesBedType) continue;
+
+ // Check all requested weeks are empty
+ const allWeeksAvailable = weekHeaders.every(wh => !bed.occupancy[wh]);
+ if (!allWeeksAvailable) continue;
+
+ return bed;
+ }
+
+ return null;
+}
+
+/**
+ * Assign a guest to a bed on the booking sheet.
+ * Writes guest name to the selected week columns for the matched bed row.
+ *
+ * @param {string} guestName - Full name of the guest
+ * @param {string} accommodationType - e.g. 'ch-multi', 'hh-single'
+ * @param {string[]} selectedWeeks - e.g. ['week1', 'week2', 'week3']
+ * @returns {object} Result with success status, venue, room, bedType
+ */
+async function assignBooking(guestName, accommodationType, selectedWeeks) {
+ if (!accommodationType || !selectedWeeks || selectedWeeks.length === 0) {
+ return { success: false, reason: 'Missing accommodation type or weeks' };
+ }
+
+ try {
+ const beds = await parseBookingSheet();
+ if (!beds) {
+ return { success: false, reason: 'Booking sheet not configured' };
+ }
+
+ const bed = findAvailableBed(beds, accommodationType, selectedWeeks);
+ if (!bed) {
+ console.warn(`[Booking Sheet] No available bed for ${accommodationType}, weeks: ${selectedWeeks.join(', ')}`);
+ return { success: false, reason: 'No available bed matching criteria' };
+ }
+
+ // Write guest name to the selected week columns
+ const sheets = await getSheetsClient();
+ const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
+ const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
+
+ // Convert week values to column headers
+ const weekHeaders = selectedWeeks.map(w => `Week ${w.replace('week', '')}`);
+
+ // Build batch update data
+ const data = weekHeaders
+ .filter(wh => bed.weekColumns[wh] !== undefined)
+ .map(wh => {
+ const col = bed.weekColumns[wh];
+ const colLetter = String.fromCharCode(65 + col); // A=0, B=1, ...
+ const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed
+ return {
+ range: `${sheetName}!${colLetter}${rowNum}`,
+ values: [[guestName]],
+ };
+ });
+
+ if (data.length > 0) {
+ await sheets.spreadsheets.values.batchUpdate({
+ spreadsheetId: sheetId,
+ resource: {
+ valueInputOption: 'USER_ENTERED',
+ data,
+ },
+ });
+ }
+
+ console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`);
+
+ return {
+ success: true,
+ venue: bed.venue,
+ room: bed.room,
+ bedType: bed.bedType,
+ };
+ } catch (error) {
+ console.error('[Booking Sheet] Assignment failed:', error);
+ return { success: false, reason: error.message };
+ }
+}
+
+module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };
diff --git a/api/google-sheets.js b/api/google-sheets.js
index 304129a..2272bc3 100644
--- a/api/google-sheets.js
+++ b/api/google-sheets.js
@@ -90,10 +90,16 @@ function syncWaitlistSignup({ email, name, involvement }) {
/**
* Sync an application to the "Registrations" sheet tab.
* Columns: Timestamp | App ID | Status | First Name | Last Name | Email | Phone |
- * Country | City | Attendance | Weeks | Accommodation | Accom Pref | Food |
+ * Country | City | Attendance | Weeks | Accommodation | Accom Type | Accom Venue | Food |
* Motivation | Contribution | How Heard | Referral | Scholarship | Scholarship Reason
*/
function syncApplication(app) {
+ // Derive venue from accommodation_type prefix
+ const accomType = app.accommodation_type || '';
+ const accomVenue = accomType.startsWith('ch-') ? 'Commons Hub'
+ : accomType.startsWith('hh-') ? 'Herrnhof Villa'
+ : '';
+
appendRow('Registrations', [
new Date().toISOString(),
app.id || '',
@@ -107,7 +113,8 @@ function syncApplication(app) {
app.attendance_type || '',
(app.weeks || []).join(', '),
app.need_accommodation ? 'Yes' : 'No',
- app.accommodation_preference || '',
+ accomType,
+ accomVenue,
app.want_food ? 'Yes' : 'No',
app.motivation || '',
app.contribution || '',
diff --git a/api/mollie.js b/api/mollie.js
index b3170c2..160e3e0 100644
--- a/api/mollie.js
+++ b/api/mollie.js
@@ -4,6 +4,7 @@
const { createMollieClient } = require('@mollie/api-client');
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
+const { assignBooking } = require('./booking-sheet');
// Initialize PostgreSQL connection pool
const pool = new Pool({
@@ -31,6 +32,31 @@ const smtp = nodemailer.createTransport({
// Base registration price per week (EUR)
const PRICE_PER_WEEK = 300.00;
+// Processing fee percentage (added on top of subtotal)
+const PROCESSING_FEE_PERCENT = 0.02;
+
+// Accommodation prices per week (EUR) — same as CCG rates
+const ACCOMMODATION_PRICES = {
+ 'ch-multi': 279.30, // Commons Hub shared room (multi-bed)
+ 'ch-double': 356.30, // Commons Hub double room
+ 'hh-single': 665.00, // Herrnhof single room
+ 'hh-double-separate': 420.00, // Herrnhof double room, separate beds
+ 'hh-double-shared': 350.00, // Herrnhof double room, shared bed
+ 'hh-triple': 350.00, // Herrnhof triple room
+ 'hh-daybed': 280.00, // Herrnhof daybed/extra bed
+};
+
+// Human-readable labels for accommodation types
+const ACCOMMODATION_LABELS = {
+ 'ch-multi': 'Commons Hub — Shared Room',
+ 'ch-double': 'Commons Hub — Double Room',
+ 'hh-single': 'Herrnhof Villa — Single Room',
+ 'hh-double-separate': 'Herrnhof Villa — Double Room (separate beds)',
+ 'hh-double-shared': 'Herrnhof Villa — Double Room (shared bed)',
+ 'hh-triple': 'Herrnhof Villa — Triple Room',
+ 'hh-daybed': 'Herrnhof Villa — Daybed / Extra Bed',
+};
+
// Legacy ticket labels (kept for backward-compat with existing DB records)
const TICKET_LABELS = {
'full-dorm': 'Full Resident - Dorm (4-6 people)',
@@ -43,25 +69,43 @@ const TICKET_LABELS = {
'registration': 'Event Registration',
};
-function calculateAmount(ticketType, weeksCount) {
- // New pricing: flat €300/week
- return (PRICE_PER_WEEK * (weeksCount || 1)).toFixed(2);
+function calculateAmount(ticketType, weeksCount, accommodationType) {
+ const weeks = weeksCount || 1;
+ const registration = PRICE_PER_WEEK * weeks;
+ const accommodation = accommodationType && ACCOMMODATION_PRICES[accommodationType]
+ ? ACCOMMODATION_PRICES[accommodationType] * weeks
+ : 0;
+ const subtotal = registration + accommodation;
+ const processingFee = subtotal * PROCESSING_FEE_PERCENT;
+ const total = subtotal + processingFee;
+ return {
+ registration: registration.toFixed(2),
+ accommodation: accommodation.toFixed(2),
+ subtotal: subtotal.toFixed(2),
+ processingFee: processingFee.toFixed(2),
+ total: total.toFixed(2),
+ };
}
// Create a Mollie payment for an application
-async function createPayment(applicationId, ticketType, weeksCount, email, firstName, lastName) {
- const amount = calculateAmount(ticketType, weeksCount);
- if (!amount) {
- throw new Error(`Invalid ticket type: ${ticketType}`);
- }
+async function createPayment(applicationId, ticketType, weeksCount, email, firstName, lastName, accommodationType, selectedWeeks) {
+ const pricing = calculateAmount(ticketType, weeksCount, accommodationType);
const baseUrl = process.env.BASE_URL || 'https://valleyofthecommons.com';
- const description = `Valley of the Commons - Registration (${weeksCount} week${weeksCount > 1 ? 's' : ''})`;
+
+ // Build itemized description
+ const parts = [`Registration (${weeksCount} week${weeksCount > 1 ? 's' : ''})`];
+ if (accommodationType && ACCOMMODATION_PRICES[accommodationType]) {
+ const label = ACCOMMODATION_LABELS[accommodationType] || accommodationType;
+ parts.push(`Accommodation: ${label} (${weeksCount} week${weeksCount > 1 ? 's' : ''})`);
+ }
+ parts.push('incl. 2% processing fee');
+ const description = `Valley of the Commons - ${parts.join(' + ')}`;
const payment = await mollieClient.payments.create({
amount: {
currency: 'EUR',
- value: amount,
+ value: pricing.total,
},
description,
redirectUrl: `${baseUrl}/payment-return.html?id=${applicationId}`,
@@ -70,6 +114,9 @@ async function createPayment(applicationId, ticketType, weeksCount, email, first
applicationId,
ticketType,
weeksCount,
+ accommodationType: accommodationType || null,
+ selectedWeeks: selectedWeeks || [],
+ breakdown: pricing,
},
});
@@ -80,20 +127,50 @@ async function createPayment(applicationId, ticketType, weeksCount, email, first
payment_amount = $2,
payment_status = 'pending'
WHERE id = $3`,
- [payment.id, amount, applicationId]
+ [payment.id, pricing.total, applicationId]
);
return {
paymentId: payment.id,
checkoutUrl: payment.getCheckoutUrl(),
- amount,
+ amount: pricing.total,
+ pricing,
};
}
// Payment confirmation email
-const paymentConfirmationEmail = (application) => ({
- subject: 'Payment Confirmed - Valley of the Commons',
- html: `
+const paymentConfirmationEmail = (application, bookingResult) => {
+ const accomLabel = application.accommodation_type
+ ? (ACCOMMODATION_LABELS[application.accommodation_type] || application.accommodation_type)
+ : null;
+
+ // Build accommodation row if applicable
+ const accomRow = accomLabel ? `
+
+ | Accommodation: |
+ ${accomLabel} |
+
` : '';
+
+ // Booking assignment info
+ let bookingHtml = '';
+ if (bookingResult) {
+ if (bookingResult.success) {
+ bookingHtml = `
+
+
Bed Assignment
+
You have been assigned to ${bookingResult.venue} — Room ${bookingResult.room}, ${bookingResult.bedType}.
+
`;
+ } else {
+ bookingHtml = `
+
+
Your accommodation request has been noted. Our team will follow up with your room assignment shortly.
+
`;
+ }
+ }
+
+ return {
+ subject: 'Payment Confirmed - Valley of the Commons',
+ html: `
Payment Confirmed!
@@ -106,8 +183,9 @@ const paymentConfirmationEmail = (application) => ({
| Type: |
- Event Registration |
+ Event Registration${accomLabel ? ' + Accommodation' : ''} |
+ ${accomRow}
| Amount: |
€${application.payment_amount} |
@@ -119,6 +197,8 @@ const paymentConfirmationEmail = (application) => ({
+ ${bookingHtml}
+
Your application is now complete. Our team will review it and get back to you within 2-3 weeks.
If you have any questions, reply to this email and we'll get back to you.
@@ -134,7 +214,8 @@ const paymentConfirmationEmail = (application) => ({
`
-});
+ };
+};
async function logEmail(recipientEmail, recipientName, emailType, subject, messageId, metadata = {}) {
try {
@@ -196,30 +277,87 @@ async function handleWebhook(req, res) {
console.log(`Payment ${paymentId} for application ${applicationId}: ${paymentStatus}`);
- // Send payment confirmation email if payment succeeded
- if (paymentStatus === 'paid' && process.env.SMTP_PASS) {
+ // On payment success: assign bed + send confirmation emails
+ if (paymentStatus === 'paid') {
try {
const appResult = await pool.query(
- 'SELECT id, first_name, last_name, email, contribution_amount, payment_amount, mollie_payment_id FROM applications WHERE mollie_payment_id = $1',
+ 'SELECT id, first_name, last_name, email, contribution_amount, payment_amount, mollie_payment_id, accommodation_type FROM applications WHERE mollie_payment_id = $1',
[paymentId]
);
if (appResult.rows.length > 0) {
const application = appResult.rows[0];
- const confirmEmail = paymentConfirmationEmail(application);
- const info = await smtp.sendMail({
- from: process.env.EMAIL_FROM || 'Valley of the Commons ',
- to: application.email,
- bcc: 'team@valleyofthecommons.com',
- subject: confirmEmail.subject,
- html: confirmEmail.html,
- });
- await logEmail(application.email, `${application.first_name} ${application.last_name}`,
- 'payment_confirmation', confirmEmail.subject, info.messageId,
- { applicationId: application.id, paymentId, amount: application.payment_amount });
+ const accommodationType = payment.metadata.accommodationType || application.accommodation_type;
+ const selectedWeeks = payment.metadata.selectedWeeks || [];
+
+ // Attempt bed assignment if accommodation was selected
+ let bookingResult = null;
+ if (accommodationType) {
+ try {
+ const guestName = `${application.first_name} ${application.last_name}`;
+ bookingResult = await assignBooking(guestName, accommodationType, selectedWeeks);
+ console.log(`[Booking] ${guestName}: ${bookingResult.success ? 'Assigned' : 'Failed'} — ${JSON.stringify(bookingResult)}`);
+ } catch (bookingError) {
+ console.error('[Booking] Assignment error:', bookingError);
+ bookingResult = { success: false, reason: bookingError.message };
+ }
+ }
+
+ // Send payment confirmation email
+ if (process.env.SMTP_PASS) {
+ const confirmEmail = paymentConfirmationEmail(application, bookingResult);
+ const info = await smtp.sendMail({
+ from: process.env.EMAIL_FROM || 'Valley of the Commons ',
+ to: application.email,
+ bcc: 'team@valleyofthecommons.com',
+ subject: confirmEmail.subject,
+ html: confirmEmail.html,
+ });
+ await logEmail(application.email, `${application.first_name} ${application.last_name}`,
+ 'payment_confirmation', confirmEmail.subject, info.messageId,
+ { applicationId: application.id, paymentId, amount: application.payment_amount });
+
+ // Send internal booking notification to team
+ if (accommodationType) {
+ const accomLabel = ACCOMMODATION_LABELS[accommodationType] || accommodationType;
+ const bookingStatus = bookingResult?.success
+ ? `Assigned: ${bookingResult.venue} Room ${bookingResult.room} (${bookingResult.bedType})`
+ : `MANUAL ASSIGNMENT NEEDED — ${bookingResult?.reason || 'unknown error'}`;
+
+ const bookingNotification = {
+ subject: `Booking ${bookingResult?.success ? 'Assigned' : 'NEEDS ATTENTION'}: ${application.first_name} ${application.last_name}`,
+ html: `
+
+
+ ${bookingResult?.success ? 'Bed Assigned' : 'Manual Assignment Needed'}
+
+
+ | Guest: | ${application.first_name} ${application.last_name} |
+ | Email: | ${application.email} |
+ | Accommodation: | ${accomLabel} |
+ | Weeks: | ${selectedWeeks.join(', ') || 'N/A'} |
+ | Status: | ${bookingStatus} |
+ | Payment: | €${application.payment_amount} |
+
+
+ `,
+ };
+
+ try {
+ await smtp.sendMail({
+ from: process.env.EMAIL_FROM || 'Valley of the Commons ',
+ to: 'team@valleyofthecommons.com',
+ subject: bookingNotification.subject,
+ html: bookingNotification.html,
+ });
+ } catch (notifyError) {
+ console.error('Failed to send booking notification:', notifyError);
+ }
+ }
+ }
}
} catch (emailError) {
- console.error('Failed to send payment confirmation email:', emailError);
+ console.error('Failed to process paid webhook:', emailError);
}
}
@@ -269,4 +407,9 @@ async function getPaymentStatus(req, res) {
}
}
-module.exports = { createPayment, handleWebhook, getPaymentStatus, PRICE_PER_WEEK, TICKET_LABELS, calculateAmount };
+module.exports = {
+ createPayment, handleWebhook, getPaymentStatus,
+ PRICE_PER_WEEK, PROCESSING_FEE_PERCENT,
+ ACCOMMODATION_PRICES, ACCOMMODATION_LABELS,
+ TICKET_LABELS, calculateAmount,
+};
diff --git a/apply.html b/apply.html
index 4ad5e72..fb4785e 100644
--- a/apply.html
+++ b/apply.html
@@ -621,38 +621,102 @@
-
+
-
Optional Add-ons
-
These can be arranged and paid separately after acceptance.
+
Accommodation
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Food
+
-
Registration fee: Select at least one week
-
Accommodation and food costs will be invoiced separately after acceptance.
+
Select at least one week
@@ -713,6 +777,28 @@
let currentStep = 1;
const totalSteps = 12;
const PRICE_PER_WEEK = 300;
+ const PROCESSING_FEE_PERCENT = 0.02;
+
+ // Accommodation prices per week (must match api/mollie.js)
+ const ACCOMMODATION_PRICES = {
+ 'ch-multi': 279.30,
+ 'ch-double': 356.30,
+ 'hh-single': 665.00,
+ 'hh-double-separate': 420.00,
+ 'hh-double-shared': 350.00,
+ 'hh-triple': 350.00,
+ 'hh-daybed': 280.00,
+ };
+
+ const ACCOMMODATION_LABELS = {
+ 'ch-multi': 'Commons Hub — Shared Room',
+ 'ch-double': 'Commons Hub — Double Room',
+ 'hh-single': 'Herrnhof Villa — Single Room',
+ 'hh-double-separate': 'Herrnhof Villa — Double (separate beds)',
+ 'hh-double-shared': 'Herrnhof Villa — Double (shared bed)',
+ 'hh-triple': 'Herrnhof Villa — Triple Room',
+ 'hh-daybed': 'Herrnhof Villa — Daybed / Extra Bed',
+ };
function updateProgress() {
const percent = Math.round(((currentStep - 1) / totalSteps) * 100);
@@ -815,19 +901,84 @@
card.classList.toggle('selected', cb.checked);
if (type === 'accommodation') {
- document.getElementById('accommodation-preference').style.display = cb.checked ? 'block' : 'none';
+ const opts = document.getElementById('accommodation-options');
+ opts.style.display = cb.checked ? 'block' : 'none';
+ if (!cb.checked) {
+ // Clear accommodation selection
+ document.getElementById('accommodation_type').value = '';
+ document.querySelectorAll('input[name="venue"]').forEach(r => { r.checked = false; r.closest('.week-card').classList.remove('selected'); });
+ document.querySelectorAll('input[name="room_type"]').forEach(r => { r.checked = false; r.closest('.week-card').classList.remove('selected'); });
+ document.getElementById('ch-rooms').style.display = 'none';
+ document.getElementById('hh-rooms').style.display = 'none';
+ }
}
+
+ // Always refresh summary (food toggle also affects it)
+ updatePriceSummary();
+ }
+
+ function selectVenue(venue) {
+ // Update radio + card styling
+ document.querySelectorAll('input[name="venue"]').forEach(r => {
+ r.checked = (r.value === venue);
+ r.closest('.week-card').classList.toggle('selected', r.checked);
+ });
+
+ // Show/hide room type sections
+ document.getElementById('ch-rooms').style.display = venue === 'ch' ? 'block' : 'none';
+ document.getElementById('hh-rooms').style.display = venue === 'hh' ? 'block' : 'none';
+
+ // Clear previous room selection
+ document.querySelectorAll('input[name="room_type"]').forEach(r => {
+ r.checked = false;
+ r.closest('.week-card').classList.remove('selected');
+ });
+ document.getElementById('accommodation_type').value = '';
+ updatePriceSummary();
+ }
+
+ function selectRoom(roomType) {
+ document.querySelectorAll('input[name="room_type"]').forEach(r => {
+ r.checked = (r.value === roomType);
+ r.closest('.week-card').classList.toggle('selected', r.checked);
+ });
+ document.getElementById('accommodation_type').value = roomType;
+ updatePriceSummary();
}
function updatePriceSummary() {
const weeksCount = document.querySelectorAll('input[name="weeks"]:checked').length;
const el = document.getElementById('price-calc');
+
if (weeksCount === 0) {
el.textContent = 'Select at least one week';
- } else {
- const total = weeksCount * PRICE_PER_WEEK;
- el.innerHTML = `€${PRICE_PER_WEEK} × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${total.toLocaleString()}`;
+ return;
}
+
+ const registration = PRICE_PER_WEEK * weeksCount;
+ const accomType = document.getElementById('accommodation_type').value;
+ const accomPerWeek = accomType ? (ACCOMMODATION_PRICES[accomType] || 0) : 0;
+ const accommodation = accomPerWeek * weeksCount;
+ const subtotal = registration + accommodation;
+ const fee = subtotal * PROCESSING_FEE_PERCENT;
+ const total = subtotal + fee;
+
+ let html = `Registration: €${PRICE_PER_WEEK} × ${weeksCount} wk = €${registration.toFixed(2)}`;
+
+ if (accommodation > 0) {
+ const label = ACCOMMODATION_LABELS[accomType] || accomType;
+ html += `
Accommodation: ${label}
€${accomPerWeek.toFixed(2)} × ${weeksCount} wk = €${accommodation.toFixed(2)}`;
+ }
+
+ html += `
Processing fee (2%): €${fee.toFixed(2)}`;
+ html += `
Total: €${total.toFixed(2)}`;
+
+ const wantFood = document.getElementById('want_food').checked;
+ if (wantFood) {
+ html += `
Food: interest registered — details & costs coming soon.`;
+ }
+
+ el.innerHTML = html;
}
// Theme ranking drag & drop
@@ -902,7 +1053,8 @@
weeks: weeks,
attendance_type: weeks.length === 4 ? 'full' : 'partial',
need_accommodation: document.getElementById('need_accommodation').checked,
- accommodation_preference: document.getElementById('accom_type').value || null,
+ accommodation_type: document.getElementById('accommodation_type').value || null,
+ accommodation_preference: document.getElementById('accommodation_type').value || null,
want_food: document.getElementById('want_food').checked,
anything_else: form.anything_else.value,
privacy_policy_accepted: form.privacy_accepted.checked,
diff --git a/db/migration-003-accommodation.sql b/db/migration-003-accommodation.sql
new file mode 100644
index 0000000..390b20e
--- /dev/null
+++ b/db/migration-003-accommodation.sql
@@ -0,0 +1,5 @@
+-- Migration 003: Add accommodation_type column to applications table
+-- Stores the CCG-style accommodation selection (e.g. ch-multi, hh-single)
+-- Keeps existing accommodation_preference column for backward compatibility
+
+ALTER TABLE applications ADD COLUMN IF NOT EXISTS accommodation_type VARCHAR(50);
diff --git a/db/schema.sql b/db/schema.sql
index 1d4b122..9b00478 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -85,9 +85,10 @@ CREATE TABLE IF NOT EXISTS applications (
scholarship_reason TEXT,
contribution_amount VARCHAR(50), -- 'registration' (base fee) or legacy ticket type
- -- Add-ons (invoiced separately after acceptance)
+ -- Add-ons
need_accommodation BOOLEAN DEFAULT FALSE,
want_food BOOLEAN DEFAULT FALSE,
+ accommodation_type VARCHAR(50), -- CCG-style: ch-multi, ch-double, hh-single, etc.
-- Payment (Mollie)
mollie_payment_id VARCHAR(255),
diff --git a/server.js b/server.js
index ce49a0d..573ac6d 100644
--- a/server.js
+++ b/server.js
@@ -82,7 +82,8 @@ async function runMigrations() {
await pool.query(`
ALTER TABLE applications
ADD COLUMN IF NOT EXISTS need_accommodation BOOLEAN DEFAULT FALSE,
- ADD COLUMN IF NOT EXISTS want_food BOOLEAN DEFAULT FALSE
+ ADD COLUMN IF NOT EXISTS want_food BOOLEAN DEFAULT FALSE,
+ ADD COLUMN IF NOT EXISTS accommodation_type VARCHAR(50)
`);
// Rename resend_id → message_id in email_log (legacy column name)