const express = require('express'); const axios = require('axios'); const crypto = require('crypto'); const config = require('../config'); const { wrapAsync, commonErrorHandler } = require('../helpers'); const checkoutRouter = express.Router(); const webhookRouter = express.Router(); const STRIPE_API_BASE_URL = 'https://api.stripe.com/v1'; const DEFAULT_CURRENCY = 'usd'; const MAX_PAYMENT_AMOUNT = 50000; const getStripeSecretKey = () => process.env.STRIPE_SECRET_KEY || config.stripe?.secretKey || ''; const getStripeWebhookSecret = () => process.env.STRIPE_WEBHOOK_SECRET || config.stripe?.webhookSecret || ''; const normalizeBaseUrl = (baseUrl) => { if (!baseUrl) return ''; try { return new URL(baseUrl).origin; } catch (error) { return String(baseUrl).replace(/\/$/, ''); } }; const getClientBaseUrl = (req) => { const fromHeader = req.get('origin') || req.get('referer'); const fromEnv = process.env.FRONTEND_URL || config.backUrl; return normalizeBaseUrl(fromHeader || fromEnv || ''); }; const toStripeAmount = (amount) => { const numericAmount = Number(amount); if (!Number.isFinite(numericAmount) || numericAmount <= 0) { return null; } if (numericAmount > MAX_PAYMENT_AMOUNT) { return null; } return Math.round(numericAmount * 100); }; const appendMetadata = (params, metadata) => { Object.entries(metadata).forEach(([key, value]) => { if (value !== undefined && value !== null && String(value).trim()) { params.append(`metadata[${key}]`, String(value)); } }); }; const stripeHeaders = (secretKey) => ({ Authorization: `Bearer ${secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded', }); checkoutRouter.post( '/create-checkout-session', wrapAsync(async (req, res) => { const stripeSecretKey = getStripeSecretKey(); if (!stripeSecretKey) { return res.status(400).send({ code: 'stripe_not_configured', message: 'Stripe is not configured yet. Add STRIPE_SECRET_KEY to the backend environment, then try again.', }); } const { reservationId, reservationNumber, guestName, guestEmail, roomNumber, amount, currency = DEFAULT_CURRENCY, } = req.body; const amountInCents = toStripeAmount(amount); if (!amountInCents) { return res.status(400).send({ code: 'invalid_payment_amount', message: `Payment amount must be greater than 0 and no more than ${MAX_PAYMENT_AMOUNT}.`, }); } const clientBaseUrl = getClientBaseUrl(req); if (!clientBaseUrl) { return res.status(400).send({ code: 'missing_client_url', message: 'Unable to determine the frontend URL for Stripe success and cancel redirects.', }); } const safeReservationId = reservationId ? String(reservationId) : ''; const safeReservationNumber = reservationNumber ? String(reservationNumber) : 'HotelPilot reservation'; const safeGuestName = guestName ? String(guestName) : 'Hotel guest'; const successUrl = `${clientBaseUrl}/stripe/success?session_id={CHECKOUT_SESSION_ID}&reservationId=${encodeURIComponent(safeReservationId)}`; const cancelUrl = `${clientBaseUrl}/stripe/cancel?reservationId=${encodeURIComponent(safeReservationId)}`; const params = new URLSearchParams(); params.append('mode', 'payment'); params.append('success_url', successUrl); params.append('cancel_url', cancelUrl); params.append('client_reference_id', safeReservationId || safeReservationNumber); params.append('line_items[0][price_data][currency]', String(currency).toLowerCase()); params.append('line_items[0][price_data][unit_amount]', String(amountInCents)); params.append('line_items[0][price_data][product_data][name]', `HotelPilot PMS · ${safeReservationNumber}`); params.append( 'line_items[0][price_data][product_data][description]', `${safeGuestName}${roomNumber ? ` · Room ${roomNumber}` : ''}`, ); params.append('line_items[0][quantity]', '1'); if (guestEmail && String(guestEmail).includes('@')) { params.append('customer_email', String(guestEmail)); } appendMetadata(params, { reservationId: safeReservationId, reservationNumber: safeReservationNumber, guestName: safeGuestName, roomNumber, createdByUserId: req.currentUser?.id, organizationId: req.currentUser?.organizationId, source: 'hotelpilot_front_desk', }); try { const stripeResponse = await axios.post( `${STRIPE_API_BASE_URL}/checkout/sessions`, params, { headers: stripeHeaders(stripeSecretKey), timeout: 20000, }, ); return res.status(200).send({ id: stripeResponse.data.id, url: stripeResponse.data.url, status: stripeResponse.data.status, payment_status: stripeResponse.data.payment_status, }); } catch (error) { console.error('Stripe Checkout session creation failed', { status: error.response?.status, data: error.response?.data, }); error.message = error.response?.data?.error?.message || error.message; throw error; } }), ); checkoutRouter.get( '/checkout-session/:sessionId', wrapAsync(async (req, res) => { const stripeSecretKey = getStripeSecretKey(); if (!stripeSecretKey) { return res.status(400).send({ code: 'stripe_not_configured', message: 'Stripe is not configured yet. Add STRIPE_SECRET_KEY to the backend environment, then try again.', }); } const { sessionId } = req.params; if (!sessionId || !sessionId.startsWith('cs_')) { return res.status(400).send({ code: 'invalid_session_id', message: 'A valid Stripe Checkout session id is required.', }); } try { const stripeResponse = await axios.get( `${STRIPE_API_BASE_URL}/checkout/sessions/${encodeURIComponent(sessionId)}`, { headers: { Authorization: `Bearer ${stripeSecretKey}` }, timeout: 20000, }, ); const session = stripeResponse.data; return res.status(200).send({ id: session.id, status: session.status, payment_status: session.payment_status, amount_total: session.amount_total, currency: session.currency, customer_email: session.customer_details?.email || session.customer_email, metadata: session.metadata, }); } catch (error) { console.error('Stripe Checkout session retrieval failed', { sessionId, status: error.response?.status, data: error.response?.data, }); error.message = error.response?.data?.error?.message || error.message; throw error; } }), ); const verifyStripeSignature = (rawBody, signatureHeader, webhookSecret) => { if (!signatureHeader || !webhookSecret) return false; const timestamp = signatureHeader .split(',') .find((item) => item.startsWith('t=')) ?.slice(2); const expectedSignature = signatureHeader .split(',') .find((item) => item.startsWith('v1=')) ?.slice(3); if (!timestamp || !expectedSignature) return false; const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`; const computedSignature = crypto .createHmac('sha256', webhookSecret) .update(signedPayload, 'utf8') .digest('hex'); const computedBuffer = Buffer.from(computedSignature, 'hex'); const expectedBuffer = Buffer.from(expectedSignature, 'hex'); if (computedBuffer.length !== expectedBuffer.length) { return false; } return crypto.timingSafeEqual(computedBuffer, expectedBuffer); }; webhookRouter.post( '/', express.raw({ type: 'application/json' }), wrapAsync(async (req, res) => { const webhookSecret = getStripeWebhookSecret(); const signatureHeader = req.get('stripe-signature'); if (!webhookSecret) { console.error('Stripe webhook rejected because STRIPE_WEBHOOK_SECRET is not configured.'); return res.status(400).send({ code: 'stripe_webhook_not_configured', message: 'Stripe webhook secret is not configured.', }); } if (!verifyStripeSignature(req.body, signatureHeader, webhookSecret)) { console.error('Stripe webhook signature verification failed.'); return res.status(400).send({ code: 'invalid_stripe_signature', message: 'Invalid Stripe signature.', }); } const event = JSON.parse(req.body.toString('utf8')); if (event.type === 'checkout.session.completed') { console.log('Stripe Checkout session completed', { sessionId: event.data?.object?.id, reservationId: event.data?.object?.metadata?.reservationId, amountTotal: event.data?.object?.amount_total, }); } return res.status(200).send({ received: true, type: event.type }); }), ); checkoutRouter.use('/', commonErrorHandler); webhookRouter.use('/', commonErrorHandler); module.exports = { checkoutRouter, webhookRouter, };