2026-06-28 02:10:55 +00:00

290 lines
8.9 KiB
JavaScript

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,
};