446 lines
18 KiB
JavaScript
446 lines
18 KiB
JavaScript
// Mollie payment integration
|
||
// Handles payment creation and webhook callbacks
|
||
|
||
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({
|
||
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 || 'contact@valleyofthecommons.com',
|
||
pass: process.env.SMTP_PASS || '',
|
||
},
|
||
tls: { rejectUnauthorized: false },
|
||
});
|
||
|
||
// 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;
|
||
|
||
// Pricing tier — override with a fixed tier, or set to null to use date-based logic
|
||
const CURRENT_PRICING_TIER = 'standard';
|
||
|
||
// Date-based tier cutoffs (used when CURRENT_PRICING_TIER is null)
|
||
// Set ISO date strings, e.g. '2026-05-01'
|
||
const PRICING_TIER_DATES = {
|
||
earlyEnd: null, // before this date → 'early'
|
||
standardEnd: null, // before this date → 'standard'; after → 'lastMin'
|
||
};
|
||
|
||
function getPricingTier() {
|
||
if (CURRENT_PRICING_TIER) return CURRENT_PRICING_TIER;
|
||
const now = new Date();
|
||
if (PRICING_TIER_DATES.earlyEnd && now < new Date(PRICING_TIER_DATES.earlyEnd)) return 'early';
|
||
if (PRICING_TIER_DATES.standardEnd && now < new Date(PRICING_TIER_DATES.standardEnd)) return 'standard';
|
||
return 'lastMin';
|
||
}
|
||
|
||
// Accommodation prices (EUR) — tiered by duration and booking window
|
||
// 1week = per-week rate (multiply by weeks for 1–3 week stays)
|
||
// 4week = flat total for a full 4-week stay
|
||
const ACCOMMODATION_PRICES = {
|
||
'ch-multi': { '1week': { early: 395, standard: 475, lastMin: 515 }, '4week': { early: 1400, standard: 1600, lastMin: 1700 } },
|
||
'ch-double': { '1week': { early: 470, standard: 550, lastMin: 590 }, '4week': { early: 1700, standard: 1900, lastMin: 2000 } },
|
||
'hh-living': { '1week': { early: 435, standard: 515, lastMin: 555 }, '4week': { early: 1560, standard: 1760, lastMin: 1860 } },
|
||
'hh-triple': { '1week': { early: 470, standard: 550, lastMin: 590 }, '4week': { early: 1700, standard: 1900, lastMin: 2000 } },
|
||
'hh-twin': { '1week': { early: 540, standard: 620, lastMin: 660 }, '4week': { early: 1980, standard: 2180, lastMin: 2280 } },
|
||
'hh-single': { '1week': { early: 785, standard: 865, lastMin: 905 }, '4week': { early: 2960, standard: 3160, lastMin: 3260 } },
|
||
'hh-couple': { '1week': { early: 940, standard: 1100, lastMin: 1180 }, '4week': { early: 3400, standard: 3800, lastMin: 4000 } },
|
||
};
|
||
|
||
// Human-readable labels for accommodation types
|
||
const ACCOMMODATION_LABELS = {
|
||
'ch-multi': 'Commons Hub — Bed in Multi-Room',
|
||
'ch-double': 'Commons Hub — Bed in Double Room',
|
||
'hh-living': 'Herrnhof Villa — Bed in Living Room',
|
||
'hh-triple': 'Herrnhof Villa — Bed in Triple Room',
|
||
'hh-twin': 'Herrnhof Villa — Bed in Twin Room',
|
||
'hh-single': 'Herrnhof Villa — Single Room',
|
||
'hh-couple': 'Herrnhof Villa — Couple Room',
|
||
};
|
||
|
||
// Legacy ticket labels (kept for backward-compat with existing DB records)
|
||
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',
|
||
'registration': 'Event Registration',
|
||
};
|
||
|
||
function calculateAmount(ticketType, weeksCount, accommodationType) {
|
||
const weeks = weeksCount || 1;
|
||
const tier = getPricingTier();
|
||
const registration = PRICE_PER_WEEK * weeks;
|
||
|
||
let accommodation = 0;
|
||
if (accommodationType && ACCOMMODATION_PRICES[accommodationType]) {
|
||
const prices = ACCOMMODATION_PRICES[accommodationType];
|
||
if (weeks === 4) {
|
||
accommodation = prices['4week'][tier];
|
||
} else {
|
||
accommodation = prices['1week'][tier] * weeks;
|
||
}
|
||
}
|
||
|
||
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),
|
||
tier,
|
||
};
|
||
}
|
||
|
||
// Create a Mollie payment for an application
|
||
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';
|
||
|
||
// 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: pricing.total,
|
||
},
|
||
description,
|
||
redirectUrl: `${baseUrl}/payment-return.html?id=${applicationId}`,
|
||
webhookUrl: `${baseUrl}/api/mollie/webhook`,
|
||
metadata: {
|
||
applicationId,
|
||
ticketType,
|
||
weeksCount,
|
||
accommodationType: accommodationType || null,
|
||
selectedWeeks: selectedWeeks || [],
|
||
breakdown: pricing,
|
||
},
|
||
});
|
||
|
||
// 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, pricing.total, applicationId]
|
||
);
|
||
|
||
return {
|
||
paymentId: payment.id,
|
||
checkoutUrl: payment.getCheckoutUrl(),
|
||
amount: pricing.total,
|
||
pricing,
|
||
};
|
||
}
|
||
|
||
// Payment confirmation email
|
||
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 ? `
|
||
<tr>
|
||
<td style="padding: 4px 0;"><strong>Accommodation:</strong></td>
|
||
<td style="padding: 4px 0;">${accomLabel}</td>
|
||
</tr>` : '';
|
||
|
||
// Booking assignment info
|
||
let bookingHtml = '';
|
||
if (bookingResult) {
|
||
if (bookingResult.success) {
|
||
bookingHtml = `
|
||
<div style="background: #e8f5e9; padding: 16px; border-radius: 8px; margin: 16px 0;">
|
||
<h3 style="margin-top: 0; color: #2d5016;">Bed Assignment</h3>
|
||
<p style="margin-bottom: 0;">You have been assigned to <strong>${bookingResult.venue} — Room ${bookingResult.room}, ${bookingResult.bedType}</strong>.</p>
|
||
</div>`;
|
||
} else {
|
||
bookingHtml = `
|
||
<div style="background: #fff3e0; padding: 16px; border-radius: 8px; margin: 16px 0;">
|
||
<p style="margin-bottom: 0;">Your accommodation request has been noted. Our team will follow up with your room assignment shortly.</p>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
return {
|
||
subject: 'Payment Confirmed - Valley of the Commons',
|
||
html: `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h1 style="color: #2d5016; margin-bottom: 24px;">Payment Confirmed!</h1>
|
||
|
||
<p>Dear ${application.first_name},</p>
|
||
|
||
<p>Your payment of <strong>€${application.payment_amount}</strong> for Valley of the Commons has been received.</p>
|
||
|
||
<div style="background: #f5f5f0; padding: 20px; border-radius: 8px; margin: 24px 0;">
|
||
<h3 style="margin-top: 0; color: #2d5016;">Payment Details</h3>
|
||
<table style="width: 100%; border-collapse: collapse;">
|
||
<tr>
|
||
<td style="padding: 4px 0;"><strong>Type:</strong></td>
|
||
<td style="padding: 4px 0;">Event Registration${accomLabel ? ' + Accommodation' : ''}</td>
|
||
</tr>
|
||
${accomRow}
|
||
<tr>
|
||
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
|
||
<td style="padding: 4px 0;">€${application.payment_amount}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 4px 0;"><strong>Mollie Reference:</strong></td>
|
||
<td style="padding: 4px 0;">${application.mollie_payment_id}</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
|
||
${bookingHtml}
|
||
|
||
<p>Your application is now complete. Our team will review it and get back to you within 2-3 weeks.</p>
|
||
|
||
<p>If you have any questions, reply to this email and we'll get back to you.</p>
|
||
|
||
<p style="margin-top: 32px;">
|
||
With warmth,<br>
|
||
<strong>The Valley of the Commons Team</strong>
|
||
</p>
|
||
|
||
<hr style="border: none; border-top: 1px solid #ddd; margin: 32px 0;">
|
||
<p style="font-size: 12px; color: #666;">
|
||
Application ID: ${application.id}
|
||
</p>
|
||
</div>
|
||
`
|
||
};
|
||
};
|
||
|
||
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::varchar,
|
||
payment_paid_at = CASE WHEN $1::varchar = 'paid' THEN CURRENT_TIMESTAMP ELSE payment_paid_at END
|
||
WHERE mollie_payment_id = $2::varchar`,
|
||
[paymentStatus, paymentId]
|
||
);
|
||
|
||
console.log(`Payment ${paymentId} for application ${applicationId}: ${paymentStatus}`);
|
||
|
||
// 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, accommodation_type FROM applications WHERE mollie_payment_id = $1',
|
||
[paymentId]
|
||
);
|
||
|
||
if (appResult.rows.length > 0) {
|
||
const application = appResult.rows[0];
|
||
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 <contact@valleyofthecommons.com>',
|
||
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: `
|
||
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h2 style="color: ${bookingResult?.success ? '#2d5016' : '#c53030'};">
|
||
${bookingResult?.success ? 'Bed Assigned' : 'Manual Assignment Needed'}
|
||
</h2>
|
||
<table style="width: 100%; border-collapse: collapse;">
|
||
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Guest:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${application.first_name} ${application.last_name}</td></tr>
|
||
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Email:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${application.email}</td></tr>
|
||
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Accommodation:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${accomLabel}</td></tr>
|
||
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Weeks:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${selectedWeeks.join(', ') || 'N/A'}</td></tr>
|
||
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Status:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${bookingStatus}</td></tr>
|
||
<tr><td style="padding: 6px 0;"><strong>Payment:</strong></td><td style="padding: 6px 0;">€${application.payment_amount}</td></tr>
|
||
</table>
|
||
</div>
|
||
`,
|
||
};
|
||
|
||
try {
|
||
await smtp.sendMail({
|
||
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
||
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 process paid webhook:', 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,
|
||
PRICE_PER_WEEK, PROCESSING_FEE_PERCENT,
|
||
ACCOMMODATION_PRICES, ACCOMMODATION_LABELS,
|
||
TICKET_LABELS, calculateAmount, getPricingTier,
|
||
};
|