valley-commons/api/mollie.js

446 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 13 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>&euro;${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;">&euro;${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;">&euro;${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,
};