From 24639808385cef2aee5c9da9927e33b2a5dcd481 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Feb 2026 20:35:35 -0800 Subject: [PATCH] fix: use DATABASE_SSL env var instead of NODE_ENV for pg SSL --- api/application.js | 40 ++++++- api/mollie.js | 284 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 api/mollie.js diff --git a/api/application.js b/api/application.js index d4d60ea..449c492 100644 --- a/api/application.js +++ b/api/application.js @@ -3,11 +3,13 @@ const { Pool } = require('pg'); const nodemailer = require('nodemailer'); +const { syncApplication } = require('./google-sheets'); +const { createPayment } = require('./mollie'); // Initialize PostgreSQL connection pool const pool = new Pool({ connectionString: process.env.DATABASE_URL, - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false + ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false }); // Initialize SMTP transport (Mailcow) @@ -239,13 +241,23 @@ module.exports = async function handler(req, res) { first_name: data.first_name, last_name: data.last_name, email: data.email, + phone: data.phone, city: data.city, country: data.country, attendance_type: data.attendance_type, scholarship_needed: data.scholarship_needed, - motivation: data.motivation + scholarship_reason: data.scholarship_reason, + motivation: data.motivation, + contribution: data.contribution, + how_heard: data.how_heard, + referral_name: data.referral_name, + arrival_date: data.arrival_date, + departure_date: data.departure_date, }; + // Sync to Google Sheets (fire-and-forget backup) + syncApplication(application); + // Send confirmation email to applicant if (process.env.SMTP_PASS) { try { @@ -279,10 +291,32 @@ module.exports = async function handler(req, res) { } } + // Create Mollie payment if ticket was selected and Mollie is configured + let checkoutUrl = null; + if (data.contribution_amount && process.env.MOLLIE_API_KEY) { + try { + const weeksCount = Array.isArray(data.weeks) ? data.weeks.length : 1; + const paymentResult = await createPayment( + application.id, + data.contribution_amount, + weeksCount, + application.email, + application.first_name, + application.last_name + ); + checkoutUrl = paymentResult.checkoutUrl; + console.log(`Mollie payment created: ${paymentResult.paymentId} (${paymentResult.amount} EUR)`); + } catch (paymentError) { + console.error('Failed to create Mollie payment:', paymentError); + // Don't fail the application - payment can be retried + } + } + return res.status(201).json({ success: true, message: 'Application submitted successfully', - applicationId: application.id + applicationId: application.id, + checkoutUrl, }); } catch (error) { diff --git a/api/mollie.js b/api/mollie.js new file mode 100644 index 0000000..9ed4730 --- /dev/null +++ b/api/mollie.js @@ -0,0 +1,284 @@ +// Mollie payment integration +// Handles payment creation and webhook callbacks + +const { createMollieClient } = require('@mollie/api-client'); +const { Pool } = require('pg'); +const nodemailer = require('nodemailer'); + +// Initialize PostgreSQL connection pool +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false +}); + +// Initialize Mollie client +const mollieClient = createMollieClient({ + apiKey: process.env.MOLLIE_API_KEY, +}); + +// Initialize SMTP transport (Mailcow) +const smtp = nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'mail.rmail.online', + port: parseInt(process.env.SMTP_PORT || '587'), + secure: false, + auth: { + user: process.env.SMTP_USER || 'noreply@jeffemmett.com', + pass: process.env.SMTP_PASS || '', + }, + tls: { rejectUnauthorized: false }, +}); + +// Ticket price mapping (in EUR) +const TICKET_PRICES = { + 'full-dorm': 1500.00, + 'full-shared': 1800.00, + 'full-single': 3200.00, + 'week-dorm': 425.00, + 'week-shared': 500.00, + 'week-single': 850.00, + 'no-accom': 300.00, // per week +}; + +const TICKET_LABELS = { + 'full-dorm': 'Full Resident - Dorm (4-6 people)', + 'full-shared': 'Full Resident - Shared Double', + 'full-single': 'Full Resident - Single (deluxe apartment)', + 'week-dorm': '1-Week Visitor - Dorm (4-6 people)', + 'week-shared': '1-Week Visitor - Shared Double', + 'week-single': '1-Week Visitor - Single (deluxe apartment)', + 'no-accom': 'Non-Accommodation Pass', +}; + +function calculateAmount(ticketType, weeksCount) { + const basePrice = TICKET_PRICES[ticketType]; + if (!basePrice) return null; + + // no-accom is priced per week + if (ticketType === 'no-accom') { + return (basePrice * (weeksCount || 1)).toFixed(2); + } + + return basePrice.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}`); + } + + const baseUrl = process.env.BASE_URL || 'https://votc.jeffemmett.com'; + const description = `Valley of the Commons - ${TICKET_LABELS[ticketType] || ticketType}`; + + const payment = await mollieClient.payments.create({ + amount: { + currency: 'EUR', + value: amount, + }, + description, + redirectUrl: `${baseUrl}/payment-return.html?id=${applicationId}`, + webhookUrl: `${baseUrl}/api/mollie/webhook`, + metadata: { + applicationId, + ticketType, + weeksCount, + }, + }); + + // Store Mollie payment ID in database + await pool.query( + `UPDATE applications + SET mollie_payment_id = $1, + payment_amount = $2, + payment_status = 'pending' + WHERE id = $3`, + [payment.id, amount, applicationId] + ); + + return { + paymentId: payment.id, + checkoutUrl: payment.getCheckoutUrl(), + amount, + }; +} + +// Payment confirmation email +const paymentConfirmationEmail = (application) => ({ + subject: 'Payment Confirmed - Valley of the Commons', + html: ` +
+

Payment Confirmed!

+ +

Dear ${application.first_name},

+ +

Your payment of €${application.payment_amount} for Valley of the Commons has been received.

+ +
+

Payment Details

+ + + + + + + + + + + + + +
Ticket:${TICKET_LABELS[application.contribution_amount] || application.contribution_amount}
Amount:€${application.payment_amount}
Mollie Reference:${application.mollie_payment_id}
+
+ +

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.

+ +

+ With warmth,
+ The Valley of the Commons Team +

+ +
+

+ Application ID: ${application.id} +

+
+ ` +}); + +async function logEmail(recipientEmail, recipientName, emailType, subject, messageId, metadata = {}) { + try { + await pool.query( + `INSERT INTO email_log (recipient_email, recipient_name, email_type, subject, message_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6)`, + [recipientEmail, recipientName, emailType, subject, messageId, JSON.stringify(metadata)] + ); + } catch (error) { + console.error('Failed to log email:', error); + } +} + +// Webhook handler - called by Mollie when payment status changes +async function handleWebhook(req, res) { + try { + const paymentId = req.body.id; + if (!paymentId) { + return res.status(400).json({ error: 'Missing payment id' }); + } + + // Fetch payment status from Mollie + const payment = await mollieClient.payments.get(paymentId); + const applicationId = payment.metadata.applicationId; + + // Map Mollie status to our status + let paymentStatus; + switch (payment.status) { + case 'paid': + paymentStatus = 'paid'; + break; + case 'failed': + paymentStatus = 'failed'; + break; + case 'canceled': + paymentStatus = 'canceled'; + break; + case 'expired': + paymentStatus = 'expired'; + break; + case 'pending': + paymentStatus = 'pending'; + break; + case 'open': + paymentStatus = 'open'; + break; + default: + paymentStatus = payment.status; + } + + // Update application payment status + await pool.query( + `UPDATE applications + SET payment_status = $1, + payment_paid_at = CASE WHEN $1 = 'paid' THEN CURRENT_TIMESTAMP ELSE payment_paid_at END + WHERE mollie_payment_id = $2`, + [paymentStatus, paymentId] + ); + + console.log(`Payment ${paymentId} for application ${applicationId}: ${paymentStatus}`); + + // Send payment confirmation email if payment succeeded + if (paymentStatus === 'paid' && process.env.SMTP_PASS) { + 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', + [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, + 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 }); + } + } catch (emailError) { + console.error('Failed to send payment confirmation email:', emailError); + } + } + + // Mollie expects a 200 response + return res.status(200).end(); + } catch (error) { + console.error('Mollie webhook error:', error); + return res.status(500).json({ error: 'Webhook processing failed' }); + } +} + +// Payment status check endpoint (for frontend polling) +async function getPaymentStatus(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + + try { + const { id } = req.query; + if (!id) { + return res.status(400).json({ error: 'Missing application id' }); + } + + const result = await pool.query( + 'SELECT payment_status, payment_amount, contribution_amount FROM applications WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Application not found' }); + } + + const app = result.rows[0]; + return res.status(200).json({ + paymentStatus: app.payment_status, + paymentAmount: app.payment_amount, + ticketType: app.contribution_amount, + ticketLabel: TICKET_LABELS[app.contribution_amount] || app.contribution_amount, + }); + } catch (error) { + console.error('Payment status check error:', error); + return res.status(500).json({ error: 'Failed to check payment status' }); + } +} + +module.exports = { createPayment, handleWebhook, getPaymentStatus, TICKET_PRICES, TICKET_LABELS, calculateAmount };