290 lines
8.9 KiB
JavaScript
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,
|
|
};
|